types:vfs/cats/groups/posts/pages - cleanup
This commit is contained in:
parent
009332adbf
commit
81dae7a5c5
@ -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>>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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)
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
27
packages/ui/src/modules/types/RJSFForm.tsx
Normal file
27
packages/ui/src/modules/types/RJSFForm.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
|
||||
57
packages/ui/src/modules/types/TypeForm.tsx
Normal file
57
packages/ui/src/modules/types/TypeForm.tsx
Normal 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;
|
||||
@ -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)}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 ? (
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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)
|
||||
);
|
||||
@ -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)));
|
||||
|
||||
94
packages/ui/src/modules/types/builder/useStore.ts
Normal file
94
packages/ui/src/modules/types/builder/useStore.ts
Normal 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: []
|
||||
})
|
||||
}));
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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');
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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`
|
||||
|
||||
@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user