From a9856fa322db697bf6a73a54e6d832200dc0523c Mon Sep 17 00:00:00 2001 From: Babayaga Date: Fri, 3 Apr 2026 14:28:33 +0200 Subject: [PATCH] types:arrays,flags,enums --- .../ui/src/modules/types/RJSFTemplates.tsx | 64 ++-- packages/ui/src/modules/types/TypeBuilder.tsx | 15 +- .../ui/src/modules/types/TypeCategoryTree.tsx | 353 ++++++++++++++++++ packages/ui/src/modules/types/TypesEditor.tsx | 121 ++++-- packages/ui/src/modules/types/TypesList.tsx | 71 ++-- .../types/builder/TypeBuilderContent.tsx | 137 ++++--- .../src/modules/types/builder/components.tsx | 25 +- .../ui/src/modules/types/builder/types.ts | 1 + .../ui/src/modules/types/builder/utils.ts | 3 +- packages/ui/src/modules/types/schema-utils.ts | 10 +- 10 files changed, 620 insertions(+), 180 deletions(-) create mode 100644 packages/ui/src/modules/types/TypeCategoryTree.tsx diff --git a/packages/ui/src/modules/types/RJSFTemplates.tsx b/packages/ui/src/modules/types/RJSFTemplates.tsx index 424fb1de..7b6094f9 100644 --- a/packages/ui/src/modules/types/RJSFTemplates.tsx +++ b/packages/ui/src/modules/types/RJSFTemplates.tsx @@ -5,11 +5,13 @@ import CollapsibleSection from '@/components/CollapsibleSection'; // Utility function to convert camelCase to Title Case const formatLabel = (str: string): string => { - // Split on capital letters and join with spaces + // Replace underscores and hyphens with spaces, add space before capitals return str - .replace(/([A-Z])/g, ' $1') // Add space before capital letters - .replace(/^./, (char) => char.toUpperCase()) // Capitalize first letter - .trim(); + .replace(/[_-]+/g, ' ') + .replace(/([A-Z])/g, ' $1') + .trim() + .replace(/^./, (char) => char.toUpperCase()) + .replace(/\s+/g, ' '); // Collapse multiple spaces }; // Custom TextWidget using Tailwind/shadcn styling @@ -137,31 +139,32 @@ export const FieldTemplate = (props: any) => { errors, children, schema, + displayLabel, } = props; // Format the label to be human-readable const formattedLabel = label ? formatLabel(label) : label; return ( -
-
- {formattedLabel && ( +
+
+ {displayLabel !== false && formattedLabel && schema?.type !== 'array' && ( )} -
{children}
+
{children}
{errors && errors.length > 0 && ( -
+
{errors}
)} - + {help &&

{help}

}
); }; @@ -198,13 +201,18 @@ export const ObjectFieldTemplate = (props: any) => { if (!hasGroups) { return ( -
+
{description && (typeof description !== 'string' || description.trim()) && (

{description}

)} -
+
{properties.map((element: any) => ( -
+
{element.content}
))} @@ -213,29 +221,33 @@ export const ObjectFieldTemplate = (props: any) => { ); } + const innerGridClass = + customClassNames || + 'grid grid-cols-1 gap-4 min-w-0 [grid-template-columns:minmax(0,1fr)]'; + return ( -
+
{props.description && (

{props.description}

)} - {/* Render Groups */} + {/* Render Groups — bordered regions contain fields so grid/col-span-full cannot escape to an outer layout */} {hasGroups && ( -
+
{Object.entries(groups).map(([groupName, elements]) => ( -
+
{elements.map((element: any) => ( -
+
{element.content}
))} @@ -247,9 +259,9 @@ export const ObjectFieldTemplate = (props: any) => { {/* Render Ungrouped Fields */} {ungrouped.length > 0 && ( -
+
{ungrouped.map((element: any) => ( -
+
{element.content}
))} @@ -264,7 +276,7 @@ export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => { const { items, canAdd, onAddClick, title } = props; return ( -
+
{title && (
diff --git a/packages/ui/src/modules/types/TypeBuilder.tsx b/packages/ui/src/modules/types/TypeBuilder.tsx index 3e8b8bd2..6661bf85 100644 --- a/packages/ui/src/modules/types/TypeBuilder.tsx +++ b/packages/ui/src/modules/types/TypeBuilder.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useImperativeHandle } from 'react'; import { TypeDefinition } from './client-types'; import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, closestCenter } from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; import { BuilderOutput, BuilderElement, BuilderMode, EnumValueEntry, FlagValueEntry } from './builder/types'; import { TypeBuilderContent } from './builder/TypeBuilderContent'; import { DraggablePaletteItem } from './builder/components'; @@ -60,7 +61,19 @@ export const TypeBuilder = React.forwardRef e.id === active.id) && elements.find(e => e.id === over.id)) { + setElements(items => { + const oldIndex = items.findIndex(i => i.id === active.id); + const newIndex = items.findIndex(i => i.id === over.id); + return arrayMove(items, oldIndex, newIndex); + }); + return; + } + + if (over.id === 'canvas' && active.data.current) { const dragged = active.data.current as BuilderElement; if (mode === 'alias') { diff --git a/packages/ui/src/modules/types/TypeCategoryTree.tsx b/packages/ui/src/modules/types/TypeCategoryTree.tsx new file mode 100644 index 00000000..9890ba57 --- /dev/null +++ b/packages/ui/src/modules/types/TypeCategoryTree.tsx @@ -0,0 +1,353 @@ +import React, { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchCategories, Category } from '@/modules/categories/client-categories'; +import { TypeDefinition } from './client-types'; +import { FolderTree, ChevronRight, ChevronDown, Loader2, Box, HelpCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppConfig } from '@/hooks/useSystemInfo'; + +export interface TypeCategoryTreeProps { + types: TypeDefinition[]; + renderItem: (type: TypeDefinition) => React.ReactNode; + excludeFieldTypes?: boolean; + className?: string; + selectedId?: string | null; +} + +const COLLAPSED_KEY = 'typeCategoryTreeCollapsed'; + +type TreeRow = { + id: string; // unique identifier + isFolder: boolean; // true for groups/categories, false for actual types + label: string; // Name of the folder or type + depth: number; // Indentation level + icon?: React.ReactNode; // Folder icon + isExpanded?: boolean; // Collapse state + typeDef?: TypeDefinition; // Payload if it's a type + parentId: string | null; // Reference for 'ArrowLeft' to jump to parent folder +}; + +/** Recursively filter a category tree by meta.type */ +const filterByType = (cats: Category[], type: string): Category[] => + cats.reduce((acc, cat) => { + if (cat.meta?.type === type) { + const filteredChildren = cat.children + ? filterByType(cat.children.map(rel => rel.child), type) + .map(child => ({ child, parent_id: cat.id })) + : undefined; + acc.push({ ...cat, children: filteredChildren as any }); + } + return acc; + }, []); + +export const TypeCategoryTree: React.FC = ({ + types, + renderItem, + excludeFieldTypes = true, + className, + selectedId +}) => { + const appConfig = useAppConfig(); + const srcLang = appConfig?.i18n?.source_language; + + const [collapsedIds, setCollapsedIds] = useState>(() => { + try { + const stored = localStorage.getItem(COLLAPSED_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { return new Set(); } + }); + + const toggleExpand = useCallback((id: string) => { + setCollapsedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next])); + return next; + }); + }, []); + + const { data: allCategories = [], isLoading } = useQuery({ + queryKey: ['categories'], + queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang }), + staleTime: 1000 * 60 * 5, + }); + + const categoryTree = useMemo(() => filterByType(allCategories, 'types'), [allCategories]); + + const rows = useMemo(() => { + const p: TypeDefinition[] = []; + const u: TypeDefinition[] = []; + const c: Record = {}; + + const filteredTypes = excludeFieldTypes ? types.filter(t => t.kind !== 'field') : types; + + filteredTypes.forEach(t => { + if (t.kind === 'primitive') { + p.push(t); + } else { + const cats = t.meta?.categoryIds || []; + if (cats.length === 0) { + u.push(t); + } else { + cats.forEach((catId: string) => { + if (!c[catId]) c[catId] = []; + c[catId].push(t); + }); + } + } + }); + + // Sort primitives slightly + const primitiveOrder = ['string', 'int', 'float', 'bool', 'object', 'array']; + p.sort((a, b) => { + const ia = primitiveOrder.indexOf(a.name); + const ib = primitiveOrder.indexOf(b.name); + if (ia !== -1 && ib !== -1) return ia - ib; + if (ia !== -1) return -1; + if (ib !== -1) return 1; + return a.name.localeCompare(b.name); + }); + + // Sort others alphabetically + u.sort((a, b) => a.name.localeCompare(b.name)); + Object.values(c).forEach(arr => arr.sort((a, b) => a.name.localeCompare(b.name))); + + const visible: TreeRow[] = []; + + const addFolder = (id: string, label: string, icon: React.ReactNode, childrenRowsFn: () => TreeRow[], depth: number, parentId: string | null) => { + const isExpanded = !collapsedIds.has(id); + visible.push({ id, isFolder: true, label, icon, depth, isExpanded, parentId }); + if (isExpanded) { + visible.push(...childrenRowsFn()); + } + }; + + const renderCatNode = (cat: Category, depth: number, parentId: string | null): TreeRow[] => { + const typesInCat = c[cat.id] || []; + const childCats = cat.children ? cat.children.map(rel => rel.child) : []; + const hasChildren = childCats.length > 0 || typesInCat.length > 0; + + if (!hasChildren && typesInCat.length === 0) return []; // hide empty + + const catId = `cat-${cat.id}`; + const isExpanded = !collapsedIds.has(catId); + + const catRows: TreeRow[] = [{ + id: catId, + isFolder: true, + label: cat.name, + depth, + icon: , + isExpanded, + parentId + }]; + + if (isExpanded) { + childCats.forEach(childCat => { + catRows.push(...renderCatNode(childCat, depth + 1, catId)); + }); + typesInCat.forEach(t => { + catRows.push({ + id: `type-${cat.id}-${t.id}`, + isFolder: false, + label: t.name, + depth: depth + 1, + typeDef: t, + parentId: catId + }); + }); + } + return catRows; + }; + + // Primitives + if (p.length > 0) { + addFolder('group-primitives', 'Primitives', , () => { + return p.map(t => ({ + id: `prim-${t.id}`, + isFolder: false, + label: t.name, + depth: 1, + typeDef: t, + parentId: 'group-primitives' + })); + }, 0, null); + } + + // Categories + categoryTree.forEach(cat => { + visible.push(...renderCatNode(cat, 0, null)); + }); + + // Uncategorized + if (u.length > 0) { + addFolder('group-uncategorized', 'Uncategorized', , () => { + return u.map(t => ({ + id: `uncat-${t.id}`, + isFolder: false, + label: t.name, + depth: 1, + typeDef: t, + parentId: 'group-uncategorized' + })); + }, 0, null); + } + + return visible; + }, [types, excludeFieldTypes, categoryTree, collapsedIds]); + + const [focusIdx, setFocusIdx] = useState(0); + const containerRef = useRef(null); + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); + + // Sync selectedId from props if provided and available in rows. + useEffect(() => { + if (selectedId) { + // Find the first row that represents this selectedType + const idx = rows.findIndex(r => !r.isFolder && r.typeDef?.id === selectedId); + if (idx >= 0) { + setFocusIdx(idx); + } + } + }, [selectedId, rows]); + + const prevRowsRef = useRef(rows); + useLayoutEffect(() => { + if (rows.length === 0) return; + if (rows === prevRowsRef.current) return; + + prevRowsRef.current = rows; + // Make sure focus is bounded + const idx = Math.min(focusIdx, rows.length - 1); + setFocusIdx(idx); + }, [rows, focusIdx]); + + useEffect(() => { + rowRefs.current[focusIdx]?.scrollIntoView({ block: 'nearest' }); + }, [focusIdx]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.altKey || rows.length === 0) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + setFocusIdx(prev => (prev < rows.length - 1 ? prev + 1 : 0)); + break; + } + case 'ArrowUp': { + e.preventDefault(); + setFocusIdx(prev => (prev > 0 ? prev - 1 : rows.length - 1)); + break; + } + case 'ArrowRight': { + e.preventDefault(); + const row = rows[focusIdx]; + if (!row) break; + if (row.isFolder) { + if (!row.isExpanded) { + toggleExpand(row.id); + } else if (focusIdx < rows.length - 1) { + setFocusIdx(focusIdx + 1); + } + } else if (focusIdx < rows.length - 1) { + setFocusIdx(focusIdx + 1); + } + break; + } + case 'ArrowLeft': { + e.preventDefault(); + const row = rows[focusIdx]; + if (!row) break; + if (row.isFolder && row.isExpanded) { + toggleExpand(row.id); + } else if (row.parentId) { + const parentIdx = rows.findIndex(r => r.id === row.parentId); + if (parentIdx >= 0) { + setFocusIdx(parentIdx); + } + } + break; + } + case 'Enter': + case ' ': { + e.preventDefault(); + const row = rows[focusIdx]; + if (!row) break; + if (row.isFolder) { + toggleExpand(row.id); + } else { + const el = rowRefs.current[focusIdx]?.querySelector('button, [role="button"]') as HTMLElement; + el?.click(); + } + break; + } + } + }, [rows, focusIdx, toggleExpand]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {rows.map((row, idx) => { + const isFocused = idx === focusIdx; + + if (row.isFolder) { + return ( +
rowRefs.current[idx] = el} + onClick={() => { + setFocusIdx(idx); + toggleExpand(row.id); + containerRef.current?.focus({ preventScroll: true }); + }} + className={cn( + "flex items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground cursor-pointer transition-colors border-l-2 border-transparent my-[1px]", + isFocused ? "bg-muted border-primary text-foreground" : "hover:bg-muted/60" + )} + style={{ paddingLeft: `${8 + row.depth * 14}px` }} + > + + {row.icon} + {row.label} +
+ ); + } + + // Node is a type layout wrapper + return ( +
rowRefs.current[idx] = el} + onClick={() => { + setFocusIdx(idx); + containerRef.current?.focus({ preventScroll: true }); + }} + className={cn( + "group rounded-sm border-l-2 border-transparent relative my-[1px]", + isFocused ? "border-primary before:absolute before:inset-0 before:bg-muted/50 before:-z-10 before:rounded-sm" : "" + )} + style={{ paddingLeft: `${8 + row.depth * 14}px`, paddingRight: '8px', paddingTop: '2px', paddingBottom: '2px' }} + > + {row.typeDef && renderItem(row.typeDef)} +
+ ); + })} +
+ ); +}; diff --git a/packages/ui/src/modules/types/TypesEditor.tsx b/packages/ui/src/modules/types/TypesEditor.tsx index fdb87f65..01aab2e0 100644 --- a/packages/ui/src/modules/types/TypesEditor.tsx +++ b/packages/ui/src/modules/types/TypesEditor.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { TypeDefinition, updateType, createType } from './client-types'; +import { deepMergeUiSchema } from './schema-utils'; import { Card } from '@/components/ui/card'; import { toast } from "sonner"; import { T, translate } from '@/i18n'; @@ -10,6 +11,54 @@ import { useActions } from '@/actions/useActions'; import { Action } from '@/actions/types'; import { TypeTranslationDialog } from '@/modules/i18n/TypeTranslationDialog'; +/** Map one structure field to a builder element, merging field-type meta.uiSchema with this structure's meta.uiSchema[fieldName] (TypeRenderer merges the same way). */ +function structureFieldToBuilderElement( + field: NonNullable[number], + types: TypeDefinition[], + structureType: TypeDefinition +): BuilderElement { + const fieldType = types.find(t => t.id === field.field_type_id); + const structureUi = structureType.meta?.uiSchema; + let fieldSlice: Record = {}; + if (structureUi && typeof structureUi === 'object' && structureUi !== null && field.field_name in structureUi) { + const raw = (structureUi as Record)[field.field_name]; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + fieldSlice = raw as Record; + } + } + const mergedUi = deepMergeUiSchema(fieldType?.meta?.uiSchema || {}, fieldSlice); + const uiGroup = mergedUi['ui:group']; + + const reqList = structureType.json_schema?.required; + const required = Array.isArray(reqList) + ? reqList.includes(field.field_name) + : (field.required || false); + + const defaultValue = + field.default_value !== undefined && field.default_value !== null + ? field.default_value + : (fieldType?.settings?.default_value ?? fieldType?.meta?.default); + + // Value type (enum, flags, string, …) lives on the field type's parent — not on the field row name `Struct.field` + const valueType = fieldType?.parent_type_id + ? types.find(t => t.id === fieldType.parent_type_id) + : undefined; + + return { + id: field.id || crypto.randomUUID(), + name: field.field_name, + type: valueType?.name || fieldType?.name || 'string', + refId: valueType?.id, + title: field.field_name, + description: fieldType?.description || '', + uiSchema: mergedUi, + itemsTypeId: fieldType?.settings?.items_type_id || undefined, + required, + defaultValue, + group: fieldType?.settings?.group ?? (typeof uiGroup === 'string' ? uiGroup : undefined) + } as BuilderElement; +} + export interface TypesEditorProps { types: TypeDefinition[]; selectedType: TypeDefinition | null; @@ -48,20 +97,9 @@ export const TypesEditor: React.FC = ({ // For structures, convert structure_fields to builder elements if (selectedType.kind === 'structure' && selectedType.structure_fields) { - builderData.elements = selectedType.structure_fields.map(field => { - const fieldType = types.find(t => t.id === field.field_type_id); - return { - id: field.id || crypto.randomUUID(), - name: field.field_name, - type: fieldType?.name || 'string', - title: field.field_name, - description: fieldType?.description || '', - uiSchema: fieldType?.meta?.uiSchema || {}, - itemsTypeId: fieldType?.settings?.items_type_id || undefined, - required: field.required || false, - defaultValue: fieldType?.settings?.default_value ?? fieldType?.meta?.default - } as BuilderElement; - }); + builderData.elements = selectedType.structure_fields.map(field => + structureFieldToBuilderElement(field, types, selectedType) + ); } // For enums, populate enum values @@ -97,20 +135,9 @@ export const TypesEditor: React.FC = ({ }; if (selectedType.kind === 'structure' && selectedType.structure_fields) { - builderData.elements = selectedType.structure_fields.map(field => { - const fieldType = types.find(t => t.id === field.field_type_id); - return { - id: field.id || crypto.randomUUID(), - name: field.field_name, - type: fieldType?.name || 'string', - title: field.field_name, - description: fieldType?.description || '', - uiSchema: fieldType?.meta?.uiSchema || {}, - itemsTypeId: fieldType?.settings?.items_type_id || undefined, - required: field.required || false, - defaultValue: fieldType?.settings?.default_value ?? fieldType?.meta?.default - } as BuilderElement; - }); + builderData.elements = selectedType.structure_fields.map(field => + structureFieldToBuilderElement(field, types, selectedType) + ); } if (selectedType.kind === 'enum' && selectedType.enum_values) { @@ -137,10 +164,31 @@ export const TypesEditor: React.FC = ({ try { const jsonSchema = JSON.parse(jsonSchemaString); const uiSchema = JSON.parse(uiSchemaString); - await updateType(selectedType.id, { + + const payload: Record = { json_schema: jsonSchema, meta: { ...selectedType.meta, uiSchema } - }); + }; + + // Keep structure_fields.required in sync when JSON Schema has a top-level required array (same source of truth as the form). + if ( + selectedType.kind === 'structure' && + selectedType.structure_fields?.length && + jsonSchema && + typeof jsonSchema === 'object' && + Array.isArray(jsonSchema.required) + ) { + const req = jsonSchema.required as string[]; + payload.structure_fields = selectedType.structure_fields.map((sf, idx) => ({ + field_name: sf.field_name, + field_type_id: sf.field_type_id, + required: req.includes(sf.field_name), + order: sf.order ?? idx, + default_value: sf.default_value ?? null + })); + } + + await updateType(selectedType.id, payload); toast.success(translate("Type updated")); onSave(); } catch (e) { @@ -184,7 +232,9 @@ export const TypesEditor: React.FC = ({ const parentType = (el as any).refId ? types.find(t => t.id === (el as any).refId) - : types.find(t => t.name === el.type); + : fieldType?.parent_type_id + ? types.find(t => t.id === fieldType.parent_type_id) + : types.find(t => t.name === el.type); if (!parentType) { console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`); @@ -200,7 +250,8 @@ export const TypesEditor: React.FC = ({ settings: { ...fieldType?.settings, ...(el.itemsTypeId ? { items_type_id: el.itemsTypeId } : {}), - ...(el.defaultValue !== undefined && el.defaultValue !== '' ? { default_value: el.defaultValue } : { default_value: null }) + ...(el.defaultValue !== undefined && el.defaultValue !== '' ? { default_value: el.defaultValue } : { default_value: null }), + ...(el.group ? { group: el.group } : { group: null }) } }; if (el.defaultValue === undefined || el.defaultValue === '') { @@ -222,13 +273,15 @@ export const TypesEditor: React.FC = ({ field_name: el.name, field_type_id: validFieldTypes[idx]?.id || '', required: el.required || false, - order: idx + order: idx, + default_value: el.defaultValue !== undefined && el.defaultValue !== '' ? el.defaultValue : null })); await updateType(selectedType.id, { name: output.name, description: output.description, - structure_fields: structureFields + structure_fields: structureFields, + meta: selectedType.meta }); } else { // Update non-structure types diff --git a/packages/ui/src/modules/types/TypesList.tsx b/packages/ui/src/modules/types/TypesList.tsx index 80141b89..8e2f9758 100644 --- a/packages/ui/src/modules/types/TypesList.tsx +++ b/packages/ui/src/modules/types/TypesList.tsx @@ -1,7 +1,8 @@ -import React, { useMemo } from 'react'; -import { TypeDefinition } from './db'; +import React from 'react'; +import { TypeDefinition } from './client-types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { TypeCategoryTree } from './TypeCategoryTree'; interface TypesListProps { types: TypeDefinition[]; @@ -17,21 +18,6 @@ export const TypesList: React.FC = ({ onSelect, className }) => { - // Group types by kind (exclude field types from display) - const groupedTypes = useMemo(() => { - const groups: Record = {}; - types - .filter(t => t.kind !== 'field') // Don't show field types in the main list - .forEach(t => { - const kind = t.kind || 'other'; - if (!groups[kind]) groups[kind] = []; - groups[kind].push(t); - }); - return groups; - }, [types]); - - const kindOrder = ['primitive', 'enum', 'flags', 'structure', 'alias', 'other']; - return ( @@ -39,36 +25,27 @@ export const TypesList: React.FC = ({ -
- {kindOrder.map(kind => { - const group = groupedTypes[kind]; - if (!group || group.length === 0) return null; - - return ( -
-

- {kind} -

-
- {group.map(t => ( - - ))} -
-
- ); - })} +
+ ( + + )} + />
diff --git a/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx b/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx index 7fde9568..30c071a6 100644 --- a/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx +++ b/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -12,6 +13,7 @@ import { Box } from 'lucide-react'; import { CategoryManager } from '@/components/widgets/CategoryManager'; import { BuilderMode, BuilderElement, BuilderOutput, EnumValueEntry, FlagValueEntry } from './types'; import { DraggablePaletteItem, CanvasElement, WidgetPicker } from './components'; +import { TypeCategoryTree } from '../TypeCategoryTree'; import { EnumEditor, FlagsEditor } from './Editors'; import { resolvePrimitiveType } from './utils'; import { TypeDefinition } from '../client-types'; @@ -54,41 +56,7 @@ export const TypeBuilderContent: React.FC<{ id: 'canvas', }); - const primitivePaletteItems = React.useMemo(() => { - const order = ['string', 'int', 'float', 'bool', 'object', 'array']; - return availableTypes - .filter(t => t.kind === 'primitive') - .sort((a, b) => { - const ia = order.indexOf(a.name); - const ib = order.indexOf(b.name); - if (ia !== -1 && ib !== -1) return ia - ib; - if (ia !== -1) return -1; - if (ib !== -1) return 1; - return a.name.localeCompare(b.name); - }) - .map(t => ({ - id: `primitive-${t.id}`, - type: t.name, - name: t.name.charAt(0).toUpperCase() + t.name.slice(1), - title: `New ${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`, - description: t.description || undefined, - refId: t.id - } as BuilderElement & { refId?: string })); - }, [availableTypes]); - - const customPaletteItems = React.useMemo(() => { - return availableTypes - .filter(t => t.kind !== 'primitive' && t.kind !== 'field') - .map(t => ({ - id: `type-${t.id}`, - type: t.name, - name: t.name, - title: t.name, - description: t.description || undefined, - isCustom: true, - refId: t.id - } as BuilderElement & { isCustom?: boolean, refId?: string })); - }, [availableTypes]); + // We don't pre-map to Palette items here anymore, as TypeCategoryTree handles TypeDefinitions directly. return (
@@ -98,28 +66,47 @@ export const TypeBuilderContent: React.FC<{ Palette -
-
Primitives
-
- {primitivePaletteItems.length > 0 ? ( - primitivePaletteItems.map(item => ( - - )) - ) : ( -
No primitive types found.
- )} -
-
- - {customPaletteItems.length > 0 && mode !== 'alias' && ( -
-
Custom Types
-
- {customPaletteItems.map(item => ( - - ))} -
-
+ {mode !== 'alias' ? ( + { + const isPrimitive = t.kind === 'primitive'; + const item: BuilderElement = isPrimitive ? { + id: `primitive-${t.id}`, + type: t.name, + name: t.name.charAt(0).toUpperCase() + t.name.slice(1), + title: `New ${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`, + description: t.description || undefined, + refId: t.id + } : { + id: `type-${t.id}`, + type: t.name, + name: t.name, + title: t.name, + description: t.description || undefined, + isCustom: true, + refId: t.id + } as BuilderElement & { isCustom?: boolean, refId?: string }; + return ; + }} + /> + ) : ( + t.kind === 'primitive')} + excludeFieldTypes={true} + renderItem={(t) => { + const item: BuilderElement = { + id: `primitive-${t.id}`, + type: t.name, + name: t.name.charAt(0).toUpperCase() + t.name.slice(1), + title: `New ${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`, + description: t.description || undefined, + refId: t.id + }; + return ; + }} + /> )}
@@ -169,16 +156,18 @@ export const TypeBuilderContent: React.FC<{
) : (
- {elements.map(el => ( - setSelectedId(el.id)} - onDelete={() => deleteElement(el.id)} - onRemoveOnly={() => removeElement(el.id)} - /> - ))} + e.id)} strategy={verticalListSortingStrategy}> + {elements.map(el => ( + setSelectedId(el.id)} + onDelete={() => deleteElement(el.id)} + onRemoveOnly={() => removeElement(el.id)} + /> + ))} +
)} @@ -238,8 +227,8 @@ export const TypeBuilderContent: React.FC<{ - {primitivePaletteItems.map(p => ( - {p.name} + {availableTypes.filter(t => t.kind === 'primitive').map(p => ( + {p.name.charAt(0).toUpperCase() + p.name.slice(1)} ))} @@ -329,6 +318,16 @@ export const TypeBuilderContent: React.FC<{

The initial value for this field.

+
+ + updateSelectedElement({ group: e.target.value })} + placeholder="e.g. Content, Advanced, SEO" + /> +

Used to group fields together during visual rendering.

+
+ {resolvePrimitiveType(selectedElement.type, types) === 'array' && (

Array Configuration

diff --git a/packages/ui/src/modules/types/builder/components.tsx b/packages/ui/src/modules/types/builder/components.tsx index 902961e6..d28b3002 100644 --- a/packages/ui/src/modules/types/builder/components.tsx +++ b/packages/ui/src/modules/types/builder/components.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react'; import { useDraggable } from '@dnd-kit/core'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -50,16 +52,37 @@ export const CanvasElement = ({ const primitiveTypes = ['string', 'number', 'int', 'float', 'boolean', 'bool', 'array', 'object']; const isPrimitive = primitiveTypes.includes(element.type.toLowerCase()); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: element.id, data: element }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return (
{ e.stopPropagation(); onSelect(); }} className={`p-3 border rounded-md mb-2 cursor-pointer flex items-center justify-between group ${isSelected ? 'ring-2 ring-primary border-primary' : 'hover:border-primary/50 bg-background'}`} >
+
+ +
{element.title || element.name} - {element.type} + + {element.type} + {element.group && {element.group}} +
diff --git a/packages/ui/src/modules/types/builder/types.ts b/packages/ui/src/modules/types/builder/types.ts index ec810ba1..8de76dfc 100644 --- a/packages/ui/src/modules/types/builder/types.ts +++ b/packages/ui/src/modules/types/builder/types.ts @@ -10,6 +10,7 @@ export interface BuilderElement { refId?: string; required?: boolean; defaultValue?: any; + group?: string; } export type BuilderMode = 'structure' | 'alias' | 'enum' | 'flags'; diff --git a/packages/ui/src/modules/types/builder/utils.ts b/packages/ui/src/modules/types/builder/utils.ts index 9467e016..88b85704 100644 --- a/packages/ui/src/modules/types/builder/utils.ts +++ b/packages/ui/src/modules/types/builder/utils.ts @@ -1,7 +1,8 @@ import { Type as TypeIcon, Hash, ToggleLeft, Box, List, FileJson } from 'lucide-react'; import { TypeDefinition } from '../client-types'; -export function getIconForType(type: string) { +export function getIconForType(type: string | undefined) { + if (!type) return FileJson; switch (type.toLowerCase()) { case 'string': return TypeIcon; case 'int': diff --git a/packages/ui/src/modules/types/schema-utils.ts b/packages/ui/src/modules/types/schema-utils.ts index a4161c69..abca804a 100644 --- a/packages/ui/src/modules/types/schema-utils.ts +++ b/packages/ui/src/modules/types/schema-utils.ts @@ -25,6 +25,11 @@ export const generateSchemaForType = (typeId: string, types: TypeDefinition[], v const type = types.find(t => t.id === typeId); if (!type) return { type: 'string' }; + // Field rows wrap the actual value type (enum, flags, …) — resolve through parent + if (type.kind === 'field' && type.parent_type_id) { + return generateSchemaForType(type.parent_type_id, types, visited); + } + // If it's a primitive, return the JSON schema mapping if (type.kind === 'primitive') { return primitiveToJsonSchema[type.name] || { type: 'string' }; @@ -137,7 +142,10 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], const isArray = parentType?.name === 'array' || parentType?.kind === 'primitive' && parentType?.name === 'array'; const isEnum = parentType?.kind === 'enum'; const isFlags = parentType?.kind === 'flags'; - const fieldUiSchema = fieldType?.meta?.uiSchema || {}; + const fieldUiSchema = { + ...(fieldType?.meta?.uiSchema || {}), + ...(fieldType?.settings?.group && { 'ui:group': fieldType.settings.group }) + }; if (isEnum) { // Enum field — default to select widget if not overridden