types:vfs/cats/groups/posts/pages - cleanup

This commit is contained in:
lovebird 2026-04-04 01:15:16 +02:00
parent 009332adbf
commit 81dae7a5c5
24 changed files with 735 additions and 636 deletions

View File

@ -1,9 +1,5 @@
import React, { useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { rjsfWidgetRegistry } from '@/modules/types/rjsfWidgetRegistry';
import { customTemplates } from '@/modules/types/RJSFTemplates';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/modules/types/schema-utils';
import { TypeForm } from '@/modules/types/TypeForm';
import type { TypeDefinition } from '@/modules/types/client-types';
import { Label } from '@/components/ui/label';
import { T } from '@/i18n';
@ -23,25 +19,16 @@ const SingleTypeForm = ({
onChange: (fd: Record<string, unknown>) => void;
disabled?: boolean;
}) => {
const jsonSchema = useMemo(() => generateSchemaForType(typeId, types), [typeId, types]);
const uiSchema = useMemo(() => {
const gen = generateUiSchemaForType(typeId, types);
return deepMergeUiSchema(gen, typeDef.meta?.uiSchema || {});
}, [typeId, types, typeDef.meta?.uiSchema]);
return (
<div className="border rounded-lg p-3 space-y-2 bg-muted/20">
<Label className="text-sm font-semibold">
{typeDef.name}
<span className="ml-2 text-xs font-normal text-muted-foreground">({typeDef.kind})</span>
</Label>
<Form
schema={jsonSchema}
uiSchema={uiSchema}
<TypeForm
typeDef={typeDef}
types={types}
formData={formData}
validator={validator}
widgets={rjsfWidgetRegistry}
templates={customTemplates}
disabled={disabled}
readonly={disabled}
omitExtraData
@ -49,7 +36,7 @@ const SingleTypeForm = ({
onChange={({ formData: fd }) => onChange((fd || {}) as Record<string, unknown>)}
>
<span className="sr-only">.</span>
</Form>
</TypeForm>
</div>
);
};
@ -57,7 +44,7 @@ const SingleTypeForm = ({
export interface ProductTypeDataFormsProps {
/** Structure (and other) type ids configured for this product */
typeIds: string[];
/** Full type registry from `fetchTypes()` — required so `generateSchemaForType` can resolve fields (enums, primitives, field rows, nested structures). */
/** Full type registry from `fetchTypes()` — required so `TypeForm` can parse and resolve schemas. */
types: TypeDefinition[];
/** Per-type id → form payload (structure field values) */
value: Record<string, Record<string, unknown>>;

View File

@ -1,10 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import React, { useState, useEffect } from 'react';
import { TypeForm } from '@/modules/types/TypeForm';
import { TypeDefinition, fetchTypes } from '@/modules/types/client-types';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/modules/types/schema-utils';
import { rjsfWidgetRegistry } from '@/modules/types/rjsfWidgetRegistry';
import { customTemplates } from '@/modules/types/RJSFTemplates';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
@ -125,23 +121,6 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
<Accordion type="multiple" className="w-full" defaultValue={assignedTypes.map(t => t.id)} key={assignedTypes.map(t => t.id).join(',')}>
{assignedTypes.map(type => {
const schema = generateSchemaForType(type.id, allTypes);
// Ensure schema is object type for form rendering
if (schema.type !== 'object') {
return null;
}
const uiSchema = generateUiSchemaForType(type.id, allTypes);
// Merge with type's own UI schema but FORCE our single-column grid layout
// Merge with type's own UI schema but FORCE our single-column grid layout
// Use deep merge to preserve field settings
const mergedUiSchema = deepMergeUiSchema(uiSchema, type.meta?.uiSchema || {});
const finalUiSchema = {
...mergedUiSchema,
'ui:classNames': 'grid grid-cols-1 gap-y-1'
};
const typeData = formData[type.id] || {};
return (
@ -180,20 +159,19 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
</div>
</AccordionPrimitive.Header>
<AccordionContent className="px-4 pb-4 pt-2">
<Form
schema={schema}
uiSchema={finalUiSchema}
<TypeForm
typeDef={type}
types={allTypes}
requireObjectSchema
uiSchemaOverrides={{ 'ui:classNames': 'grid grid-cols-1 gap-y-1' }}
formData={typeData}
validator={validator}
widgets={rjsfWidgetRegistry}
templates={customTemplates}
onChange={(e) => isEditMode && handleFormChange(type.id, e.formData)}
readonly={!isEditMode}
className={isEditMode ? "" : "pointer-events-none opacity-80"}
>
{/* No submit button — changes are saved via the global save (Ctrl+S) */}
<></>
</Form>
</TypeForm>
</AccordionContent>
</AccordionItem>
);

View File

@ -3,16 +3,23 @@
* ui:widget keys are listed in builder/appPickerWidgetOptions.ts.
*/
import React, { lazy, Suspense, useEffect, useState } from 'react';
import { GroupPicker } from '@/components/admin/GroupPicker';
import type { WidgetProps } from '@rjsf/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { FileText, FolderOpen, Image as ImageIcon, X } from 'lucide-react';
import { FileText, FolderOpen, Image as ImageIcon, X, RefreshCw } from 'lucide-react';
import { T, translate } from '@/i18n';
import { PagePickerDialog } from '@/modules/pages/PagePickerDialog';
import { fetchPageDetailsById } from '@/modules/pages/client-pages';
import PostPicker from '@/components/PostPicker';
import { fetchPostDetailsAPI } from '@/modules/posts/client-posts';
import { CategoryPickerField } from '@/components/widgets/CategoryPickerField';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
import { UserPicker } from '@/components/admin/UserPicker';
import { getUiOptions } from '@rjsf/utils';
const FileBrowserWidget = lazy(() =>
import('@/modules/storage/FileBrowserWidget').then((m) => ({ default: m.default }))
@ -336,3 +343,282 @@ export const FilePickerWidget = (props: WidgetProps) => {
</div>
);
};
type CategoryUiOptions = {
filterType?: string;
};
/**
* RJSF widget wrapping CategoryPickerField. Stores selected category id (string).
* Optional `ui:options.filterType` matches CategoryPickerField (e.g. "types", "pages").
*/
export const CategoryPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus, schema, uiSchema } = props;
const uiOptions = getUiOptions(uiSchema) as CategoryUiOptions;
const filterType = uiOptions.filterType ?? (schema as { 'x-category-filter-type'?: string })?.['x-category-filter-type'];
const v = value == null ? '' : String(value);
if (readonly || disabled) {
return (
<div
id={id}
className="text-xs text-muted-foreground py-1"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
{v || '—'}
</div>
);
}
return (
<div onBlur={() => onBlur(id, value)} onFocus={() => onFocus(id, value)}>
<CategoryPickerField value={v} onSelect={(categoryId) => onChange(categoryId || undefined)} filterType={filterType} />
</div>
);
};
/**
* RJSF widget wrapping GroupPicker (single). Stores selected group name (string), matching GroupPicker onSelect.
*/
export const GroupPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const v = value == null ? '' : String(value);
if (readonly) {
return (
<div
id={id}
className="text-xs text-muted-foreground py-1"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
{v || '—'}
</div>
);
}
return (
<div
className="[&_[role=combobox]]:min-h-8 [&_[role=combobox]]:px-2 [&_[role=combobox]]:py-1.5 [&_[role=combobox]]:text-xs"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
<GroupPicker
value={v || undefined}
disabled={disabled}
multi={false}
onSelect={(groupName) => onChange(groupName || undefined)}
/>
</div>
);
};
export const ImageWidget = (props: WidgetProps) => {
const {
id,
value,
readonly,
disabled,
onChange,
onBlur,
onFocus,
} = props;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [picture, setPicture] = useState<any>(null);
const [loading, setLoading] = useState(false);
// Fetch picture details if value exists
useEffect(() => {
const fetchPicture = async () => {
if (!value) {
setPicture(null);
return;
}
// If we already have the picture and IDs match, don't re-fetch
if (picture && picture.id === value) return;
setLoading(true);
try {
const data = await fetchPictureById(value);
setPicture(data);
} catch (error) {
console.error("Error fetching picture:", error);
} finally {
setLoading(false);
}
};
fetchPicture();
}, [value]);
const handleSelectPicture = (selectedPicture: any) => {
onChange(selectedPicture.id);
setPicture(selectedPicture);
setIsDialogOpen(false);
};
const handleClear = () => {
onChange(undefined);
setPicture(null);
};
return (
<div className="space-y-2">
{!value ? (
<Button
type="button"
variant="outline"
className="w-full h-24 border-dashed flex flex-col gap-2 items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-muted/50 transition-colors"
onClick={() => setIsDialogOpen(true)}
disabled={disabled || readonly}
>
<ImageIcon className="h-8 w-8 opacity-50" />
<span className="text-xs">Select Image</span>
</Button>
) : (
<div className="relative group rounded-lg overflow-hidden border border-border bg-muted/30">
<div className="aspect-video w-full max-w-sm relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : picture ? (
<div className="w-full h-full relative">
<img
src={picture.image_url}
alt={picture.title || 'Selected image'}
className="w-full h-full object-contain bg-black/5"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2 text-white text-xs truncate">
{picture.title}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
Image ID: {value} (Loading or Not Found)
</div>
)}
</div>
{/* Actions Overlay */}
{!readonly && !disabled && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 shadow-sm"
onClick={() => setIsDialogOpen(true)}
title="Change Image"
>
<RefreshCw className="h-3 w-3" />
</Button>
<Button
type="button"
variant="destructive"
size="icon"
className="h-7 w-7 shadow-sm"
onClick={handleClear}
title="Remove Image"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
</div>
)}
<ImagePickerDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
onSelectPicture={handleSelectPicture}
currentValue={value}
/>
</div>
);
};
/**
* RJSF widget wrapping UserPicker. Stores selected user id (string).
*/
export const UserPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const v = value == null ? '' : String(value);
if (readonly) {
return (
<div
id={id}
className="text-xs text-muted-foreground py-1"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
{v || '—'}
</div>
);
}
return (
<div
className="[&_button]:h-8 [&_button]:text-xs [&_button]:font-normal"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
<UserPicker
value={v || undefined}
disabled={disabled}
onSelect={(userId) => onChange(userId || undefined)}
/>
</div>
);
};
// --- Widget Group Options ---
/** What is persisted in form data for each ui:widget (for authors / codegen). */
export const APP_PICKER_STORAGE_HINT: Record<string, string> = {
pagePicker: 'Page UUID',
postPicker: 'Post UUID',
filePicker: 'VFS location as mount:path or mount:dir/file.ext',
categoryPicker: 'Category UUID',
userPicker: 'User id (UUID)',
groupPicker: 'ACL group name (string)',
};
export const APP_PICKER_WIDGET_GROUPS = [
{
label: 'App pickers',
options: [
{ value: 'pagePicker', label: 'Page (ID)', types: ['string'] as const },
{ value: 'postPicker', label: 'Post (ID)', types: ['string'] as const },
{ value: 'filePicker', label: 'VFS file / path', types: ['string'] as const },
],
},
] as const;
/** Drag palette + default JSON keys when dropping onto a structure */
export const APP_PICKER_PALETTE_ENTRIES = [
{ widget: 'pagePicker' as const, defaultFieldName: 'pageId', paletteTitle: 'Page (ID)' },
{ widget: 'postPicker' as const, defaultFieldName: 'postId', paletteTitle: 'Post (ID)' },
{ widget: 'filePicker' as const, defaultFieldName: 'filePath', paletteTitle: 'VFS file / path' },
] as const;
/**
* Array field "Items Type" string items plus a per-item ui:widget (same widgets as single-field pickers).
*/
export const ARRAY_ITEMS_APP_PICKER_ENTRIES = [
{ widget: 'categoryPicker' as const, label: 'Category' },
{ widget: 'userPicker' as const, label: 'User' },
{ widget: 'groupPicker' as const, label: 'Group' },
{ widget: 'pagePicker' as const, label: 'Page (ID)' },
{ widget: 'postPicker' as const, label: 'Post (ID)' },
{ widget: 'filePicker' as const, label: 'VFS file / path' },
] as const;
export const ARRAY_ITEMS_APP_PICKER_WIDGETS: ReadonlySet<string> = new Set(
ARRAY_ITEMS_APP_PICKER_ENTRIES.map((e) => e.widget)
);

View File

@ -1,134 +0,0 @@
import React, { useState, useEffect } from 'react';
import { WidgetProps } from '@rjsf/utils';
import { Button } from '@/components/ui/button';
import { Image as ImageIcon, X, RefreshCw } from 'lucide-react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
export const ImageWidget = (props: WidgetProps) => {
const {
id,
value,
readonly,
disabled,
onChange,
onBlur,
onFocus,
} = props;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [picture, setPicture] = useState<any>(null);
const [loading, setLoading] = useState(false);
// Fetch picture details if value exists
useEffect(() => {
const fetchPicture = async () => {
if (!value) {
setPicture(null);
return;
}
// If we already have the picture and IDs match, don't re-fetch
if (picture && picture.id === value) return;
setLoading(true);
try {
const data = await fetchPictureById(value);
setPicture(data);
} catch (error) {
console.error("Error fetching picture:", error);
} finally {
setLoading(false);
}
};
fetchPicture();
}, [value]);
const handleSelectPicture = (selectedPicture: any) => {
onChange(selectedPicture.id);
setPicture(selectedPicture);
setIsDialogOpen(false);
};
const handleClear = () => {
onChange(undefined);
setPicture(null);
};
return (
<div className="space-y-2">
{!value ? (
<Button
type="button"
variant="outline"
className="w-full h-24 border-dashed flex flex-col gap-2 items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-muted/50 transition-colors"
onClick={() => setIsDialogOpen(true)}
disabled={disabled || readonly}
>
<ImageIcon className="h-8 w-8 opacity-50" />
<span className="text-xs">Select Image</span>
</Button>
) : (
<div className="relative group rounded-lg overflow-hidden border border-border bg-muted/30">
<div className="aspect-video w-full max-w-sm relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : picture ? (
<div className="w-full h-full relative">
<img
src={picture.image_url}
alt={picture.title || 'Selected image'}
className="w-full h-full object-contain bg-black/5"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2 text-white text-xs truncate">
{picture.title}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
Image ID: {value} (Loading or Not Found)
</div>
)}
</div>
{/* Actions Overlay */}
{!readonly && !disabled && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 shadow-sm"
onClick={() => setIsDialogOpen(true)}
title="Change Image"
>
<RefreshCw className="h-3 w-3" />
</Button>
<Button
type="button"
variant="destructive"
size="icon"
className="h-7 w-7 shadow-sm"
onClick={handleClear}
title="Remove Image"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
</div>
)}
<ImagePickerDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
onSelectPicture={handleSelectPicture}
currentValue={value}
/>
</div>
);
};

View File

@ -0,0 +1,27 @@
import React from 'react';
import BaseForm, { FormProps } from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { customWidgets, customTemplates } from './RJSFTemplates';
/**
* A wrapper around `@rjsf/core` Form that pre-injects our standard validator,
* widget registry, and custom templates to avoid prop drilling everywhere.
*/
export const RJSFForm = <T = any, S extends import('@rjsf/utils').StrictRJSFSchema = import('@rjsf/utils').RJSFSchema, F extends import('@rjsf/utils').FormContextType = any>(
props: Omit<FormProps<T, S, F>, 'validator' | 'widgets' | 'templates'> & {
validator?: import('@rjsf/utils').ValidatorType<T, S, F>;
widgets?: import('@rjsf/utils').RegistryWidgetsType<T, S, F>;
templates?: Partial<import('@rjsf/utils').TemplatesType<T, S, F>>;
}
) => {
return (
<BaseForm
{...props}
validator={props.validator || validator as any}
widgets={{ ...customWidgets, ...props.widgets } as any}
templates={{ ...customTemplates, ...props.templates } as any}
/>
);
};
export default RJSFForm;

View File

@ -1,8 +1,135 @@
import React from 'react';
import type { WidgetProps, RegistryWidgetsType, ArrayFieldTemplateProps, ArrayFieldItemTemplateProps } from '@rjsf/utils';
import type { WidgetProps, RegistryWidgetsType, ArrayFieldTemplateProps, ArrayFieldItemTemplateProps, IconButtonProps } from '@rjsf/utils';
import { getTemplate, getUiOptions, type FormContextType, type RJSFSchema, type StrictRJSFSchema } from '@rjsf/utils';
import CollapsibleSection from '@/components/CollapsibleSection';
import { rjsfButtonTemplateOverrides } from '@/modules/types/rjsfButtonTemplates';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronUp, Copy, Plus, Trash2, X } from 'lucide-react';
const iconBtn =
'h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground hover:bg-muted';
/**
* RJSF core ships Bootstrap + glyphicon icon buttons; we don't load those styles, so controls were effectively invisible.
* These replace the array toolbar actions with shadcn + Lucide.
*/
function RjsfToolbarIconButton(
props: IconButtonProps & {
children: React.ReactNode;
destructive?: boolean;
}
) {
const { className, disabled, onClick, title, id, children, destructive, registry: _r, uiSchema: _u, icon: _i, iconType: _it } = props;
const label = typeof title === 'string' ? title : 'Action';
return (
<Button
type="button"
variant="ghost"
size="icon"
id={id}
className={cn(
iconBtn,
destructive && 'text-destructive hover:text-destructive hover:bg-destructive/10',
className
)}
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
>
{children}
</Button>
);
}
export function RjsfRemoveButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className} destructive>
<Trash2 className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfMoveUpButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<ChevronUp className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfMoveDownButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<ChevronDown className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfCopyButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<Copy className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
/** Used for additionalProperties and other add affordances that still use the default AddButton slot. */
export function RjsfAddIconButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<Plus className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfClearButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<X className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
/** Partial overrides merged with @rjsf/core defaults (see Form registry merge). */
export const rjsfButtonTemplateOverrides = {
RemoveButton: RjsfRemoveButton,
MoveUpButton: RjsfMoveUpButton,
MoveDownButton: RjsfMoveDownButton,
CopyButton: RjsfCopyButton,
AddButton: RjsfAddIconButton,
ClearButton: RjsfClearButton,
};
// Utility function to convert camelCase to Title Case
const formatLabel = (str: string): string => {
@ -349,11 +476,7 @@ export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => {
};
// Custom widgets
import { ImageWidget } from '@/modules/types/ImageWidget';
import { FilePickerWidget, PagePickerWidget, PostPickerWidget } from '@/modules/types/appPickerWidgets';
import { CategoryPickerWidget } from '@/modules/types/categoryPickerWidget';
import { UserPickerWidget } from '@/modules/types/userPickerWidget';
import { GroupPickerWidget } from '@/modules/types/groupPickerWidget';
import { FilePickerWidget, PagePickerWidget, PostPickerWidget, CategoryPickerWidget, GroupPickerWidget, UserPickerWidget, ImageWidget } from '@/modules/types/AppTypeWidgets';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox as RadixCheckbox } from '@/components/ui/checkbox';

View File

@ -0,0 +1,57 @@
import React, { useMemo } from 'react';
import { TypeDefinition } from '@/modules/types/client-types';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/modules/types/schema-utils';
import RJSFForm from '@/modules/types/RJSFForm';
export interface TypeFormProps extends Omit<React.ComponentProps<typeof RJSFForm>, 'schema' | 'uiSchema'> {
/** The definition of the type to render */
typeDef: TypeDefinition;
/** The full registry of types, needed to resolve nested structures/enums */
types: TypeDefinition[];
/** Any additional uiSchema overrides to merge with the final generated uiSchema */
uiSchemaOverrides?: Record<string, any>;
/**
* If true, validation is skipped if the generated schema is not an object.
* Useful when we only want to render forms for objects (like in UserPageTypeFields).
*/
requireObjectSchema?: boolean;
}
/**
* A higher-order wrapper around RJSFFormWrapper that automatically generates
* the JSON Schema and UI Schema from a Polymech `TypeDefinition`.
*/
export const TypeForm: React.FC<TypeFormProps> = ({
typeDef,
types,
uiSchemaOverrides,
requireObjectSchema,
...formProps
}) => {
const jsonSchema = useMemo(() => generateSchemaForType(typeDef.id, types), [typeDef.id, types]);
const uiSchema = useMemo(() => {
const generatedUi = generateUiSchemaForType(typeDef.id, types);
// Merge with the type's own UI schema metadata
let merged = deepMergeUiSchema(generatedUi, typeDef.meta?.uiSchema || {});
// Merge with any explicit overrides provided as props
if (uiSchemaOverrides) {
merged = deepMergeUiSchema(merged, uiSchemaOverrides);
}
return merged;
}, [typeDef.id, types, typeDef.meta?.uiSchema, uiSchemaOverrides]);
if (requireObjectSchema && jsonSchema.type !== 'object') {
return null;
}
return (
<RJSFForm
schema={jsonSchema}
uiSchema={uiSchema}
{...formProps}
/>
);
};
export default TypeForm;

View File

@ -4,10 +4,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw } from 'lucide-react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { rjsfWidgetRegistry } from './rjsfWidgetRegistry';
import { customTemplates } from './RJSFTemplates';
import Form from './RJSFForm';
import { generateRandomData } from './randomDataGenerator';
import { toast } from 'sonner';
@ -35,38 +32,19 @@ export const TypeRenderer = forwardRef<TypeRendererRef, TypeRendererProps>(({
const [showPreview, setShowPreview] = useState(false);
const [previewFormData, setPreviewFormData] = useState<any>({});
const [previewKey, setPreviewKey] = useState(0);
/*
console.log('editedType', editedType);
console.log('types', types);
console.log('jsonSchemaString', jsonSchemaString);
console.log('uiSchemaString', uiSchemaString);
*/
// Generate JSON schema and UI schema when editedType changes
React.useEffect(() => {
if (!editedType) return;
if (['structure', 'enum', 'flags'].includes(editedType.kind)) {
const generatedSchema = generateSchemaForType(editedType.id, types);
setJsonSchemaString(JSON.stringify(generatedSchema, null, 2));
const generatedSchema = generateSchemaForType(editedType.id, types);
setJsonSchemaString(JSON.stringify(generatedSchema, null, 2));
// Generate UI schema recursively
const generatedUiSchema = generateUiSchemaForType(editedType.id, types);
const generatedUiSchema = generateUiSchemaForType(editedType.id, types);
// Merge with component's own UI schema (metadata) using deep merge
const finalUiSchema = deepMergeUiSchema(generatedUiSchema, editedType.meta?.uiSchema || {});
if (editedType.kind === 'enum') {
generatedUiSchema['ui:widget'] = 'select';
} else if (editedType.kind === 'flags') {
generatedUiSchema['ui:widget'] = 'checkboxes';
}
// Merge with component's own UI schema (metadata) using deep merge
const finalUiSchema = deepMergeUiSchema(generatedUiSchema, editedType.meta?.uiSchema || {});
setUiSchemaString(JSON.stringify(finalUiSchema, null, 2));
} else {
setJsonSchemaString(JSON.stringify(editedType.json_schema || {}, null, 2));
setUiSchemaString(JSON.stringify(editedType.meta?.uiSchema || {}, null, 2));
}
setUiSchemaString(JSON.stringify(finalUiSchema, null, 2));
// Reset preview when type changes
setShowPreview(false);
@ -197,9 +175,6 @@ export const TypeRenderer = forwardRef<TypeRendererRef, TypeRendererProps>(({
schema={previewSchema}
uiSchema={previewUiSchema}
formData={showPreview ? previewFormData : undefined}
validator={validator}
widgets={rjsfWidgetRegistry}
templates={customTemplates}
onChange={({ formData }) => showPreview && setPreviewFormData(formData)}
onSubmit={({ formData }) => toast.success("Form submitted (check console)")}
onError={(errors) => console.log('Form errors:', errors)}

View File

@ -4,7 +4,7 @@ import { deepMergeUiSchema } from './schema-utils';
import { Card } from '@/components/ui/card';
import { toast } from "sonner";
import { T, translate } from '@/i18n';
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef, EnumValueEntry, FlagValueEntry } from './TypeBuilder';
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef, EnumValueEntry, FlagValueEntry } from './builder/TypeBuilder';
import { TypeRenderer, TypeRendererRef } from './TypeRenderer';
import { RefreshCw, Save, Trash2, X, Play, Languages } from "lucide-react";
import { useActions } from '@/actions/useActions';

View File

@ -3,7 +3,7 @@ import { TypeDefinition } from './client-types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { TypeCategoryTree } from './TypeCategoryTree';
import { T } from '@/i18n'
interface TypesListProps {
types: TypeDefinition[];
selectedTypeId: string | null;
@ -21,7 +21,7 @@ export const TypesList: React.FC<TypesListProps> = ({
return (
<Card className={`flex flex-col min-h-0 ${className}`}>
<CardHeader className="pb-3 border-b px-4 py-3">
<CardTitle className="text-sm font-medium">Available Types</CardTitle>
<CardTitle className="text-sm font-medium"><T>Available Types</T></CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 p-0">
<ScrollArea className="h-full">
@ -33,11 +33,10 @@ export const TypesList: React.FC<TypesListProps> = ({
renderItem={(t) => (
<button
onClick={() => onSelect(t)}
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${
selectedTypeId === t.id
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${selectedTypeId === t.id
? 'bg-secondary text-secondary-foreground font-medium'
: 'hover:bg-muted text-muted-foreground'
}`}
}`}
>
<span className="truncate">{t.name}</span>
{selectedTypeId === t.id && (

View File

@ -1,3 +1,4 @@
// Type Builder Editors for Enum and Flags
import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

View File

@ -1,13 +1,14 @@
import React, { useState, useEffect, useImperativeHandle } from 'react';
import { TypeDefinition } from './client-types';
import React, { useEffect, useImperativeHandle } from 'react';
import { TypeDefinition } from '../client-types';
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor } from '@dnd-kit/core';
import { typeBuilderCollisionDetection } from './builder/typeBuilderCollision';
import { typeBuilderCollisionDetection } from './typeBuilderCollision';
import { arrayMove } from '@dnd-kit/sortable';
import { BuilderOutput, BuilderElement, BuilderMode, EnumValueEntry, FlagValueEntry } from './builder/types';
import { TypeBuilderContent } from './builder/TypeBuilderContent';
import { DraggablePaletteItem } from './builder/components';
import { BuilderOutput, BuilderElement } from './types';
import { TypeBuilderContent } from './TypeBuilderContent';
import { DraggablePaletteItem } from './components';
import { useBuilderStore } from './useStore';
export * from './builder/types';
export * from './types';
export interface TypeBuilderRef {
triggerSave: () => void;
@ -23,18 +24,25 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
onUpdateMeta?: (meta: any) => Promise<void>;
}>(({ onSave, onCancel, availableTypes, initialData, types, selectedType, onUpdateMeta }, ref) => {
const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure');
const [typeName, setTypeName] = useState(initialData?.name || '');
const [typeDescription, setTypeDescription] = useState(initialData?.description || '');
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeDragItem, setActiveDragItem] = useState<BuilderElement | null>(null);
const [fieldsToDelete, setFieldsToDelete] = useState<string[]>([]);
// Enum and Flags specific state
const [enumValues, setEnumValues] = useState<EnumValueEntry[]>(initialData?.enumValues || []);
const [flagValues, setFlagValues] = useState<FlagValueEntry[]>(initialData?.flagValues || []);
const {
mode,
elements,
typeName,
typeDescription,
fieldsToDelete,
enumValues,
flagValues,
activeDragItem,
setElements,
setActiveDragItem,
setSelectedId,
initialize
} = useBuilderStore();
// Initialize store when component mounts or initialData changes
useEffect(() => {
initialize(initialData);
}, [initialData, initialize]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@ -48,12 +56,6 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
}
}));
useEffect(() => {
if (elements.length > 0 && selectedId === null) {
// setSelectedId(elements[0].id);
}
}, [elements, selectedId]);
const handleDragStart = (e: any) => {
setActiveDragItem(e.active.data.current as BuilderElement);
};
@ -106,26 +108,6 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
}
};
const deleteElement = (id: string) => {
const el = elements.find(e => e.id === id);
if (el && el.refId) {
setFieldsToDelete(prev => [...prev, el.refId!]);
}
setElements(prev => prev.filter(e => e.id !== id));
if (selectedId === id) setSelectedId(null);
};
const removeElement = (id: string) => {
setElements(prev => prev.filter(e => e.id !== id));
if (selectedId === id) setSelectedId(null);
};
const updateSelectedElement = (updates: Partial<BuilderElement>) => {
setElements(prev => prev.map(e => e.id === selectedId ? { ...e, ...updates } : e));
};
const selectedElement = elements.find(e => e.id === selectedId);
return (
<DndContext
sensors={sensors}
@ -134,31 +116,12 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
onDragEnd={handleDragEnd}
>
<TypeBuilderContent
mode={mode}
setMode={setMode}
elements={elements}
setElements={setElements}
selectedId={selectedId}
setSelectedId={setSelectedId}
onCancel={onCancel}
onSave={onSave}
deleteElement={deleteElement}
removeElement={removeElement}
updateSelectedElement={updateSelectedElement}
selectedElement={selectedElement}
availableTypes={availableTypes}
typeName={typeName}
setTypeName={setTypeName}
typeDescription={typeDescription}
setTypeDescription={setTypeDescription}
fieldsToDelete={fieldsToDelete}
types={types}
selectedType={selectedType}
onUpdateMeta={onUpdateMeta}
enumValues={enumValues}
setEnumValues={setEnumValues}
flagValues={flagValues}
setFlagValues={setFlagValues}
/>
<DragOverlay>
{activeDragItem ? (

View File

@ -11,47 +11,36 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch';
import { Box } from 'lucide-react';
import { CategoryManager } from '@/components/widgets/CategoryManager';
import { BuilderMode, BuilderElement, BuilderOutput, EnumValueEntry, FlagValueEntry } from './types';
import { BuilderMode, BuilderElement, BuilderOutput } from './types';
import { DraggablePaletteItem, CanvasElement, WidgetPicker } from './components';
import { TypeCategoryTree } from '../TypeCategoryTree';
import { EnumEditor, FlagsEditor } from './Editors';
import { resolvePrimitiveType } from './utils';
import { APP_PICKER_PALETTE_ENTRIES, ARRAY_ITEMS_APP_PICKER_ENTRIES, ARRAY_ITEMS_APP_PICKER_WIDGETS } from './appPickerWidgetOptions';
import { APP_PICKER_PALETTE_ENTRIES, ARRAY_ITEMS_APP_PICKER_ENTRIES, ARRAY_ITEMS_APP_PICKER_WIDGETS } from '../AppTypeWidgets';
import { TypeDefinition } from '../client-types';
import { FolderTree, Link2 } from 'lucide-react';
import { useBuilderStore } from './useStore';
export const TypeBuilderContent: React.FC<{
mode: BuilderMode;
setMode: (m: BuilderMode) => void;
elements: BuilderElement[];
setElements: React.Dispatch<React.SetStateAction<BuilderElement[]>>;
selectedId: string | null;
setSelectedId: (id: string | null) => void;
onCancel: () => void;
onSave: (data: BuilderOutput) => void;
deleteElement: (id: string) => void;
removeElement: (id: string) => void;
updateSelectedElement: (updates: Partial<BuilderElement>) => void;
selectedElement?: BuilderElement;
availableTypes: TypeDefinition[];
typeName: string;
setTypeName: (n: string) => void;
typeDescription: string;
setTypeDescription: (d: string) => void;
fieldsToDelete: string[];
types: TypeDefinition[];
enumValues: EnumValueEntry[];
setEnumValues: React.Dispatch<React.SetStateAction<EnumValueEntry[]>>;
flagValues: FlagValueEntry[];
setFlagValues: React.Dispatch<React.SetStateAction<FlagValueEntry[]>>;
selectedType?: TypeDefinition;
onUpdateMeta?: (meta: any) => Promise<void>;
}> = ({
mode, setMode, elements, setElements, selectedId, setSelectedId,
onCancel, onSave, deleteElement, removeElement, updateSelectedElement, selectedElement,
availableTypes, typeName, setTypeName, typeDescription, setTypeDescription, fieldsToDelete,
types, enumValues, setEnumValues, flagValues, setFlagValues, selectedType, onUpdateMeta
onCancel, onSave,
availableTypes, types, selectedType, onUpdateMeta
}) => {
const {
mode, setMode, elements, setElements, selectedId, setSelectedId,
deleteElement, removeElement, updateSelectedElement,
typeName, setTypeName, typeDescription, setTypeDescription, fieldsToDelete,
enumValues, setEnumValues, flagValues, setFlagValues
} = useBuilderStore();
const selectedElement = elements.find(e => e.id === selectedId);
const [showCategoryManager, setShowCategoryManager] = React.useState(false);
const { setNodeRef: setCanvasRef, isOver } = useDroppable({
id: 'canvas',
@ -389,9 +378,9 @@ export const TypeBuilderContent: React.FC<{
<div className="text-[10px] text-muted-foreground mb-2">
Assign this type to specific categories.
</div>
<Button
variant="outline"
size="sm"
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => setShowCategoryManager(true)}
>

View File

@ -1,48 +0,0 @@
/**
* Static groups for ui:widget entries backed by appPickerWidgets.tsx.
* Add more entries here when new RJSF picker widgets are implemented.
*/
/** What is persisted in form data for each ui:widget (for authors / codegen). */
export const APP_PICKER_STORAGE_HINT: Record<string, string> = {
pagePicker: 'Page UUID',
postPicker: 'Post UUID',
filePicker: 'VFS location as mount:path or mount:dir/file.ext',
categoryPicker: 'Category UUID',
userPicker: 'User id (UUID)',
groupPicker: 'ACL group name (string)',
};
export const APP_PICKER_WIDGET_GROUPS = [
{
label: 'App pickers',
options: [
{ value: 'pagePicker', label: 'Page (ID)', types: ['string'] as const },
{ value: 'postPicker', label: 'Post (ID)', types: ['string'] as const },
{ value: 'filePicker', label: 'VFS file / path', types: ['string'] as const },
],
},
] as const;
/** Drag palette + default JSON keys when dropping onto a structure */
export const APP_PICKER_PALETTE_ENTRIES = [
{ widget: 'pagePicker' as const, defaultFieldName: 'pageId', paletteTitle: 'Page (ID)' },
{ widget: 'postPicker' as const, defaultFieldName: 'postId', paletteTitle: 'Post (ID)' },
{ widget: 'filePicker' as const, defaultFieldName: 'filePath', paletteTitle: 'VFS file / path' },
] as const;
/**
* Array field "Items Type" string items plus a per-item ui:widget (same widgets as single-field pickers).
*/
export const ARRAY_ITEMS_APP_PICKER_ENTRIES = [
{ widget: 'categoryPicker' as const, label: 'Category' },
{ widget: 'userPicker' as const, label: 'User' },
{ widget: 'groupPicker' as const, label: 'Group' },
{ widget: 'pagePicker' as const, label: 'Page (ID)' },
{ widget: 'postPicker' as const, label: 'Post (ID)' },
{ widget: 'filePicker' as const, label: 'VFS file / path' },
] as const;
export const ARRAY_ITEMS_APP_PICKER_WIDGETS: ReadonlySet<string> = new Set(
ARRAY_ITEMS_APP_PICKER_ENTRIES.map((e) => e.widget)
);

View File

@ -11,6 +11,17 @@ import { BuilderElement } from './types';
import { getIconForType, resolvePrimitiveType, WIDGET_OPTIONS } from './utils';
import { TypeDefinition } from '../client-types';
/**
* DraggablePaletteItem
*
* Represents an item in the left-hand sidebar palette (e.g., primitive types or existing models)
* that the user can drag and drop onto the builder canvas to add a new field to their structure.
*
* Utilizes `@dnd-kit/core`'s `useDraggable` hook to enable drag capabilities.
*
* @param props.item - The element object containing type, title, and other metadata.
*/
export const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: item.id,
@ -33,6 +44,21 @@ export const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
);
};
/**
* CanvasElement
*
* Represents a field that has been dropped onto the central builder canvas.
*
* Enables reordering via drag-and-drop using `@dnd-kit/sortable`'s `useSortable` hook.
* It also encapsulates a delete/remove confirmation dialog, specifically distinguishing
* between removing an inline primitive field versus unlinking/deleting a complex referenced type.
*
* @param props.element - The active element structure currently on the canvas.
* @param props.isSelected - Indicates whether this element is actively selected in the canvas.
* @param props.onSelect - Callback triggered when the user clicks the element to edit its properties.
* @param props.onDelete - Callback to permanently delete a referenced type from the underlying database.
* @param props.onRemoveOnly - Optional callback to merely unlink the field from the structure without destroying the type.
*/
export const CanvasElement = ({
element,
isSelected,
@ -160,12 +186,26 @@ export const CanvasElement = ({
);
};
/**
* WidgetPicker
*
* A specialized select input that allows users to pick a custom RJSF widget
* (e.g. a date picker, color picker, or app browser) for the current field.
*
* It filters the list of available widgets based on the field's underlying primitive type.
* If no predefined widget matches the user's needs, it supports entering a freeform "custom" string.
*
* @param props.value - The currently selected custom widget's name (or undefined if defaulting).
* @param props.onChange - Callback to map the updated widget selection back to the field's UI schema.
* @param props.fieldType - The type identifier of the current element.
* @param props.types - Full registry of all available type definitions (to resolve complex primitives).
*/
export const WidgetPicker = ({ value, onChange, fieldType, types }: { value: string | undefined, onChange: (val: string | undefined) => void, fieldType: string, types: TypeDefinition[] }) => {
const primitiveType = resolvePrimitiveType(fieldType, types);
const filteredOptions = WIDGET_OPTIONS.map(group => ({
...group,
options: group.options.filter(opt => !opt.types || opt.types.includes(primitiveType))
options: group.options.filter(opt => !opt.types || (opt.types as string[]).includes(primitiveType))
})).filter(group => group.options.length > 0);
const isCustom = value !== undefined && (value === '' || !filteredOptions.some(g => g.options.some(o => o.value === value)));

View File

@ -0,0 +1,94 @@
import { create } from 'zustand';
import { BuilderMode, BuilderElement, EnumValueEntry, FlagValueEntry, BuilderOutput } from './types';
interface BuilderState {
mode: BuilderMode;
typeName: string;
typeDescription: string;
elements: BuilderElement[];
selectedId: string | null;
activeDragItem: BuilderElement | null;
fieldsToDelete: string[];
enumValues: EnumValueEntry[];
flagValues: FlagValueEntry[];
// Setters
setMode: (mode: BuilderMode) => void;
setTypeName: (name: string) => void;
setTypeDescription: (desc: string) => void;
setElements: (elements: BuilderElement[] | ((prev: BuilderElement[]) => BuilderElement[])) => void;
setSelectedId: (id: string | null) => void;
setActiveDragItem: (item: BuilderElement | null) => void;
setFieldsToDelete: (fields: string[] | ((prev: string[]) => string[])) => void;
setEnumValues: (values: EnumValueEntry[] | ((prev: EnumValueEntry[]) => EnumValueEntry[])) => void;
setFlagValues: (values: FlagValueEntry[] | ((prev: FlagValueEntry[]) => FlagValueEntry[])) => void;
// Actions
deleteElement: (id: string) => void;
removeElement: (id: string) => void;
updateSelectedElement: (updates: Partial<BuilderElement>) => void;
// Initializer
initialize: (data?: BuilderOutput) => void;
}
export const useBuilderStore = create<BuilderState>((set) => ({
mode: 'structure',
typeName: '',
typeDescription: '',
elements: [],
selectedId: null,
activeDragItem: null,
fieldsToDelete: [],
enumValues: [],
flagValues: [],
setMode: (mode) => set({ mode }),
setTypeName: (typeName) => set({ typeName }),
setTypeDescription: (typeDescription) => set({ typeDescription }),
setElements: (updater) => set((state) => ({
elements: typeof updater === 'function' ? updater(state.elements) : updater
})),
setSelectedId: (selectedId) => set({ selectedId }),
setActiveDragItem: (activeDragItem) => set({ activeDragItem }),
setFieldsToDelete: (updater) => set((state) => ({
fieldsToDelete: typeof updater === 'function' ? updater(state.fieldsToDelete) : updater
})),
setEnumValues: (updater) => set((state) => ({
enumValues: typeof updater === 'function' ? updater(state.enumValues) : updater
})),
setFlagValues: (updater) => set((state) => ({
flagValues: typeof updater === 'function' ? updater(state.flagValues) : updater
})),
deleteElement: (id) => set((state) => {
const el = state.elements.find(e => e.id === id);
const newFieldsToDelete = el && el.refId ? [...state.fieldsToDelete, el.refId] : state.fieldsToDelete;
return {
elements: state.elements.filter(e => e.id !== id),
fieldsToDelete: newFieldsToDelete,
selectedId: state.selectedId === id ? null : state.selectedId
};
}),
removeElement: (id) => set((state) => ({
elements: state.elements.filter(e => e.id !== id),
selectedId: state.selectedId === id ? null : state.selectedId
})),
updateSelectedElement: (updates) => set((state) => ({
elements: state.elements.map(e => e.id === state.selectedId ? { ...e, ...updates } : e)
})),
initialize: (data) => set({
mode: data?.mode || 'structure',
typeName: data?.name || '',
typeDescription: data?.description || '',
elements: data?.elements || [],
enumValues: data?.enumValues || [],
flagValues: data?.flagValues || [],
selectedId: null,
activeDragItem: null,
fieldsToDelete: []
})
}));

View File

@ -1,6 +1,6 @@
import { Type as TypeIcon, Hash, ToggleLeft, Box, List, FileJson } from 'lucide-react';
import { TypeDefinition } from '../client-types';
import { APP_PICKER_WIDGET_GROUPS } from './appPickerWidgetOptions';
import { APP_PICKER_WIDGET_GROUPS } from '../AppTypeWidgets';
export function getIconForType(type: string | undefined) {
if (!type) return FileJson;

View File

@ -1,39 +0,0 @@
import React from 'react';
import type { WidgetProps } from '@rjsf/utils';
import { getUiOptions } from '@rjsf/utils';
import { CategoryPickerField } from '@/components/widgets/CategoryPickerField';
type CategoryUiOptions = {
filterType?: string;
};
/**
* RJSF widget wrapping CategoryPickerField. Stores selected category id (string).
* Optional `ui:options.filterType` matches CategoryPickerField (e.g. "types", "pages").
*/
export const CategoryPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus, schema, uiSchema } = props;
const uiOptions = getUiOptions(uiSchema) as CategoryUiOptions;
const filterType = uiOptions.filterType ?? (schema as { 'x-category-filter-type'?: string })?.['x-category-filter-type'];
const v = value == null ? '' : String(value);
if (readonly || disabled) {
return (
<div
id={id}
className="text-xs text-muted-foreground py-1"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
{v || '—'}
</div>
);
}
return (
<div onBlur={() => onBlur(id, value)} onFocus={() => onFocus(id, value)}>
<CategoryPickerField value={v} onSelect={(categoryId) => onChange(categoryId || undefined)} filterType={filterType} />
</div>
);
};

View File

@ -1,39 +0,0 @@
import React from 'react';
import type { WidgetProps } from '@rjsf/utils';
import { GroupPicker } from '@/components/admin/GroupPicker';
/**
* RJSF widget wrapping GroupPicker (single). Stores selected group name (string), matching GroupPicker onSelect.
*/
export const GroupPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const v = value == null ? '' : String(value);
if (readonly) {
return (
<div
id={id}
className="text-xs text-muted-foreground py-1"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
{v || '—'}
</div>
);
}
return (
<div
className="[&_[role=combobox]]:min-h-8 [&_[role=combobox]]:px-2 [&_[role=combobox]]:py-1.5 [&_[role=combobox]]:text-xs"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
<GroupPicker
value={v || undefined}
disabled={disabled}
multi={false}
onSelect={(groupName) => onChange(groupName || undefined)}
/>
</div>
);
};

View File

@ -1,19 +1,5 @@
// Helper functions for generating random form data based on JSON schema
export const generateRandomData = (schema: any): any => {
if (!schema) return null;
if (schema.type === 'object' && schema.properties) {
const data: any = {};
Object.keys(schema.properties).forEach(key => {
data[key] = generateValueForSchema(schema.properties[key], key);
});
return data;
}
return generateValueForSchema(schema, 'root');
};
const generateValueForSchema = (prop: any, key: string): any => {
const type = prop.type;
const fieldName = key.toLowerCase();
@ -83,3 +69,17 @@ const generateValueForSchema = (prop: any, key: string): any => {
return null;
}
};
export const generateRandomData = (schema: any): any => {
if (!schema) return null;
if (schema.type === 'object' && schema.properties) {
const data: any = {};
Object.keys(schema.properties).forEach(key => {
data[key] = generateValueForSchema(schema.properties[key], key);
});
return data;
}
return generateValueForSchema(schema, 'root');
};

View File

@ -1,130 +0,0 @@
import React from 'react';
import type { FormContextType, IconButtonProps, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronUp, Copy, Plus, Trash2, X } from 'lucide-react';
const iconBtn =
'h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground hover:bg-muted';
/**
* RJSF core ships Bootstrap + glyphicon icon buttons; we don't load those styles, so controls were effectively invisible.
* These replace the array toolbar actions with shadcn + Lucide.
*/
function RjsfToolbarIconButton(
props: IconButtonProps & {
children: React.ReactNode;
destructive?: boolean;
}
) {
const { className, disabled, onClick, title, id, children, destructive, registry: _r, uiSchema: _u, icon: _i, iconType: _it } = props;
const label = typeof title === 'string' ? title : 'Action';
return (
<Button
type="button"
variant="ghost"
size="icon"
id={id}
className={cn(
iconBtn,
destructive && 'text-destructive hover:text-destructive hover:bg-destructive/10',
className
)}
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
>
{children}
</Button>
);
}
export function RjsfRemoveButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className} destructive>
<Trash2 className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfMoveUpButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<ChevronUp className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfMoveDownButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<ChevronDown className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfCopyButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<Copy className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
/** Used for additionalProperties and other add affordances that still use the default AddButton slot. */
export function RjsfAddIconButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<Plus className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
export function RjsfClearButton<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: IconButtonProps<T, S, F>) {
const { className, ...rest } = props;
return (
<RjsfToolbarIconButton {...rest} className={className}>
<X className="h-3.5 w-3.5" />
</RjsfToolbarIconButton>
);
}
/** Partial overrides merged with @rjsf/core defaults (see Form registry merge). */
export const rjsfButtonTemplateOverrides = {
RemoveButton: RjsfRemoveButton,
MoveUpButton: RjsfMoveUpButton,
MoveDownButton: RjsfMoveDownButton,
CopyButton: RjsfCopyButton,
AddButton: RjsfAddIconButton,
ClearButton: RjsfClearButton,
};

View File

@ -1,9 +1,7 @@
// App Level Types & Widgets
import type { RegistryWidgetsType } from '@rjsf/utils';
import { customWidgets } from '@/modules/types/RJSFTemplates';
import { CategoryPickerWidget } from '@/modules/types/categoryPickerWidget';
import { UserPickerWidget } from '@/modules/types/userPickerWidget';
import { GroupPickerWidget } from '@/modules/types/groupPickerWidget';
import { FilePickerWidget, PagePickerWidget, PostPickerWidget } from '@/modules/types/appPickerWidgets';
import { CategoryPickerWidget, FilePickerWidget, GroupPickerWidget, PagePickerWidget, PostPickerWidget, UserPickerWidget } from '@/modules/types/AppTypeWidgets';
/**
* Full RJSF widget registry for app forms. Re-merges picker widgets explicitly so fields like `ui:widget: categoryPicker`

View File

@ -114,7 +114,7 @@ export const generateSchemaForType = (typeId: string, types: TypeDefinition[], v
}
// Fallback for other kinds (alias, etc -> resolve to their target if possible, currently alias is just string for now)
return { type: 'string' };
return type.json_schema || { type: 'string' };
};
// Recursive function to generate UI schema for a type
@ -122,7 +122,17 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[],
if (visited.has(typeId)) return {};
const type = types.find(t => t.id === typeId);
if (!type || type.kind !== 'structure' || !type.structure_fields) {
if (!type) return {};
if (type.kind === 'enum') {
return { 'ui:widget': 'select' };
}
if (type.kind === 'flags') {
return { 'ui:widget': 'checkboxes' };
}
if (type.kind !== 'structure' || !type.structure_fields) {
return {};
}

View File

@ -1,38 +0,0 @@
import React from 'react';
import type { WidgetProps } from '@rjsf/utils';
import { UserPicker } from '@/components/admin/UserPicker';
/**
* RJSF widget wrapping UserPicker. Stores selected user id (string).
*/
export const UserPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const v = value == null ? '' : String(value);
if (readonly) {
return (
<div
id={id}
className="text-xs text-muted-foreground py-1"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
{v || '—'}
</div>
);
}
return (
<div
className="[&_button]:h-8 [&_button]:text-xs [&_button]:font-normal"
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
>
<UserPicker
value={v || undefined}
disabled={disabled}
onSelect={(userId) => onChange(userId || undefined)}
/>
</div>
);
};