diff --git a/packages/ui/docs/products-acl.md b/packages/ui/docs/products-acl.md index 03c5574c..b4981dcc 100644 --- a/packages/ui/docs/products-acl.md +++ b/packages/ui/docs/products-acl.md @@ -116,35 +116,52 @@ This avoids splitting unified codebases into artificial "sub-products" just for ### The Schema Expansion -We augment the existing `product.settings.routes` definition to accept `groups` properties for access tiering, and `pricing` properties for exact cost allocations per endpoint. +We augment the existing `product.settings.routes` definition to accept `groups` properties for access tiering, and `pricing` properties for exact cost allocations per endpoint. For more complex cases where a single endpoint has different costs per tier, we use the `variants` array. ```json { "enabled": true, - "default_cost_units": 5, // Fallback cost if a route doesn't specify - "groups": ["Registered"], // Overall product minimum requirements (if any) + "default_cost_units": 5, "routes": [ { - "url": "/api/video/export-720p", + "url": "/api/video/export", "method": "post", - "groups": ["Free", "Pro"], // Available without strict tier requirements - "rate": 10 // Deducts 10 general system credits per execution - }, - { - "url": "/api/video/export-4k", - "method": "post", - "groups": ["Pro", "Enterprise"], // Restricted to upper tiers - "pricing": { - "provider": "stripe", // e.g. 'stripe', 'paddle', 'lemonsqueezy' - "provider_price_id": "price_1Pkx...", // The upstream gateway ID - "amount": 0.50, // Reference amount for internal display - "currency": "usd" - } + "rate": 10, // Global fallback rate for this route + "variants": [ + { + "groups": ["Pro", "Enterprise"], + "rate": 2, // Discounted rate for Pro/Enterprise + "pricing": { + "provider": "stripe", + "provider_price_id": "price_pro_export", + "amount": 0.20, + "currency": "usd" + } + }, + { + "groups": ["Registered"], + "rate": 10, + "pricing": { + "provider": "stripe", + "provider_price_id": "price_free_export", + "amount": 1.00, + "currency": "usd" + } + } + ] } ] } ``` +### Variant Selection Logic + +When a request matches a route, the middleware evaluates `variants` in order: +1. **First Match**: The first variant whose `groups` intersection with the user's `effectiveGroups` is non-empty is selected. +2. **Override**: If a variant is selected, its `rate` and `pricing` override any settings defined at the route or product level. +3. **Implicit Grant**: Matching a variant (or a route-level `groups` array) constitutes an implicit grant, bypassing the need for a separate entry in the `resource_acl` table. +4. **Fallback**: If no variant matches, the system falls back to the route's default `rate` and evaluates against the global `resource_acl` permissions. + ### Extending Pricing Resolution When an endpoint is metered or requires direct fiat payment per use, the `productAclMiddleware` or a downstream billing handler can read the `matchedRoute.pricing` or `matchedRoute.rate`. diff --git a/packages/ui/docs/type-system.md b/packages/ui/docs/type-system.md index 775feff2..52a6632e 100644 --- a/packages/ui/docs/type-system.md +++ b/packages/ui/docs/type-system.md @@ -186,7 +186,6 @@ The server enforces the schema structure. When creating a `structure`, it handle - **Replacement Strategy**: For `structure_fields`, it often performs a `DELETE` (of all existing link records for that structure) followed by an `INSERT` of the new set to ensure order and composition are exactly as requested. - **Orphan Cleanup**: Accepts `fieldsToDelete` array to clean up `field` types that were removed from the structure. -### Source Reference ### Source Reference - [server/src/products/serving/db/db-types.ts](../server/src/products/serving/db/db-types.ts) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2992eeba..a2e07f9b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -67,6 +67,7 @@ let SupportChat: any; GridSearch = React.lazy(() => import("./modules/places/gridsearch/GridSearch")); LocationDetail = React.lazy(() => import("./modules/places/LocationDetail")); +TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground")); if (enablePlaygrounds) { PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor")); @@ -78,7 +79,6 @@ if (enablePlaygrounds) { PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor")); VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCanvas")); - TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground")); VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat")); @@ -185,6 +185,8 @@ const AppWrapper = () => { Loading...}>} /> {enablePlaygrounds && Loading...}>} />} + Loading...}>} /> + {/* Playground Routes */} {enablePlaygrounds && ( <> @@ -192,7 +194,6 @@ const AppWrapper = () => { Loading...}>} /> Loading...}>} /> Loading...}>} /> - Loading...}>} /> Loading...}>} /> Loading...}>} /> Loading...}>} /> diff --git a/packages/ui/src/modules/types/RJSFTemplates.tsx b/packages/ui/src/modules/types/RJSFTemplates.tsx index 66a52566..424fb1de 100644 --- a/packages/ui/src/modules/types/RJSFTemplates.tsx +++ b/packages/ui/src/modules/types/RJSFTemplates.tsx @@ -1,274 +1,402 @@ -import React from 'react'; -import type { WidgetProps, RegistryWidgetsType } from '@rjsf/utils'; -import CollapsibleSection from '@/components/CollapsibleSection'; - -// Utility function to convert camelCase to Title Case -const formatLabel = (str: string): string => { - // Split on capital letters and join with spaces - return str - .replace(/([A-Z])/g, ' $1') // Add space before capital letters - .replace(/^./, (char) => char.toUpperCase()) // Capitalize first letter - .trim(); -}; - -// Custom TextWidget using Tailwind/shadcn styling -const TextWidget = (props: WidgetProps) => { - const { - id, - required, - readonly, - disabled, - type, - label, - value, - onChange, - onBlur, - onFocus, - autofocus, - options, - schema, - rawErrors = [], - } = props; - - const _onChange = ({ target: { value } }: React.ChangeEvent) => - onChange(value === '' ? options.emptyValue : value); - const _onBlur = ({ target: { value } }: React.FocusEvent) => - onBlur(id, value); - const _onFocus = ({ target: { value } }: React.FocusEvent) => - onFocus(id, value); - - return ( - 0 ? `${id}-error` : undefined} - /> - ); -}; - -// Custom CheckboxWidget for toggle switches -const CheckboxWidget = (props: WidgetProps) => { - const { - id, - value, - disabled, - readonly, - label, - onChange, - onBlur, - onFocus, - autofocus, - rawErrors = [], - } = props; - - // Handle both boolean and string "true"/"false" values - const isChecked = value === true || value === 'true'; - - const _onChange = ({ target: { checked } }: React.ChangeEvent) => { - // Emit boolean to satisfy z.boolean() schema - onChange(checked); - }; - - const _onBlur = () => onBlur(id, value); - const _onFocus = () => onFocus(id, value); - - return ( -
- - 0 ? `${id}-error` : undefined} - /> -
- ); -}; - -// Custom FieldTemplate -export const FieldTemplate = (props: any) => { - const { - id, - classNames, - label, - help, - required, - description, - errors, - children, - schema, - } = props; - - // Format the label to be human-readable - const formattedLabel = label ? formatLabel(label) : label; - - return ( -
-
- {formattedLabel && ( - - )} -
{children}
-
- {errors && errors.length > 0 && ( -
- {errors} -
- )} - -
- ); -}; - -// Custom ObjectFieldTemplate with Grouping Support -export const ObjectFieldTemplate = (props: any) => { - const { properties, schema, uiSchema, title, description } = props; - - // Get custom classNames from uiSchema - const customClassNames = uiSchema?.['ui:classNames'] || ''; - - // Group properties based on uiSchema - const groups: Record = {}; - const ungrouped: any[] = []; - - properties.forEach((element: any) => { - // Skip if hidden widget - if (uiSchema?.[element.name]?.['ui:widget'] === 'hidden') { - return; - } - - const groupName = uiSchema?.[element.name]?.['ui:group']; - if (groupName) { - if (!groups[groupName]) { - groups[groupName] = []; - } - groups[groupName].push(element); - } else { - ungrouped.push(element); - } - }); - - const hasGroups = Object.keys(groups).length > 0; - - if (!hasGroups) { - return ( -
- {description && (typeof description !== 'string' || description.trim()) && ( -

{description}

- )} -
- {properties.map((element: any) => ( -
- {element.content} -
- ))} -
-
- ); - } - - return ( -
- {props.description && ( -

{props.description}

- )} - - {/* Render Groups */} - {hasGroups && ( -
- {Object.entries(groups).map(([groupName, elements]) => ( - -
- {elements.map((element: any) => ( -
- {element.content} -
- ))} -
-
- ))} -
- )} - - {/* Render Ungrouped Fields */} - {ungrouped.length > 0 && ( -
- {ungrouped.map((element: any) => ( -
- {element.content} -
- ))} -
- )} -
- ); -}; - -// Custom widgets -import { ImageWidget } from '@/modules/types/ImageWidget'; - -export const customWidgets: RegistryWidgetsType = { - TextWidget, - CheckboxWidget, - ImageWidget, -}; - -// Custom templates -export const customTemplates = { - FieldTemplate, - ObjectFieldTemplate, -}; +import React from 'react'; +import type { WidgetProps, RegistryWidgetsType } from '@rjsf/utils'; +import type { ArrayFieldTemplateProps } from '@rjsf/utils'; +import CollapsibleSection from '@/components/CollapsibleSection'; + +// Utility function to convert camelCase to Title Case +const formatLabel = (str: string): string => { + // Split on capital letters and join with spaces + return str + .replace(/([A-Z])/g, ' $1') // Add space before capital letters + .replace(/^./, (char) => char.toUpperCase()) // Capitalize first letter + .trim(); +}; + +// Custom TextWidget using Tailwind/shadcn styling +const TextWidget = (props: WidgetProps) => { + const { + id, + required, + readonly, + disabled, + type, + label, + value, + onChange, + onBlur, + onFocus, + autofocus, + options, + schema, + rawErrors = [], + } = props; + + const _onChange = ({ target: { value } }: React.ChangeEvent) => + onChange(value === '' ? options.emptyValue : value); + const _onBlur = ({ target: { value } }: React.FocusEvent) => + onBlur(id, value); + const _onFocus = ({ target: { value } }: React.FocusEvent) => + onFocus(id, value); + + return ( + 0 ? `${id}-error` : undefined} + /> + ); +}; + +// Custom CheckboxWidget for toggle switches +const CheckboxWidget = (props: WidgetProps) => { + const { + id, + value, + disabled, + readonly, + label, + onChange, + onBlur, + onFocus, + autofocus, + rawErrors = [], + } = props; + + // Handle both boolean and string "true"/"false" values + const isChecked = value === true || value === 'true'; + + const _onChange = ({ target: { checked } }: React.ChangeEvent) => { + // Emit boolean to satisfy z.boolean() schema + onChange(checked); + }; + + const _onBlur = () => onBlur(id, value); + const _onFocus = () => onFocus(id, value); + + return ( +
+ + 0 ? `${id}-error` : undefined} + /> +
+ ); +}; + +// Custom FieldTemplate +export const FieldTemplate = (props: any) => { + const { + id, + classNames, + label, + help, + required, + description, + errors, + children, + schema, + } = props; + + // Format the label to be human-readable + const formattedLabel = label ? formatLabel(label) : label; + + return ( +
+
+ {formattedLabel && ( + + )} +
{children}
+
+ {errors && errors.length > 0 && ( +
+ {errors} +
+ )} + +
+ ); +}; + +// Custom ObjectFieldTemplate with Grouping Support +export const ObjectFieldTemplate = (props: any) => { + const { properties, schema, uiSchema, title, description } = props; + + // Get custom classNames from uiSchema + const customClassNames = uiSchema?.['ui:classNames'] || ''; + + // Group properties based on uiSchema + const groups: Record = {}; + const ungrouped: any[] = []; + + properties.forEach((element: any) => { + // Skip if hidden widget + if (uiSchema?.[element.name]?.['ui:widget'] === 'hidden') { + return; + } + + const groupName = uiSchema?.[element.name]?.['ui:group']; + if (groupName) { + if (!groups[groupName]) { + groups[groupName] = []; + } + groups[groupName].push(element); + } else { + ungrouped.push(element); + } + }); + + const hasGroups = Object.keys(groups).length > 0; + + if (!hasGroups) { + return ( +
+ {description && (typeof description !== 'string' || description.trim()) && ( +

{description}

+ )} +
+ {properties.map((element: any) => ( +
+ {element.content} +
+ ))} +
+
+ ); + } + + return ( +
+ {props.description && ( +

{props.description}

+ )} + + {/* Render Groups */} + {hasGroups && ( +
+ {Object.entries(groups).map(([groupName, elements]) => ( + +
+ {elements.map((element: any) => ( +
+ {element.content} +
+ ))} +
+
+ ))} +
+ )} + + {/* Render Ungrouped Fields */} + {ungrouped.length > 0 && ( +
+ {ungrouped.map((element: any) => ( +
+ {element.content} +
+ ))} +
+ )} +
+ ); +}; + +// Custom ArrayFieldTemplate for premium array management +export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => { + const { items, canAdd, onAddClick, title } = props; + + return ( +
+ {title && ( +
+ + {title} + + + {items.length} item{items.length !== 1 ? 's' : ''} + +
+ )} +
+ {items.length === 0 && ( +
+ No items yet. Click "+ Add" to create one. +
+ )} + {items.map((element, idx) => ( +
+ {element} +
+ ))} +
+ {canAdd && ( + + )} +
+ ); +}; + +// Custom widgets +import { ImageWidget } from '@/modules/types/ImageWidget'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox as RadixCheckbox } from '@/components/ui/checkbox'; + +// Radix-based SelectWidget for enum fields +const SelectWidget = (props: WidgetProps) => { + const { + id, + options, + value, + disabled, + readonly, + onChange, + } = props; + + const { enumOptions } = options; + + return ( + + ); +}; + +// Radix-based CheckboxesWidget for flags fields +const CheckboxesWidget = (props: WidgetProps) => { + const { + id, + options, + value, + disabled, + readonly, + onChange, + } = props; + + const { enumOptions } = options; + const selected: string[] = Array.isArray(value) ? value : []; + + const handleToggle = (optValue: string) => { + if (disabled || readonly) return; + const newValue = selected.includes(optValue) + ? selected.filter(v => v !== optValue) + : [...selected, optValue]; + onChange(newValue); + }; + + return ( +
+ {(enumOptions as any[])?.map((opt: any) => ( +
+ handleToggle(opt.value)} + disabled={disabled || readonly} + className="h-3.5 w-3.5" + /> + +
+ ))} +
+ ); +}; + +export const customWidgets: RegistryWidgetsType = { + TextWidget, + CheckboxWidget, + SelectWidget, + CheckboxesWidget, + ImageWidget, +}; + +// Custom templates +export const customTemplates = { + FieldTemplate, + ObjectFieldTemplate, + ArrayFieldTemplate, +}; diff --git a/packages/ui/src/modules/types/TypeBuilder.tsx b/packages/ui/src/modules/types/TypeBuilder.tsx index eeb3f51a..f60dda83 100644 --- a/packages/ui/src/modules/types/TypeBuilder.tsx +++ b/packages/ui/src/modules/types/TypeBuilder.tsx @@ -23,7 +23,7 @@ import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { GripVertical, Type as TypeIcon, Hash, ToggleLeft, Box, List, FileJson, Trash2 } from 'lucide-react'; +import { GripVertical, Type as TypeIcon, Hash, ToggleLeft, Box, List, FileJson, Trash2, Plus, Flag, ListOrdered } from 'lucide-react'; export interface BuilderElement { id: string; @@ -33,6 +33,7 @@ export interface BuilderElement { title?: string; jsonSchema?: any; uiSchema?: any; + itemsTypeId?: string; // For array fields: the type ID of each item } @@ -289,7 +290,18 @@ const WidgetPicker = ({ value, onChange, fieldType, types }: { value: string | u ); }; -export type BuilderMode = 'structure' | 'alias'; +export type BuilderMode = 'structure' | 'alias' | 'enum' | 'flags'; + +export interface EnumValueEntry { + value: string; + label: string; + order: number; +} + +export interface FlagValueEntry { + name: string; + bit: number; +} export interface BuilderOutput { mode: BuilderMode; @@ -297,6 +309,8 @@ export interface BuilderOutput { name: string; description?: string; fieldsToDelete?: string[]; // Field type IDs to delete from database + enumValues?: EnumValueEntry[]; + flagValues?: FlagValueEntry[]; } @@ -321,12 +335,16 @@ const TypeBuilderContent: React.FC<{ typeDescription: string; setTypeDescription: (d: string) => void; fieldsToDelete: string[]; - types: TypeDefinition[]; // Add types to props + types: TypeDefinition[]; + enumValues: EnumValueEntry[]; + setEnumValues: React.Dispatch>; + flagValues: FlagValueEntry[]; + setFlagValues: React.Dispatch>; }> = ({ mode, setMode, elements, setElements, selectedId, setSelectedId, onCancel, onSave, deleteElement, removeElement, updateSelectedElement, selectedElement, availableTypes, typeName, setTypeName, typeDescription, setTypeDescription, fieldsToDelete, - types // Add types to destructuring + types, enumValues, setEnumValues, flagValues, setFlagValues }) => { // This hook now works because it's inside DndContext provided by parent const { setNodeRef: setCanvasRef, isOver } = useDroppable({ @@ -412,16 +430,18 @@ const TypeBuilderContent: React.FC<{
- { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]"> - + { setMode(v as BuilderMode); setElements([]); }} className="w-fit"> + Structure - Single Type + Single + Enum + Flags
-
@@ -431,29 +451,158 @@ const TypeBuilderContent: React.FC<{
)}
- {elements.length === 0 ? ( -
- -

- {mode === 'alias' - ? "Drag a primitive type here to define the base type" - : "Drag items here to build your structure" - } + {/* Enum Values Editor */} + {mode === 'enum' && ( +

+
+
+ + Enum Values + {enumValues.length} +
+
+ {enumValues.length > 0 && ( +
+ Value + Label + +
+ )} + {enumValues.map((entry, idx) => ( +
+ { + const updated = [...enumValues]; + updated[idx] = { ...entry, value: e.target.value }; + setEnumValues(updated); + }} + placeholder="e.g. draft" + className="h-8 text-xs font-mono" + /> + { + const updated = [...enumValues]; + updated[idx] = { ...entry, label: e.target.value }; + setEnumValues(updated); + }} + placeholder="e.g. Draft" + className="h-8 text-xs" + /> + +
+ ))} + +
+ )} + + {/* Flags Values Editor */} + {mode === 'flags' && ( +
+
+
+ + Flag Values + {flagValues.length} +
+
+ {flagValues.length > 0 && ( +
+ Name + Bit + +
+ )} + {flagValues.map((entry, idx) => ( +
+ { + const updated = [...flagValues]; + updated[idx] = { ...entry, name: e.target.value }; + setFlagValues(updated); + }} + placeholder="e.g. can_edit" + className="h-8 text-xs font-mono" + /> + { + const updated = [...flagValues]; + updated[idx] = { ...entry, bit: parseInt(e.target.value) || 0 }; + setFlagValues(updated); + }} + className="h-8 text-xs font-mono text-center" + /> + +
+ ))} + +

