types:vfs/cats/groups/posts/pages - fixes
This commit is contained in:
parent
81dae7a5c5
commit
691c10d5a8
212
packages/ui/src/modules/types/Readme.md
Normal file
212
packages/ui/src/modules/types/Readme.md
Normal file
@ -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 `<Form>`. 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 ``<fieldset>`` 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 `<Form>` 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.
|
||||
@ -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<TypeDefinition['structure_fields']>[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<void | boolean>; // 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<TypesEditorProps> = ({
|
||||
types,
|
||||
selectedType,
|
||||
@ -198,6 +222,19 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
|
||||
}, [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<TypesEditorProps> = ({
|
||||
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<TypesEditorProps> = ({
|
||||
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<TypesEditorProps> = ({
|
||||
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<TypesEditorProps> = ({
|
||||
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);
|
||||
|
||||
@ -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<TypeBuilderRef, {
|
||||
onSave: (data: BuilderOutput) => void,
|
||||
onCancel: () => void,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -8,6 +8,7 @@ export interface BuilderElement {
|
||||
uiSchema?: any;
|
||||
itemsTypeId?: string;
|
||||
refId?: string;
|
||||
fieldTypeId?: string;
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
group?: string;
|
||||
|
||||
@ -63,7 +63,7 @@ export const useBuilderStore = create<BuilderState>((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,
|
||||
|
||||
131
packages/ui/src/modules/types/json-schema-import.ts
Normal file
131
packages/ui/src/modules/types/json-schema-import.ts
Normal file
@ -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<string, any>;
|
||||
/** 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<string, any> = 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<string, TypeDefinition>();
|
||||
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 };
|
||||
}
|
||||
@ -19,8 +19,14 @@ export const primitiveToJsonSchema: Record<string, any> = {
|
||||
export const generateSchemaForType = (typeId: string, types: TypeDefinition[], visited = new Set<string>()): 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<string, any> = {};
|
||||
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<string>()): 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<string, any> = {
|
||||
'ui:options': { orderable: false },
|
||||
'ui:classNames': 'grid grid-cols-1 gap-y-1'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user