types:arrays,flags,enums

This commit is contained in:
lovebird 2026-04-03 14:28:33 +02:00
parent 063e56036a
commit a9856fa322
10 changed files with 620 additions and 180 deletions

View File

@ -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 (
<div className={`w-full ${classNames}`}>
<div className="flex items-center gap-2">
{formattedLabel && (
<div className={`w-full min-w-0 max-w-full ${classNames}`}>
<div className="flex flex-col gap-1.5 mb-1 min-w-0">
{displayLabel !== false && formattedLabel && schema?.type !== 'array' && (
<label
htmlFor={id}
className="text-[11px] font-medium text-muted-foreground shrink-0 whitespace-nowrap"
className="text-xs font-semibold text-foreground/80"
>
{formattedLabel}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
<div className="flex-1 min-w-0">{children}</div>
<div className="w-full">{children}</div>
</div>
{errors && errors.length > 0 && (
<div id={`${id}-error`} className="mt-1 text-xs text-red-600 pl-[88px]">
<div id={`${id}-error`} className="mt-1 text-[11px] text-destructive font-medium">
{errors}
</div>
)}
{help && <p className="mt-1 text-[11px] text-muted-foreground">{help}</p>}
</div>
);
};
@ -198,13 +201,18 @@ export const ObjectFieldTemplate = (props: any) => {
if (!hasGroups) {
return (
<div className="space-y-4">
<div className="space-y-4 min-w-0 w-full">
{description && (typeof description !== 'string' || description.trim()) && (
<p className="text-sm text-gray-600 mb-4">{description}</p>
)}
<div className={customClassNames || 'grid grid-cols-1 gap-4'}>
<div
className={
customClassNames ||
'grid grid-cols-1 gap-4 min-w-0 [grid-template-columns:minmax(0,1fr)]'
}
>
{properties.map((element: any) => (
<div key={element.name} className="w-full">
<div key={element.name} className="min-w-0 w-full max-w-full">
{element.content}
</div>
))}
@ -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 (
<div className="space-y-6">
<div className="space-y-6 min-w-0 w-full">
{props.description && (
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
)}
{/* Render Groups */}
{/* Render Groups — bordered regions contain fields so grid/col-span-full cannot escape to an outer layout */}
{hasGroups && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 min-w-0 w-full">
{Object.entries(groups).map(([groupName, elements]) => (
<CollapsibleSection
key={groupName}
title={groupName}
initiallyOpen={true}
minimal={true}
storageKey={`competitor-search-group-${groupName}`}
className=""
headerClassName="flex justify-between items-center p-3 cursor-pointer hover:/50 transition-colors rounded-t-lg"
contentClassName="p-3"
storageKey={`rjsf-object-group-${groupName}`}
className="rounded-lg border border-border bg-card text-card-foreground shadow-sm overflow-hidden min-w-0 w-full"
headerClassName="flex justify-between items-center gap-2 px-3 py-2.5 cursor-pointer bg-muted/50 border-b border-border hover:bg-muted/70 transition-colors"
contentClassName="px-3 py-3 bg-muted/10 min-w-0 overflow-x-auto"
>
<div className="grid grid-cols-1 gap-4">
<div className={innerGridClass}>
{elements.map((element: any) => (
<div key={element.name} className="w-full">
<div key={element.name} className="min-w-0 w-full max-w-full">
{element.content}
</div>
))}
@ -247,9 +259,9 @@ export const ObjectFieldTemplate = (props: any) => {
{/* Render Ungrouped Fields */}
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 gap-4">
<div className={hasGroups ? innerGridClass : (customClassNames || innerGridClass)}>
{ungrouped.map((element: any) => (
<div key={element.name} className="w-full">
<div key={element.name} className="min-w-0 w-full max-w-full">
{element.content}
</div>
))}
@ -264,7 +276,7 @@ export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => {
const { items, canAdd, onAddClick, title } = props;
return (
<div className="col-span-full">
<div className="w-full min-w-0 max-w-full">
{title && (
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">

View File

@ -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<TypeBuilderRef, {
setActiveDragItem(null);
const { over, active } = event;
if (over && over.id === 'canvas' && active.data.current) {
if (!over) return;
// If sorting elements inside canvas
if (active.id !== over.id && elements.find(e => 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') {

View File

@ -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<Category[]>((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<TypeCategoryTreeProps> = ({
types,
renderItem,
excludeFieldTypes = true,
className,
selectedId
}) => {
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language;
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => {
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<string, TypeDefinition[]> = {};
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: <FolderTree className="h-3.5 w-3.5 shrink-0" />,
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', <Box className="h-3.5 w-3.5 shrink-0" />, () => {
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', <HelpCircle className="h-3.5 w-3.5 shrink-0" />, () => {
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<HTMLDivElement>(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 (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
ref={containerRef}
className={cn("flex flex-col select-none outline-none focus-visible:ring-1 focus-visible:ring-primary/50 rounded-md", className)}
tabIndex={0}
onKeyDown={handleKeyDown}
>
{rows.map((row, idx) => {
const isFocused = idx === focusIdx;
if (row.isFolder) {
return (
<div
key={row.id}
ref={el => 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` }}
>
<button className="shrink-0 p-0.5 rounded hover:bg-muted focus:outline-none">
{row.isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
{row.icon}
<span className="truncate">{row.label}</span>
</div>
);
}
// Node is a type layout wrapper
return (
<div
key={row.id}
ref={el => 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)}
</div>
);
})}
</div>
);
};

View File

@ -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<TypeDefinition['structure_fields']>[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<string, unknown> = {};
if (structureUi && typeof structureUi === 'object' && structureUi !== null && field.field_name in structureUi) {
const raw = (structureUi as Record<string, unknown>)[field.field_name];
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
fieldSlice = raw as Record<string, unknown>;
}
}
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<TypesEditorProps> = ({
// 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<TypesEditorProps> = ({
};
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<TypesEditorProps> = ({
try {
const jsonSchema = JSON.parse(jsonSchemaString);
const uiSchema = JSON.parse(uiSchemaString);
await updateType(selectedType.id, {
const payload: Record<string, unknown> = {
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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

View File

@ -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<TypesListProps> = ({
onSelect,
className
}) => {
// Group types by kind (exclude field types from display)
const groupedTypes = useMemo(() => {
const groups: Record<string, TypeDefinition[]> = {};
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 (
<Card className={`flex flex-col min-h-0 ${className}`}>
<CardHeader className="pb-3 border-b px-4 py-3">
@ -39,36 +25,27 @@ export const TypesList: React.FC<TypesListProps> = ({
</CardHeader>
<CardContent className="flex-1 min-h-0 p-0">
<ScrollArea className="h-full">
<div className="p-3 space-y-4">
{kindOrder.map(kind => {
const group = groupedTypes[kind];
if (!group || group.length === 0) return null;
return (
<div key={kind} className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-2 mb-2">
{kind}
</h3>
<div className="space-y-0.5">
{group.map(t => (
<button
key={t.id}
onClick={() => onSelect(t)}
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${selectedTypeId === t.id
? 'bg-secondary text-secondary-foreground font-medium'
: 'hover:bg-muted text-muted-foreground'
}`}
>
<span className="truncate">{t.name}</span>
{selectedTypeId === t.id && (
<div className="w-1 h-1 rounded-full bg-primary flex-shrink-0 ml-2" />
)}
</button>
))}
</div>
</div>
);
})}
<div className="p-2 space-y-4">
<TypeCategoryTree
types={types}
excludeFieldTypes={true}
selectedId={selectedTypeId}
renderItem={(t) => (
<button
onClick={() => onSelect(t)}
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${
selectedTypeId === t.id
? 'bg-secondary text-secondary-foreground font-medium'
: 'hover:bg-muted text-muted-foreground'
}`}
>
<span className="truncate">{t.name}</span>
{selectedTypeId === t.id && (
<div className="w-1 h-1 rounded-full bg-primary flex-shrink-0 ml-2" />
)}
</button>
)}
/>
</div>
</ScrollArea>
</CardContent>

View File

@ -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 (
<div className="flex h-full gap-6">
@ -98,28 +66,47 @@ export const TypeBuilderContent: React.FC<{
<CardTitle className="text-sm font-medium">Palette</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-3 space-y-6 overflow-y-auto scrollbar-custom">
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div>
<div className="space-y-2">
{primitivePaletteItems.length > 0 ? (
primitivePaletteItems.map(item => (
<DraggablePaletteItem key={item.id} item={item} />
))
) : (
<div className="text-xs text-muted-foreground italic">No primitive types found.</div>
)}
</div>
</div>
{customPaletteItems.length > 0 && mode !== 'alias' && (
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Custom Types</div>
<div className="space-y-2">
{customPaletteItems.map(item => (
<DraggablePaletteItem key={item.id} item={item} />
))}
</div>
</div>
{mode !== 'alias' ? (
<TypeCategoryTree
types={availableTypes}
excludeFieldTypes={true}
renderItem={(t) => {
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 <DraggablePaletteItem key={item.id} item={item} />;
}}
/>
) : (
<TypeCategoryTree
types={availableTypes.filter(t => 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 <DraggablePaletteItem key={item.id} item={item} />;
}}
/>
)}
</CardContent>
</Card>
@ -169,16 +156,18 @@ export const TypeBuilderContent: React.FC<{
</div>
) : (
<div className="space-y-2 max-w-md mx-auto">
{elements.map(el => (
<CanvasElement
key={el.id}
element={el}
isSelected={selectedId === el.id}
onSelect={() => setSelectedId(el.id)}
onDelete={() => deleteElement(el.id)}
onRemoveOnly={() => removeElement(el.id)}
/>
))}
<SortableContext items={elements.map(e => e.id)} strategy={verticalListSortingStrategy}>
{elements.map(el => (
<CanvasElement
key={el.id}
element={el}
isSelected={selectedId === el.id}
onSelect={() => setSelectedId(el.id)}
onDelete={() => deleteElement(el.id)}
onRemoveOnly={() => removeElement(el.id)}
/>
))}
</SortableContext>
</div>
)}
</>
@ -238,8 +227,8 @@ export const TypeBuilderContent: React.FC<{
<SelectValue placeholder="Select a primitive type" />
</SelectTrigger>
<SelectContent>
{primitivePaletteItems.map(p => (
<SelectItem key={p.id} value={p.type}>{p.name}</SelectItem>
{availableTypes.filter(t => t.kind === 'primitive').map(p => (
<SelectItem key={p.id} value={p.name}>{p.name.charAt(0).toUpperCase() + p.name.slice(1)}</SelectItem>
))}
</SelectContent>
</Select>
@ -329,6 +318,16 @@ export const TypeBuilderContent: React.FC<{
<p className="text-[10px] text-muted-foreground">The initial value for this field.</p>
</div>
<div className="space-y-2">
<Label>Group (Rendering)</Label>
<Input
value={selectedElement.group || ''}
onChange={e => updateSelectedElement({ group: e.target.value })}
placeholder="e.g. Content, Advanced, SEO"
/>
<p className="text-[10px] text-muted-foreground">Used to group fields together during visual rendering.</p>
</div>
{resolvePrimitiveType(selectedElement.type, types) === 'array' && (
<div className="pt-4 border-t">
<h4 className="text-xs font-semibold mb-3">Array Configuration</h4>

View File

@ -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 (
<div
ref={setNodeRef}
style={style}
onClick={(e) => { 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'}`}
>
<div className="flex items-center gap-2">
<div {...attributes} {...listeners} className="cursor-grab hover:bg-muted p-1 rounded -ml-1">
<GripVertical className="h-4 w-4 text-muted-foreground opacity-50 hover:opacity-100" />
</div>
<Icon className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{element.title || element.name}</span>
<span className="text-xs text-muted-foreground">{element.type}</span>
<span className="text-xs text-muted-foreground">
{element.type}
{element.group && <span className="ml-2 px-1.5 py-0.5 rounded-full bg-secondary text-[10px] text-secondary-foreground">{element.group}</span>}
</span>
</div>
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>

View File

@ -10,6 +10,7 @@ export interface BuilderElement {
refId?: string;
required?: boolean;
defaultValue?: any;
group?: string;
}
export type BuilderMode = 'structure' | 'alias' | 'enum' | 'flags';

View File

@ -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':

View File

@ -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