diff --git a/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx b/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx new file mode 100644 index 0000000..2091132 --- /dev/null +++ b/apps/web/src/components/blueprints/BlueprintFieldCatalog.tsx @@ -0,0 +1,786 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { FieldType } from "@planarchy/shared"; +import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@planarchy/shared"; +import { trpc } from "~/lib/trpc/client.js"; +import { RolePresetsEditor } from "./RolePresetsEditor.js"; +import { FieldCard } from "./FieldCard.js"; +import type { FieldOverrides } from "./FieldCard.js"; +import { + getCatalogForTarget, + getCategoriesForTarget, + findCatalogField, +} from "~/lib/blueprint-field-catalog.js"; +import type { CatalogField } from "~/lib/blueprint-field-catalog.js"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const INPUT_CLS = + "px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"; + +const BTN_PRIMARY = + "px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"; + +const BTN_SECONDARY = + "px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type BlueprintTargetValue = "RESOURCE" | "PROJECT"; + +/** Internal state for a field: catalog index or custom definition */ +interface FieldState { + /** Catalog key (undefined for custom fields) */ + catalogKey: string | undefined; + overrides: FieldOverrides; + /** For custom fields only */ + custom?: { + key: string; + label: string; + type: FieldType; + options: FieldOption[]; + }; +} + +// --------------------------------------------------------------------------- +// Helpers: Convert between FieldState and BlueprintFieldDefinition +// --------------------------------------------------------------------------- + +function fieldDefToState( + def: BlueprintFieldDefinition, + target: BlueprintTargetValue, +): FieldState { + const catalogField = findCatalogField(target, def.key); + if (catalogField) { + return { + catalogKey: catalogField.key, + overrides: { + enabled: true, + required: def.required, + showInList: def.showInList ?? false, + defaultValue: def.defaultValue, + description: def.description ?? "", + }, + }; + } + // Custom field -- not in catalog + return { + catalogKey: undefined, + overrides: { + enabled: true, + required: def.required, + showInList: def.showInList ?? false, + defaultValue: def.defaultValue, + description: def.description ?? "", + }, + custom: { + key: def.key, + label: def.label, + type: def.type, + options: def.options ?? [], + }, + }; +} + +function stateToFieldDef( + state: FieldState, + order: number, + target: BlueprintTargetValue, +): BlueprintFieldDefinition | null { + if (!state.overrides.enabled) return null; + + if (state.catalogKey) { + const catalogField = findCatalogField(target, state.catalogKey); + if (!catalogField) return null; + const desc = state.overrides.description || catalogField.description; + return { + id: catalogField.key, + key: catalogField.key, + label: catalogField.label, + type: catalogField.type, + required: state.overrides.required, + order, + ...(state.overrides.showInList ? { showInList: true } : {}), + ...(desc ? { description: desc } : {}), + defaultValue: state.overrides.defaultValue, + ...(catalogField.options ? { options: catalogField.options } : {}), + }; + } + + // Custom field + if (!state.custom) return null; + const customDesc = state.overrides.description || undefined; + return { + id: state.custom.key, + key: state.custom.key, + label: state.custom.label, + type: state.custom.type, + required: state.overrides.required, + order, + ...(state.overrides.showInList ? { showInList: true } : {}), + ...(customDesc !== undefined ? { description: customDesc } : {}), + defaultValue: state.overrides.defaultValue, + ...(state.custom.options.length > 0 ? { options: state.custom.options } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface BlueprintFieldCatalogProps { + blueprintId: string; + blueprintName: string; + blueprintTarget: BlueprintTargetValue; + initialFieldDefs: BlueprintFieldDefinition[]; + initialRolePresets?: StaffingRequirement[]; + initialTab?: "fields" | "presets"; + onClose: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const FIELD_TYPES: { value: FieldType; label: string }[] = [ + { value: FieldType.TEXT, label: "Text" }, + { value: FieldType.TEXTAREA, label: "Textarea" }, + { value: FieldType.NUMBER, label: "Number" }, + { value: FieldType.BOOLEAN, label: "Boolean" }, + { value: FieldType.DATE, label: "Date" }, + { value: FieldType.SELECT, label: "Select" }, + { value: FieldType.MULTI_SELECT, label: "Multi-Select" }, + { value: FieldType.URL, label: "URL" }, + { value: FieldType.EMAIL, label: "Email" }, +]; + +export function BlueprintFieldCatalog({ + blueprintId, + blueprintName, + blueprintTarget, + initialFieldDefs, + initialRolePresets = [], + initialTab = "fields", + onClose, +}: BlueprintFieldCatalogProps) { + const utils = trpc.useUtils(); + + const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab); + const [searchQuery, setSearchQuery] = useState(""); + const [activeCategory, setActiveCategory] = useState(null); + const [saveError, setSaveError] = useState(null); + const [presetSaveError, setPresetSaveError] = useState(null); + + // -- Custom field form state -- + const [showCustomForm, setShowCustomForm] = useState(false); + const [customKey, setCustomKey] = useState(""); + const [customLabel, setCustomLabel] = useState(""); + const [customType, setCustomType] = useState(FieldType.TEXT); + + const catalog = useMemo(() => getCatalogForTarget(blueprintTarget), [blueprintTarget]); + const categories = useMemo(() => getCategoriesForTarget(blueprintTarget), [blueprintTarget]); + + // --------------------------------------------------------------------------- + // Build initial state from existing fieldDefs + catalog + // --------------------------------------------------------------------------- + + const [catalogOverrides, setCatalogOverrides] = useState< + Record + >(() => { + const map: Record = {}; + // Start with all catalog fields disabled + for (const cf of catalog) { + map[cf.key] = { + enabled: false, + required: false, + showInList: false, + defaultValue: cf.defaultValue, + description: "", + }; + } + // Override from existing fieldDefs + for (const def of initialFieldDefs) { + const state = fieldDefToState(def, blueprintTarget); + if (state.catalogKey && map[state.catalogKey]) { + map[state.catalogKey] = state.overrides; + } + } + return map; + }); + + const [customFields, setCustomFields] = useState(() => { + return initialFieldDefs + .map((def) => fieldDefToState(def, blueprintTarget)) + .filter((s) => !s.catalogKey); + }); + + // --------------------------------------------------------------------------- + // Mutations + // --------------------------------------------------------------------------- + + const updateMutation = trpc.blueprint.update.useMutation(); + const presetMutation = trpc.blueprint.updateRolePresets.useMutation(); + + // --------------------------------------------------------------------------- + // Derived data + // --------------------------------------------------------------------------- + + const allCategoryNames = useMemo( + () => [...categories.map((c) => c.name), "Custom Fields"], + [categories], + ); + + const filteredCatalog = useMemo(() => { + if (!searchQuery.trim()) return catalog; + const q = searchQuery.toLowerCase(); + return catalog.filter( + (f) => + f.label.toLowerCase().includes(q) || + f.key.toLowerCase().includes(q) || + f.description.toLowerCase().includes(q) || + f.category.toLowerCase().includes(q), + ); + }, [catalog, searchQuery]); + + const fieldsByCategory = useMemo(() => { + const map = new Map(); + for (const cat of categories) { + map.set(cat.name, []); + } + for (const f of filteredCatalog) { + const list = map.get(f.category); + if (list) list.push(f); + } + return map; + }, [filteredCatalog, categories]); + + const enabledCount = useMemo(() => { + let count = 0; + for (const ov of Object.values(catalogOverrides)) { + if (ov.enabled) count++; + } + count += customFields.filter((f) => f.overrides.enabled).length; + return count; + }, [catalogOverrides, customFields]); + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + + const handleCatalogFieldChange = useCallback( + (key: string, overrides: FieldOverrides) => { + setCatalogOverrides((prev) => ({ ...prev, [key]: overrides })); + }, + [], + ); + + const handleCustomFieldChange = useCallback( + (idx: number, overrides: FieldOverrides) => { + setCustomFields((prev) => + prev.map((f, i) => (i === idx ? { ...f, overrides } : f)), + ); + }, + [], + ); + + function removeCustomField(idx: number) { + setCustomFields((prev) => prev.filter((_, i) => i !== idx)); + } + + function addCustomField() { + if (!customKey.trim() || !customLabel.trim()) return; + // Check for duplicate key + const allKeys = new Set([ + ...catalog.map((f) => f.key), + ...customFields.map((f) => f.custom?.key).filter(Boolean), + ]); + if (allKeys.has(customKey.trim())) return; + + setCustomFields((prev) => [ + ...prev, + { + catalogKey: undefined, + overrides: { + enabled: true, + required: false, + showInList: false, + defaultValue: undefined, + description: "", + }, + custom: { + key: customKey.trim(), + label: customLabel.trim(), + type: customType, + options: [], + }, + }, + ]); + setCustomKey(""); + setCustomLabel(""); + setCustomType(FieldType.TEXT); + setShowCustomForm(false); + } + + function handleSave() { + setSaveError(null); + const defs: BlueprintFieldDefinition[] = []; + let order = 0; + + // Catalog fields first (in catalog order) + for (const cf of catalog) { + const ov = catalogOverrides[cf.key]; + if (!ov?.enabled) continue; + const state: FieldState = { catalogKey: cf.key, overrides: ov }; + const def = stateToFieldDef(state, order, blueprintTarget); + if (def) { + defs.push(def); + order++; + } + } + + // Custom fields + for (const cf of customFields) { + if (!cf.overrides.enabled) continue; + const def = stateToFieldDef(cf, order, blueprintTarget); + if (def) { + defs.push(def); + order++; + } + } + + updateMutation.mutate( + { id: blueprintId, data: { fieldDefs: defs } }, + { + onSuccess: async () => { + await utils.blueprint.list.invalidate(); + onClose(); + }, + onError: (err) => setSaveError(err.message), + }, + ); + } + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === e.currentTarget) onClose(); + } + + // --------------------------------------------------------------------------- + // Collapsed categories + // --------------------------------------------------------------------------- + + const [collapsedCategories, setCollapsedCategories] = useState>( + new Set(), + ); + + function toggleCategory(name: string) { + setCollapsedCategories((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( +
+
+ {/* Header */} +
+
+

