types:arrays,flags,enums
This commit is contained in:
parent
d66f31c9e9
commit
b1663838ca
@ -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`.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>} />
|
||||
|
||||
@ -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 "+ Add" 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,
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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] = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user