+ Bit values should be powers of 2 (1, 2, 4, 8, 16...) for proper bitmasking.

- ) : ( -
- {elements.map(el => ( - setSelectedId(el.id)} - onDelete={() => deleteElement(el.id)} - onRemoveOnly={() => removeElement(el.id)} - /> - ))} -
+ )} + + {/* Structure/Alias Canvas (existing) */} + {(mode === 'structure' || mode === 'alias') && ( + <> + {elements.length === 0 ? ( +
+ +

+ {mode === 'alias' + ? "Drag a primitive type here to define the base type" + : "Drag items here to build your structure" + } +

+
+ ) : ( +
+ {elements.map(el => ( + setSelectedId(el.id)} + onDelete={() => deleteElement(el.id)} + onRemoveOnly={() => removeElement(el.id)} + /> + ))} +
+ )} + )}
@@ -561,11 +710,52 @@ const TypeBuilderContent: React.FC<{ />
+ {/* Array Items Type picker - show when field is 'array' */} + {resolvePrimitiveType(selectedElement.type, types) === 'array' && ( +
+

Array Configuration

+
+ + +

+ Choose the type for each item in this array. Pick a Structure to render complex sub-forms. +

+
+
+ )} +

UI Schema

- (initialData?.name || ''); const [typeDescription, setTypeDescription] = useState(initialData?.description || ''); const [fieldsToDelete, setFieldsToDelete] = useState([]); // Track field type IDs to delete + const [enumValues, setEnumValues] = useState(initialData?.enumValues || []); + const [flagValues, setFlagValues] = useState(initialData?.flagValues || []); // Setup Sensors with activation constraint const sensors = useSensors( @@ -709,7 +901,7 @@ export const TypeBuilder = React.forwardRef {createPortal( diff --git a/packages/ui/src/modules/types/TypesEditor.tsx b/packages/ui/src/modules/types/TypesEditor.tsx index 9ae55f7c..9fcd5c6c 100644 --- a/packages/ui/src/modules/types/TypesEditor.tsx +++ b/packages/ui/src/modules/types/TypesEditor.tsx @@ -3,7 +3,7 @@ import { TypeDefinition, updateType, createType } from './client-types'; import { Card } from '@/components/ui/card'; import { toast } from "sonner"; import { T, translate } from '@/i18n'; -import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef } from './TypeBuilder'; +import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef, EnumValueEntry, FlagValueEntry } from './TypeBuilder'; import { TypeRenderer, TypeRendererRef } from './TypeRenderer'; import { RefreshCw, Save, Trash2, X, Play, Languages } from "lucide-react"; import { useActions } from '@/actions/useActions'; @@ -38,10 +38,12 @@ export const TypesEditor: React.FC = ({ // Convert current type to builder format const builderData: BuilderOutput = { - mode: (selectedType.kind === 'structure' || selectedType.kind === 'alias') ? selectedType.kind : 'structure', + mode: (['structure', 'alias', 'enum', 'flags'].includes(selectedType.kind) ? selectedType.kind : 'structure') as BuilderMode, name: selectedType.name, description: selectedType.description || '', - elements: [] + elements: [], + enumValues: [], + flagValues: [] }; // For structures, convert structure_fields to builder elements @@ -54,26 +56,44 @@ export const TypesEditor: React.FC = ({ type: fieldType?.name || 'string', title: field.field_name, description: fieldType?.description || '', - uiSchema: fieldType?.meta?.uiSchema || {} + uiSchema: fieldType?.meta?.uiSchema || {}, + itemsTypeId: fieldType?.settings?.items_type_id || undefined } as BuilderElement; }); } + // For enums, populate enum values + if (selectedType.kind === 'enum' && selectedType.enum_values) { + builderData.enumValues = selectedType.enum_values.map((v: any, idx: number) => ({ + value: v.value || '', + label: v.label || '', + order: v.order ?? idx + })); + } + + // For flags, populate flag values + if (selectedType.kind === 'flags' && selectedType.flag_values) { + builderData.flagValues = selectedType.flag_values.map((v: any) => ({ + name: v.name || '', + bit: v.bit ?? 0 + })); + } + 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', + mode: (['structure', 'alias', 'enum', 'flags'].includes(selectedType.kind) ? selectedType.kind : 'structure') as BuilderMode, name: selectedType.name, description: selectedType.description || '', - elements: [] + elements: [], + enumValues: [], + flagValues: [] }; - // 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); @@ -83,10 +103,27 @@ export const TypesEditor: React.FC = ({ type: fieldType?.name || 'string', title: field.field_name, description: fieldType?.description || '', - uiSchema: fieldType?.meta?.uiSchema || {} + uiSchema: fieldType?.meta?.uiSchema || {}, + itemsTypeId: fieldType?.settings?.items_type_id || undefined } as BuilderElement; }); } + + if (selectedType.kind === 'enum' && selectedType.enum_values) { + builderData.enumValues = selectedType.enum_values.map((v: any, idx: number) => ({ + value: v.value || '', + label: v.label || '', + order: v.order ?? idx + })); + } + + if (selectedType.kind === 'flags' && selectedType.flag_values) { + builderData.flagValues = selectedType.flag_values.map((v: any) => ({ + name: v.name || '', + bit: v.bit ?? 0 + })); + } + return builderData; }, [selectedType, types]); @@ -113,14 +150,34 @@ export const TypesEditor: React.FC = ({ if (selectedType) { // Editing existing type try { - // For structures, we need to update structure_fields - if (output.mode === 'structure') { + if (output.mode === 'enum') { + // Update enum values + const enumValues = (output.enumValues || []).map((v, idx) => ({ + value: v.value, + label: v.label, + order: v.order ?? idx + })); + await updateType(selectedType.id, { + name: output.name, + description: output.description, + enum_values: enumValues + }); + } else if (output.mode === 'flags') { + // Update flag values + const flagValues = (output.flagValues || []).map(v => ({ + name: v.name, + bit: v.bit + })); + await updateType(selectedType.id, { + name: output.name, + description: output.description, + 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) => { - // 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); @@ -130,27 +187,27 @@ export const TypesEditor: React.FC = ({ return null; } - const fieldTypeData = { + const fieldTypeData: any = { name: `${selectedType.name}.${el.name}`, kind: 'field' as const, description: el.description || `Field ${el.name}`, parent_type_id: parentType.id, - meta: { ...fieldType?.meta, uiSchema: el.uiSchema || {} } + meta: { ...fieldType?.meta, uiSchema: el.uiSchema || {} }, + settings: { + ...fieldType?.settings, + ...(el.itemsTypeId ? { items_type_id: el.itemsTypeId } : {}) + } }; 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) => ({ @@ -184,14 +241,24 @@ export const TypesEditor: React.FC = ({ } else { // Creating new type try { - const newType: Partial = { + const newType: any = { name: output.name, description: output.description, kind: output.mode, }; - // For structures, create field types first - if (output.mode === 'structure') { + if (output.mode === 'enum') { + newType.enum_values = (output.enumValues || []).map((v, idx) => ({ + value: v.value, + label: v.label, + order: v.order ?? idx + })); + } else if (output.mode === 'flags') { + newType.flag_values = (output.flagValues || []).map(v => ({ + name: v.name, + bit: v.bit + })); + } else 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) @@ -206,7 +273,10 @@ export const TypesEditor: React.FC = ({ kind: 'field', description: el.description || `Field ${el.name}`, parent_type_id: parentType.id, - meta: { uiSchema: el.uiSchema || {} } + meta: { uiSchema: el.uiSchema || {} }, + settings: { + ...(el.itemsTypeId ? { items_type_id: el.itemsTypeId } : {}) + } } as any); })); @@ -218,7 +288,7 @@ export const TypesEditor: React.FC = ({ })); } - await createType(newType as any); + await createType(newType); toast.success(translate("Type created successfully")); setBuilderInitialData(undefined); onIsBuildingChange(false); diff --git a/packages/ui/src/modules/types/randomDataGenerator.ts b/packages/ui/src/modules/types/randomDataGenerator.ts index 96a5ba5a..4c778b95 100644 --- a/packages/ui/src/modules/types/randomDataGenerator.ts +++ b/packages/ui/src/modules/types/randomDataGenerator.ts @@ -1,71 +1,90 @@ -// Helper functions for generating random form data based on JSON schema - -export const generateRandomData = (schema: any): any => { - if (!schema || !schema.properties) return {}; - - const data: any = {}; - const sampleTexts = ['Lorem ipsum dolor sit amet', 'Sample text content', 'Example value here', 'Test data entry', 'Demo content item']; - const sampleNames = ['Alice Johnson', 'Bob Smith', 'Charlie Brown', 'Diana Prince', 'Eve Anderson']; - const sampleEmails = ['alice@example.com', 'bob@test.com', 'charlie@demo.org', 'diana@sample.net', 'eve@mail.com']; - - Object.keys(schema.properties).forEach(key => { - const prop = schema.properties[key]; - const type = prop.type; - const fieldName = key.toLowerCase(); - - switch (type) { - case 'string': - // Try to generate contextual data based on field name - if (fieldName.includes('email')) { - data[key] = sampleEmails[Math.floor(Math.random() * sampleEmails.length)]; - } else if (fieldName.includes('name')) { - data[key] = sampleNames[Math.floor(Math.random() * sampleNames.length)]; - } else if (fieldName.includes('phone')) { - data[key] = `+1-555-${Math.floor(Math.random() * 900) + 100}-${Math.floor(Math.random() * 9000) + 1000}`; - } else if (fieldName.includes('url') || fieldName.includes('link')) { - data[key] = `https://example.com/${key}`; - } else { - data[key] = sampleTexts[Math.floor(Math.random() * sampleTexts.length)]; - } - break; - case 'number': - case 'integer': - // Generate contextual numbers - if (fieldName.includes('age')) { - data[key] = Math.floor(Math.random() * 50) + 18; - } else if (fieldName.includes('price') || fieldName.includes('cost')) { - data[key] = Math.floor(Math.random() * 10000) / 100; - } else if (fieldName.includes('quantity') || fieldName.includes('count')) { - data[key] = Math.floor(Math.random() * 20) + 1; - } else { - data[key] = Math.floor(Math.random() * 100) + 1; - } - break; - case 'boolean': - data[key] = Math.random() > 0.5; - break; - case 'array': - const itemCount = Math.floor(Math.random() * 3) + 1; - data[key] = Array.from({ length: itemCount }, (_, index) => { - if (prop.items) { - if (prop.items.type === 'object' && prop.items.properties) { - return generateRandomData(prop.items); - } else if (prop.items.type === 'string') { - return `Item ${index + 1}`; - } else if (prop.items.type === 'number') { - return Math.floor(Math.random() * 100); - } - } - return `Item ${index + 1}`; - }); - break; - case 'object': - data[key] = prop.properties ? generateRandomData(prop) : {}; - break; - default: - data[key] = null; - } - }); - - return data; -}; +// Helper functions for generating random form data based on JSON schema + +export const generateRandomData = (schema: any): any => { + if (!schema || !schema.properties) return {}; + + const data: any = {}; + const sampleTexts = ['Lorem ipsum dolor sit amet', 'Sample text content', 'Example value here', 'Test data entry', 'Demo content item']; + const sampleNames = ['Alice Johnson', 'Bob Smith', 'Charlie Brown', 'Diana Prince', 'Eve Anderson']; + const sampleEmails = ['alice@example.com', 'bob@test.com', 'charlie@demo.org', 'diana@sample.net', 'eve@mail.com']; + + Object.keys(schema.properties).forEach(key => { + const prop = schema.properties[key]; + const type = prop.type; + const fieldName = key.toLowerCase(); + + switch (type) { + case 'string': + // If it has enum values, pick a random one + if (prop.enum && Array.isArray(prop.enum) && prop.enum.length > 0) { + data[key] = prop.enum[Math.floor(Math.random() * prop.enum.length)]; + } + // Try to generate contextual data based on field name + else if (fieldName.includes('email')) { + data[key] = sampleEmails[Math.floor(Math.random() * sampleEmails.length)]; + } else if (fieldName.includes('name')) { + data[key] = sampleNames[Math.floor(Math.random() * sampleNames.length)]; + } else if (fieldName.includes('phone')) { + data[key] = `+1-555-${Math.floor(Math.random() * 900) + 100}-${Math.floor(Math.random() * 9000) + 1000}`; + } else if (fieldName.includes('url') || fieldName.includes('link')) { + data[key] = `https://example.com/${key}`; + } else { + data[key] = sampleTexts[Math.floor(Math.random() * sampleTexts.length)]; + } + break; + case 'number': + case 'integer': + // Generate contextual numbers + if (fieldName.includes('age')) { + data[key] = Math.floor(Math.random() * 50) + 18; + } else if (fieldName.includes('price') || fieldName.includes('cost')) { + data[key] = Math.floor(Math.random() * 10000) / 100; + } else if (fieldName.includes('quantity') || fieldName.includes('count')) { + data[key] = Math.floor(Math.random() * 20) + 1; + } else { + data[key] = Math.floor(Math.random() * 100) + 1; + } + break; + case 'boolean': + data[key] = Math.random() > 0.5; + break; + case 'array': + // Flags pattern: uniqueItems + items with enum + if (prop.uniqueItems && prop.items?.enum && Array.isArray(prop.items.enum)) { + const allFlags = prop.items.enum as string[]; + const count = Math.min( + Math.floor(Math.random() * allFlags.length) + 1, + allFlags.length + ); + // Shuffle and pick a subset + const shuffled = [...allFlags].sort(() => Math.random() - 0.5); + data[key] = shuffled.slice(0, count); + } else { + const itemCount = Math.floor(Math.random() * 3) + 1; + data[key] = Array.from({ length: itemCount }, (_, index) => { + if (prop.items) { + if (prop.items.type === 'object' && prop.items.properties) { + return generateRandomData(prop.items); + } else if (prop.items.type === 'string') { + if (prop.items.enum && Array.isArray(prop.items.enum)) { + return prop.items.enum[Math.floor(Math.random() * prop.items.enum.length)]; + } + return `Item ${index + 1}`; + } else if (prop.items.type === 'number') { + return Math.floor(Math.random() * 100); + } + } + return `Item ${index + 1}`; + }); + } + break; + case 'object': + data[key] = prop.properties ? generateRandomData(prop) : {}; + break; + default: + data[key] = null; + } + }); + + return data; +}; diff --git a/packages/ui/src/modules/types/schema-utils.ts b/packages/ui/src/modules/types/schema-utils.ts index e2c5a82a..f31e42fc 100644 --- a/packages/ui/src/modules/types/schema-utils.ts +++ b/packages/ui/src/modules/types/schema-utils.ts @@ -30,6 +30,34 @@ export const generateSchemaForType = (typeId: string, types: TypeDefinition[], v return primitiveToJsonSchema[type.name] || { type: 'string' }; } + // If it's an enum, generate schema with actual enum values + if (type.kind === 'enum') { + const values = (type.enum_values || []) + .sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)); + const enumValues = values.map((v: any) => v.value); + const enumNames = values.map((v: any) => v.label || v.value); + return { + type: 'string', + enum: enumValues, + enumNames: enumNames + }; + } + + // If it's a flags type, generate schema with checkboxes (array of unique strings) + if (type.kind === 'flags') { + const values = (type.flag_values || []) + .sort((a: any, b: any) => (a.bit ?? 0) - (b.bit ?? 0)); + const flagValues = values.map((v: any) => v.name); + return { + type: 'array', + items: { + type: 'string', + enum: flagValues + }, + uniqueItems: true + }; + } + // If it's a structure, recursively build its schema if (type.kind === 'structure' && type.structure_fields) { visited.add(typeId); @@ -42,12 +70,26 @@ export const generateSchemaForType = (typeId: string, types: TypeDefinition[], v // If the field type has a parent (it's a field definition), we need to resolve the parent's schema const typeToResolve = fieldType.parent_type_id ? types.find(t => t.id === fieldType.parent_type_id) - : fieldType; // Should effectively not happen for 'field' kind without parent, but safe fallback + : fieldType; if (typeToResolve) { // Recursively generate schema + let fieldSchema = generateSchemaForType(typeToResolve.id, types, new Set(visited)); + + // If the resolved type is 'array', check for items_type_id in the field's settings + if (typeToResolve.name === 'array' || fieldSchema.type === 'array') { + const itemsTypeId = fieldType.settings?.items_type_id || fieldType.meta?.items_type_id; + if (itemsTypeId) { + const itemsSchema = generateSchemaForType(itemsTypeId, types, new Set(visited)); + fieldSchema = { + ...fieldSchema, + items: itemsSchema + }; + } + } + properties[field.field_name] = { - ...generateSchemaForType(typeToResolve.id, types, new Set(visited)), + ...fieldSchema, title: field.field_name, ...(fieldType.description && { description: fieldType.description }) }; @@ -91,9 +133,56 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], : null; const isNestedStructure = parentType?.kind === 'structure'; + const isArray = parentType?.name === 'array' || parentType?.kind === 'primitive' && parentType?.name === 'array'; + const isEnum = parentType?.kind === 'enum'; + const isFlags = parentType?.kind === 'flags'; const fieldUiSchema = fieldType?.meta?.uiSchema || {}; - if (isNestedStructure && parentType) { + if (isEnum) { + // Enum field — default to select widget if not overridden + uiSchema[field.field_name] = { + 'ui:widget': 'select', + ...fieldUiSchema, + }; + } else if (isFlags) { + // Flags field — default to checkboxes widget + uiSchema[field.field_name] = { + 'ui:widget': 'checkboxes', + 'ui:classNames': 'col-span-full', + ...fieldUiSchema, + }; + } else if (isArray && fieldType) { + // Array field — check if items are a structure and generate nested UI schema for items + const itemsTypeId = fieldType.settings?.items_type_id || fieldType.meta?.items_type_id; + if (itemsTypeId) { + const itemsType = types.find(t => t.id === itemsTypeId); + if (itemsType?.kind === 'structure') { + const itemsUiSchema = generateUiSchemaForType(itemsTypeId, types, new Set(visited)); + uiSchema[field.field_name] = { + ...fieldUiSchema, + items: { + ...itemsUiSchema, + 'ui:label': false + }, + 'ui:options': { orderable: false }, + 'ui:classNames': 'col-span-full' + }; + } else { + uiSchema[field.field_name] = { + ...fieldUiSchema, + items: { 'ui:label': false }, + 'ui:options': { orderable: false }, + 'ui:classNames': 'col-span-full' + }; + } + } else { + uiSchema[field.field_name] = { + ...fieldUiSchema, + 'ui:options': { orderable: false }, + 'ui:classNames': 'col-span-full' + }; + } + } else if (isNestedStructure && parentType) { // Recursively generate UI schema for nested structure const nestedUiSchema = generateUiSchemaForType(parentType.id, types, new Set(visited)); uiSchema[field.field_name] = {