+ Configure Fields:{" "} + {blueprintName} +

+

+ {enabledCount} field{enabledCount !== 1 ? "s" : ""} enabled +

+
+ +
+ + {/* Tabs */} +
+ {(["fields", "presets"] as const).map((tab) => ( + + ))} +
+ + {activeTab === "fields" ? ( + <> + {/* Search + category sidebar layout */} +
+ {/* Category sidebar */} +
+ +
+ + {/* Main content */} +
+ {/* Search bar */} +
+ setSearchQuery(e.target.value)} + placeholder="Search fields..." + className={`${INPUT_CLS} w-full`} + autoFocus + /> +
+ + {/* Field cards */} +
+ {categories + .filter( + (cat) => + activeCategory === null || + activeCategory === cat.name, + ) + .map((cat) => { + const fields = fieldsByCategory.get(cat.name) ?? []; + if (fields.length === 0 && searchQuery.trim()) return null; + if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null; + + const isCollapsed = collapsedCategories.has(cat.name); + + return ( +
+ + {!isCollapsed && ( +
+ {fields.map((field) => ( + + handleCatalogFieldChange(field.key, ov) + } + /> + ))} + {fields.length === 0 && ( +

+ No fields in this category. +

+ )} +
+ )} +
+ ); + })} + + {/* Custom Fields section */} + {(activeCategory === null || + activeCategory === "Custom Fields") && ( +
+ + {!collapsedCategories.has("Custom Fields") && ( +
+ {customFields.map((cf, idx) => { + if (!cf.custom) return null; + // Build a pseudo CatalogField for the FieldCard + const pseudoCatalog: CatalogField = { + key: cf.custom.key, + label: cf.custom.label, + type: cf.custom.type, + category: "Custom Fields", + description: + cf.overrides.description || "Custom field", + ...(cf.custom.options.length > 0 + ? { options: cf.custom.options } + : {}), + builtIn: false, + }; + return ( +
+ + handleCustomFieldChange(idx, ov) + } + /> + +
+ ); + })} + + {/* Add custom field */} + {showCustomForm ? ( +
+
+
+ + + setCustomKey( + e.target.value.replace( + /[^a-zA-Z0-9_]/g, + "", + ), + ) + } + placeholder="field_key" + className={`${INPUT_CLS} font-mono`} + /> +
+
+ + + setCustomLabel(e.target.value) + } + placeholder="Display Label" + className={INPUT_CLS} + /> +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} +
+ )} +
+ )} +
+
+
+ + {/* Error */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Footer */} +
+ + {enabledCount} field{enabledCount !== 1 ? "s" : ""} will be + saved + +
+ + +
+
+ + ) : ( +
+

+ Role presets are auto-loaded in Step 3 of the Project Creation + Wizard when this blueprint is selected. +

+ + presetMutation.mutate( + { id: blueprintId, rolePresets: presets }, + { + onSuccess: async () => { + await utils.blueprint.list.invalidate(); + setPresetSaveError(null); + onClose(); + }, + onError: (err) => { + setPresetSaveError(err.message); + }, + }, + ) + } + isSaving={presetMutation.isPending} + saveError={presetSaveError} + /> +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/blueprints/BlueprintsClient.tsx b/apps/web/src/components/blueprints/BlueprintsClient.tsx index 5952034..f5f8c5d 100644 --- a/apps/web/src/components/blueprints/BlueprintsClient.tsx +++ b/apps/web/src/components/blueprints/BlueprintsClient.tsx @@ -5,7 +5,7 @@ import type { FormEvent, MouseEvent } from "react"; import { BlueprintTarget } from "@planarchy/shared"; import type { BlueprintFieldDefinition } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; -import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js"; +import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js"; import { useSelection } from "~/hooks/useSelection.js"; import { BatchActionBar } from "~/components/ui/BatchActionBar.js"; import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; @@ -496,9 +496,10 @@ export function BlueprintsClient() { )} {editingBlueprint && ( - = { + [FieldType.TEXT]: "Aa", + [FieldType.TEXTAREA]: "Aa", + [FieldType.NUMBER]: "#", + [FieldType.BOOLEAN]: "\u2611", + [FieldType.DATE]: "\u{1F4C5}", + [FieldType.SELECT]: "\u25BC", + [FieldType.MULTI_SELECT]: "\u25BC\u25BC", + [FieldType.URL]: "\u{1F517}", + [FieldType.EMAIL]: "@", +}; + +const TYPE_LABELS: Record = { + [FieldType.TEXT]: "Text", + [FieldType.TEXTAREA]: "Textarea", + [FieldType.NUMBER]: "Number", + [FieldType.BOOLEAN]: "Boolean", + [FieldType.DATE]: "Date", + [FieldType.SELECT]: "Select", + [FieldType.MULTI_SELECT]: "Multi-Select", + [FieldType.URL]: "URL", + [FieldType.EMAIL]: "Email", +}; + +// --------------------------------------------------------------------------- +// Field overrides that the user can set per-field +// --------------------------------------------------------------------------- + +export interface FieldOverrides { + enabled: boolean; + required: boolean; + showInList: boolean; + defaultValue: unknown; + description: string; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface FieldCardProps { + field: CatalogField; + overrides: FieldOverrides; + onChange: (overrides: FieldOverrides) => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const INPUT_CLS = + "px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"; + +export function FieldCard({ field, overrides, onChange }: FieldCardProps) { + const [expanded, setExpanded] = useState(false); + + function update(patch: Partial) { + onChange({ ...overrides, ...patch }); + } + + function handleToggle() { + const next = !overrides.enabled; + update({ enabled: next }); + if (!next) { + setExpanded(false); + } + } + + const isActive = overrides.enabled; + + return ( +
+ {/* Header row */} +
{ + if (isActive) setExpanded((v) => !v); + else handleToggle(); + }} + > + {/* Type icon */} + + {TYPE_ICONS[field.type]} + + + {/* Label + description */} +
+
+ + {field.label} + + + {field.key} + +
+

{field.description}

+
+ + {/* Toggle switch */} + +
+ + {/* Expanded settings */} + {isActive && expanded && ( +
+ {/* Default value input */} +
+ + update({ defaultValue: val })} + /> +
+ + {/* Toggles row */} +
+ + +
+ + {/* Description override */} +
+ + update({ description: e.target.value })} + placeholder={field.description} + className={INPUT_CLS} + /> +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Type-appropriate default value input +// --------------------------------------------------------------------------- + +function DefaultValueInput({ + type, + options, + value, + onChange, +}: { + type: FieldType; + options?: FieldOption[]; + value: unknown; + onChange: (val: unknown) => void; +}) { + switch (type) { + case FieldType.BOOLEAN: + return ( + + ); + + case FieldType.NUMBER: + return ( + + onChange(e.target.value === "" ? undefined : Number(e.target.value)) + } + placeholder="No default" + className={INPUT_CLS} + /> + ); + + case FieldType.DATE: + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + className={INPUT_CLS} + /> + ); + + case FieldType.SELECT: + return ( + + ); + + case FieldType.MULTI_SELECT: + return ( + + ); + + case FieldType.URL: + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + placeholder="https://..." + className={INPUT_CLS} + /> + ); + + case FieldType.EMAIL: + return ( + + onChange(e.target.value === "" ? undefined : e.target.value) + } + placeholder="name@example.com" + className={INPUT_CLS} + /> + ); + + case FieldType.TEXTAREA: + return ( +