types:vfs/cats/groups/posts/pages
This commit is contained in:
parent
a9856fa322
commit
009332adbf
@ -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 app’s `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.
|
||||
|
||||
108
packages/ui/src/components/admin/ProductTypeDataForms.tsx
Normal file
108
packages/ui/src/components/admin/ProductTypeDataForms.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 "Type data" section when editing this product.</T>
|
||||
</p>
|
||||
</div>
|
||||
<VariablesEditor
|
||||
onLoad={handleLoadSettings}
|
||||
onSave={handleSaveSettings}
|
||||
variableSchema={PRODUCT_VARIABLE_SCHEMA}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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)")}
|
||||
|
||||
338
packages/ui/src/modules/types/appPickerWidgets.tsx
Normal file
338
packages/ui/src/modules/types/appPickerWidgets.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 [];
|
||||
};
|
||||
@ -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 => {
|
||||
|
||||
39
packages/ui/src/modules/types/categoryPickerWidget.tsx
Normal file
39
packages/ui/src/modules/types/categoryPickerWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
packages/ui/src/modules/types/groupPickerWidget.tsx
Normal file
39
packages/ui/src/modules/types/groupPickerWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
packages/ui/src/modules/types/rjsfButtonTemplates.tsx
Normal file
130
packages/ui/src/modules/types/rjsfButtonTemplates.tsx
Normal 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,
|
||||
};
|
||||
20
packages/ui/src/modules/types/rjsfWidgetRegistry.ts
Normal file
20
packages/ui/src/modules/types/rjsfWidgetRegistry.ts
Normal 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,
|
||||
};
|
||||
@ -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'
|
||||
};
|
||||
|
||||
38
packages/ui/src/modules/types/userPickerWidget.tsx
Normal file
38
packages/ui/src/modules/types/userPickerWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user