types:vfs/cats/groups/posts/pages

This commit is contained in:
lovebird 2026-04-03 17:15:16 +02:00
parent a9856fa322
commit 009332adbf
21 changed files with 1476 additions and 164 deletions

View File

@ -1,148 +1,178 @@
# Unified Type System
The Type System provides a flexible, schema-driven way to define data structures, enums, flags, and relationships within the Polymech platform. It is built on top of Supabase (PostgreSQL) and supports inheritance, validation (JSON Schema), and strict typing.
Schema-driven definitions for structures, enums, flags, and field wrappers. Persisted in PostgreSQL (Supabase), exposed under `/api/types`, and edited in the app via **visual builder** (`TypeBuilder`) or **JSON schema / UI schema** (`TypeRenderer`). Runtime forms use **@rjsf/core** with templates in `src/modules/types/RJSFTemplates.tsx`.
## Database Schema
For a deeper architecture walkthrough (diagrams, sequence sketches), see [`type-system.md`](./type-system.md).
The system consists of the following core tables in the `public` schema.
---
### Core Tables
## Type kinds (`types.kind`)
#### `types`
The main table storing type definitions.
| Kind | Role |
|------|------|
| `primitive` | Built-in value kinds: `string`, `int`, `float`, `bool`, `array`, `object`, `enum`, `flags`, `reference`, `alias`, … |
| `enum` | Custom enum: values live in `type_enum_values`. |
| `flags` | Custom flags: values live in `type_flag_values`. |
| `structure` | Object shape: members listed in `type_structure_fields`. |
| `field` | **Per-structure field row**: name `{StructureName}.{fieldName}`, `parent_type_id` → the **value** type (primitive, custom enum, flags, nested structure, array target, …). Referenced by `type_structure_fields.field_type_id`. |
| `alias` | Treated as string in generated JSON Schema until fully resolved. |
- `id`: UUID (Primary Key)
- `name`: Text (Unique constraint usually desired but not strictly enforced at DB level yet)
- `kind`: Enum (`primitive`, `enum`, `flags`, `structure`, `alias`)
- `parent_type_id`: UUID (Foreign Key -> `types.id`). Supports inheritance.
- `description`: Text (Optional)
- `json_schema`: JSONB (JSON Schema fragment validation)
- `owner_id`: UUID (Foreign Key -> `auth.users.id`)
- `visibility`: Enum (`public`, `private`, `custom`)
- `meta`: JSONB (Arbitrary metadata)
- `settings`: JSONB (UI/Editor settings)
- `created_at`, `updated_at`: Timestamps
Structures never point `field_type_id` at a bare custom enum id; they point at a **`field` row** whose parent is that enum. That allows per-field `meta`, `settings` (defaults, `items_type_id`, `group`), and descriptions.
#### `type_enum_values`
Values for `enum` types.
---
- `id`: UUID
- `type_id`: UUID (FK -> `types.id`)
- `value`: Text (The raw value)
- `label`: Text (Display label)
- `order`: Integer (Sort order)
## Database (summary)
#### `type_flag_values`
Bit definitions for `flags` types.
### `types`
- `id`: UUID
- `type_id`: UUID (FK -> `types.id`)
- `name`: Text (Flag name)
- `bit`: Integer (Power of 2 or bit index)
- `id`, `name`, `kind`, `parent_type_id` (inheritance / alias target / **field → value type**)
- `description`, `json_schema` (JSONB), `owner_id`, `visibility` (`public` \| `private` \| `custom`)
- `meta` (JSONB) — e.g. `meta.uiSchema` for RJSF overrides on this type
- `settings` (JSONB) — e.g. `default_value`, `items_type_id`, `group` on **field** kinds
- `created_at`, `updated_at`
#### `type_structure_fields`
Field definitions for `structure` types.
### `type_enum_values` / `type_flag_values`
- `id`: UUID
- `structure_type_id`: UUID (FK -> `types.id`)
- `field_name`: Text
- `field_type_id`: UUID (FK -> `types.id`)
- `required`: Boolean
- `default_value`: JSONB
- `order`: Integer
- Enum: `type_id`, `value`, `label`, `order`
- Flags: `type_id`, `name`, `bit`
#### `type_casts`
Transformation rules between types.
### `type_structure_fields`
- `from_type_id`: UUID
- `to_type_id`: UUID
- `cast_kind`: Enum (`implicit`, `explicit`, `lossy`)
- `description`: Text
- `structure_type_id` → structure `types.id`
- `field_name`, `field_type_id`**`kind: field`** type id (not the raw enum id)
- `required`, `default_value` (JSONB), `order`
## Access Control (RLS)
`required` is also reflected in generated JSON Schema `required[]`. If you edit the JSON Schema tab and save, the client can sync `structure_fields[].required` from `json_schema.required` when that array is present.
Row Level Security is enabled on all tables.
### `type_casts`
### Policies
- **Read**:
- `public` types are visible to **all users** (authenticated and anonymous).
- `private` and `custom` types are visible only to their **owner** (creator) and **admins**.
- **Write** (Create, Update, Delete):
- **Owners** can modify their own types.
- **Admins** can modify any type.
- Ordinary users cannot modify system types (types with no owner or owned by system).
Transformation metadata between types (`from_type_id`, `to_type_id`, `cast_kind`, …).
## API Endpoints
---
The type system is exposed via a RESTful API under `/api/types`.
## Server API (`/api/types`)
### List Types
`GET /api/types`
Implemented in `server/src/products/serving/db/db-types.ts`. OpenAPI route definitions and Zod shapes live in that file (`TypeWithRelationsSchema`, `ExtendedTypeInsertSchema`, `ExtendedTypeUpdateSchema`).
**Query Parameters:**
- `kind`: Filter by type kind (e.g., `structure`).
- `parentTypeId`: Filter by parent type.
- `visibility`: Filter by visibility.
List/detail responses **enrich** each row in memory with:
### Get Type Details
`GET /api/types/:id`
- `structure_fields`, `enum_values`, `flag_values`
Returns the full type definition, including:
- Enum values (if enum)
- Flag values (if flags)
- Structure fields (if structure)
- Cast definitions
`type_casts` are loaded into the server cache object but **not** attached per type in JSON responses (only the three relations above).
### Create Type
`POST /api/types`
### `GET /api/types`
Creates a new type. Supports atomic creation of children (enums/flags/fields).
**Query** (all optional, exact match filters):
- `kind` — e.g. `structure`, `enum`, `field`
- `parentTypeId` — types whose `parent_type_id` equals this id
- `visibility``public` \| `private` \| `custom`
**Response header:** `X-Cache: HIT` \| `MISS` (server aggregate cache).
Returns a **sorted** array (by `name`).
### `GET /api/types/:id`
Returns one enriched type, or **404** `{ "error": "Type not found" }` if the id is missing from the cache map (same backing data as the list endpoint).
### `POST /api/types`
Body uses **snake_case** keys matching persistence, e.g.:
**Body Payload:**
```json
{
"name": "MyType",
"name": "MyEnum",
"kind": "enum",
"description": "A custom enum",
"description": "",
"visibility": "public",
"enumValues": [
{ "value": "A", "label": "Option A" },
{ "value": "B", "label": "Option B" }
"enum_values": [
{ "value": "a", "label": "A", "order": 0 },
{ "value": "b", "label": "B", "order": 1 }
]
}
```
### Update Type
`PATCH /api/types/:id`
Structures can send `structure_fields` in the same request; nested `field` types are usually created first (see app flow).
Updates metadata (name, description, visibility, etc.).
*Note: Child modifications (adding fields/enums) should be handled via separate specific updates or by implementing deep update logic.*
**Auth:** If `Authorization: Bearer <token>` is present and resolves to a user, **`owner_id`** is set on the new row from that user.
### Delete Type
`DELETE /api/types/:id`
**Response:** **200** with the full enriched type (same shape as GET by id).
Deletes a type and cascades to its children (values, fields).
### `PATCH /api/types/:id`
## Primitive Types
**Core row** (`types` table): the handler updates only **`name`**, **`description`**, **`json_schema`**, **`visibility`**, **`meta`**, **`settings`** — read from the parsed body. Because the server forwards those properties explicitly, keys **missing** from JSON arrive as **`undefined`** in JS; verify your client / PostgREST behavior (unintended **`null`** clears are possible if you PATCH with a sparse body). The apps `updateType` client usually sends a merged object for `meta` when needed.
The system is seeded with the following primitive types (immutable system types):
- `bool`
- `int`
- `float`
- `string`
- `array`
- `object`
- `enum`
- `flags`
- `reference`
- `alias`
**Not** updated via this path: **`kind`**, **`parent_type_id`**, **`owner_id`** (change those only with direct DB / a different API if added later).
- `alias`
**Child tables** (only when the corresponding key is present and is an array):
## Caching & Consistency
| Key | Behavior |
|-----|----------|
| `structure_fields` | **Replace** all rows for this structure (delete by `structure_type_id`, then insert). |
| `enum_values` | **Replace** all rows for this enum type. |
| `flag_values` | **Replace** all rows for this flags type. |
| `fieldsToDelete` | After the above, **`deleteType`** each id (e.g. orphaned **`field`** types). |
The Type System uses a high-performance in-memory caching layer (`AppCache`) to ensure fast read access.
**Response:** **200** with the full enriched type after `flushTypeCache()` and `appCache.notify('type', id, 'update')`.
- **Read Operations**: Responses for `/api/types` are cached with a 5-minute TTL.
- **Write Operations**: Creating, Updating, or Deleting types immediately **invalidates** the cache.
- **Real-time Updates**: Clients connected to the SSE stream (`/api/stream`) receive `app-update` events (type: `types`), allowing UIs to refresh schemas instantly without manual reloading.
### `DELETE /api/types/:id`
Deletes the **`types`** row (CASCADE removes `type_structure_fields` links). If the deleted row was a **structure**, the server then **best-effort deletes** the former `field_type_id` entries (no longer referenced). Failures on that second step are logged but do not roll back the main delete.
**Response:** **200** `{ "success": true }`, plus cache flush / notify.
---
## Caching
- Server: in-memory cache for the types aggregate (`getTypeState` in `db-types.ts`), TTL **5 minutes**; **create / update / delete** call **`flushTypeCache()`** and **`appCache.notify('type', id, …)`** for downstream listeners.
- Client: `fetchWithDeduplication` / invalidation in `src/modules/types/client-types.ts` (`invalidateCache` on create/update/delete).
---
## Client modules (pm-pics)
| Area | Location |
|------|----------|
| Fetch / CRUD | `src/modules/types/client-types.ts` |
| Schema + UI generation | `src/modules/types/schema-utils.ts``generateSchemaForType`, `generateUiSchemaForType`, `deepMergeUiSchema` |
| Editor shell | `src/modules/types/TypesEditor.tsx` — visual vs detail mode, save handlers |
| Visual builder | `src/modules/types/TypeBuilder.tsx`, `builder/TypeBuilderContent.tsx` |
| JSON / preview | `src/modules/types/TypeRenderer.tsx` |
| RJSF widgets/templates | `src/modules/types/RJSFTemplates.tsx` |
### Generated JSON Schema & UI schema
- For `structure` / `enum` / `flags`, the UI builds from **`generateSchemaForType`** / **`generateUiSchemaForType`**, then merges **`meta.uiSchema`** from the type with `deepMergeUiSchema` (structure-level overrides win on conflicts).
- **`meta.uiSchema`** holds RJSF keys: per-property widgets, `ui:group` for sections, nested `items` for arrays, root `ui:classNames`, etc.
- Field-level customization also lives on the **`field`** type: `field.meta.uiSchema` and `field.settings` (group, defaults, `items_type_id`). The visual builder merges structure `meta.uiSchema[fieldName]` with the field type when loading so JSON-tab edits round-trip.
### Field parent resolution (critical)
- Palette drops store `refId` = chosen type id. When loading from DB, **`structureFieldToBuilderElement`** sets `type` and **`refId`** from `field.parent_type_id`** so saves resolve the value type, not the `Struct.field` name string.
- **`generateSchemaForType`** treats `kind === 'field'` as a transparent wrapper and resolves through `parent_type_id`.
### Forms & groups
- Custom **`ObjectFieldTemplate`** groups properties by `ui:group` (collapsible sections).
- Default widgets: enum → `select`, flags → `checkboxes`, custom widgets registered in `customWidgets`.
---
## Primitive types (seed)
Typical seeds include: `string`, `int`, `float`, `bool`, `array`, `object`, `enum`, `flags`, `reference`, `alias`.
Using the **primitive** `enum` / `flags` in a structure yields **empty** enums until you use a **custom** `enum` / `flags` type with values defined — custom types carry `enum_values` / `flag_values` in API responses.
---
## Access control (RLS)
Row Level Security applies on the underlying tables. Typical intent:
- **Read**: `public` types visible broadly; `private` / `custom` restricted by owner/admin rules (see policies in Supabase).
- **Write**: owners and admins per product rules.
Exact policies live with the database migrations; treat this section as behavioral summary, not the source of truth for SQL.

