- {containers.map(c => renderContainer(c, 0))}
+ {containers.map(c => renderContainer(c, 0, layoutId))}
);
};
diff --git a/packages/ui/src/components/sidebar/TableOfContentsList.tsx b/packages/ui/src/components/sidebar/TableOfContentsList.tsx
index d490aa4f..29f49d3d 100644
--- a/packages/ui/src/components/sidebar/TableOfContentsList.tsx
+++ b/packages/ui/src/components/sidebar/TableOfContentsList.tsx
@@ -62,7 +62,7 @@ function TocItemRenderer({
React.useEffect(() => {
if (isActive && itemRef.current) {
- itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ // itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isActive]);
diff --git a/packages/ui/src/components/types/TypeBuilder.tsx b/packages/ui/src/components/types/TypeBuilder.tsx
index be31fb14..4e50c5d0 100644
--- a/packages/ui/src/components/types/TypeBuilder.tsx
+++ b/packages/ui/src/components/types/TypeBuilder.tsx
@@ -34,13 +34,7 @@ export interface BuilderElement {
uiSchema?: any;
}
-const PALETTE_ITEMS: BuilderElement[] = [
- { id: 'p-string', type: 'string', name: 'String', title: 'New String' },
- { id: 'p-number', type: 'number', name: 'Number', title: 'New Number' },
- { id: 'p-boolean', type: 'boolean', name: 'Boolean', title: 'New Boolean' },
- { id: 'p-object', type: 'object', name: 'Object', title: 'New Object' },
- { id: 'p-array', type: 'array', name: 'Array', title: 'New Array' },
-];
+
const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
@@ -81,8 +75,8 @@ const CanvasElement = ({
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Check if this is a primitive type or a custom type
- const primitiveTypes = ['string', 'number', 'boolean', 'array', 'object'];
- const isPrimitive = primitiveTypes.includes(element.type);
+ const primitiveTypes = ['string', 'number', 'int', 'float', 'boolean', 'bool', 'array', 'object'];
+ const isPrimitive = primitiveTypes.includes(element.type.toLowerCase());
return (
{isPrimitive ? (
- <>This will remove the field "{element.title || element.name}" from the structure and delete it from the database. This action cannot be undone.>
+ <>This will remove the field "{element.title || element.name}" from the structure.>
) : (
<>
Choose how to remove the field "{element.title || element.name}":
@@ -128,28 +122,42 @@ const CanvasElement = ({
Cancel
- {!isPrimitive && onRemoveOnly && (
+ {isPrimitive ? (
{
e.stopPropagation();
- onRemoveOnly();
+ if (onRemoveOnly) onRemoveOnly();
setShowDeleteDialog(false);
}}
- className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
>
- Remove Only
+ Remove
+ ) : (
+ <>
+ {onRemoveOnly && (
+ {
+ e.stopPropagation();
+ onRemoveOnly();
+ setShowDeleteDialog(false);
+ }}
+ className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
+ >
+ Remove Only
+
+ )}
+ {
+ e.stopPropagation();
+ onDelete();
+ setShowDeleteDialog(false);
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Delete in Database
+
+ >
)}
- {
- e.stopPropagation();
- onDelete();
- setShowDeleteDialog(false);
- }}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- Delete in Database
-
@@ -158,9 +166,12 @@ const CanvasElement = ({
};
function getIconForType(type: string) {
- switch (type) {
+ switch (type.toLowerCase()) {
case 'string': return TypeIcon;
+ case 'int':
+ case 'float':
case 'number': return Hash;
+ case 'bool':
case 'boolean': return ToggleLeft;
case 'object': return Box;
case 'array': return List;
@@ -210,9 +221,31 @@ 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 !== 'field') // Exclude field types from palette
+ .filter(t => t.kind !== 'primitive' && t.kind !== 'field') // Exclude primitives (handled above) and fields
.map(t => ({
id: `type-${t.id}`,
type: t.name, // Use name as type reference for now? Or ID? ID is better for strictness, Name for display.
@@ -236,13 +269,17 @@ const TypeBuilderContent: React.FC<{
Palette
-
+
Primitives
- {PALETTE_ITEMS.map(item => (
-
- ))}
+ {primitivePaletteItems.length > 0 ? (
+ primitivePaletteItems.map(item => (
+
+ ))
+ ) : (
+
No primitive types found.
+ )}
@@ -263,9 +300,8 @@ const TypeBuilderContent: React.FC<{
- Builder
{ setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
-
+
Structure
Single Type
@@ -278,7 +314,7 @@ const TypeBuilderContent: React.FC<{
-
+
{isOver && (
)}
@@ -342,31 +378,27 @@ const TypeBuilderContent: React.FC<{
{
- if (elements.length > 0) {
- const updated = { ...elements[0], type: val, name: 'value', title: val + ' Alias' };
- setElements([updated]);
- setSelectedId(updated.id);
- } else {
- // Create new if empty? Or just wait for drag?
- // Let's allow creating via select if empty
- const newItemId = `field-${Date.now()}`;
- const newItem: BuilderElement = {
- id: newItemId,
- type: val,
- name: 'value',
- title: val + ' Alias',
- uiSchema: {}
- };
- setElements([newItem]);
- setSelectedId(newItemId);
- }
+ const foundType = availableTypes.find(t => t.name === val);
+ if (!foundType) return;
+
+ const newItemId = `field-${Date.now()}`;
+ const newItem: BuilderElement = {
+ id: elements.length > 0 ? elements[0].id : newItemId,
+ type: val,
+ name: 'value',
+ title: val + ' Alias',
+ uiSchema: {},
+ ...(foundType && { refId: foundType.id } as any)
+ };
+ setElements([newItem]);
+ setSelectedId(newItem.id);
}}
>
- {PALETTE_ITEMS.map(p => (
+ {primitivePaletteItems.map(p => (
{p.name}
))}
@@ -464,12 +496,12 @@ const TypeBuilderContent: React.FC<{
);
};
-export const TypeBuilder: React.FC<{
+export const TypeBuilder = React.forwardRef void,
onCancel: () => void,
availableTypes: TypeDefinition[],
initialData?: BuilderOutput
-}> = ({ onSave, onCancel, availableTypes, initialData }) => {
+}>(({ onSave, onCancel, availableTypes, initialData }, ref) => {
const [mode, setMode] = useState(initialData?.mode || 'structure');
const [elements, setElements] = useState(initialData?.elements || []);
const [selectedId, setSelectedId] = useState(null);
@@ -503,11 +535,11 @@ export const TypeBuilder: React.FC<{
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
// Determine the refId for this element
- // If template already has refId (custom type from palette), use it
- // Otherwise, look up primitive type by mapped name
+ // If template already has refId (custom type from palette or primitive from DB), use it
let refId = (template as any).refId;
+
+ // Fallback for legacy palette items (shouldn't be hit now, but good for safety)
if (!refId) {
- // Map JSON Schema type names to database primitive type names
const typeNameMap: Record = {
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
};
@@ -554,6 +586,16 @@ export const TypeBuilder: React.FC<{
if (selectedId === id) setSelectedId(null);
};
+ React.useImperativeHandle(ref, () => ({
+ triggerSave: () => {
+ if (!typeName.trim()) {
+ // Maybe validate here or show error
+ return;
+ }
+ onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete });
+ }
+ }));
+
return (
);
-};
+});
+
+export interface TypeBuilderRef {
+ triggerSave: () => void;
+}
diff --git a/packages/ui/src/components/types/TypeEditorActions.tsx b/packages/ui/src/components/types/TypeEditorActions.tsx
new file mode 100644
index 00000000..6f53a3be
--- /dev/null
+++ b/packages/ui/src/components/types/TypeEditorActions.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { useActions } from '@/actions/useActions';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+interface TypeEditorActionsProps {
+ actionIds?: string[];
+ className?: string;
+}
+
+export const TypeEditorActions: React.FC = ({
+ actionIds = ['types.new', 'types.edit.visual', 'types.preview.toggle', 'types.delete', 'types.save'],
+ className
+}) => {
+ const { actions, executeAction } = useActions();
+
+ const renderActionButton = (actionId: string) => {
+ const action = actions[actionId];
+ if (!action) return null;
+
+ // Respect visible property if present, default to true
+ if (action.visible === false) return null;
+
+ const Icon = action.icon;
+
+ // Determine variant based on metadata or conventions
+ let variant: "default" | "destructive" | "outline" | "secondary" | "ghost" = "outline";
+
+ if (actionId === 'types.save') variant = 'default';
+ if (actionId === 'types.delete') variant = 'destructive';
+ if (actionId === 'types.cancel') variant = 'ghost';
+ if (action.metadata?.variant) variant = action.metadata.variant;
+ if (action.metadata?.active) variant = 'secondary'; // Or default?
+
+ return (
+ executeAction(actionId)}
+ disabled={action.disabled}
+ className={cn("gap-2", action.metadata?.className)}
+ title={action.tooltip}
+ >
+ {Icon && }
+ {action.label}
+
+ );
+ };
+
+ return (
+
+ {actionIds.map(id => renderActionButton(id))}
+
+ );
+};
diff --git a/packages/ui/src/components/types/TypeRenderer.tsx b/packages/ui/src/components/types/TypeRenderer.tsx
index a0f19e50..ab6c0204 100644
--- a/packages/ui/src/components/types/TypeRenderer.tsx
+++ b/packages/ui/src/components/types/TypeRenderer.tsx
@@ -1,9 +1,9 @@
-import React, { useState, useMemo } from 'react';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import React, { useState, useMemo, useImperativeHandle, forwardRef } from 'react';
+import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import { RefreshCw, Save, Trash2, Play } from 'lucide-react';
+import { RefreshCw } from 'lucide-react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { customWidgets, customTemplates } from './RJSFTemplates';
@@ -11,21 +11,22 @@ import { generateRandomData } from './randomDataGenerator';
import { TypeDefinition } from './db';
import { toast } from 'sonner';
+export interface TypeRendererRef {
+ triggerSave: () => Promise;
+ triggerPreview: () => void;
+}
+
interface TypeRendererProps {
editedType: TypeDefinition;
types: TypeDefinition[];
onSave: (jsonSchema: string, uiSchema: string) => Promise;
- onDelete: () => Promise;
- onVisualEdit: () => void;
}
-export const TypeRenderer: React.FC = ({
+export const TypeRenderer = forwardRef(({
editedType,
types,
onSave,
- onDelete,
- onVisualEdit
-}) => {
+}, ref) => {
const [jsonSchemaString, setJsonSchemaString] = useState('{}');
const [uiSchemaString, setUiSchemaString] = useState('{}');
const [showPreview, setShowPreview] = useState(false);
@@ -184,13 +185,16 @@ export const TypeRenderer: React.FC = ({
await onSave(jsonSchemaString, uiSchemaString);
};
- const handlePreviewToggle = () => {
- if (!showPreview && editedType?.json_schema) {
- const randomData = generateRandomData(previewSchema);
- setPreviewFormData(randomData);
+ useImperativeHandle(ref, () => ({
+ triggerSave: handleSave,
+ triggerPreview: () => {
+ if (!showPreview && editedType?.json_schema) {
+ const randomData = generateRandomData(previewSchema);
+ setPreviewFormData(randomData);
+ }
+ setShowPreview(prev => !prev);
}
- setShowPreview(!showPreview);
- };
+ }));
const handleRegenerateData = () => {
if (previewSchema) {
@@ -214,34 +218,6 @@ export const TypeRenderer: React.FC = ({
{editedType.description || "No description"}
-
-
-
- Visual Edit
-
- {editedType.kind === 'structure' && (
-
-
- {showPreview ? 'Hide' : 'Preview'}
-
- )}
-
-
- Delete
-
-
-
- Save Changes
-
-
@@ -341,6 +317,6 @@ export const TypeRenderer: React.FC = ({
>
);
-};
+});
export default TypeRenderer;
diff --git a/packages/ui/src/components/types/TypesEditor.tsx b/packages/ui/src/components/types/TypesEditor.tsx
new file mode 100644
index 00000000..a12f39b5
--- /dev/null
+++ b/packages/ui/src/components/types/TypesEditor.tsx
@@ -0,0 +1,349 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { TypeDefinition, updateType, createType } from './db';
+import { Card } from '@/components/ui/card';
+import { toast } from "sonner";
+import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef } from './TypeBuilder';
+import { TypeRenderer, TypeRendererRef } from './TypeRenderer';
+import { RefreshCw, Save, Trash2, X, Play } from "lucide-react";
+import { useActions } from '@/actions/useActions';
+import { Action } from '@/actions/types';
+
+export interface TypesEditorProps {
+ types: TypeDefinition[];
+ selectedType: TypeDefinition | null;
+ isBuilding: boolean;
+ onIsBuildingChange: (isBuilding: boolean) => void;
+ onSave: () => void; // Callback to reload types
+ onDeleteRaw: (id: string) => Promise; // Renamed to clarify it's the raw delete function
+}
+
+export const TypesEditor: React.FC = ({
+ types,
+ selectedType,
+ isBuilding,
+ onIsBuildingChange,
+ onSave,
+ onDeleteRaw
+}) => {
+ const [builderInitialData, setBuilderInitialData] = useState(undefined);
+ const rendererRef = useRef(null);
+ const builderRef = useRef(null);
+ const { registerAction, updateAction, unregisterAction } = useActions();
+
+ const handleEditVisual = useCallback(() => {
+ if (!selectedType) return;
+
+ // Convert current type to builder format
+ const builderData: BuilderOutput = {
+ mode: (selectedType.kind === 'structure' || selectedType.kind === 'alias') ? selectedType.kind : 'structure',
+ name: selectedType.name,
+ description: selectedType.description || '',
+ elements: []
+ };
+
+ // 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 || ''
+ } as BuilderElement;
+ });
+ }
+
+ setBuilderInitialData(builderData);
+ onIsBuildingChange(true);
+ }, [selectedType, types, onIsBuildingChange]);
+
+ const getBuilderData = useCallback(() => {
+ if (!selectedType) return undefined;
+ // Convert current type to builder format
+ const builderData: BuilderOutput = {
+ mode: (selectedType.kind === 'structure' || selectedType.kind === 'alias') ? selectedType.kind : 'structure',
+ name: selectedType.name,
+ description: selectedType.description || '',
+ elements: []
+ };
+
+ // 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 || ''
+ } as BuilderElement;
+ });
+ }
+ return builderData;
+ }, [selectedType, types]);
+
+ // Handler for Renderer Save
+ const handleRendererSave = useCallback(async (jsonSchemaString: string, uiSchemaString: string) => {
+ if (!selectedType) return;
+ try {
+ const jsonSchema = JSON.parse(jsonSchemaString);
+ const uiSchema = JSON.parse(uiSchemaString);
+ await updateType(selectedType.id, {
+ json_schema: jsonSchema,
+ meta: { ...selectedType.meta, uiSchema }
+ });
+ toast.success("Type updated");
+ onSave();
+ } catch (e) {
+ console.error(e);
+ toast.error("Failed to update type");
+ }
+ }, [selectedType, onSave]);
+
+ // Handler for Builder Save
+ const handleBuilderSave = useCallback(async (output: BuilderOutput) => {
+ if (selectedType) {
+ // Editing existing type
+ try {
+ // For structures, we need to update structure_fields
+ if (output.mode === 'structure') {
+ // Create/update field types for each element
+ const fieldUpdates = await Promise.all(output.elements.map(async (el) => {
+ // Find or create the field type
+ let fieldType = types.find(t => t.name === `${selectedType.name}.${el.name}` && t.kind === 'field');
+
+ // Find the parent type for this field (could be primitive or custom)
+ const parentType = (el as any).refId
+ ? types.find(t => t.id === (el as any).refId)
+ : types.find(t => t.name === el.type);
+
+ if (!parentType) {
+ console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
+ return null;
+ }
+
+ const fieldTypeData = {
+ name: `${selectedType.name}.${el.name}`,
+ kind: 'field' as const,
+ description: el.description || `Field ${el.name}`,
+ parent_type_id: parentType.id,
+ meta: {}
+ };
+
+ if (fieldType) {
+ // Update existing field type
+ await updateType(fieldType.id, fieldTypeData);
+ return { ...fieldType, ...fieldTypeData };
+ } else {
+ // Create new field type
+ const newFieldType = await createType(fieldTypeData as any);
+ return newFieldType;
+ }
+ }));
+
+ // Update the structure with new structure_fields
+ // Filter nulls strictly
+ const validFieldTypes = fieldUpdates.filter((f): f is TypeDefinition => f !== null && f !== undefined);
+
+ const structureFields = output.elements.map((el, idx) => ({
+ field_name: el.name,
+ field_type_id: validFieldTypes[idx]?.id || '',
+ required: false,
+ order: idx
+ }));
+
+ await updateType(selectedType.id, {
+ name: output.name,
+ description: output.description,
+ structure_fields: structureFields
+ });
+ } else {
+ // Update non-structure types
+ await updateType(selectedType.id, {
+ name: output.name,
+ description: output.description,
+ });
+ }
+
+ toast.success("Type updated");
+ setBuilderInitialData(undefined);
+ onIsBuildingChange(false);
+ onSave();
+ } catch (error) {
+ console.error("Failed to update type", error);
+ toast.error("Failed to update type");
+ }
+ } else {
+ // Creating new type
+ try {
+ const newType: Partial = {
+ name: output.name,
+ description: output.description,
+ kind: output.mode,
+ };
+
+ // For structures, create field types first
+ if (output.mode === 'structure') {
+ const fieldTypes = await Promise.all(output.elements.map(async (el) => {
+ const parentType = (el as any).refId
+ ? types.find(t => t.id === (el as any).refId)
+ : types.find(t => t.name === el.type);
+
+ if (!parentType) {
+ throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
+ }
+
+ return await createType({
+ name: `${output.name}.${el.name}`,
+ kind: 'field',
+ description: el.description || `Field ${el.name}`,
+ parent_type_id: parentType.id,
+ meta: {}
+ } as any);
+ }));
+
+ newType.structure_fields = output.elements.map((el, idx) => ({
+ field_name: el.name,
+ field_type_id: fieldTypes[idx].id,
+ required: false,
+ order: idx
+ }));
+ }
+
+ await createType(newType as any);
+ toast.success("Type created successfully");
+ setBuilderInitialData(undefined);
+ onIsBuildingChange(false);
+ onSave();
+ } catch (error) {
+ console.error("Failed to create type", error);
+ toast.error("Failed to create type");
+ }
+ }
+ }, [selectedType, types, onIsBuildingChange, onSave]);
+
+ // Register Actions
+ useEffect(() => {
+ const actionsDef: Action[] = [
+ {
+ id: 'types.save',
+ label: 'Save Changes',
+ icon: Save,
+ group: 'types',
+ handler: async () => {
+ if (isBuilding) {
+ builderRef.current?.triggerSave();
+ } else {
+ rendererRef.current?.triggerSave();
+ }
+ },
+ shortcut: 'mod+s'
+ },
+ {
+ id: 'types.delete',
+ label: 'Delete',
+ icon: Trash2,
+ group: 'types',
+ handler: async () => {
+ if (selectedType && confirm("Are you sure you want to delete this type?")) {
+ try {
+ await onDeleteRaw(selectedType.id);
+ toast.success("Type deleted");
+ onSave(); // Reload
+ } catch (e) {
+ console.error(e);
+ toast.error("Failed to delete type");
+ }
+ }
+ }
+ },
+ {
+ id: 'types.cancel',
+ label: 'Cancel',
+ icon: X,
+ group: 'types',
+ handler: () => {
+ onIsBuildingChange(false);
+ }
+ },
+ {
+ id: 'types.edit.visual',
+ label: 'Visual Edit',
+ icon: RefreshCw,
+ group: 'types',
+ handler: handleEditVisual
+ },
+ {
+ id: 'types.preview.toggle',
+ label: 'Preview',
+ icon: Play,
+ group: 'types',
+ handler: () => {
+ rendererRef.current?.triggerPreview();
+ },
+ metadata: { active: false }
+ }
+ ];
+
+ actionsDef.forEach(registerAction);
+
+ return () => {
+ actionsDef.forEach(a => unregisterAction(a.id));
+ };
+ }, [registerAction, unregisterAction, isBuilding, selectedType, onSave, onDeleteRaw, handleEditVisual]);
+
+ // Update action states/visibility
+ useEffect(() => {
+ updateAction('types.save', { disabled: (!selectedType && !isBuilding) });
+ updateAction('types.delete', { disabled: !selectedType, visible: !isBuilding });
+ updateAction('types.edit.visual', {
+ visible: !isBuilding && selectedType?.kind === 'structure',
+ disabled: false
+ });
+ updateAction('types.cancel', { visible: isBuilding });
+ updateAction('types.preview.toggle', {
+ visible: !isBuilding && selectedType?.kind === 'structure'
+ });
+ }, [selectedType, isBuilding, updateAction]);
+
+ if (isBuilding) {
+ return (
+
+ {
+ onIsBuildingChange(false);
+ setBuilderInitialData(undefined);
+ }}
+ availableTypes={types}
+ initialData={builderInitialData || getBuilderData()}
+ />
+
+ );
+ }
+
+ return (
+
+ {selectedType ? (
+
+ ) : (
+
+
+
+
+
No Type Selected
+
Select a type from the sidebar to view its details, edit the schema, and preview the generated form.
+
+ )}
+
+ );
+};
diff --git a/packages/ui/src/components/types/TypesList.tsx b/packages/ui/src/components/types/TypesList.tsx
new file mode 100644
index 00000000..80141b89
--- /dev/null
+++ b/packages/ui/src/components/types/TypesList.tsx
@@ -0,0 +1,77 @@
+import React, { useMemo } from 'react';
+import { TypeDefinition } from './db';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+interface TypesListProps {
+ types: TypeDefinition[];
+ selectedTypeId: string | null;
+ onSelect: (type: TypeDefinition) => void;
+ className?: string;
+ onDelete?: (typeId: string) => void; // Optional if we want to support delete from list context
+}
+
+export const TypesList: React.FC = ({
+ types,
+ selectedTypeId,
+ 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 (
+
+
+ Available Types
+
+
+
+
+ {kindOrder.map(kind => {
+ const group = groupedTypes[kind];
+ if (!group || group.length === 0) return null;
+
+ return (
+
+
+ {kind}
+
+
+ {group.map(t => (
+
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'
+ }`}
+ >
+ {t.name}
+ {selectedTypeId === t.id && (
+
+ )}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/types/TypesPlayground.tsx b/packages/ui/src/components/types/TypesPlayground.tsx
index 3fc29d7c..c7110733 100644
--- a/packages/ui/src/components/types/TypesPlayground.tsx
+++ b/packages/ui/src/components/types/TypesPlayground.tsx
@@ -1,30 +1,31 @@
-import React, { useEffect, useState, useMemo } from 'react';
-import { fetchTypes, updateType, createType, deleteType, TypeDefinition } from './db';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Button } from '@/components/ui/button';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { Loader2, Plus, RefreshCw } from "lucide-react";
+import React, { useEffect, useState } from 'react';
+import { fetchTypes, deleteType, TypeDefinition } from './db';
+import { Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
-import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode } from './TypeBuilder';
-import TypeRenderer from './TypeRenderer';
+import { TypesList } from './TypesList';
+import { TypesEditor } from './TypesEditor';
+import { useActions } from '@/actions/useActions';
+import { Action } from '@/actions/types';
+import { TypeEditorActions } from './TypeEditorActions';
const TypesPlayground: React.FC = () => {
const [types, setTypes] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedTypeId, setSelectedTypeId] = useState(null);
- const [editedType, setEditedType] = useState(null);
const [isBuilding, setIsBuilding] = useState(false);
- const [builderInitialData, setBuilderInitialData] = useState(undefined);
- const loadTypes = async () => {
+ const { registerAction, updateAction, unregisterAction } = useActions();
+
+ const loadTypes = React.useCallback(async () => {
setLoading(true);
try {
const data = await fetchTypes();
- console.log('types', data);
+ // console.log('types', data);
setTypes(data);
if (selectedTypeId) {
- const refreshed = data.find(t => t.id === selectedTypeId);
- if (refreshed) selectType(refreshed);
+ // Ensure selection is valid
+ const exists = data.find(t => t.id === selectedTypeId);
+ if (!exists) setSelectedTypeId(null);
}
} catch (error) {
console.error("Failed to fetch types", error);
@@ -32,202 +33,72 @@ const TypesPlayground: React.FC = () => {
} finally {
setLoading(false);
}
- };
+ }, [selectedTypeId]);
useEffect(() => {
loadTypes();
+ }, [loadTypes]);
+
+ const handleCreateNew = React.useCallback(() => {
+ setSelectedTypeId(null);
+ setIsBuilding(true);
}, []);
- const selectType = (t: TypeDefinition) => {
+ const handleSelectType = React.useCallback((t: TypeDefinition) => {
setIsBuilding(false);
setSelectedTypeId(t.id);
- setEditedType(t);
- };
+ }, []);
- const handleCreateNew = () => {
- setSelectedTypeId(null);
- setEditedType(null);
- setBuilderInitialData(undefined);
- setIsBuilding(true);
- };
-
- const handleEditVisual = () => {
- if (!editedType) return;
-
- // Convert current type to builder format
- const builderData: BuilderOutput = {
- mode: editedType.kind as BuilderMode,
- name: editedType.name,
- description: editedType.description || '',
- elements: []
+ // Register "New Type" action
+ useEffect(() => {
+ const action: Action = {
+ id: 'types.new',
+ label: 'New Type',
+ icon: Plus,
+ group: 'types',
+ handler: handleCreateNew,
+ shortcut: 'mod+n' // Or something else
};
- // For structures, convert structure_fields to builder elements
- if (editedType.kind === 'structure' && editedType.structure_fields) {
- builderData.elements = editedType.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 || ''
- } as BuilderElement;
- });
- }
+ registerAction(action);
- setBuilderInitialData(builderData);
- setIsBuilding(true);
- };
+ return () => unregisterAction('types.new');
+ }, [registerAction, unregisterAction]);
- const handleBuilderSave = async (output: BuilderOutput) => {
- console.log('Builder output:', output);
+ // Update "New Type" state
+ useEffect(() => {
+ updateAction('types.new', {
+ disabled: isBuilding || loading,
+ visible: !isBuilding // Hide when building to keep toolbar clean? Or just disable?
+ // User requested "Gray out buttons", so maybe just disabled.
+ // But if we hide it, we make space for other actions.
+ // Let's decide to keep it visible but disabled if building, or hide it if we want to focus on editor actions.
+ // Logic in TypesEditor hides some actions.
+ // Let's hide 'types.new' when building to match 'cancel' appearing.
+ });
+ }, [isBuilding, loading, updateAction]);
- if (builderInitialData) {
- // Editing existing type
- if (!editedType) return;
-
- try {
- // For structures, we need to update structure_fields
- if (output.mode === 'structure') {
- // Create/update field types for each element
- const fieldUpdates = await Promise.all(output.elements.map(async (el) => {
- // Find or create the field type
- let fieldType = types.find(t => t.name === `${editedType.name}.${el.name}` && t.kind === 'field');
-
- // Find the parent type for this field (could be primitive or custom)
- // First check if element has a refId (for custom types dragged from palette)
- let parentType = (el as any).refId
- ? types.find(t => t.id === (el as any).refId)
- : types.find(t => t.name === el.type);
-
- if (!parentType) {
- console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
- return null;
- }
-
- const fieldTypeData = {
- name: `${editedType.name}.${el.name}`,
- kind: 'field' as const,
- description: el.description || `Field ${el.name}`,
- parent_type_id: parentType.id,
- meta: {}
- };
-
- if (fieldType) {
- // Update existing field type
- await updateType(fieldType.id, fieldTypeData);
- return { ...fieldType, ...fieldTypeData };
- } else {
- // Create new field type
- const newFieldType = await createType(fieldTypeData as any);
- return newFieldType;
- }
- }));
-
- // Filter out any null results
- const validFieldTypes = fieldUpdates.filter(f => f !== null);
-
- // Update the structure with new structure_fields
- const structureFields = output.elements.map((el, idx) => ({
- field_name: el.name,
- field_type_id: validFieldTypes[idx]?.id || '',
- required: false,
- order: idx
- }));
-
- await updateType(editedType.id, {
- name: output.name,
- description: output.description,
- structure_fields: structureFields
- });
- }
-
- toast.success("Type updated");
- setIsBuilding(false);
- loadTypes();
- } catch (error) {
- console.error("Failed to update type", error);
- toast.error("Failed to update type");
- }
- } else {
- // Creating new type
- try {
- const newType: Partial = {
- name: output.name,
- description: output.description,
- kind: output.mode,
- };
-
- // For structures, create field types first
- if (output.mode === 'structure') {
- const fieldTypes = await Promise.all(output.elements.map(async (el) => {
- // Find the parent type (could be primitive or custom)
- const parentType = (el as any).refId
- ? types.find(t => t.id === (el as any).refId)
- : types.find(t => t.name === el.type);
-
- if (!parentType) {
- throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
- }
-
- return await createType({
- name: `${output.name}.${el.name}`,
- kind: 'field',
- description: el.description || `Field ${el.name}`,
- parent_type_id: parentType.id,
- meta: {}
- } as any);
- }));
-
- newType.structure_fields = output.elements.map((el, idx) => ({
- field_name: el.name,
- field_type_id: fieldTypes[idx].id,
- required: false,
- order: idx
- }));
- }
-
- await createType(newType as any);
- toast.success("Type created successfully");
- setIsBuilding(false);
- loadTypes();
- } catch (error) {
- console.error("Failed to create type", error);
- toast.error("Failed to create type");
- }
- }
- };
-
- // 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 (
{/* Header */}
-
Types Playground
+
Types Editor
Manage and preview your type definitions
-
-
- New Type
-
+
{/* Main Content */}
@@ -239,111 +110,29 @@ const TypesPlayground: React.FC = () => {
) : (
{/* List Sidebar */}
-
-
- Available Types
-
-
-
-
- {kindOrder.map(kind => {
- const group = groupedTypes[kind];
- if (!group || group.length === 0) return null;
-
- return (
-
-
- {kind}
-
-
- {group.map(t => (
-
selectType(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'
- }`}
- >
- {t.name}
- {selectedTypeId === t.id && (
-
- )}
-
- ))}
-
-
- );
- })}
-
-
-
-
+
{/* Main Content Area */}
- {isBuilding ? (
-
{ setIsBuilding(false); setBuilderInitialData(undefined); if (types.length > 0 && selectedTypeId) selectType(types.find(t => t.id === selectedTypeId)!); }}
- availableTypes={types}
- initialData={builderInitialData}
- />
- ) : (
-
- {editedType ? (
- {
- try {
- const jsonSchema = JSON.parse(jsonSchemaString);
- const uiSchema = JSON.parse(uiSchemaString);
- await updateType(editedType.id, {
- json_schema: jsonSchema,
- meta: { ...editedType.meta, uiSchema }
- });
- toast.success("Type updated");
- loadTypes();
- } catch (e) {
- console.error(e);
- toast.error("Failed to update type");
- }
- }}
- onDelete={async () => {
- if (confirm("Are you sure you want to delete this type?")) {
- try {
- await deleteType(editedType.id);
- toast.success("Type deleted");
- setEditedType(null);
- setSelectedTypeId(null);
- loadTypes();
- } catch (e) {
- console.error(e);
- toast.error("Failed to delete type");
- }
- }
- }}
- onVisualEdit={handleEditVisual}
- />
- ) : (
-
-
-
-
-
No Type Selected
-
Select a type from the sidebar to view its details, edit the schema, and preview the generated form.
-
- )}
-
- )}
+ t.id === selectedTypeId) || null}
+ isBuilding={isBuilding}
+ onIsBuildingChange={setIsBuilding}
+ onSave={loadTypes}
+ onDeleteRaw={deleteType}
+ />
)}
);
-
};
export default TypesPlayground;
diff --git a/packages/ui/src/components/types/db.ts b/packages/ui/src/components/types/db.ts
index a55d3094..bdaaa74a 100644
--- a/packages/ui/src/components/types/db.ts
+++ b/packages/ui/src/components/types/db.ts
@@ -31,7 +31,7 @@ export interface TypeDefinition {
}
export const fetchTypes = async (options?: {
- kind?: TypeDefinition['kind'] | string; // Allow string for flexibility or specific enum
+ kind?: TypeDefinition['kind'] | string;
parentTypeId?: string;
visibility?: string;
}) => {
@@ -39,64 +39,39 @@ export const fetchTypes = async (options?: {
const key = `types-${JSON.stringify(options || {})}`;
return fetchWithDeduplication(key, async () => {
- let query = supabase
- .from('types')
- .select(`
- *,
- structure_fields:type_structure_fields!type_structure_fields_structure_type_id_fkey(*)
- `)
- .order('name');
+ const params = new URLSearchParams();
+ if (options?.kind) params.append('kind', options.kind);
+ if (options?.parentTypeId) params.append('parentTypeId', options.parentTypeId);
+ if (options?.visibility) params.append('visibility', options.visibility);
- if (options?.kind) {
- query = query.eq('kind', options.kind as any);
- }
+ const { data: sessionData } = await supabase.auth.getSession();
+ const token = sessionData.session?.access_token;
+ const headers: HeadersInit = {};
+ if (token) headers['Authorization'] = `Bearer ${token}`;
- if (options?.parentTypeId) {
- query = query.eq('parent_type_id', options.parentTypeId);
- }
+ const res = await fetch(`/api/types?${params.toString()}`, { headers });
+ if (!res.ok) throw new Error(`Failed to fetch types: ${res.statusText}`);
- if (options?.visibility) {
- query = query.eq('visibility', options.visibility as any);
- }
-
- const { data, error } = await query;
- if (error) throw error;
+ const data = await res.json();
return data as TypeDefinition[];
- }, 1); // 5 min cache
+ }, 1); // 5 min cache (client side)
};
export const fetchTypeById = async (id: string) => {
const key = `type-${id}`;
return fetchWithDeduplication(key, async () => {
- // We can call the API endpoint or Supabase directly.
- // Using API might yield more enriched data if the server does heavy lifting.
- // But for consistency with lib/db.ts, let's use supabase client directly or the API route?
- // lib/db.ts uses supabase client directly mostly.
- // However, the server does a nice join:
- /*
- .select(`
- *,
- enum_values:type_enum_values(*),
- flag_values:type_flag_values(*),
- structure_fields:type_structure_fields(*),
- casts_from:type_casts!from_type_id(*),
- casts_to:type_casts!to_type_id(*)
- `)
- */
- const { data, error } = await supabase
- .from('types')
- .select(`
- *,
- enum_values:type_enum_values(*),
- flag_values:type_flag_values(*),
- structure_fields:type_structure_fields(*),
- casts_from:type_casts!from_type_id(*),
- casts_to:type_casts!to_type_id(*)
- `)
- .eq('id', id)
- .single();
+ const { data: sessionData } = await supabase.auth.getSession();
+ const token = sessionData.session?.access_token;
+ const headers: HeadersInit = {};
+ if (token) headers['Authorization'] = `Bearer ${token}`;
- if (error) throw error;
+ const res = await fetch(`/api/types/${id}`, { headers });
+ if (!res.ok) {
+ if (res.status === 404) return null;
+ throw new Error(`Failed to fetch type ${id}: ${res.statusText}`);
+ }
+
+ const data = await res.json();
return data as TypeDefinition;
});
};
diff --git a/packages/ui/src/components/user-page/UserPageDetails.tsx b/packages/ui/src/components/user-page/UserPageDetails.tsx
index 514ec4b5..d257650c 100644
--- a/packages/ui/src/components/user-page/UserPageDetails.tsx
+++ b/packages/ui/src/components/user-page/UserPageDetails.tsx
@@ -7,7 +7,7 @@ import { T, translate } from "@/i18n";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { invalidateUserPageCache } from "@/lib/db";
-import { PageActions } from "@/components/PageActions";
+const PageActions = React.lazy(() => import("@/components/PageActions").then(module => ({ default: module.PageActions })));
import {
FileText, Check, X, Calendar, FolderTree, EyeOff, Plus
} from "lucide-react";
@@ -356,19 +356,21 @@ export const UserPageDetails: React.FC
= ({
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
{!isEditMode && (
- {
- onToggleEditMode();
- if (isEditMode) onWidgetRename(null);
- }}
- onPageUpdate={onPageUpdate}
- onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
- templates={templates}
- onLoadTemplate={onLoadTemplate}
- />
+ }>
+ {
+ onToggleEditMode();
+ if (isEditMode) onWidgetRename(null);
+ }}
+ onPageUpdate={onPageUpdate}
+ onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
+ templates={templates}
+ onLoadTemplate={onLoadTemplate}
+ />
+
)}
diff --git a/packages/ui/src/components/user-page/UserPageTypeFields.tsx b/packages/ui/src/components/user-page/UserPageTypeFields.tsx
index 39cff79b..86ea5cdd 100644
--- a/packages/ui/src/components/user-page/UserPageTypeFields.tsx
+++ b/packages/ui/src/components/user-page/UserPageTypeFields.tsx
@@ -4,12 +4,17 @@ import validator from '@rjsf/validator-ajv8';
import { TypeDefinition, fetchTypes } from '../types/db';
import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils';
import { customWidgets, customTemplates } from '../types/RJSFTemplates';
-import { useQuery } from '@tanstack/react-query';
-import { Loader2 } from 'lucide-react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useLayout } from '@/contexts/LayoutContext';
import { UpdatePageMetaCommand } from '@/lib/page-commands/commands';
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Accordion, AccordionContent, AccordionItem } from "@/components/ui/accordion";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Loader2, Settings, ChevronDown } from 'lucide-react';
+import { TypesEditor } from '../types/TypesEditor';
+import { TypeEditorActions } from '../types/TypeEditorActions';
interface UserPageTypeFieldsProps {
pageId: string;
@@ -77,6 +82,37 @@ export const UserPageTypeFields: React.FC