diff --git a/packages/ui/src/modules/types/Readme.md b/packages/ui/src/modules/types/Readme.md new file mode 100644 index 00000000..85038c65 --- /dev/null +++ b/packages/ui/src/modules/types/Readme.md @@ -0,0 +1,212 @@ +# Types Module + +The `types` module is the foundation of PoolyPress's high-level schema definition system. It provides an intuitive, drag-and-drop interface for users to construct complex data forms while storing the underlying data in a fully relational database and mapping to standard React JSON Schema Form (RJSF) payloads for the frontend. + +## Architecture & Data Flow + +At its core, this module is the interface between our dynamic backend types system (with primitives, structures, enums, and flags) and front-end forms. + +### Key Files and Directories +* `./TypesEditor.tsx` - The central orchestrator switching between Visual DND mode and JSON/UI Schema edit mode. Contains the complex logic bridging Flat Builder Output <-> Relational DB fields. +* `./TypeRenderer.tsx` - Raw schema editor rendering the Live Preview in the sidebar using RJSF. +* `./builder/` - Code strictly related to the Drag-and-Drop Form Builder + * `./builder/TypeBuilder.tsx` - Configures `@dnd-kit` contexts and ties drag operations into the Zustand store. + * `./builder/TypeBuilderContent.tsx` - Three-pane visual layout (Palette, Canvas, Config) powered by UI components and the drag context. + * `./builder/useStore.ts` - Local Zustand store that eliminates component prop-drilling for managing builder layout state. + +### Relational Data Structures & Caching + +The schemas defined in `src/modules/types/client-types.ts` (Frontend) and `server/src/products/serving/db/db-types.ts` (Backend) define the boundaries of the Type System. + +A single `TypeDefinition` is actually a composite of multiple relational tables: +1. `types` (The root definition: `id`, `name`, `kind`, `json_schema`, `meta`, `settings`) +2. `type_structure_fields` (When kind is 'structure': The 1-to-many joined edges connecting this structure's `id` to native child `field_type_id` rows) +3. `type_enum_values` & `type_flag_values` (For specific strict sets) + +**Backend Caching Model (`db-types.ts`)**: +Because types map the entire application schema, they must be exceptionally fast. The system heavily guards PostgreSQL by using `appCache` (a 5-minute memory cache). Inside the backend loader (`getTypeState`), all tables (`types`, `type_structure_fields`, `type_enum_values`, etc.) are pulled fully into memory arrays, manually mapped into heavily joined `typesMap` Dictionary Nodes, and preserved across requests. The client receives these pre-enriched `TypeDefinition` payloads directly via the unified GET route. +## Built-In Types vs App Level Types + +### Built-in Primitives +The base types managed in this system mirror JSON schema scalars: +* **String** (Text lines, Textareas) +* **Number/Integer** +* **Boolean** + +### Complex Formats +* **Structures / Objects**: Nested collections of fields. +* **Enums**: Hardcoded dropdowns and specific arrays of allowed values. +* **Flags**: Bitmask-style boolean representations. + +### App Level Context Pickers +PoolyPress doesn't just manage raw JSON scalar data; it handles relational metadata for the entire app. For these references, we use specific UI *Widgets*. +Instead of storing complex objects, forms store UUIDs (Strings) and display a specialized picker for them: +* **Users** +* **Groups** +* **Categories** +* **Apps/Products** +* **Images / Media** + +## How We Deal With Custom Widgets (RJSF) + +By default, RJSF generates standard HTML inputs based on the variable's type. +To override this (e.g. rendering our custom "Category Picker" rather than a raw UUID text input field), we inject a custom widget via `uiSchema`. + +```json +{ + "ui:widget": "category_picker" +} +``` + +### Widget and Template Registration +To prevent fragmented widget and template files, everything is highly consolidated: + +* **`./AppTypeWidgets.tsx`** - Contains all specialized form components. Rather than having a `userPickerWidget.tsx`, `groupPickerWidget.tsx`, etc., they are all exported from here tightly grouped. +* **`./RJSFTemplates.tsx`** - Combines custom RJSF structural overwrites (like customized submit buttons, field templates, grid layouts, ObjectField layouts) alongside compiling the `customWidgets` object registry used by the builder. +* **`./RJSFFormWrapper.tsx`** - The highest-level component that wraps a standard RJSF `
`. It statically injects the custom components (from `RJSFTemplates.tsx`) without the rest of the application having to worry about widget hydration. + +## Usage Example +If a developer needs a completely new app-level data field (e.g. a "Video Chooser"): +1. Build the specific picker logic component inside `./AppTypeWidgets.tsx`. +2. Add its text key (e.g., `video_chooser`) to the export registry in `./RJSFTemplates.tsx`. +3. In the platform builder UI, users can now assign `ui:widget: "video_chooser"` to any string field. + +## Satisfying RJSF: Tricks & Hacks + +React JSON Schema Form (RJSF) is exceptionally powerful but natively produces unstyled or Bootstrap-specific markup, and can be inflexible regarding deeply nested configuration overrides. Here is how we adapted it to the PoolyPress architecture: + +1. **Massive Template Overrides (`RJSFTemplates.tsx`)**: + Native RJSF layouts are deeply nested ``
`` blobs. We override almost every core template (`ObjectFieldTemplate`, `ArrayFieldTemplate`, `FieldTemplate`, `SubmitButton`) to map their internal node structures to our clean `shadcn/ui` Tailwind CSS components (like `Card`, standard inputs, and icon buttons). +2. **Deep UI Schema Merging (`schema-utils.ts`)**: + A generic primitive `field_type` in the database might have a default `uiSchema` (e.g., specifying a text area). However, a parent **Structure** might want to override that specific child's presentation. To accomplish this, `TypesEditor` runs a deep merge (`deepMergeUiSchema`) overriding the child's base `uiSchema` with the parent structure's nested `uiSchema[fieldName]` configuration right before handing it to the Builder or Renderer. +3. **Empty Dependency Stripping**: + RJSF's validation engine throws aggressive visual errors if it encounters half-written `required` arrays or null structural nodes. When moving between modes or generating live previews, we actively clean up unused top-level keys before passing the payload into the `` wrapper to bypass these strict standard evaluations. + +## TODOs & Future Improvements + +* **Dynamic Imports for Heavy Widgets**: Widgets like `MermaidWidget` or the `ThreeDViewer` currently increase the base bundle size. These should be refactored into dynamic `use() / React.lazy()` lazyloads specifically within `AppTypeWidgets.tsx` to fix Vite chunk-size warnings. +* **Typescript Polish**: Clean up lingering strict typing violations mapping from our internal `TypeDefinition` model to the `@rjsf/utils` library standards (notably inside `randomDataGenerator.ts` and `TypesList.tsx`). +* **Validation Pushdown**: Continue improving the logic inside `handleBuilderSave` (`TypesEditor.tsx`) to ensure top-level JSON schema validation constraints (regex, min/max limits) properly distribute down to their distinct relational `field_type` row configurations in the DB. +* **Runtime Type Safety & Codegen**: Currently, types are purely dynamic mapping to RJSF JSON schemas. Implement a pipeline to generate strict Typescript interfaces (`.d.ts`) or `zod` schemas at runtime directly from the PostgreSQL definitions to provide robust runtime/compile-time safety when utilizing app-level entity data throughout the codebase. + +--- + +## Architectural Diagrams & Internals + +The following diagrams illustrate the deep connections between the visual React Builder, our API layer, and the relational Supabase PostgreSQL database. + +### Database Schema + +The core of the system is the `types` table, which stores the definitions. Relationships between types (inheritance, composition) are modeled via auxiliary tables. + +```mermaid +erDiagram + types { + uuid id PK + string name + enum kind "primitive, enum, flags, structure, alias, field" + uuid parent_type_id FK "Inheritance / Alias Target" + string description + jsonb json_schema "JSON Schema representation" + uuid owner_id + enum visibility "public, private, custom" + jsonb meta "UI Schema, arbitrary metadata" + jsonb settings + } + + type_structure_fields { + uuid id PK + uuid structure_type_id FK "The parent Structure" + uuid field_type_id FK "The Type of the field" + string field_name + boolean required + jsonb default_value + int order + } + + type_enum_values { + uuid id PK + uuid type_id FK + string value + string label + int order + } + + type_flag_values { + uuid id PK + uuid type_id FK + string name + int bit + } + + type_casts { + uuid from_type_id FK + uuid to_type_id FK + enum cast_kind "implicit, explicit, lossy" + } + + types ||--|{ type_structure_fields : "defines fields" + types ||--|{ type_structure_fields : "used as field type" + types ||--|{ type_enum_values : "has values" + types ||--|{ type_flag_values : "has flags" + types ||--|{ type_casts : "can cast to/from" + types ||--|| types : "parent_type_id" +``` + +### Data Flow Pattern + +The system uses a synchronized Client-Server model. The visual builder constructs a schema, which is translated into API calls. The server handles persistence and guarantees referential integrity. + +```mermaid +sequenceDiagram + participant UI as TypesPlayground (React) + participant Builder as TypeBuilder + participant ClientDB as Client DB Layer (client-types.ts) + participant API as Server API + participant ServerLogic as Server Logic (db-types.ts) + participant DB as Supabase (PostgreSQL) + + Note over UI, Builder: User creates a new Structure + UI->>Builder: Opens Builder Mode + Builder->>Builder: User drags "String" to Canvas + Builder->>Builder: User names field "title" + + Builder->>UI: onSave(BuilderOutput) + + rect rgb(240, 248, 255) + Note right of UI: Structure Creation Logic + + loop For each field element + UI->>ClientDB: createType({ kind: 'field', ... }) + ClientDB->>API: POST /api/types + API->>ServerLogic: createTypeServer() + ServerLogic->>DB: INSERT into types (kind='field') + DB-->>ServerLogic: New Field Type ID + ServerLogic-->>API: Type Object + API-->>ClientDB: 200 OK + ClientDB-->>UI: New Field Type + end + + UI->>ClientDB: createType({ kind: 'structure', structure_fields: [...] }) + Note right of UI: Links previously created Field Types + ClientDB->>API: POST /api/types + end + + API->>ServerLogic: createTypeServer() + ServerLogic->>DB: INSERT into types (kind='structure') + ServerLogic->>DB: INSERT into type_structure_fields + DB-->>ServerLogic: Structure ID + ServerLogic-->>API: Structure Object + API-->>ClientDB: 200 OK + ClientDB-->>UI: Structure Type + UI->>UI: Refreshes List +``` + +### Server-Side Invalidation + +To ensure low-latency access to type definitions, the robust server-side caching strategy broadcasts invalidations to clients seamlessly. + +1. **Server Mutation**: Any Create, Update, or Delete operation triggers `flushTypeCache()`. +2. **AppCache**: `AppCache.invalidate('types')` emits an `app-update` event. +3. **SSE Stream**: The event stream manager forwards this invalidate signal to all active client connections. +4. **Client ReactQuery**: The frontend `StreamContext` receives the `cache` event (type: `types`) and forces an immediate re-fetch so the UI reflects the new schema instantly without polling. diff --git a/packages/ui/src/modules/types/TypesEditor.tsx b/packages/ui/src/modules/types/TypesEditor.tsx index e4c53e05..98cfd0f8 100644 --- a/packages/ui/src/modules/types/TypesEditor.tsx +++ b/packages/ui/src/modules/types/TypesEditor.tsx @@ -4,14 +4,23 @@ import { deepMergeUiSchema } from './schema-utils'; import { Card } from '@/components/ui/card'; import { toast } from "sonner"; import { T, translate } from '@/i18n'; -import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef, EnumValueEntry, FlagValueEntry } from './builder/TypeBuilder'; +import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef } from './builder/TypeBuilder'; import { TypeRenderer, TypeRendererRef } from './TypeRenderer'; import { RefreshCw, Save, Trash2, X, Play, Languages } from "lucide-react"; 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). */ +/** + * Translates a single `structure_fields` database record into a `BuilderElement` that the DND visual builder can understand. + * + * THE DIFFICULT PART: + * 1. UI Schema Merging: The underlying specific `field_type` may have its own `uiSchema` defaults (e.g. `{"ui:widget": "textarea"}`). + * However, the parent structural type (`structureType`) can OVERRIDE this child's `uiSchema` specifically for this structure context. + * Therefore, we must find the structure's `meta.uiSchema[field.field_name]`, and deeply merge it over the default field's `uiSchema`. + * 2. Value Type resolution: A `field_type` in the DB is actually just a relational wrapper (e.g., `Profile.email`). + * Its real type is often its `parent_type_id` (e.g., the primitive `String` type). We must traverse up to `parent_type_id` to get the core scalar or reference that the Builder expects. + */ function structureFieldToBuilderElement( field: NonNullable[number], types: TypeDefinition[], @@ -41,14 +50,15 @@ function structureFieldToBuilderElement( // 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) + ? types.find(t => t.id === fieldType.parent_type_id && t.id !== fieldType?.id) : undefined; return { id: field.id || crypto.randomUUID(), name: field.field_name, - type: valueType?.name || fieldType?.name || 'string', + type: valueType?.name || (typeof fieldType?.json_schema?.type === 'string' ? fieldType.json_schema.type : undefined) || 'string', refId: valueType?.id, + fieldTypeId: fieldType?.id, title: field.field_name, description: fieldType?.description || '', uiSchema: mergedUi, @@ -68,6 +78,20 @@ export interface TypesEditorProps { onDeleteRaw: (id: string) => Promise; // Renamed to clarify it's the raw delete function } +/** + * `TypesEditor` + * + * This component acts as the main orchestrator for viewing and editing a specific `TypeDefinition`. + * It manages the visual toggle switch between two distinct interfaces: + * - Visual Schema Editor (`TypeBuilder` + `TypeBuilderContent`): For drag-and-drop structural design. + * - JSON Schema / UI Schema Raw Editor (`TypeRenderer`): For raw schema/meta editing and preview generation. + * + * Key Responsibilities: + * 1. Bridging the gap between the relational nested row DB structures (`type_definitions`, `structure_fields`, `enum_values`, `flag_values`) + * and the flat JSON-based `BuilderOutput` expected by the drag-and-drop interface. + * 2. Managing global action injections (save, translate, preview) dependent on the current mode (`isBuilding`). + * 3. Bidirectional data hydration (DB -> Builder output) and serialization (Builder Output -> nested relational DB transactions). + */ export const TypesEditor: React.FC = ({ types, selectedType, @@ -198,6 +222,19 @@ export const TypesEditor: React.FC = ({ }, [selectedType, onSave]); // Handler for Builder Save + /** + * Serializes the `BuilderOutput` back into complex relational DB updates (`updateType`/`createType`). + * + * THE DIFFICULT PART - Structure Mode: + * When saving a 'structure', elements in the builder don't correspond to a single DB row. + * Each element requires a UNIQUE nested `field_type` row (e.g., `User.Age`) which acts as the wrapper connecting + * the root structure (`User`) to a generic primitive scalar parent type (`Integer`). + * + * If an element's type was changed in the Builder (e.g., String -> Email), the parent_type_id of the intermediate + * `field_type` row is updated to point to the new scalar base. + * Furthermore, any custom constraint arrays defined top-level by the form (like `.required`) must be unpacked + * and pushed down to the specific `structure_fields.required` booleans to guarantee DB synchronization. + */ const handleBuilderSave = useCallback(async (output: BuilderOutput) => { if (selectedType) { // Editing existing type @@ -226,8 +263,7 @@ export const TypesEditor: React.FC = ({ flag_values: flagValues }); } else if (output.mode === 'structure') { - // Create/update field types for each element - const fieldUpdates = await Promise.all(output.elements.map(async (el) => { + const nestedFields = output.elements.map((el, idx) => { let fieldType = types.find(t => t.name === `${selectedType.name}.${el.name}` && t.kind === 'field'); const parentType = (el as any).refId @@ -241,47 +277,78 @@ export const TypesEditor: React.FC = ({ return null; } - const fieldTypeData: any = { - name: `${selectedType.name}.${el.name}`, - kind: 'field' as const, + const fieldMeta: any = { ...fieldType?.meta, uiSchema: el.uiSchema || {}, ...(el.defaultValue !== undefined && el.defaultValue !== '' ? { default: el.defaultValue } : {}) }; + if (el.defaultValue === undefined || el.defaultValue === '') { + delete fieldMeta.default; + } + + return { + id: fieldType?.id, + name: `${output.name}.${el.name}`, + field_name: el.name, + kind: 'field', description: el.description || `Field ${el.name}`, parent_type_id: parentType.id, - meta: { ...fieldType?.meta, uiSchema: el.uiSchema || {}, ...(el.defaultValue !== undefined && el.defaultValue !== '' ? { default: el.defaultValue } : {}) }, + meta: fieldMeta, settings: { ...fieldType?.settings, ...(el.itemsTypeId ? { items_type_id: el.itemsTypeId } : {}), ...(el.defaultValue !== undefined && el.defaultValue !== '' ? { default_value: el.defaultValue } : { default_value: null }), ...(el.group ? { group: el.group } : { group: null }) - } + }, + required: el.required || false, + default_value: el.defaultValue !== undefined && el.defaultValue !== '' ? el.defaultValue : null, + order: idx }; - if (el.defaultValue === undefined || el.defaultValue === '') { - delete fieldTypeData.meta.default; + }).filter(Boolean); + + // Clean up json_schema + let updatedJsonSchema = selectedType.json_schema; + if (updatedJsonSchema && typeof updatedJsonSchema === 'object') { + updatedJsonSchema = { ...updatedJsonSchema }; + if (updatedJsonSchema.properties) { + const newProps: any = {}; + output.elements.forEach(el => { + if (updatedJsonSchema.properties[el.name]) { + newProps[el.name] = updatedJsonSchema.properties[el.name]; + } + }); + updatedJsonSchema.properties = newProps; } - - if (fieldType) { - await updateType(fieldType.id, fieldTypeData); - return { ...fieldType, ...fieldTypeData }; - } else { - const newFieldType = await createType(fieldTypeData as any); - return newFieldType; + if (Array.isArray(updatedJsonSchema.required)) { + updatedJsonSchema.required = updatedJsonSchema.required.filter((req: string) => + output.elements.some(el => el.name === req) + ); } - })); + } - 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: el.required || false, - order: idx, - default_value: el.defaultValue !== undefined && el.defaultValue !== '' ? el.defaultValue : null - })); + // Clean up meta.uiSchema + let updatedMeta = selectedType.meta; + if (updatedMeta && typeof updatedMeta === 'object') { + updatedMeta = { ...updatedMeta }; + if (updatedMeta.uiSchema) { + const newUiSchema: any = {}; + for (const key in updatedMeta.uiSchema) { + if (key.startsWith('ui:')) { + newUiSchema[key] = updatedMeta.uiSchema[key]; + } + } + output.elements.forEach(el => { + if (updatedMeta.uiSchema[el.name]) { + newUiSchema[el.name] = updatedMeta.uiSchema[el.name]; + } + }); + updatedMeta.uiSchema = newUiSchema; + } + } await updateType(selectedType.id, { name: output.name, description: output.description, - structure_fields: structureFields, - meta: selectedType.meta + nested_fields: nestedFields, + meta: updatedMeta, + json_schema: updatedJsonSchema, + fieldsToDelete: output.fieldsToDelete }); } else { // Update non-structure types @@ -320,7 +387,7 @@ export const TypesEditor: React.FC = ({ bit: v.bit })); } else if (output.mode === 'structure') { - const fieldTypes = await Promise.all(output.elements.map(async (el) => { + const nestedFields = output.elements.map((el, idx) => { const parentType = (el as any).refId ? types.find(t => t.id === (el as any).refId) : types.find(t => t.name === el.type); @@ -329,24 +396,23 @@ export const TypesEditor: React.FC = ({ throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`); } - return await createType({ + return { name: `${output.name}.${el.name}`, + field_name: el.name, kind: 'field', description: el.description || `Field ${el.name}`, parent_type_id: parentType.id, meta: { uiSchema: el.uiSchema || {} }, settings: { ...(el.itemsTypeId ? { items_type_id: el.itemsTypeId } : {}) - } - } as any); - })); + }, + required: el.required || false, + default_value: el.defaultValue !== undefined && el.defaultValue !== '' ? el.defaultValue : null, + order: idx + }; + }); - newType.structure_fields = output.elements.map((el, idx) => ({ - field_name: el.name, - field_type_id: fieldTypes[idx].id, - required: el.required || false, - order: idx - })); + newType.nested_fields = nestedFields; } await createType(newType); diff --git a/packages/ui/src/modules/types/builder/TypeBuilder.tsx b/packages/ui/src/modules/types/builder/TypeBuilder.tsx index afba7040..1bef2144 100644 --- a/packages/ui/src/modules/types/builder/TypeBuilder.tsx +++ b/packages/ui/src/modules/types/builder/TypeBuilder.tsx @@ -14,6 +14,19 @@ export interface TypeBuilderRef { triggerSave: () => void; } +/** + * TypeBuilder + * + * The top-level orchestrator for the visual schema builder. + * + * Responsibilities: + * 1. Establishing the `@dnd-kit` drag-and-drop context (`DndContext` and `DragOverlay`). + * 2. Wiring drag events (start, end/drop) into the central `useBuilderStore` to manage field creation and sorting. + * 3. Initializing the global drag-and-drop state when editing an existing type or creating a new one. + * + * Note: It intentionally delegates actual visual rendering to `TypeBuilderContent` to ensure that inner hooks + * successfully resolve the context established by the wrap providers here. + */ export const TypeBuilder = React.forwardRef void, onCancel: () => void, diff --git a/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx b/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx index 09c16583..c97e9ba6 100644 --- a/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx +++ b/packages/ui/src/modules/types/builder/TypeBuilderContent.tsx @@ -21,6 +21,19 @@ import { TypeDefinition } from '../client-types'; import { FolderTree, Link2 } from 'lucide-react'; import { useBuilderStore } from './useStore'; +/** + * TypeBuilderContent + * + * This is the core visual layout and state consumer of the TypeBuilder interface. + * It is separated from the top-level `TypeBuilder` component to ensure that hooks (like `useBuilderStore` + * and `@dnd-kit` sorting/dropping context) have access to the appropriate Context Providers instantiated + * higher up in the component tree. + * + * Responsibilities: + * 1. Rendering the 3-pane layout: Left (Available Types/Palettes), Center (Drop Canvas), Right (Selected Field Details). + * 2. Consuming and mutating shared state from `useBuilderStore`. + * 3. Handling advanced field configurations (Widgets, Array Items, Array widget bindings, Enum/Flags values). + */ export const TypeBuilderContent: React.FC<{ onCancel: () => void; onSave: (data: BuilderOutput) => void; diff --git a/packages/ui/src/modules/types/builder/types.ts b/packages/ui/src/modules/types/builder/types.ts index 8de76dfc..8a2d48f1 100644 --- a/packages/ui/src/modules/types/builder/types.ts +++ b/packages/ui/src/modules/types/builder/types.ts @@ -8,6 +8,7 @@ export interface BuilderElement { uiSchema?: any; itemsTypeId?: string; refId?: string; + fieldTypeId?: string; required?: boolean; defaultValue?: any; group?: string; diff --git a/packages/ui/src/modules/types/builder/useStore.ts b/packages/ui/src/modules/types/builder/useStore.ts index 101efc75..09d7f87a 100644 --- a/packages/ui/src/modules/types/builder/useStore.ts +++ b/packages/ui/src/modules/types/builder/useStore.ts @@ -63,7 +63,7 @@ export const useBuilderStore = create((set) => ({ deleteElement: (id) => set((state) => { const el = state.elements.find(e => e.id === id); - const newFieldsToDelete = el && el.refId ? [...state.fieldsToDelete, el.refId] : state.fieldsToDelete; + const newFieldsToDelete = el && el.fieldTypeId ? [...state.fieldsToDelete, el.fieldTypeId] : state.fieldsToDelete; return { elements: state.elements.filter(e => e.id !== id), fieldsToDelete: newFieldsToDelete, diff --git a/packages/ui/src/modules/types/json-schema-import.ts b/packages/ui/src/modules/types/json-schema-import.ts new file mode 100644 index 00000000..ac294f72 --- /dev/null +++ b/packages/ui/src/modules/types/json-schema-import.ts @@ -0,0 +1,131 @@ +import { TypeDefinition, createType } from './client-types'; + +/** + * Maps a JSON Schema scalar type to the platform's primitive type name. + * Handles the `type` field, `format` hints, and falls back to `string`. + */ +export function jsonSchemaTypeToPrimitive(prop: any): string { + const t = prop?.type; + const format = prop?.format; + + if (t === 'integer') return 'int'; + if (t === 'number') return 'float'; + if (t === 'boolean') return 'bool'; + if (t === 'array') return 'array'; + if (t === 'object') return 'object'; + // string with format hints + if (t === 'string') { + if (format === 'date-time' || format === 'date') return 'string'; + return 'string'; + } + // anyOf / oneOf with null — treat as the non-null type + if (Array.isArray(prop?.anyOf) || Array.isArray(prop?.oneOf)) { + const variants: any[] = prop.anyOf || prop.oneOf; + const nonNull = variants.find((v: any) => v.type !== 'null'); + if (nonNull) return jsonSchemaTypeToPrimitive(nonNull); + } + return 'string'; // safe fallback +} + +export interface ImportResult { + /** New structure type payload ready to POST to /api/types */ + payload: Record; + /** Fields that couldn't be resolved to a known primitive (skipped) */ + unresolved: string[]; +} + +/** + * Converts a JSON Schema object into the `nested_fields` payload + * accepted by `POST /api/types` (kind: "structure"). + * + * @param schema - A JSON Schema with `type: "object"` and `properties` + * @param name - Name to give the resulting structure type + * @param types - Full list of platform types (primitives + others) + * @param description - Optional description + */ +export function jsonSchemaToStructurePayload( + schema: any, + name: string, + types: TypeDefinition[], + description?: string +): ImportResult { + const properties: Record = schema?.properties || {}; + const requiredFields: string[] = Array.isArray(schema?.required) ? schema.required : []; + const unresolved: string[] = []; + + // Build a quick lookup: primitive name → type object + const primitiveByName = new Map(); + types.forEach(t => { + if (t.kind === 'primitive') { + primitiveByName.set(t.name.toLowerCase(), t); + } + }); + + const nested_fields = Object.entries(properties) + .map(([fieldName, propSchema], idx) => { + const primitiveName = jsonSchemaTypeToPrimitive(propSchema as any); + const primitiveType = primitiveByName.get(primitiveName); + + if (!primitiveType) { + unresolved.push(fieldName); + return null; + } + + return { + name: `${name}.${fieldName}`, + field_name: fieldName, + parent_type_id: primitiveType.id, + description: (propSchema as any).description || (propSchema as any).title || `${fieldName} field`, + required: requiredFields.includes(fieldName), + default_value: (propSchema as any).default ?? null, + order: idx, + meta: { + uiSchema: {} + }, + settings: {} + }; + }) + .filter(Boolean); + + const payload = { + name, + kind: 'structure', + description: description || schema?.description || schema?.title || '', + visibility: 'public' as const, + json_schema: schema, + nested_fields + }; + + return { payload, unresolved }; +} + +/** + * One-shot helper: parse a JSON Schema string, build the payload, and POST it. + * Returns the created TypeDefinition. + */ +export async function importJsonSchemaAsStructure( + schemaJson: string, + name: string, + types: TypeDefinition[], + description?: string +): Promise<{ type: any; unresolved: string[] }> { + let schema: any; + try { + schema = JSON.parse(schemaJson); + } catch { + throw new Error('Invalid JSON — could not parse schema'); + } + + if (schema.type !== 'object' && !schema.properties) { + throw new Error('Schema must be a JSON Schema object (type: "object" with properties)'); + } + + const { payload, unresolved } = jsonSchemaToStructurePayload(schema, name, types, description); + + if ((payload.nested_fields as any[]).length === 0) { + throw new Error('No resolvable fields found in schema properties'); + } + + const created = await createType(payload); + return { type: created, unresolved }; +} diff --git a/packages/ui/src/modules/types/schema-utils.ts b/packages/ui/src/modules/types/schema-utils.ts index 019d54d1..9284c013 100644 --- a/packages/ui/src/modules/types/schema-utils.ts +++ b/packages/ui/src/modules/types/schema-utils.ts @@ -19,8 +19,14 @@ export const primitiveToJsonSchema: Record = { export const generateSchemaForType = (typeId: string, types: TypeDefinition[], visited = new Set()): any => { // Prevent infinite recursion for circular references if (visited.has(typeId)) { + const cycleType = types.find(t => t.id === typeId); + const path = Array.from(visited).map(id => types.find(t => t.id === id)?.name || id).join(' -> '); + console.warn(`[schema] Circular reference detected at type: ${cycleType?.name || typeId} (kind: ${cycleType?.kind})\nResolution path: ${path} -> ${cycleType?.name || typeId}`); return { type: 'object', description: 'Circular reference detected' }; } + + // Add to visited to catch loops through fields or aliases + visited.add(typeId); const type = types.find(t => t.id === typeId); if (!type) return { type: 'string' }; @@ -65,7 +71,6 @@ export const generateSchemaForType = (typeId: string, types: TypeDefinition[], v // If it's a structure, recursively build its schema if (type.kind === 'structure' && type.structure_fields) { - visited.add(typeId); const properties: Record = {}; const required: string[] = []; @@ -119,7 +124,13 @@ export const generateSchemaForType = (typeId: string, types: TypeDefinition[], v // Recursive function to generate UI schema for a type export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], visited = new Set()): any => { - if (visited.has(typeId)) return {}; + if (visited.has(typeId)) { + const cycleType = types.find(t => t.id === typeId); + const path = Array.from(visited).map(id => types.find(t => t.id === id)?.name || id).join(' -> '); + console.warn(`[uiSchema] Circular reference detected at type: ${cycleType?.name || typeId} (kind: ${cycleType?.kind})\nResolution path: ${path} -> ${cycleType?.name || typeId}`); + return {}; + } + visited.add(typeId); const type = types.find(t => t.id === typeId); if (!type) return {}; @@ -137,6 +148,7 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], } visited.add(typeId); + const uiSchema: Record = { 'ui:options': { orderable: false }, 'ui:classNames': 'grid grid-cols-1 gap-y-1'