View File

@ -0,0 +1,108 @@
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 type { TypeDefinition } from '@/modules/types/client-types';
import { Label } from '@/components/ui/label';
import { T } from '@/i18n';
const SingleTypeForm = ({
typeId,
typeDef,
types,
formData,
onChange,
disabled,
}: {
typeId: string;
typeDef: TypeDefinition;
types: TypeDefinition[];
formData: Record<string, unknown>;
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}
formData={formData}
validator={validator}
widgets={rjsfWidgetRegistry}
templates={customTemplates}
disabled={disabled}
readonly={disabled}
omitExtraData
liveValidate={false}
onChange={({ formData: fd }) => onChange((fd || {}) as Record<string, unknown>)}
>
<span className="sr-only">.</span>
</Form>
</div>
);
};
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). */
types: TypeDefinition[];
/** Per-type id → form payload (structure field values) */
value: Record<string, Record<string, unknown>>;
onChange: (typeId: string, formData: Record<string, unknown>) => void;
disabled?: boolean;
}
/**
* Renders one @rjsf form per assigned type so product settings can hold structured payloads
* (e.g. pricing dimensions) defined in the type system.
*/
export const ProductTypeDataForms: React.FC<ProductTypeDataFormsProps> = ({
typeIds,
types,
value,
onChange,
disabled,
}) => {
const resolved = useMemo(() => {
return typeIds
.map((id) => ({ id, def: types.find((t) => t.id === id) }))
.filter((x): x is { id: string; def: TypeDefinition } => !!x.def);
}, [typeIds, types]);
if (resolved.length === 0) {
return (
<p className="text-xs text-muted-foreground italic py-2">
<T>Assign structure types in Product Settings to edit type-based fields here.</T>
</p>
);
}
return (
<div className="space-y-6">
{resolved.map(({ id, def }) => (
<SingleTypeForm
key={id}
typeId={id}
typeDef={def}
types={types}
formData={value[id] ?? {}}
onChange={(fd) => onChange(id, fd)}
disabled={disabled}
/>
))}
</div>
);
};

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -9,27 +10,56 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { fetchProducts, createProduct, updateProduct, deleteProduct, Product } from '@/modules/ecommerce/client-products';
import { fetchTypes } from '@/modules/types/client-types';
import { VariablesEditor } from '@/components/variables/VariablesEditor';
import { GroupPicker } from '@/components/admin/GroupPicker';
import { ProductTypeDataForms } from '@/components/admin/ProductTypeDataForms';
import { Badge } from '@/components/ui/badge';
/** Merge list row + optional snake_case / legacy keys so Edit form matches DB after Settings save (avoids race before loadProducts finishes). */
function normalizeProductSettings(raw: Record<string, unknown> | null | undefined): Record<string, unknown> {
const s: Record<string, unknown> = raw && typeof raw === 'object' ? { ...raw } : {};
const ids =
s.assignedTypeIds ??
s.assigned_type_ids;
if (ids !== undefined) {
s.assignedTypeIds = Array.isArray(ids) ? ids : [];
}
return s;
}
const PRODUCT_VARIABLE_SCHEMA = {
enabled: { label: translate("Enabled"), description: translate("Is the product enabled?") },
default_cost_units: { label: translate("Default Cost Units"), description: translate("Cost per use") },
default_rate_limit: { label: translate("Default Rate Limit"), description: translate("Requests per window limit") },
default_rate_window: { label: translate("Default Rate Window Time"), description: translate("Time window in seconds") }
default_rate_window: { label: translate("Default Rate Window Time"), description: translate("Time window in seconds") },
productTypeData: {
label: translate("Type data (per type id)"),
description: translate("JSON map of type id → payload; edited in Product Edit or here."),
},
};
const ProductsManager = () => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
``
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
const [settingsProduct, setSettingsProduct] = useState<Product | null>(null);
/** Mirrors CategoryManager `meta.assignedTypes` — type ids allowed for this product's edit form */
const [settingsAssignedTypeIds, setSettingsAssignedTypeIds] = useState<string[]>([]);
const { data: allTypes = [] } = useQuery({
queryKey: ['types', 'all', 'products'],
queryFn: () => fetchTypes(),
staleTime: 1000 * 60 * 2,
});
const assignableTypes = useMemo(
() => allTypes.filter((t) => t.kind === 'structure' || t.kind === 'alias'),
[allTypes]
);
// Form states
const [formData, setFormData] = useState({
@ -66,23 +96,79 @@ const ProductsManager = () => {
setIsCreateOpen(true);
};
const handleEditOpen = (product: Product) => {
const handleEditOpen = async (product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
slug: product.slug, // Can't typically change slug but we might allow or hide
slug: product.slug,
description: product.description || '',
settings: product.settings || {}
settings: normalizeProductSettings(product.settings as Record<string, unknown>),
});
try {
const list = await fetchProducts();
const fresh = list.find((row) => row.id === product.id) ?? product;
setProducts(list);
setEditingProduct(fresh);
setFormData({
name: fresh.name,
slug: fresh.slug,
description: fresh.description || '',
settings: normalizeProductSettings(fresh.settings as Record<string, unknown>),
});
} catch (e) {
console.error(e);
}
};
const handleProductTypeDataChange = (typeId: string, data: Record<string, unknown>) => {
setFormData((prev) => ({
...prev,
settings: {
...(prev.settings || {}),
productTypeData: {
...((prev.settings?.productTypeData as Record<string, Record<string, unknown>>) || {}),
[typeId]: data,
},
},
}));
};
const handleFormAssignedTypeToggle = (typeId: string, checked: boolean) => {
setFormData((prev) => {
const current = (prev.settings?.assignedTypeIds as string[] | undefined) || [];
const nextIds = checked ? [...new Set([...current, typeId])] : current.filter((id) => id !== typeId);
const ptd = { ...((prev.settings?.productTypeData as Record<string, Record<string, unknown>>) || {}) };
if (!checked) delete ptd[typeId];
return {
...prev,
settings: {
...(prev.settings || {}),
assignedTypeIds: nextIds,
productTypeData: ptd,
},
};
});
setEditingProduct(product);
};
const handleSave = async () => {
try {
const base = (editingProduct?.settings || {}) as Record<string, unknown>;
const mergedSettings = { ...base, ...formData.settings } as Record<string, unknown>;
const assignedIds = (mergedSettings.assignedTypeIds as string[] | undefined) || [];
const ptd = (mergedSettings.productTypeData || {}) as Record<string, Record<string, unknown>>;
const prunedTypeData = Object.fromEntries(
Object.entries(ptd).filter(([k]) => assignedIds.includes(k))
);
const settingsPayload = {
...mergedSettings,
productTypeData: prunedTypeData,
};
if (editingProduct) {
await updateProduct(editingProduct.slug, {
name: formData.name,
description: formData.description,
settings: formData.settings
settings: settingsPayload
});
toast.success(translate('Product updated successfully'));
setIsCreateOpen(false);
@ -92,7 +178,7 @@ const ProductsManager = () => {
name: formData.name,
slug: formData.slug || undefined,
description: formData.description,
settings: formData.settings
settings: settingsPayload
});
toast.success(translate('Product created successfully'));
setIsCreateOpen(false);
@ -124,18 +210,37 @@ const ProductsManager = () => {
};
const settingsProductRef = useRef(settingsProduct);
const settingsAssignedTypeIdsRef = useRef(settingsAssignedTypeIds);
useEffect(() => {
settingsProductRef.current = settingsProduct;
}, [settingsProduct]);
useEffect(() => {
settingsAssignedTypeIdsRef.current = settingsAssignedTypeIds;
}, [settingsAssignedTypeIds]);
useEffect(() => {
if (settingsProduct) {
setSettingsAssignedTypeIds(settingsProduct.settings?.assignedTypeIds || []);
}
}, [settingsProduct]);
const handleLoadSettings = useCallback(async () => {
return settingsProductRef.current?.settings || {};
const s = settingsProductRef.current?.settings || {};
const { assignedTypeIds: _a, ...rest } = s;
return rest;
}, []);
const handleSaveSettings = useCallback(async (data: Record<string, any>) => {
if (!settingsProduct) return;
await updateProduct(settingsProduct.slug, { settings: data });
setSettingsProduct({ ...settingsProduct, settings: data });
const prev = settingsProduct.settings || {};
const assignedTypeIds = settingsAssignedTypeIdsRef.current;
const nextSettings = {
...prev,
...data,
assignedTypeIds,
};
await updateProduct(settingsProduct.slug, { settings: nextSettings });
setSettingsProduct({ ...settingsProduct, settings: nextSettings });
loadProducts();
}, [settingsProduct]);
@ -240,7 +345,7 @@ const ProductsManager = () => {
setEditingProduct(null);
}
}}>
<DialogContent>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingProduct ? <T>Edit Product</T> : <T>Create Product</T>}</DialogTitle>
</DialogHeader>
@ -305,6 +410,61 @@ const ProductsManager = () => {
<T>Members of these groups will inherently be granted access to this product.</T>
</p>
</div>
<div className="grid gap-2 mt-2 border-t pt-4">
<Label><T>Assigned types</T></Label>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
{assignableTypes.length === 0 && (
<div className="text-xs text-muted-foreground p-1">
<T>No assignable types found.</T>
</div>
)}
{assignableTypes.map((type) => {
const assigned = (formData.settings?.assignedTypeIds as string[] | undefined) || [];
const checked = assigned.includes(type.id);
return (
<div key={type.id} className="flex items-center gap-2">
<Checkbox
id={`product-form-type-${type.id}`}
checked={checked}
onCheckedChange={(v) =>
handleFormAssignedTypeToggle(type.id, v === true)
}
/>
<Label
htmlFor={`product-form-type-${type.id}`}
className="text-sm font-normal cursor-pointer"
>
{type.name}{' '}
<span className="text-xs text-muted-foreground">({type.kind})</span>
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-muted-foreground">
<T>Structure and alias types enabled for this product. You can also change them under Product Settings.</T>
</p>
</div>
<div className="grid gap-2 mt-2 border-t pt-4">
<Label><T>Type data</T></Label>
<p className="text-xs text-muted-foreground">
<T>Structured fields from the types selected above (saved with this form).</T>
</p>
{(formData.settings?.assignedTypeIds?.length ?? 0) === 0 ? (
<p className="text-xs text-muted-foreground italic py-2 border rounded-md px-3 bg-muted/30">
<T>Select one or more types above to edit type-based fields here.</T>
</p>
) : (
<ProductTypeDataForms
typeIds={formData.settings?.assignedTypeIds || []}
types={allTypes}
value={(formData.settings?.productTypeData || {}) as Record<string, Record<string, unknown>>}
onChange={handleProductTypeDataChange}
/>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
@ -345,16 +505,65 @@ const ProductsManager = () => {
{/* Settings Dialog */}
<Dialog open={!!settingsProduct} onOpenChange={(open) => !open && setSettingsProduct(null)}>
<DialogContent className="max-w-5xl">
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Product Settings</T>: {settingsProduct?.name}</DialogTitle>
</DialogHeader>
{!!settingsProduct && (
<VariablesEditor
onLoad={handleLoadSettings}
onSave={handleSaveSettings}
variableSchema={PRODUCT_VARIABLE_SCHEMA}
/>
<div className="space-y-6">
<div className="space-y-2">
<Label><T>Assigned types</T></Label>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
{assignableTypes.length === 0 && (
<div className="text-xs text-muted-foreground p-1">
<T>No assignable types found.</T>
</div>
)}
{assignableTypes.map((type) => (
<div key={type.id} className="flex items-center gap-2">
<Checkbox
id={`product-type-${type.id}`}
checked={settingsAssignedTypeIds.includes(type.id)}
onCheckedChange={async (checked) => {
if (!settingsProduct) return;
const next = checked
? [...settingsAssignedTypeIds, type.id]
: settingsAssignedTypeIds.filter((id) => id !== type.id);
setSettingsAssignedTypeIds(next);
try {
const prev = settingsProduct.settings || {};
const nextSettings = {
...prev,
assignedTypeIds: next,
productTypeData: prev.productTypeData || {},
};
await updateProduct(settingsProduct.slug, { settings: nextSettings });
setSettingsProduct({ ...settingsProduct, settings: nextSettings });
loadProducts();
} catch (e) {
console.error(e);
toast.error(translate('Failed to save types'));
setSettingsAssignedTypeIds(settingsProduct.settings?.assignedTypeIds || []);
}
}}
/>
<Label htmlFor={`product-type-${type.id}`} className="text-sm font-normal cursor-pointer">
{type.name}{' '}
<span className="text-xs text-muted-foreground">({type.kind})</span>
</Label>
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground">
<T>These types drive the &quot;Type data&quot; section when editing this product.</T>
</p>
</div>
<VariablesEditor
onLoad={handleLoadSettings}
onSave={handleSaveSettings}
variableSchema={PRODUCT_VARIABLE_SCHEMA}
/>
</div>
)}
</DialogContent>
</Dialog>

View File

@ -6,6 +6,10 @@ export interface ProductSettings {
default_cost_units?: number;
default_rate_limit?: number;
default_rate_window?: number;
/** Type system ids (structure/alias) enabled for this product — configured in admin Product Settings */
assignedTypeIds?: string[];
/** Per-type form payloads keyed by type id (from RJSF in product edit) */
productTypeData?: Record<string, Record<string, unknown>>;
[key: string]: any;
}

View File

@ -3,7 +3,8 @@ import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { TypeDefinition, fetchTypes } from '@/modules/types/client-types';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/modules/types/schema-utils';
import { customWidgets, customTemplates } from '@/modules/types/RJSFTemplates';
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';
@ -184,7 +185,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
uiSchema={finalUiSchema}
formData={typeData}
validator={validator}
widgets={customWidgets}
widgets={rjsfWidgetRegistry}
templates={customTemplates}
onChange={(e) => isEditMode && handleFormChange(type.id, e.formData)}
readonly={!isEditMode}

View File

@ -1,7 +1,8 @@
import React from 'react';
import type { WidgetProps, RegistryWidgetsType } from '@rjsf/utils';
import type { ArrayFieldTemplateProps } from '@rjsf/utils';
import type { WidgetProps, RegistryWidgetsType, ArrayFieldTemplateProps, ArrayFieldItemTemplateProps } 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';
// Utility function to convert camelCase to Title Case
const formatLabel = (str: string): string => {
@ -271,6 +272,38 @@ export const ObjectFieldTemplate = (props: any) => {
);
};
/**
* RJSF default ArrayFieldItemTemplate uses Bootstrap grid classes (col-xs-*); without Bootstrap the
* remove/move toolbar collapses. This layout keeps item content + buttons visible (Tailwind).
*/
export function ArrayFieldItemTemplate<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = Record<string, unknown>,
>(props: ArrayFieldItemTemplateProps<T, S, F>) {
const { children, className, buttonsProps, displayLabel, hasDescription, hasToolbar, registry, uiSchema } = props;
const uiOptions = getUiOptions<T, S, F>(uiSchema);
const ArrayFieldItemButtonsTemplate = getTemplate<'ArrayFieldItemButtonsTemplate', T, S, F>(
'ArrayFieldItemButtonsTemplate',
registry,
uiOptions,
);
return (
<div
className={`${className ?? ''} flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3`.trim()}
>
<div className={`min-w-0 flex-1 ${hasToolbar ? '' : 'w-full'}`}>{children}</div>
{hasToolbar && (
<div
className={`flex shrink-0 flex-wrap items-center justify-end gap-0.5 sm:pt-0.5 ${displayLabel && hasDescription ? 'sm:mt-6' : displayLabel ? 'sm:mt-2' : ''}`}
>
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
</div>
)}
</div>
);
}
// Custom ArrayFieldTemplate for premium array management
export const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => {
const { items, canAdd, onAddClick, title } = props;
@ -317,6 +350,10 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox as RadixCheckbox } from '@/components/ui/checkbox';
@ -404,6 +441,12 @@ export const customWidgets: RegistryWidgetsType = {
SelectWidget,
CheckboxesWidget,
ImageWidget,
pagePicker: PagePickerWidget,
postPicker: PostPickerWidget,
filePicker: FilePickerWidget,
categoryPicker: CategoryPickerWidget,
userPicker: UserPickerWidget,
groupPicker: GroupPickerWidget,
};
// Custom templates
@ -411,4 +454,6 @@ export const customTemplates = {
FieldTemplate,
ObjectFieldTemplate,
ArrayFieldTemplate,
ArrayFieldItemTemplate,
ButtonTemplates: rjsfButtonTemplateOverrides,
};

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useImperativeHandle } from 'react';
import { TypeDefinition } from './client-types';
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, closestCenter } from '@dnd-kit/core';
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor } from '@dnd-kit/core';
import { typeBuilderCollisionDetection } from './builder/typeBuilderCollision';
import { arrayMove } from '@dnd-kit/sortable';
import { BuilderOutput, BuilderElement, BuilderMode, EnumValueEntry, FlagValueEntry } from './builder/types';
import { TypeBuilderContent } from './builder/TypeBuilderContent';
@ -82,20 +83,22 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
id: `field-${Date.now()}`,
type: dragged.type,
name: 'value',
title: dragged.type + ' Alias',
uiSchema: {},
title: dragged.title?.trim() ? dragged.title : dragged.type + ' Alias',
uiSchema: dragged.uiSchema && Object.keys(dragged.uiSchema).length > 0 ? { ...dragged.uiSchema } : {},
refId: dragged.refId
};
setElements([newElement]);
setSelectedId(newElement.id);
} else {
const n = elements.length + 1;
const newElement: BuilderElement = {
id: `field-${Date.now()}`,
type: dragged.type,
name: `field${elements.length + 1}`,
title: `Field ${elements.length + 1}`,
uiSchema: {},
refId: dragged.refId
name: dragged.name?.trim() ? dragged.name : `field${n}`,
title: dragged.title?.trim() ? dragged.title : `Field ${n}`,
uiSchema: dragged.uiSchema && Object.keys(dragged.uiSchema).length > 0 ? { ...dragged.uiSchema } : {},
refId: dragged.refId,
...(dragged.description !== undefined ? { description: dragged.description } : {}),
};
setElements(prev => [...prev, newElement]);
setSelectedId(newElement.id);
@ -126,7 +129,7 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
collisionDetection={typeBuilderCollisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>

View File

@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffe
import { useQuery } from '@tanstack/react-query';
import { fetchCategories, Category } from '@/modules/categories/client-categories';
import { TypeDefinition } from './client-types';
import { FolderTree, ChevronRight, ChevronDown, Loader2, Box, HelpCircle } from 'lucide-react';
import { FolderTree, ChevronRight, ChevronDown, Loader2, Box, HelpCircle, Link2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppConfig } from '@/hooks/useSystemInfo';
@ -12,6 +12,10 @@ export interface TypeCategoryTreeProps {
excludeFieldTypes?: boolean;
className?: string;
selectedId?: string | null;
/** Extra collapsible folders (e.g. app pickers), rendered after Primitives */
paletteExtraFolders?: TypeCategoryPaletteExtraFolder[];
/** Extra draggable rows appended inside the Primitives folder (after DB primitives) */
primitivePaletteExtras?: Array<{ id: string; node: React.ReactNode }>;
}
const COLLAPSED_KEY = 'typeCategoryTreeCollapsed';
@ -25,6 +29,15 @@ type TreeRow = {
isExpanded?: boolean; // Collapse state
typeDef?: TypeDefinition; // Payload if it's a type
parentId: string | null; // Reference for 'ArrowLeft' to jump to parent folder
/** Pre-rendered node (e.g. app picker drags); mutually exclusive with typeDef for rendering */
customContent?: React.ReactNode;
};
export type TypeCategoryPaletteExtraFolder = {
folderId: string;
label: string;
icon?: React.ReactNode;
items: Array<{ id: string; node: React.ReactNode }>;
};
/** Recursively filter a category tree by meta.type */
@ -45,7 +58,9 @@ export const TypeCategoryTree: React.FC<TypeCategoryTreeProps> = ({
renderItem,
excludeFieldTypes = true,
className,
selectedId
selectedId,
paletteExtraFolders,
primitivePaletteExtras,
}) => {
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language;
@ -161,20 +176,51 @@ export const TypeCategoryTree: React.FC<TypeCategoryTreeProps> = ({
return catRows;
};
// Primitives
if (p.length > 0) {
// Primitives (+ optional synthetic entries e.g. category picker)
const primitiveExtraRows: TreeRow[] =
primitivePaletteExtras?.map((it) => ({
id: it.id,
isFolder: false,
label: '',
depth: 1,
parentId: 'group-primitives',
customContent: it.node,
})) ?? [];
if (p.length > 0 || primitiveExtraRows.length > 0) {
addFolder('group-primitives', 'Primitives', <Box className="h-3.5 w-3.5 shrink-0" />, () => {
return p.map(t => ({
const primRows: TreeRow[] = p.map((t) => ({
id: `prim-${t.id}`,
isFolder: false,
label: t.name,
depth: 1,
typeDef: t,
parentId: 'group-primitives'
parentId: 'group-primitives',
}));
return [...primRows, ...primitiveExtraRows];
}, 0, null);
}
// App pickers & other synthetic palette entries
paletteExtraFolders?.forEach((group) => {
addFolder(
group.folderId,
group.label,
group.icon ?? <Link2 className="h-3.5 w-3.5 shrink-0" />,
() =>
group.items.map((it) => ({
id: it.id,
isFolder: false,
label: '',
depth: 1,
parentId: group.folderId,
customContent: it.node,
})),
0,
null
);
});
// Categories
categoryTree.forEach(cat => {
visible.push(...renderCatNode(cat, 0, null));
@ -195,7 +241,7 @@ export const TypeCategoryTree: React.FC<TypeCategoryTreeProps> = ({
}
return visible;
}, [types, excludeFieldTypes, categoryTree, collapsedIds]);
}, [types, excludeFieldTypes, categoryTree, collapsedIds, paletteExtraFolders, primitivePaletteExtras]);
const [focusIdx, setFocusIdx] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
@ -329,7 +375,7 @@ export const TypeCategoryTree: React.FC<TypeCategoryTreeProps> = ({
);
}
// Node is a type layout wrapper
// Node is a type layout wrapper or custom palette slot
return (
<div
key={row.id}
@ -344,7 +390,7 @@ export const TypeCategoryTree: React.FC<TypeCategoryTreeProps> = ({
)}
style={{ paddingLeft: `${8 + row.depth * 14}px`, paddingRight: '8px', paddingTop: '2px', paddingBottom: '2px' }}
>
{row.typeDef && renderItem(row.typeDef)}
{row.customContent ?? (row.typeDef ? renderItem(row.typeDef) : null)}
</div>
);
})}

View File

@ -6,7 +6,8 @@ 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 { customWidgets, customTemplates } from './RJSFTemplates';
import { rjsfWidgetRegistry } from './rjsfWidgetRegistry';
import { customTemplates } from './RJSFTemplates';
import { generateRandomData } from './randomDataGenerator';
import { toast } from 'sonner';
@ -197,7 +198,7 @@ export const TypeRenderer = forwardRef<TypeRendererRef, TypeRendererProps>(({
uiSchema={previewUiSchema}
formData={showPreview ? previewFormData : undefined}
validator={validator}
widgets={customWidgets}
widgets={rjsfWidgetRegistry}
templates={customTemplates}
onChange={({ formData }) => showPreview && setPreviewFormData(formData)}
onSubmit={({ formData }) => toast.success("Form submitted (check console)")}

View File

@ -0,0 +1,338 @@
/**
* RJSF widgets that store app entity IDs / VFS paths (no inline rendering of linked content).
* ui:widget keys are listed in builder/appPickerWidgetOptions.ts.
*/
import React, { lazy, Suspense, useEffect, useState } from 'react';
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 { 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';
const FileBrowserWidget = lazy(() =>
import('@/modules/storage/FileBrowserWidget').then((m) => ({ default: m.default }))
);
function parseVfsStored(val: string | undefined): { mount: string; path: string } {
if (!val || typeof val !== 'string') {
return { mount: 'home', path: '/' };
}
const i = val.indexOf(':');
if (i <= 0) {
return { mount: 'home', path: val.startsWith('/') ? val : `/${val}` };
}
return {
mount: val.slice(0, i) || 'home',
path: val.slice(i + 1) || '/',
};
}
export const PagePickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const [open, setOpen] = useState(false);
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const v = value as string | undefined;
if (!v) {
setLabel(null);
return;
}
(async () => {
try {
const res = (await fetchPageDetailsById(v)) as
| { page?: { title?: string }; title?: string }
| null
| undefined;
const title = res?.page?.title ?? res?.title;
if (!cancelled && title) setLabel(String(title));
} catch {
if (!cancelled) setLabel(null);
}
})();
return () => {
cancelled = true;
};
}, [value]);
const display = value
? label
? `${label} (${String(value).slice(0, 8)}…)`
: String(value)
: '';
return (
<div className="space-y-1">
<div className="flex gap-2">
<Input
id={id}
readOnly
value={display}
placeholder={translate('No page selected')}
className="flex-1 font-mono text-[10px] h-8"
disabled={disabled}
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
disabled={disabled || readonly}
onClick={() => setOpen(true)}
>
<FileText className="h-3 w-3 mr-1" />
<span className="text-xs">{translate('Browse')}</span>
</Button>
{value && !readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onChange(undefined)}
title={translate('Clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<PagePickerDialog
isOpen={open}
onClose={() => setOpen(false)}
currentValue={value as string | undefined}
onSelect={(page) => {
onChange(page?.id ?? undefined);
setOpen(false);
}}
/>
</div>
);
};
export const PostPickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const [open, setOpen] = useState(false);
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const v = value as string | undefined;
if (!v) {
setLabel(null);
return;
}
(async () => {
try {
const data = (await fetchPostDetailsAPI(v)) as
| { title?: string; post?: { title?: string } }
| null
| undefined;
const title = data?.title ?? data?.post?.title;
if (!cancelled && title) setLabel(String(title));
} catch {
if (!cancelled) setLabel(null);
}
})();
return () => {
cancelled = true;
};
}, [value]);
const display = value
? label
? `${label} (${String(value).slice(0, 8)}…)`
: String(value)
: '';
return (
<div className="space-y-1">
<div className="flex gap-2">
<Input
id={id}
readOnly
value={display}
placeholder={translate('No post selected')}
className="flex-1 font-mono text-[10px] h-8"
disabled={disabled}
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
disabled={disabled || readonly}
onClick={() => setOpen(true)}
>
<ImageIcon className="h-3 w-3 mr-1" />
<span className="text-xs">{translate('Browse')}</span>
</Button>
{value && !readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onChange(undefined)}
title={translate('Clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
<T>Select Post</T>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto -mx-2 px-2">
<PostPicker
onSelect={(postId) => {
onChange(postId);
setOpen(false);
}}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
};
export const FilePickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const [open, setOpen] = useState(false);
const parsed = parseVfsStored(value as string | undefined);
const [browseMount, setBrowseMount] = useState(parsed.mount);
const [browsePath, setBrowsePath] = useState(parsed.path);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
useEffect(() => {
if (open) {
const p = parseVfsStored(value as string | undefined);
setBrowseMount(p.mount);
setBrowsePath(p.path);
setSelectedFilePath(null);
}
}, [open, value]);
const commitSelection = () => {
let finalPath = browsePath;
if (selectedFilePath && selectedFilePath !== browsePath) {
finalPath = selectedFilePath;
}
const encoded = `${browseMount}:${finalPath}`;
onChange(encoded);
setOpen(false);
setSelectedFilePath(null);
};
const display = (value as string) || '';
return (
<div className="space-y-1">
<div className="flex gap-2">
<Input
id={id}
readOnly
value={display}
placeholder="home:/path"
className="flex-1 font-mono text-[10px] h-8"
disabled={disabled}
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
disabled={disabled || readonly}
onClick={() => setOpen(true)}
>
<FolderOpen className="h-3 w-3 mr-1" />
<span className="text-xs">{translate('Browse')}</span>
</Button>
{value && !readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onChange(undefined)}
title={translate('Clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl max-w-[95vw] p-0 gap-0 flex flex-col max-h-[90vh]">
<DialogHeader className="p-4 pb-2 shrink-0">
<DialogTitle>
<T>Browse Files</T>
</DialogTitle>
<p className="text-xs text-muted-foreground font-mono">
{browseMount}:{browsePath}
</p>
</DialogHeader>
<div className="flex-1 min-h-0 px-2 pb-2" style={{ height: 420 }}>
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
<T>Loading</T>
</div>
}
>
<FileBrowserWidget
key={browseMount}
mount={browseMount}
path={browsePath}
onMountChange={(m: string) => {
setBrowseMount(m);
setSelectedFilePath(null);
}}
onPathChange={(p: string) => {
setBrowsePath(p);
setSelectedFilePath(null);
}}
onSelect={(p: string | null) => setSelectedFilePath(p)}
viewMode="list"
mode="simple"
showToolbar={true}
glob="*.*"
sortBy="name"
canChangeMount={true}
allowFileViewer={false}
allowLightbox={false}
allowDownload={false}
jail={false}
minHeight="380px"
showStatusBar={true}
/>
</Suspense>
</div>
<div className="p-3 border-t flex justify-end gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
<T>Cancel</T>
</Button>
<Button size="sm" onClick={commitSelection}>
<T>Select</T>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -16,8 +16,9 @@ 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 { TypeDefinition } from '../client-types';
import { FolderTree } from 'lucide-react';
import { FolderTree, Link2 } from 'lucide-react';
export const TypeBuilderContent: React.FC<{
mode: BuilderMode;
@ -56,7 +57,139 @@ export const TypeBuilderContent: React.FC<{
id: 'canvas',
});
// We don't pre-map to Palette items here anymore, as TypeCategoryTree handles TypeDefinitions directly.
const stringPrimitive = React.useMemo(
() => availableTypes.find((t) => t.kind === 'primitive' && t.name === 'string'),
[availableTypes]
);
const itemsTypeSelectValue = React.useMemo(() => {
if (!selectedElement?.itemsTypeId) return '_none';
const wid = selectedElement.uiSchema?.items?.['ui:widget'];
if (
stringPrimitive &&
selectedElement.itemsTypeId === stringPrimitive.id &&
typeof wid === 'string' &&
ARRAY_ITEMS_APP_PICKER_WIDGETS.has(wid)
) {
return `widget:${wid}`;
}
const t = availableTypes.find((x) => x.id === selectedElement.itemsTypeId);
if (t?.kind === 'structure') return `struct:${selectedElement.itemsTypeId}`;
return `prim:${selectedElement.itemsTypeId}`;
}, [selectedElement?.itemsTypeId, selectedElement?.uiSchema, stringPrimitive, availableTypes]);
const applyItemsTypeChange = React.useCallback(
(val: string) => {
if (!selectedElement) return;
const stripItemsKey = (ui: Record<string, unknown> | undefined) => {
if (!ui?.items) return ui;
const { items: _i, ...rest } = ui;
return Object.keys(rest).length ? rest : undefined;
};
if (val === '_none') {
updateSelectedElement({ itemsTypeId: undefined, uiSchema: stripItemsKey(selectedElement.uiSchema as Record<string, unknown>) as BuilderElement['uiSchema'] });
return;
}
if (val.startsWith('widget:') && stringPrimitive) {
const w = val.slice('widget:'.length);
updateSelectedElement({
itemsTypeId: stringPrimitive.id,
uiSchema: {
...(selectedElement.uiSchema || {}),
items: { 'ui:widget': w, 'ui:label': false },
},
});
return;
}
const id = val.startsWith('prim:') ? val.slice('prim:'.length) : val.startsWith('struct:') ? val.slice('struct:'.length) : val;
updateSelectedElement({
itemsTypeId: id,
uiSchema: stripItemsKey(selectedElement.uiSchema as Record<string, unknown>) as BuilderElement['uiSchema'],
});
},
[selectedElement, stringPrimitive, updateSelectedElement]
);
const primitivePaletteExtras = React.useMemo(() => {
if ((mode !== 'structure' && mode !== 'alias') || !stringPrimitive) return undefined;
return [
{
id: 'palette-primitive-categoryPicker',
node: (
<DraggablePaletteItem
key="palette-primitive-categoryPicker"
item={{
id: 'palette-primitive-categoryPicker',
type: 'string',
name: 'categoryId',
title: 'Category',
refId: stringPrimitive.id,
uiSchema: { 'ui:widget': 'categoryPicker' },
}}
/>
),
},
{
id: 'palette-primitive-userPicker',
node: (
<DraggablePaletteItem
key="palette-primitive-userPicker"
item={{
id: 'palette-primitive-userPicker',
type: 'string',
name: 'userId',
title: 'User',
refId: stringPrimitive.id,
uiSchema: { 'ui:widget': 'userPicker' },
}}
/>
),
},
{
id: 'palette-primitive-groupPicker',
node: (
<DraggablePaletteItem
key="palette-primitive-groupPicker"
item={{
id: 'palette-primitive-groupPicker',
type: 'string',
name: 'groupName',
title: 'Group',
refId: stringPrimitive.id,
uiSchema: { 'ui:widget': 'groupPicker' },
}}
/>
),
},
];
}, [mode, stringPrimitive]);
const paletteExtraFolders = React.useMemo(() => {
if (mode !== 'structure' || !stringPrimitive) return undefined;
return [
{
folderId: 'group-app-pickers',
label: 'App pickers',
icon: <Link2 className="h-3.5 w-3.5 shrink-0" />,
items: APP_PICKER_PALETTE_ENTRIES.map((entry) => ({
id: `palette-app-${entry.widget}`,
node: (
<DraggablePaletteItem
key={`palette-app-${entry.widget}`}
item={{
id: `palette-app-${entry.widget}`,
type: 'string',
name: entry.defaultFieldName,
title: entry.paletteTitle,
refId: stringPrimitive.id,
uiSchema: { 'ui:widget': entry.widget },
}}
/>
),
})),
},
];
}, [mode, stringPrimitive]);
return (
<div className="flex h-full gap-6">
@ -70,13 +203,15 @@ export const TypeBuilderContent: React.FC<{
<TypeCategoryTree
types={availableTypes}
excludeFieldTypes={true}
primitivePaletteExtras={primitivePaletteExtras}
paletteExtraFolders={paletteExtraFolders}
renderItem={(t) => {
const isPrimitive = t.kind === 'primitive';
const item: BuilderElement = isPrimitive ? {
id: `primitive-${t.id}`,
type: t.name,
name: t.name.charAt(0).toUpperCase() + t.name.slice(1),
title: `New ${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`,
title: `${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`,
description: t.description || undefined,
refId: t.id
} : {
@ -95,6 +230,7 @@ export const TypeBuilderContent: React.FC<{
<TypeCategoryTree
types={availableTypes.filter(t => t.kind === 'primitive')}
excludeFieldTypes={true}
primitivePaletteExtras={primitivePaletteExtras}
renderItem={(t) => {
const item: BuilderElement = {
id: `primitive-${t.id}`,
@ -131,11 +267,14 @@ export const TypeBuilderContent: React.FC<{
</Button>
</div>
</CardHeader>
<div ref={setCanvasRef} className="flex-1 p-4 bg-muted/10 overflow-y-auto min-h-[300px] transition-colors relative">
<div
ref={setCanvasRef}
className="flex-1 min-h-0 p-4 bg-muted/10 overflow-y-auto min-h-[min(420px,55vh)] transition-colors relative"
>
{isOver && (
<div className="absolute inset-0 bg-primary/5 rounded-none border-2 border-primary/20 border-dashed pointer-events-none z-0" />
<div className="absolute inset-0 bg-primary/10 rounded-md border-2 border-primary/40 border-dashed pointer-events-none z-0" />
)}
<div className="relative z-10 min-h-full">
<div className="relative z-10 min-h-[min(380px,50vh)]">
{mode === 'enum' && (
<EnumEditor enumValues={enumValues} setEnumValues={setEnumValues} />
)}
@ -334,10 +473,8 @@ export const TypeBuilderContent: React.FC<{
<div className="space-y-2">
<Label className="text-xs">Items Type</Label>
<Select
value={selectedElement.itemsTypeId || '_none'}
onValueChange={(val) => {
updateSelectedElement({ itemsTypeId: val === '_none' ? undefined : val });
}}
value={itemsTypeSelectValue}
onValueChange={applyItemsTypeChange}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select what each item is..." />
@ -348,22 +485,32 @@ export const TypeBuilderContent: React.FC<{
{availableTypes
.filter(t => t.kind === 'primitive' && t.name !== 'array')
.map(t => (
<SelectItem key={t.id} value={t.id} className="text-xs pl-4">
<SelectItem key={t.id} value={`prim:${t.id}`} className="text-xs pl-4">
{t.name.charAt(0).toUpperCase() + t.name.slice(1)}
</SelectItem>
))}
{stringPrimitive && (
<>
<div className="px-2 py-1.5 text-[0.625rem] font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50 mt-1">App pickers</div>
{ARRAY_ITEMS_APP_PICKER_ENTRIES.map((e) => (
<SelectItem key={e.widget} value={`widget:${e.widget}`} className="text-xs pl-4">
{e.label}
</SelectItem>
))}
</>
)}
<div className="px-2 py-1.5 text-[0.625rem] font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50 mt-1">Structures</div>
{availableTypes
.filter(t => t.kind === 'structure')
.map(t => (
<SelectItem key={t.id} value={t.id} className="text-xs pl-4">
<SelectItem key={t.id} value={`struct:${t.id}`} className="text-xs pl-4">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Choose the type for each item in this array. Pick a Structure to render complex sub-forms.
Choose the type for each array element. Structures render nested forms; app pickers store IDs/paths as strings.
</p>
</div>
</div>

View File

@ -0,0 +1,48 @@
/**
* 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

@ -28,7 +28,7 @@ export const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">{item.name}</span>
<span className="text-sm font-medium">{item.title || item.name}</span>
</div>
);
};

View File

@ -0,0 +1,49 @@
import {
closestCenter,
pointerWithin,
rectIntersection,
type CollisionDetection,
} from '@dnd-kit/core';
/** Draggable palette item ids (see TypeBuilderContent + TypeCategoryTree) */
export function isPaletteDragId(id: string | number): boolean {
const s = String(id);
return (
s.startsWith('primitive-') ||
s.startsWith('type-') ||
s.startsWith('palette-app-') ||
s.startsWith('palette-primitive-')
);
}
/**
* Palette canvas: prefer the canvas droppable whenever the pointer intersects it,
* so sortable field cards do not steal hits from `closestCenter`.
* Field reorder: keep default closest-center behavior.
*/
export const typeBuilderCollisionDetection: CollisionDetection = (args) => {
const activeId = args.active?.id;
if (activeId == null) {
return closestCenter(args);
}
if (!isPaletteDragId(activeId)) {
return closestCenter(args);
}
const pointerHits = pointerWithin(args);
const canvasPointer = pointerHits.find((c) => c.id === 'canvas');
if (canvasPointer) {
return [canvasPointer];
}
// Slightly more forgiving when the pointer is near the edge of the scroll area
const rectHits = rectIntersection(args);
const canvasRect = rectHits.find((c) => c.id === 'canvas');
if (canvasRect) {
return [canvasRect];
}
// Do not fall back to closestCenter for palette drags — it targets sortable fields.
return [];
};

View File

@ -1,5 +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';
export function getIconForType(type: string | undefined) {
if (!type) return FileJson;
@ -42,8 +43,12 @@ export const WIDGET_OPTIONS = [
{ value: 'TextWidget', label: 'Custom Text (Styled)', types: ['string'] },
{ value: 'CheckboxWidget', label: 'Toggle Switch', types: ['bool', 'boolean'] },
{ value: 'ImageWidget', label: 'Image Picker', types: ['string'] },
{ value: 'categoryPicker', label: 'Category', types: ['string'] },
{ value: 'userPicker', label: 'User', types: ['string'] },
{ value: 'groupPicker', label: 'Group', types: ['string'] },
]
}
},
...APP_PICKER_WIDGET_GROUPS,
];
export const resolvePrimitiveType = (typeName: string, types: TypeDefinition[]): string => {

View File

@ -0,0 +1,39 @@
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

@ -0,0 +1,39 @@
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

@ -0,0 +1,130 @@
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

@ -0,0 +1,20 @@
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';
/**
* Full RJSF widget registry for app forms. Re-merges picker widgets explicitly so fields like `ui:widget: categoryPicker`
* always resolve even if a consumer imported `customWidgets` before `RJSFTemplates` finished initializing (circular import edge cases).
*/
export const rjsfWidgetRegistry: RegistryWidgetsType = {
...customWidgets,
categoryPicker: CategoryPickerWidget,
userPicker: UserPickerWidget,
groupPicker: GroupPickerWidget,
pagePicker: PagePickerWidget,
postPicker: PostPickerWidget,
filePicker: FilePickerWidget,
};

View File

@ -167,19 +167,31 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[],
const itemsType = types.find(t => t.id === itemsTypeId);
if (itemsType?.kind === 'structure') {
const itemsUiSchema = generateUiSchemaForType(itemsTypeId, types, new Set(visited));
const itemExtras =
fieldUiSchema.items && typeof fieldUiSchema.items === 'object'
? fieldUiSchema.items
: {};
uiSchema[field.field_name] = {
...fieldUiSchema,
items: {
...itemsUiSchema,
'ui:label': false
'ui:label': false,
...itemExtras
},
'ui:options': { orderable: false },
'ui:classNames': 'col-span-full'
};
} else {
const itemExtras =
fieldUiSchema.items && typeof fieldUiSchema.items === 'object'
? fieldUiSchema.items
: {};
uiSchema[field.field_name] = {
...fieldUiSchema,
items: { 'ui:label': false },
items: {
'ui:label': false,
...itemExtras
},
'ui:options': { orderable: false },
'ui:classNames': 'col-span-full'
};

View File

@ -0,0 +1,38 @@
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>
);
};