types:arrays,flags,enums
This commit is contained in:
parent
063e56036a
commit
a9856fa322
@ -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">
|
||||
|
||||
@ -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') {
|
||||
|
||||
353
packages/ui/src/modules/types/TypeCategoryTree.tsx
Normal file
353
packages/ui/src/modules/types/TypeCategoryTree.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -10,6 +10,7 @@ export interface BuilderElement {
|
||||
refId?: string;
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export type BuilderMode = 'structure' | 'alias' | 'enum' | 'flags';
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user