types:arrays,flags,enums

This commit is contained in:
lovebird 2026-04-03 04:27:05 +02:00
parent d66f31c9e9
commit b1663838ca
8 changed files with 942 additions and 423 deletions

View File

@ -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`.

View File

@ -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)

View File

@ -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 = () => {
<Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />
{enablePlaygrounds && <Route path="/products/places/*" element={<React.Suspense fallback={<div>Loading...</div>}><PlacesModule /></React.Suspense>} />}
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
{/* Playground Routes */}
{enablePlaygrounds && (
<>
@ -192,7 +194,6 @@ const AppWrapper = () => {
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} />
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
<Route path="/playground/chat" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundChat /></React.Suspense>} />

View File

@ -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<HTMLInputElement>) =>
onChange(value === '' ? options.emptyValue : value);
const _onBlur = ({ target: { value } }: React.FocusEvent<HTMLInputElement>) =>
onBlur(id, value);
const _onFocus = ({ target: { value } }: React.FocusEvent<HTMLInputElement>) =>
onFocus(id, value);
return (
<input
id={id}
type={type || 'text'}
className="flex h-7 w-full rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
readOnly={readonly}
disabled={disabled}
autoFocus={autofocus}
value={value || ''}
required={required}
onChange={_onChange}
onBlur={_onBlur}
onFocus={_onFocus}
aria-describedby={rawErrors.length > 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<HTMLInputElement>) => {
// Emit boolean to satisfy z.boolean() schema
onChange(checked);
};
const _onBlur = () => onBlur(id, value);
const _onFocus = () => onFocus(id, value);
return (
<div className="flex items-center">
<button
type="button"
role="switch"
aria-checked={isChecked}
disabled={disabled || readonly}
onClick={() => {
if (!disabled && !readonly) {
onChange(!isChecked);
}
}}
onBlur={_onBlur}
onFocus={_onFocus}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${isChecked ? 'bg-indigo-600' : 'bg-gray-200'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full transition-transform
${isChecked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={_onChange}
disabled={disabled}
readOnly={readonly}
autoFocus={autofocus}
className="sr-only"
aria-describedby={rawErrors.length > 0 ? `${id}-error` : undefined}
/>
</div>
);
};
// 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 (
<div className={`w-full ${classNames}`}>
<div className="flex items-center gap-2">
{formattedLabel && (
<label
htmlFor={id}
className="text-[11px] font-medium text-muted-foreground shrink-0 whitespace-nowrap"
>
{formattedLabel}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
<div className="flex-1 min-w-0">{children}</div>
</div>
{errors && errors.length > 0 && (
<div id={`${id}-error`} className="mt-1 text-xs text-red-600 pl-[88px]">
{errors}
</div>
)}
</div>
);
};
// 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<string, any[]> = {};
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 (
<div className="space-y-4">
{description && (typeof description !== 'string' || description.trim()) && (
<p className="text-sm text-gray-600 mb-4">{description}</p>
)}
<div className={customClassNames || 'grid grid-cols-1 gap-4'}>
{properties.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{props.description && (
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
)}
{/* Render Groups */}
{hasGroups && (
<div className="flex flex-col gap-4">
{Object.entries(groups).map(([groupName, elements]) => (
<CollapsibleSection
key={groupName}
title={groupName}
initiallyOpen={true}
minimal={true}
storageKey={`competitor-search-group-${groupName}`}
className=""
headerClassName="flex justify-between items-center p-3 cursor-pointer hover:/50 transition-colors rounded-t-lg"
contentClassName="p-3"
>
<div className="grid grid-cols-1 gap-4">
{elements.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
</CollapsibleSection>
))}
</div>
)}
{/* Render Ungrouped Fields */}
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 gap-4">
{ungrouped.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
)}
</div>
);
};
// 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<HTMLInputElement>) =>
onChange(value === '' ? options.emptyValue : value);
const _onBlur = ({ target: { value } }: React.FocusEvent<HTMLInputElement>) =>
onBlur(id, value);
const _onFocus = ({ target: { value } }: React.FocusEvent<HTMLInputElement>) =>
onFocus(id, value);
return (
<input
id={id}
type={type || 'text'}
className="flex h-7 w-full rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
readOnly={readonly}
disabled={disabled}
autoFocus={autofocus}
value={value || ''}
required={required}
onChange={_onChange}
onBlur={_onBlur}
onFocus={_onFocus}
aria-describedby={rawErrors.length > 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<HTMLInputElement>) => {
// Emit boolean to satisfy z.boolean() schema
onChange(checked);
};
const _onBlur = () => onBlur(id, value);
const _onFocus = () => onFocus(id, value);
return (
<div className="flex items-center">
<button
type="button"
role="switch"
aria-checked={isChecked}
disabled={disabled || readonly}
onClick={() => {
if (!disabled && !readonly) {
onChange(!isChecked);
}
}}
onBlur={_onBlur}
onFocus={_onFocus}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${isChecked ? 'bg-indigo-600' : 'bg-gray-200'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full transition-transform
${isChecked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={_onChange}
disabled={disabled}
readOnly={readonly}
autoFocus={autofocus}
className="sr-only"
aria-describedby={rawErrors.length > 0 ? `${id}-error` : undefined}
/>
</div>
);
};
// 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 (
<div className={`w-full ${classNames}`}>
<div className="flex items-center gap-2">
{formattedLabel && (
<label
htmlFor={id}
className="text-[11px] font-medium text-muted-foreground shrink-0 whitespace-nowrap"
>
{formattedLabel}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
<div className="flex-1 min-w-0">{children}</div>
</div>
{errors && errors.length > 0 && (
<div id={`${id}-error`} className="mt-1 text-xs text-red-600 pl-[88px]">
{errors}
</div>
)}
</div>
);
};
// 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<string, any[]> = {};
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 (
<div className="space-y-4">
{description && (typeof description !== 'string' || description.trim()) && (
<p className="text-sm text-gray-600 mb-4">{description}</p>
)}
<div className={customClassNames || 'grid grid-cols-1 gap-4'}>
{properties.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{props.description && (
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
)}
{/* Render Groups */}
{hasGroups && (
<div className="flex flex-col gap-4">
{Object.entries(groups).map(([groupName, elements]) => (
<CollapsibleSection
key={groupName}
title={groupName}
initiallyOpen={true}
minimal={true}
storageKey={`competitor-search-group-${groupName}`}
className=""
headerClassName="flex justify-between items-center p-3 cursor-pointer hover:/50 transition-colors rounded-t-lg"
contentClassName="p-3"
>
<div className="grid grid-cols-1 gap-4">
{elements.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
</CollapsibleSection>
))}
</div>
)}
{/* Render Ungrouped Fields */}
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 gap-4">
{ungrouped.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
)}
</div>
);
};
// Custom ArrayFieldTemplate for premium array management
export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => {
const { items, canAdd, onAddClick, title } = props;
return (
<div className="col-span-full">
{title && (
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{title}
</span>
<span className="text-[10px] text-muted-foreground">
{items.length} item{items.length !== 1 ? 's' : ''}
</span>
</div>
)}
<div className="space-y-2">
{items.length === 0 && (
<div className="text-xs text-muted-foreground italic py-3 text-center border border-dashed rounded-md bg-muted/20">
No items yet. Click &quot;+ Add&quot; to create one.
</div>
)}
{items.map((element, idx) => (
<div
key={idx}
className="border rounded-md bg-background hover:border-primary/30 transition-colors p-3"
>
{element}
</div>
))}
</div>
{canAdd && (
<button
type="button"
onClick={onAddClick}
className="mt-2 w-full py-1.5 text-xs font-medium border border-dashed rounded-md text-primary hover:bg-primary/5 hover:border-primary/50 transition-colors"
>
+ Add Item
</button>
)}
</div>
);
};
// 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 (
<Select
value={value ?? ''}
onValueChange={(val) => onChange(val)}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className="h-7 text-xs">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{(enumOptions as any[])?.map((opt: any) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
// 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 (
<div className="flex flex-wrap gap-x-4 gap-y-1.5 py-1">
{(enumOptions as any[])?.map((opt: any) => (
<div key={opt.value} className="flex items-center gap-1.5">
<RadixCheckbox
id={`${id}-${opt.value}`}
checked={selected.includes(opt.value)}
onCheckedChange={() => handleToggle(opt.value)}
disabled={disabled || readonly}
className="h-3.5 w-3.5"
/>
<label
htmlFor={`${id}-${opt.value}`}
className="text-xs text-foreground cursor-pointer select-none"
>
{opt.label}
</label>
</div>
))}
</div>
);
};
export const customWidgets: RegistryWidgetsType = {
TextWidget,
CheckboxWidget,
SelectWidget,
CheckboxesWidget,
ImageWidget,
};
// Custom templates
export const customTemplates = {
FieldTemplate,
ObjectFieldTemplate,
ArrayFieldTemplate,
};

View File

@ -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<React.SetStateAction<EnumValueEntry[]>>;
flagValues: FlagValueEntry[];
setFlagValues: React.Dispatch<React.SetStateAction<FlagValueEntry[]>>;
}> = ({
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<{
<Card className={`flex-1 flex flex-col transition-colors ${isOver ? 'bg-muted/30 border-primary/50 ring-2 ring-primary/20' : ''}`}>
<CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
<div className="flex items-center gap-4">
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
<TabsList className="grid grid-cols2 h-7">
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-fit">
<TabsList className="h-7">
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
<TabsTrigger value="alias" className="text-xs">Single</TabsTrigger>
<TabsTrigger value="enum" className="text-xs">Enum</TabsTrigger>
<TabsTrigger value="flags" className="text-xs">Flags</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
<Button size="sm" onClick={() => onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete })} disabled={!typeName.trim()}>
<Button size="sm" onClick={() => onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete, enumValues, flagValues })} disabled={!typeName.trim()}>
Save Type
</Button>
</div>
@ -431,29 +451,158 @@ const TypeBuilderContent: React.FC<{
<div className="absolute inset-0 bg-primary/5 rounded-none border-2 border-primary/20 border-dashed pointer-events-none z-0" />
)}
<div className="relative z-10 min-h-full">
{elements.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground border-2 border-dashed rounded-lg opacity-50 min-h-[250px]">
<Box className="h-12 w-12 opacity-50 mb-2" />
<p>
{mode === 'alias'
? "Drag a primitive type here to define the base type"
: "Drag items here to build your structure"
}
{/* Enum Values Editor */}
{mode === 'enum' && (
<div className="max-w-lg mx-auto space-y-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Enum Values</span>
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{enumValues.length}</span>
</div>
</div>
{enumValues.length > 0 && (
<div className="grid grid-cols-[1fr_1fr_auto] gap-x-2 gap-y-0 px-1 mb-1">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Value</span>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Label</span>
<span className="w-7" />
</div>
)}
{enumValues.map((entry, idx) => (
<div key={idx} className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center group">
<Input
value={entry.value}
onChange={(e) => {
const updated = [...enumValues];
updated[idx] = { ...entry, value: e.target.value };
setEnumValues(updated);
}}
placeholder="e.g. draft"
className="h-8 text-xs font-mono"
/>
<Input
value={entry.label}
onChange={(e) => {
const updated = [...enumValues];
updated[idx] = { ...entry, label: e.target.value };
setEnumValues(updated);
}}
placeholder="e.g. Draft"
className="h-8 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setEnumValues(enumValues.filter((_, i) => i !== idx))}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
<button
onClick={() => setEnumValues([...enumValues, { value: '', label: '', order: enumValues.length }])}
className="w-full py-2 text-xs font-medium border border-dashed rounded-md text-primary hover:bg-primary/5 hover:border-primary/50 transition-colors flex items-center justify-center gap-1.5"
>
<Plus className="h-3 w-3" /> Add Value
</button>
</div>
)}
{/* Flags Values Editor */}
{mode === 'flags' && (
<div className="max-w-lg mx-auto space-y-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Flag className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Flag Values</span>
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{flagValues.length}</span>
</div>
</div>
{flagValues.length > 0 && (
<div className="grid grid-cols-[1fr_80px_auto] gap-x-2 gap-y-0 px-1 mb-1">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Name</span>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Bit</span>
<span className="w-7" />
</div>
)}
{flagValues.map((entry, idx) => (
<div key={idx} className="grid grid-cols-[1fr_80px_auto] gap-2 items-center group">
<Input
value={entry.name}
onChange={(e) => {
const updated = [...flagValues];
updated[idx] = { ...entry, name: e.target.value };
setFlagValues(updated);
}}
placeholder="e.g. can_edit"
className="h-8 text-xs font-mono"
/>
<Input
type="number"
value={entry.bit}
onChange={(e) => {
const updated = [...flagValues];
updated[idx] = { ...entry, bit: parseInt(e.target.value) || 0 };
setFlagValues(updated);
}}
className="h-8 text-xs font-mono text-center"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setFlagValues(flagValues.filter((_, i) => i !== idx))}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
<button
onClick={() => {
const nextBit = flagValues.length > 0
? Math.max(...flagValues.map(f => f.bit)) * 2
: 1;
setFlagValues([...flagValues, { name: '', bit: nextBit }]);
}}
className="w-full py-2 text-xs font-medium border border-dashed rounded-md text-primary hover:bg-primary/5 hover:border-primary/50 transition-colors flex items-center justify-center gap-1.5"
>
<Plus className="h-3 w-3" /> Add Flag
</button>
<p className="text-[10px] text-muted-foreground italic">
Bit values should be powers of 2 (1, 2, 4, 8, 16...) for proper bitmasking.
</p>
</div>
) : (
<div className="space-y-2 max-w-md mx-auto">
{elements.map(el => (
<CanvasElement
key={el.id}
element={el}
isSelected={selectedId === el.id}
onSelect={() => setSelectedId(el.id)}
onDelete={() => deleteElement(el.id)}
onRemoveOnly={() => removeElement(el.id)}
/>
))}
</div>
)}
{/* Structure/Alias Canvas (existing) */}
{(mode === 'structure' || mode === 'alias') && (
<>
{elements.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground border-2 border-dashed rounded-lg opacity-50 min-h-[250px]">
<Box className="h-12 w-12 opacity-50 mb-2" />
<p>
{mode === 'alias'
? "Drag a primitive type here to define the base type"
: "Drag items here to build your structure"
}
</p>
</div>
) : (
<div className="space-y-2 max-w-md mx-auto">
{elements.map(el => (
<CanvasElement
key={el.id}
element={el}
isSelected={selectedId === el.id}
onSelect={() => setSelectedId(el.id)}
onDelete={() => deleteElement(el.id)}
onRemoveOnly={() => removeElement(el.id)}
/>
))}
</div>
)}
</>
)}
</div>
</div>
@ -561,11 +710,52 @@ const TypeBuilderContent: React.FC<{
/>
</div>
{/* Array Items Type picker - show when field is 'array' */}
{resolvePrimitiveType(selectedElement.type, types) === 'array' && (
<div className="pt-4 border-t">
<h4 className="text-xs font-semibold mb-3">Array Configuration</h4>
<div className="space-y-2">
<Label className="text-xs">Items Type</Label>
<Select
value={selectedElement.itemsTypeId || '_none'}
onValueChange={(val) => {
updateSelectedElement({ itemsTypeId: val === '_none' ? undefined : val });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select what each item is..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none" className="text-xs">None (generic)</SelectItem>
<div className="px-2 py-1.5 text-[0.625rem] font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50 mt-1">Primitives</div>
{availableTypes
.filter(t => t.kind === 'primitive' && t.name !== 'array')
.map(t => (
<SelectItem key={t.id} value={t.id} className="text-xs pl-4">
{t.name.charAt(0).toUpperCase() + t.name.slice(1)}
</SelectItem>
))}
<div className="px-2 py-1.5 text-[0.625rem] font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50 mt-1">Structures</div>
{availableTypes
.filter(t => t.kind === 'structure')
.map(t => (
<SelectItem key={t.id} value={t.id} className="text-xs pl-4">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Choose the type for each item in this array. Pick a Structure to render complex sub-forms.
</p>
</div>
</div>
)}
<div className="pt-4 border-t">
<h4 className="text-xs font-semibold mb-3">UI Schema</h4>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Widget</Label>
<Label className="text-xs">Widget</Label>
<WidgetPicker
fieldType={selectedElement.type}
@ -626,6 +816,8 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
const [typeName, setTypeName] = useState<string>(initialData?.name || '');
const [typeDescription, setTypeDescription] = useState<string>(initialData?.description || '');
const [fieldsToDelete, setFieldsToDelete] = useState<string[]>([]); // Track field type IDs to delete
const [enumValues, setEnumValues] = useState<EnumValueEntry[]>(initialData?.enumValues || []);
const [flagValues, setFlagValues] = useState<FlagValueEntry[]>(initialData?.flagValues || []);
// Setup Sensors with activation constraint
const sensors = useSensors(
@ -709,7 +901,7 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
// Maybe validate here or show error
return;
}
onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete });
onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete, enumValues, flagValues });
}
}));
@ -745,6 +937,10 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
setTypeDescription={setTypeDescription}
fieldsToDelete={fieldsToDelete}
types={types}
enumValues={enumValues}
setEnumValues={setEnumValues}
flagValues={flagValues}
setFlagValues={setFlagValues}
/>
{createPortal(

View File

@ -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<TypesEditorProps> = ({
// 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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
} else {
// Creating new type
try {
const newType: Partial<TypeDefinition> = {
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
}));
}
await createType(newType as any);
await createType(newType);
toast.success(translate("Type created successfully"));
setBuilderInitialData(undefined);
onIsBuildingChange(false);

View File

@ -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;
};

View File

@ -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] = {