diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 873a15c3f91..f9aa290fe02 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -1058,6 +1058,83 @@ what the box accepts, and only one of the two outcomes is announced: "No warning" therefore does not mean "the value is right". +### Polymorphic pointer pairs (`referenceVia`) + +A `text` field that declares `referenceVia` holds a record id of the object its +sibling column names, row by row: `sys_approval_request.record_id` points at the +record `object_name` names, and `sys_activity.source_id` at the one +`source_object` names. The record details grid resolves the pair from the row +with `resolveRecordPointer(field, row)` and draws it with the renderer +registered under `RECORD_POINTER_CARD_TYPE`. The fields package's default draws +the record id as text; the console registers a record preview card there, so a +pointer field on a record page shows the record it points at. When the card +could read that record, its title links to the record's page in the current +app, built by the same record-link builder the page's lookup values use; a +record the reader cannot read is drawn with no link, and the card never says +why. Nothing is authored for this: the `referenceVia` declaration on the field +is the whole contract. List cells are not a call site yet. + +When the platform deletes the record a pointer names, it can say so on the row. +A pointer field that also declares `referenceDeletedWhen: { field, value }` +names a sibling `select` field and the option the platform writes there, as +`sys_approval_request.record_id` declares +`{ field: 'cancel_reason', value: 'record_deleted' }`. On a row holding that +option the details grid draws a tombstone in the option's own label (localized +when the console's bundle localizes it), reads nothing and links nothing. A +record the card cannot read on any other row still gets the cause-free text: +only the declaration means deleted. + +### Naming a registry item by name (`position-ref`, `permission-ref`) + +A `text` field that stores the **machine name** of one item of a metadata +registry — a position, a permission set — declares the matching widget, and the +record form lists that registry instead of asking for a typed name: + +```json +{ + "position": { + "type": "text", + "label": "Position", + "required": true, + "maxLength": 100, + "widget": "position-ref" + } +} +``` + +| Widget | Lists | Stores | +|---|---|---| +| `position-ref` | the `position` registry (`GET /api/v1/meta/position`) | the position's `name` | +| `permission-ref` | the `permission` registry (`GET /api/v1/meta/permission`) | the permission set's `name` | + +Like every `widget` value, the name is looked up as `field:`. Each item +reads as its label beside its name, and the value written is the name alone: no +label, no id. A stored name the registry does not list stays on screen, flagged +*not found*, so a renamed or removed item is visible rather than dropped. +`position-ref` leaves out the audience anchors, the spec's +`AUDIENCE_ANCHOR_POSITIONS` (`everyone`, `guest`), because the server refuses an +anchor as a grant target; a stored anchor still shows as its own option. The +list comes from the data source's `listMetadataItems(type)`. When the adapter +has none, the list is empty, or the load fails, the field is a text box that +takes the typed name; a failure is shown with its cause, and the box stays +editable. + +### A widget's face on the record heading + +A `text` field that declares `widget: 'recipient-picker'` or `widget: 'object-ref'` is drawn +in a list column through the read cell registered under that widget's key, which names what +the stored value references. When that field is also the object's `nameField`, the record +page heading draws the value through the same cell: a record share heads its page with the +recipient's name, not the user id it stores. Nothing is authored for this; the `widget` +declaration on the field is the whole contract. + +The heading lives in `@object-ui/components`, which this package depends on and so cannot be +imported from here the other way round. `@object-ui/components` therefore owns a slot, +`setFieldReadFaceResolver` / `resolveFieldReadFace`, and this package fills it when it loads with the +lookup the list cells make. The widget's key draws the stored text until a host registers a +face under it (the console registers its own), and while it does, the heading prints the +stored value as before. So does a host that does not load `@object-ui/fields`. + ## Choosing a Field Type Not sure which type fits your data? Follow the [Field Type Decision Tree](/docs/data-modeling/field-type-decision-tree) — a flowchart, quick-reference tables, and a use-case mapping for every type in this gallery. diff --git a/content/docs/frontend/app-shell.mdx b/content/docs/frontend/app-shell.mdx new file mode 100644 index 00000000000..b00fc254d74 --- /dev/null +++ b/content/docs/frontend/app-shell.mdx @@ -0,0 +1,306 @@ +--- +title: "AppShell" +description: "Main application shell with sidebar and header layout" +--- + +The AppShell component provides the main application layout structure with an integrated sidebar, header, and content area. It's the foundation for building enterprise applications with ObjectUI. + +## Basic Usage + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { Home, Settings, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/', icon: Home }, + { title: 'Users', href: '/users', icon: Users }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +} + navbar={ +
+ My App +
+ } +> + {/* Your page content */} +
Main content area
+
+``` + +## Component Props + +```ts +import type { AppShellBranding } from '@object-ui/layout'; + +interface AppShellProps { + sidebar?: React.ReactNode; // Sidebar content (usually SidebarNav) + navbar?: React.ReactNode; // Top navbar content (logo, search, user menu) + children: React.ReactNode; // Main content area + className?: string; // Additional CSS classes for content area + defaultOpen?: boolean; // Sidebar default state (default: true) + branding?: AppShellBranding; // App branding — four theme fields, see Branding + rightRail?: React.ReactNode; // Optional right rail beside the content, not + // over it (ADR-0057 P3a) +} +``` + +## Features + +### Responsive Sidebar + +The AppShell does not build a sidebar of its own: it renders the node you pass in +`sidebar`, wrapped in a `SidebarProvider`. Pass a `Sidebar`-based node (`SidebarNav`, or +your own) and you get a sidebar that: +- Collapses on mobile devices +- Can be toggled with the `Cmd/Ctrl + B` keyboard shortcut, or by a toggle button you + render yourself — see Collapsible Sidebar below +- Persists state across page navigations +- Supports smooth animations + +### Header Bar + +The header renders your `navbar` content and nothing else — the AppShell adds no controls +of its own to it, in particular **no sidebar toggle**. If you want one, render it inside +`navbar` (see Collapsible Sidebar below). The header itself provides: +- Custom navbar content area +- Fixed height: `h-12` (3rem / 48px) at every breakpoint — there is no responsive height variant +- Border bottom separator +- Background matching app theme + +### Content Area + +The main content area features: +- Responsive padding in three steps: `p-3` on mobile, `sm:p-4` from the `sm` breakpoint, `md:p-6` from `md` up +- A taller bottom padding on mobile only: `pb-20` (5rem), which keeps content clear of the mobile bottom bar (that bar is itself `sm:hidden`); from `sm` up the bottom edge follows `sm:pb-4` / `md:pb-6` like the other sides +- Automatic scrolling +- Flexible height (fills viewport) +- Custom className support + +### Branding + +`branding` is theming, not chrome — the shell hands it to `useAppShellBranding`, and all +four of the fields it declares are read: + +- `primaryColor` / `accentColor` — a six-digit hex (`#3B82F6`; the leading `#` is + optional, three-digit shorthand is not accepted), translated into the Shadcn theme + tokens listed under Styling below. A value that does not parse that way is ignored + silently. +- `favicon` — overwrites the `href` of the icon link the page already has (`#favicon`, + otherwise `link[rel="icon"]`). It never creates one, so a document with no icon link is + left unchanged. +- `title` — assigned to `document.title` as given. It is the whole title, not a suffix: + the caller composes the string it wants (the console passes + `"App label — Product name"`). + +All of it acts on the document rather than on the shell's subtree, and the colour +properties are removed again when the shell unmounts. + +### Right Rail + +`rightRail` renders your node as a flex sibling of the content area, so the rail sits +**beside** the content and reflows it rather than overlaying it (ADR-0057 P3a). The shell +adds no wrapper and no width around it — the node you pass owns its width, border and +scrolling; omit it and the layout is the unchanged single pane. + +## Layout Structure + +``` +┌─────────────────────────────────────┐ +│ Header (your navbar content) │ +├──────────┬──────────────────────────┤ +│ │ │ +│ Sidebar │ Main Content Area │ +│ │ │ +│ │ │ +│ │ │ +└──────────┴──────────────────────────┘ +``` + +## Sidebar Configuration + +### Default Open State + +Control whether sidebar is open by default: + +```plaintext + + {/* Sidebar starts collapsed */} + +``` + +### Collapsible Sidebar + +The AppShell renders no toggle button of its own. What it does give you is the +`SidebarProvider` wrapped around both slots, so the sidebar you supply can be toggled via: +- The `Cmd/Ctrl + B` keyboard shortcut, provided by `SidebarProvider` +- Programmatic control — `useSidebar().toggleSidebar()` from any component inside the shell +- A toggle button you render yourself; it belongs in the `navbar` slot: + +```tsx +import { SidebarTrigger } from '@object-ui/components'; +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { Home, Settings } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/', icon: Home }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +} + sidebar={} +> +
Your page content
+
+``` + +## Navbar Content + +The navbar area is flexible and can contain: + +### Logo and Title + +```plaintext +navbar={ +
+ Logo + My Application +
+} +``` + +### Search Bar + +```plaintext +navbar={ +
+ +
+} +``` + +### User Menu + +```plaintext +navbar={ +
+ + +
+} +``` + +## Complete Example + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { Avatar, AvatarFallback, Button, Input } from '@object-ui/components'; +import { Bell, Folder, LayoutDashboard, Settings, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/', icon: LayoutDashboard, badge: '12' }, + { + title: 'Projects', + href: '/projects', + icon: Folder, + children: [ + { title: 'Active', href: '/projects/active' }, + { title: 'Archived', href: '/projects/archived' }, + ], + }, + { title: 'Team', href: '/team', icon: Users }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +function App() { + return ( + } + navbar={ +
+ {/* Search */} +
+ +
+ + {/* Right side items */} +
+ + + JD + +
+
+ } + className="bg-gray-50" + > + {/* Main content */} +
+

Dashboard

+ {/* Page content */} +
+
+ ); +} +``` + +`SidebarNav` has no logo slot and no footer slot — `title` is its only branding prop, the +section label printed above a flat `NavItem[]` (it is ignored when `items` is a +`NavGroup[]`, since each group prints its own `label`). Put a logo in the `navbar` slot +instead, as the Logo and Title example under Navbar Content above does. + +## With Page Component + +Combine AppShell with the Page component for complete layouts: + +```plaintext +}> + + +``` + +## Styling + +The AppShell uses Tailwind CSS and Shadcn UI components: + +- `SidebarProvider`: Manages sidebar state +- `SidebarInset`: Content area wrapper + +Brand colours arrive as Shadcn theme tokens rather than as per-component styles: each hex +from `branding` is converted to the `H S% L%` triple those tokens carry and set as a CSS +custom property on `document.documentElement`. `primaryColor` writes `--primary`, +`--primary-foreground`, `--ring`, `--sidebar-primary` and `--sidebar-ring`; `accentColor` +writes `--accent` and `--accent-foreground`. Tailwind's utilities resolve through the same +tokens (`--color-primary: hsl(var(--primary))`), so every `bg-primary` / `text-primary` / +`ring-primary` / `bg-accent` consumer picks the brand colour up with no per-component +wiring. In dark mode a lighter variant of the same hue is used instead, and the shell +re-applies the values whenever the `dark` class on the `html` element changes. + +## Accessibility + +The AppShell includes: +- A keyboard shortcut for toggling the sidebar (`Cmd/Ctrl + B`), from `SidebarProvider` +- Keyboard navigation support +- Focus management +- Screen reader announcements + +## Related + +- [SidebarNav](/docs/frontend/sidebar-nav) - Sidebar navigation component +- [PageHeader](/docs/ui/pages) - Page header component +- [Page](/docs/frontend/renderers) - Page layout component diff --git a/content/docs/frontend/architecture-overview.mdx b/content/docs/frontend/architecture-overview.mdx new file mode 100644 index 00000000000..972bb7e75ed --- /dev/null +++ b/content/docs/frontend/architecture-overview.mdx @@ -0,0 +1,338 @@ +--- +title: Architecture Overview +description: Deep dive into the ObjectUI 3-layer architecture, data flow, plugin system, state management, and action system. +--- + +ObjectUI is a **Server-Driven UI (SDUI) engine** that transforms JSON schemas into fully interactive React interfaces built on Shadcn/Tailwind. This document covers the internal architecture, data flow, and extension points. + +It is the architecture of the SDK's own packages. Where ObjectUI sits among ObjectStack's protocol layers (data, kernel, view) is covered in [Protocol architecture](/docs/concepts/architecture). + +## The 3-Layer Architecture + +ObjectUI enforces a strict separation across three layers. Each layer has clear constraints on what it may import and what it may contain. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Layer 1: @objectstack/spec (The Protocol) │ +│ Pure TypeScript type definitions — 12 export modules │ +│ ❌ No runtime code. No React. No dependencies. │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ imports (never redefines) +┌──────────────────────────────▼──────────────────────────────────────┐ +│ Layer 2: @object-ui/types (The Bridge) │ +│ Re-exports spec types + ObjectUI-specific schemas │ +│ ❌ No runtime code. Zero runtime dependencies. │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ consumed by +┌──────────────────────────────▼──────────────────────────────────────┐ +│ Layer 3: Implementations (The Runtime) │ +│ core, react, components (91+), fields (35+), plugins, etc. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Layer 1 — `@objectstack/spec` (The Protocol) + +The upstream JSON specification for all ObjectStack products. ObjectUI **imports** these types but never redefines them. Located externally and consumed as `@objectstack/spec`; the range each package installs is declared in that package's own `package.json`, under `dependencies`. + +### Layer 2 — `@object-ui/types` (The Bridge) + +Lives in `packages/types/`. Re-exports spec types and adds ObjectUI-specific schemas (component schemas, widget props, field props). Has **zero** runtime dependencies — only type-level imports of `@objectstack/spec` and `zod` for validation schemas. Marked `sideEffects: false`. + +### Layer 3 — Implementations (The Runtime) + +All packages that ship runnable code: `core`, `react`, `components`, `fields`, `layout`, `i18n`, `auth`, `permissions`, and every `plugin-*` package. + +## Package Dependency Graph + +``` +@objectstack/spec + │ + ▼ +@object-ui/types ◄─────────────────────────────────────┐ + │ │ + ▼ │ +@object-ui/core │ + │ (registry, evaluator, actions, validation) │ + ▼ │ +@object-ui/react ──────► @object-ui/i18n │ + │ (SchemaRenderer, hooks, contexts) │ + ├──────────────────────────────────────┐ │ + ▼ ▼ │ +@object-ui/components @object-ui/fields │ + │ (91+ Shadcn atoms) (35+ inputs) │ + ▼ ▼ │ +@object-ui/layout @object-ui/plugin-* │ + (AppShell, Header, (grid, kanban, charts, │ + Sidebar, routing) dashboard, form, etc.) │ + │ │ + └─────────────────┘ + (all packages import types) +``` + +**Strict rules:** + +| Package | May Import | Must NOT Import | +|---------|-----------|-----------------| +| `types` | `@objectstack/spec` | Any runtime package | +| `core` | `types`, `lodash`, `zod` | React, any UI library | +| `react` | `core`, `types`, `i18n` | Plugin packages directly | +| `components` | `types` (for props) | `core`, `react` | +| `fields` | `types` (for `FieldWidgetProps`) | `core` business logic | +| `plugin-*` | Any package | Other plugins | + +## Data Flow: Schema → Screen + +The rendering pipeline transforms a JSON schema into a live React component tree: + +``` + JSON Schema (from API or static file) + │ + ▼ + ┌─────────────┐ ┌──────────────────┐ + │ SchemaRenderer │───►│ ExpressionEvaluator │ + │ (packages/ │ │ Resolves ${...} │ + │ react/src/) │ │ expressions │ + └──────┬────────┘ └──────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ ComponentRegistry │ ← registry.resolve(schema.type) + │ (packages/core/ │ + │ src/registry/) │ + └──────┬──────────┘ + │ + ▼ + ┌──────────────────┐ + │ React Component │ ← Wrapped in ErrorBoundary + │ (Button, Grid, │ with ARIA attributes + │ Kanban, etc.) │ + └──────────────────┘ +``` + +1. **SchemaRenderer** (`packages/react/src/SchemaRenderer.tsx`) receives a JSON schema object. +2. It evaluates dynamic expressions via `ExpressionEvaluator` (`packages/core/src/evaluator/`). +3. It looks up the component type in the `ComponentRegistry` (`packages/core/src/registry/Registry.ts`). +4. The matched component renders with schema props, wrapped in a per-component `ErrorBoundary` with ARIA accessibility attributes (`aria-label`, `aria-describedby`, `role`). + +## The Plugin System + +Plugins are self-contained packages that register heavy or complex views (grids, kanbans, charts). They follow a consistent pattern defined in `packages/core/src/registry/PluginSystem.ts`. + +### Plugin Lifecycle + +``` + import 'plugin-kanban' + │ + ▼ + PluginSystem.load(plugin) + │ ├─ Check dependencies + │ ├─ Prevent duplicate loading + │ └─ Call plugin.register(scope) + ▼ + PluginScope.registerComponent(type, Component, meta) + │ └─ Auto-prefixes with plugin namespace + ▼ + ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, { + namespace: 'plugin-kanban', + category: 'view' + }) +``` + +### Lazy Loading + +Plugins use `React.lazy()` wrapped by `LazyPluginLoader` (`packages/react/src/LazyPluginLoader.tsx`): + +- **Retry logic**: 2 retries with 1-second delay by default +- **Custom fallbacks**: Loading skeleton + error boundary +- **Tree-shaking**: Plugins are code-split from the initial bundle + +### Plugin Registration Pattern + +Every plugin follows the same structure (see `packages/plugin-kanban/`, `packages/plugin-grid/`): + +```tsx +// 1. Lazy-load the heavy implementation +const LazyKanban = React.lazy(() => import('./KanbanImpl')); + +// 2. Define a thin wrapper with Suspense +const KanbanRenderer: React.FC = ({ schema }) => ( + }> + + +); + +// 3. Register in the global registry +ComponentRegistry.register('object-kanban', KanbanRenderer, { + namespace: 'plugin-kanban', + label: 'Object Kanban', + category: 'view', + inputs: [/* schema config */] +}); +``` + +Scaffold a new plugin with `npx @object-ui/create-plugin`. + +## State Management + +ObjectUI uses **React Context** for all state management, with contexts scoped to specific concerns: + +### Core Contexts (`packages/react/src/context/`) + +| Context | Purpose | +|---------|---------| +| `SchemaRendererContext` | Data scope + debug mode for the rendering tree | +| `ActionContext` | Action runner instance for handling user interactions | +| `ThemeContext` | Theme tokens and dark/light mode | +| `NotificationContext` | Toast and notification system | +| `DndContext` | Drag-and-drop state for sortable views | + +### Domain Contexts (separate packages) + +| Context | Package | Purpose | +|---------|---------|---------| +| `AuthContext` | `packages/auth/` | Authentication state | +| `PermissionContext` | `packages/permissions/` | RBAC/ABAC permissions | +| `I18nProvider` | `packages/i18n/` | Locale and translations | + +Contexts are composed at the application root and consumed by plugins and components via hooks (e.g., `useActionRunner`, `useExpression`, `useViewData`). + +## Expression Evaluation + +The expression engine lives in `packages/core/src/evaluator/` and powers dynamic schemas: + +### Components + +- **`ExpressionEvaluator.ts`** — Main engine: parses and evaluates `${...}` template expressions. +- **`ExpressionContext.ts`** — Variable scope: provides `data`, `user`, `params` to expressions. +- **`ExpressionCache.ts`** — Caches parsed expressions for repeated evaluations. +- **`FormulaFunctions.ts`** — Built-in functions available inside expressions. + +### Expression Types + +```json +{ + "visible": "${data.department === 'finance'}", + "label": "Hello, ${data.user.name}!", + "disabled": "${data.status !== 'draft'}", + "className": "${data.priority === 'high' ? 'text-red-500' : 'text-gray-500'}" +} +``` + +Expressions support: +- **String interpolation**: `"Welcome, ${data.name}"` +- **Conditionals**: `"${data.age > 18}"` +- **Ternary operators**: `"${data.active ? 'Yes' : 'No'}"` +- **Variable references**: `data.*`, `user.*`, `params.*` + +## Action System + +The action system (`packages/core/src/actions/`) handles user interactions defined in schemas. + +### ActionRunner (`ActionRunner.ts`) + +Executes action schemas and returns directives: + +``` + User Click → Schema Action + │ + ▼ + ActionRunner.execute(action, context) + │ ├─ Check confirmation dialog + │ ├─ Evaluate conditions + │ └─ Execute by action type + ▼ + ActionResult + ├─ reload: boolean + ├─ redirect: string + ├─ modal: SchemaObject + └─ toast: { message, type } +``` + +### Supported Action Types + +| Type | Description | +|------|-------------| +| `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** | +| `url` | Navigate to a URL | +| `api` | Make an API request (AJAX) | +| `modal` | Open a modal with a nested schema | +| `flow` | Start a server-side flow by name (`target`). In the console, a click on a declared `type: 'flow'` action goes through the action endpoint, `POST /api/v1/actions/{object}/{action}`, so every server-side gate the action declares applies; a flow start that names no declared action (an inline page button, a dashboard header action) goes through `POST /api/v1/automation/{flow}/trigger`. A run that pauses at a `screen` node opens the flow runner. Whether the run pauses or goes straight through, it closes on one success toast: the action's `successMessage`, else the flow's own `successMessage`, else a default sentence. A run that ends refused or fails shows no success toast | + +### Server Action Dispatch (`serverActionHandler.ts`) + +`ActionSchema.body` (the spec's preferred binding for script actions) executes +server-side via `POST /api/v1/actions/{object}/{action}` — the client +dispatches, it never interprets a body (a browser cannot enforce the L2 +sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL, +a different dialect from the `${...}` evaluator). + +`createServerActionHandler` is the core factory every consumer uses to build +that dispatch. It owns the protocol (name-based action identity, record-id +resolution, re-entrancy guard, the `/actions` response-envelope rule) and +injects the three things core has no opinion about: + +```typescript +import { createServerActionHandler } from '@object-ui/core'; + +const script = createServerActionHandler({ + fetch: myAuthenticatedFetch, // auth is yours + baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin) + resolveObject: () => currentObject, // fallback object scope is yours + onRefresh: () => notifyDataChanged(), // data invalidation is yours +}); + +// Registered handlers beat the built-in executors: + +``` + +The console builds on the same factory (`@object-ui/app-shell`'s +`createConsoleServerActionHandler` adds the popup pre-open dance and the +`redirectUrl` convention on top). + +### TransactionManager (`TransactionManager.ts`) + +Wraps multi-step actions in transactions for consistency, supporting rollback on failure. + +## Key Directories Reference + +``` +packages/ +├── types/src/ # Layer 2 — Pure type definitions +├── core/src/ +│ ├── evaluator/ # Expression engine +│ ├── registry/ # Component & plugin registries +│ ├── actions/ # ActionRunner, TransactionManager +│ ├── validation/ # Schema validation engine +│ ├── adapters/ # Data source adapters (API, Value) +│ ├── data-scope/ # ViewDataProvider, element data sources +│ ├── query/ # Query AST (filtering/sorting) +│ ├── theme/ # ThemeEngine +│ └── builder/ # Schema builder utilities +├── react/src/ +│ ├── SchemaRenderer.tsx +│ ├── LazyPluginLoader.tsx +│ ├── context/ # All React contexts +│ └── hooks/ # 20+ hooks (useExpression, useActionRunner, etc.) +├── components/ # 91+ Shadcn UI atoms +├── fields/ # 35+ field renderers +├── layout/ # AppShell, Header, Sidebar +├── i18n/ # Internationalization +├── auth/ # AuthContext + providers +├── permissions/ # RBAC/ABAC +└── plugin-*/ # 20 plugin packages + ├── plugin-grid/ + ├── plugin-kanban/ + ├── plugin-charts/ + ├── plugin-dashboard/ + ├── plugin-form/ + └── ... (15 more) +``` + +## Further Reading + +- [Schema Overview](/docs/frontend) — JSON schema structure +- [Schema Rendering](/docs/frontend/schema-rendering) — How schemas become UI +- [Component Registry](/docs/frontend/component-registry) — Registering components +- [Plugin Development](/docs/frontend/plugin-development) — Building plugins +- [Expressions](/docs/ui) — Expression syntax reference +- [Theming](/docs/frontend/theming) — Theme configuration diff --git a/content/docs/frontend/building-crud-app.mdx b/content/docs/frontend/building-crud-app.mdx new file mode 100644 index 00000000000..29df2f7f807 --- /dev/null +++ b/content/docs/frontend/building-crud-app.mdx @@ -0,0 +1,427 @@ +--- +title: "Building a CRUD App" +description: "End-to-end tutorial for building a complete CRUD application with ObjectUI — from schema definition to deployment" +--- + +This tutorial walks you through building a **Task Manager** CRUD application with ObjectUI — from schema definition to data source setup to deployment. + +## Prerequisites + +- **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. +- Basic knowledge of **React** and **TypeScript** + +## Step 1: Project Setup + +Create a new React project and install the required ObjectUI packages: + +```bash +pnpm create vite task-manager --template react-ts +cd task-manager +``` + +Install ObjectUI core packages and the plugins you need: + +```bash +pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields +pnpm add @object-ui/plugin-grid @object-ui/plugin-form @object-ui/plugin-detail +``` + +Install Tailwind CSS: + +```bash +pnpm add -D tailwindcss @tailwindcss/vite +``` + +Add Tailwind to your `vite.config.ts`: + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}); +``` + +Add to your `src/index.css`: + +```css +@import "tailwindcss"; +``` + +## Step 2: Register Components + +Create `src/setup.ts` to register the built-in component and field renderers: + +```ts +import { initializeComponents } from '@object-ui/components'; +// Side-effect imports: loading each package runs its own registration. +import '@object-ui/fields'; +import '@object-ui/plugin-grid'; +import '@object-ui/plugin-form'; +import '@object-ui/plugin-detail'; + +initializeComponents(); +``` + +Loading each package registers what it owns — the components, the field widgets +and each plugin's blocks all go into the one `ComponentRegistry` that +`@object-ui/core` exports. `initializeComponents()` takes no arguments; it exists +so a bundler cannot tree-shake the side-effect import away. + +**Every block this tutorial renders comes from a plugin**, so the three plugin +imports are not optional extras: `object-grid` lives in `@object-ui/plugin-grid`, +`object-form` in `@object-ui/plugin-form` and `detail-view` in +`@object-ui/plugin-detail`. Leave one out and `SchemaRenderer` has nothing to +resolve that `type` to — it renders the red **Unknown component type** panel +instead of the block. + +Import this file once at the top of your app entry point (`src/main.tsx` or `src/App.tsx`). + +## Step 3: Define the Object Schema + +Create `src/schemas/task.ts`. This is the metadata that drives the entire UI — grid columns, form fields, validation, and views are all derived from this schema: + +```ts +import type { FieldMetadata } from '@object-ui/types'; + +const fields: Record = { + title: { name: 'title', type: 'text', label: 'Title', required: true }, + status: { + name: 'status', + type: 'select', + label: 'Status', + defaultValue: 'Todo', + options: [ + { label: 'Backlog', value: 'Backlog' }, + { label: 'Todo', value: 'Todo' }, + { label: 'In Progress', value: 'In Progress' }, + { label: 'Review', value: 'Review' }, + { label: 'Done', value: 'Done' }, + ], + }, + priority: { + name: 'priority', + type: 'select', + label: 'Priority', + defaultValue: 'Medium', + options: [ + { label: 'Critical', value: 'Critical' }, + { label: 'High', value: 'High' }, + { label: 'Medium', value: 'Medium' }, + { label: 'Low', value: 'Low' }, + ], + }, + assignee: { name: 'assignee', type: 'text', label: 'Assignee' }, + due_date: { name: 'due_date', type: 'date', label: 'Due Date' }, + description: { name: 'description', type: 'textarea', label: 'Description' }, +}; + +export const TaskSchema = { + name: 'task', + label: 'Task', + icon: 'check-circle-2', + titleFormat: '{title}', + fields, + listViews: { + all: { + label: 'All Tasks', + columns: ['title', 'status', 'priority', 'assignee', 'due_date'], + }, + active: { + label: 'Active', + columns: ['title', 'status', 'priority', 'assignee', 'due_date'], + filter: [{ field: 'status', operator: 'not_equals', value: 'Done' }], + sort: [{ field: 'priority', order: 'asc' }], + }, + }, +}; +``` + +An object's metadata is a plain document — the same shape a backend serves from +`DataSource.getObjectSchema()` — so it is written as a literal rather than built +by a helper. `FieldMetadata` is the union every field entry belongs to (`text`, +`select`, `date`, `textarea`, …), and typing the `fields` record against it is +what makes a wrong `type` or a misspelled key fail at compile time. ObjectUI +reads those entries to choose the renderer, apply validation, and generate form +controls automatically. + +## Step 4: Create a Data Source + +ObjectUI never hardcodes `fetch` calls inside components. Instead, it communicates with your backend through the `DataSource` interface. Create `src/data/rest-data-source.ts`: + +```ts +import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; + +export class RestDataSource implements DataSource { + constructor(private baseUrl: string) {} + + async find(resource: string, params?: QueryParams): Promise { + const query = new URLSearchParams(); + if (params?.$top) query.set('$top', String(params.$top)); + if (params?.$skip) query.set('$skip', String(params.$skip)); + // `$orderby` is a union — an OData clause string, a map, or an array of + // fields. This backend speaks the string form, so narrow to it rather + // than stringifying a shape the server cannot parse. + if (typeof params?.$orderby === 'string') query.set('$orderby', params.$orderby); + if (params?.$search) query.set('$search', params.$search); + const res = await fetch(`${this.baseUrl}/${resource}?${query}`); + const data = await res.json(); + return { data: data.items, total: data.total }; + } + + async findOne(resource: string, id: string | number) { + return (await fetch(`${this.baseUrl}/${resource}/${id}`)).json(); + } + + async create(resource: string, data: Partial) { + const res = await fetch(`${this.baseUrl}/${resource}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); + } + + async update(resource: string, id: string | number, data: Partial) { + const res = await fetch(`${this.baseUrl}/${resource}/${id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); + } + + async delete(resource: string, id: string | number) { + return (await fetch(`${this.baseUrl}/${resource}/${id}`, { method: 'DELETE' })).ok; + } + + async getObjectSchema(objectName: string) { + return (await fetch(`${this.baseUrl}/schema/${objectName}`)).json(); + } +} +``` + +> **Tip:** For the ObjectStack backend, use the official `@object-ui/data-objectstack` adapter instead of writing your own. See the [Data Connectivity](/docs/frontend/data-source) guide for details. + +## Step 5: Render a CRUD Grid View + +Wire everything together in `src/App.tsx`. `SchemaRendererProvider` injects the +data source once, and every `SchemaRenderer` beneath it renders its schema +against that one adapter: + +```tsx +import './setup'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { RestDataSource } from './data/rest-data-source'; + +const dataSource = new RestDataSource('https://api.example.com/v1'); + +function App() { + return ( + +
+ +
+
+ ); +} + +export default App; +``` + +This renders a fully interactive data grid with sortable columns, pagination, +and row actions. `object-grid` fetches the object's schema itself — through +your data source's `getObjectSchema` (Step 4) — to generate the columns and +field types, so nothing from `src/schemas/task.ts` needs to be imported here; +that file only has to describe what your backend actually serves. + +Two details in that snippet are load-bearing, and getting either wrong produces +a grid that draws its header and nothing else: + +- **The data source is injected by an ancestor, not passed to the block.** + `object-grid`, `object-form` and `detail-view` all read the adapter from + `SchemaRendererProvider`, so it has to be *above* them in the tree. A + `dataSource` written on the block itself reaches that one block and never + becomes context for anything under it. +- **The object is named by `objectName`.** That is the key each of these blocks + declares as required — inside the `properties` bag on `object-grid` and + `object-form`, as the spec's page component declares their props, and on the + node on `detail-view`; a differently-spelled key (`object`) is not read, so the + block has no object to query. + +Neither mistake used to say anything — the block simply rendered empty. Both now +report themselves: a block that resolves no adapter renders a **No data source +resolved** panel naming itself and the object it was about to read. + +## Step 6: Add Create and Edit Forms + +ObjectUI generates forms directly from your schema. Extend `App.tsx` with form state: + +```tsx +const [showForm, setShowForm] = useState(false); +const [editId, setEditId] = useState(null); +``` + +Add a "New Task" button and handle row clicks to open the edit form: + +Both of these render inside the `SchemaRendererProvider` from Step 5, so neither +carries a data source of its own: + +```tsx + { setEditId(row.id); setShowForm(true); }} +/> + +{showForm && ( + setShowForm(false)} + onCancel={() => setShowForm(false)} + /> +)} +``` + +`recordId` is the key `object-form` reads to load the record it is editing — it +is the form's own spelling and is not shared with `detail-view`, which uses +`resourceId` (Step 8). + +The form automatically renders the correct field widgets (text inputs, select dropdowns, date pickers) based on your `FieldMetadata` definitions. Validation rules like `required` are enforced out of the box. + +In **create** mode the form also opens with the `defaultValue`s your schema +declares — the `status` and `priority` fields above start on `Todo` and +`Medium`, already submittable, rather than empty next to a required marker. +Only static defaults are seeded: a `defaultValue` that is a runtime token +(`'NOW()'`, `'current_user'`) or a CEL expression is an instruction the server +resolves at insert time, so the form leaves that field empty and lets it, with +*Set automatically when saved.* under the field while it stays empty. In +**edit** mode nothing is seeded — the form shows the record as stored. Values +you pass as `initialData` / `initialValues` outrank a schema default. + +## Step 7: Add Filters and Search + +Leverage the `active` list view you defined in Step 3 by binding the grid to +it declaratively, with the spec's per-element `dataSource` binding +(`dataSource: { object, view }`). This is the same binding `list-view`, +`detail-view` and every other object-bound block in this repo read — +`ElementDataSourceGate` resolves `view` against the object's saved views +(fetched through your data source's `getObjectSchema` / `listViews`) and +composes that view's `filter` and `sort` onto the query for you: + +```tsx +const [activeView, setActiveView] = useState('all'); + +// View switcher buttons — the names match the `listViews` keys from Step 3. + + + +// The grid re-resolves `view` whenever `activeView` changes. + +``` + +Selecting **Active** re-queries with the `active` view's `filter` +(`status != Done`) and `sort` (`priority asc`) applied — you never assemble +`$filter` / `$orderby` by hand. A view name your backend does not publish is +reported, not silently ignored: swap `activeView` for a name outside +`listViews` and the grid renders a configuration-error panel in place of the +table, the same way an unresolved `objectName` does (Step 5). A page that +instead fell back to the object's full, unfiltered scope would look like it +worked while returning every record regardless of which button was pressed — +so this block does not offer that fallback. + +**Search needs no binding at all.** `object-grid` renders its own search box +in the toolbar — on by default — and typing there drives +`DataSource.find()`'s `$search` parameter directly; there is no separate +query-param key to author. Add `searchableFields: ['title', 'description']` +to the grid's `properties` bag to narrow which fields the server matches; leave +it out and the server decides. + +## Step 8: Add a Detail View + +Create a detail page that renders a single record with all its fields: + +```tsx +import { SchemaRenderer } from '@object-ui/react'; + +function TaskDetail({ taskId, onBack }: { taskId: string; onBack: () => void }) { + return ( +
+ + +
+ ); +} +``` + +Render `TaskDetail` inside the same `SchemaRendererProvider` as the grid — it +reads the injected data source from context, exactly as `object-grid` and +`object-form` do. + +Two keys differ from the form above, and both matter: + +- **`resourceId`, not `recordId`.** `detail-view` sources the record id from + `resourceId`; `recordId` is `object-form`'s spelling. The two blocks are not + interchangeable here. +- **No `data`.** On `detail-view`, `data` means *"here is the record already, + do not fetch"* — so handing it anything (including the object's metadata) + makes the block skip `findOne` entirely and render that value as if it were + the record. Omit it and the block loads the record for itself. + +Use this component in your main app with simple routing state, or integrate with a router like React Router or TanStack Router for URL-based navigation. + +## Deployment Considerations + +**Environment config** — Keep your API URL configurable: + +```ts +const dataSource = new RestDataSource( + import.meta.env.VITE_API_URL || 'http://localhost:3000/api' +); +``` + +**Performance** — Use server-side pagination via `$top`/`$skip` query params. ObjectUI plugins support lazy loading via `LazyPluginLoader` from `@object-ui/react`. Set `cache: { enabled: true, ttl: 300 }` on your schema for client-side caching. + +**Production build** — Run `pnpm build` and deploy the `dist/` folder to any static host (Vercel, Netlify, Cloudflare Pages). + +**Authentication** — Extend `RestDataSource` to inject auth headers: + +```ts +class AuthenticatedDataSource extends RestDataSource { + constructor(baseUrl: string, private getToken: () => string) { + super(baseUrl); + } + // Override fetch calls to include: Authorization: `Bearer ${this.getToken()}` +} +``` + +## Next Steps + +- Explore the [Schema Overview](/docs/frontend) for advanced schema features +- Add a Kanban board view using `@object-ui/plugin-kanban` (see the [Kanban Plugin](/docs/frontend/plugins/plugin-kanban) reference for runnable board schemas) +- Connect to a production backend with the [Data Connectivity](/docs/frontend/data-source) guide +- Build multi-object apps with relationships using `lookup` / `master_detail` fields (see the [Lookup Field](/docs/data-modeling/field-types) reference) diff --git a/content/docs/frontend/cli.mdx b/content/docs/frontend/cli.mdx new file mode 100644 index 00000000000..c6da7d0543e --- /dev/null +++ b/content/docs/frontend/cli.mdx @@ -0,0 +1,422 @@ +--- +title: "ObjectUI CLI" +description: "Standalone command-line interface for building ObjectUI applications from JSON/YAML schemas" +--- + +`@object-ui/cli` is a standalone command-line tool for scaffolding, developing, +building, and validating ObjectUI applications driven by JSON or YAML schemas. + +It is **not** an oclif plugin — it ships its own `objectui` binary just like +`vite`, `next`, or `astro`. + +## Installation + +```bash +npm install -g @object-ui/cli +# or run on demand +npx @object-ui/cli --help +``` + +The installed binary is named `objectui`. + +## Quick start + +```bash +objectui init my-dashboard --template dashboard +cd my-dashboard +objectui dev app.json +``` + +Your app runs at `http://localhost:3000`. + +## Commands + +Run `objectui --help` for the command list, and `objectui --help` +for a command's full flag list. + +### `objectui init [name]` + +Scaffold a new ObjectUI project (creates `app.json`, `package.json`, +`vite.config.ts`, `tsconfig.json`, `index.html`, and `src/`). + +```bash +objectui init my-app +objectui init my-app --template form +objectui init . --template dashboard +``` + +| Flag | Default | Description | +|---|---|---| +| `-t, --template ` | `dashboard` | Template to use (`simple`, `form`, `dashboard`) | + +### `objectui dev [schema]` + +Start the development server with hot reload. Opens the browser automatically. + +```bash +objectui dev app.json +objectui dev pages/ # file-system routing +objectui dev my-app/pages # ...from anywhere, not just inside my-app +objectui dev --port 8080 --no-open +``` + +| Argument / Flag | Default | Description | +|---|---|---| +| `[schema]` (positional) | `app.json` | Path to JSON/YAML schema file, or a project / `pages/` directory | +| `-p, --port ` | `3000` | Port to bind | +| `-h, --host ` | `localhost` | Host to bind | +| `--no-open` | — | Do not open the browser | + +### `objectui serve [schema]` + +Legacy alias of `dev`, kept for older scripts. Same positional argument and +flags as `dev`, `--no-open` included. + +### `objectui build [schema]` + +Build the application for production with Vite. + +```bash +objectui build app.json +objectui build --out-dir build --clean +``` + +| Argument / Flag | Default | Description | +|---|---|---| +| `[schema]` (positional) | `app.json` | Path to JSON/YAML schema file, or a project / `pages/` directory | +| `-o, --out-dir ` | `dist` | Output directory | +| `--clean` | `false` | Clean output directory before build | + +### `objectui start` + +Serve a previously-built production bundle. + +```bash +objectui start +objectui start --port 8080 --dir build +``` + +| Flag | Default | Description | +|---|---|---| +| `-p, --port ` | `3000` | Port to bind | +| `-h, --host ` | `0.0.0.0` | Host to bind | +| `-d, --dir ` | `dist` | Directory to serve | + +### `objectui generate ` (alias `g`) + +Generate new resources. + +```bash +objectui generate object User +objectui g page Dashboard +objectui generate plugin my-widget +``` + +### `objectui validate [schema]` + +Validate a schema file against the ObjectUI specification. Exits with code `0` +on success and non-zero on failure — use it in CI pipelines. + +```bash +objectui validate app.json +objectui validate ./schemas/dashboard.yaml +``` + +#### What is judged + +Every node is judged by its own component schema, at every depth: a child in a +node-level slot such as `children`, and a component nested in a container's +props bag — at the positions `@objectstack/spec`'s own page walk +(`walkAddressedPageComponents`) descends, which for the page containers are +`properties.children` and `properties.items[].children`. A nested failure is +reported at the nested node's real path: + +```text +1. Invalid input + Path: properties → items → 0 → children → 0 → type + Code: invalid_union + No arm accepts type "element:txt" at properties → items → 0 → children → 0. + Nearest of the … accepted types: element:text, element:button, … +``` + +A slot that walk does not descend is not judged here. + +#### Reading a failure + +A document is checked against a union of every component type, so a failure is +reported first at the document root and then narrowed to the arm your `type` +selected. Each numbered issue carries a path and a code; the `1.1`-style entries +beneath it are that arm's own diagnosis, with the real path to the node that +failed: + +```text +1. Invalid input + Path: (root) + Code: invalid_union + 1.1 RETIRED (objectui#6523) — dividers are `{ separator: true }`; … + Path: items → 0 → type + Code: invalid_type +``` + +Only the selected arm is shown. When your `type` matches no component at all, +the report says so and offers the nearest few of the accepted values instead: + +```text +1. Invalid input + Path: (root) + Code: invalid_union + No arm accepts type "dropdwn-menu". + Nearest of the 108 accepted types: dropdown-menu, context-menu, component, drawer, breadcrumb +``` + +A document with no `type` at all is reported the same way, without a candidate +list — there is nothing for the suggestions to be near. + +#### Undeclared keys + +`objectui validate` parses through the **strict authoring face**: a key that no +schema declares is refused, on the root node and on every nested node, where the +renderer would silently ignore it. Each refused key is named once more after the +numbered issues, with a prescription — including one inside a union the node fits +more than one arm of, such as a dashboard widget, which the issue list reports +only as an `Invalid input` that names no key: + +```text +Undeclared keys — the strict authoring face refuses them: + Undeclared key "nonsense" at children → 0 (type "input"): no schema declares it there. Remove it, or check its spelling against the keys declared at that position. +``` + +There is no flag to relax this: remove the key, or correct its spelling. + +### `objectui check` + +Scan the project for ObjectUI schema files and report on the ones it +recognises. No flags. + +A JSON file with a root `type` is recognised when its root carries an ObjectUI +structural key (`children`, `className`, …) **or** when the whole +document validates as an ObjectUI component schema — through the same strict +parse `objectui validate` runs, so a document carrying an undeclared key does not +validate here either. Recognised files have their `type` checked against the +component types this build registers. + +The two ways in are not equal. A document recognised by validating was parsed +in full, nested nodes included. A document recognised by a structural key was +**not parsed at all**: a page whose `children` hold a node with a refused key +is recognised on `children` alone, and `check` says nothing about that node. +`objectui validate` is the command that parses it. + +Files that are recognised by neither arm fall into one of two reports, and the +distinction matters: + +- **Reported by name** when the root `type` *does* name a registered component. + That is ObjectUI content the validator could not accept — either the document + is off-spec, or its component type is not modelled by `@object-ui/types`. + Under each file, `check` prints the validator's first issue: its path, spelled + as `objectui validate` spells it, and its message — then one line per + undeclared key, worded as `objectui validate` words it. Run + `objectui validate ` for the full diagnosis. +- **Counted as skipped** otherwise. A root `type` heads several unrelated JSON + vocabularies — `package.json`'s `"type": "module"` most of all — and those are + not ObjectUI files. + +```text +⚠️ 1 file carries a registered ObjectUI component type but did not validate as an ObjectUI schema: + stores.json (type "object-map") + Issue at properties → map: Unrecognized key(s) on this map configuration: `latitudeFieId`. Did you mean `latitudeFieId` → `latitudeField`? Until these shapes were closed an unknown key was dropped silently — the view still rendered, without whatever the key was meant to configure. +``` + +#### Expressions on text keys that are never evaluated + +`SchemaRenderer` evaluates a `${…}` expression on the closed text keys `title`, +`label`, `value` and `description` only where `@objectstack/spec`'s +`expressionBindableTextKeysFor(type)` lists that key for the node's type. On any +other type, the expression is never resolved: the user sees its literal text, or +nothing. `objectui check` refuses it in every recognised file, and in every file +it reports by name as a registered type that did not validate — a document +carrying an undeclared key is still judged for its expressions: + +- **Component nodes only.** The file's root node, every node its `children` + hold, and every node under a slot the node's type declares — a dialog's + `trigger` and `content`, a tab item's `content`, a page's + `regions[].components` — at any depth. Which keys are slots is per type and + declared once, as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` in + `@object-ui/types`, the same declaration core's schema validator and the + SDUI parser walk; the check keeps no list of its own. Objects under any other + key (a form's `fields`, a grid's `columns`) are definitions, not nodes, and + are not judged. A root whose `type` is `page` keeps its own `title`: that is + a page key, while its `children` and its regions' components are still + judged. +- **The type is matched as written.** `ui:card` is not `card`, exactly as at + render time. +- **A type no registered component answers to** is warned about, not refused: a + custom renderer may evaluate its own keys. +- **There is no escape spelling.** A `${…}` meant as literal text on one of + these keys — a code sample in a `code-editor`'s `value`, a shell `${VAR}` — is + refused too. Write the text without `${`, for example with string + concatenation in a code sample. + +```text +x Unevaluated expression in dashboard.json at children → 0 → title: "statistic" never evaluates `title` (it evaluates only `label`, `value`, `description`), so the expression is never resolved: the user sees its literal text, or nothing. + Channels SchemaRenderer evaluates: `label`, `value`, `description`, `content`, `properties.title` (evaluated, then hoisted onto the node); or resolve the value in the host before the schema reaches SchemaRenderer. +``` + +A run with no parse errors and no refused expression ends on a tally rather than +a pass: how many recognised files validated, how many were recognised by a +structural key and not validated, and how many did not validate, followed by a +pointer to `objectui validate` for the verdict. + +Unreadable JSON and a refused expression make `objectui check` exit non-zero; +everything else above is a report. Use `objectui validate` when you want a +failing exit code on a document that does not validate. + +### `objectui doctor` + +Diagnose common project setup issues: `package.json` and its React and +Tailwind dependencies, the Tailwind PostCSS plugin and CSS entry, and +`tsconfig.json`. It reports only and changes no file. + +### `objectui analyze` + +Analyze the built application's bundle: the total, JavaScript and CSS sizes of +`dist/` in the current directory (where `objectui build` writes by default), +and its ten largest files. No flags. With no `dist/` to analyze, it exits +non-zero. + +## Scaffolding a plugin + +A plugin is scaffolded by its own initializer, `@object-ui/create-plugin`, not +by this CLI: + +```bash +npm create @object-ui/plugin my-awesome-widget +``` + +See [Create Plugin](/docs/frontend/create-plugin) for where to run it and what +it writes. + +## Schema file format + +`dev`, `serve`, `build`, and `validate` accept either JSON or YAML. + +### JSON + +```json +{ + "type": "div", + "className": "min-h-screen flex items-center justify-center", + "children": { + "type": "card", + "title": "Hello World", + "children": { "type": "text", "content": "Welcome to ObjectUI!" } + } +} +``` + +### YAML + +```yaml +type: div +className: min-h-screen flex items-center justify-center +children: + type: card + title: Hello World + children: + type: text + content: Welcome to ObjectUI! +``` + +### File-system routing + +If the project contains a `pages/` directory, `dev`, `serve` and `build` +automatically switch to file-system routing — every `.json`/`.yaml` file under +`pages/` becomes a route based on its path. + +``` +my-app/ +├── app.json → app config (title, layout) +├── pages/ +│ ├── index.json → / +│ ├── about.json → /about +│ └── users/ +│ └── [id].json → /users/:id +``` + +**Which directory is "the project"** is decided by the schema argument, so the +three commands answer one invocation the same way from anywhere: + +1. The directory the schema argument points into — `objectui build my-app/app.json` + run from above `my-app/` routes from `my-app/pages/`, with `my-app/app.json` + as the app config. A **directory** argument counts here too: it may be the + `pages/` directory itself (`objectui dev my-app/pages`) or the project + directory holding it (`objectui dev my-app`), and both answer exactly as + naming the app config beside them does — from any working directory. +2. Otherwise the current working directory — `cd my-app && objectui dev` routes + from `./pages/`, with `./app.json` as the app config when present. +3. Otherwise the named file is rendered on its own (single-schema mode), which + is what a lone `.json`/`.yaml` schema with no `pages/` beside it does. A + directory that reaches this step is no project, and is refused by name — it + is neither a `pages/` directory nor does it contain one. + +## Programmatic API + +```ts +import { serve, init } from '@object-ui/cli'; + +await serve('app.json', { port: '3000', host: 'localhost' }); +await init('my-app', { template: 'dashboard' }); +``` + +Only the long-running `serve` and project-creating `init` are exported as +functions today; other commands are bin-only. + +## Troubleshooting + +### Port already in use +```bash +objectui dev --port 3001 +``` + +### Schema not found +```bash +objectui dev ./path/to/schema.json +# or, scaffold a fresh one +objectui init . +``` + +### Run diagnostics +```bash +objectui doctor +``` + +## Package information + +| | | +|---|---| +| **Name** | `@object-ui/cli` | +| **Bin** | `objectui` | +| **License** | MIT | +| **Node** | See the `engines` field in the repository's [root `package.json`](https://github.com/objectstack-ai/objectstack/blob/main/ui/package.json) | + +## Dependencies + +The CLI is built on: + +- **Vite** + `@vitejs/plugin-react` — bundling and dev server +- **Express** + `express-rate-limit` — preview / production server +- **Commander** — argument parsing +- **chalk** — terminal colors +- **js-yaml** — YAML parsing +- **glob** — file-system routing scan + +## Next steps + +- **[Create Plugin](/docs/frontend/create-plugin)** — author your own plugins +- **[Schema Overview](/docs/frontend)** — learn the schema format +- **[Plugins](/docs/frontend/plugins)** — explore available plugins +- **[Examples](https://github.com/objectstack-ai/objectstack/tree/main/ui/examples)** — see example projects + +## Need help? + +- [GitHub Issues](https://github.com/objectstack-ai/objectstack/issues) +- [Documentation](/docs/frontend) diff --git a/content/docs/frontend/component-registry.mdx b/content/docs/frontend/component-registry.mdx new file mode 100644 index 00000000000..b70d476f4ca --- /dev/null +++ b/content/docs/frontend/component-registry.mdx @@ -0,0 +1,457 @@ +--- +title: "Component Registry" +--- + +The Component Registry is ObjectUI's system for mapping schema types to React components. Understanding the registry is key to extending ObjectUI with custom components. + +## Overview + +The registry acts as a lookup table that the `SchemaRenderer` uses to determine which React component to render for each schema type: + +``` +Schema Type → Component Registry → React Component +``` + +## Getting the Registry + +`ComponentRegistry` is a process-level singleton exported by `@object-ui/core`. +Import it directly — there is no accessor function and nothing to construct: + +```tsx +import { ComponentRegistry } from '@object-ui/core' +``` + +## Registering Components + +### Using Default Components + +The easiest way to get started is to register all default components: + +```tsx +import { initializeComponents } from '@object-ui/components' +// Side-effect import: loading the package runs its own field registration. +import '@object-ui/fields' + +// Call once at app initialization +initializeComponents() +``` + +Loading each package registers what it owns — the components and the field +widgets both land in the one `ComponentRegistry`; `initializeComponents()` exists +so a bundler cannot tree-shake the side-effect import away. The individual +renderers are not exported for hand-registration: registration is what loading +the package does. + +This registers all built-in components like: +- Forms: `input`, `textarea`, `select`, `checkbox`, etc. +- Data: `table`, `list`, `card`, `tree-view`, etc. +- Layout: `page`, `grid`, `flex`, `container`, etc. +- Feedback: `alert`, `dialog`, `toast`, etc. + +### Registering Custom Components + +Create and register your own components: + +```tsx +import { ComponentRegistry } from '@object-ui/core' +import type { BaseSchema } from '@object-ui/types' + +interface MyComponentSchema extends BaseSchema { + type: 'my-component' + title: string + content: string +} + +function MyComponent(props: MyComponentSchema) { + return ( +
+

{props.title}

+

{props.content}

+
+ ) +} + +ComponentRegistry.register('my-component', MyComponent) +``` + +Now you can use it in schemas: + +```json +{ + "type": "my-component", + "title": "Hello", + "content": "This is my custom component!" +} +``` + +## Component Interface + +All registered components receive the schema as props: + +```tsx +interface ComponentProps { + // The complete schema object + schema: T + + // Data context (optional) + data?: Record + + // Event handlers (optional) + onAction?: (action: any, context: any) => void + onChange?: (value: any) => void + onSubmit?: (data: any) => void +} + +function MyRenderer(props: ComponentProps) { + const { schema, data, onChange } = props + + return ( +
+ {/* Your component implementation */} +
+ ) +} +``` + +## Advanced Registration + +### With Metadata + +Register components with additional metadata: + +```tsx +ComponentRegistry.register('my-component', MyComponent, { + label: 'My Custom Component', + category: 'Custom', + icon: 'component-icon', + inputs: [ + { name: 'title', type: 'string' }, + { name: 'content', type: 'string' } + ] +}) +``` + +This metadata is used by the Visual Designer to provide better editing experience. + +`inputs` is also the authoring contract the SDUI parser validates against. A +component that renders `schema.children` declares the slot as +`{ name: 'children', type: 'slot' }`; a child list authored under a component +whose `inputs` carry no such entry draws the `not-a-container` diagnostic. +`isContainer` is a separate fact — *layout* containment, which keeps a block out +of a react page's JSX scope — and does not stand in for the slot. + +### Lazy Loading + +Register components that load on demand: + +```tsx +// The loader runs the first time a schema asks for `heavy-component`. +ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent')) +``` + +### Overriding Built-in Components + +Override default components with your own: + +```tsx +import { ComponentRegistry } from '@object-ui/core' +import { initializeComponents } from '@object-ui/components' +import '@object-ui/fields' + +// Register defaults first +initializeComponents() + +// Override specific component +ComponentRegistry.register('button', MyCustomButton) +``` + +## Component Categories + +Default components are organized by category: + +### Form Components + +- `input` +- `textarea` +- `select` +- `checkbox` +- `radio` +- `switch` +- `slider` +- `date-picker` +- `time-picker` +- `file-upload` +- `color-picker` + +### Data Display + +- `table` +- `list` +- `card` +- `tree-view` +- `timeline` +- `calendar` +- `kanban` + +### Layout + +- `page` +- `container` +- `grid` +- `flex` +- `tabs` +- `accordion` +- `divider` +- `spacer` + +### Feedback + +- `alert` +- `toast` +- `dialog` +- `drawer` +- `popover` +- `tooltip` +- `progress` +- `skeleton` +- `spinner` + +### Navigation + +- `menu` +- `breadcrumb` +- `pagination` +- `steps` + +### Other + +- `button` +- `link` +- `text` +- `icon` +- `image` +- `video` +- `badge` +- `avatar` + +## Checking Registered Components + +### Get All Registered Types + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +const types = ComponentRegistry.getAllTypes() +console.log(types) // ['input', 'button', 'form', ...] +``` + +### Check if Type is Registered + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +if (ComponentRegistry.has('my-component')) { + console.log('Component is registered') +} +``` + +### Get Component Metadata + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +const metadata = ComponentRegistry.getMeta('input') +console.log(metadata) +// { +// label: 'Input', +// category: 'Form', +// icon: 'input-icon', +// ... +// } +``` + +## Best Practices + +### 1. Register Once at App Initialization + +```tsx +// main.tsx or App.tsx +import { initializeComponents } from '@object-ui/components' +import '@object-ui/fields' + +initializeComponents() + +function App() { + // Your app code +} +``` + +### 2. Use TypeScript for Custom Components + +```tsx +import type { BaseSchema } from '@object-ui/types' + +interface CustomSchema extends BaseSchema { + type: 'custom' + customProp: string +} + +function CustomComponent(props: { schema: CustomSchema }) { + // TypeScript ensures type safety +} +``` + +### 3. Follow Naming Conventions + +Use kebab-case for component types: +- ✅ `my-component`, `custom-button`, `data-table` +- ❌ `MyComponent`, `customButton`, `DataTable` + +### 4. Provide Meaningful Metadata + +```tsx +ComponentRegistry.register('rating', RatingComponent, { + label: 'Star Rating', + category: 'Form', + icon: 'star', + labelling: 'group' +}) +``` + +### 5. Handle Missing Props Gracefully + +```tsx +function MyComponent(props: ComponentProps) { + const { schema } = props + const title = schema.title || 'Default Title' + const content = schema.content || '' + + return ( +
+

{title}

+

{content}

+
+ ) +} +``` + +## Creating Plugin Packages + +Group related components into plugin packages: + +```tsx +// @my-org/objectui-plugin-charts +import { ComponentRegistry } from '@object-ui/core' +import { BarChart } from './BarChart' +import { LineChart } from './LineChart' +import { PieChart } from './PieChart' + +export function registerChartComponents() { + ComponentRegistry.register('bar-chart', BarChart) + ComponentRegistry.register('line-chart', LineChart) + ComponentRegistry.register('pie-chart', PieChart) +} +``` + +Usage: + +```tsx +import { initializeComponents } from '@object-ui/components' +import '@object-ui/fields' +import { registerChartComponents } from '@my-org/objectui-plugin-charts' + +initializeComponents() +registerChartComponents() +``` + +## Example: Custom Form Component + +Here's a complete example of a custom form component: + +```tsx +import { forwardRef, useState } from 'react' +import { ComponentRegistry } from '@object-ui/core' +import type { BaseSchema } from '@object-ui/types' +import { cn } from '@object-ui/components' + +interface RatingSchema extends BaseSchema { + type: 'rating' + name: string + label?: string + maxStars?: number + required?: boolean + disabled?: boolean + onChange?: (value: number) => void +} + +const RatingComponent = forwardRef( + ({ schema }, ref) => { + const [value, setValue] = useState(0) + const maxStars = schema.maxStars || 5 + + const handleClick = (rating: number) => { + if (schema.disabled) return + setValue(rating) + schema.onChange?.(rating) + } + + return ( +
+ {schema.label && ( + + )} +
+ {Array.from({ length: maxStars }).map((_, index) => ( + + ))} +
+
+ ) + } +) + +RatingComponent.displayName = 'Rating' + +// Register the component +ComponentRegistry.register('rating', RatingComponent, { + label: 'Star Rating', + category: 'Form', + labelling: 'group', + inputs: [ + { name: 'name', type: 'string', required: true }, + { name: 'label', type: 'string' }, + { name: 'maxStars', type: 'number', description: 'Defaults to 5 — the renderer\'s own fallback' }, + { name: 'required', type: 'boolean' }, + { name: 'disabled', type: 'boolean' } + ] +}) + +export { RatingComponent } +``` + +## Next Steps + +- [Expression System](/docs/ui) - Learn about dynamic expressions +- [Schema Rendering](/docs/frontend/schema-rendering) - Understand the rendering engine +- [Custom Plugin Development](/docs/frontend/plugin-development) - Deep dive into component creation + +## Related Documentation + +- [`@object-ui/core` README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/core) - Component registry API +- [`@object-ui/react` README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/react) - React integration +- [Schema Type Reference](/docs/ui/components) - Component metadata reference diff --git a/content/docs/frontend/create-plugin.mdx b/content/docs/frontend/create-plugin.mdx new file mode 100644 index 00000000000..fc4bf623c7e --- /dev/null +++ b/content/docs/frontend/create-plugin.mdx @@ -0,0 +1,187 @@ +--- +title: "Create Plugin" +description: "Interactive CLI tool for creating ObjectUI plugins" +--- + +The `@object-ui/create-plugin` package is a small interactive CLI that scaffolds a new +ObjectUI plugin package. It asks a couple of questions, writes a ready-to-build package, +and prints the commands to run next. Writing those files is the whole of what it does: it +installs no dependencies and initialises no git repository. + +## Installation + +```bash +npm install -g @object-ui/create-plugin +``` + +Or use with npx (recommended — nothing to install, and you always get the published +version): + +```bash +npx @object-ui/create-plugin my-plugin +``` + +## Quick Start + +**Run it from the root of a pnpm workspace.** The new package is written to +`packages/plugin-` **relative to the current directory**, and what it writes is +built for a workspace: it depends on the `@object-ui` packages through `workspace:*` +links, and its `tsconfig.json` extends the one two directories above it. Run the +generator anywhere else and you get a package whose dependencies and TypeScript config +resolve to nothing. + +```bash +cd my-workspace +npx @object-ui/create-plugin awesome +``` + +The CLI validates the name you gave it — lowercase letters, digits and hyphens only, no +scopes and no path separators — and then cleans it up: a leading `plugin-` is stripped, so +`awesome` and `plugin-awesome` mean the same thing. From the cleaned name it derives, +without asking: + +- the directory it writes into, `packages/plugin-`; +- the package name, `@object-ui/plugin-`; +- the `type` key the plugin registers itself under in the + [Component Registry](/docs/frontend/component-registry) — the cleaned name itself, so + `awesome` in the example above; +- the PascalCase component name, and the name of the file implementing it. + +One name decides all four, so pick it with all four in mind. Everything else — a +description and an author — is asked interactively with a default filled in. The +`--description` and `--author` options **pre-fill those prompts rather than skip them**; +run `npx @object-ui/create-plugin --help` for the options it accepts today. + +**A run that cannot ask still scaffolds.** With no TTY — a pipe, a CI step, stdin taken +from `/dev/null` — nothing is asked and every prompt takes its default, and the same +happens to the questions after a Ctrl-C. Either way the complete file set is written, +licence text included. The plugin name is the sole exception, being the one answer with +no default: without it such a run prints `Plugin name is required` and exits `1` having +created nothing, which is why the name belongs on the command line whenever the run is +not interactive. The package README states the rule in full. + +## What It Generates + +This page deliberately does **not** reproduce the generated tree or the generated files. +Run the generator and read what it wrote — and when you need the answer without running +it, read the one function that decides it: +[`buildPluginFiles()` in `packages/create-plugin/src/templates.ts`](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/create-plugin/src/templates.ts). +That map of *path to file contents* is the single source of truth for what a scaffolded +plugin contains; the CLI in `src/index.ts` is the loop that writes it to disk. + +Two things are worth knowing before you look: + +- **It scaffolds a library, not an app.** The generated Vite config is a `build.lib` + config emitting an ES and a UMD bundle into `dist/`. There is no application to serve, + so there is no dev server to start — see [Development Workflow](#development-workflow). +- **Its dependency ranges are not listed here on purpose.** They live in that one + template literal, and `packages/create-plugin/src/__tests__/templates.test.ts` anchors + the generated `devDependencies` to this repository's own declarations, so the scaffold + cannot drift away from the toolchain it is part of. Reading the literal is the only way + to see what your scaffold will actually contain; a range copied onto this page would be + the first thing to go stale. + +## Development Workflow + +When the generator finishes it prints the next steps for the package it just wrote — +follow those. Inside the new package, `pnpm run` lists the scripts it actually has. Two +notes on what to expect from them: + +- **There is no dev server, and no `dev` script to run one.** A library build has no app + to serve. Iterate either by building the plugin and reloading the app that consumes it, + or — the way this repository develops its own plugins — by pointing the consuming app's + Vite `resolve.alias` at your plugin's `src`, so your sources become part of that app's + dev server. The Runner's `vite.config.ts` (`ui/packages/runner` in the ObjectStack repository) carries that alias table, including + the transitive-closure rule that makes it work. +- **Tests are green on a fresh scaffold, and are meant to stay that way.** The generator + writes an example test together with the Vitest setup file its config points at, and + `templates.test.ts` pins those pieces to each other: the test stack is declared, the + environment is a DOM, the setup file exists. If the first test run in a freshly + scaffolded plugin is red, that is a bug in the generator rather than in your machine — + please file it. + +To use the plugin from an app in the same workspace, add it as a `workspace:*` dependency +and import the package for its side effect: importing it is what registers the component, +which is what makes its `type` key usable in a schema. See +[Plugin Concepts](/docs/frontend/plugins). + +To publish it, use `pnpm publish`. pnpm rewrites the `workspace:*` links into real ranges +as it packs; a plain `npm publish` would ship the literal `workspace:*` and the resulting +tarball is uninstallable. + +## Configuration + +There is none beyond the command line. The generator reads no configuration file — there +is no `.create-plugin.config.js` and no template directory to point it at. Everything it +can be told, it is told through the plugin-name argument and the options `--help` lists. + +## Plugin Naming Conventions + +- **Package name:** `@object-ui/plugin-` — the CLI composes it, you only choose + ``. +- **Schema `type` key:** `kebab-case`, the cleaned plugin name. +- **Component name:** `PascalCase`, derived from the same name. + +## Best Practices + +Advice for the plugin you are about to write, beyond whatever the scaffold hands you: + +- **Lazy-load heavy dependencies.** `@object-ui/plugin-*` is the layer where heavy + third-party libraries are allowed to live, which makes it the layer that most needs a + `React.lazy` boundary, so an app that never renders your component never pays for it: + + ```tsx + const HeavyImpl = React.lazy(() => import('./HeavyImpl')); + ``` + +- **Give that boundary a real fallback.** A `Skeleton` shaped like the component beats a + bare spinner: the layout does not jump when the chunk arrives. +- **Export your schema types.** The schema interface is the contract between a metadata + author and your renderer, so make it importable rather than internal. +- **Treat the registry key as public.** The `type` you register appears in every schema + that uses your plugin, so renaming it is a breaking change for metadata already stored. + +## Example Plugins + +See these official plugins for reference: + +- **[@object-ui/plugin-charts](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-charts)** - Data visualization +- **[@object-ui/plugin-kanban](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-kanban)** - Kanban boards +- **[@object-ui/plugin-editor](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-editor)** - Code editor + +## Package Information + +**Package Name:** `@object-ui/create-plugin` — published on npm, see the +[npm page](https://www.npmjs.com/package/@object-ui/create-plugin) for the current version +**Binary:** `create-plugin` +**License:** MIT + +## Troubleshooting + +**It stopped because the directory already exists.** The generator never writes into an +existing directory. Pick another name, or remove the old package first. + +**The name was rejected.** Only lowercase letters, digits and hyphens are accepted; a +scope, a slash or a `..` is refused. The validation is at the top of +[`src/index.ts`](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/create-plugin/src/index.ts). + +**The new package's imports and `tsconfig.json` resolve to nothing.** The generator was +almost certainly run outside a workspace root — see [Quick Start](#quick-start). Move the +package into a real workspace's `packages/` directory, or re-run the generator from +there. + +**Permission denied installing globally.** Use npx instead of `npm install -g`; it needs +no write access outside the npm cache. + +## Next Steps + +- **[Plugin Concepts](/docs/frontend/plugins)** - Learn how plugins work +- **[Plugin Development Guide](/docs/frontend/plugin-development)** - The full authoring guide +- **[Component Registry](/docs/frontend/component-registry)** - Register components +- **[CLI](/docs/frontend/cli)** - Test your plugin with the CLI + +## Need Help? + +- [GitHub Issues](https://github.com/objectstack-ai/objectstack/issues) +- [Plugin Development Guide](/docs/frontend/plugin-development) +- [Examples](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages) diff --git a/content/docs/frontend/data-objectstack.mdx b/content/docs/frontend/data-objectstack.mdx new file mode 100644 index 00000000000..42a4e6fe500 --- /dev/null +++ b/content/docs/frontend/data-objectstack.mdx @@ -0,0 +1,554 @@ +--- +title: "ObjectStack Data Adapter" +description: "Data adapter for connecting ObjectUI to ObjectStack backends" +--- + +The `@object-ui/data-objectstack` package provides a data adapter that connects ObjectUI to ObjectStack backends. It enables seamless integration with ObjectStack's data layer, supporting CRUD operations, queries, and real-time updates. + +## Installation + +```bash +npm install @object-ui/data-objectstack +``` + +**Note:** `@objectstack/client` is a regular dependency of this package — it is installed and resolved along with it, so there is nothing to install separately. + +## Overview + +The ObjectStack adapter bridges ObjectUI's schema-driven UI with ObjectStack's data backend, providing: + +- 🔌 **Automatic Integration** - Connect to ObjectStack APIs +- 🔄 **CRUD Operations** - Create, read, update, delete +- 🔍 **Query Builder** - Build complex queries +- 📊 **Data Binding** - Automatic data synchronization +- 🚀 **Real-time Updates** - Live data updates (if supported by backend) + +## Features + +- **ObjectStack Client Integration** - Uses `@objectstack/client` for API communication +- **Headless** - No React dependency; the adapter is a plain `DataSource` object you inject at the renderer boundary +- **Query Support** - Full ObjectQL query support +- **Automatic Field Mapping** - Maps ObjectStack schemas to UI components +- **Type Safety** - Full TypeScript support +- **Error Handling** - Comprehensive error handling and retry logic + +## Quick Start + +### 1. Create the adapter + +This package is **headless** — it exports no React components and no hooks. You +create a plain adapter object: + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com', + token: 'your-api-token', // optional if auth is handled elsewhere +}); +``` + +`createObjectStackAdapter` returns an `ObjectStackAdapter` — the concrete adapter +class, which implements `DataSource`, the universal interface every ObjectUI +renderer consumes. `new ObjectStackAdapter(config)` is the class form of the same +thing and has the same type. Annotate the value as `DataSource` wherever you want +only the universal surface. + +### 2. Inject it at the renderer boundary + +React wiring lives in `@object-ui/react`, not here. Wrap your tree in +`SchemaRendererProvider` and hand it the adapter: + +```tsx +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; +import type { ObjectGridBlockNode } from '@object-ui/types'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com', +}); + +// The block's props go in its `properties` bag, as `@objectstack/spec` declares them. +const mySchema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'user' } }; + +function App() { + return ( + + + + ); +} +``` + +`SchemaRenderer` also accepts an explicit `dataSource` prop +(``) when you would +rather inject per render than through context. + +### 3. Bind a block to data + +A schema node's `dataSource` key is **metadata, not the adapter** — it is the +spec's per-element binding describing *what to query*, while the adapter above +describes *how to reach the backend*: + +```json +{ + "type": "list-view", + "dataSource": { "object": "user", "view": "active", "limit": 50 } +} +``` + +The binding accepts `object`, `view`, `filter`, `sort` and `limit`. `view` names +a saved view whose columns, filter, sort and page size are applied to the render; +`filter` is *additional* criteria that AND-combines with the view's rather than +replacing it. See [Per-element data binding](/docs/frontend/data-source) for the +full table of which blocks honour which keys. + +## API Reference + +### `createObjectStackAdapter` + +Factory returning an `ObjectStackAdapter` — the concrete adapter class, which +implements `DataSource` — backed by an ObjectStack backend. + +**Config:** + +```typescript +function createObjectStackAdapter(config: { + /** ObjectStack server base URL */ + baseUrl: string; + + /** Optional bearer token */ + token?: string; + + /** Optional custom fetch (proxies, auth headers, test doubles) */ + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + + /** Metadata cache tuning */ + cache?: { + maxSize?: number; // default 100 schemas + ttl?: number; // default 5 * 60 * 1000 ms + }; + + /** Reconnect behaviour */ + autoReconnect?: boolean; // default true + maxReconnectAttempts?: number; // default 3 + reconnectDelay?: number; // default 1000 ms +}): ObjectStackAdapter; +``` + +**Example:** + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com', + token: 'your-api-token', + cache: { maxSize: 100, ttl: 5 * 60 * 1000 }, + autoReconnect: true, + maxReconnectAttempts: 5, +}); +``` + +`api.example.com` is a placeholder — here and in every other example on this +page. RFC 2606 reserves `example.com` precisely so documentation can name a host +that can never resolve to somebody's real service, so a snippet copied with it +left in fails loudly instead of reaching a stranger's server. Replace it with +your own ObjectStack server's URL. + +Pass a record type to get typed results — the default is `unknown`: + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +type User = { id: string; name: string; email: string }; + +const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); +``` + +### `ObjectStackAdapter` + +The class behind the factory — and the type the factory declares. `new +ObjectStackAdapter(config)` and `createObjectStackAdapter(config)` take the same +config and produce the same type, so the two forms are interchangeable: + +```typescript +import { createObjectStackAdapter, ObjectStackAdapter } from '@object-ui/data-objectstack'; + +type User = { id: string; name: string; email: string }; + +// Same declared type, either way. +const fromFactory = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); +const fromClass = new ObjectStackAdapter({ baseUrl: 'https://api.example.com' }); +``` + +**What changed.** This section used to tell you to hold the class type to +reach the members listed under *Beyond `DataSource`* below, because the factory +declared `DataSource` while returning `new ObjectStackAdapter(config)`: the +value always carried those members, only the declared type hid them +(objectui#7323). That distinction is gone. If you switched to +`new ObjectStackAdapter(...)` solely to reach a cache, connection-state or batch +method, you can switch back to the factory — nothing about the value changes, and +neither does its type. The class name is still worth importing when you need +something to annotate with. + +Narrowing still works, and is still the right shape for a prop, a field or a test +double that must accept **any** adapter: + +```typescript +import type { DataSource } from '@object-ui/types'; +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +// The universal surface only, by annotation. +const dataSource: DataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); +``` + +**On the `DataSource` interface** (available from either form, and from any other +adapter): + +- `find(resource, params?)` - Query multiple records +- `findOne(resource, id, params?)` - Get a single record by ID +- `create(resource, data)` - Create a record +- `update(resource, id, data, opts?)` - Update a record +- `delete(resource, id, opts?)` - Delete a record +- `getObjectSchema(objectName)` - Fetch schema metadata (cached) +- `bulk?(resource, operation, data)` - Batch create/update/delete on one object +- `batchTransaction?(operations)` - Cross-object atomic batch (master-detail) +- `onMutation?(listener)` - Subscribe to create/update/delete events +- `queryGroupHeaders?(resource, query)` - Answer a grouped list view's group + header query (`compileListViewGroupQuery`), one row per group with its true + `count` — what a grouped grid groups by on the server (objectui#7189) + +`bulk`, `batchTransaction`, `onMutation` and `queryGroupHeaders` are +**optional** members of `DataSource`: not every adapter implements them, so +through a `DataSource`-typed value they must be feature-detected +(`typeof dataSource.bulk === 'function'`). `ObjectStackAdapter` implements all +four unconditionally, and the factory declares the class, so a value from +either form calls them directly. (`onMutation` +was listed under *Adapter-only* here until objectui#7323; it is optional on +`DataSource`, not absent from it.) + +**Beyond `DataSource`** (declared on `ObjectStackAdapter`, so reachable from +either form): + +- `connect()` - Establish the connection (called lazily by every operation) +- `getCacheStats()` / `invalidateCache(key?)` / `clearCache()` - Cache control +- `getClient()` - Access the underlying `@objectstack/client` instance +- `getConnectionState()` / `isConnected()` - Connection introspection +- `onConnectionStateChange(listener)` - Subscribe to state changes (returns unsubscribe) +- `onBatchProgress(listener)` - Subscribe to bulk progress (returns unsubscribe) + +Those six bullets cover nine members, and they are the documented subset. +`ObjectStackAdapter` declares more members beyond `DataSource` than these — +`Exclude, keyof DataSource>` lists +them all; the rest are lower-level seams — client discovery, cache-key +invalidation, dataset and dashboard access and advisory subscriptions among +them — which this page does not document and which carry no compatibility +promise here. + +### Per-element data binding + +A schema node's `dataSource` key is the spec's `ElementDataSource` (validated by +`ElementDataSourceSchema` in `@objectstack/spec`) — plain JSON metadata, **not** +the adapter object: + +```typescript +interface ElementDataSource { + /** Object to query (required) */ + object: string + + /** Saved view name — supplies columns, filter, sort and page size */ + view?: string + + /** Additional filter rules, one `{ field, operator, value }` per condition; they AND together and with the view's filter */ + filter?: ViewFilterRule[] + + /** Sort order — overrides the view's */ + sort?: Array<{ field: string; order: 'asc' | 'desc' }> + + /** Row cap — overrides the view's page size */ + limit?: number +} +``` + +The spec schema is **strict**: a key outside this list is rejected rather than +silently ignored. `SchemaRenderer` also strips this key from the props it +spreads, so the metadata can never shadow the injected adapter. Full per-block +support table: [Per-element data binding](/docs/frontend/data-source). + +## Usage Examples + +Every example below binds a block with the per-element `dataSource` metadata. The +adapter itself is injected once, at the renderer boundary, as shown in Quick Start. + +### Grid over an object + +```json +{ + "type": "object-grid", + "dataSource": { "object": "product", "limit": 20 } +} +``` + +### Narrowing a saved view + +`filter` AND-combines with the view's own filter, so a binding can only narrow +what the view already restricts. It is a list of `{ field, operator, value }` +rules, which AND together: + +```json +{ + "type": "object-grid", + "dataSource": { + "object": "order", + "view": "open_orders", + "filter": [{ "field": "total", "operator": "greater_than", "value": 100 }], + "limit": 50 + } +} +``` + +### Form on one record + +Form blocks honour `object` (a form edits a record, so there is no collection +query to filter or page): + +```json +{ + "type": "object-form", + "dataSource": { "object": "user" } +} +``` + +### Kanban + +`object-kanban` honours `object` and `filter`; it has no ordering or row cap of +its own: + +```json +{ + "type": "object-kanban", + "dataSource": { + "object": "task", + "filter": [{ "field": "project", "operator": "equals", "value": "acme" }] + } +} +``` + +## Configuration + +The adapter reads **no environment variables of its own** — configuration is +passed to `createObjectStackAdapter` in code. Sourcing those values from the +environment is your bundler's business (`process.env.*` under Node or webpack, +`import.meta.env.*` under Vite): + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: process.env.OBJECTSTACK_API_URL!, + token: process.env.OBJECTSTACK_API_TOKEN, + cache: { maxSize: 200, ttl: 10 * 60 * 1000 }, + autoReconnect: true, + maxReconnectAttempts: 5, + reconnectDelay: 2000, +}); +``` + +### Custom fetch + +Pass your own `fetch` to route requests through a proxy or attach extra headers: + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com', + fetch: (input, init) => + fetch(input, { ...init, headers: { ...init?.headers, 'X-Tenant': 'acme' } }), +}); +``` + +## Advanced Usage + +### Custom Queries + +Call the adapter directly for queries a block does not cover. Query parameters +are OData-style (`$filter`, `$orderby`, `$top`, `$skip`, `$select`, `$expand`): + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); +const startDate = '2024-01-01'; + +const [users, orders] = await Promise.all([ + dataSource.find('user', { + $filter: { createdAt: { $gte: startDate } }, + $orderby: 'createdAt desc', + $top: 20, + }), + dataSource.find('order', { + $filter: { status: 'completed' }, + $select: ['id', 'total', 'createdAt'], + }), +]); + +// find() resolves to { data, total? } +console.log(users.data.length, users.total); +``` + +### Mutations + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +type User = { id: string; name: string; email: string }; + +const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); + +const user = await dataSource.create('user', { name: 'Ada', email: 'ada@example.com' }); + +const updated = await dataSource.update('user', user.id, { name: 'Ada Lovelace' }); + +await dataSource.delete('user', user.id); +``` + +Batch writes on one object go through `bulk`, and cross-object writes that must +commit or roll back together go through `batchTransaction`. Both are optional on +the `DataSource` interface, so a value you annotated as `DataSource` still has to +feature-detect them — but the adapter implements both unconditionally and the +factory declares the adapter, so the value from Quick Start calls them directly: + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +type User = { id: string; name: string; email: string }; + +const adapter = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); + +await adapter.bulk('user', 'create', [ + { name: 'Alice', email: 'alice@example.com' }, + { name: 'Bob', email: 'bob@example.com' }, +]); + +// `{ $ref: 0 }` resolves to the id minted by operation 0 +await adapter.batchTransaction([ + { object: 'invoice', action: 'create', data: { no: 'INV-1' } }, + { object: 'invoice_line', action: 'create', data: { invoice: { $ref: 0 }, amount: 10 } }, +]); +``` + +### Error Handling + +The adapter throws typed errors with stable codes: + +```typescript +import { + createObjectStackAdapter, + MetadataNotFoundError, + AuthenticationError, + ConnectionError, +} from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); + +try { + const schema = await dataSource.getObjectSchema('user'); +} catch (error) { + if (error instanceof MetadataNotFoundError) { + // code 'METADATA_NOT_FOUND', statusCode 404 + } else if (error instanceof AuthenticationError) { + // code 'AUTHENTICATION_ERROR', statusCode 401 + } else if (error instanceof ConnectionError) { + // code 'CONNECTION_ERROR', statusCode 503 + } +} +``` + +Exported error types: `ObjectStackError` (base), `MetadataNotFoundError`, +`BulkOperationError`, `ConnectionError`, `AuthenticationError`, +`DataApiValidationError`, plus the `isObjectStackError` / `isErrorType` guards. + +## Package Information + +**Package Name:** `@object-ui/data-objectstack` — published on npm, see the +[npm page](https://www.npmjs.com/package/@object-ui/data-objectstack) for the current version +**License:** MIT + +## Dependencies + +This package declares no peer dependencies. Everything below is a regular dependency that is installed and resolved with the package: + +- **@objectstack/client** - ObjectStack API client +- **@objectstack/spec** - ObjectStack metadata spec and shared contracts +- **@object-ui/core** - Schema engine and query helpers +- **@object-ui/types** - TypeScript types + +## Integration with Plugins + +Many plugins support ObjectStack data sources: + +- **plugin-grid** - Data grids with ObjectStack queries +- **plugin-form** - Forms with ObjectStack CRUD +- **plugin-kanban** - Kanban boards with ObjectStack data +- **plugin-calendar** - Calendars with ObjectStack events +- **plugin-gantt** - Gantt charts with ObjectStack tasks +- **plugin-map** - Maps with ObjectStack locations + +## Next Steps + +- **[ObjectStack Documentation](https://docs.objectstack.ai)** - Learn about ObjectStack +- **[Data Sources Guide](/docs/frontend/data-source)** - Learn about data sources +- **[Plugins](/docs/frontend/plugins)** - Explore plugins with data support +- **[Schema Overview](/docs/frontend)** - Learn the schema format + +## Troubleshooting + +### Authentication Errors + +An `AuthenticationError` (code `AUTHENTICATION_ERROR`, status 401) means the +`token` passed to `createObjectStackAdapter` was missing, expired or rejected. +Check the connection state and the values you passed in: + +Connection introspection lives on the adapter, not on the `DataSource` interface +— and the factory declares the adapter, so the value from Quick Start reaches it +without a second construction: + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); + +console.log(dataSource.getConnectionState()); // 'connected' | 'error' | ... + +dataSource.onConnectionStateChange((event) => { + if (event.error) console.error('Connection error:', event.error); +}); +``` + +After the first connect, the state follows the requests you make. A request +with no HTTP answer moves it to `reconnecting`, then to `error` once the +`maxReconnectAttempts` budget is spent. The next answered request moves it back +to `connected`. An answered 4xx or 5xx leaves it unchanged, because the server +is reachable. + +### CORS Issues + +Configure CORS on your ObjectStack backend to allow your domain. + +### Data Not Loading + +Check browser console and network tab for errors. Verify: +- `baseUrl` points at the right ObjectStack server +- The token is valid and not expired +- The object name exists in your ObjectStack instance +- The block's `dataSource` binding names an object (and a `view`, if used) that resolves +- An adapter is actually injected — a block with no `SchemaRendererProvider` + above it and no `dataSource` prop has nothing to query with + +## Need Help? + +- [ObjectStack Documentation](https://docs.objectstack.ai) +- [GitHub Issues](https://github.com/objectstack-ai/objectstack/issues) +- [ObjectUI Documentation](/docs/frontend) diff --git a/content/docs/frontend/data-source.mdx b/content/docs/frontend/data-source.mdx new file mode 100644 index 00000000000..df30b6ec48b --- /dev/null +++ b/content/docs/frontend/data-source.mdx @@ -0,0 +1,328 @@ +--- +title: "Data Connectivity" +--- + +ObjectUI follows the **Universal Adapter Pattern**. UI components do not hardcode transport details. They receive a `DataSource` implementation from `SchemaRendererProvider` and call a stable CRUD/query contract. + +This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface. + +## The Interface + +The canonical interface lives in `@object-ui/types`: + +```typescript +import type { BatchTransactionOperation, QueryParams, QueryResult } from '@object-ui/types'; + +export interface DataSource { + find(resource: string, params?: QueryParams): Promise>; + findOne(resource: string, id: string, params?: QueryParams): Promise; + create(resource: string, data: Partial): Promise; + update( + resource: string, + id: string, + data: Partial, + opts?: { ifMatch?: string }, + ): Promise; + delete( + resource: string, + id: string, + opts?: { ifMatch?: string }, + ): Promise; + + // Optional: atomically persist an ordered set of cross-object operations + // (master-detail save). `{ $ref: }` links a child to a parent + // created earlier in the same batch. Adapters without server-side atomicity + // may emulate it — see below. + batchTransaction?( + operations: BatchTransactionOperation[], + ): Promise<{ results: any[] }>; + + getObjectSchema(objectName: string): Promise; +} +``` + +`find()` returns a `QueryResult` so components can receive both rows and pagination metadata: + +```typescript +interface QueryResult { + data: T[]; + total?: number; + page?: number; + pageSize?: number; + hasMore?: boolean; + cursor?: string; + metadata?: Record; +} +``` + +## Available Adapters + +### ObjectStack Adapter (Official) + +Use `@object-ui/data-objectstack` for ObjectStack-compatible backends. + +```bash +pnpm add @object-ui/data-objectstack +``` + +```typescript +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.your-instance.com' +}); +``` + +## Usage + +Inject the data source at the renderer boundary: + +```tsx +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; +import type { DeclaredNode } from '@object-ui/types'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com' +}); + +// `object-grid` is a data-bound node: it reads `objectName` from its +// `properties` bag and queries the injected data source for the rows. +const mySchema: DeclaredNode = { type: 'object-grid', properties: { objectName: 'users' } }; + +function App() { + return ( + + + + ); +} +``` + +## Creating a Custom Adapter + +If you have a proprietary backend, wrap its SDK or client in a `DataSource` implementation. Keep transport details in the adapter, not in renderers. + +```typescript +import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; + +type User = { + id: string; + name: string; + email: string; +}; + +type BackendClient = { + listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>; + getUser(id: string | number): Promise; + createUser(data: Partial): Promise; + updateUser(id: string | number, data: Partial): Promise; + deleteUser(id: string | number): Promise; + describeObject(name: string): Promise; +}; + +class UserDataSource implements DataSource { + constructor(private readonly client: BackendClient) {} + + async find(resource: string, params?: QueryParams): Promise> { + if (resource !== 'users') { + return { data: [], total: 0 }; + } + + const result = await this.client.listUsers(params); + return { + data: result.rows, + total: result.total, + }; + } + + findOne(_resource: string, id: string | number): Promise { + return this.client.getUser(id); + } + + create(_resource: string, data: Partial): Promise { + return this.client.createUser(data); + } + + update(_resource: string, id: string | number, data: Partial): Promise { + return this.client.updateUser(id, data); + } + + delete(_resource: string, id: string | number): Promise { + return this.client.deleteUser(id); + } + + getObjectSchema(objectName: string): Promise { + return this.client.describeObject(objectName); + } +} +``` + +## Query Parameters + +ObjectUI uses OData-style query keys for broad compatibility: + +```typescript +await dataSource.find('users', { + $select: ['id', 'name', 'email'], + $filter: { status: 'active' }, + $orderby: { name: 'asc' }, + $skip: 0, + $top: 25, + $count: true, +}); +``` + +Data-aware plugins may also use optional methods such as `batchTransaction`, `bulkUpdate`, `bulkDelete`, `getView`, or `listViewOverrides` when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them. + +### Bulk writes: per-record results and one refresh per run + +`bulkUpdate` and `bulkDelete` resolve to either a plain count or a `BulkWriteResult`: the count plus one `{ id, error }` entry per record the server refused, with the server's reason. An adapter whose server reports per-row outcomes should return the `BulkWriteResult` (the `@object-ui/data-objectstack` adapter does, from the `results[].errors[]` of `updateMany` / `deleteMany`). An adapter that has only a count keeps returning the number. A caller handles both: + +```typescript +import type { DataSource } from '@object-ui/types'; + +declare const dataSource: DataSource; + +const res = await dataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' }); +if (typeof res === 'number') { + console.log(`${res} updated`); +} else if (res) { + console.log(`${res.succeeded} updated`); + for (const { id, error } of res.errors) console.warn(`${id}: ${error}`); +} +``` + +The grid's bulk actions read both shapes. A refused record shows up in the result dialog and the error CSV under its own id and reason, and **Retry** works on it, whatever the action's `batchSize`. + +Every `create` / `update` / `delete` normally fires one `onMutation` event, and every bound view refetches once per event. A loop that writes many rows (a bulk action on its per-row path, an undo) would refetch the list once per row. Wrap such a loop in the optional `batchMutationEvents(work)`: the events its writes fire are held and delivered when `work` settles, one per touched resource and mutation type, so bound views refresh once. The grid's bulk executor does this when the adapter has the method. + +```typescript +import type { DataSource } from '@object-ui/types'; + +declare const dataSource: DataSource; + +const ids = ['o1', 'o2', 'o3']; +const write = async () => { + for (const id of ids) await dataSource.update('order', id, { status: 'closed' }); +}; +await (dataSource.batchMutationEvents ? dataSource.batchMutationEvents(write) : write()); +``` + +`listMetadataItems(type)` lists one metadata registry type as `{ name, label }` headers, for the `position-ref` / `permission-ref` field widgets (see the [Fields guide](/docs/frontend/fields)). An empty array means the registry has no item of that type, so a failed load must **reject**, never resolve `[]`. + +### Cross-object atomic writes (`batchTransaction`) + +Master-detail saves (a parent record plus its child line items) go through +`dataSource.batchTransaction(operations)` — one ordered list of cross-object +create/update/delete operations, where a child's foreign key can be +`{ $ref: }` to point at a parent created in the same batch. +The `@object-ui/data-objectstack` adapter maps this to the published +`@objectstack/client` `data.batchTransaction` SDK method, which drives the +server's atomic `POST /api/v1/batch` endpoint (commit-all-or-roll-back-all). +Adapters without a +transactional endpoint don't need to hand-write orchestration: call +`emulateBatchTransaction(dataSource, operations)` from `@object-ui/core`, which +executes the operations sequentially (resolving `$ref`s) with best-effort +compensation on failure. UI components never branch on atomicity — they call +`runBatchTransaction(dataSource, operations)` (also from `@object-ui/core`), +which uses the adapter's method when present and emulates otherwise. + +The `@object-ui/data-objectstack` adapter decides whether it can trust server +atomicity **declaratively**, at connect time: it reads the +`capabilities.transactionalBatch` flag from `GET /api/v1/discovery` +(framework #3298). When the backend advertises `true`, the adapter treats any +`/batch` failure as a real error — no non-atomic client-side compensation. When +the flag is `false` or absent (a backend predating #3298), it keeps the legacy +behaviour: probe `/batch` and fall back to the non-atomic emulation on +`404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only +against backends that advertise the capability; older ones still save, but +best-effort. See the +[adapter README](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction) +for the full capability table and minimum-backend note. + +## Per-element data binding on a page (`dataSource`) + +A metadata page component carries its own data binding — +`PageComponentSchema.dataSource`, the spec's `ElementDataSourceSchema` — so one +page can show several objects without a page-level object context: + +```json +{ + "type": "list-view", + "dataSource": { "object": "account", "view": "hot", "limit": 10 } +} +``` + +This is metadata, **not** the data-source adapter. The two share a name and are +different things: the adapter is injected by the host (`SchemaRendererProvider`), +while `dataSource` on a schema node is JSON describing *what to query*. A +renderer therefore reads the binding off `schema.dataSource` and gets its adapter +from context — never from a prop the schema could occupy. `SchemaRenderer` strips +the binding from the props it spreads for exactly this reason. + +`@object-ui/react` exposes `useElementDataSource(schema, dataSource?)` for +renderers that need the same resolution, and `@object-ui/core` exposes the pure +parts (`isElementDataSourceConfig`, `resolveSavedView`, +`composeElementDataSource`). + +Which keys each page block honours, and how a saved `view` combines with the binding's own +`filter`, `sort` and `limit`, is page metadata, documented under +[Element data sources](/docs/ui/pages#element-data-sources). + +## Binding a page element to an API endpoint (`provider: 'api'`) + +`PageComponentSchema.dataSource` has a second shape beside the object binding: +the spec's `ElementApiDataSourceSchema`. It binds one same-origin endpoint +instead of the rows of an object. Its authoring rules are under +[Element data sources](/docs/ui/pages#element-data-sources); this section is what an SDK host +provides for it: + +```json +{ + "type": "stat", + "dataSource": { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" }, + "properties": { "label": "Members", "value": "${data.members.count}" } +} +``` + +`SchemaRenderer` honours it on every node: + +- It reads `read.url` with `GET`, through the host's authenticated fetch + (`SchemaRendererProvider`'s `apiFetch`; the console passes its signed-in + fetch), and selects `path` from the answer — dotted keys, a numeric segment + indexing a list. Omit `path` to bind the whole answer. +- The selected value is `data` in the node's `${…}` holes and in its + `visibleWhen`, and in the holes of everything the node renders. A hole that is + the whole string (`"${data.members.count}"`) evaluates to the value itself — a + number, a boolean, a list — rather than to text. +- While the read is in flight the node draws a loading line; a failed read, or + a `path` that selects nothing, draws an error panel naming why. The node is + never drawn without its data. +- The binding is judged by the spec's own schema before anything is read. An + absolute or protocol-relative `url`, a credential key, or a `method` other + than `GET` is refused with the spec's message, and no request is sent. + +Elements that bind the same URL at the same time share one request. A host can +also keep one open's answers: inside `ElementApiOpenScope` (from +`@object-ui/react`), a read that has already answered in the same open is +answered again with no request. The console mounts it and counts one open per +visit of a history entry, so a navigation entry's `visible` and a page element bound to +the same URL make one request, even when the entry's read settled first: + +- following a link, a navigation entry, or Back and Forward starts a new open, + and the page reads again, even on an entry visited before; +- a redirect that replaces the address (the app root's landing on its first + entry) stays in the open; +- a failed read is not kept, and any declared data change (`notifyDataChanged`, + which every save and action reports) drops what the open kept, so a read + after a write asks the server. + +Outside such a scope nothing is kept beyond the request in flight: a page that +mounts again reads again. + +The console's product-page blocks — `hero`, `stat`, `usage-bar`, `checklist` and +`tile-grid` — accept this binding in their arms; see the `@object-ui/app-shell` +README. diff --git a/content/docs/frontend/fields.mdx b/content/docs/frontend/fields.mdx new file mode 100644 index 00000000000..ea8d2de30ed --- /dev/null +++ b/content/docs/frontend/fields.mdx @@ -0,0 +1,116 @@ +--- +title: "Field Registry" +--- + +ObjectUI uses a **Field Registry** system to decouple the core engine from specific UI implementations of fields. This allows for rich extensibility and plugin support. + +## Concept + +The `@object-ui/fields` package serves as the "Universal Language" for rendering values. + +When a component like `` needs to render a `date` field, it doesn't import a DatePicker directly. Instead, it asks the registry: + +> *"Hey, give me the component responsible for rendering type 'date'."* + +This architecture allows you to: +1. **Override standard fields** (e.g. replace the native date picker with a fancy one). +2. **Add new field types** (e.g. add a `rating` or `signature` field). +3. **Keep bundles small** (heavy components like Code Editors are loaded only if their plugin is registered). + +## Usage + +### 1. Registering a Custom Field + +You can register a custom renderer globally, typically at your app's entry point. + +```tsx +// src/setup.tsx +import { registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; + +const MyRatingField = ({ value, onChange }: CellRendererProps) => { + return ( +
+ {[1, 2, 3, 4, 5].map(star => ( + onChange?.(star)} + style={{ color: star <= value ? 'gold' : 'grey' }} + > + ★ + + ))} +
+ ); +}; + +// Register it +registerFieldRenderer('rating', MyRatingField); +``` + +### 2. Using in Schema + +Once registered, you can simply use the new type in your JSON schema. + +```json +{ + "type": "form", + "fields": [ + { + "name": "customer_satisfaction", + "type": "rating", + "label": "Satisfaction" + } + ] +} +``` + +## Standard Fields + +ObjectUI comes with built-in support for the standard [ObjectStack Protocol](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec) types: + +| Type | Description | +|---|---| +| `text` | Single line text | +| `textarea` | Multi-line text | +| `number` | Numeric input | +| `currency` | Currency formatting | +| `percent` | Percentage values | +| `date` | Date picker | +| `datetime` | Date & Time picker | +| `boolean` | Checkbox / Switch | +| `select` | Dropdown | +| `lookup` | Reference to another object | +| `master_detail` | Parent-child relationship | +| `user` | Person picker — searches the `sys_user` object (a lookup specialized to users) | +| `owner` | Record owner — a `user` field, typically read-only and stamped with the current user | + +## How the Console draws field metadata + +What a field's metadata makes the Console draw (number inputs, polymorphic record pointers, +registry pickers such as `position-ref`, widget faces on the record heading) is documented with the +field types, under [How the Console renders a field](/docs/data-modeling/field-types#how-the-console-renders-a-field). + +## Using Renderers in Custom Components + +If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel. + +```tsx +import { getCellRenderer } from '@object-ui/fields'; + +export const KanbanCard = ({ task }: { task: { name: string; assignee: string } }) => { + // Get the standard renderer for a 'user' type field + const UserRenderer = getCellRenderer('user'); + + return ( +
+

{task.name}

+
+ +
+
+ ); +}; +``` diff --git a/content/docs/frontend/index.mdx b/content/docs/frontend/index.mdx new file mode 100644 index 00000000000..d4a1de2b44b --- /dev/null +++ b/content/docs/frontend/index.mdx @@ -0,0 +1,87 @@ +--- +title: "Custom front-end" +description: "Three ways to build your own UI on ObjectStack: a React page inside the Console, your own front-end over the Client SDK, or your own React app rendering metadata with the ObjectUI SDK" +--- + +ObjectStack gives you three ways to build UI the Console does not draw for you. Pick by where the +UI runs and who renders it. + +| You want to… | Use | Where it runs | +| --- | --- | --- | +| Add a custom page to an ObjectStack app | [React pages](/docs/ui/react-pages): the page source is metadata | Inside the Console | +| Read and write ObjectStack data from your own front-end | [Client SDK](/docs/api/client-sdk) (`@objectstack/client`) | Your app, any framework | +| Render ObjectStack views, forms and dashboards in your own React app | The ObjectUI SDK, below | Your React app | + +The first path is metadata like any other page, so it is documented with [User Interface](/docs/ui). +The rest of this section is the ObjectUI SDK. + +## The ObjectUI SDK + +The ObjectUI SDK is the set of MIT-licensed `@object-ui/*` packages that render ObjectStack +JSON schemas inside your own React app. JSON metadata goes in, and accessible, themeable +components built with Tailwind CSS, Shadcn UI and Radix primitives come out. + +```bash +npm install @object-ui/react @object-ui/components +``` + +The ObjectStack Console is built from these same packages: it reads your stack's metadata from +the server and renders it through the renderer and component registry described here. If the +Console already does what you need, you author metadata and never touch the SDK. + +## A small schema + +```json +{ + "type": "data-table", + "caption": "Users", + "className": "rounded-lg border", + "columns": [ + { + "header": "Name", + "accessorKey": "name", + "sortable": true + }, + { + "header": "Email", + "accessorKey": "email" + } + ], + "data": [ + { + "name": "Ada Lovelace", + "email": "ada@example.com" + }, + { + "name": "Grace Hopper", + "email": "grace@example.com" + } + ] +} +``` + +That schema renders through `SchemaRenderer` once `@object-ui/components` has been imported for +its registration side effects. + +## What's in this module + + + + + + + + + + + + + + + + + + + + + diff --git a/content/docs/frontend/layout.mdx b/content/docs/frontend/layout.mdx new file mode 100644 index 00000000000..2b2b8418933 --- /dev/null +++ b/content/docs/frontend/layout.mdx @@ -0,0 +1,849 @@ +--- +title: "Layout System" +description: "Understanding ObjectUI's layout components for building application shells and page structures" +--- + +ObjectUI provides a comprehensive layout system through the `@object-ui/layout` package. This guide explains how to use layout components to build professional application structures. + +## Overview + +The layout system provides: + +- **AppShell** - Full application container with a top navbar, sidebar, and content areas +- **Page** - Individual page wrapper with header and body +- **PageHeader** - Consistent page headers with title and actions +- **SidebarNav** - Navigation sidebar with menu items + +## Installation + +The layout package is included in the core ObjectUI installation: + +```bash +npm install @object-ui/react +``` + +Layout components are automatically registered when you import ObjectUI. + +## AppShell Component + +The `AppShell` provides a complete application structure with a top navbar, a sidebar, and +a main content area. + +**`AppShell` is a React component, not an authorable JSON node.** Four of its seven props +are `React.ReactNode` slots — `sidebar`, `navbar`, `children` and `rightRail` — and a JSON +document has no way to put a node into any of them. Compose the shell in React, and render +your JSON pages *inside* it. `app-shell` is not a component key either — what a +`{ "type": "app-shell" }` node does now is measured under +[There is no `app-shell` node](#there-is-no-app-shell-node) below. + +### Basic Usage + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { SchemaRenderer } from '@object-ui/react'; +import { LayoutDashboard, Settings, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { title: 'Users', href: '/users', icon: Users }, + { title: 'Settings', href: '/settings', icon: Settings }, +]; + +My Application} + sidebar={} +> + + +``` + +Top-bar content goes in `navbar`. `AppShell` renders the sticky `
` element itself +and `{navbar}` is the only thing that fills it — there is no `header` prop. Main content is +the component's `children`; there is no `body` prop either. + +### Props + +`AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these seven. The +component destructures that fixed key list with **no rest element**, so anything else you +pass is built and then dropped on the floor. + +These are React props, passed in JSX. None of them is authorable in JSON — there is no +`app-shell` node to write them on. + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `sidebar` | `React.ReactNode` | no | Left sidebar node, a flex sibling of the content. Pass `SidebarNav`, or your own node. | +| `navbar` | `React.ReactNode` | no | Top-bar content. `AppShell` supplies the sticky `
` around it, so pass only what goes inside. | +| `children` | `React.ReactNode` | yes | Main content, rendered inside the content element, within the shell's one `
` landmark. | +| `className` | `string` | no | Tailwind overrides for the content element — **not** for the outer container. | +| `defaultOpen` | `boolean` | no | Initial open state of the underlying Shadcn `SidebarProvider`. Defaults to `true`. | +| `branding` | `AppShellBranding` | no | App branding, applied by `useAppShellBranding`: `primaryColor` / `accentColor` become CSS custom properties on the document root, `favicon` sets the icon link's `href`, and `title` sets `document.title`. | +| `rightRail` | `React.ReactNode` | no | Optional right-side rail. It reflows the content beside it rather than overlaying it; absent → unchanged single-pane layout. | + +### There is no `app-shell` node + +`app-shell` is not a component key. `registerLayout()` (`packages/layout/src/index.ts`) +does not register it, and nothing else in this repo does either, so a +`{ "type": "app-shell" }` node resolves to nothing and says so. Measured on this tree: + +- `SchemaRenderer` replaces the node with its error panel — + `Unknown component type: app-shell`, error code `OBJUI-001`. +- `sdui-parser` reports it before render, as an `error`-severity diagnostic with code + `unknown-component` and the message ` is not a known component`. + +It **was** registered until objectui#4841, and the registration could never produce a +shell. Four of the seven props are `React.ReactNode` slots that a JSON document cannot +fill, so a node had exactly two outcomes: `children` was stripped by `SchemaRenderer` +before a node's keys were spread as props — `AppShell` reads its `children` prop, never +`schema.children` — so the `
` element rendered **empty with nothing logged**; and a +schema written into `sidebar` / `navbar` / `rightRail` arrived as a plain object, which +React refuses to render, replacing the node with an error box. Only `className`, +`defaultOpen` and `branding` ever survived the JSON path, i.e. the best result JSON could +reach was a shell with no navigation, no top bar and an empty content area. The key was +retired under ADR-0049 (enforce-or-remove) so that this comes out as a named refusal +rather than a page that renders nothing. + +Two doors remain, one per capability: + +- **Compose in React** — `` as shown above, with your JSON pages rendered + *inside* it through `SchemaRenderer`. +- **The whole shell from metadata** — the `app-schema-renderer` node, which renders + `AppSchemaRenderer`. It builds the branding and the sidebar navigation from the app + document it carries under `schema`, below. It draws no page content: the node's + `children` are not rendered, and `objectui validate` refuses them on this node. Page + content reaches the shell only as React `children`, when a host renders + `AppSchemaRenderer` in JSX. + +The node takes three inputs: + +- `schema`: the app document, nested, with its own `"type": "app"`. It is validated as + the app document, so the document's own refusals apply inside it: `mobileNavMode` + there is refused, because the mode belongs on the node. +- `basePath`: the prefix of every href the shell generates. +- `mobileNavMode`: `"drawer"` (the default) or `"bottom_nav"`, which adds a fixed + bottom bar. + +```json +{ + "type": "app-schema-renderer", + "basePath": "/apps/crm", + "mobileNavMode": "bottom_nav", + "schema": { + "type": "app", + "name": "crm", + "title": "CRM", + "navigation": [ + { "id": "home", "type": "page", "pageName": "home", "label": "Home" } + ] + } +} +``` + +The document has one spelling. Its keys written flat on the node (`"navigation"`, +`"title"` beside `"type": "app-schema-renderer"`) are not drawn, and the strict authoring +face (`StrictAnyComponentSchema` in `@object-ui/types/zod`) refuses them as unrecognized +keys. A node without `schema` draws the shell with no branding and no navigation. + +### Features + +- Responsive layout that adapts to mobile/tablet/desktop +- Collapsible sidebar with state management +- Sticky header +- Scroll management for content area +- Consistent spacing and structure + +## Page Component + +The `Page` component provides a consistent wrapper for individual pages with optional headers. + +### Basic Usage + +```json +{ + "type": "page", + "pageType": "app", + "title": "User Management", + "description": "Manage users and permissions", + "children": { + "type": "container", + "children": [ + { "type": "text", "content": "User list goes here" } + ] + } +} +``` + +`pageType` decides whether the page draws its own `title` and `description`. `app`, `list`, +`utility` and `home` draw both, unless a titled `page:header` block in the page takes over +the heading. A page with no `pageType` is a `record` page, which draws neither: its heading +is a `page:header` block, as [Detail Page with Actions](#detail-page-with-actions) shows. +So without `"pageType": "app"`, the page above renders only its children. +[Content Width](#content-width) covers what else each type changes. + +### With Action Buttons + +A `page` node has no action row of its own. Buttons are NODES, and they go in `children`. +This is a list of products, so the page is a `list` page, which draws its `title` itself: + +```json +{ + "type": "page", + "pageType": "list", + "title": "Products", + "children": [ + { + "type": "flex", + "properties": { + "justify": "end", + "gap": 2, + "children": [ + { + "type": "button", + "label": "Add Product", + "variant": "default", + "icon": "plus" + }, + { + "type": "button", + "label": "Export", + "variant": "outline", + "icon": "download" + } + ] + } + }, + { + "type": "object-grid", + "properties": { "objectName": "products" } + } + ] +} +``` + +`label` is the button's text key — `text` is not a `ButtonSchema` key. `objectui validate` +refuses it by name, but nothing on the render path does: `BaseSchema` is `.passthrough()`, +so the rendering face keeps the unknown key and `button.tsx`, which reads `schema.label`, +renders a button with no text. + +> **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to +> teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer` +> has never had a read point for the key, and `BaseSchema`'s `.passthrough()` kept the array +> rather than refusing it — so the author got a green validation and an empty page (before +> objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema` +> now declares the key as a refusal, so the same document fails with the remedy in the +> message instead of rendering silently short. Buttons in `children`, as above; on a record page, +> the `page:header` block's own `actions` — which are **action ids**, not nodes +> (see the [PageHeader reference](/docs/ui/pages)). + +> **⛔ `breadcrumbs` on a `page` node is refused by name** (objectui#8871). This page used +> to declare it in the Schema API block below and author it in two passages, and it drew +> **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept +> the array rather than refusing it — the same silent-accept shape as `actions`, retired +> under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put +> a `breadcrumb` node in `children`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) +> shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is a singular +> **boolean** the renderer ignores, not a list of links. + +### Schema API + +```typescript +{ + type: 'page', + + // Header + title?: string, // Page heading; not drawn on a 'record' page, whose heading is a `page:header` block + description?: string, // Line under the heading; a 'record' page does not draw it + icon?: string, // Optional icon + // NO `actions` — refused by name (objectui#7926); put the buttons in `children` + // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `children` + + // Content + children: SchemaNode, // Main page content + + // Layout + pageType?: 'record' | 'home' | 'app' | 'utility' | 'list', // default 'record'; sets the max width and who draws the heading + // NO `maxWidth` — refused by name (objectui#11318); use `pageType`, or a `container` in `children` + // NO `padding` — refused by name (objectui#11318); put a `container` with `padding` in `children` + + // Styling + className?: string, +} +``` + +### Content Width + +A `page` node has no `maxWidth` key. **`pageType`** sets how wide the page's content may +grow: `utility` is the narrowest and suits settings and forms, `record` (the default), +`list` and `app` share a wider cap, and `home` is the widest. `getPageMaxWidth` in +`packages/components/src/renderers/layout/page.tsx` holds the mapping. + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Settings", + "children": { + "type": "form", + "fields": [] + } +} +``` + +A page laid out in `regions` can drop the cap altogether: a `main` region declared +`width: 'full'` makes the page full-bleed. That is the only `width` a `main` region takes. +The template decides how wide `main` is (the space `sidebar` and `aside` leave, or one grid +cell on `dashboard`), so `'small'`, `'medium'` and `'large'` on `main` are refused at that +region's `width`, as `@objectstack/spec` refuses them. Other regions keep all four values. + +`pageType` is more than a width. A `record` page leaves its heading to a `page:header` +block, so a `title` on a page with no `pageType` draws no heading. That is one more reason +the settings page above says `utility`. + +For a column narrower than the page's cap, wrap the content in a `container` and set +**its** `maxWidth`. That key is a `container` member, and the +[Container reference](/docs/ui) lists its values: + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Profile", + "children": { + "type": "container", + "maxWidth": "2xl", + "children": { + "type": "form", + "fields": [] + } + } +} +``` + +> **⛔ `maxWidth` on a `page` node is refused by name** (objectui#11318). This page used to +> teach `"maxWidth": "lg"` beside `title`, with a list of sizes, and it changed **nothing**: +> `PageRenderer` has no read for the key and takes its width from `pageType`, while +> `BaseSchema`'s `.passthrough()` kept the value rather than refusing it. `PageNodeSchema` +> now declares the key as a refusal whose message names `pageType` and the `container`. + +## Page Header (`page:header`) + +`page:header` is a page's title bar: a title, an optional subtitle and, on a record page, the +record chip and the record's header actions. It is rendered by the record-aware +`PageHeaderRenderer` in `@object-ui/components`, and its props contract is the spec's +`ComponentPropsMap['page:header']` row. Every key, the defaults and the retired spellings are +documented with page metadata under [The page header](/docs/ui/pages#the-page-header). + +## SidebarNav Component + +The `SidebarNav` provides a collapsible navigation sidebar with menu items. + +**`SidebarNav` is a React component, and `sidebar-nav` is not a component key at all.** +`registerLayout()` (`packages/layout/src/index.ts`) registers two keys — `page:card` and +`app-schema-renderer` — and nothing in this repo registers `sidebar-nav`. What a +`{ "type": "sidebar-nav" }` node actually does is measured under +[There is no `sidebar-nav` node](#there-is-no-sidebar-nav-node) below. Compose the nav in +React, or declare it as the app's `navigation` metadata when the tree has to come from JSON. + +### Basic Usage + +`SidebarNav` renders a Shadcn `Sidebar`, so it must be inside a `SidebarProvider` — +`AppShell` supplies one. Its rows are `NavLink`s, so it also needs a router above it. + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { SchemaRenderer } from '@object-ui/react'; +import { BarChart3, LayoutDashboard, Users } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { title: 'Users', href: '/users', icon: Users, badge: '12' }, + { + title: 'Reports', + href: '/reports', + icon: BarChart3, + children: [ + { title: 'Sales', href: '/reports/sales' }, + { title: 'Analytics', href: '/reports/analytics' }, + ], + }, +]; + +}> + +; +``` + +Three things that block a copy-paste: `icon` is a **component**, not an icon name — +`SidebarNav` renders it as ``. Nested rows go in `children`; `items` is a key +on `NavGroup`, never on a `NavItem`. And `href` is **required** on every item, including +one that only expands children — it is the row's React key as well as its link target. + +### Props + +`SidebarNavProps` (`packages/layout/src/SidebarNav.tsx`) declares exactly these six. + +| Prop | Type | Required | Description | +| --- | --- | --- | --- | +| `items` | `NavItem[] \| NavGroup[]` | yes | The rows. A flat `NavItem[]`, or `NavGroup[]` for labelled sections — the array's first element decides which. | +| `title` | `string` | no | Group label shown above a flat, ungrouped `items` list. Defaults to `"Application"`. | +| `className` | `string` | no | Tailwind overrides for the `Sidebar` root. | +| `collapsible` | `"offcanvas" \| "icon" \| "none"` | no | How the sidebar collapses at or above 768px. Defaults to `"icon"`. Not a boolean. | +| `searchEnabled` | `boolean` | no | Show a search input that filters rows by title, children included. Defaults to `false`. | +| `searchPlaceholder` | `string` | no | Placeholder for that input. Defaults to `"Search…"`. | + +#### `NavItem` + +| Key | Type | Required | Description | +| --- | --- | --- | --- | +| `title` | `string` | yes | The row's label. | +| `href` | `string` | yes | Link target, and the row's React key. | +| `icon` | `React.ComponentType<{ className?: string }>` | no | Rendered as `` — pass the Lucide component, not its name. | +| `badge` | `string \| number` | no | Trailing badge content. Rendered whenever it is not `null`/`undefined`, so `0` shows. | +| `badgeVariant` | `'default' \| 'destructive' \| 'outline'` | no | Badge styling. Defaults to `'default'`. | +| `children` | `NavItem[]` | no | One level of nested rows; the parent becomes a collapsible trigger. | + +#### `NavGroup` + +| Key | Type | Required | Description | +| --- | --- | --- | --- | +| `label` | `string` | yes | Section heading, shown in place of `title`. | +| `items` | `NavItem[]` | yes | The section's rows. | + +There is no `active` key — the active row is derived from the router +(`pathname === item.href`), never declared. There is no `disabled` key either, and no +`defaultOpen`: that one is `AppShell`'s prop, not this component's. + +### There is no `sidebar-nav` node + +`sidebar-nav` was never registered, so the node does not resolve to anything. Rendering +`{ "type": "sidebar-nav", "items": [...] }` produces the renderer's red error box instead +of a sidebar: + +``` +Unknown component type: sidebar-nav +💡 Ensure the component is registered via registry.register() before rendering. + Check for typos in the component type name. (OBJUI-001) +``` + +This is louder than the `app-shell` case above — nothing is silently dropped, because +nothing is parsed as props at all. The whole sidebar is replaced by the error panel. + +When the navigation tree genuinely has to come from JSON, it is application metadata: +the app document's `navigation` items (`{ "type": "app", "navigation": [...] }`, which +`objectui validate` accepts), drawn by the shell — the console's sidebar, or +`AppSchemaRenderer` (the whole shell from metadata, above). Both hand the items to the +`NavigationRenderer` component, and its items are JSON-shaped: `icon` really is a string +name there, resolved by `resolveIcon`. There is no page node for navigation: +`navigation-renderer` was retired (objectui#11441), so a node written with that type +renders the same "Unknown component type" panel. + +### Features + +- Nested menu items (2 levels) with collapsible expand/collapse +- Active state highlighting via React Router +- Icon support (Lucide icons) +- Badge/counter support with variant styling (`default`, `destructive`, `outline`) +- NavGroup support for grouped navigation sections +- Built-in search filtering (`searchEnabled`) across all items and children +- Collapse/expand animation + +## Common Layout Patterns + +### Full Application Layout + +The shell is React; the page inside it is your JSON. + +```tsx +import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; +import { SchemaRenderer } from '@object-ui/react'; +import { SidebarTrigger } from '@object-ui/components'; +import { Home, Package, ShoppingCart } from 'lucide-react'; + +const navItems: NavItem[] = [ + { title: 'Home', href: '/', icon: Home }, + { title: 'Products', href: '/products', icon: Package }, + { title: 'Orders', href: '/orders', icon: ShoppingCart }, +]; + + + + My App + + } + sidebar={} + defaultOpen +> + + +``` + +`AppShell` adds no controls of its own to the top bar — the sidebar toggle above is one +you render yourself, in `navbar`. There is no `sidebarCollapsible` prop: the sidebar's +collapse behaviour belongs to the sidebar node you pass (`SidebarNav`'s `collapsible`), +and `defaultOpen` is the shell's own initial-state prop. + +### Landing Page (No Sidebar) + +Omit `sidebar` and the content fills the width under the top bar. + +```tsx +Welcome}> + + +``` + +### Settings Page with Tabs + +A settings page is a `utility` page, which gives it the narrowest content width (see +[Content Width](#content-width)): + +```json +{ + "type": "page", + "pageType": "utility", + "title": "Settings", + "children": { + "type": "tabs", + "items": [ + { + "label": "General", + "value": "general", + "content": { "type": "form", "fields": [] } + }, + { + "label": "Security", + "value": "security", + "content": { "type": "form", "fields": [] } + }, + { + "label": "Notifications", + "value": "notifications", + "content": { "type": "form", "fields": [] } + } + ] + } +} +``` + +### Detail Page with Actions + +Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both +**nodes in `children`** — never a `breadcrumbs` or an `actions` key on the page. + +A detail page is a `record` page. That is the default, and it is named below so the snippet +says so. A record page draws no `title` and no `description` of its own: its heading is +the [`page:header`](#page-header-pageheader) block, so the record's name goes in that +block's `properties.title`, not on the page. + +```json +{ + "type": "page", + "pageType": "record", + "children": [ + { + "type": "breadcrumb", + "items": [ + { "label": "Home", "href": "/" }, + { "label": "Customers", "href": "/customers" }, + { "label": "Acme Corporation" } + ] + }, + { + "type": "page:header", + "properties": { "title": "Acme Corporation" } + }, + { + "type": "flex", + "properties": { + "justify": "end", + "gap": 2, + "children": [ + { + "type": "action:button", + "properties": { + "name": "edit_record", + "label": "Edit", + "variant": "default", + "icon": "pencil", + "actionType": "editRecord" + } + }, + { + "type": "action:button", + "properties": { + "name": "delete_record", + "label": "Delete", + "variant": "destructive", + "icon": "trash", + "actionType": "deleteRecord" + } + } + ] + } + }, + { + "type": "card", + "children": [ + { "type": "text", "content": "Record details..." } + ] + } + ] +} +``` + +A button that RUNS something is an `action:button` node, not a `button` carrying an +`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's +`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component +refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node +before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied +function and the zod mirror refuses it BY NAME — JSON has no function value, and no +handler key consumes a declarative action object. The refusal is not the whole cost: +`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is +forwarded to the real DOM listener slot, and React throws the moment anyone clicks. +Measured, React's own error: "Expected `onClick` listener to be a function, instead got +a value of `object` type." + +The handler name goes in `actionType`, which `action:button` forwards to the action +runner as the action's type; the runner dispatches to the handler registered under it. +Same spelling as [Record Edit Modes](/docs/ui). + +The rest of the action is authored on the same node. `action:button` and `action:icon` +evaluate `visible` and `disabled` themselves (a boolean, a CEL string, or a +`{ dialect, source }` envelope), and forward the action-definition keys to the runner: +`params`, `confirmText`, `successMessage`, `errorMessage`, `toast`, `bodyExtra`, +`bodyShape`, `operation`, `onSuccess`, `objectName` and the others each +registration's `inputs` lists, with a `description` per key. For an `api` action, write +the request URL in `target`: `endpoint` is not published, because the console's `api` +handler never reads it. + +An `operation: update` node runs the declared action its `name` names: the platform +action route writes that declaration's `patch` under the collected params. A `patch` +written on the node itself is not forwarded and writes nothing. + +`undoable: true` on an `operation: update` makes the success toast offer Undo, which +writes back the prior values of the fields the update wrote: the named declaration's +`patch` fields and the collected params. `action:button` reads those values off the +record in scope: the record page's record, or the row the host binds through `data`. A +button with no record in scope offers no Undo, because there is no row to restore; +neither does one whose `recordId` names a record other than the one in scope. + +## Responsive Behavior + +The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and +`MOBILE_BREAKPOINT` in `packages/components/src/hooks/use-mobile.tsx`. There is no +separate tablet tier: nothing in `AppShell`, `SidebarNav` or the Shadcn sidebar underneath +them reads `lg` (1024px), so 800px and 1400px get the same layout. + +### Sidebar, at or above 768px + +- Rendered inline, as a flex sibling of the content — not an overlay. +- How it collapses is the sidebar node's own `collapsible` prop: `"icon"` (the `SidebarNav` + default) leaves an icon rail, `"offcanvas"` slides it fully out, `"none"` pins it open. +- `AppShell`'s `defaultOpen` picks the initial state, and defaults to `true`. + +### Sidebar, below 768px + +- It leaves the layout entirely and becomes a `Sheet` overlay (18rem) above the content, + which is why the content is full-width there. +- **Nothing opens it for you.** `AppShell`'s header renders `{navbar}` and nothing else — + it adds no controls of its own, in particular **no sidebar toggle**. Render a + `SidebarTrigger` inside `navbar` yourself; otherwise the only way in is the + `SidebarProvider` keyboard shortcut, `Cmd/Ctrl + B`, which no touch device has. + +### Header and content + +- The header is `h-12` (3rem / 48px) at **every** breakpoint — there is no compact + variant — and spans the full viewport width at every size. The only thing about it that + responds is horizontal padding, and it turns at `sm` (640px), not 768: `px-2 sm:px-4`. +- Content padding steps three ways — `p-3`, `sm:p-4` (640px), `md:p-6` (768px) — with a + taller `pb-20` below `sm` only. + +## Styling and Customization + +### Custom Classes + +Add Tailwind classes to layout components: + +```tsx + + My App + + } + sidebar={} +> + + +``` + +`className` is the only class hook `AppShell` itself takes, and it lands on the content +element (a `
` inside the shell's one `
` landmark) — not on the outer container. There is **no** per-slot className: +`headerClassName`, `sidebarClassName` and `contentClassName` do not exist. The top bar and +the sidebar are nodes you build, so style them where you build them, as above. + +### Page Padding + +A `page` node has no padding switch. Its wrapper always insets the content: `p-3`, then +`md:p-4`, then `lg:p-6`. The padding you control is a `container`'s. Its `padding` is a +step on the container's spacing scale: one of 0 to 8, 10, 12 or 16, and `0` means none. +Any other number is refused: + +```json +{ + "type": "page", + "children": { + "type": "container", + "maxWidth": false, + "padding": 8, + "children": [] + } +} +``` + +`"maxWidth": false` keeps the container as wide as the page. Leave it out and the container +also caps its own width at its default, `xl`. The +[Container reference](/docs/ui) lists both scales. + +> **⛔ `padding` on a `page` node is refused by name** (objectui#11318). This page used to +> teach `"padding": false` here, and it removed **nothing**: the page wrapper kept its inset, +> because `PageRenderer` has no read for the key and `BaseSchema`'s `.passthrough()` kept the +> value rather than refusing it. The refusal's message names the `container` and its `padding`. + +## Best Practices + +### 1. Consistent Structure + +Compose the shell once and let the page JSON change per route: + +```tsx +// One shell for the whole app; `pageSchema` is whatever the route resolves to. +}> + + +``` + +### 2. Breadcrumbs for Deep Navigation + +Add a breadcrumb trail to help users navigate. It is a **node in `children`**, not a key on the +page — `breadcrumb`, singular, is the registered renderer: + +```json +{ + "type": "breadcrumb", + "items": [ + { "label": "Home", "href": "/" }, + { "label": "Products", "href": "/products" }, + { "label": "Electronics", "href": "/products/electronics" }, + { "label": "Laptops" } + ], + "separator": "/", + "maxItems": 3 +} +``` + +`separator` defaults to `/`, and `maxItems` collapses the middle of a long trail behind an +ellipsis while keeping the first crumb and the current page. See the +[Breadcrumb reference](/docs/frontend/renderers) for the per-key face. + +⛔ Not `"breadcrumbs"` on the `page` node — that key has no reader and is refused by name +(objectui#8871), the same way `actions` is. ⛔ Nor the `page:header` block's `breadcrumb`, +a **boolean** the renderer ignores rather than a list of links. + +### 3. Action Buttons at the Top of the Body + +Place primary actions in the first `children` node, so they sit above the content. A list +of orders is a `list` page, which draws its `title` as the heading above them: + +```json +{ + "type": "page", + "pageType": "list", + "title": "Orders", + "children": [ + { + "type": "flex", + "properties": { + "justify": "end", + "gap": 2, + "children": [ + { "type": "button", "label": "New Order", "variant": "default" } + ] + } + } + ] +} +``` + +⛔ Not `"actions"` on the `page` node — that key has no reader and is refused by name +(objectui#7926). A record page has a second door: the `page:header` block, whose `actions` +are **action ids** resolved from the object's own actions metadata, not nodes +(see the [PageHeader reference](/docs/ui/pages)). + +### 4. Constrained Width for Forms + +Give forms and reading content a narrow column: a `container` with its own `maxWidth`, +inside the page. ⛔ Not `maxWidth` on the `page` node, which is refused by name +(objectui#11318); the page's own cap comes from `pageType` (see [Content Width](#content-width)). + +```json +{ + "type": "page", + "pageType": "utility", + "children": { + "type": "container", + "maxWidth": "2xl", + "children": { + "type": "form", + "fields": [] + } + } +} +``` + +### 5. Sidebar Organization + +Group related items with `NavGroup`. Pass groups instead of a flat list and `SidebarNav` +labels each section and draws the separator between them itself — there is no `divider` +item, and a row is either a link or a group, never both: + +```tsx +import { SidebarNav, type NavGroup } from '@object-ui/layout'; +import { DollarSign, Home, Settings } from 'lucide-react'; + +const navGroups: NavGroup[] = [ + { + label: 'Overview', + items: [{ title: 'Dashboard', href: '/', icon: Home }], + }, + { + label: 'Sales', + items: [ + { + title: 'Sales', + href: '/sales', + icon: DollarSign, + children: [ + { title: 'Orders', href: '/orders' }, + { title: 'Invoices', href: '/invoices' }, + ], + }, + ], + }, + { + label: 'System', + items: [{ title: 'Settings', href: '/settings', icon: Settings }], + }, +]; + +; +``` + +## Related Documentation + +- [Components Overview](/docs/frontend/renderers) - All available components +- [Schema Rendering](/docs/frontend/schema-rendering) - How schemas work +- [Architecture Overview](/docs/frontend/architecture-overview) - System architecture diff --git a/content/docs/frontend/meta.json b/content/docs/frontend/meta.json new file mode 100644 index 00000000000..2340e372d8c --- /dev/null +++ b/content/docs/frontend/meta.json @@ -0,0 +1,29 @@ +{ + "title": "Custom Front-end", + "pages": [ + "index", + "---ObjectUI SDK---", + "quick-start", + "building-crud-app", + "architecture-overview", + "---Rendering---", + "schema-rendering", + "component-registry", + "renderers", + "fields", + "data-source", + "---Layout & theming---", + "layout", + "app-shell", + "sidebar-nav", + "theming", + "---Plugins---", + "plugins", + "plugin-development", + "---Tools---", + "cli", + "create-plugin", + "data-objectstack", + "troubleshooting" + ] +} diff --git a/content/docs/frontend/plugin-development.mdx b/content/docs/frontend/plugin-development.mdx new file mode 100644 index 00000000000..b22dc09faac --- /dev/null +++ b/content/docs/frontend/plugin-development.mdx @@ -0,0 +1,555 @@ +--- +title: "Custom Plugin Development" +--- + +This page builds a **front-end** plugin for the ObjectUI SDK. To extend the ObjectStack server +with a runtime plugin, see [Plugin development](/docs/plugins/development). + +This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading. + +## What Is an ObjectUI Plugin? + +A plugin is a self-contained package that registers one or more components into the [Component Registry](/docs/frontend/component-registry). When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically. + +Plugins differ from regular components in two ways: + +- **Lazy-loaded** — heavy dependencies are code-split and fetched on demand. +- **Self-registering** — importing the package is enough; no manual wiring required. + +Official plugins (`@object-ui/plugin-grid`, `@object-ui/plugin-kanban`, `@object-ui/plugin-charts`, etc.) all follow this pattern, and your custom plugins should too. + +## Plugin Anatomy + +Every plugin has three key parts: + +``` +packages/plugin-board/ +├── src/ +│ ├── index.tsx # Entry point: lazy wrapper + ComponentRegistry.register() +│ ├── BoardImpl.tsx # Heavy implementation (imported lazily) +│ ├── BoardImpl.test.tsx # Tests +│ └── types.ts # TypeScript interfaces & schema types +├── package.json +├── vite.config.ts +├── tsconfig.json +└── README.md +``` + +| File | Purpose | +|------|------| +| `index.tsx` | Lightweight entry — sets up `React.lazy()`, `Suspense` fallback, and calls `ComponentRegistry.register()`. | +| `BoardImpl.tsx` | The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle. | +| `types.ts` | Schema interfaces extending `BaseSchema` from `@object-ui/types`. | + +## Scaffolding With the CLI + +The fastest way to start is the `create-plugin` generator: + +```bash +npx @object-ui/create-plugin board --description "Kanban-style board view" + +# Or with pnpm / npm create aliases: +pnpm create @object-ui/plugin board +npm create @object-ui/plugin board +``` + +This produces a ready-to-build plugin under `packages/plugin-board/` with the correct `package.json`, Vite config, test file, and registry call already in place. That directory — and the anatomy shown above — is what the generator writes into **your** workspace (`/packages/plugin-`, see `packages/create-plugin/src/index.ts`); it is not a package that ships in this repository, so do not expect to find it in a fresh ObjectUI checkout. + +After scaffolding, install dependencies: + +```bash +pnpm install +``` + +## Implementing a Custom View Plugin + +Let's build a **board** view plugin that renders items in columns (similar to a Kanban but simplified). + +### 1. Define the Schema Types + +```typescript +// src/types.ts +import type { BaseSchema } from '@object-ui/types'; + +export interface BoardColumn { + id: string; + title: string; +} + +export interface BoardItem { + id: string; + columnId: string; + title: string; + description?: string; +} + +export interface BoardSchema extends BaseSchema { + type: 'board'; + columns: BoardColumn[]; + items: BoardItem[]; + onItemMove?: (itemId: string, toColumnId: string) => void; +} + +export interface BoardProps { + schema: BoardSchema; + className?: string; +} +``` + +### 2. Build the Implementation + +```tsx +// src/BoardImpl.tsx +import React from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; +import { cn } from '@object-ui/components'; +import type { BoardProps } from './types'; + +export default function BoardImpl({ schema, className }: BoardProps) { + const { columns, items } = schema; + + return ( +
+ {columns.map((col) => ( +
+

{col.title}

+ {items + .filter((item) => item.columnId === col.id) + .map((item) => ( + + + {item.title} + + {item.description && ( + + {item.description} + + )} + + ))} +
+ ))} +
+ ); +} +``` + +### 3. Create the Entry Point + +```tsx +// src/index.tsx +import React, { Suspense } from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { Skeleton } from '@object-ui/components'; + +const LazyBoard = React.lazy(() => import('./BoardImpl')); + +export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ + schema, + ...props +}) => ( + }> + + +); + +// Auto-register on import +ComponentRegistry.register('board', BoardRenderer, { + namespace: 'plugin-board', + label: 'Board View', + category: 'plugin', + inputs: [ + { name: 'columns', type: 'array', required: true }, + { name: 'items', type: 'array', required: true }, + ], + defaultProps: { + columns: [ + { id: 'todo', title: 'To Do' }, + { id: 'done', title: 'Done' }, + ], + items: [], + }, +}); + +export { default as BoardImpl } from './BoardImpl'; +export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types'; +``` + +Now any schema with `"type": "board"` will resolve to your component. + +## What Reaches the DOM + +`SchemaRenderer` hands a registered component far more than the keys it +declares: the node itself as `schema`, every other key the author wrote on the +node, the contents of its `props` bag, the runtime props it injects (`bind`, +`events`, the camelCase `ariaLabel` the author wrote, the data-source adapter) +and whatever its own host passed. React writes unknown lowercase props straight +onto an element and stringifies objects, so a component that ends in a bare +`{...props}` spread onto an element puts all of it on the page — +`schema="[object Object]"`, `datasource="[object Object]"`, a `name` on a `div`, +and every authored key the widget never declared. Nothing warns: React stays +silent for lowercase names. + +**The contract for a registered widget's host element** (objectui#4425, +phase-2 ruling, comment 5270759246): it receives only what `toDomProps` from `@object-ui/core` +passes — the SDUI contract's declared DOM keys (`id`, `className`, `role`, +`tabIndex` and the rest of `SDUI_DOM_PASS_THROUGH_KEYS`) and the open `aria-*` +and `data-*` families. Everything else the renderer hands a widget is either +**consumed** — read by name and turned into behaviour — or dropped. It is a +whitelist rather than a list of keys to strip, because the set of keys an author +may write is unbounded and a strip list can only name the ones that exist today. + +```tsx +import type { HTMLAttributes, ReactNode } from 'react'; +import { toDomProps, type SduiDomPassThroughKey } from '@object-ui/core'; + +// The DOM pass-through this widget declares is exactly what the whitelist +// forwards — named from the whitelist's own key type, so the two cannot drift. +interface BoardFrameProps extends Pick, SduiDomPassThroughKey> { + children?: ReactNode; +} + +export function BoardFrame({ className, children, ...props }: BoardFrameProps) { + return ( + // Spread FIRST, so the attributes the widget computes stay authoritative. +
+ {children} +
+ ); +} +``` + +A widget that really means to forward something beyond that set **declares** +it on its props type and forwards it **by name** — the way `style` reaches the +layout containers. Never reopen the spread to get it. A component that is both +registered and exported for direct React use may keep a wider declared +`HTMLAttributes` pass-through on its direct door, as `@object-ui/plugin-dashboard`'s +metric cards do; what the renderer hands it still goes through the whitelist. + +Field widgets follow the same rule with their own declared set: `@object-ui/fields` +exports a `toDomProps` that adds the form-control keys `name` and `disabled` +(see [Field Widget Props](/docs/data-modeling/field-types)). + +**The gate.** `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` +renders every registered widget of the packages it covers through the real +`SchemaRenderer`, with planted canary keys from every family above, and fails +on any attribute HTML does not define. A known leak is a ledger row in that +file naming the issue that owns its fix, and the ledger is a two-way ratchet: a +new leak fails the gate, and so does a fixed one until its row is deleted. + +## Implementing a Custom Field Widget + +Field widgets follow the `FieldWidgetComponentProps` interface from `@object-ui/fields`. + +```typescript +// FieldWidgetComponentProps shape (from packages/fields/src/widgets/types.ts) +import type { FieldMetadata } from '@object-ui/types'; + +type FieldWidgetComponentProps = { + value: T; + onChange: (val: T) => void; + field: FieldMetadata; + readonly?: boolean; + disabled?: boolean; + className?: string; + error?: string; +}; +``` + +The validation slot is named `error`, matching `FieldWidgetPropsSchema` in +`@objectstack/spec/ui` — the published contract a widget is written against. +The form renderer supplies it from the active validation message. + +### `field` is the only metadata carrier (v17, breaking) + +Before v17 a widget could receive its metadata under **either** `field` or +`schema`, depending on which host rendered it, so widgets written in that era +resolve their config as `field || schema`. `schema` has been removed from +`FieldWidgetComponentProps` in v17: **read `props.field`, full stop.** Reading +`props.schema` now yields `undefined`. + +`schema` itself is not going anywhere — it is the universal SDUI node +`SchemaRenderer` hands to *every* registered component (`element:*`, `page:*`, +grids, reports). That is precisely why a **field widget** needs an adapter when +it is rendered from a schema node instead of from a form. Wrap it once, at +registration, and the widget only ever implements one contract: + +```tsx +import { ComponentRegistry } from '@object-ui/core'; +import { withFieldCarrier } from '@object-ui/fields'; + +ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), { + namespace: 'field', +}); +``` + +`withFieldCarrier` forwards the node by reference (nothing is copied or +dropped) and consumes `schema` so it never reaches the DOM. Every built-in +field widget is registered through it. + +### Who renders what + +The widget and the form renderer split validation display, and the split is +not optional: + +| Concern | Owner | +|---|---| +| `aria-invalid` on the input | **the widget** — only it renders the input element | +| the required marker (`*`) | **the form renderer** (``) | +| the message TEXT | **the form renderer** (``) | + +So consume `error` as a **boolean signal** — `aria-invalid={!!error}` — and do +not render the message yourself. The form already prints it below the control; +a widget that prints it too shows the user the same sentence twice. For the +same reason `required` is not in the props: the marker has one author. + +### Example: Color Picker Field + +```tsx +// src/ColorPickerField.tsx +import React from 'react'; +import { Input } from '@object-ui/components'; +import type { FieldWidgetComponentProps } from '@object-ui/fields'; + +export function ColorPickerField({ + value, + onChange, + field, + readonly, + disabled, + error, +}: FieldWidgetComponentProps) { + if (readonly) { + return ( +
+ + {value || '—'} +
+ ); + } + + return ( +
+ onChange(e.target.value)} + disabled={disabled} + className="h-8 w-8 cursor-pointer rounded border-0 p-0" + /> + onChange(e.target.value)} + placeholder={field?.placeholder || '#000000'} + disabled={disabled} + className="font-mono text-sm" + // The whole job of `error` here: tell assistive tech the field failed. + // The message text is rendered by the form, not by this widget. + aria-invalid={!!error} + /> +
+ ); +} +``` + +Register it as a field widget: + +```tsx +// src/index.tsx +import { ComponentRegistry } from '@object-ui/core'; +import { withFieldCarrier } from '@object-ui/fields'; +import { ColorPickerField } from './ColorPickerField'; + +ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), { + namespace: 'plugin-board', + label: 'Color Picker', + category: 'field', + inputs: [ + { name: 'value', type: 'string', label: 'Value' }, + { name: 'placeholder', type: 'string', label: 'Placeholder' }, + ], +}); + +export { ColorPickerField }; +``` + +## Using the ComponentRegistry + +### Namespaced Registration + +Namespaces prevent type collisions between plugins: + +```tsx +import { ComponentRegistry } from '@object-ui/core'; + +// Register with a namespace — accessible as 'plugin-board:board' AND 'board' +ComponentRegistry.register('board', BoardRenderer, { + namespace: 'plugin-board', +}); + +// Explicit lookup by namespace +ComponentRegistry.get('board', 'plugin-board'); + +// Fallback lookup (works when the type is unambiguous) +ComponentRegistry.get('board'); +``` + +Use `skipFallback: true` in the metadata if you do **not** want the component to be available without a namespace prefix. + +### Querying Registered Components + +```tsx +import { ComponentRegistry } from '@object-ui/core'; + +ComponentRegistry.has('board'); // boolean +ComponentRegistry.getAllTypes(); // string[] +ComponentRegistry.getNamespaceComponents('plugin-board'); // RegistryComponentConfig[] +``` + +## Plugin Configuration & Schema Types + +Define your schema interface in `types.ts` and extend `BaseSchema`: + +```typescript +import type { BaseSchema } from '@object-ui/types'; + +export interface BoardSchema extends BaseSchema { + type: 'board'; + columns: BoardColumn[]; + items: BoardItem[]; +} +``` + +Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them). At the component level the manifest carries one marker of its own: `tier: 'html'` on the intrinsic HTML elements a `kind:'html'` page may author (`HTML_TIER_INTRINSICS` in `@object-ui/core`). An entry without it is a curated block; a reader that means the curated vocabulary filters on it, a reader that whitelists tags takes every key. + +`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all: + +```tsx +ComponentRegistry.register('board', BoardRenderer, { + inputs: [ + { name: 'columns', type: 'array', of: 'object', required: true }, + { name: 'items', type: 'array', of: 'object', required: true }, + { + name: 'layout', + type: 'enum', + enum: ['horizontal', 'vertical'], + description: 'Defaults to "horizontal" — the renderer\'s own fallback', + }, + ], +}); +``` + +## Testing Plugins + +ObjectUI uses **Vitest + React Testing Library**. Place tests next to the implementation. + +```tsx +// src/BoardImpl.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +// `toBeInTheDocument` is a jest-dom matcher, not a Vitest one — without this +// import the assertions below do not type-check and do not run. +import '@testing-library/jest-dom'; +import BoardImpl from './BoardImpl'; + +const schema = { + type: 'board' as const, + columns: [ + { id: 'todo', title: 'To Do' }, + { id: 'done', title: 'Done' }, + ], + items: [ + { id: '1', columnId: 'todo', title: 'Write tests' }, + { id: '2', columnId: 'done', title: 'Ship plugin' }, + ], +}; + +describe('BoardImpl', () => { + it('renders all columns', () => { + render(); + expect(screen.getByText('To Do')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + + it('renders items in correct columns', () => { + render(); + expect(screen.getByText('Write tests')).toBeInTheDocument(); + expect(screen.getByText('Ship plugin')).toBeInTheDocument(); + }); + + it('handles empty items gracefully', () => { + render(); + expect(screen.getByText('To Do')).toBeInTheDocument(); + }); +}); +``` + +Run tests: + +```bash +pnpm vitest run packages/plugin-board +``` + +## Publishing Guidelines + +### Package Checklist + +Before publishing, verify: + +- [ ] `package.json` has correct `name`, `version`, `exports`, and `peerDependencies`. +- [ ] `react` and `react-dom` are **peer** dependencies, not direct dependencies. +- [ ] `@object-ui/core` and `@object-ui/components` are in `devDependencies` (or `peerDependencies`). +- [ ] `vite.config.ts` marks React and ObjectUI packages as **external**. +- [ ] Types are exported via `"types"` field in `package.json`. +- [ ] All tests pass (`pnpm vitest run`). +- [ ] The entry point is lightweight — heavy code lives in `*Impl.tsx` files. + +### Build & Verify + +```bash +pnpm build --filter @object-ui/plugin-board +ls -lh packages/plugin-board/dist/ +``` + +The entry chunk should be under 1 KB; the lazy chunk carries the bulk. + +### Publish + +```bash +cd packages/plugin-board +npm publish --access public +``` + +### Consumers Install & Use + +```bash +pnpm add @object-ui/plugin-board +``` + +```tsx +// app/main.tsx — import once, auto-registers +import '@object-ui/plugin-board'; +``` + +```json +{ + "type": "board", + "columns": [ + { "id": "todo", "title": "To Do" }, + { "id": "done", "title": "Done" } + ], + "items": [ + { "id": "1", "columnId": "todo", "title": "Write docs" } + ] +} +``` + +## Related Documentation + +- [Component Registry](/docs/frontend/component-registry) — registry internals and advanced usage +- [Plugins Overview](/docs/frontend/plugins) — official plugin catalog +- [Schema Rendering](/docs/frontend/schema-rendering) — how schemas become UI +- [Fields Guide](/docs/frontend/fields) — built-in field widgets and `FieldWidgetComponentProps` diff --git a/content/docs/frontend/plugins/index.mdx b/content/docs/frontend/plugins/index.mdx new file mode 100644 index 00000000000..47daa51ba00 --- /dev/null +++ b/content/docs/frontend/plugins/index.mdx @@ -0,0 +1,564 @@ +--- +title: "UI plugins" +description: "Lazy-loaded @object-ui/plugin-* packages that add views, charts, editors and chat to the ObjectUI SDK" +--- + +These are **front-end** plugins: npm packages that register extra renderers in the ObjectUI SDK. +They are not the server-side runtime plugins that extend the ObjectStack kernel, which are documented +under [Plugins & Packages](/docs/plugins). + +ObjectUI supports a powerful plugin system that allows you to extend the framework with additional components. Plugins are separate packages that load on-demand, keeping your main application bundle small while providing rich functionality. + +## Overview + +Plugins are lazy-loaded component packages that: + +- **Auto-register** components when imported +- **Lazy-load** heavy dependencies on-demand +- **Keep bundles small** - only load when needed +- **Are type-safe** with full TypeScript support +- **Follow best practices** with built-in loading states + +## Official Plugins + +ObjectUI provides 14+ official plugins for common use cases: + +### Data Visualization & Dashboards + +#### [@object-ui/plugin-charts](/docs/frontend/plugins/plugin-charts) + +Data visualization components powered by Recharts. + +- Bar, line, area, and pie charts +- Responsive design +- Customizable colors +- Lazy-loaded (~80 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-charts) + +--- + +#### [@object-ui/plugin-dashboard](/docs/frontend/plugins/plugin-dashboard) + +Dashboard layouts with metric cards and widgets. + +- Dashboard grid layouts +- Metric/KPI cards with trends +- Widget system +- Lazy-loaded (~22 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-dashboard) + +--- + +#### [@object-ui/plugin-timeline](/docs/frontend/plugins/plugin-timeline) + +Timeline component with multiple layout variants. + +- Vertical, horizontal layouts +- Customizable markers +- Date formatting +- Lazy-loaded (~20 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-timeline) + +--- + +#### [@object-ui/plugin-gantt](/docs/frontend/plugins/plugin-gantt) + +Gantt chart for project visualization. + +- Task dependencies +- Progress tracking +- ObjectQL integration +- Lazy-loaded (~40 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-gantt) + +--- + +#### [@object-ui/plugin-calendar](/docs/frontend/plugins/plugin-calendar) + +Calendar visualization for events. + +- Month/week/day views +- Event management +- ObjectQL integration +- Lazy-loaded (~25 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-calendar) + +--- + +#### [@object-ui/plugin-map](/docs/frontend/plugins/plugin-map) + +Map visualization with markers. + +- Interactive maps +- Location markers +- ObjectQL integration +- Lazy-loaded (~60 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-map) + +--- + +### Data Management + +#### [@object-ui/plugin-grid](/docs/frontend/plugins/plugin-grid) + +Advanced data grid with sorting, filtering, and pagination. + +- Column sorting and filtering +- Pagination controls +- Row selection +- Lazy-loaded (~45 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-grid) + +--- + +#### [@object-ui/plugin-form](/docs/frontend/plugins/plugin-form) + +Advanced form builder with validation. + +- Multi-step forms +- Field validation +- Custom field types +- Lazy-loaded (~28 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-form) + +--- + +#### [@object-ui/plugin-view](/docs/frontend/plugins/plugin-view) + +ObjectQL-integrated views for automatic CRUD. + +- Auto-generated forms and grids +- CRUD operations +- Field mapping +- Lazy-loaded (~35 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-view) + +--- + +#### [@object-ui/plugin-list](/docs/frontend/plugins/plugin-list) + +One list-view component that switches between the grid, kanban, calendar and other view plugins. + +[Read full documentation →](/docs/frontend/plugins/plugin-list) + +--- + +#### [@object-ui/plugin-detail](/docs/frontend/plugins/plugin-detail) + +Record detail surfaces: the activity feed, comments and history. + +[Read full documentation →](/docs/frontend/plugins/plugin-detail) + +--- + +### Content & Editing + +#### [@object-ui/plugin-editor](/docs/frontend/plugins/plugin-editor) + +Code editor component powered by Monaco Editor. + +- Syntax highlighting for 100+ languages +- IntelliSense and code completion +- Multiple themes +- Lazy-loaded (~120 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-editor) + +--- + +#### [@object-ui/plugin-markdown](/docs/frontend/plugins/plugin-markdown) + +Markdown renderer with GitHub Flavored Markdown support. + +- GitHub Flavored Markdown +- XSS protection +- Code syntax highlighting +- Lazy-loaded (~30 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-markdown) + +--- + +#### [@object-ui/plugin-chatbot](/docs/frontend/plugins/plugin-chatbot) + +Chat interface component. + +- Message history +- User and assistant messages +- Timestamps and avatars +- Responsive floating panel for console assistants +- Inline responding, stop, and retry states +- Lazy-loaded (~35 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-chatbot) + +--- + +### Workflows & Tasks + +#### [@object-ui/plugin-kanban](/docs/frontend/plugins/plugin-kanban) + +Kanban board component with drag-and-drop powered by @dnd-kit. + +- Drag and drop cards between columns +- Column limits (WIP limits) +- Card badges for status/priority +- Lazy-loaded (~100 KB) + +[Read full documentation →](/docs/frontend/plugins/plugin-kanban) + +--- + +## How Plugins Work + +### Stylesheets + +A plugin's JavaScript is only half of what it renders with. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` publish a `style.css` of their own, and an app that installs one must import it after the base sheets: + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Each plugin sheet is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it carries only what the plugin adds. That includes the themed utilities (`bg-muted/10`, `bg-card/60`, `ring-primary/40`) which **no consumer-side configuration can produce** — the `@theme` block declaring their tokens lives in package source that is not published, so scanning `node_modules` cannot reach it ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). Skip the import and the view renders unstyled. + +Add a line only for the plugins you install. The other `@object-ui/plugin-*` packages do not publish a stylesheet yet; the build step above is the pattern each of them will adopt when it needs one. + +### Lazy Loading Architecture + +Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: + +```tsx +// The plugin structure +import React, { Suspense } from 'react' +import { Skeleton } from '@object-ui/components' + +// Lazy load the heavy implementation +const LazyEditor = React.lazy(() => import('./MonacoImpl')) + +export const CodeEditorRenderer = (props) => ( + }> + + +) +``` + +**Benefits:** +- **Smaller initial bundle**: Main app loads faster +- **Progressive loading**: Components load when needed +- **Better UX**: Loading skeletons while chunks download +- **Automatic code splitting**: Vite handles chunking + +### Bundle Impact + +| Plugin | Initial Load | Lazy Load | Description | +|--------|-------------|-----------|-------------| +| plugin-editor | ~0.2 KB | ~120 KB | Monaco editor | +| plugin-charts | ~0.2 KB | ~80 KB | Recharts visualization | +| plugin-kanban | ~0.2 KB | ~100 KB | Drag-and-drop board | +| plugin-markdown | ~0.2 KB | ~30 KB | Markdown rendering | +| plugin-dashboard | ~0.2 KB | ~22 KB | Dashboard layouts | +| plugin-form | ~0.2 KB | ~28 KB | Form builder | +| plugin-grid | ~0.2 KB | ~45 KB | Data grid | +| plugin-view | ~0.2 KB | ~35 KB | ObjectQL views | +| plugin-timeline | ~0.2 KB | ~20 KB | Timeline layouts | +| plugin-chatbot | ~0.2 KB | ~35 KB | Chat interface | +| plugin-calendar | ~0.2 KB | ~25 KB | Calendar views | +| plugin-gantt | ~0.2 KB | ~40 KB | Gantt charts | +| plugin-map | ~0.2 KB | ~60 KB | Map visualization | + +Without lazy loading, all this code would be in your main bundle! + +### Auto-Registration + +Plugins automatically register their components when imported: + +```typescript +// In the plugin's index.tsx +import { ComponentRegistry } from '@object-ui/core' + +ComponentRegistry.register('code-editor', CodeEditorRenderer) +``` + +You just need to import the plugin once: + +```typescript +// In your App.tsx or main.tsx +import '@object-ui/plugin-editor' +import '@object-ui/plugin-charts' +import '@object-ui/plugin-kanban' +import '@object-ui/plugin-markdown' +import '@object-ui/plugin-dashboard' +import '@object-ui/plugin-form' +import '@object-ui/plugin-grid' +// ... import other plugins as needed +``` + +Now all plugin components are available in your schemas! + +## Creating Custom Plugins + +You can create your own plugins following the same pattern: + +### 1. Create Package Structure + +```bash +mkdir -p packages/plugin-myfeature/src +cd packages/plugin-myfeature +``` + +### 2. Create Heavy Implementation + +```tsx +// src/MyFeatureImpl.tsx +import HeavyLibrary from 'heavy-library' + +export default function MyFeatureImpl(props) { + return +} +``` + +### 3. Create Lazy Wrapper + +```tsx +// src/index.tsx +import React, { Suspense } from 'react' +import { ComponentRegistry } from '@object-ui/core' +import { Skeleton } from '@object-ui/components' + +// Lazy load implementation +const LazyFeature = React.lazy(() => import('./MyFeatureImpl')) + +// Create renderer with Suspense +export const MyFeatureRenderer = (props) => ( + }> + + +) + +// Auto-register +ComponentRegistry.register('my-feature', MyFeatureRenderer) + +// Export for manual use +export const myFeatureComponents = { + 'my-feature': MyFeatureRenderer +} +``` + +### 4. Add TypeScript Types + +```typescript +// src/types.ts +import type { BaseSchema } from '@object-ui/types' + +export interface MyFeatureSchema extends BaseSchema { + type: 'my-feature' + customProp?: string +} +``` + +### 5. Configure Build + +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: resolve(__dirname, 'src/index.tsx'), + name: 'ObjectUIPluginMyFeature', + fileName: (format) => `index.${format}.js` + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + '@object-ui/components', + '@object-ui/core' + ], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM' + } + } + } + } +}) +``` + +### 6. Add Package.json + +```json +{ + "name": "@object-ui/plugin-myfeature", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.umd.js", + "module": "./dist/index.es.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.es.js", + "require": "./dist/index.umd.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "vite build && tsc --emitDeclarationOnly" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "heavy-library": "^1.0.0" + }, + "devDependencies": { + "@object-ui/components": "workspace:*", + "@object-ui/core": "workspace:*", + "@object-ui/types": "workspace:*", + "@vitejs/plugin-react": "^6.0.5", + "typescript": "^6.0.3", + "vite": "^8.2.1" + } +} +``` + +## Best Practices + +### 1. Keep Entry Point Light + +The main index file should only contain: +- Lazy loading wrapper +- Component registration +- Type exports + +Heavy imports go in the `*Impl.tsx` file. + +### 2. Provide Good Loading States + +Always show a meaningful skeleton while loading: + +```tsx + +}> + + +``` + +### 3. Export Types + +Make your plugin type-safe: + +```typescript +export type { MyFeatureSchema } from './types' +``` + +### 4. Document Your Plugin + +Include a README with: +- Installation instructions +- Usage examples +- Schema API reference +- Bundle size information + +### 5. Test Lazy Loading + +Verify that: +- The main bundle is small (~200 bytes) +- The lazy chunk is separate +- Components load correctly when rendered + +```bash +pnpm build +ls -lh dist/ +``` + +## Plugin vs Component Package + +**Use a Plugin when:** +- The component depends on large libraries (>50 KB) +- Not all apps will use this component +- You want on-demand loading + +**Use regular Components when:** +- The component is lightweight +- Most apps will use it +- It's part of core functionality + +## Troubleshooting + +### Plugin not loading + +Check that you imported it in your app: + +```typescript +import '@object-ui/plugin-myfeature' +``` + +### TypeScript errors + +Make sure types are exported: + +```typescript +export type { MyFeatureSchema } from '@object-ui/plugin-myfeature' +``` + +### Bundle size too large + +Check that the implementation is in a separate file: + +``` +✅ src/index.tsx (light, uses React.lazy) +✅ src/MyFeatureImpl.tsx (heavy, imported lazily) +``` + +### Component not registering + +Check that ComponentRegistry.register() is called at the module level: + +```typescript +// ✅ Good - runs on import +ComponentRegistry.register('my-feature', MyFeatureRenderer) + +// ❌ Bad - never runs +export function registerComponents() { + ComponentRegistry.register('my-feature', MyFeatureRenderer) +} +``` + +## Related Documentation + +- [Component Registry](/docs/frontend/component-registry) - Understanding the registry +- [Schema Rendering](/docs/frontend/schema-rendering) - How schemas become UI +- [Custom Plugin Development](/docs/frontend/plugin-development) - Component development +- **[Create Plugin Utility](/docs/frontend/create-plugin)** - Scaffold new plugins quickly +- **[CLI Tool](/docs/frontend/cli)** - Test plugins with the CLI +- **[All Utilities](/docs/frontend/cli)** - Complete toolkit for development + +## Next Steps + +1. Install official plugins you need +2. Try creating a custom plugin +3. Share your plugins with the community +4. Contribute new plugins to ObjectUI diff --git a/content/docs/frontend/plugins/meta.json b/content/docs/frontend/plugins/meta.json new file mode 100644 index 00000000000..900f42333b9 --- /dev/null +++ b/content/docs/frontend/plugins/meta.json @@ -0,0 +1,20 @@ +{ + "title": "UI plugins", + "pages": [ + "plugin-calendar", + "plugin-charts", + "plugin-chatbot", + "plugin-dashboard", + "plugin-detail", + "plugin-editor", + "plugin-form", + "plugin-gantt", + "plugin-grid", + "plugin-kanban", + "plugin-list", + "plugin-map", + "plugin-markdown", + "plugin-timeline", + "plugin-view" + ] +} diff --git a/content/docs/frontend/plugins/plugin-calendar.mdx b/content/docs/frontend/plugins/plugin-calendar.mdx new file mode 100644 index 00000000000..199a81bde8c --- /dev/null +++ b/content/docs/frontend/plugins/plugin-calendar.mdx @@ -0,0 +1,578 @@ +--- +title: "Plugin Calendar" +--- + +Calendar view components for ObjectUI - includes both ObjectQL-integrated and standalone calendar components. + +## Installation + +```bash +npm install @object-ui/plugin-calendar +``` + +The props an [`object-calendar`](/docs/ui/components/plugins#object-calendar) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Overview + +The `@object-ui/plugin-calendar` plugin provides two calendar components: + +1. **ObjectCalendar** (`object-calendar`): For ObjectQL data sources - displays database records as calendar events +2. **CalendarView** (`calendar-view`): Standalone calendar component for displaying pre-loaded event data + +Both components support month, week, and day views with full event management capabilities. + +Every week starts on the first day of the week of the calendar's locale (the `locale` prop, else the user's display locale): Sunday under `en-US`, Monday under `en-GB` or `zh-CN`. The month grid, its weekday heads, the week view's columns, the header's week range and the date popover all start on that day. There is no separate week-start setting. + +## Drag-and-Drop Rescheduling + +### Month view + +- **Move an event** — grab any cell of the event pill and drop it on another day. The calendar days from the grabbed day to the drop day move both `startDateField` and `endDateField`, so dragging a multi-day span from any of its days behaves intuitively. Each value keeps its time of day: a 10:00 event stays at 10:00, even across a DST change. +- **Resize an event** — grab the small right-edge handle on the last day of a multi-day pill and drop it on a different day to extend or shrink the end date. The end keeps its time of day, and the start date is preserved; drops earlier than the start are ignored. + +### Week / Day view (time grid) + +Week and day views render a classic Google Calendar-style vertical time +grid. All gestures use pointer events and snap to `slotMinutes` (default +30): + +- **Move an event** — drag the event body vertically to change its start + time (and end, by the same delta). In week view, drag horizontally to + also change the day. +- **Resize start** — drag the top edge of an event to adjust only the + start time. Refuses to cross the existing end. +- **Resize end** — drag the bottom edge to adjust only the end time. + Refuses to cross the existing start. +- **Drag-to-create** — click-drag on an empty area of the time grid to + open the quick-create dialog with start/end pre-filled to the dragged + time range. + +Pass `slotMinutes={15}` to change the snap granularity, or +`onTimeRangeSelect={(start, end) => …}` to override the drag-to-create +default. + +When `ObjectCalendar` is bound to an object (i.e. it has `objectName` and a `dataSource`), the new dates are persisted automatically via `dataSource.update()` — local state is updated optimistically and rolled back on failure. To intercept (e.g. for a confirm dialog) pass an `onEventDrop` prop; supplying your own handler disables the default persistence. + +The default persistence is offered only to a caller who may make the write: events +are draggable when the `calendarReschedule` row of the affordance-to-grant map +(`@object-ui/core`) allows it — the object's policy, the effective API operation +set and the caller's **update** grant. Without it the events stay where they are. +With no permission provider mounted it reads open. + +```jsx + { + if (confirm(`Move ${record.name}?`)) save({ id: record.id, start_date: newStart, end_date: newEnd }); + }} +/> +``` + +## Click-to-Create + +Clicking the empty area of any day cell (month view) or click-dragging +in the week/day time grid opens a quick-create dialog pre-filled with +the selected date/range. Type a title and press Enter (or +click **Create**) to persist a new record via +`dataSource.create(objectName, payload)`. The new record is inserted +optimistically into the calendar so it appears immediately. + +The payload includes the configured `titleField`, `startDateField`, +optional `endDateField` (set to the clicked day), plus auto-defaults +for any other required fields not provided by the user (first picklist +option for `select`/`status`, `false` for booleans, `0` for numerics, +or the field's declared `defaultValue`). This ensures the create +succeeds against `NOT NULL` columns without forcing the user through +the full form. + +When the object has a required field outside that set (a required lookup, +or a required text field other than the title), the dialog's create could +not succeed, so it is not offered. The same gesture opens the host's full +create form instead: the calendar calls `onEdit` with a new record (no `id`) +carrying the date values the dialog would have written, and the host +pre-fills its create form with them. The console does this already. Without +an `onEdit`, the gesture offers nothing on such an object. Required fields +the server fills on insert (`readonly`, `system`, `autonumber`) do not count. + +```jsx + openCreateForm({ initialValues: record })} +/> +``` + +Quick-create is offered only when the `calendarQuickCreate` row of the +affordance-to-grant map allows it (the caller's **create** grant, with the +object's policy and the effective API operation set): without it a day click +opens nothing and the time grid starts no range drag. With no permission +provider mounted it reads open. + +To override (e.g. open your own multi-field create form), pass +`onDateClick`: + +```jsx + navigate(`/campaign/new?start=${day.toISOString()}`)} +/> +``` + +## CalendarView Component + +Full-featured standalone calendar with month, week, and day views for displaying events and scheduling. + +### Example schemas + +#### Month View + +**Month View Calendar** + +```json +{ + "type": "calendar-view", + "view": "month", + "currentDate": "2026-05-15T12:00:00.000Z", + "data": [ + { + "id": 1, + "title": "Team Meeting", + "start": "2026-05-28T02:00:00.000Z", + "end": "2026-05-28T03:00:00.000Z", + "color": "#3b82f6", + "allDay": false + }, + { + "id": 2, + "title": "Project Deadline", + "start": "2026-05-31T06:32:37.758Z", + "color": "#ef4444", + "allDay": true + }, + { + "id": 3, + "title": "Conference", + "start": "2026-06-04T06:32:37.758Z", + "end": "2026-06-06T06:32:37.758Z", + "color": "#10b981", + "allDay": true + } + ], + "titleField": "title", + "startDateField": "start", + "endDateField": "end", + "colorField": "color", + "allDayField": "allDay", + "className": "h-[600px] border rounded-lg" +} +``` + +#### Week View + +**Week View Calendar** + +```json +{ + "type": "calendar-view", + "view": "week", + "currentDate": "2026-05-27T12:00:00.000Z", + "data": [ + { + "id": 1, + "title": "Standup", + "start": "2026-05-28T01:00:00.000Z", + "end": "2026-05-28T01:30:00.000Z", + "color": "#3b82f6", + "allDay": false + }, + { + "id": 2, + "title": "Sprint Planning", + "start": "2026-05-28T06:00:00.000Z", + "end": "2026-05-28T07:30:00.000Z", + "color": "#8b5cf6", + "allDay": false + }, + { + "id": 3, + "title": "1:1 with Manager", + "start": "2026-05-29T03:00:00.000Z", + "end": "2026-05-29T03:30:00.000Z", + "color": "#10b981", + "allDay": false + }, + { + "id": 4, + "title": "Team Lunch", + "start": "2026-05-30T04:00:00.000Z", + "end": "2026-05-30T05:00:00.000Z", + "color": "#f59e0b", + "allDay": false + } + ], + "titleField": "title", + "startDateField": "start", + "endDateField": "end", + "colorField": "color", + "allDayField": "allDay", + "className": "h-[500px] border rounded-lg" +} +``` + +### CalendarView Usage + +```tsx +import '@object-ui/plugin-calendar' +import type { CalendarViewSchema } from '@object-ui/types' + +const schema: CalendarViewSchema = { + type: 'calendar-view', + view: 'month', + data: [ + { + id: 1, + title: 'Team Meeting', + start: '2024-01-15T10:00:00', + end: '2024-01-15T11:00:00', + color: '#3b82f6' + } + ] +} +``` + +### CalendarView Schema API + +```plaintext +{ + type: 'calendar-view', + view?: 'month' | 'week' | 'day', + data?: Array, + titleField?: string, + startDateField?: string, + endDateField?: string, + allDayField?: string, + colorField?: string, + currentDate?: string, + allowCreate?: boolean, + onEventClick?: (event: any) => void, + onDateClick?: (date: Date) => void, + onViewChange?: (view: string) => void, + onNavigate?: (date: Date) => void, + className?: string +} +``` + +### Allowing event creation + +`allowCreate: true` adds the header's **New event** button. Clicking it +dispatches the standard create action — `{ type: 'create', payload: {} }` — on +the same action channel every other `calendar-view` gesture uses, so the host +decides what a "create" means (open a form, navigate, call an API). + +```tsx +const schema = { + type: 'calendar-view', + data: events, + allowCreate: true, +} +``` + +Only the boolean `true` turns the button on. Omitting the key, `false`, and any +non-boolean value all render the calendar without the button — the default. + +A React host can supply the affordance directly instead, with or without +`allowCreate`, by passing its own `onAddClick` handler; an explicit handler +replaces the action dispatch rather than running alongside it. + +## ObjectCalendar Component + +Calendar component designed for use with ObjectQL data sources. + +### Features + +- **ObjectQL Integration**: Works seamlessly with object/value data providers +- **Automatic Field Mapping**: Maps database fields to calendar events +- **Multiple View Modes**: Month, week, and day calendar views +- **Visible days for a host**: Reports the days it draws, so a host that fetches its records can fetch only those days (see [Visible days](#visible-days)); on its own it fetches the whole filtered set, up to the platform row ceiling +- **Event Interaction**: Click handling for events and dates +- **Color Coding**: Support for event color customization + +### ObjectCalendar Usage + +#### Basic Usage with ObjectQL + +```tsx +import '@object-ui/plugin-calendar' +import type { ObjectCalendarSchema } from '@object-ui/types' + +const schema: ObjectCalendarSchema = { + type: 'object-calendar', + objectName: 'events', // Your ObjectQL object + calendar: { + startDateField: 'startDate', + endDateField: 'endDate', + titleField: 'title', + colorField: 'category' + } +} +``` + +### With Static Data + +```tsx +import type { ObjectCalendarSchema } from '@object-ui/types' + +const schema: ObjectCalendarSchema = { + type: 'object-calendar', + staticData: [ + { + id: 1, + title: 'Team Meeting', + startDate: '2024-01-15T10:00:00', + endDate: '2024-01-15T11:00:00', + category: 'meeting' + }, + { + id: 2, + title: 'Project Deadline', + startDate: '2024-01-20', + category: 'deadline' + } + ], + calendar: { + startDateField: 'startDate', + endDateField: 'endDate', + titleField: 'title', + colorField: 'category' + } +} +``` + +## Comparison: CalendarView vs ObjectCalendar + +| Feature | CalendarView | ObjectCalendar | +|---------|-------------|----------------| +| **Schema Type** | `calendar-view` | `object-calendar` | +| **Data Source** | Static arrays | ObjectQL (database) | +| **Use Case** | Pre-loaded event data | Dynamic data from backend | +| **Field Mapping** | Standard field names | Configurable field names | +| **Data Loading** | Manual via props | Automatic via ObjectQL | +| **Best For** | Static schedules | Database-driven apps | + +**When to use CalendarView**: +- You have static or pre-loaded event data +- You're not using ObjectQL +- You need a simple, standalone calendar + +**When to use ObjectCalendar**: +- You're using ObjectQL for data management +- Events come from a database or API +- You need automatic data fetching and filtering + +## Event Handling + +A click handler is a function, so it is a prop of the `ObjectCalendar` +component a React host renders, never a key on the node: +`ObjectCalendarSchema` declares no `onEventClick` or `onDateClick`, and +authored JSON can never carry a function anyway. + +### Event Click + +```tsx +import { ObjectCalendar } from '@object-ui/plugin-calendar'; +import type { DataSource, ObjectCalendarSchema } from '@object-ui/types'; + +const eventClickCalendar: ObjectCalendarSchema = { + type: 'object-calendar', + objectName: 'events', + calendar: { + startDateField: 'start', + titleField: 'title' + } +}; + +export function EventClickCalendar({ dataSource }: { dataSource: DataSource }) { + return ( + { + console.log('Event clicked:', record); + // Open event details, or navigate to the event page + }} + /> + ); +} +``` + +### Date Click + +```tsx +import { ObjectCalendar } from '@object-ui/plugin-calendar'; +import type { DataSource, ObjectCalendarSchema } from '@object-ui/types'; + +const dateClickCalendar: ObjectCalendarSchema = { + type: 'object-calendar', + objectName: 'events', + calendar: { + startDateField: 'start', + titleField: 'title' + } +}; + +export function DateClickCalendar({ dataSource }: { dataSource: DataSource }) { + return ( + { + console.log('Date clicked:', date); + // Create a new event on this date + }} + /> + ); +} +``` + +### Visible days + +A host that fetches the calendar's records for it, and hands them over as +`data`, can fetch only the days on screen. `onVisibleRangeChange` receives +`{ start, end }` once on mount and again whenever navigation or a view change +moves them: `start` is local midnight of the first day drawn and `end` local +midnight of the day after the last. In the month view the range covers the +whole six-week grid, including the neighbouring months' leading and trailing +days, under the locale's first day of the week; in the week view it is the +week, and in the day view the day. An unchanged range is not reported again. +A list view's calendar uses it to fetch the days it shows instead of one batch +of the object (objectui#12081). + +## Direct Component Usage + +You can also import and use the components directly in React: + +```tsx +import { CalendarView, ObjectCalendar } from '@object-ui/plugin-calendar'; +import type { DataSource, ObjectCalendarSchema } from '@object-ui/types'; + +// CalendarView - Standalone calendar +function MyCalendar() { + const events = [ + { + id: 1, + title: 'Meeting', + start: new Date('2024-01-15T10:00:00'), + end: new Date('2024-01-15T11:00:00'), + color: '#3b82f6' + } + ]; + + return ( + console.log(event)} + /> + ); +} + +// ObjectCalendar - ObjectQL-integrated +function MyObjectCalendar({ dataSource }: { dataSource: DataSource }) { + const schema: ObjectCalendarSchema = { + type: 'object-calendar', + objectName: 'events', + calendar: { + startDateField: 'startDate', + titleField: 'title' + } + }; + + return ( + console.log(record)} + /> + ); +} +``` + +## TypeScript Support + +```ts +import type { + CalendarViewSchema, + ObjectCalendarSchema, + CalendarConfig +} from '@object-ui/types' +import type { CalendarViewEvent } from '@object-ui/plugin-calendar' + +// CalendarView component events — the RUNTIME shape: `id: string | number`, +// `start` / `end` are real `Date` objects. `@object-ui/types` exports a +// separate `CalendarEvent`, the AUTHORING event (`id: string`, ISO strings, +// `end` required); the two are not interchangeable. Renamed in objectui#5044, +// which left `CalendarEvent` on this package as a `@deprecated` alias. +const events: CalendarViewEvent[] = [ + { + id: 1, + title: 'Meeting', + start: new Date('2024-01-15T10:00:00'), + end: new Date('2024-01-15T11:00:00'), + color: '#3b82f6' + } +] + +const calendarViewSchema: CalendarViewSchema = { + type: 'calendar-view', + view: 'month', + data: events +} + +// ObjectCalendar types +const calendarConfig: CalendarConfig = { + startDateField: 'startDate', + endDateField: 'endDate', + titleField: 'title', + colorField: 'category' +} + +const objectCalendarSchema: ObjectCalendarSchema = { + type: 'object-calendar', + objectName: 'events', + calendar: calendarConfig +} +``` + +## Migration from @object-ui/plugin-calendar-view + +The `@object-ui/plugin-calendar-view` package has been merged into this package. If you were using it: + +### Before + +```bash +npm install @object-ui/plugin-calendar-view +``` + +```tsx +import '@object-ui/plugin-calendar-view' +import { CalendarView } from '@object-ui/plugin-calendar-view' +``` + +### After + +```bash +npm install @object-ui/plugin-calendar +``` + +```tsx +import '@object-ui/plugin-calendar' +import { CalendarView } from '@object-ui/plugin-calendar' +``` + +All functionality remains the same - just update your imports and package dependencies. + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-calendar) diff --git a/content/docs/frontend/plugins/plugin-charts.mdx b/content/docs/frontend/plugins/plugin-charts.mdx new file mode 100644 index 00000000000..be3e42a440a --- /dev/null +++ b/content/docs/frontend/plugins/plugin-charts.mdx @@ -0,0 +1,339 @@ +--- +title: "Plugin Charts" +--- + +Data visualization components powered by Recharts. + +## Installation + +```bash +npm install @object-ui/plugin-charts +``` + +The props an [`object-chart`](/docs/ui/components/plugins#object-chart) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Usage + +The plugin provides two chart APIs: + +1. **Simple Bar Chart** (`bar-chart`) - Simplified API for basic bar charts +2. **Advanced Charts** (`chart`) - Full-featured API supporting multiple chart types (bar, line, area) and multiple data series + +### Basic Usage + +```tsx +// Import once in your app entry point +import '@object-ui/plugin-charts' +import type { BarChartSchema } from '@object-ui/plugin-charts' + +// Use in schemas +const schema: BarChartSchema = { + type: 'bar-chart', + data: [ + { name: 'Jan', value: 400 }, + { name: 'Feb', value: 300 }, + { name: 'Mar', value: 600 } + ], + dataKey: 'value', + xAxisKey: 'name', + height: 400 +} +``` + +## Features + +- **Bar, line, and area charts** +- **Responsive design** +- **Customizable colors and styles** +- **Lazy-loaded** (~540 KB loads only when rendered) + +## Chart Types + +The plugin provides three chart components: `bar-chart` and `chart` draw the rows they are given, and `object-chart` fetches its own: + +### Simple Bar Chart (bar-chart) + +For basic single-series bar charts, use the simplified `bar-chart` type: + +```tsx +import type { BarChartSchema } from '@object-ui/plugin-charts' + +const schema: BarChartSchema = { + type: 'bar-chart', + data: [ + { month: 'Jan', sales: 400 }, + { month: 'Feb', sales: 300 }, + { month: 'Mar', sales: 600 } + ], + dataKey: 'sales', + xAxisKey: 'month', + color: '#3b82f6', + height: 300 +} +``` + +### Advanced Charts (chart) + +For multi-series charts or line/area charts, use the advanced `chart` type with the `chartType` property: + +##### Bar Chart + +```tsx +import type { ChartSchema } from '@object-ui/types' + +const schema: ChartSchema = { + type: 'chart', + chartType: 'bar', + data: [ + { month: 'Jan', sales: 400, target: 350 }, + { month: 'Feb', sales: 300, target: 400 }, + { month: 'Mar', sales: 600, target: 550 } + ], + xAxisKey: 'month', + series: [ + { dataKey: 'sales' }, + { dataKey: 'target' } + ], + config: { + sales: { label: 'Sales', color: '#3b82f6' }, + target: { label: 'Target', color: '#10b981' } + } +} +``` + +##### Line Chart + +```tsx +import type { ChartSchema } from '@object-ui/types' + +const schema: ChartSchema = { + type: 'chart', + chartType: 'line', + data: [ + { date: '2024-01', users: 120 }, + { date: '2024-02', users: 180 }, + { date: '2024-03', users: 250 } + ], + xAxisKey: 'date', + series: [ + { dataKey: 'users' } + ], + config: { + users: { label: 'Users', color: '#8884d8' } + } +} +``` + +###### Area Chart + +```tsx +import type { ChartSchema } from '@object-ui/types' + +const schema: ChartSchema = { + type: 'chart', + chartType: 'area', + data: [ + { time: '9:00', value: 20 }, + { time: '10:00', value: 35 }, + { time: '11:00', value: 45 } + ], + xAxisKey: 'time', + series: [ + { dataKey: 'value' } + ], + config: { + value: { label: 'Value', color: '#10b981' } + } +} +``` + +##### Per-series options + +Each `series` entry names the column it plots (`dataKey`, or the spec spelling `name`) and may carry the presentation keys the renderer reads — the same keys as `@objectstack/spec`'s `ChartSeries`: + +| Key | Type | Effect | +|---|---|---| +| `label` | `string` or inline locale map | Legend / tooltip name; defaults to the column key. | +| `type` | `'bar' \| 'line' \| 'area'` | Per-series family override — a combo chart is a series disagreeing with the chart's `chartType`. | +| `color` | `string` | Series colour; wins over the positional palette. | +| `stack` | `string` | Stack group id — series sharing one id stack together. | +| `yAxis` | `'left' \| 'right'` | Which y-axis the series binds to, on a chart that declares a second y-axis. | +| `variant` | `'primary' \| 'comparison'` | `comparison` draws the muted period-over-period overlay; `primary` (the default) is the normal treatment. The muted defaults are per mark family; a `radar` overlay takes the `area` ones, radar being the other mark this renderer both strokes and fills. | +| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Applies on **any** series, whatever its `variant`, and overrides the comparison overlay's default `"4 4"`. Only a mark with a stroke can show a dash, and this renderer strokes `line`, `area` and `radar` marks (the chart's `chartType`, or a per-series `type` override) — on a `bar` or `scatter` mark the value reaches the mark and paints nothing, which is the mark's geometry rather than a condition on the key. | +| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Applies on **any** series, whatever its `variant`, on every mark family — the fill of a `bar` or `scatter` mark, the stroke of a `line` mark, both on an `area` or `radar` mark — and overrides the comparison overlay's per-family default. | + +`chartType` is **not** a series key. It is the renderer's internal spelling of `type` — `@objectstack/spec`'s `ChartSeries` lists it as an alias — and the validator refuses it by name (`Unrecognized key(s) on this chart series: chartType. Did you mean chartType → type?`) instead of dropping it silently, which is what it used to do. Write `type`. + +An `area` chart keeps every key in this example live: `stack` stacks the two primary areas, and the comparison overlay takes both the dash and the opacity (on a `bar` chart the dash would still reach the mark, but a bar has no stroke to dash). Neither key needs `variant: 'comparison'` — a primary series may carry them too, and then it is the overlay's defaults, not the keys, that stay comparison-only. + +```tsx +import type { ChartSchema } from '@object-ui/types' + +const schema: ChartSchema = { + type: 'chart', + chartType: 'area', + data: [ + { month: 'Jan', revenue: 400, cost: 250, revenue_prev: 360 }, + { month: 'Feb', revenue: 300, cost: 210, revenue_prev: 330 } + ], + xAxisKey: 'month', + series: [ + { name: 'revenue', label: 'Revenue', stack: 'money' }, + { name: 'cost', label: 'Cost', stack: 'money' }, + // '8 4' overrides the overlay's default '4 4' dash; 0.6 lifts its 0.2 fill default (its stroke default is already 0.6) + { name: 'revenue_prev', label: 'Revenue (previous period)', variant: 'comparison', dashArray: '8 4', opacity: 0.6 } + ] +} +``` + +##### Axis options + +`xAxis` and `yAxis` take `@objectstack/spec`'s axis object, `ChartAxis` — the same object the react `ObjectChart` tier (an `object-chart` node) takes for its own `xAxis` / `yAxis`, with the same keys and the same checks. `xAxis` is one object; `yAxis` is a **list**, one entry per value axis, and a second entry declares the right-hand axis. A bar (`column`), line, area, combo or `horizontal-bar` chart draws at most two value axes: an entry after the second is accepted but drawn on no axis, and the chart shows a note under the plot naming it (`yAxis[2]`, …) and, when the chart declares neither `series` nor `categories`, the axis its `field` is plotted against — the right-hand one, the top one on `horizontal-bar` (objectui#10691). A `scatter` chart draws one value axis, the first entry's: an entry after the first is accepted but drawn on no axis, and the chart shows a note under the plot naming it (`yAxis[1]`, …) and the axis it draws, `yAxis[0]` (objectui#10721). The object is strict: an undeclared key is refused at parse, with the spec's own hint (`Did you mean grid → showGridLines?`). + +| Key | Type | Effect | +|---|---|---| +| `field` | `string` | **Required.** The column the axis reads. On `xAxis` it names the category column (`xAxisKey` wins when both are written); on `yAxis`, when the chart has neither `series` nor `categories`, the entries' `field`s are the plotted columns. | +| `title` | `string` or inline locale map | Axis title, resolved to the viewer's language. | +| `format` | `string` | Tick number format, e.g. `"$0,0.00"` or `"0.0%"`. A string the renderer does not recognise keeps the default ticks. | +| `min` / `max` | `number` | Pin the ends of a numeric axis: the value axes, and the x axis of a `scatter` chart. | +| `stepSize` | `number` | Distance between ticks on a numeric axis. | +| `showGridLines` | `boolean` | `false` on the first `yAxis` entry hides the horizontal grid lines; `true` on `xAxis` draws vertical ones. | +| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | The side of the chart the axis belongs to. On `xAxis` it is one of the two sides that axis can run along: `top` or `bottom` on bar (`column`), line, area and `scatter` charts, whose x axis runs across the plot; `left` or `right` on a `horizontal-bar` chart, where `xAxis` configures the category axis running down the plot. Any other side draws the axis at its default side (the bottom; the left on `horizontal-bar`), and the chart shows a note under the plot naming `xAxis.position`. On `yAxis` it is the side of that entry's value axis. The open sides are `left` and `right` on bar (`column`), line, area, combo and `scatter` charts (a `scatter` chart draws one y axis, the first entry's), and `bottom` and `top` on a `horizontal-bar` chart, whose value axes run across the plot; there a series' `yAxis: 'left'` binds the bottom axis and `'right'` the top one. A lone entry's axis is drawn on the side it names. With two entries each is drawn on the side it names, and an entry that names no side takes the side the other left free; when both name the same side, the first keeps it and the second is drawn on the other side, with a note under the plot naming `yAxis[1].position`. Any other side places the entry as if it named none, and the chart shows a note under the plot naming `yAxis[N].position` (objectui#10654). | +| `logarithmic` | `boolean` | Logarithmic scale on a numeric axis. | + +A bare string is not an axis object. `xAxis: 'month'` is the sibling spelling of `xAxisKey` and folds onto it at parse; a single object or a bare string on `yAxis` is refused — write a one-entry list. + +```tsx +import type { ChartSchema } from '@object-ui/types' + +const schema: ChartSchema = { + type: 'chart', + chartType: 'line', + data: [ + { month: 'Jan', revenue: 4000, margin: 0.21 }, + { month: 'Feb', revenue: 3000, margin: 0.18 } + ], + xAxis: { field: 'month', title: 'Month' }, + yAxis: [ + { field: 'revenue', title: 'Revenue', format: '$0,0', min: 0 }, + // the second entry is the right-hand axis the `margin` series binds to + { field: 'margin', title: { en: 'Margin', fr: 'Marge' }, format: '0.0%' } + ], + series: [ + { name: 'revenue' }, + { name: 'margin', yAxis: 'right' } + ] +} +``` + +### Object Charts (object-chart) + +An `object-chart` node charts an object's records (`objectName` with an `aggregate`) or a dataset (`dataset` with `dimensions` and `values`), and fetches the rows itself. An authored node takes its props in the `properties` bag. Its family is `chartType`, one of the `@objectstack/spec` chart families this package draws as a chart: `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo` and `radar` (objectui#11513). + +```json +{ + "type": "object-chart", + "properties": { + "chartType": "funnel", + "objectName": "opportunity", + "aggregate": { "field": "amount", "function": "sum", "groupBy": "stage" }, + "xAxis": { "field": "stage" }, + "series": [{ "name": "amount" }] + } +} +``` + +The spec's single-value families (`gauge`, `solid-gauge`, `metric`, `kpi`, `bullet`) and tabular ones (`table`, `pivot`) are refused on `chartType`, and the refusal names the set above: this node draws no chart of them. Write a single number as an `object-metric`, rows as an `object-data-table`, and a cross-tab as an `object-pivot`. The TypeScript `ObjectChartSchema` in `@object-ui/types` declares the same set, taken from the spec's `ChartType`: + +```ts +import type { ObjectChartSchema } from '@object-ui/types' + +const pipeline: ObjectChartSchema = { + type: 'object-chart', + chartType: 'funnel', + objectName: 'opportunity', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + series: [{ name: 'amount' }] +} +``` + +## Bundle Size + +The plugin uses lazy loading to optimize bundle size: + +- **Initial load**: ~0.2 KB (entry point) +- **Lazy chunk**: ~541 KB minified (~136 KB gzipped) +- **Loads only when rendered** + +Without lazy loading, this would add 541 KB to your main bundle. With lazy loading, it's loaded only when charts are displayed. + +## Customization + +### Custom Colors + +```tsx +const schema = { + type: 'bar-chart', + data: salesData, + color: '#f59e0b', // Amber color + className: 'bg-white p-4 rounded-lg' +} +``` + +### Responsive Height + +```tsx +const schema = { + type: 'chart', + chartType: 'line', + data: metricsData, + xAxisKey: 'date', + series: [{ dataKey: 'value' }], + config: { + value: { label: 'Metrics', color: '#8884d8' } + }, + className: 'h-64 sm:h-96 lg:h-[500px]' +} +``` + +## Rendering Without the Entrance Animation (host-composed) + +A host that builds an `object-chart` or `chart` node in code can set `isAnimationActive: false` on it for a deterministic render: the chart draws at its final geometry on the first frame, with no entrance animation. Omitted, the chart animates. `ChartRenderer` honours the flag, and the flag reaches it by one of two routes: + +- **An `object-chart` node.** `ObjectChart` spreads the node into the schema it hands `ChartRenderer`. `DashboardRenderer` and `DashboardGridLayout` set the flag this way on the `object-chart` nodes they build for an object-bound widget. For this node it is declared on the TypeScript `ObjectChartSchema` in `@object-ui/types`, as a host-composed member. +- **A bare `chart` node.** `ChartRenderer` renders it directly: it owns the bare `chart` key, and `ObjectChart`'s block is registered as `view:chart` only. `ChartRenderer`'s own `schema` prop declares the flag. `DatasetWidget` and `DatasetReportRenderer` set it this way, and so do the two dashboard renderers for a widget on inline data. These nodes do not go through `ObjectChartSchema`. + +It is not an authoring key: the zod mirror of `ObjectChartSchema` declares no member for it, so it is not part of the `object-chart` node's `properties` vocabulary. + +## Axis Tick Labels and the Host Page Font + +**Precondition:** the host page's `body` font family must match the font family the chart's axis tick labels render with. When the two differ, Recharts computes its tick-density and collision decisions against the wrong glyph metrics: tick labels overlap when the page font is narrower than the tick font, and labels that would have fitted are dropped from the axis when it is wider. Nothing reports the mismatch — there is no error and no warning, only a different set of visible labels. + +To meet it, render charts in the page's body font: set your font stack on `body`, and do not give a chart, or any element between `body` and the chart, a different `font-family`. + +Why it happens: on any axis whose `interval` is not a number — Recharts' default `preserveEnd`, and `preserveStart`, `preserveStartEnd`, `equidistantPreserveStart` and `equidistantPreserveEnd` — Recharts chooses which tick labels to keep by measuring every label: its width on a horizontal axis, its height on a vertical one. It takes that measurement in a hidden `span` that it appends to `document.body`, so the span inherits the page's body font family rather than the chart's. After the first render Recharts reads the computed `fontSize` and `letterSpacing` back from a rendered tick and measures again with them, but it never reads `fontFamily`, and a `fontFamily` in an axis's `tick` props does not reach the measurement either. A numeric `interval` (such as `0`) skips the measurement altogether. The fix belongs in Recharts itself; it is tracked in [objectui#9430](https://github.com/objectstack-ai/objectui/issues/9430). + +This package has one instance of it. `ChartImpl`, the component the `bar-chart` type lazy-loads, sets `fontFamily: 'monospace'` in its y-axis `tick` props and gives that axis no `interval`, so the axis runs Recharts' default `preserveEnd` thinning and its labels are measured in the page's body font instead of in monospace. Its x-axis carries the same font but sets `interval={0}`, which skips the measurement. Because a vertical axis measures label heights, and the measuring `span` is one line tall — its height comes from the `line-height` it inherits from `body`, and depends on the font family only when that `line-height` is `normal` — this y-axis can keep a different set of labels only on a page that leaves `line-height` at `normal`. + +## TypeScript Support + +```ts +import type { BarChartSchema } from '@object-ui/plugin-charts' + +const chartSchema: BarChartSchema = { + type: 'bar-chart', + data: [ + { name: 'Product A', value: 400 }, + { name: 'Product B', value: 300 } + ], + dataKey: 'value', + xAxisKey: 'name', + height: 400 +} +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Lazy-Loaded Plugins Architecture](/docs/frontend/plugins#lazy-loading-architecture) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-charts) diff --git a/content/docs/frontend/plugins/plugin-chatbot.mdx b/content/docs/frontend/plugins/plugin-chatbot.mdx new file mode 100644 index 00000000000..5715fa629b3 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-chatbot.mdx @@ -0,0 +1,853 @@ +--- +title: "Plugin Chatbot" +--- + +Chat interface component with message history, typing indicators, and customizable avatars. + +> **v5.4+** — the chat surface is now composed from vendored +> [Vercel AI Elements](https://elements.ai-sdk.dev) (MIT). The public +> `chatbot` schema and `ChatbotEnhanced` / `FloatingChatbot` props are +> unchanged. A new optional `suggestions: string[]` field renders chip +> prompts in the empty state. + +For advanced compositions, the underlying elements are re-exported: + +```tsx +import { AIElements } from '@object-ui/plugin-chatbot'; +// AIElements.Conversation, AIElements.Message, AIElements.PromptInput, ... +``` + +## Installation + +```bash +npm install @object-ui/plugin-chatbot +``` + +## Example schemas + +### Basic Chatbot + +**Basic Chatbot** + +```json +{ + "type": "chatbot", + "messages": [ + { + "id": "1", + "role": "assistant", + "content": "Hello! How can I help you today?" + } + ], + "placeholder": "Type your message...", + "showTimestamp": false, + "autoResponse": true, + "autoResponseText": "Thank you for your message! This is an automated response.", + "autoResponseDelayMs": 1000, + "maxHeight": "400px" +} +``` + +### Chatbot with Timestamps + +**Chatbot With Timestamps** + +```json +{ + "type": "chatbot", + "messages": [ + { + "id": "1", + "role": "assistant", + "content": "Welcome to our support chat!", + "timestamp": "9:00 AM" + }, + { + "id": "2", + "role": "user", + "content": "I need help with my account", + "timestamp": "9:01 AM" + }, + { + "id": "3", + "role": "assistant", + "content": "I'd be happy to help you with your account. What specific issue are you experiencing?", + "timestamp": "9:01 AM" + } + ], + "showTimestamp": true, + "placeholder": "Ask a question...", + "autoResponse": true, + "autoResponseText": "I'll look into that for you right away.", + "maxHeight": "450px" +} +``` + +### Customer Support Chat + +**Customer Support Chat** + +```json +{ + "type": "chatbot", + "messages": [ + { + "id": "system-1", + "role": "system", + "content": "Support chat session started" + }, + { + "id": "1", + "role": "assistant", + "content": "Hi! I'm Sarah from customer support. How may I assist you today?", + "avatarFallback": "SA" + }, + { + "id": "2", + "role": "user", + "content": "I'm having trouble accessing my dashboard", + "avatarFallback": "JD" + }, + { + "id": "3", + "role": "assistant", + "content": "I understand. Let me help you with that. Could you tell me if you're seeing any error messages?", + "avatarFallback": "SA" + } + ], + "placeholder": "Type your message here...", + "userAvatarFallback": "JD", + "assistantAvatarFallback": "SA", + "showTimestamp": true, + "autoResponse": true, + "autoResponseDelayMs": 1500, + "autoResponseText": "Let me check that for you...", + "maxHeight": "500px" +} +``` + +## Usage + +### Basic Usage + +```tsx +// Import once in your app entry point +import '@object-ui/plugin-chatbot' +import type { ChatbotSchema } from '@object-ui/types' + +// Use in schemas +const schema: ChatbotSchema = { + type: 'chatbot', + messages: [ + { + id: '1', + role: 'assistant', + content: 'Hello! How can I help you?' + } + ], + placeholder: 'Type your message...', + autoResponse: true +} +``` + +## Features + +- **Message History**: Display user and assistant chat messages +- **System Messages**: Show system notifications in the chat +- **Timestamps**: Optional timestamp display for each message +- **Custom Avatars**: Configurable avatar images and fallback text +- **Auto-scroll**: Automatically scroll to newest messages +- **Typing Indicator**: Built-in typing indicator component +- **Auto-response**: Demo mode with automatic responses +- **Responsive Floating Panel**: Console assistants keep the panel inside safe + browser gutters and hide the FAB while the chat is open +- **Conversation States**: Empty streaming messages render as an assistant + responding indicator, the submit control becomes Stop while streaming, and + backend errors collapse into a retryable notice with optional details +- **Lightweight**: Pure React components with minimal dependencies + +## Schema API + +```plaintext +{ + type: 'chatbot', + messages?: ChatMessage[], + placeholder?: string, + showTimestamp?: boolean, + disabled?: boolean, + userAvatarUrl?: string, + userAvatarFallback?: string, + assistantAvatarUrl?: string, + assistantAvatarFallback?: string, + maxHeight?: string, + autoResponse?: boolean, + autoResponseText?: string, + autoResponseDelayMs?: number, + onSend?: (content: string, messages: ObjectChatMessage[]) => void, + className?: string, + // AI / service-ai integration fields + api?: string, + conversationId?: string, + systemPrompt?: string, + model?: string, + streamingEnabled?: boolean, + headers?: Record, + requestBody?: Record, + onError?: (error: Error) => void, +} +``` + +### ChatMessage + +```plaintext +{ + id: string, + role: 'user' | 'assistant' | 'system' | 'tool', + content: string, + timestamp?: string | Date, + metadata?: any, + streaming?: boolean, + toolInvocations?: ChatToolInvocation[], +} +``` + +## Properties + +`showTimestamp` through `onSend` below are declared on [`ChatbotSchema`](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/types/src/complex.ts) +(`@object-ui/types`) — previously they existed only in an anonymous type local +to the renderer, referenceable, validatable and documentable by nothing +outside that one file (objectui#6169). + +This page documents **three** registrations - `chatbot`, `chatbot-enhanced` and +`chatbot-floating` - and they do not all read the same keys. **A row whose +description carries no bolded scope note is read by all three.** The rows only +some of them read say so in bold at the start of the description, and name what +to author instead on the registrations that ignore the key. + +Each registration has its own importable authoring-face type in +`@object-ui/types` (objectui#7655): `ChatbotSchema` for `chatbot`, +`ChatbotEnhancedSchema` for `chatbot-enhanced` and `ChatbotFloatingSchema` for +`chatbot-floating`. Each declares exactly the keys its registration reads - the +nineteen shared rows below are one declaration the two newer faces pick off +`ChatbotSchema` by name, and a scoped row is declared only on the face(s) whose +registration reads it. A key a registration ignores is therefore not a declared +member of its type. In a typed literal it is a compile error (objectui#8347 +removed the `BaseSchema` index signature that typed an unlisted key `any`), but +it still parses (the Zod twins are `.passthrough()`). It is then dropped silently at render +time, on all three registrations alike. `chatbot-floating` used to be the +exception: its registration ended its panel element with an unfiltered props +spread, so three keys its type does not declare (`processVisibility`, `surface`, +`showAvatars`) did reach the panel. That channel was accidental, not a contract +to author against, and objectui#7708 closed it - the spread is filtered through +the DOM-safe whitelist and moved ahead of every named prop, the shape the two +sibling registrations already used. A `chatbot-floating` node's declared set is +now also its delivered set. That is why the scope is spelled out here as well as +in the types. + +The table below is that shared chat surface. `chatbot-floating` declares +six more keys of its own - the six `floatingConfig` entries - which no row +below carries and which the other two registrations have no trigger or panel +to apply. They are documented in their own table after this one, under +**`chatbot-floating` panel and trigger keys**. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `messages` | array | `[]` | Initial chat messages | +| `placeholder` | string | `'Type your message...'` | Input field placeholder text | +| `showTimestamp` | boolean | `false` | Display timestamps for messages | +| `disabled` | boolean | `false` | Disable chat input | +| `userAvatarUrl` | string | - | URL for user avatar image | +| `userAvatarFallback` | string | `'You'` | Fallback text for user avatar | +| `assistantAvatarUrl` | string | - | URL for assistant avatar image | +| `assistantAvatarFallback` | string | `'AI'` | Fallback text for assistant avatar | +| `maxHeight` | string | `'500px'` | **`chatbot` and `chatbot-enhanced` only** (declared on `ChatbotSchema` and `ChatbotEnhancedSchema`; `ChatbotFloatingSchema` does not declare it). Maximum height of the chat message container, as a CSS length. `chatbot-floating` does not read it: its panel is sized by `floatingConfig.panelHeight` (a **number** of pixels, default `520`), and the panel pins its inner chat to `maxHeight: '100%'` so it fills that panel - a `maxHeight` authored on a floating node would be overridden even if it were forwarded. Size a floating chatbot with `floatingConfig.panelHeight` instead | +| `autoResponse` | boolean | `false` | Enable auto-response (demo mode, ignored when `api` is set) | +| `autoResponseText` | string | - | Text for auto-response | +| `autoResponseDelayMs` | number | `1000` | Delay before auto-response, in milliseconds (renamed from `autoResponseDelay`, which is now refused with a message naming this key) | +| `onSend` | function | - | Callback when message is sent | +| `className` | string | `''` | Additional Tailwind CSS classes | +| `api` | string | - | Backend SSE endpoint (e.g., `/api/v1/ai/chat`). Enables AI streaming mode | +| `conversationId` | string | - | Multi-turn conversation identifier | +| `systemPrompt` | string | - | System prompt to configure assistant behavior | +| `model` | string | - | AI model identifier (e.g., `gpt-4o`) | +| `streamingEnabled` | boolean | `true` | Enable SSE streaming for AI responses | +| `headers` | object | - | Additional headers for API requests | +| `requestBody` | object | - | Additional body parameters sent with each API request. Authored on the node as `requestBody`; the renderer forwards it to the chat runtime under its own `body` option. Writing `body` on a `chatbot` node instead is **refused by name** - that key is retired there (objectui#8572) and the refusal names `requestBody` as the replacement. The refusal is delivered when the document is parsed (`objectui validate`) or type-checked, not while it renders. `objectui check` does not deliver it: a file whose root carries a structural key (`body` is one, `children` another) is recognised by that key and never parsed against the schema, so `check` counts it as recognised but not validated and exits 0; `objectui validate` delivers the refusal | +| `surface` | `'card' \| 'plain'` | `'card'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`). Controls whether the chat renders as a bordered panel (`'card'`) or a frameless full-page workspace (`'plain'`). `chatbot` renders the plain chat component, which has no such chrome to switch, and does not read this key. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; its panel is a `ChatbotEnhanced`, and an authored value used to reach that panel through the registration's unfiltered props spread, an accidental channel objectui#7708 fenced - it is dark there now, so switch the node to `chatbot-enhanced` if you need the frameless chrome | +| `processVisibility` | `'hidden' \| 'summary' \| 'debug'` | `'summary'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`; `ChatbotSchema` still declares it too, though the `chatbot` registration has no read for it). Controls how much agent reasoning and tool detail is shown. `chatbot` renders the plain chat component, which has no agent-process display to configure at all - switch the node to `chatbot-enhanced` if you need one. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; an authored value used to reach its panel through the registration's unfiltered props spread, an accidental channel objectui#7708 fenced - it is dark there now, and there is no floating-side substitute to author | +| `onError` | function | - | Error callback for streaming/API errors | + +**There are no `loading`, `showAvatars`, `userAvatar`, `assistantAvatar`, +`markdown` or `height` keys.** All six were declared on `ChatbotSchema`, mirrored +on its Zod twin, and read by no registration - a `showAvatars: true` or a +`height: 400` on a `chatbot` node type-checked, parsed green, and changed +nothing. objectui#7703 retired them (ADR-0049 enforce-or-remove, one decision per +key): each is a `never` tombstone on the declaration and a named refusal on the +twin, so writing one is now a compile error **and** a parse error that says what +to write instead. Four have a live replacement, and it is the key the +registrations already read: + +| retired | write instead | +|---------|---------------| +| `userAvatar` | `userAvatarUrl` (with `userAvatarFallback`) | +| `assistantAvatar` | `assistantAvatarUrl` (with `assistantAvatarFallback`) | +| `height` | `maxHeight` on `chatbot` / `chatbot-enhanced`; `floatingConfig.panelHeight` on `chatbot-floating` | +| `markdown` | `enableMarkdown`, on a `chatbot-enhanced` or `chatbot-floating` node | +| `loading` | nothing - chat progress is runtime state the chat runtime owns, never authored | +| `showAvatars` | nothing - a `chatbot` node already renders an avatar beside every message; the images are the `*AvatarUrl` keys above | + +`showAvatars` is the one of the six that was not always inert: `ChatbotEnhanced` +has such a prop, and until objectui#7708 fenced it the `chatbot-floating` +registration's unfiltered props spread delivered an authored value to it. It is +dark on all three registrations now. The other five were live on no path at any +time. + +**There is no `maxToolRoundtrips` key: tool-loop caps live on the agent, as +`planning.maxIterations`.** A chat node cannot honour a cap because the tool loop +runs on the server agent, so objectui#5605 retired the key on all three chat +nodes: writing it is a compile error and a parse error that names +`planning.maxIterations`. + +### `chatbot-floating` panel and trigger keys + +The six keys below are declared in the `chatbot-floating` registration's own +`inputs` (`packages/plugin-chatbot/src/renderer.tsx`). They configure the +floating action button and the panel it opens; the `chatbot` and +`chatbot-enhanced` registrations render neither and ignore them. `floatingConfig` +is declared on `ChatbotFloatingSchema` only. It used to be declared on +`ChatbotSchema` as well, so authoring it on a `chatbot` node type-checked and +parsed, and was then dropped at render time, because the `chatbot` node never +read it. objectui#6152 retired it there: writing `floatingConfig` on a `chatbot` +node is now a compile error **and** a parse error that names `chatbot-floating`, +the node type that reads it. `ChatbotEnhancedSchema` never declared it. + +**There is no `displayMode` key.** The presentation is selected by the node's +own `type`: author a `chatbot-floating` node for the trigger-and-panel +presentation, and a `chatbot` or `chatbot-enhanced` node for an inline one. +`displayMode` (`'inline' | 'floating'`) used to be declared on both faces, +offered as a **Display Mode** control in the designer and seeded as +`'floating'` into every node the designer created - and read by nothing: it was +a second spelling of the choice `type` already makes, so `'inline'` on a +`chatbot-floating` node changed nothing and `'floating'` on a `chatbot` node +produced no trigger. objectui#7654 retired it (maintainer ruling, 2026-09-05): +the declaration is a `never` tombstone on `ChatbotSchema` and +`ChatbotFloatingSchema`, so writing the key against either face is now a +compile error, and the designer control and default are gone. Stored documents +that still carry the key parse exactly as they did - it never had a Zod arm and +the twins are passthrough - and the value is ignored at render time, as it +always was. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `floatingConfig.position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | Corner the trigger sits in; the panel is anchored to the same side | +| `floatingConfig.defaultOpen` | boolean | `false` | Whether the panel is already open when the node mounts | +| `floatingConfig.panelWidth` | number | `400` | Panel width in pixels, applied from the `sm` breakpoint up - below it the panel is full-bleed. Snapped to a step, see below | +| `floatingConfig.panelHeight` | number | `520` | Panel height in **pixels, as a number** - not a CSS length string. This is the key that sizes a floating chatbot: `maxHeight` is a string, belongs to the two inline registrations, and is not read here. Snapped to a step, see below | +| `floatingConfig.title` | string | `'Chat'` | Text in the panel header, and the panel's `aria-label` | +| `floatingConfig.triggerSize` | number | `56` | Diameter of the floating action button in pixels. Snapped to a step, see below | + +**The three size keys snap to the nearest declared step.** `FloatingChatbotPanel` +and `FloatingChatbotTrigger` resolve each number through a fixed table of +Tailwind classes and fall back to the closest entry, so a number outside the +table does not render at that size - a `panelHeight` of `530` renders at `520`: + +- `panelWidth` - 300, 320, 340, 360, 380, 400, 420, 440, 450, 460, 480, 500, 520, 560, 600, 640, 720, 800 +- `panelHeight` - 360, 400, 420, 440, 480, 500, 520, 560, 600, 640, 720, 800 +- `triggerSize` - 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80 + +On small screens `panelHeight` is additionally capped to the viewport +(`min(step, 100svh - 6rem - safe-area-inset-bottom)`), and while the panel is +fullscreen it ignores both size keys and fills the screen. + +Authored on the node, with the node's own type - `ChatbotFloatingSchema` pins +`type` to `'chatbot-floating'` and declares `floatingConfig`, so this fence +compiles against the published types. (Until objectui#7655 no type could +annotate a floating node - `ChatbotSchema` pins `type` to `'chatbot'` - and +this example had to be untyped JSON.) + +```tsx +import type { ChatbotFloatingSchema } from '@object-ui/types'; + +const supportChat: ChatbotFloatingSchema = { + type: 'chatbot-floating', + messages: [], // seed with your own ChatMessage values + floatingConfig: { + position: 'bottom-left', + defaultOpen: false, + panelWidth: 400, + panelHeight: 520, + title: 'Support', + triggerSize: 56, + }, + placeholder: 'Ask us anything...', +}; +``` + +The config object also has its own exported type: + +```tsx +import type { FloatingChatbotConfig } from '@object-ui/types'; + +const floatingConfig: FloatingChatbotConfig = { + position: 'bottom-left', + panelWidth: 400, + panelHeight: 520, // pixels, as a number - '520px' does not type-check + title: 'Support', + triggerSize: 56, +}; +``` + +## Operating Modes + +The chatbot supports two modes, automatically selected based on the `api` field: + +### Local/Demo Mode (default) + +When `api` is not set, the chatbot operates in local mode with optional auto-response: + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + messages: [], // seed with your own ChatMessage values + autoResponse: true, + autoResponseText: 'Thanks!', + autoResponseDelayMs: 1000, +}; +``` + +### AI Streaming Mode (service-ai) + +When `api` is set, the chatbot uses `@ai-sdk/react` for real SSE streaming: + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + api: '/api/v1/ai/chat', + model: 'gpt-4o', + systemPrompt: 'You are a helpful assistant.', + streamingEnabled: true, + messages: [], +}; +``` + +## Message Kinds + +### User Messages + +Messages from the user appear on the right side with primary styling: + +```tsx +import type { ChatMessage } from '@object-ui/types'; + +const userMessage: ChatMessage = { + id: '1', + role: 'user', + content: 'Hello!', + timestamp: '10:30 AM', +}; +``` + +### Assistant Messages + +Messages from the assistant appear on the left side: + +```tsx +import type { ChatMessage } from '@object-ui/types'; + +const assistantMessage: ChatMessage = { + id: '2', + role: 'assistant', + content: 'Hi! How can I help?', + timestamp: '10:30 AM', +}; +``` + +### System Messages + +System messages appear centered with muted styling: + +```tsx +import type { ChatMessage } from '@object-ui/types'; + +const systemMessage: ChatMessage = { + id: '3', + role: 'system', + content: 'Chat session started', +}; +``` + +### Tool Messages (AI Mode) + +Tool messages represent results from tool invocations during AI streaming. They are generated automatically by the vercel/ai SDK when the backend performs tool calls (e.g., fetching weather, querying a database). The SDK may emit tool messages as well as populate the assistant message's `toolInvocations` array. + +By default, `ChatbotEnhanced` renders tool invocations as a compact agent activity summary. Repeated calls are grouped, raw tool names are hidden, and reasoning text is not shown. Use `processVisibility="debug"` for developer/admin views that need the full reasoning panel, raw tool names, parameters, and results. Use `processVisibility="hidden"` to suppress non-interactive activity entirely; approval and draft-review actions remain visible. + +Each step is named by the locale pack's `chatbot.tool.` entry, a +short sentence in the user's language ("List the existing objects"). A tool +the pack has no entry for falls back to its name, title-cased. + +The AI build conversation's two cards draw only what the build agent's tool +results declare. The plan card (`propose_blueprint`) reads the result's +`planCard`: a chip per field with the format its sample data is held to, a +dashed chip for a field the agent added on its own, the record's name field, +one line per view, the assumptions, the sample data the build will stage, and +how the build will land (`posture`: live at once, or saved as a draft). +The result card (`apply_blueprint`, then a later `verify_build` in the same +turn) reads `checklist`: a passed/total chip, the verified lines in two +columns, and the app and package that "Open app" and "View in Studio" open. +A result without those keys keeps the simpler rows the cards drew before. + +The change card (a `changes_proposed` result) draws what the copilot's tools +say about how a change will land. When the result carries a `posture`, the +card is headed "Change plan · N changes" and draws one row per change: a +symbol for add, change or remove, the kind (field, view, object), the +`object · field` it touches, and its type and parameters in small type. A +change to an app that is live and holds records (`posture.mode: 'draft'`) is +saved as a Studio draft: the card's note says so in amber, and the primary +button reads "Save as draft" when `primaryAction` is `save_draft` (the message +it sends is still `changesConfirmMessage`). Once applied, the card shows the +result's `appliedCard` title and lines, "Review changes and publish" through +`onReviewDrafts(packageId)`, and "Undo" through `onDiscardDrafts(packageId)`. +Undo is meant for `POST /api/v1/packages/:id/discard-drafts`, which discards +every pending draft of the package, so the card asks first whenever other +drafts are pending: it reads `draftState.others` from the result, and the live +count from `fetchPendingDraftCount` when the host provides one. A host without +`onReviewDrafts` keeps the inline Publish button; a result without these keys +draws the card as before. + +Use `surface="plain"` for full-page chat workspaces where the surrounding app +already provides navigation chrome. The default `surface="card"` remains a +better fit for embedded dashboards, side panels, and floating chat windows. + +Both keys are authorable as metadata on a `chatbot-enhanced` node, typed with +that node's own face (objectui#7655): + +```tsx +import type { ChatbotEnhancedSchema } from '@object-ui/types'; + +const workspace: ChatbotEnhancedSchema = { + type: 'chatbot-enhanced', + messages: [], // seed with your own ChatMessage values + api: '/api/v1/ai/chat', + surface: 'plain', + processVisibility: 'debug', + enableFileUpload: true, +}; +``` + +Console chat surfaces also keep a sanitized browser-side display cache for the +current conversation. When a conversation can be reopened but the server returns +no message rows, the UI restores user/assistant text and grouped tool names plus +states. Reasoning, tool parameters, and raw tool results are not stored in this +cache. + +```tsx +import type { ChatMessage } from '@object-ui/types'; + +const toolMessage: ChatMessage = { + id: '4', + role: 'assistant', + content: 'The weather in SF is 68°F.', + toolInvocations: [ + { + toolCallId: 'tc-1', + toolName: 'getWeather', + args: { city: 'San Francisco' }, + result: { temp: 68, condition: 'Sunny' }, + state: 'result', + }, + ], +}; +``` + +## Examples + +### Simple AI Chat + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const aiChat: ChatbotSchema = { + type: 'chatbot', + messages: [ + { + id: 'welcome', + role: 'assistant', + content: 'Hello! I\'m your AI assistant. Ask me anything!' + } + ], + placeholder: 'Ask me a question...', + assistantAvatarFallback: 'AI', + userAvatarFallback: 'You', + autoResponse: true, + autoResponseText: 'That\'s a great question! Let me think about that...', + maxHeight: '600px' +} +``` + +### Support Ticket Chat + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const supportChat: ChatbotSchema = { + type: 'chatbot', + messages: [ + { + id: 'sys-1', + role: 'system', + content: 'Ticket #12345 - Account Access Issue' + }, + { + id: '1', + role: 'assistant', + content: 'Hi! I\'m here to help with your account access issue.', + avatarFallback: 'SP', + timestamp: '2:15 PM' + } + ], + placeholder: 'Describe your issue...', + showTimestamp: true, + userAvatarFallback: 'JD', + assistantAvatarFallback: 'SP', + className: 'w-full max-w-3xl mx-auto' +}; +``` + +### Sales Bot + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const salesBot: ChatbotSchema = { + type: 'chatbot', + messages: [ + { + id: '1', + role: 'assistant', + content: 'Welcome! I\'m here to help you find the perfect product. What are you looking for today?', + avatarFallback: 'SB' + } + ], + placeholder: 'Tell us what you need...', + assistantAvatarFallback: 'SB', + userAvatarFallback: 'You', + autoResponse: true, + autoResponseText: 'Great choice! Let me show you some options...', + autoResponseDelayMs: 1200, + maxHeight: '500px', + className: 'border-2 border-primary rounded-xl' +}; +``` + +### Multi-agent Chat + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const multiAgentChat: ChatbotSchema = { + type: 'chatbot', + messages: [ + { + id: '1', + role: 'assistant', + content: 'Hello! Sarah from Sales here.', + avatarFallback: 'SA' + }, + { + id: '2', + role: 'user', + content: 'I need help with pricing', + avatarFallback: 'CU' + }, + { + id: '3', + role: 'system', + content: 'Transferring to Finance team...' + }, + { + id: '4', + role: 'assistant', + content: 'Hi! Mike from Finance. I can help with that.', + avatarFallback: 'MI' + } + ], + showTimestamp: true, + userAvatarFallback: 'CU' +}; +``` + +## Custom Avatars + +### Avatar Images + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + userAvatarUrl: 'https://example.com/user-avatar.jpg', + assistantAvatarUrl: 'https://example.com/bot-avatar.jpg', + messages: [], // seed with your own ChatMessage values +}; +``` + +### Avatar Fallbacks + +When images aren't available, fallback text is displayed: + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + userAvatarFallback: 'JD', // User initials + assistantAvatarFallback: 'AI', // Bot identifier + messages: [], // seed with your own ChatMessage values +}; +``` + +### Per-message Avatars + +Override avatars for individual messages: + +```tsx +import type { ChatbotEnhancedMessage } from '@object-ui/plugin-chatbot'; + +const message: ChatbotEnhancedMessage = { + id: '1', + role: 'assistant', + content: 'Message content', + avatar: 'https://example.com/special-avatar.jpg', + avatarFallback: 'SP', +}; +``` + +## Event Handling + +### onSend Callback + +Handle message sending in your application: + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + messages: [], // seed with your own ChatMessage values + onSend: (content, allMessages) => { + console.log('User sent:', content); + console.log('All messages:', allMessages); + + // Send to your backend + fetch('/api/chat', { + method: 'POST', + body: JSON.stringify({ message: content }), + }); + }, +}; +``` + +## Customization + +### Container Styling + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + className: 'w-full max-w-2xl mx-auto border-2 rounded-xl shadow-lg', + maxHeight: '600px', + messages: [], // seed with your own ChatMessage values +}; +``` + +### Responsive Heights + +```tsx +import type { ChatbotSchema } from '@object-ui/types'; + +const schema: ChatbotSchema = { + type: 'chatbot', + maxHeight: '400px', // or use Tailwind: 'h-96' + className: 'sm:max-h-[500px] lg:max-h-[600px]', + messages: [], // seed with your own ChatMessage values +}; +``` + +## TypeScript Support + +Each of the three registrations has its own authoring-face type: +`ChatbotSchema` (`chatbot`), `ChatbotEnhancedSchema` (`chatbot-enhanced`) and +`ChatbotFloatingSchema` (`chatbot-floating`) - see the typed examples under +**Tool Messages** and **`chatbot-floating` panel and trigger keys** above. + +```ts +import type { ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessage, ChatToolInvocation } from '@object-ui/types' +import { useObjectChat } from '@object-ui/plugin-chatbot' + +// Basic messages +const messages: ChatMessage[] = [ + { + id: '1', + role: 'assistant', + content: 'Hello!' + } +] + +// Local/demo mode +const demoSchema: ChatbotSchema = { + type: 'chatbot', + messages, + placeholder: 'Type here...', + showTimestamp: true +} + +// AI streaming mode (service-ai) +const aiSchema: ChatbotSchema = { + type: 'chatbot', + messages: [], + api: '/api/v1/ai/chat', + model: 'gpt-4o', + systemPrompt: 'You are a helpful assistant.', + streamingEnabled: true, + conversationId: 'conv-123', +} +``` + +### What comes back out: `ObjectChatMessage` + +You AUTHOR with `@object-ui/types`' `ChatMessage`. What `useObjectChat` HANDS +BACK — from `messages` and from `onSend(content, messages)` — is +`ObjectChatMessage`, exported from `@object-ui/plugin-chatbot`: + +```ts +import type { ObjectChatMessage } from '@object-ui/plugin-chatbot' +``` + +It is the authoring shape plus the render-only keys API mode really carries +(`buildProgress`, `blueprintProgress`, `charts`, and `pendingActionId` / +`draftReview` / `proposedPlan` / `proposedChanges` / `builderHandoff` on each +tool invocation — the approval card, the "Review N changes" affordance, the plan +card, the build panel, the inline charts), with `timestamp` narrowed to `string` +because both modes absorb an authored `Date` before emitting. + +In API mode its tool invocations can also carry the AI SDK's three approval +states — `approval-requested`, `approval-responded` and `output-denied`. Those +are runtime-only: the authoring `ChatToolInvocation` refuses them, with or +without an `approval` envelope (objectui#10018). So `ObjectChatMessage` is +**not** a subtype of the authoring `ChatMessage`, and an `onSend` callback that +declares its parameter as `ChatMessage[]` does not type-check; declare +`ObjectChatMessage[]`, which is also what lets it read those keys. Rebuilding a +message field-by-field from the authoring type drops every one of them — +silently, and with the compiler's agreement (objectui#4424). + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-chatbot) diff --git a/content/docs/frontend/plugins/plugin-dashboard.mdx b/content/docs/frontend/plugins/plugin-dashboard.mdx new file mode 100644 index 00000000000..8d1ecf88130 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-dashboard.mdx @@ -0,0 +1,218 @@ +--- +title: "Plugin Dashboard" +--- + +Dashboard layouts and metric widgets for creating beautiful dashboards with KPIs, charts, and statistics. + +## Installation + +```bash +npm install @object-ui/plugin-dashboard +``` + +The props the [`dashboard`](/docs/ui/components/plugins#dashboard), [`object-metric`](/docs/ui/components/plugins#object-metric), [`object-pivot`](/docs/ui/components/plugins#object-pivot) nodes read on a page are generated from the spec into the component reference; this page covers using the plugin from React. Dashboard filters, widget `options` and drill-down are documented with dashboard metadata under [Dashboards](/docs/ui/dashboards). + +## Features + +- **Dashboard Layouts** - Responsive grid-based layouts +- **Metric Cards** - Display KPIs with trends and icons +- **Widget System** - Modular widget architecture +- **Customizable** - Full Tailwind CSS styling support + +## Usage + +### Auto-registration (Side-effect Import) + +```ts +import '@object-ui/plugin-dashboard'; +``` + +### What the side-effect import registers + +That single import is the whole of registration — there is no components map to +iterate over. Importing the entry runs the seven live `ComponentRegistry.register(...)` +calls in `packages/plugin-dashboard/src/index.tsx`, which claim exactly these +schema types. The keys below are read off those calls. + +| Namespaced key | Bare-name fallback | Renderer behind it | +| --- | --- | --- | +| `plugin-dashboard:dashboard` | `dashboard` | `DashboardRenderer` — the widget container | +| `plugin-dashboard:metric` | none — `skipFallback: true` | `MetricWidget` — one KPI value | +| `plugin-dashboard:metric-card` | none — `skipFallback: true` | `MetricCard` — KPI with trend and icon | +| `plugin-dashboard:object-metric` | `object-metric` | internal wrapper around `ObjectMetricWidget` — a metric aggregated over an object | +| `plugin-dashboard:pivot` | `pivot` | `PivotTable` — pivot over rows you pass in | +| `plugin-dashboard:object-pivot` | `object-pivot` | `ObjectPivotTable` — a pivot queried from an object; its props go in `properties` | +| `plugin-dashboard:object-data-table` | `object-data-table` | `ObjectDataTable` — table queried from an object | + +`ComponentRegistry.register` publishes `namespace:type`, and — unless the call +passes `skipFallback: true` — the bare `type` as a back-compat fallback +(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). Every +call behind the table above leaves `skipFallback` unset except `metric` and +`metric-card`, so each other type there resolves under both spellings; the schema +snippets on this page use the bare one. + +`metric` and `metric-card` are two vocabularies that share a spelling: the +dashboard WIDGET types (`widgets[].type: 'metric'`, and the `metric-card` slot +entry above) and the internal NODE keys the dashboard renders them through. The +widget types are unchanged. The node keys register with `skipFallback: true` +(objectui#10859 batch 8), and both dashboard surfaces emit the namespaced keys, so +only `plugin-dashboard:metric` / `plugin-dashboard:metric-card` resolve as nodes. + +`dashboard-grid` is no longer registered (objectui#10859 batch 8: builder chrome, +with no producer and no runtime emitter). `DashboardGridLayout` is still exported; +a host that wants the drag/resize grid mounts the component directly. + +⛔ One further registration is deliberately absent from the table above. Until +objectui#9533 the dashboard renderer was published as `view:dashboard`, while +`apps/console`'s lazy stubs and the CLI's known-type whitelist already spelled it +`plugin-dashboard:dashboard`; the bare `dashboard` key therefore declared one +namespace before the chunk loaded and the other after it, and the +`plugin-dashboard:dashboard` stub was never cleared, so that spelling could never +resolve. The renderer now registers under `plugin-dashboard`, and the retired +`view:dashboard` key is answered by a tombstone widget that refuses BY NAME and +names `plugin-dashboard:dashboard` as its replacement — so an authored +`view:dashboard` gets a visible refusal carrying its own migration, never a +silent fall-through. That tombstone registration passes `skipFallback: true`, so +it claims no bare key, and `view:dashboard` is deliberately NOT a renderable key: +`objectui check` reports it as unknown. + +The two `object-*` rows name a wrapper rather than an export on purpose. +`ObjectMetricBlock` and `ObjectPivotBlock` are internal to `src/index.tsx`: each +first resolves the element's `dataSource` binding through `ElementDataSourceGate` +(from `@object-ui/react`) and only then renders the exported +`ObjectMetricWidget` / `ObjectPivotTable`. + +### Registering a component under your own key + +The import above needs no follow-up call. What a manual registration is for here +is serving one of this package's exported components under a key of your own — +you register the component, not a map: + +```ts +import { ComponentRegistry } from '@object-ui/core'; +import { MetricCard } from '@object-ui/plugin-dashboard'; + +ComponentRegistry.register('my-metric', MetricCard, { + namespace: 'my-app', + label: 'My Metric', + category: 'Dashboard', +}); +``` + +The package also exports a `dashboardComponents` object: the manual-integration +map, keyed by the **same seven schema types** as the table above (objectui#5064 +re-keyed it from component class names; since objectui#10859 batch 8 the `metric` +and `metric-card` entries are keyed `plugin-dashboard:metric` / +`plugin-dashboard:metric-card`, the type each serves, so iterating the map cannot +re-create their retired bare keys). Each key maps to the exact component +the import registers for that type — for the two `object-*` types that is the +internal data-source-gate wrapper, not the exported widget. Iterating it with +`ComponentRegistry.register(type, component)` therefore re-registers the seven +types the import has already claimed, which is still not the registration above: +each such call passes no `meta`, so it trips the no-namespace deprecation +warning in `register` (`packages/core/src/registry/Registry.ts:198`) and +rewrites each bare-name registry entry without its `label`/`category` metadata +(the `namespace:type` entries are untouched). + +## Translations — a served dashboard is translated once + +Widget texts have a client translation channel: bundle keys under +`dashboards..widgets.` (`title`, `description`), plus +`dashboards..label` and `.description`. There is no `subCaption` key: +the translation schema in `@objectstack/spec` refuses it by name in a bundle, +and the renderer reads no such key (objectui#11389). A document read from +ObjectStack's `/meta` route does not need it. The server has already applied +those keys for the request's locale, and it keeps a published edit over them: +an explicit override beats the packaged default. + +So the host tells the renderer which kind of document it holds. The `localized` +prop on `DashboardRenderer` says the document was served, and the console's +dashboard page sets it. Those texts are then drawn as served. Without the prop, +the client bundle translates them. That covers an inline `dashboard` block, a +preview or a design surface (objectui#11295). + +## TypeScript Support + +```ts +import type { + DashboardComponentSchema, + DashboardWidgetSchema, + DashboardWidgetSlotComponentSchema, +} from '@object-ui/types'; + +const widget: DashboardWidgetSchema = { + id: 'revenue', + title: 'Revenue', + type: 'metric', + dataset: 'invoices', + values: ['amount'], + layout: { x: 0, y: 0, w: 1, h: 1 }, +}; + +const card: DashboardWidgetSlotComponentSchema = { + id: 'users', + type: 'metric-card', + title: 'Users', + value: '1,234', + layout: { x: 1, y: 0, w: 1, h: 1 }, +}; + +const dashboard: DashboardComponentSchema = { + type: 'dashboard', + columns: 3, + widgets: [widget, card], +}; +``` + +An entry of `widgets[]` is either a widget (`DashboardWidgetSchema`) or a +component node placed directly in the slot (`DashboardWidgetSlotComponentSchema`). +The component arm declares the card's registered inputs — the keys above. The +widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the widget +arm, which takes them from the spec's `DashboardWidget` row, and on no member of +the component arm. An entry's own type is the slot's element type, +`DashboardComponentSchema['widgets'][number]`. The component arm is not +assignable to `DashboardWidgetSchema`, whose `type` names no component type, so +narrow an entry on `type` before reading a widget key. `metric-card` is the one +component type the slot holds; any other entry is the widget arm, the compiler +checks the narrowing, and the key gets its declared type instead of the `any` +the component arm's passthrough supplies: + +```ts +import type { DashboardComponentSchema } from '@object-ui/types'; + +declare const dashboard: DashboardComponentSchema; + +const accents = dashboard.widgets.map((w) => + w.type === 'metric-card' ? 'default' : (w.colorVariant ?? 'default'), +); +``` + +`title` and `layout` are the exceptions: both arms declare them. `title` is the +card's heading on the component arm, typed as `MetricCard` reads it. The +component arm declares `layout` by reference to the spec's widget `layout`, +because the editable grid's Save Layout writes it onto every entry, a +`metric-card` node included. `layout` reads with the spec's type off any +`widgets[]` entry, the strict authoring face accepts it on a component +node, and a malformed one (not four numbers, or a key besides `x`, `y`, `w` and +`h`) is refused on either arm. + +A widget with no `layout` is auto-placed by the grid. The width and height +editors (Studio's widget inspector, `DashboardWithConfig` and +`DashboardEditor`) write the whole box: the coordinates an edit leaves alone +come from where the grid auto-places that widget, through `completeWidgetLayout` +and `defaultWidgetPlacement` from `@object-ui/types`. So changing one dimension +never stores a `{ w }` or `{ h }` box, which the spec refuses. + +## Type-aware list/table widget cells + +`type: 'table'` widgets bound to an `objectName` infer the renderer for +each cell from the object's field type — Badges for `select`, expanded +display name for `lookup`/`user`/`owner`, formatted currency/percent/date +for numeric fields, and so on. Lookup columns are auto-expanded server-side +via `$expand`, so you don't see raw FK ids. See +[`@object-ui/plugin-dashboard` README](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/plugin-dashboard/README.md#type-aware-listtable-widget-cells) +for the full mapping table and an example. + +## License + +MIT diff --git a/content/docs/frontend/plugins/plugin-detail.mdx b/content/docs/frontend/plugins/plugin-detail.mdx new file mode 100644 index 00000000000..048fb951d6b --- /dev/null +++ b/content/docs/frontend/plugins/plugin-detail.mdx @@ -0,0 +1,604 @@ +--- +title: "Plugin Detail — Feed & Chatter" +--- + +Record-level discussion, activity timeline, and collaboration components for enterprise CRUD interfaces. + +## Installation + +```bash +npm install @object-ui/plugin-detail +``` + +## Overview + +The Feed & Chatter system provides Airtable-style record-level collaboration. It includes components for threaded discussions, field change history, @mentions, emoji reactions, and notification subscriptions — all driven by `FeedItem[]` data. + +### Key Components + +| Component | Purpose | +|-----------|---------| +| `RecordChatterPanel` | Main wrapper panel (bottom/right/left positioning, collapsible) | +| `RecordActivityTimeline` | Core activity feed renderer (filter, pagination, actor display) | +| `FieldChangeItem` | Field change history display (old → new values) | +| `ReactionPicker` | Emoji reaction selector | +| `ThreadedReplies` | Collapsible reply threading | +| `SubscriptionToggle` | Bell notification toggle | + +## Usage + +### Basic RecordChatterPanel + +```tsx +import { RecordChatterPanel } from '@object-ui/plugin-detail'; +import type { FeedItem, RecordSubscription } from '@object-ui/types'; + +const items: FeedItem[] = [ + { + id: '1', + type: 'comment', + actor: 'Alice', + body: 'Updated the deal stage to Negotiation.', + createdAt: '2026-02-25T10:00:00Z', + reactions: [{ emoji: '👍', count: 2, reacted: true }], + }, + { + id: '2', + type: 'field_change', + actor: 'System', + createdAt: '2026-02-24T15:30:00Z', + fieldChanges: [ + { field: 'stage', oldValue: 'Qualification', newValue: 'Negotiation' }, + ], + }, +]; + + console.log('Comment:', text)} + onAddReply={(parentId, text) => console.log('Reply:', parentId, text)} + onToggleReaction={(itemId, emoji) => console.log('Reaction:', itemId, emoji)} +/> +``` + +### RecordActivityTimeline + +```tsx +import { RecordActivityTimeline } from '@object-ui/plugin-detail'; +import type { FeedItem } from '@object-ui/types'; + +declare const items: FeedItem[]; + + console.log('Filter:', mode)} + hasMore={false} + loading={false} +/> +``` + +### Console Integration + +In the Console app, `RecordChatterPanel` is integrated with record detail views. Comments are fetched from the `sys_comment` object using a thread ID pattern of `{objectName}:{recordId}`: + +```tsx +import type { DataSource, FeedItem } from '@object-ui/types'; + +// The record being viewed, and the host's data source. +declare const objectName: string; +declare const recordId: string; +declare const dataSource: DataSource; + +// Thread ID format +const threadId = `${objectName}:${recordId}`; + +// Fetch comments — `find` resolves to a `QueryResult`, so the rows are `.data`. +const comments = await dataSource.find('sys_comment', { + $filter: { threadId }, + $orderby: 'createdAt asc', +}); + +// Map to FeedItem[] +const feedItems: FeedItem[] = comments.data.map((c) => ({ + id: c._id, + type: 'comment', + actor: c.author, + body: c.body, + createdAt: c.createdAt, + reactions: c.reactions ?? [], +})); +``` + +**Full-page detail view** renders the panel non-collapsible at the bottom: + +```tsx +import { RecordChatterPanel } from '@object-ui/plugin-detail'; +import type { FeedItem } from '@object-ui/types'; + +declare const feedItems: FeedItem[]; +declare const handleAddComment: (text: string) => void; + + +``` + +**Drawer overlay** renders the panel collapsible and default-collapsed: + +```tsx +import { RecordChatterPanel } from '@object-ui/plugin-detail'; +import type { FeedItem } from '@object-ui/types'; + +declare const feedItems: FeedItem[]; +declare const handleAddComment: (text: string) => void; + + +``` + +## Features + +- **Threaded Discussions**: Nested comment replies with collapsible threading +- **Activity Timeline**: Unified view of comments, field changes, tasks, and system events +- **Filter Modes**: Filter by `all`, `comments_only`, `changes_only`, or `tasks_only` +- **@Mentions**: Autocomplete dropdown for mentioning users +- **Emoji Reactions**: Toggle emoji reactions on comments. A reaction is stored on the comment (`sys_comment`), so only a `comment` row offers the reaction control +- **Subscription Toggle**: Bell icon to subscribe/unsubscribe from record notifications +- **Field Change History**: Visual old → new value display for field changes +- **Pagination**: Load more items with `hasMore` / `onLoadMore` pattern +- **Positioning**: Panel can be placed at `bottom`, `right`, or `left` of the record view +- **Collapsible**: Optional collapse/expand with configurable default state + +## The `record:activity` block + +`RecordActivityTimeline` is a presenter: you hand it `items`. The SDUI block +`record:activity` is the schema-authored form of the same panel, and it finds +its own feed. Drop it anywhere inside a record page; its inputs go in the node's +`properties` bag, as `@objectstack/spec` declares them (the strict page component +refuses them written flat on the node): + +```json +{ + "type": "page:tabs", + "properties": { + "items": [ + { + "label": "Activity", + "value": "activity", + "children": [ + { "type": "record:activity", "properties": { "limit": 20, "showCompleted": false } } + ] + } + ] + } +} +``` + +### Where the feed comes from + +Three sources, in precedence order: + +1. **`items` on the node** — a host that already owns the feed passes it in + (the same convention `record:history` uses for `entries`). The host does + this in code, on a node it composes and renders. `items` and its paired + `loading` flag are not schema keys: a JSON document that writes either one, + on the node or in `properties`, is refused, and on the node the refusal + names it as the host's feed slot. +2. **A mounted discussion context** — the console's record page wraps the + record body in one, carrying the merged `sys_comment` + `sys_activity` feed + *and* the comment / reply / reaction handlers. When it is present the block + presents that feed instead of fetching a second, poorer copy. The record page + hands each handler over only where the caller may make that write + (objectui#12082): the composer and the reply input need the **create** grant + on `sys_comment`; the reaction control needs the **create** grant on + `sys_comment_reaction`, and taking the caller's own reaction back needs the + same grant: it goes through the unreact door + (`DELETE /api/v1/comments/reactions/:id`), which removes only the caller's + own row and asks no **delete** grant. On a + deployment with no `sys_comment_reaction` the control needs the **update** + grant on `sys_comment`. These are the `feedComment`, `feedReactionAdd`, + `feedReactionRemove` and `feedReactionColumn` rows of the affordance-to-grant + map in `@object-ui/core`, which also read each object's policy and effective + API operation set. With no permission provider mounted they read open. +3. **Self-fetch** — otherwise the block reads `sys_activity` scoped to the + record it is bound to (`object_name` + `record_id`), newest first, `limit` + rows per page. This is what makes it work on a hand-authored record page + with no host feeding it. + +### When the read is refused + +A refused read is not an empty feed. When the server refuses the feed's read +(401, 403, or a permission envelope), the panel shows a no-permission state, a +lock and "You don't have permission to view activity on this record.", in place +of "No activity recorded". If some rows did load (the comments were readable and +the activity was not, or the other way round), the rows stay and the notice +above them names the half that is withheld. The console's record page applies +the same rule to both of its reads, `sys_activity` and `sys_comment`. + +Everything else keeps the empty state: a read that answers with zero rows, a 404 +(`sys_activity` on a deployment without the audit plugin), and any other +failure, such as a 500. A refusal is not retried. `isRefusedFeedRead`, exported +from this package, is the verdict both surfaces use. + +`sys_activity` rows map to feed items like this: +`created` / `updated` / `deleted` / `assigned` / `shared` → `field_change`, +`completed` → `task`, `scheduled` → `event`, `system` → `system`. +`commented` / `mentioned` are skipped (their content lives in `sys_comment`), +and `login` / `logout` are account events, not record activity. + +`scheduled` and `completed` are the two branches the same producer writes for a +meeting, and both show by default: a not-yet-held meeting is `scheduled` → +`event`, because an upcoming meeting is the part of a timeline you still act on; +a held meeting is `completed` → `task`, and shows because it is a logged +interaction (see below). + +A milestone is the exception to the `completed` rule. When an object's +`activityMilestones` entry fires, plugin-audit writes the row with the type the +declaration names and stamps `metadata.kind: 'milestone'` on it. A milestone is +not a completed activity, so a `completed` row with that marker renders as a +`system` item and shows whatever `showCompleted` says. That covers "Case +resolved" on a case, and a task's own "Task completed" on that task's page. A +`completed` row without the marker is still a `task` item. It is hidden by +default unless it is a logged interaction: a call, meeting or email an app logs +on the record writes the record it logged first (an event, a `sys_email` row) +and points at it from the activity row through `source_object` / `source_id` +(ADR-0052). A `task` item that carries that pointer shows whatever +`showCompleted` says, and it is the same item the timeline gives a **View +source** link. It stays a `task` item, so `types: ['task']` and +`filterMode: 'tasks_only'` still list it. A `completed` row with neither the +marker nor a pointer is hidden by default. That includes a finished task, every +row a runtime from before the marker serves, a row an app inserts itself +without a pointer, and a milestone row served to a reader who may not read a +field the milestone watches (the server leaves the marker off for that reader). + +A row whose `type` is in neither list is **not dropped**: it renders through the +generic `system` presentation and logs one `console.warn` naming the type. +`sys_activity.type` is author-extensible and is not validated on write, so a +value this map has never seen is real record activity a producer stored, not a +mistake to discard. The warning therefore reports a **missing decision**, not +lost data — the row is on the timeline; what it lacks is the icon and colour a +mapped type gets. Give it one by adding the type to `ACTIVITY_TYPE_TO_FEED_TYPE` +in `@object-ui/plugin-detail`. + +Each item names who acted. The row's `actor_name`, the name recorded when the +action happened, wins when it is present. A row without one is named by its +`actor_id`, a lookup to `sys_user`: the block's read expands it, so each user's +record comes back with the page in the same request and the item shows that +user's `name`. A row with neither, or whose user the viewer may not read, shows +"System". `record:history` names the entries of its own `sys_activity` read the +same way, and falls back to "Unknown user". + +The rows that really are dropped are the four the map lists as non-activity — +`commented` / `mentioned` / `login` / `logout` — and they go silently on +purpose: each is a decision already taken, and a warning about a decision +teaches authors to ignore the channel. + +Both surfaces map rows through the same `activityRowToFeedItem`: the console's +own record page imports it from this package rather than mapping the rows +itself, so everything above describes that page too. + +### Which inputs do what + +| Input | Effect | Needs | +|---|---|---| +| `types` | Allow-list of feed item types. Recognised entries narrow the feed; unrecognised ones are dropped and named in one `console.warn`. A list that keeps nothing — `types: []`, or one whose every entry is unrecognised — filters to **nothing** and the panel renders an empty timeline, rather than widening back to every kind. Omit `types` for no filter; that is the only value that means it. A recognised entry that **no producer emits** is honoured but named too — see [Feed kinds with no producer](#feed-kinds-with-no-producer). | — | +| `filterMode` | Which slice the filter dropdown opens on (`all` / `comments_only` / `changes_only` / `tasks_only`). The user can still change it. An unrecognised value falls back to `all`. | — | +| `showFilterToggle` | Shows/hides that dropdown. | — | +| `limit` | Items per page; also caps the scoped `sys_activity` read. "Load more" grows the window by this much and re-reads. Must be a positive integer **number**: any other value (a string such as `"5"`, a boolean, a fraction, zero or a negative) is ignored, the block uses its default of 20, and one `console.warn` names the value. The same rule applies to `feed.limit` on `record:chatter` / `record:discussion` and to `limit` on `record:history` (default 50). | — | +| `showCompleted` | Include completed activities (`sys_activity.type = 'completed'`, which surfaces as a `task` item). Off by default, per spec. A milestone row (`metadata.kind: 'milestone'`) is not a completed activity: it surfaces as a `system` item and shows either way. A logged interaction (a `task` item that points at its source record through `source_object` / `source_id`, such as a logged call, meeting or email) stays a `task` item and also shows either way. | — | +| `unifiedTimeline` | Default `true` mixes field changes and comments in one timeline (Airtable style). `false` un-mixes them: the panel becomes a discussion stream and field changes stay in `record:history`. | — | +| `showCommentInput` | Shows the composer. | A host discussion context to persist the comment. Without one the feed is read-only and no composer is rendered. | +| `enableMentions` | Offers @-mention autocomplete in the composer. `false` withholds the suggestions. | The host context's user list. | +| `enableReactions` | Shows emoji reactions on feed items. Existing reactions still render without a host; toggling one needs the host handler. Only a `comment` row offers Add reaction and clickable chips, because a reaction is stored on the comment (`sys_comment`). Any other row, such as a field change read from `sys_activity`, shows its reactions as chips that cannot be clicked. | A host `onToggleReaction`. | +| `enableThreading` | Groups replies under their parent comment. A reply whose parent the feed does not hold (deleted, or older than the loaded page) shows as a row of its own. Posting a reply needs the host handler. | A host `onAddReply`. | +| `showSubscriptionToggle` | **Not implemented.** | — | + +**Editing and deleting your own comment needs no input.** `sys_comment` is +`managedBy: 'platform'`, which already declares the generic Edit / Delete +affordances, so the feed offers them wherever its host does: on a `comment` +row (or a reply) whose `authoredByViewer` the host set, with the host's +`onEditComment` / `onDeleteComment`. The console's record page marks each row +with `@object-ui/collaboration`'s `isCommentAuthor`, the rule `CommentThread` +uses, and hands each handler over under the caller's update or delete grant on +`sys_comment`. Edit opens the text in place; Delete asks through the console's +destructive-action confirm. The server still decides the write, and a refusal +shows as an error with the comment unchanged. An edited comment reads as +edited (`is_edited` on the row). + +> **Known gap — `showSubscriptionToggle`.** It renders nothing, whatever you set +> it to. The bell needs a `RecordSubscription` value and somewhere to persist +> it, and the platform has no record-subscription object to read or write one +> from. The input stays declared only because `@objectstack/spec` declares it — +> it is recorded as a known gap here and in the block's registration rather than +> left looking configurable (objectui#3165). + +### Feed kinds with no producer + +`FeedItemType` publishes thirteen kinds. ObjectUI produces **five** of them: + +| kind | produced by | +|---|---| +| `field_change` | `sys_activity` types `created` / `updated` / `deleted` / `assigned` / `shared` | +| `task` | `sys_activity` type `completed`, except a milestone row | +| `event` | `sys_activity` type `scheduled` | +| `system` | `sys_activity` type `system`, **and** any author-extended type the map has never seen (it renders through the generic presentation rather than vanishing), **and** a `completed` row a milestone fired (`metadata.kind: 'milestone'`) | +| `comment` | `sys_comment` rows, merged in by the console's record page and handed to the block through the discussion context | + +The other eight — `email`, `call`, `note`, `file`, `record_create`, +`record_delete`, `approval`, `sharing` — have no producer on any ObjectUI +surface. Authoring `types: ['approval']` is legal, typechecks, builds, and +renders a **permanently empty** timeline. Since objectui#5877 it also emits one +`console.warn` per distinct kind, so the emptiness is diagnosable instead of +looking like "a working feature with no data yet". + +The diagnostic separates two reasons a kind has no producer, because reporting a +decision as a defect is how a warning channel gets ignored: + +- `record_create`, `record_delete` and `sharing` are **deliberately not + adopted**. Those rows are mapped to `field_change` on purpose — the record page + and this block have to agree about what a `created` row *is* before either can + move to the richer kind, so adopting them is a change to a shared map rather + than to one block. +- The rest simply have **no producer**. Whether that is a gap or a decision + nobody wrote down is not recorded anywhere the renderer can read, so the + warning reports the measurement and stops there. + + + The census is over **ObjectUI's own** producers. If your host supplies the feed + itself — `items` on the node, or a discussion context you mount — it can + produce kinds this list cannot see, and the warning does not apply to them. + + +## Schema API + +### RecordChatterPanel Config + +```plaintext +{ + position?: 'bottom' | 'right' | 'left', // Panel placement + width?: string, // CSS width (e.g., '400px') + collapsible?: boolean, // Enable collapse toggle + defaultCollapsed?: boolean, // Start collapsed + feed?: RecordActivityComponentProps, // Activity feed config + aria?: RecordComponentAriaProps // Accessibility props +} +``` + + + `feed` is the **same shape as `record:activity`** — `@objectstack/spec` + declares `RecordChatterProps.feed` as `RecordActivityProps` — and since + objectui#8934 an **authored `record:chatter` / `record:discussion` block**, + and every synthesized default page (which emits `record:discussion`), applies + it with the same pipeline `record:activity` uses. Its **defaults** come with + it: `showCompleted` is `false`, so completed activities other than logged + interactions are hidden unless asked for, and an unauthored `limit` is `20`, + so a longer feed pages with **Load more** rather than rendering whole. + + Since objectui#8968 the remaining two members are read here too. + `filterMode` seeds which slice the panel opens on, normalized by the same + function `record:activity` uses, so an unrecognised value opens on `all` + rather than on a filter nothing matches; it seeds component **state**, so the + dropdown stays usable instead of freezing on the authored value. + `enableMentions: false` withholds the composer's @-autocomplete; the + protocol's default is on, so an unauthored member keeps the affordance. + Both values were accepted and discarded before, so a schema that already + authored either one changes behaviour on upgrade — unless what it authored + is what this path already did: `filterMode: 'all'` and + `enableMentions: true` both land on the same feed as before. The case worth + auditing is `showFilterToggle: false` alongside a `filterMode` other than + `all` — that slice is now **pinned** where the panel used to show + everything, and the dropdown that would widen it is not on screen. + + There is no third path left to disagree with: the host used to auto-append a + panel below any page that omitted a discussion block, and objectui#7298 + retired that append — a record page shows a discussion panel if and only if it + composes one. Every panel a user sees is therefore an authored or synthesized + block running this pipeline. The object's `enable.feeds: false` still + outranks the page and suppresses the panel either way. + + `feed.aria` is read too (objectui#11083), the way `record:activity` reads its + own `aria`: it lands on a `div` around the embedded timeline, and a declared + `ariaLabel` (a plain string or an inline locale map) names that `div` as a + `region`. The timeline keeps its heading as its own name, and the block's + top-level `aria` still names the block's own container, so the two bags name + two different regions. + + +### RecordActivityTimeline Config + +```plaintext +{ + types?: string[], // Feed item types to show + filterMode?: string, // Default filter mode + showFilterToggle?: boolean, // Show filter mode buttons + limit?: number, // Items per page + showCompleted?: boolean, // Include completed activities + unifiedTimeline?: boolean, // Unified timeline view + showCommentInput?: boolean, // Show comment input + enableMentions?: boolean, // Enable @mentions + enableReactions?: boolean, // Enable emoji reactions + enableThreading?: boolean, // Enable threaded replies + showSubscriptionToggle?: boolean // Show subscription toggle +} +``` + +### FeedItem + +```plaintext +{ + id: string | number, + type: 'comment' | 'field_change' | 'task' | 'event' | 'system' | 'email' | 'call', + actor: string, // Author display name + actorAvatarUrl?: string, // Author avatar URL + body?: string, // Comment/event body text + createdAt: string, // ISO 8601 timestamp + updatedAt?: string, // Last edit timestamp + source?: string, // Origin source + parentId?: string | number, // Parent item for threading + replyCount?: number, // Number of replies + fieldChanges?: FieldChangeEntry[], // For field_change type + mentions?: Mention[], // @mention references + reactions?: Reaction[], // Emoji reactions + pinned?: boolean, // Pinned comment + edited?: boolean, // Edited flag + authoredByViewer?: boolean // The viewer wrote it (set by the host); gates Edit / Delete +} +``` + +### RecordSubscription + +```plaintext +{ + recordId: string, + subscribed: boolean, + channels?: Array<'in_app' | 'email' | 'push'> +} +``` + +## Properties + +### RecordChatterPanel + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `config` | `RecordChatterComponentProps` | `{}` | Panel configuration (position, collapsible, feed) | +| `items` | `FeedItem[]` | required | Feed items to display | +| `hasMore` | `boolean` | `false` | Whether more items can be loaded | +| `onLoadMore` | `() => void` | — | Callback to load next page | +| `loading` | `boolean` | `false` | Loading state indicator | +| `onAddComment` | `(text: string) => void` | — | New comment callback | +| `onAddReply` | `(parentId, text) => void` | — | Reply to comment callback | +| `onToggleReaction` | `(itemId, emoji) => void` | — | Toggle emoji reaction callback. Called only for a `comment` row | +| `onEditComment` | `(itemId, text) => void \| Promise` | — | Stores a comment's new text. Offered only on a `comment` row (or reply) whose `authoredByViewer` is `true`; a rejection keeps the editor's draft | +| `onDeleteComment` | `(itemId) => void \| Promise` | — | Deletes a comment, on the same rows. The host confirms first | +| `subscription` | `RecordSubscription` | — | Current subscription state | +| `onToggleSubscription` | `(subscribed: boolean) => void` | — | Toggle subscription callback | +| `filterMode` | `FeedFilterMode` | `'all'` | Active filter mode | +| `onFilterChange` | `(mode: FeedFilterMode) => void` | — | Filter mode change callback | +| `className` | `string` | `''` | Additional CSS classes | + +### RecordActivityTimeline + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `items` | `FeedItem[]` | required | Feed items to display | +| `config` | `RecordActivityComponentProps` | `{}` | Timeline configuration | +| `filterMode` | `FeedFilterMode` | `'all'` | Active filter mode | +| `onFilterChange` | `(mode) => void` | — | Filter mode change callback | +| `hasMore` | `boolean` | `false` | Whether more items can be loaded | +| `onLoadMore` | `() => void` | — | Callback to load next page | +| `loading` | `boolean` | `false` | Loading state indicator | +| `onAddComment` | `(text: string) => void` | — | New comment callback | +| `onAddReply` | `(parentId, text) => void` | — | Reply to comment callback | +| `onToggleReaction` | `(itemId, emoji) => void` | — | Toggle emoji reaction callback. Called only for a `comment` row | +| `onEditComment` | `(itemId, text) => void \| Promise` | — | Stores a comment's new text. Offered only on a `comment` row (or reply) whose `authoredByViewer` is `true`; a rejection keeps the editor's draft | +| `onDeleteComment` | `(itemId) => void \| Promise` | — | Deletes a comment, on the same rows. The host confirms first | +| `subscription` | `RecordSubscription` | — | Current subscription state | +| `onToggleSubscription` | `(subscribed: boolean) => void` | — | Toggle subscription callback | +| `className` | `string` | `''` | Additional CSS classes | + +### ReactionPicker + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `reactions` | `Reaction[]` | required | Current reactions | +| `onToggleReaction` | `(emoji: string) => void` | — | Toggle reaction callback | +| `emojiOptions` | `string[]` | default set | Available emoji options | +| `className` | `string` | `''` | Additional CSS classes | + +### ThreadedReplies + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `parentItem` | `FeedItem` | required | Parent comment item | +| `replies` | `FeedItem[]` | required | Reply items | +| `onAddReply` | `(parentId, text) => void` | — | Add reply callback | +| `showReplyInput` | `boolean` | `false` | Show reply input | +| `onEditComment` | `(itemId, text) => void \| Promise` | — | Edit on a reply whose `authoredByViewer` is `true` | +| `onDeleteComment` | `(itemId) => void \| Promise` | — | Delete on the same replies | +| `className` | `string` | `''` | Additional CSS classes | + +### SubscriptionToggle + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `subscription` | `RecordSubscription` | required | Current subscription state | +| `onToggle` | `(subscribed: boolean) => void` | — | Toggle callback | +| `className` | `string` | `''` | Additional CSS classes | + +## Filter Modes + +```tsx +import { RecordChatterPanel } from '@object-ui/plugin-detail'; +import type { FeedFilterMode } from '@object-ui/plugin-detail'; +import type { FeedItem } from '@object-ui/types'; + +// The four modes, restated against the declared union rather than beside it: +// a typo or a retired member fails here. +const filterModes: FeedFilterMode[] = ['all', 'comments_only', 'changes_only', 'tasks_only']; + +declare const items: FeedItem[]; +declare const setFilterMode: (mode: FeedFilterMode) => void; + +// Example: Filter to comments only + setFilterMode(mode)} +/> +``` + +## TypeScript Support + +```tsx +import type { + FeedItem, + FeedItemType, + FieldChangeEntry, + Mention, + Reaction, + RecordSubscription, +} from '@object-ui/types'; + +import type { + RecordChatterPanelProps, + RecordActivityTimelineProps, + FieldChangeItemProps, + ReactionPickerProps, + ThreadedRepliesProps, + SubscriptionToggleProps, + FeedFilterMode, +} from '@object-ui/plugin-detail'; +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Detail View Plugin](/docs/frontend/plugins/plugin-view) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-detail) diff --git a/content/docs/frontend/plugins/plugin-editor.mdx b/content/docs/frontend/plugins/plugin-editor.mdx new file mode 100644 index 00000000000..1d3f045de17 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-editor.mdx @@ -0,0 +1,192 @@ +--- +title: "Plugin Editor" +--- + +Code editor component powered by Monaco Editor (VS Code's editor). + +## Installation + +```bash +npm install @object-ui/plugin-editor +``` + +## Example schemas + +**Javascript Editor** + +```json +{ + "type": "code-editor", + "value": "function greet(name) {\n console.log('Hello, ' + name + '!');\n return 'Welcome, ' + name;\n}\n\ngreet('Developer');", + "language": "javascript", + "theme": "vs-dark", + "height": "300px" +} +``` + +**Python Editor** + +```json +{ + "type": "code-editor", + "value": "def calculate_fibonacci(n):\n if n <= 1:\n return n\n return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)\n\n# Calculate first 10 Fibonacci numbers\nfor i in range(10):\n print(f'F({i}) = {calculate_fibonacci(i)}')", + "language": "python", + "theme": "vs-dark", + "height": "300px" +} +``` + +**Read Only Json Viewer** + +```json +{ + "type": "code-editor", + "value": "{\n \"name\": \"objectui-demo\",\n \"version\": \"1.0.0\",\n \"description\": \"Interactive demo\",\n \"main\": \"index.js\",\n \"dependencies\": {\n \"@object-ui/core\": \"latest\",\n \"@object-ui/react\": \"latest\"\n }\n}", + "language": "json", + "theme": "vs-dark", + "height": "300px", + "readOnly": true +} +``` + +## Usage + +### Basic Usage + +```tsx +// Import once in your app entry point +import '@object-ui/plugin-editor' +import type { CodeEditorSchema } from '@object-ui/plugin-editor' + +// Use in schemas +const schema: CodeEditorSchema = { + type: 'code-editor', + value: 'console.log("Hello, World!");', + language: 'javascript', + theme: 'vs-dark', + height: '400px' +} +``` + +## Features + +- **Syntax highlighting** for 100+ languages +- **IntelliSense and code completion** +- **Find and replace** +- **Multiple themes** +- **Lazy-loaded** (~20 KB initial, Monaco loads on-demand) + +## Schema API + +```plaintext +{ + type: 'code-editor', + value?: string, // Code content + language?: string, // Programming language + theme?: 'vs-dark' | 'light', // Editor theme + height?: string, // Height (e.g., '400px') + readOnly?: boolean, // Read-only mode + className?: string // Tailwind classes +} +``` + +### CodeEditorSchema + +Declared by `@object-ui/plugin-editor` (`packages/plugin-editor/src/types.ts`). +It extends `BaseSchema`, so the shared component properties are available on a +`code-editor` node as well as the ones below. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `value` | string | `''` | The code content to display | +| `language` | string | `'javascript'` | Programming language for syntax highlighting | +| `theme` | `'vs-dark'` \| `'light'` | `'vs-dark'` | Editor theme | +| `height` | string | `'400px'` | Height of the editor | +| `readOnly` | boolean | `false` | Whether the editor is read-only | +| `className` | string | `''` | Additional Tailwind CSS classes | + +## Supported Languages + +The Monaco Editor supports 100+ programming languages including: + +- JavaScript / TypeScript +- Python +- Java / C# / C++ +- HTML / CSS / SCSS +- JSON / YAML / XML +- Markdown +- SQL +- Shell / Bash +- And many more... + +## Examples + +### JavaScript Editor + +```tsx +import type { CodeEditorSchema } from '@object-ui/plugin-editor' + +const schema: CodeEditorSchema = { + type: 'code-editor', + value: 'function hello() {\n console.log("Hello!");\n}', + language: 'javascript', + theme: 'vs-dark', + height: '300px' +} +``` + +### Read-only Code Display + +```tsx +import type { CodeEditorSchema } from '@object-ui/plugin-editor' + +const schema: CodeEditorSchema = { + type: 'code-editor', + value: 'const API_KEY = "secret";\n// Do not modify', + language: 'typescript', + readOnly: true, + theme: 'light' +} +``` + +### Python Editor + +```tsx +import type { CodeEditorSchema } from '@object-ui/plugin-editor' + +const schema: CodeEditorSchema = { + type: 'code-editor', + value: 'def hello():\n print("Hello, World!")', + language: 'python', + height: '400px' +} +``` + +## Bundle Size + +The plugin uses lazy loading to optimize bundle size: + +- **Initial load**: ~0.2 KB (entry point) +- **Lazy chunk**: ~20 KB (loaded when editor is rendered) +- **Monaco Editor**: Loaded on-demand from CDN + +This keeps your main application bundle small while providing full IDE-like editing capabilities. + +## TypeScript Support + +```ts +import type { CodeEditorSchema } from '@object-ui/plugin-editor' + +const editorSchema: CodeEditorSchema = { + type: 'code-editor', + value: 'console.log("TypeScript!");', + language: 'typescript', + theme: 'vs-dark' +} +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Lazy-Loaded Plugins Architecture](/docs/frontend/plugins#lazy-loading-architecture) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-editor) diff --git a/content/docs/frontend/plugins/plugin-form.mdx b/content/docs/frontend/plugins/plugin-form.mdx new file mode 100644 index 00000000000..88354d7f6c4 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-form.mdx @@ -0,0 +1,128 @@ +--- +title: "Plugin Form" +--- + +Advanced form components with validation, multi-step forms, and comprehensive field support. + +## Installation + +```bash +npm install @object-ui/plugin-form +``` + +The props the [`object-form`](/docs/ui/components/plugins#object-form), [`embeddable-form`](/docs/ui/components/plugins#embeddable-form), [`object-master-detail-form`](/docs/ui/components/plugins#object-master-detail-form) nodes read on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Features + +- **Form Builder** - Create complex forms from schemas +- **Validation** - Built-in validation with custom rules +- **Multi-Step Forms** - Wizard-style multi-step forms +- **Field Types** - All standard HTML5 input types +- **Form State** - Automatic state management +- **Error Handling** - Field-level and form-level errors + +## Usage + +### Registration is a side effect of the import + +```ts +import '@object-ui/plugin-form'; +``` + +That single import is the whole of registration — there is no components map to +iterate over. Importing the entry runs the five `ComponentRegistry.register(...)` +calls in `packages/plugin-form/src/index.tsx`, which claim exactly these schema +types: + +| Namespaced key | Bare-name fallback | Renderer behind it | +| --- | --- | --- | +| `plugin-form:object-form` | `object-form` | `ObjectForm` — metadata-driven form over one record | +| `view:form` | none — `skipFallback: true` | the same renderer, under the view protocol | +| `plugin-form:embeddable-form` | `embeddable-form` | `EmbeddableForm` — standalone public form | +| `plugin-form:object-master-detail-form` | `object-master-detail-form` | `MasterDetailForm` — parent plus child line items in one submit | +| `record:line_items` | none — `skipFallback: true` | `LineItemsPanel` — child grid bound to the record on the page | + +`ComponentRegistry.register` publishes `namespace:type`, and — unless the call +passes `skipFallback: true` — the bare `type` as a back-compat fallback +(`packages/core/src/registry/Registry.ts:194`, fallback at `:226`). The two +`skipFallback` calls here are deliberate: bare `form` stays the basic +`@object-ui/components` form, and bare `line_items` is left to whoever else +claims it. + +The `form-analytics` node key is RETIRED (objectui#10859 batch 8): no schema +produced it, and `objectui validate` refused it at `type`. `FormAnalytics` is still +exported; mount the component directly where you need the submission dashboard. + +### Registering a component under your own key + +To serve one of this package's components under a key of your own, register the +exported component — that is what a manual registration is here: + +```ts +import { ComponentRegistry } from '@object-ui/core'; +import { ObjectForm } from '@object-ui/plugin-form'; + +ComponentRegistry.register('my-form', ObjectForm, { namespace: 'my-app' }); +``` + +The renderers this package registers for *itself* are internal wrappers rather +than these exported components. `SchemaRenderer` hands a registered component +its `schema`, but never a `dataSource` — that travels on +`SchemaRendererContext` — so each wrapper resolves it from the context first. +`ObjectForm` takes `dataSource` as a prop (optional, because inline +`customFields` need no adapter), so a custom-key registration either supplies +one or wraps the component the same way. + +The components are on the package's export surface — `ObjectForm`, +`TabbedForm`, `WizardForm`, `SplitForm`, `DrawerForm`, `ModalForm`, +`EmbeddableForm`, `MasterDetailForm`, `LineItemsPanel`, `FormAnalytics`, +`FormSectionContainer` — alongside the layout helpers (`applyAutoLayout`, +`inferColumns`, `filterCreateModeFields`, …) and the per-container schema types +(`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …). There is no +aggregate map among them. + +## What an edit save writes + +Which fields an edit save sends, and which fields and line-item actions a caller is offered, is the same in the SDK and the Console. It is documented under [What an edit save writes](/docs/ui/forms#what-an-edit-save-writes). + +## Field Types + +The plugin supports these field types: + +- **input** - Text, email, password, number, tel, url, etc. +- **textarea** - Multi-line text input +- **select** - Dropdown select +- **checkbox** - Single checkbox +- **radio-group** - Radio button group +- **date-picker** - Date selection +- **file-upload** - File upload + +## TypeScript Support + +`FormSchema` and `FormField` are protocol types, so they live in +`@object-ui/types` alongside the rest of the JSON contract. This package imports +them and does not re-export them — the form types on its own entry are the +per-container ones (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, +`SplitFormSchema`, `DrawerFormSchema`, `MasterDetailFormSchema`). + +```typescript +import type { FormSchema, FormField } from '@object-ui/types'; + +const emailField: FormField = { + name: 'email', + type: 'input', + inputType: 'email', + label: 'Email', + required: true +}; + +const loginForm: FormSchema = { + type: 'form', + fields: [emailField], + submitLabel: 'Sign In' +}; +``` + +## License + +MIT diff --git a/content/docs/frontend/plugins/plugin-gantt.mdx b/content/docs/frontend/plugins/plugin-gantt.mdx new file mode 100644 index 00000000000..8d5dd8054fe --- /dev/null +++ b/content/docs/frontend/plugins/plugin-gantt.mdx @@ -0,0 +1,306 @@ +--- +title: "Plugin Gantt" +--- + +Gantt chart component for ObjectQL data sources - visualizes project tasks, timelines, and dependencies. + +## Installation + +```bash +npm install @object-ui/plugin-gantt +``` + +The props an [`object-gantt`](/docs/ui/components/plugins#object-gantt) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Overview + +The `@object-ui/plugin-gantt` plugin provides Gantt chart visualization for ObjectQL data sources. It's designed to work with object-based data providers and automatically maps record fields to Gantt chart tasks. + +**Note**: This plugin is designed for use with ObjectQL data sources. For a simpler timeline component, see [Timeline Plugin](/docs/frontend/plugins/plugin-timeline). + +## Features + +- **ObjectQL Integration**: Works seamlessly with object/value data providers +- **Automatic Field Mapping**: Maps database fields to Gantt tasks +- **Task Timeline**: Visual bars showing task duration +- **Progress Tracking**: Display task completion percentage (0-100%) +- **Dependencies**: Visualize task dependencies and relationships +- **Date Ranges**: Automatic date range calculation +- **Dates name their year**: the task list's Start / End cells and date line, + the bar tooltip and the drag previews add the year to a date outside the + current year (two digits on the compact faces), so a plan crossing a year + boundary never shows two different days alike +- **Interactive**: Click handling for tasks +- **Drag-and-drop rescheduling**: Drag a bar to move it; drag either edge to + resize start/end. Snaps to whole days, keeps each date's time of day across + a daylight-saving change (with shift bands configured via `timeSegments`, a + day-view drag goes by bands instead, and a move keeps the bar's elapsed + length), and persists the change through + `dataSource.update()` automatically (optimistic local update + revert on + failure). See the [package README](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/plugin-gantt/README.md#drag-and-drop-rescheduling) for details on the + lower-level `onTaskUpdate` hook when embedding `` directly. +- **Writes follow the caller's grant**: a bar's drag, resize and progress, its + title edited in place and its dependency links are offered only when the + `rowEdit` row of the affordance-to-grant map (`@object-ui/core`) allows them — + the object's policy, the effective API operation set and the caller's + **update** grant — and a row's Delete only when `rowDelete` does. The record + drawer's inline edit reads the record page's `recordEdit` row. With no + permission provider mounted they read open, as `readOnly` and `lockField` + still close them. + +## Usage + +An `object-gantt` node takes its props in its `properties` bag, whose members are +`@objectstack/spec`'s `ComponentPropsMap['object-gantt']` row, and its field +mapping in the bag's `gantt` block. `objectui validate` judges the bag against that +row and refuses a prop written flat on the node by name, naming its bag member +(`Did you mean objectName → properties.objectName?`, or +`startDateField → properties.gantt.startDateField` for a field-mapping key), as the +spec's own page component does (objectui#10859). `SchemaRenderer` hoists the bag +onto the node before `ObjectGantt` runs, so `ObjectGanttSchema` is the node as the +renderer reads it, and as code composes it. + +### Basic Usage with ObjectQL + +```tsx +import '@object-ui/plugin-gantt' +import type { ObjectGanttBlockNode } from '@object-ui/types' + +const schema: ObjectGanttBlockNode = { + type: 'object-gantt', + properties: { + objectName: 'tasks', // Your ObjectQL object + gantt: { + startDateField: 'startDate', + endDateField: 'endDate', + titleField: 'taskName', + progressField: 'completion', + dependenciesField: 'dependencies' + } + } +} +``` + +### With Static Data + +```tsx +import type { ObjectGanttBlockNode } from '@object-ui/types'; + +const schema: ObjectGanttBlockNode = { + type: 'object-gantt', + properties: { + staticData: [ + { + id: 1, + taskName: 'Design Phase', + startDate: '2024-01-01', + endDate: '2024-01-15', + completion: 100 + }, + { + id: 2, + taskName: 'Development', + startDate: '2024-01-16', + endDate: '2024-02-28', + completion: 60, + dependencies: [1] + }, + { + id: 3, + taskName: 'Testing', + startDate: '2024-03-01', + endDate: '2024-03-15', + completion: 0, + dependencies: [2] + } + ], + gantt: { + startDateField: 'startDate', + endDateField: 'endDate', + titleField: 'taskName', + progressField: 'completion', + dependenciesField: 'dependencies' + } + } +} +``` + +## Data Providers + +### Object Provider (Database) + +```tsx +import type { ObjectGanttBlockNode } from '@object-ui/types'; + +const objectProviderGantt: ObjectGanttBlockNode = { + type: 'object-gantt', + properties: { + objectName: 'project_tasks', + gantt: { + startDateField: 'start_date', + endDateField: 'due_date', + titleField: 'title', + progressField: 'progress' + } + } +}; +``` + +### Value Provider (Static) + +```tsx +import type { ObjectGanttBlockNode } from '@object-ui/types'; + +const valueProviderGantt: ObjectGanttBlockNode = { + type: 'object-gantt', + properties: { + staticData: [ + { id: 1, title: 'Task 1', start: '2024-01-01', end: '2024-01-15' }, + { id: 2, title: 'Task 2', start: '2024-01-16', end: '2024-01-31' } + ], + gantt: { + startDateField: 'start', + endDateField: 'end', + titleField: 'title' + } + } +}; +``` + +### No API provider + +The view-level `{ provider: 'api', read, write }` data source is retired in +`@objectstack/spec` (objectstack#23082). A gantt reads its object +(`provider: 'object'`) or inline rows (`provider: 'value'`); a +metadata-declared API call is the page element's `dataSource` binding. A +gantt that still carries an `api` block is not read through it: its `read` / +`write` URLs are never requested. + +## Event Handling + +### Task Click + +The click handler is a **React prop**, not a schema key — the schema stays +serializable. A component mounted yourself, without `SchemaRenderer`, receives the +node as it reads it, so its `schema` prop keeps its props on the node: nothing +hoists a `properties` bag there. + +```tsx +import { ObjectGantt } from '@object-ui/plugin-gantt'; +import type { DataSource } from '@object-ui/types'; + +export function ProjectGantt({ dataSource }: { dataSource: DataSource }) { + return ( + { + console.log('Task clicked:', task); + // Open task details / edit / show dependencies + }} + /> + ); +} +``` + +Rendered through the registered `object-gantt` type there is usually nothing to +wire: clicking a row already opens the standard detail drawer. + +### Task click navigation + +`navigation` in the `properties` bag decides what a task click opens. With the +key absent it is the drawer. `drawer`, and a block written without `mode` (the +spec's `drawer` default), open the task's record in the drawer. `page` opens +the record page in the same tab, and +`new_window` opens it in a new tab. `none` opens nothing, `preventNavigation: +true` opens nothing whatever the mode, `openNewTab: true` opens the record page +in a new tab and outranks every mode except `none`, and `size` sets the overlay +width. + +```json +{ + "type": "object-gantt", + "properties": { + "objectName": "project_task", + "gantt": { "startDateField": "start_date", "endDateField": "end_date", "titleField": "name" }, + "navigation": { "mode": "drawer", "size": "lg" } + } +} +``` + +The record-page address is the gantt's own: it derives it from the page it is +on and does not use a record navigator the host publishes, so mounting one +changes nothing. On the object's own list or view route the address is the +object's record page; on any other page, such as a custom page, the gantt +appends `/OBJECT/record/ID` to the current address. On inline rows that name +no object (no `objectName`, no object `data` provider), no mode opens anything. + +## Comparison with Timeline Plugin + +| Feature | object-gantt | timeline (gantt variant) | +|---------|-------------|--------------------------| +| **Data Source** | ObjectQL (database) | Static arrays | +| **Dependencies** | Yes | No | +| **Progress** | Yes (0-100%) | No | +| **Use Case** | Project management | Simple timelines | +| **Field Mapping** | Configurable | Fixed schema | +| **Best For** | Database-driven projects | Static presentations | + +**When to use object-gantt**: +- You're using ObjectQL for data management +- You need task dependencies and progress tracking +- Tasks come from a database or API +- You're building project management features + +**When to use timeline (gantt variant)**: +- You have static timeline data +- You don't need dependencies or progress +- You're creating simple visual timelines +- You're not using ObjectQL + +## Typical Use Cases + +1. **Project Management**: Track project tasks, milestones, and dependencies +2. **Sprint Planning**: Visualize agile sprint tasks and their timeline +3. **Construction Planning**: Display construction phases and their relationships +4. **Event Planning**: Show event preparation tasks and schedules +5. **Product Roadmap**: Display product features and release timelines + +## TypeScript Support + +```ts +import type { ObjectGanttBlockNode, GanttConfig } from '@object-ui/types' + +const ganttConfig: GanttConfig = { + startDateField: 'startDate', + endDateField: 'endDate', + titleField: 'taskName', + progressField: 'completion', + dependenciesField: 'dependencies' +} + +const ganttSchema: ObjectGanttBlockNode = { + type: 'object-gantt', + properties: { + objectName: 'project_tasks', + gantt: ganttConfig + } +} +``` + +`ObjectGanttSchema` types the node as `ObjectGantt` reads it: after `SchemaRenderer` +hoists the bag, or as you hand it to `` yourself. + +## Related Documentation + +- [Timeline Plugin](/docs/frontend/plugins/plugin-timeline) - Simpler timeline visualization +- [Plugin System Overview](/docs/frontend/plugins) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-gantt) diff --git a/content/docs/frontend/plugins/plugin-grid.mdx b/content/docs/frontend/plugins/plugin-grid.mdx new file mode 100644 index 00000000000..c5f82eddb25 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-grid.mdx @@ -0,0 +1,467 @@ +--- +title: "Plugin Grid" +--- + +Advanced data grid with sorting, filtering, pagination, and row selection capabilities. + +## Installation + +```bash +npm install @object-ui/plugin-grid +``` + +This package publishes a stylesheet. Import it after the base sheets, or the grid renders unstyled — the themed utilities it uses have no other source in a published app ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)): + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-grid/style.css"; +``` + +The props an [`object-grid`](/docs/ui/components/plugins#object-grid) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Features + +- **Sorting** - Multi-column sorting support +- **Filtering** - Column-level filtering +- **Pagination** - Built-in pagination controls +- **Row Selection** - Single and multi-row selection +- **Custom Cells** - Custom cell renderers +- **Responsive** - Mobile-friendly layouts + +## Usage + +### Registration is a side effect of the import + +```ts +import '@object-ui/plugin-grid'; +``` + +That single import is the whole of registration — there is no components map to +iterate over. Importing the entry runs the two `ComponentRegistry.register(...)` +calls in `packages/plugin-grid/src/index.tsx`, which claim exactly these schema +types: + +| Namespaced key | Bare-name fallback | Renderer behind it | +| --- | --- | --- | +| `plugin-grid:object-grid` | `object-grid` | `ObjectGridRenderer` — the data grid, queried from an object | +| `view:grid` | none — `skipFallback: true` | the same renderer, under the view protocol | + +`ComponentRegistry.register` publishes `namespace:type`, and — unless the call +passes `skipFallback: true` — the bare `type` as a back-compat fallback +(`packages/core/src/registry/Registry.ts:194`, fallback at `:226`). + +The `import-wizard` node key is RETIRED (objectui#10859 batch 8): no schema +produced it, and `objectui validate` refused it at `type`. `ImportWizard` is still +exported, and `@object-ui/app-shell` mounts it directly (the object view's import +flow). + +**Bare `grid` is deliberately not this plugin's.** `skipFallback: true` on the +`view:grid` call keeps the data grid from claiming it, because `grid` belongs to +the CSS Grid *layout* container in `@object-ui/components` +(`packages/components/src/renderers/layout/grid.tsx:50`). Reach the data grid as +`object-grid`, or as `view:grid` when you want the namespaced spelling. + +### Registering the renderer under your own key + +To serve the data grid under a key of your own, register the exported renderer — +that is what a manual registration is here: + +```ts +import { ComponentRegistry } from '@object-ui/core'; +import { ObjectGridRenderer } from '@object-ui/plugin-grid'; + +ComponentRegistry.register('my-grid', ObjectGridRenderer, { + namespace: 'my-app', + label: 'My Grid', + category: 'plugin', +}); +``` + +`ObjectGrid`, `ObjectGridRenderer`, `VirtualGrid`, `SplitPaneGrid`, +`ImportWizard`, `InlineEditing` and `FormulaBar` are on the package's export +surface, alongside the hooks (`useGroupedData`, `useColumnSummary`, +`useCellClipboard`, …) and the component prop types +(`ObjectGridComponentProps`, `VirtualGridProps`, …). There is no aggregate map +among them, and the *schema* types are not here either — they live in +`@object-ui/types`, because the schema is the shared authoring contract rather +than this package's component API. + +## Features + +### Sorting + +Columns sort by default. `sortable` is a **per-column** key, used to turn a +column off; the grid-level `sort` declares the order the grid opens with. There +is no top-level `sortable` switch. + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "sort": [{ "field": "created", "order": "desc" }], + "columns": [ + { "field": "name", "label": "Name" }, + { "field": "email", "label": "Email", "sortable": false } + ] + } +} +``` + +### Filtering and search + +There is no per-column filter key and no top-level `filterable` switch. A grid +narrows its query two ways: a `filter` baked into the metadata, and a toolbar +search over the fields named in `searchableFields`. + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "filter": [ + { "field": "status", "operator": "equals", "value": "active" }, + { "field": "created", "operator": "after", "value": "2026-01-01" } + ], + "searchableFields": ["name", "email"], + "columns": ["name", "email", "status"] + } +} +``` + +### Row Actions + +`rowActions` and `bulkActions` are lists of **action names** — the actions +themselves live in the object's action set, so the same action behaves +identically wherever it is offered. They are `string[]`, not inline definitions +carrying callbacks. + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "columns": ["name", "email"], + "rowActions": ["view", "edit", "delete"], + "selection": { "type": "multiple" }, + "bulkActions": ["delete", "export"] + } +} +``` + +A name in `bulkActions` runs that action once per selected record. If the +action declares `params`, the selection bar's dialog collects them once, before +the run, and resolves them as the record page's action dialog does. A +field-backed param (`{ "field": "crm_campaign", "objectOverride": +"crm_campaign_member" }`) takes its type, label, options, default and picker +target from that field, so a lookup param renders the record picker and the +confirm step shows the picked record's label. A param whose field is not in +the object metadata is refused, and the run is blocked. + +The confirm step lists the affected records by their display title — the +object's `nameField`, resolved as the record header resolves it — for every +bulk action, whether it runs once per record or once over the selection. A +record with no title to show is listed as `Record #` followed by its id. + +The selection bar offers a write only to a caller who may make it. A bulk Delete +is the `rowDelete` row of the affordance-to-grant map (`@object-ui/core`), and a +`bulkActionDefs` entry with `operation: 'update'` — a patch written to every +selected record — is the `rowEdit` row, the same rows the row menu's Delete and +Edit read: the object's policy, the effective API operation set and the +caller's **delete** or **update** grant. A closed row removes the button, and +with it the dialog, its undo and its retry. `custom` entries are unchanged. +With no permission provider mounted both read open. + +### Row callbacks are component props + +`onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`, `onBatchSave`, +`onEdit`, `onDelete`, `onBulkDelete` and `onAddRecord` are React props on +`ObjectGridComponentProps`. They are functions, so no metadata document can hold +them, and writing one of them into a schema does nothing at all: the grid builds +the inner table's handlers itself and never reads any of these nine off the +schema. + +The one callback the grid does read off the schema is `onNavigate`, declared on +`ObjectGridSchema` for programmatic callers only. It is a function value too, so +it is no more authorable than the nine — it is deliberately absent from the +manifest and the designer panel, and prefer passing it as a prop +(objectui#5234, maintainer ruling of 2026-08-19). + +```tsx +import { ObjectGrid } from '@object-ui/plugin-grid'; +import type { ObjectGridComponentProps } from '@object-ui/plugin-grid'; + +export const Grid = (props: ObjectGridComponentProps) => ( + console.log('Row clicked:', record)} + onRowSelect={(rows) => console.log('Selection changed:', rows)} + /> +); +``` + +Note `onRowSelect` — the prop that reports a selection change is spelled that +way; there is no `onSelectionChange` on this component. + +The declarative alternative, which *is* metadata and survives a round trip +through storage, is `navigation`: its `mode` decides what a row click does +without any host code. + +### Withholding a row's Edit or Delete + +`operations` and the `onEdit` / `onDelete` wiring are GRID-level: they decide +whether the generic Edit / Delete entries exist at all, identically for every +row. When the refusal belongs to one RECORD — a system field a designer may not +drop — use `rowOperations`, a component prop called with a row record that +answers for that row alone. Mounting `` directly hands it the node as +it reads it, so its `schema` prop is the flat `ObjectGridSchema`, with no bag: + +```tsx +import { ObjectGrid } from '@object-ui/plugin-grid'; +import type { ObjectGridSchema } from '@object-ui/types'; + +const schema: ObjectGridSchema = { + type: 'object-grid', + objectName: 'field_definition', + columns: ['name', 'label', 'type'], +}; + +export const FieldList = ({ isSystem }: { isSystem: (name: string) => boolean }) => ( + console.log('edit', record)} + onDelete={(record) => console.log('delete', record)} + rowOperations={(record) => ({ delete: !isSystem(String(record.name)) })} + /> +); +``` + +It speaks the same `update` / `delete` vocabulary as the authored `operations` +block, and it is an **intersection**, never a union: `false` withholds the +entry, while `true`, an omitted member, and a `null` / `undefined` return all +leave the grid's own verdict alone. Nothing it returns can re-open what the +object's lifecycle bucket, its `userActions`, the server's effective operations, +the principal's grant or the record-level verdict already closed — and a grid +that passes no `rowOperations` renders exactly as it did before the prop +existed. + +Prefer this over refusing inside the callback. A refusal that runs after the +click ships a button that is drawn as available and then does nothing, which is +indistinguishable from a broken build (objectui#8674). + +### A node beside a row's title + +A host that has something to say about one record, next to its name, passes +`rowAdornment`: a component prop called with a row record that returns a React +node, or `null` for none. The grid draws it in the cell of the object's +declared `nameField` when a column shows that field, else in the first shown +column (never in the row actions), and beside the card's title in the card +view. It is drawn after the cell's own content and never in place of it, so +the value, its link and its mask stay as they were. A grid that passes no +`rowAdornment` renders exactly as it did before the prop existed. It is a host +prop, not a schema key: list metadata cannot set it. + +```tsx +import { ObjectGrid } from '@object-ui/plugin-grid'; +import type { ObjectGridSchema } from '@object-ui/types'; + +const schema: ObjectGridSchema = { + type: 'object-grid', + objectName: 'sys_user', + columns: ['name', 'email'], +}; + +export const UserList = ({ needsAccess }: { needsAccess: (id: string) => boolean }) => ( + (needsAccess(String(record.id)) ? Not authorized : null)} + /> +); +``` + +The console's Setup → Users list on a cloud-managed environment uses it to mark +a user who holds no permission set beyond the baseline (objectui#13330). + +### Inline Editing + +Enable inline cell editing for quick data updates: + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "columns": [ + { "field": "id", "label": "ID" }, + { "field": "name", "label": "Name" }, + { "field": "email", "label": "Email" }, + { "field": "status", "label": "Status", "type": "select" } + ], + "editable": true, + "singleClickEdit": false + } +} +``` + +`editable` is the only switch: it is a grid-level flag, and edits persist +through the host's data source (`dataSource.update`) with no callback to wire. + +**Features:** +- **Double-click to edit**: double-click any editable cell to enter edit mode + (`singleClickEdit: true` opens it on the first click instead) +- **Keyboard shortcuts**: press Enter on a focused cell to start editing, Enter + again to save, Escape to cancel +- **Per-field read-only**: which cells open is decided by the **field + definition**, not by a column key — a field marked `readonly`, and + computed/binary field types (formula, autonumber, file, …), never open an + editor. There is no `editable` key on `ListColumn`. +- **Visual feedback**: editable cells show a hover state, and the input is + focused and selected when editing begins + +To own persistence in a React host, supply `onCellChange` as a **component +prop** — it is not a schema key. + +### Keyboard navigation + +`keyboardNavigation` turns the grid's data cells into one roving Tab stop, on the +WAI-ARIA grid pattern (objectui#11068): + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "users", + "columns": ["name", "email", "status"], + "keyboardNavigation": true + } +} +``` + +- **One Tab stop.** Tab reaches the cells once — on the cell that last held + focus, or the first cell of the first row — and the next Tab moves past them. + A widget a cell renders (the record link, a row's action menu, a selection + checkbox) keeps its own Tab stop. +- **Arrow keys** move focus one cell; **Home** / **End** go to the first / last + cell of the row, and **Ctrl+Home** / **Ctrl+End** to the first / last cell of + the page. At an edge, focus stays put. +- **Editing.** On an editable grid, Enter still opens the focused cell, and an + open cell's editor keeps every key. An edit ended with Enter or Escape hands + focus back to its cell, so the arrows carry on from there. +- **Default.** On when the grid renders editable — the authored `editable` *and* + the viewer's permission to update the object, the same value inline editing + obeys. A read-only grid keeps every cell its own Tab stop unless you write + `true`, and `false` turns it off on an editable grid. +- While it is on, the table is exposed to assistive technology as a `grid`. A + grouped grid navigates within each group's table; the mobile card layout has + no cells and is unaffected. + +### Batch Editing & Multi-Row Save + +Edit multiple cells across multiple rows and save them individually or all at +once. The schema half is just `editable` — the save/cancel affordances appear on +their own once a row has pending changes: + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "products", + "columns": [ + { "field": "sku", "label": "SKU" }, + { "field": "name", "label": "Name" }, + { "field": "price", "label": "Price", "type": "currency", "align": "right" }, + { "field": "stock", "label": "Stock", "type": "number", "align": "right" } + ], + "editable": true + } +} +``` + +Left alone, saving goes through the host's data source. A React host that needs +to own persistence supplies `onRowSave` / `onBatchSave` as **component props** — +and because they are props, they take the adapter from the host's own scope +rather than from anything in the schema: + +```tsx +import type { ObjectGridComponentProps } from '@object-ui/plugin-grid'; + +type Persistence = Pick; + +const persistence = ( + dataSource: NonNullable, +): Persistence => ({ + onRowSave: async (rowIndex, changes, row) => { + await dataSource.update('products', row.id, changes); + }, + onBatchSave: async (allChanges) => { + await Promise.all( + allChanges.map(({ row, changes }) => dataSource.update('products', row.id, changes)), + ); + }, +}); +``` + +**Features:** +- **Pending changes tracking**: edit multiple cells across rows before saving; a + cell edited back to the value it loaded with is not a pending change, and its + row is no longer counted as modified +- **Visual indicators**: modified rows highlighted in amber, modified cells in bold +- **Row-level save/cancel**: individual row save and cancel buttons +- **Batch operations**: Save All and Cancel All buttons for bulk actions +- **Flexible callbacks** — all three are `ObjectGridComponentProps`, never schema + keys: `onRowSave` for a single row, `onBatchSave` for many, `onCellChange` for + each committed cell edit, including one that leaves the value as loaded (only a + real change is staged) +- **A concurrent write is refused, not overwritten**: the default saves send each + row's `updated_at` as `If-Match`, and an editable grid asks for that column even + when no column shows it. A row someone else saved after it was read answers + `409`, which opens the record page's conflict dialog (Reload latest, Overwrite + anyway, Cancel). "Save All" reports each row on its own: a refused row stays + pending and flagged, and the rows that saved are cleared. + +## TypeScript Support + +The schema and column types come from `@object-ui/types`; this package exports +the *component* types. Neither `GridSchema` nor `GridColumn` is on this +package's export surface, and both names are taken elsewhere by different +things — `GridSchema` in `@object-ui/types` is the CSS Grid **layout** +container, and `GridColumn` in `@object-ui/fields` is a column of the +line-items form widget (keyed `name`). The data-grid pair is +`ObjectGridSchema` + `ListColumn`. `ObjectGridSchema` types the component's +`schema` prop — the node as the grid reads it, after `SchemaRenderer` hoists an +authored node's `properties` bag onto it — so its keys sit flat here. + +```typescript +import type { ObjectGridSchema, ListColumn } from '@object-ui/types'; +import type { ObjectGridComponentProps } from '@object-ui/plugin-grid'; + +const nameColumn: ListColumn = { + field: 'name', + label: 'Full Name', + sortable: true +}; + +const grid: ObjectGridSchema = { + type: 'object-grid', + objectName: 'users', + columns: [nameColumn], + pagination: { pageSize: 20 } +}; + +// Row callbacks are COMPONENT props, not schema keys. +const gridProps: ObjectGridComponentProps = { + schema: grid, + onRowClick: (record) => console.log('Row clicked:', record) +}; +``` + +Annotating the literal is the point: an un-annotated `const schema = { … }` +type-checks no matter what is written in it, so a snippet that carries no +annotation cannot tell you whether its keys are real. + +## License + +MIT diff --git a/content/docs/frontend/plugins/plugin-kanban.mdx b/content/docs/frontend/plugins/plugin-kanban.mdx new file mode 100644 index 00000000000..92a278c97a1 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-kanban.mdx @@ -0,0 +1,229 @@ +--- +title: "Plugin Kanban" +--- + +Kanban board component with drag-and-drop powered by @dnd-kit. + +## Installation + +```bash +npm install @object-ui/plugin-kanban +``` + +This package publishes a stylesheet. Import it after the base sheets, or the board renders unstyled — the themed utilities it uses have no other source in a published app ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)): + +```css +/* src/index.css */ +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +The props an [`object-kanban`](/docs/ui/components/plugins#object-kanban) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Usage + +### Basic Usage + +```tsx +// Import once in your app entry point +import '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' + +// Use in schemas +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], + columns: [ + { + id: 'todo', + title: 'To Do', + cards: [ + { id: '1', title: 'Task 1', description: 'Do something' } + ] + }, + { + id: 'done', + title: 'Done', + cards: [] + } + ] +} + +// `onCardMove` is NOT a document key — JSON has no function value. The host +// supplies it as a React prop, separate from `schema` above. +const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => { + console.log(`Card ${cardId} moved`) +} +``` + +## Features + +- **Drag and drop cards** between columns — on an object-bound board + (`object-kanban`), only for a caller the `kanbanCardMove` row of the + affordance-to-grant map allows (the object's policy, the effective API + operation set and the caller's **update** grant); without it the cards are + not movable. With no permission provider mounted it reads open. +- **Column limits** (WIP limits) +- **Column totals**: a kanban view's `summarizeField` sums that field over each column's loaded cards in the column header, beside the count (with the count's `+` when the fetch window is full) +- **Card badges** for status/priority +- **Keyboard navigation** +- **Lazy-loaded** (~100-150 KB loads only when rendered) + +## Card Badges + +Use badges to show card status, priority, or categories: + +```tsx +import type { KanbanCard } from '@object-ui/plugin-kanban' + +const card: KanbanCard = { + id: 'task-1', + title: 'Important Task', + badges: [ + { label: 'Frontend', variant: 'default' }, + { label: 'Urgent', variant: 'destructive' }, + { label: 'Reviewed', variant: 'secondary' }, + { label: 'Blocked', variant: 'outline' } + ] +} +``` + +### Badge Variants + +- `default` - Blue badge +- `secondary` - Gray badge +- `destructive` - Red badge +- `outline` - Outlined badge + +## Column Limits (WIP Limits) + +Set maximum cards per column to enforce work-in-progress limits: + +```tsx +import type { KanbanColumn } from '@object-ui/plugin-kanban' + +const column: KanbanColumn = { + id: 'in-progress', + title: 'In Progress', + limit: 3, // Max 3 cards + cards: [ + { id: 'task-2', title: 'Implement authentication' } + ] +} +``` + +When the limit is reached, the column shows visual feedback. + +## Event Handling + +Handle card movements to update your backend or state: + +```tsx +import { useState } from 'react' +import type { KanbanColumn } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' + +declare function updateCardColumn(cardId: string, toColumnId: string, newIndex: number): Promise +declare function moveCard(columns: KanbanColumn[], cardId: string, toColumnId: string, newIndex: number): KanbanColumn[] + +export function useBoard(initialColumns: KanbanColumn[]) { + const [columns, setColumns] = useState(initialColumns) + + const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], + columns, + } + + // `onCardMove` is NOT a document key — the host supplies it as a React prop, + // separate from `schema` above. It returns void, so the callback cannot be + // awaited by the board: start the write and update local state without + // blocking the drop. + const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + // Update database + void updateCardColumn(cardId, toColumnId, newIndex) + + // Update local state + setColumns((prev) => moveCard(prev, cardId, toColumnId, newIndex)) + } + + return { schema, onCardMove } +} +``` + +### Quick Add is a React-host capability, not a document one + +The Quick Add button at the bottom of a lane is gated on **both** halves of a +pair: `quickAdd`, and an `onQuickAdd` handler. `onQuickAdd` is a function, so it +can only be supplied by a React host that mounts the board component directly: +`KanbanRenderer`, exported from `@object-ui/plugin-kanban`, reads both halves off +its `schema` and forwards them by identity. + +⚠️ There is **no node type key** that reaches it. The `kanban-ui` registration +retired with objectui#8257, so a document naming that tag is now an +`unknown-component` **error** rather than a board with a working pair. Import the +component instead. + +An **object-driven** board (`object-kanban`, or a kanban view inside `ListView` / +`ObjectView`) has no Quick Add control, and **both** halves of the pair are +**retired** on it: `quickAdd` since objectui#8285, and `onQuickAdd` since +objectui#11234. The board does not create records inline. Both +`ObjectKanbanSchema` faces in `@object-ui/types` refuse both keys by name, and +`@objectstack/spec` refuses `quickAdd`. `ObjectKanban` renders an internal board +that takes the pair only as explicit props, and it supplies neither — so even a +host that hands the board both halves of the pair gets no control. A page +written as constrained JSX draws the ordinary `unknown-prop` **warning** for +`quickAdd` (`validateTree` in `@objectstack/sdui-parser`), since the block has no +such prop. Delete both keys; to offer Quick Add, mount `KanbanRenderer` as above. + +## Bundle Size + +The plugin uses lazy loading to optimize bundle size: + +- **Initial load**: ~0.2 KB (entry point) +- **Lazy chunk**: ~100-150 KB (loaded when kanban is rendered) +- **Includes @dnd-kit** for drag-and-drop functionality + +## Accessibility + +The kanban board includes: + +- **Keyboard navigation** - Move cards with keyboard +- **Screen reader support** - ARIA labels for all interactions +- **Focus management** - Clear focus indicators + +## TypeScript Support + +`ObjectKanbanSchema` is published by `@object-ui/types` (and its Zod mirror by +`@object-ui/types/zod`); `@object-ui/plugin-kanban` publishes the card and column +shapes. Importing all three from the plugin does not resolve — its barrel never +exported the schema type. + +```ts +import type { ObjectKanbanSchema } from '@object-ui/types' +import type { KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' + +const column: KanbanColumn = { + id: 'todo', + title: 'To Do', + cards: [], + limit: 5 +} + +const kanbanSchema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], + columns: [column] +} +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Lazy-Loaded Plugins Architecture](/docs/frontend/plugins#lazy-loading-architecture) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-kanban) diff --git a/content/docs/frontend/plugins/plugin-list.mdx b/content/docs/frontend/plugins/plugin-list.mdx new file mode 100644 index 00000000000..5a66b1594a7 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-list.mdx @@ -0,0 +1,70 @@ +--- +title: "Plugin List" +description: "ListView plugin for ObjectUI - unified view component with view type switching" +--- + +## @object-ui/plugin-list + +ListView plugin for ObjectUI - unified view component with view type switching + +## Installation + +```bash +pnpm add @object-ui/plugin-list +``` + +The props a [`list-view`](/docs/ui/components/plugins#list-view) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## On a page — `requiredPermissions` + +A page `list-view` node is written in `@objectstack/spec`'s list view spelling, +with its keys in `properties`. `requiredPermissions` gates what the node draws: + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "requiredPermissions": ["sales.view_pipeline"] + } +} +``` + +`requiredPermissions` lists capabilities the viewer must all hold. A viewer +missing one sees "Insufficient permissions to view list." in place of the list, +as the record blocks show, and no records are queried. + +`addRecord` draws an Add button, and `position` places it (`"top"`, `"bottom"` +or `"both"`). The spec row declares `enabled` default `true` and `position` +default `"bottom"`, but the renderer does not apply those defaults: it draws the +button only for an explicit `enabled: true`, and places an absent `position` at +the top. objectui#13099 owns that gap; until it lands, write both keys. A click opens the +create form the object list's New opens: the record-form overlay, or the +full-page create for an object whose `editMode` is `page`. The button is not +drawn for a viewer who may not create, or under a host that serves no create. +`mode` and `formView` are not read. + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "addRecord": { "enabled": true, "position": "top" } + } +} +``` + +`sharing` is not a published input of a page node yet: the Share button has no +action the spec declares. + +## Status + +This plugin is part of the v3.3.0 release. For full API reference and usage +examples, see the package README: + +- 📦 [`@object-ui/plugin-list` on npm](https://www.npmjs.com/package/@object-ui/plugin-list) +- 📝 [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-list) +- 🐛 [Report an issue](https://github.com/objectstack-ai/objectstack/issues) + +> **Note:** A rich interactive documentation page for this plugin is coming soon. +> In the meantime, refer to the package README for schema, props and examples. diff --git a/content/docs/frontend/plugins/plugin-map.mdx b/content/docs/frontend/plugins/plugin-map.mdx new file mode 100644 index 00000000000..b2c6e4e95ee --- /dev/null +++ b/content/docs/frontend/plugins/plugin-map.mdx @@ -0,0 +1,337 @@ +--- +title: "Plugin Map" +--- + +Map visualization component for ObjectQL data sources - displays database records as map markers based on location data. + +## Installation + +```bash +npm install @object-ui/plugin-map +``` + +The props an [`object-map`](/docs/ui/components/plugins#object-map) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Overview + +The `@object-ui/plugin-map` plugin provides map visualization for ObjectQL data sources. It's designed to work with object-based data providers and automatically maps record fields to map markers/pins. + +**Note**: This is a basic implementation suitable for simple use cases. For production applications with advanced mapping needs, consider integrating a dedicated mapping library like Mapbox, Leaflet, or Google Maps. + +## Features + +- **ObjectQL Integration**: Works seamlessly with object/value data providers +- **Automatic Field Mapping**: Maps database fields to map markers +- **Location Support**: Handle latitude/longitude or combined location fields +- **Marker Customization**: Configurable marker titles and descriptions +- **Marker Clustering**: Group nearby markers (when many points) +- **Popup/Tooltip**: Show details on marker click +- **Interactive**: Click handling for markers + +## Usage + +An `object-map` node takes its props in its `properties` bag, whose members are +`@objectstack/spec`'s `ComponentPropsMap['object-map']` row. `objectui validate` +judges the bag against that row and refuses a prop written flat on the node by +name, naming its bag member (`Did you mean objectName → properties.objectName?`), +as the spec's own page component does (objectui#10859). `SchemaRenderer` hoists +the bag onto the node before `ObjectMap` runs, so `ObjectMapSchema` is the node as +the renderer reads it, and as code composes it. + +### Basic Usage with ObjectQL + +```tsx +import '@object-ui/plugin-map' +import type { ObjectMapBlockNode } from '@object-ui/types' + +const schema: ObjectMapBlockNode = { + type: 'object-map', + properties: { + objectName: 'locations', // Your ObjectQL object + map: { + latitudeField: 'lat', + longitudeField: 'lng', + titleField: 'name', + descriptionField: 'address' + } + } +} +``` + +### With Static Data + +```tsx +import type { ObjectMapBlockNode } from '@object-ui/types' + +const schema: ObjectMapBlockNode = { + type: 'object-map', + properties: { + staticData: [ + { + id: 1, + name: 'Office HQ', + lat: 37.7749, + lng: -122.4194, + address: '123 Main St, San Francisco, CA' + }, + { + id: 2, + name: 'Warehouse', + lat: 37.8044, + lng: -122.2711, + address: '456 Oak Ave, Oakland, CA' + } + ], + map: { + latitudeField: 'lat', + longitudeField: 'lng', + titleField: 'name', + descriptionField: 'address' + } + } +} +``` + +## Which style draws + +A map draws the first style it finds, in this order: + +1. the view's own style: the node-level `mapStyle`, then `map.style`; +2. the deployment's default style, which the host hands down through + `HostMapStyleProvider` from `@object-ui/react`. The Console supplies the + operator's `OS_CONSOLE_MAP_STYLE_URL`, served as `mapStyleUrl` on + `GET /api/v1/runtime/config`; +3. the public MapLibre demo style (`https://demotiles.maplibre.org/style.json`), + which is rate-limited and not meant for production traffic. + +A host that provides no style, such as an embedded renderer or a Console whose +operator set none, keeps the demo style. When the style that drew fails to load, +the map is replaced by its record list, and the notice names that style: the +view's own, the deployment's or the public default. + +## Data Providers + +### Object Provider (Database) + +```tsx +import type { ObjectMapBlockNode } from '@object-ui/types'; + +const retailStores: ObjectMapBlockNode = { + type: 'object-map', + properties: { + objectName: 'retail_stores', + map: { + latitudeField: 'store_lat', + longitudeField: 'store_lng', + titleField: 'store_name', + descriptionField: 'store_address' + } + } +}; +``` + +### Value Provider (Static) + +```tsx +const staticLocations = { + type: 'object-map', + properties: { + staticData: [ + { id: 1, name: 'Location 1', lat: 37.7749, lng: -122.4194 }, + { id: 2, name: 'Location 2', lat: 37.8044, lng: -122.2711 } + ], + map: { + latitudeField: 'lat', + longitudeField: 'lng', + titleField: 'name' + } + } +}; +``` + +### No API provider + +The view-level `data.provider: 'api'` is retired in `@objectstack/spec` +(objectstack#23082), and `ObjectMap` has no branch for it: a schema that still +carries one plots no markers and requests nothing. Without a `DataSource` it +fails one step earlier, with the error an object-bound map gets: +`DataSource required for the object provider`. +A metadata-declared API call is the page element's `dataSource` binding. + +Read from the database with the **Object Provider** above, or pass records you +already hold with the **Value Provider**. + +## Event Handling + +### Marker click navigation + +`navigation` takes the spec's `NavigationConfig` (`{ mode, size, width, +openNewTab, preventNavigation }`), the block a list view declares. On a map no +parent view navigates for: + +- **Absent**: a marker click opens nothing. This renderer supplies no drawer + default. +- `drawer`, and a block without `mode` (it takes the spec's `drawer` + default), open the marker's record in the drawer. +- `new_window` opens `/{objectName}/record/{id}` in a new tab. `none` opens + nothing. +- `page` opens the record page of the map's `objectName` through the record navigator the + host publishes (the console publishes one on its custom pages, record pages + and list views). Under a host that publishes none, such as an embedded + renderer, or on a map that names no `objectName`, there is no record page to + open and the click opens nothing. +- `preventNavigation: true` opens nothing whatever the mode. `openNewTab: true` + opens the record page in a new tab and outranks every mode except `none`. +- `size` picks the overlay width bucket; the deprecated `width` wins over it. + +A click handler from a parent view (`onRowClick`) outranks the whole key. + +### Marker Click + +`onMarkerClick` is a function, so it is no member of the `properties` bag (the +spec's row declares none) and no key of the node either: a React host passes it +as a prop, and `SchemaRenderer` forwards it to the map. `ObjectMapBlockNode` is +the node with its bag closed, so a misspelled key inside it is a compile error. + +```tsx +import { SchemaRenderer } from '@object-ui/react'; +import type { ObjectMapBlockNode } from '@object-ui/types'; + +const clickableMap: ObjectMapBlockNode = { + type: 'object-map', + properties: { + objectName: 'locations', + map: { + latitudeField: 'lat', + longitudeField: 'lng', + titleField: 'name' + } + } +}; + +export function ClickableMap() { + return ( + ) => { + console.log('Marker clicked:', location); + // Show location details, navigate to the location page, or open directions + }} + /> + ); +} +``` + +## Typical Use Cases + +1. **Store Locator**: Display retail store locations +2. **Fleet Tracking**: Show vehicle or delivery locations +3. **Real Estate**: Display property listings on a map +4. **Event Venues**: Show event or venue locations +5. **Field Service**: Track service calls or technician locations +6. **Customer Locations**: Visualize customer distribution +7. **Asset Tracking**: Show location of equipment or assets + +## Location Data Formats + +### Separate Fields + +```tsx +{ + id: 1, + name: 'Location', + latitude: 37.7749, + longitude: -122.4194 +} +``` + +### Combined String + +```tsx +{ + id: 1, + name: 'Location', + coordinates: '37.7749,-122.4194' +} +``` + +### Object Format + +```tsx +{ + id: 1, + name: 'Location', + location: { + lat: 37.7749, + lng: -122.4194 + } +} +``` + +## Map Controls + +The map typically includes: +- **Zoom controls**: Zoom in/out buttons +- **Pan**: Drag to move around the map +- **Marker click**: Click markers to show details +- **Auto-fit**: Automatically adjust view to show all markers + +## Integration with Full-featured Map Libraries + +For production applications, you may want to integrate with: + +- **Mapbox**: Advanced styling, 3D maps, routing +- **Google Maps**: Street view, directions, extensive POI data +- **Leaflet**: Open-source, lightweight, customizable +- **OpenStreetMap**: Free, community-driven map data + +This plugin provides a basic implementation. For advanced features like: +- Directions/routing +- Street view +- 3D buildings +- Traffic data +- Custom map styles +- Advanced geocoding + +Consider using one of the full-featured mapping libraries above. + +## TypeScript Support + +`ObjectMapConfig` types the `map` block. `ObjectMapSchema` is the node as +`ObjectMap` reads it, after `SchemaRenderer` hoists the `properties` bag, or as +code hands it to the component directly (``); an +authored node writes its props in the bag. + +```ts +import type { ObjectMapBlockNode, ObjectMapSchema, ObjectMapConfig } from '@object-ui/types' + +const mapConfig: ObjectMapConfig = { + latitudeField: 'lat', + longitudeField: 'lng', + titleField: 'name', + descriptionField: 'description', + zoom: 12, + center: [37.7749, -122.4194] +} + +// The authored node: its props in the `properties` bag. +const mapNode: ObjectMapBlockNode = { + type: 'object-map', + properties: { + objectName: 'locations', + map: mapConfig + } +} + +// The node as `ObjectMap` reads it, after the hoist. +const mapSchema: ObjectMapSchema = { + type: 'object-map', + objectName: 'locations', + map: mapConfig +} +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-map) diff --git a/content/docs/frontend/plugins/plugin-markdown.mdx b/content/docs/frontend/plugins/plugin-markdown.mdx new file mode 100644 index 00000000000..ce7591ebaa6 --- /dev/null +++ b/content/docs/frontend/plugins/plugin-markdown.mdx @@ -0,0 +1,181 @@ +--- +title: "Plugin Markdown" +--- + +Markdown renderer with GitHub Flavored Markdown support. + +## Installation + +```bash +npm install @object-ui/plugin-markdown +``` + +The props a [`markdown`](/docs/ui/components/plugins#markdown) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Usage + +### Basic Usage + +```tsx +// Import once in your app entry point +import '@object-ui/plugin-markdown' +import type { MarkdownSchema } from '@object-ui/plugin-markdown' + +// Use in schemas +const schema: MarkdownSchema = { + type: 'markdown', + content: '# Hello World\n\nThis is **markdown** text with [links](https://example.com).' +} +``` + +## Features + +- **GitHub Flavored Markdown** (tables, task lists, strikethrough) +- **XSS protection** (sanitized output) +- **Code syntax highlighting** +- **Lazy-loaded** (~100-200 KB loads only when rendered) + +## Supported Markdown Features + +### Headers + +```markdown +# H1 Header +## H2 Header +### H3 Header +#### H4 Header +##### H5 Header +###### H6 Header +``` + +### Text Formatting + +```markdown +**Bold text** +*Italic text* +~~Strikethrough~~ +`Inline code` +``` + +### Links and Images + +```markdown +[Link text](https://example.com) +![Alt text](https://example.com/image.jpg) +``` + +### Lists + +```markdown +- Unordered item 1 +- Unordered item 2 + - Nested item + +1. Ordered item 1 +2. Ordered item 2 + 1. Nested ordered item +``` + +### Task Lists + +```markdown +- [x] Completed task +- [ ] Incomplete task +- [ ] Another task +``` + +### Tables + +```markdown +| Column 1 | Column 2 | Column 3 | +|----------|----------|----------| +| Cell 1 | Cell 2 | Cell 3 | +| Cell 4 | Cell 5 | Cell 6 | +``` + +### Code Blocks + +````markdown +```javascript +function hello() { + console.log("Hello, World!"); +} +``` +```` + +### Blockquotes + +```markdown +> This is a blockquote +> It can span multiple lines +``` + +### Horizontal Rules + +```markdown +--- +*** +___ +``` + +## Styling with Tailwind + +Use Tailwind Typography plugin for better markdown styling: + +```tsx +import type { MarkdownSchema } from '@object-ui/plugin-markdown' + +const schema: MarkdownSchema = { + type: 'markdown', + content: '# My Document\n\nContent here...', + className: 'prose prose-lg prose-slate dark:prose-invert max-w-none' +} +``` + +Common prose classes: + +- `prose` - Base typography styles +- `prose-sm` / `prose-lg` / `prose-xl` - Size variants +- `prose-slate` / `prose-gray` - Color schemes +- `dark:prose-invert` - Dark mode support +- `max-w-none` - Remove max-width constraint + +## Security (XSS Protection) + +All markdown content is automatically sanitized using `rehype-sanitize` to prevent XSS attacks: + +```tsx +// Safe - HTML tags are sanitized +import type { MarkdownSchema } from '@object-ui/plugin-markdown' + +const schema: MarkdownSchema = { + type: 'markdown', + content: '**Safe** markdown with ' +} +// The script tag will be removed in the output +``` + +## Bundle Size + +The plugin uses lazy loading to optimize bundle size: + +- **Initial load**: ~0.2 KB (entry point) +- **Lazy chunk**: ~100-200 KB (loaded when markdown is rendered) +- **Includes react-markdown and plugins** + +## TypeScript Support + +```ts +import type { MarkdownSchema } from '@object-ui/plugin-markdown' + +const markdownSchema: MarkdownSchema = { + type: 'markdown', + content: '# Hello TypeScript!' +} +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Lazy-Loaded Plugins Architecture](/docs/frontend/plugins#lazy-loading-architecture) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-markdown) diff --git a/content/docs/frontend/plugins/plugin-timeline.mdx b/content/docs/frontend/plugins/plugin-timeline.mdx new file mode 100644 index 00000000000..d8c6c6c16fb --- /dev/null +++ b/content/docs/frontend/plugins/plugin-timeline.mdx @@ -0,0 +1,249 @@ +--- +title: "Plugin Timeline" +--- + +Timeline component with vertical, horizontal, and Gantt-style layouts for displaying events and project timelines. + +## Installation + +```bash +npm install @object-ui/plugin-timeline +``` + +The props an [`object-timeline`](/docs/ui/components/plugins#object-timeline) node reads on a page are generated from the spec into the component reference; this page covers using the plugin from React. + +## Usage + +### Basic Usage + +```tsx +// Import once in your app entry point +import '@object-ui/plugin-timeline' +import type { TimelineSchema } from '@object-ui/types' + +// Use in schemas - Vertical Timeline +const schema: TimelineSchema = { + type: 'timeline', + variant: 'vertical', + items: [ + { + time: '2024-01-15', + title: 'Project Started', + description: 'Kickoff meeting', + variant: 'success' + } + ] +} +``` + +## Features + +- **Three Layout Variants**: Vertical, horizontal, and Gantt-style layouts +- **Customizable Markers**: Color-coded markers with icon support +- **Date Formatting**: Multiple date format options +- **Gantt Charts**: Project timeline visualization with task bars +- **Time Scales**: Hour, day, week, month, quarter, or year scales for Gantt view +- **Lightweight**: Pure CSS and React components + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `variant` | string | `'vertical'` | Timeline layout: vertical, horizontal, or gantt | +| `items` | array | `[]` | Array of timeline items | +| `dateFormat` | string | `'short'` | Date formatting: short, long, or iso | +| `scale` | string | `'month'` | Gantt axis bucket: hour, day, week, month, quarter, or year | +| `rowLabel` | string | `'Items'` | Label for Gantt rows | +| `minDate` | string | auto | Override min date for Gantt (YYYY-MM-DD) | +| `maxDate` | string | auto | Override max date for Gantt (YYYY-MM-DD) | +| `className` | string | `''` | Additional Tailwind CSS classes | + +Every key above is declared on the exported `TimelineSchema`, so an editor +completes them and a wrong value is a type error. `scale` is the canonical axis +key and the only one — it is `@objectstack/spec`'s `ui/TimelineConfig.json` +spelling and the one the renderer reads. + + +`timeScale`, the pre-spec spelling of `scale`, is **retired** (objectui#6355). +It is no longer read, and it is no longer authorable: the key is tombstoned on +`TimelineSchema`, so writing it is a type error and a validation failure rather +than a chart that quietly falls back to the `month` default. Rename the key to +`scale` — the accepted values are unchanged. + + + +`events`, `orientation` and `position` are **retired** (objectui#6170). Nothing +ever read them — a timeline authored with `events` rendered an empty rail — and +they are no longer authorable: each is tombstoned on `TimelineSchema`, so +writing one is a type error and a validation failure. Write `items` instead of +`events` (each entry `{ time, title, … }`; the date goes in `time`, not +`date`) and `variant` instead of `orientation`. `position` has no replacement: +the vertical rail is always drawn on the left. + + +## Object-bound timeline (`object-timeline`) + +`object-timeline` fetches records from `objectName` and draws one entry per +record, from the fields its `timeline` config names. An authored node takes its +props in the `properties` bag, where `@objectstack/spec`'s +`ComponentPropsMap['object-timeline']` row declares them: + +```json +{ + "type": "object-timeline", + "properties": { + "objectName": "project_task", + "timeline": { "startDateField": "start_date", "titleField": "name" }, + "navigation": { "mode": "drawer" } + } +} +``` + +### Where the entries come from + +The first record source present wins: `items` (authored entries, drawn as +written), then `data` (pre-fetched records), then a `bind` path, then the query +on `objectName`. Only the query is a fetch, so `objectName` is optional: a +timeline drawn from `items`, `data` or a `bind` path needs none to draw (entry-click +navigation still reads it, below), and a node-level `dataSource` binding can +supply the object instead. Records, from +`data`, a `bind` path or the query, are composed into entries through the field +bindings below; authored `items` are not. + +| Prop | What the renderer does with it | +|------|--------------------------------| +| `timeline` | Names the record fields an entry is drawn from: `startDateField` (its date), `endDateField` (the end printed after it), `titleField`, `groupByField` (the field whose value heads each group; without it, entries group into date buckets) and `colorField` (the marker colour: the field's option colour for the value, or the value itself when it is a colour literal). Each outranks the flat key of the same binding. `scale` sets the gantt axis unit. | +| `mapping` | `{ title, date, description, variant }` field names. `title` and `date` are read where the `timeline` block leaves the binding unset, ahead of the flat keys. `variant` names the field whose value picks the marker colour (the record's own `variant` field without it); on the vertical rail a colour from `colorField` outranks it. | +| `descriptionField` | The field an entry's description is read from (default `description`); `mapping.description` outranks it. | +| `limit` | The row cap, lowered to `$top` (default 100). A value that is not a positive integer is ignored with a console warning. | +| `filter` / `sort` | Lowered to `$filter` / `$orderby` on the query, so they do nothing when `items` or `data` supply the entries. The rail draws entries composed from records by start date, earliest first, whatever `sort` says. | +| `dateFormat` | `short` (the default) or `long`, both locale-formatted, or `iso`. | +| `rowLabel`, `minDate`, `maxDate` | The gantt's row-column header and its pinned axis start and end, read only by the gantt branch, which on this block draws authored `items`. An empty `minDate` / `maxDate` is not honoured; a value that is not a date, or a start after the end, refuses the chart with a diagnostic. | + +### Entry-click navigation + +`navigation` is what an entry click opens — the spec's `NavigationConfig` by +reference, `ViewNavigationConfig` in `@object-ui/types`, the type +`ObjectGridSchema.navigation` uses: `mode` (`drawer`, `page`, `new_window` +or `none`) with `size`, `openNewTab` and `preventNavigation`. + +| Member | On an entry click | +|--------|-------------------| +| `mode: 'drawer'`, or a block without `mode` (the spec's `drawer` default) | Opens the entry's record in the drawer | +| `mode: 'new_window'`, or `openNewTab: true` | Opens the record page in a new tab; `openNewTab` outranks every mode except `none`. On a timeline that names no `objectName` there is no record page: the tab opens at a slash and the entry's `id` alone (its `_id` when it has no `id`, `undefined` when it has neither) | +| `mode: 'page'` | Opens the record page of the timeline's `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, such as an embedded renderer, or on a timeline that names no `objectName`, it opens nothing | +| `preventNavigation: true`, or `mode: 'none'` | Opens nothing, whatever else the block says | +| `size` | Sets the overlay width (`sm` … `full`, or `auto` for the default) | + +A click handler from a parent view outranks the whole key. + + +With the key absent a click opens nothing: unlike the board and the calendar, +this renderer supplies no drawer default. + + +## Variants + +### Marker Variants + +Available marker/bar colors: + +- `default` - Gray +- `success` - Green +- `info` - Blue +- `warning` - Yellow +- `danger` - Red + +```tsx +const item = { + time: '2024-01-15', + title: 'Important Milestone', + variant: 'success' // Green marker +} +``` + +## Date Formatting + +The timeline component supports three date formats: + +```tsx +import type { TimelineSchema } from '@object-ui/types' + +const short: TimelineSchema['dateFormat'] = 'short' // "1/15/2024" +const long: TimelineSchema['dateFormat'] = 'long' // "January 15, 2024" +const iso: TimelineSchema['dateFormat'] = 'iso' // "2024-01-15" +``` + +A date-only value (`2024-01-15`) names a calendar day, so every face prints that day in every viewer's time zone, and the object timeline buckets and sorts it as that day. A value with a time part is an instant: `short` and `long` print its day in the viewer's zone, and `iso` prints its UTC day. + +Without a `groupByField`, the object timeline groups entries into date buckets (Earlier, Today, Tomorrow, This week, Next week, Later, No date). "This week" starts on the first day of the week of the user's display locale, the day the calendar's weeks start on: Sunday under `en-US`, Monday under `en-GB` or `zh-CN`. + +## Time Scales (Gantt) + +For Gantt-style timelines, choose the appropriate time scale: + +```tsx +import type { TimelineSchema } from '@object-ui/types' + +const shortProject: TimelineSchema['scale'] = 'day' // short projects +const mediumProject: TimelineSchema['scale'] = 'week' // medium projects +const longProject: TimelineSchema['scale'] = 'month' // long projects +``` + +A date-only bar date, `minDate` or `maxDate` (`2024-01-15`) names a calendar day, so the axis headers, the bar positions and the bar tooltips all place it on that day in every viewer's time zone. A value with a time part is an instant and sits at its hour in the viewer's day. A plan with no bar dates and no pinned range shows a one-day axis on the viewer's today. + +The headers and the bars share one axis. It runs from the start of the unit that holds the first date to the end of the unit that holds the last. Each header is as wide as its unit is long: hour, day and week columns are equal, and a month is as wide as its days. So a bar lines up under the header of the unit it starts in. + +## Customization + +### Custom Icons + +```tsx +const item = { + time: '2024-01-15', + title: 'Milestone', + icon: '🎯', // Any emoji or short text + variant: 'success' +} +``` + +### Custom Colors + +Use Tailwind CSS classes for additional styling: + +```tsx +import type { TimelineSchema } from '@object-ui/types' + +const schema: TimelineSchema = { + type: 'timeline', + variant: 'vertical', + className: 'max-w-4xl mx-auto', + items: [ + { time: '2024-01-15', title: 'Milestone', variant: 'success' } + ] +} +``` + +## TypeScript Support + +```ts +import type { TimelineSchema } from '@object-ui/types' + +const timelineSchema: TimelineSchema = { + type: 'timeline', + variant: 'vertical', + items: [ + { + time: '2024-01-15', + title: 'Event', + description: 'Description', + variant: 'success' + } + ] +} +``` + +## Related Documentation + +- [Plugin System Overview](/docs/frontend/plugins) +- [Package README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/plugin-timeline) diff --git a/content/docs/frontend/plugins/plugin-view.mdx b/content/docs/frontend/plugins/plugin-view.mdx new file mode 100644 index 00000000000..d6d5c17694a --- /dev/null +++ b/content/docs/frontend/plugins/plugin-view.mdx @@ -0,0 +1,849 @@ +--- +title: "Plugin View" +--- + +Unified view component for ObjectQL objects with automatic form and grid generation. + +## Installation + +```bash +npm install @object-ui/plugin-view +``` + +## Example schemas + +Each preview below **is** an `object-view` node drawn by this plugin — the JSON +in the Code tab is the whole example, and the rows on screen came out of a +`find()` call, not out of that JSON. The records are served by the docs site's +demo data source, because `dataSource` is not a schema key: it is the prop the +registered renderer pulls off `SchemaRendererProvider` context (see +[Schema API](#schema-api) below), so in your own app these same nodes read +whatever object your data source serves. That fixture answers search and sort +but not filters, which is the only reason `showFilters: false` is authored +here. + +### The list surface + +**Object View — List Surface** — object-view rendering the users object: the columns declared in `table`, with search and sort served by the host's data source. + +```json +{ + "type": "object-view", + "objectName": "users", + "title": "User Directory", + "description": "Everyone with an account", + "defaultViewType": "grid", + "showSearch": true, + "showSort": true, + "showFilters": false, + "showCreate": false, + "searchableFields": [ + "name", + "email", + "department" + ], + "table": { + "columns": [ + "name", + "email", + "title", + "department", + "status" + ], + "sort": [ + { + "field": "name", + "order": "asc" + } + ], + "pagination": { + "pageSize": 5 + } + } +} +``` + +### Saved views + +**Object View — Saved Views** — Two `listViews` entries over one object, each with its own label and column set; `defaultListView` picks which opens first. + +```json +{ + "type": "object-view", + "objectName": "users", + "title": "User Directory", + "description": "Two saved views over one object", + "defaultListView": "directory", + "showSearch": true, + "showFilters": false, + "showCreate": false, + "listViews": { + "directory": { + "label": "Directory", + "columns": [ + "name", + "email", + "title", + "department", + "status" + ] + }, + "contacts": { + "label": "Contact Details", + "columns": [ + "name", + "email", + "department" + ] + } + } +} +``` + +### The record surface + +**Object View — Record Surface** — Create, read and edit as one surface: `layout` decides where it opens, `form` decides what it contains, and a row click chooses the record. + +```json +{ + "type": "object-view", + "objectName": "users", + "title": "User Directory", + "description": "Create and edit open on the record surface", + "layout": "drawer", + "navigation": { + "mode": "drawer" + }, + "showSearch": false, + "showFilters": false, + "showCreate": true, + "operations": { + "create": true, + "read": true, + "update": true, + "delete": true + }, + "table": { + "columns": [ + "name", + "email", + "title", + "status" + ] + }, + "form": { + "fields": [ + "name", + "email", + "title", + "department", + "status" + ], + "showSubmit": true, + "submitText": "Save user", + "showCancel": true + } +} +``` + +## Features + +- **Automatic Views** - Generate views from ObjectQL schemas +- **Form Generation** - Auto-generate forms from object definitions +- **Grid Generation** - Auto-generate data grids +- **CRUD Operations** - Built-in create, read, update, delete +- **Field Mapping** - Automatic field type detection +- **ObjectQL Integration** - Native ObjectStack support + +## Schema API + +### ObjectView + +The keys below are the ones `ObjectView` actually reads off the schema node +(`packages/plugin-view/src/ObjectView.tsx`). The node is typed by +`ObjectViewSchema` in `@object-ui/types` — `objectName` and `type` are its only +required keys, and every example on this page carries the annotation, so a +missing `objectName` fails to compile rather than rendering an empty view. + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const shape: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', // required — the ObjectQL object name + title: 'Users', + description: 'Everyone with an account', + + // --- List surface --- + defaultViewType: 'grid', // grid | kanban | gallery | calendar | timeline | gantt | map + listViews: { all: { label: 'All Users', columns: ['name', 'email'] } }, // named views; each needs `label` and `columns` + defaultListView: 'all', + table: { columns: ['name', 'email'] }, // grid configuration (see below) + + // --- Record surface (create / edit / read) --- + layout: 'drawer', // drawer | modal | page + form: { showSubmit: true }, // form configuration (see below) + navigation: { mode: 'drawer' }, // row-click behaviour + onNavigate: (recordId, mode) => {}, // required by layout/navigation 'page' + + // --- Toolbar --- + showSearch: true, + showFilters: true, + showSort: true, + showCreate: true, + showViewSwitcher: false, // default false + allowCreateView: false, + + // --- Built-in CRUD toggles --- + operations: { create: true, read: true, update: true, delete: true }, +}; +``` + +The object name key is **`objectName`**. There is no `object`, `viewMode`, +`fields`, `mode`, `recordId`, `fieldConfig`, `nestedFields`, `tabs`, +`enableDelete`, `filters` or `searchable` key on this node — none of them is a +declared member of `ObjectViewSchema`, and none is read anywhere in +`packages/plugin-view/src`. Because `type: 'object-view'` *is* registered, a +node built from those keys still resolves to a renderer; it just never receives +an `objectName`, and the component's data effects are all guarded on it +(`ObjectView.tsx`'s `if (!dataSource || !schema.objectName) return;` guards), so +the result is a silent empty view rather than an error. + +Three structural facts about this node: + +- **`dataSource` is not a schema key.** It is a **required prop** of + `ObjectViewProps` (`ObjectView.tsx:146`). Pass it to `` directly, + or let the registered renderer pull it off `SchemaRendererProvider` context. + Putting `dataSource` inside the schema object does nothing. +- **There is no `viewMode`, and no per-record `mode` / `recordId`.** The list + type is `defaultViewType` (plus `listViews` / `defaultListView`); create, edit + and read are internal states of one record surface, opened by the toolbar's + create button and by row actions and rendered as a drawer, a modal or a page + according to `layout`. Accordingly `ObjectViewSchema['form']` omits `mode` — + the component sets it. +- **There are no `onCreate` / `onUpdate` / `onDelete` callbacks.** The component + performs mutations itself through the `dataSource`. What you can author is + `operations` (booleans that enable or disable each built-in) and + `onNavigate(recordId, mode)`, which hands off to your router when the record + surface is a page. + +#### `table` and `form` sub-configuration + +`table` carries grid configuration and `form` carries form configuration, but +`ObjectView` forwards a **fixed set of keys** from each rather than passing the +object through: + +| Sub-config | Keys `ObjectView` forwards | +| --- | --- | +| `table` | read by name: `columns`, `fields`, `title`, `filter`, `sort`, `pagination`, `pageSize`, `selection`, `selectable`, `operations`, `className`; handed to the grid as written: `editable`, `singleClickEdit`, `frozenColumns`, `rowHeight`, `resizable`, `reorderableColumns`, `searchableFields`, `showSearch`, `showPagination`, `showColumnTypeIcons`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs`, `exportOptions`, `grouping`, `aggregations`, `rowColor`, `label` | +| `form` | `fields`, `customFields`, `sections`, `groups`, `layout`, `columns`, `title`, `description`, `subforms`, `buttons`, `defaults`, `initialValues`, `readOnly`, `showSubmit`, `submitText`, `showCancel`, `cancelText`, `showReset`, `className` | + +`table` declares exactly these keys (objectui#10976). Any other grid key — +`emptyState`, `showFilters`, `description`, `keyboardNavigation`, the record +source (`data`, `staticData`), the row click (`navigation`, `onNavigate`: write +those on the `object-view` node), node-level keys such as `id`, `hidden` or +`style`, and the legacy aliases `batchActions` / `resizableColumns` (write +`bulkActions` / `resizable`) — has nothing to act on in the grid the view +draws, so TypeScript rejects it and the validator refuses it by name. Anything +else you put in `form` is ignored. + +The keys handed to the grid as written reach the grid the registered renderer +draws. Where the active named view declares the same member (see below), the +named view wins and `table` is the fallback. A host that supplies +`renderListView` composes its own list and takes `columns`, `fields`, `filter` +and `sort` from `table`, as before. + +The forwarding is literal, key by key, spread across three sites in +`ObjectView.tsx` — the non-grid data fetch, the `gridSchema` memo, and the +`mergedFilters` / `mergedSort` pair handed to a host-supplied list renderer — +so the keys below behave the same on every rendering path. + +> **`table.defaultFilters` is retired (objectui#12030).** It was the legacy +> spelling of `table.filter`, the same rule array. `@objectstack/spec` 18 +> refuses `object-grid`'s `defaultFilters` by name (a retired-key tombstone, +> objectstack#11509), and neither `ObjectView` nor `ObjectGrid` reads it any +> more: a view that still carries it renders **unfiltered**. Rename the key to +> `filter`; the value does not change. + +> **`table.defaultSort` is retired (objectui#5861).** It was the legacy +> single-entry spelling of `table.sort`. The installed `@objectstack/spec` +> protocol refuses `object-grid`'s `defaultSort` by name (a retired-key +> tombstone), and `ObjectView` no longer reads it on any of the three +> paths above (nor does `ObjectGrid`): a view that still carries it renders +> **unsorted**. Rename the key to `sort` and wrap the value in an array — +> `defaultSort: { field: 'name', order: 'asc' }` becomes +> `sort: [{ field: 'name', order: 'asc' }]`. + +#### Canonical keys now take effect (objectui#5102) + +Two of the forwarded `table` keys are pairs — a canonical `ObjectGridSchema` +key and the `@deprecated` legacy spelling it replaced. Both work; write the +canonical one. (Two more pairs, `filter` / `defaultFilters` and `sort` / +`defaultSort`, used to be listed here; their legacy halves are retired — see +the notes above.) + +| write this (canonical) | not this (legacy alias — still works) | +| --- | --- | +| `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | +| `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | + +Before objectui#5102, `pagination` / `selection` / `filter` / `sort` had **no +read point at all** in this file: an author who wrote the canonical shape +`ObjectGridSchema`'s own JSDoc recommends got a view that compiled, read +correctly, and silently did nothing. That is fixed — the legacy spellings on +the right are simply no longer the ones to reach for. + +**When a key is written both ways, the canonical spelling wins** — that is +`ObjectGrid`'s own existing resolution for the paging pairs +(`schema.pagination?.pageSize || schema.pageSize`; `if +(schema.selection?.type) … else if (schema.selectable !== undefined)`), and +`ObjectView` defers to it by forwarding both slots rather than re-resolving +the pair itself. `table.filter` reaches the grid in its one `filter` slot +(objectui#11880), unless it lowers to nothing (absent, `[]` or `{}`), so its +export carries the filter in force and a change to it returns the grid to +page 1: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const bothSpellingsWritten: ObjectViewSchema = { + type: 'object-view', + objectName: 'products', + table: { + pagination: { pageSize: 10 }, // wins + pageSize: 50, // ignored while `pagination` is present + }, +}; +``` + +⚠️ **`filter` / `sort` have one more tier ahead of `table` entirely**, and it +predates this change: an **active named view's own** `filter` / `sort` +(`listViews..filter` / `.sort`) always outranks anything written on +`table`. In order, highest first: the active named view's `filter`/`sort`, +then `table.filter`/`table.sort`. Neither has a legacy tier: `table.defaultFilters` +and `table.defaultSort` are retired. If you never write `listViews`, that +first tier never applies. + +Whichever tier wins, its filter goes through `resolveFilterPlaceholders` from +`@object-ui/core` before it leaves `ObjectView` (objectui#10506), on all three +paths: the query a non-grid view issues, the grid, and a host's +`renderListView`. So the spec's context tokens (`{current_user_id}`, +`{current_org_id}`) and the relative-date macros (`{today}`, +`{current_quarter_start}`, …) arrive as real values. The user and organization +come from the nearest `FilterScopeProvider` (`@object-ui/react`), which the +console shell mounts; with none mounted a context token is left as written and +the resolver warns. The filter is never widened: the ObjectStack server resolves +the literal token for a signed-in request and refuses the request otherwise +(the REST face answers 401 at its auth gate before the filter is read, and +where a guest context reaches the engine the resolver answers 400), and a +backend with no resolver of its own matches no record. + +`pagination` and `selection` have no such tier, and no effect outside the +grid: `defaultViewType: 'kanban' | 'gallery' | 'calendar' | 'timeline' | +'gantt' | 'map'` don't page or multi-select, so `ObjectView` never forwards +either spelling to those renderers. + +`columns` is the one forwarded `table` key that is **not** part of this +canonical/legacy story, and its gap is still open: it is forwarded on the grid +path only (the row above, and the section below), so on a non-grid +`defaultViewType` the field list still comes from `table.fields` +(objectui#5269). + +## Usage + +### Registration is a side effect of the import + +```ts +import '@object-ui/plugin-view'; +``` + +That single import is the whole of registration — there is no components map to +iterate over. Importing the entry runs the five `ComponentRegistry.register(...)` +calls in `packages/plugin-view/src/index.tsx`, which claim exactly these schema +types: + +| Namespaced key | Bare-name fallback | Renderer behind it | +| --- | --- | --- | +| `plugin-view:object-view` | `object-view` | `ObjectViewRenderer` — list plus integrated create / edit | +| `plugin-view:view:simple` | `view:simple` | `SimpleViewRenderer` — container-only view | +| `view:view-switcher` | `view-switcher` | `ViewSwitcher` | +| `view:filter-ui` | `filter-ui` | `FilterUI` | +| `view:sort-ui` | `sort-ui` | `SortUI` | + +Both spellings resolve here: `ComponentRegistry.register` publishes +`namespace:type` and, unless the call passes `skipFallback: true`, the bare +`type` as a back-compat fallback (`ComponentRegistry.register` in +`packages/core/src/registry/Registry.ts`, its `!meta?.skipFallback` branch). No +call in this package sets `skipFallback`. Note the +namespaces are not uniform — `object-view` and `view:simple` register under +`plugin-view`, the three control components under `view`. + +The bare `view` alias of `object-view` is RETIRED (objectui#10859 batch 8): it +named the same renderer as `object-view`, and `objectui validate` refused it at +`type`. Author `object-view`. + +The `shared-view-link` node key is RETIRED too (objectui#10859 batch 8, phase 2b): +no schema produced it, and `objectui validate` refused it at `type`. +`SharedViewLink` is still exported; mount the component directly. + +`ObjectViewRenderer` and `SimpleViewRenderer` are internal wrappers and are not +exported: the wrapper resolves `dataSource` from the renderer context and hands +the schema to `ObjectView`, which takes `dataSource` as a required prop rather +than as a schema key. + +### Registering a component under your own key + +To serve one of this package's components under a key of your own, register the +exported component — that is what a manual registration is here: + +```ts +import { ComponentRegistry } from '@object-ui/core'; +import { ViewSwitcher } from '@object-ui/plugin-view'; + +ComponentRegistry.register('my-switcher', ViewSwitcher, { namespace: 'my-app' }); +``` + +`ObjectView`, `ViewSwitcher`, `FilterUI`, `SortUI`, `SharedViewLink`, +`ViewTabBar` and `ManageViewsDialog` are on the package's export surface, +alongside the derivation helpers (`deriveRecordSurface`, `deriveFieldOptions`, +`toFilterGroup`, `toSortItems`, …) and the component prop types +(`ObjectViewProps`, `ViewSwitcherProps`, …). There is no aggregate map among +them, and no schema types — the authored `object-view` node is typed by +`@object-ui/types`. + +## Examples + +### Choosing the list type + +The list is always rendered. `defaultViewType` picks which renderer draws it, +and `table` configures the grid: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const userDirectory: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', + defaultViewType: 'grid', + table: { + columns: ['name', 'email', 'title', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + }, +}; +``` + +Non-grid types (`kanban`, `gallery`, `calendar`, `timeline`, `gantt`, `map`) are +rendered through `SchemaRenderer`, so `@object-ui/react` and the matching plugin +must be installed for those. On that path the field list comes from +`table.fields` (or from the active named view's `columns`), not from +`table.columns`. + +### Configuring the record form + +Create and edit share one record surface. `layout` chooses where it opens and +`form` configures what it contains — there is no separate "form view" node and +no authored `mode`: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const userForm: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', + layout: 'drawer', // drawer | modal | page + form: { + fields: ['name', 'email', 'title'], + submitText: 'Save user', + showCancel: true, + }, +}; +``` + +When `layout` is omitted, a record opens in the drawer, which expands to the +full page, and a mobile viewport pages. The object's field count does not pick +the surface: a full page is declared (`layout: 'page'`, or the view's +`navigation.mode: 'page'`), never guessed. A page is handed to `onNavigate`; +with none to hand it to, as on the registered `object-view` renderer, whose JSON +schema cannot carry a function, the page falls back to the drawer for create, +edit and read. + +### Opening a record + +Reading a record is the same surface in its read state, reached by clicking a +row — there is no `recordId` to author, because the click chooses the record. +`navigation.mode` decides how it opens, and `onNavigate` is what hands a +page-mode record off to your router: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const userDetail: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', + layout: 'page', + navigation: { mode: 'page' }, // drawer (the default) | page | new_window | none + onNavigate: (recordId, mode) => { + // mode is 'view' or 'edit' + router.push(`/users/${recordId}${mode === 'edit' ? '/edit' : ''}`); + }, +}; +``` + +Without an `onNavigate` handler, `page` mode has nowhere to send the user, so +keep the two together: a `page` layout without one falls back to the drawer, +and a row click under `navigation: { mode: 'page' }` opens nothing. +`navigation: { mode: 'none' }` (or `preventNavigation`) +makes rows inert. An active named view (`listViews`, under Read/List) that +declares its own `navigation` replaces this one, as a whole, while it is shown. +A `navigation` block written without `mode` takes the spec's `drawer` default. + +With no host `onRowClick`, a Cmd/Ctrl-click or middle-click on a row opens the +record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'`, +`preventNavigation`, and `operations.read: false` without a `navigation` config +ignore a modifier click too. + +## CRUD Operations + +All four operations are built in and run against the `dataSource` prop. You do +not wire handlers for them — you switch them on or off with `operations`, and +the `show*` flags control whether the matching toolbar affordance is visible. + +### Create + +`operations.create` enables record creation; `showCreate` shows the button. Both +default to on, and the button also needs the `listNew` row of the +affordance-to-grant map (`@object-ui/core`) to allow it — the object's policy, +the effective API operation set and the caller's **create** grant — exactly as +the console's list pages read it. With no permission provider mounted that grant +reads open. The new-record form opens in the drawer a `drawer` navigation +names, and on the `layout` surface otherwise (see Opening a record): + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const productCreate: ObjectViewSchema = { + type: 'object-view', + objectName: 'products', + showCreate: true, + operations: { create: true }, + layout: 'drawer', + form: { fields: ['name', 'price', 'category'] }, +}; +``` + +With a `page` surface, authored or derived, creation calls +`onNavigate('new', 'edit')`, whatever the navigation, so the host route owns the +form. With no `onNavigate`, the page has nowhere to route, so it falls back to +the drawer. + +### Read/List + +Search, filter and sort are toolbar toggles; the column set, filter, sort and +page size live in `table`: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const productList: ObjectViewSchema = { + type: 'object-view', + objectName: 'products', + defaultViewType: 'grid', + showSearch: true, + showFilters: true, + showSort: true, + table: { + columns: ['name', 'price', 'category'], + filter: [{ field: 'category', operator: 'equals', value: 'electronics' }], + pagination: { pageSize: 25 }, + }, +}; +``` + +Saved views are `listViews`, keyed by view name, with `defaultListView` +selecting which opens first: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const productViews: ObjectViewSchema = { + type: 'object-view', + objectName: 'products', + listViews: { + all: { label: 'All Products', type: 'grid', columns: ['name', 'price'] }, + cheap: { + label: 'Under 100', + type: 'grid', + columns: ['name', 'price'], + filter: [{ field: 'price', operator: 'less_than', value: 100 }], + }, + }, + defaultListView: 'all', +}; +``` + +**On the registered renderer.** An authored `object-view` node, and the Studio's +view preview, draws a grid named view through `ObjectGrid`. Ten grid members the +protocol declares under the same name on a named view and on `object-grid` — +`pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, +`conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and +`exportOptions` — come from the active named view first, and fall back to +the same key on `table` (`pagination` and `selection` since objectui#10885, the +rest since objectui#10976). A named view's +`hiddenFields` removes those fields from the columns the grid draws, when a +column list is declared, and its `fieldOrder` then orders the columns that +remain, the way `ListView` orders them on a host's `renderListView`. Its +`inlineEdit` turns on in-cell editing, and only where the object grants inline +edit to the user. Its `navigation` replaces the node's `navigation` as a whole: +the row click, and the surface and width of the record, create and edit forms, +follow the active named view, on this path, and on a host's `renderListView` +when the host wires the `onRowClick` it is handed and passes `ObjectView` no +`onRowClick` of its own. `label` and `data`, +also declared on both, are not +handed to the grid on this path: the named view's `label` is already the tab's +text, and `data` waits on objectui#10971, because `ListView` and `ObjectGrid` +pick different objects for it. +`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins the ten +grid members and `hiddenFields`; `ObjectView.namedViewNavigation-10885.test.tsx` +and `ObjectView.namedViewInlineEdit-10885.test.tsx` pin `navigation` and +`inlineEdit`, and `@object-ui/app-shell`'s +`objectViewRouteParity.fieldOrder-10885.test.tsx` pins `fieldOrder` against +`ListView`. + +**On a host's `renderListView`.** A host that composes `ObjectView` with both +`listViews` and its own `renderListView` receives a `list-view` node for the +active view. For the list members the protocol declares on a named view — list +chrome (`description`, `compactToolbar`, `allowPrinting`, `showRecordCount`, +`sharing`, `aria`, `emptyState`), record actions (`addRecord`, `inlineEdit`, +`rowActions`, `bulkActions`, `bulkActionDefs`, `exportOptions`), grid +presentation (`rowHeight`, `pagination`, `selection`, `resizable`, +`hiddenFields`, `conditionalFormatting`), and search, filter and navigation +(`searchableFields`, `userFilters`, `navigation`) — that node takes the value +from the active named view first, ahead of the host's `views` entry +(objectui#10758). `filterableFields` is relayed from no source: `@objectstack/spec` +18 retires it, and no per-view Filter-panel whitelist replaces it — the Filter +panel offers every field the user may read, minus the fields the object marks +`hidden: true` (objectui#13118). +`src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx` pins each family. + +**Inline editing on that node is on by default.** Both keys below are the +author's permission. `ListView` reads the node's `userActions.editInline` with +the spec's default, on since the v18 ruling (objectstack#22605), so a view that +declares neither key offers the inline-edit toggle (objectui#12086), and +`userActions.editInline: false` is how a view withdraws it. When the node +carries no `editInline` of its own, its `inlineEdit` stands in for it +(objectui#5144). The named view's keys win over the host `views` entry's, as +for every member above: + +| The view declares | Inline-edit toggle | Grid opens in edit mode | +| --- | --- | --- | +| neither key | offered | no | +| `inlineEdit: true` | offered | yes | +| `inlineEdit: false` | not offered | no | +| `userActions.editInline: true` | offered | only with `inlineEdit: true` | +| `userActions.editInline: false` | not offered | no, whatever `inlineEdit` says | + +The toggle switches the edit mode for the session and reports it through +`ListView`'s `onInlineEditChange`. The console stores nothing for it: each load +opens in the mode the view's `inlineEdit` declares, so a user's choice is not +remembered across loads. A view where an earlier console stored +`inlineEdit: false` still reads off; that data is not migrated, and declaring +`userActions.editInline: true` brings the toggle back. A list whose records are +read-only by nature declares `userActions.editInline: false`. Neither key opens +editing past the object's own editability or the user's permission to update +the object. + +### Update + +Editing is reached from a row's edit action, and `operations.update` is what +gates it. The edited record is chosen by the click, never by an authored +`recordId`: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const productEdit: ObjectViewSchema = { + type: 'object-view', + objectName: 'products', + operations: { update: true }, + layout: 'modal', + form: { fields: ['name', 'price'], submitText: 'Update' }, +}; +``` + +Under a `page` surface this becomes `onNavigate(recordId, 'edit')`; a page with +no `onNavigate` falls back to the drawer. + +### Delete + +`operations.delete` enables both the per-row delete and bulk delete. There is no +`enableDelete` key and no `onDelete` callback: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const productNoDelete: ObjectViewSchema = { + type: 'object-view', + objectName: 'products', + operations: { create: true, read: true, update: true, delete: false }, +}; +``` + +## Field Configuration + +There is no `fieldConfig` key. Labels, types, requiredness and validation come +from the object's own metadata, which the view reads through the `dataSource` — +that is what makes the view "automatic". What the schema node chooses is +**which** fields appear and how they are grouped: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const userFields: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', + table: { + columns: ['name', 'email', 'title'], // grid columns + }, + form: { + fields: ['name', 'email', 'title'], // flat field list, or use sections + sections: [ + { name: 'identity', label: 'Identity', fields: ['name', 'email'] }, + { name: 'work', label: 'Work', fields: ['title'] }, + ], + columns: 2, + }, +}; +``` + +To override a field's rendering beyond what the object metadata says, use +`form.customFields` (full field definitions) rather than a per-field patch on +the view node. + +## Advanced Features + +### Child records (master-detail) + +There is no `nestedFields` key. A child collection is declared as a **subform** +on the record form, which is where an order's line items belong: + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const orderView: ObjectViewSchema = { + type: 'object-view', + objectName: 'orders', + layout: 'page', + form: { + fields: ['order_number', 'customer', 'total'], + subforms: [ + { + childObject: 'order_items', + title: 'Line items', + columns: [{ name: 'product' }, { name: 'quantity' }, { name: 'price' }], + }, + ], + }, +}; +``` + +Only `childObject` is required — the relationship field and the grid columns are +derived from the child object's metadata unless you override them +(`relationshipField`, `columns`). A column is `@objectstack/spec`'s +`InlineGridColumn`: an object keyed by `name`, never a bare field name. One that +declares no `type` takes its label, type and the rest from the child field; +an undeclared column key is refused by the validator. + +### View tabs + +There is no `tabs` key, and `form.layout` has no tabbed value +(`vertical | horizontal`). The tab strip this package ships is +the **saved-view** tab bar: declare the views and render `` (or let +a host such as `@object-ui/app-shell` do it): + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const userTabs: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', + showViewSwitcher: true, + allowCreateView: true, + listViews: { + active: { label: 'Active', type: 'grid', columns: ['name', 'email'] }, + admins: { + label: 'Admins', + type: 'grid', + columns: ['name', 'email'], + filter: [{ field: 'title', operator: 'equals', value: 'Manager' }], + }, + }, + defaultListView: 'active', +}; +``` + +To group a *form's* fields instead, use `form.sections` as shown under "Field +Configuration". + +## Integration with ObjectQL + +The adapter is the `dataSource` **prop**, not part of the schema: + +```tsx +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; +import { ObjectView } from '@object-ui/plugin-view'; +import type { ObjectViewSchema } from '@object-ui/types'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com', + token: 'your-auth-token', +}); + +const contactView: ObjectViewSchema = { + type: 'object-view', + objectName: 'contacts', + defaultViewType: 'grid', + showSearch: true, + showSort: true, + table: { + columns: ['first_name', 'last_name', 'email', 'company'], + pagination: { pageSize: 25 }, + }, +}; + +; +``` + +Rendering the same node through the registry instead (`type: 'object-view'` in a +larger schema tree) works because `ObjectViewRenderer` reads the `dataSource` +off `SchemaRendererProvider` context — again, not off the schema. + +## TypeScript Support + +This package's type export surface is the component `*Props` types plus the +record-surface and field-option types listed under "Registering a component +under your own key" — it ships **no schema types**. The authored +`type: 'object-view'` node is typed by `@object-ui/types`, which +`@object-ui/plugin-view` imports without re-exporting, so import it from there: + +| Import from `@object-ui/types` | What it types | +| --- | --- | +| `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, the `show*` flags, `operations`, `onNavigate`, `allowCreateView`, `viewActions` (`viewTabBar` is retired — objectui#7779 — and refused by name; `filterableFields` is retired with the spec's own retirement of it — objectui#13118 — and nothing reads it) | +| `NamedListView` | the former type of a `listViews` entry, still exported. Since objectui#7928 an entry is `@objectstack/spec`'s `ObjectListViewSchema` (`NonNullable[string]`): toolbar toggles go under `userActions` (`search`, `sort`, `filter`, `group`, `hideFields`, `rowHeight`, `rowColor`), `showDescription` under `appearance`, and view-kind config in the top-level `kanban` / `calendar` / … block, never a legacy `options` bag | +| `ViewNavigationConfig` | `navigation` — row/item click behaviour | +| `ViewTabBarConfig` | the `config` prop of `ViewTabBar` — tab-bar UX (inline add, overflow, indicators), composed by the host; not an `object-view` node key | + +```typescript +import type { ObjectViewSchema } from '@object-ui/types'; + +const userView: ObjectViewSchema = { + type: 'object-view', + objectName: 'users', + defaultViewType: 'grid', + // Displayed columns are grid configuration, inherited from ObjectGridSchema. + table: { columns: ['name', 'email', 'title'] }, +}; +``` + +## License + +MIT diff --git a/content/docs/frontend/quick-start.mdx b/content/docs/frontend/quick-start.mdx new file mode 100644 index 00000000000..f01aab7ef4b --- /dev/null +++ b/content/docs/frontend/quick-start.mdx @@ -0,0 +1,330 @@ +--- +title: "Quick Start" +description: "Get up and running with ObjectUI in 5 minutes - install, configure, and render your first server-driven UI" +--- + +Get up and running with ObjectUI in a small Vite app. This guide installs the core renderer, registers the built-in component packages, and renders a first JSON schema. + +## Prerequisites + +- **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. +- Basic knowledge of **React** and **TypeScript** + +## Step 1: Create a React Project + +If you don't have an existing React project, create one with Vite: + +```bash +pnpm create vite my-app --template react-ts +cd my-app +``` + +## Step 2: Install ObjectUI + +Install the core ObjectUI packages: + +```bash +pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields +``` + +Install Tailwind CSS for styling: + +```bash +pnpm add -D tailwindcss @tailwindcss/vite +``` + +## Step 3: Configure Tailwind CSS + +Add Tailwind to your `vite.config.ts`: + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}); +``` + +Add to your `src/index.css`: + +```css +@import "tailwindcss"; +@import "@object-ui/components/style.css"; +@import "@object-ui/fields/style.css"; +``` + +Each `style.css` is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (`bg-primary`, `border-input`) included. + +**Import them in that order.** `@object-ui/components/style.css` is the complete sheet: Tailwind's base layer, the `@theme` tokens and the utilities its components use. `@object-ui/fields/style.css` is a small supplement on top of it — only the ~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything. + +**Plugin packages that publish a stylesheet need one line each.** `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` ship the same kind of supplement, built the same way, so add whichever of them you install: + +```css +@import "@object-ui/plugin-grid/style.css"; +@import "@object-ui/plugin-kanban/style.css"; +``` + +Without that line the plugin renders with no themed styling at all — its `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` have no other source in a published app, because the `@theme` block they resolve lives in package source that is never published ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). The remaining `@object-ui/plugin-*` packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above. + +That is the whole styling setup: you do not add `@source` lines for the ObjectUI packages, and pointing Tailwind at them inside `node_modules` only regenerates utilities these imports already gave you. + +## Step 4: Render Your First Schema + +Replace `src/App.tsx` with: + +```tsx +import '@object-ui/components'; +import '@object-ui/fields'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import type { CardSchema } from '@object-ui/types'; + +const schema: CardSchema = { + type: 'card', + title: 'Team Directory', + description: 'Rendered from JSON metadata', + className: 'mx-auto max-w-3xl', + children: { + type: 'data-table', + caption: 'Users', + columns: [ + { header: 'Name', accessorKey: 'name', sortable: true }, + { header: 'Email', accessorKey: 'email' }, + { header: 'Department', accessorKey: 'department' }, + ], + data: [ + { name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering' }, + { name: 'Grace Hopper', email: 'grace@example.com', department: 'Marketing' }, + { name: 'Katherine Johnson', email: 'katherine@example.com', department: 'Sales' }, + ], + pagination: false, + searchable: false, + }, +} as const; + +function App() { + return ( +
+ + + +
+ ); +} + +export default App; +``` + +Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` injects the host's data adapter, and everything below it — expressions, smart fields, data-aware plugins — reads it back from there. + +This app renders inline `data`, so it has no adapter to inject and says so with `undefined`. That is a real state of the contract, not a placeholder: `dataSource` is typed `DataSource | null | undefined` (`@object-ui/types`), so a host either hands over an adapter or states that it has none. It used to be typed `any` and this example passed an empty object, which no renderer can do anything with (objectui#7912). + +## Step 5: Run the App + +```bash +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173). You should see a card and data table rendered from JSON. + +## What Just Happened? + +1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `children`. +2. **Registry** - importing the component packages registered renderers for `card` and `data-table`. +3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components. +4. **Provider** - `SchemaRendererProvider` is where a host injects its `DataSource`; this app has none, so it passes `undefined`. + +## Expression Scope + +A `${…}` expression in a schema reads a root the host publishes. Publish those roots with +`PredicateScopeProvider`: + +### Basic Usage + +```tsx +import React from 'react' +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' +import type { DeclaredNode } from '@object-ui/types' +// Importing the package registers every default renderer as a side effect — +// there is no separate registration call. +import '@object-ui/components' + +const schema: DeclaredNode = { + type: "page", + // An app page draws `title` as its heading; a page with no `pageType` is a + // record page, which leaves the heading to a `page:header` block. + pageType: "app", + title: "Dashboard", + children: { + type: "grid", + columns: 3, + children: [ + { type: "statistic", label: "Total Users", value: "${stats.users}" }, + { type: "statistic", label: "Revenue", value: "${stats.revenue}" }, + { type: "statistic", label: "Orders", value: "${stats.orders}" } + ] + } +} + +function App() { + // Every key of this object becomes a root the schema's expressions can read — + // `stats` here is what answers `${stats.users}`. + const scope = { + stats: { users: 1234, revenue: "$56,789", orders: 432 } + } + + return ( + + + + ) +} + +export default App +``` + +Expression scope reaches the renderer through the provider, never through a prop on the +element. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop +it is handed to the component the schema names — so a value passed as `data={…}` is neither +read nor refused, and the expression that wanted it is returned as its own source text, with +nothing thrown and one line on the console. + +⛔ `dataSource` is the **adapter** — the object data renderers call `find()` on — and not an +expression root. It is a different channel from the expression scope: publish the values your +`${…}` expressions read with `PredicateScopeProvider`, and inject the adapter your data +components query with `SchemaRendererProvider`. + +## Copy-Paste Schemas + +A `${name.…}` in any of these reads the root `name` off the scope the host published — see +[Expression Scope](#expression-scope) for the provider that publishes one. A head name nothing +published is not an error: the expression is returned as its own source text. + +### Contact Form + +```json +{ + "type": "form", + "fields": [ + { "name": "name", "type": "text", "label": "Full Name", "required": true }, + { "name": "email", "type": "email", "label": "Email", "required": true }, + { "name": "subject", "type": "select", "label": "Subject", "options": [ + { "label": "General Inquiry", "value": "general" }, + { "label": "Bug Report", "value": "bug" }, + { "label": "Feature Request", "value": "feature" } + ]}, + { "name": "message", "type": "textarea", "label": "Message", "required": true } + ], + "submitLabel": "Send Message" +} +``` + +### Data Grid + +```json +{ + "type": "object-grid", + "properties": { + "objectName": "user", + "title": "Users", + "columns": [ + { "field": "name", "label": "Name", "sortable": true }, + { "field": "email", "label": "Email" }, + { "field": "department", "label": "Department" }, + { "field": "status", "label": "Status" }, + { "field": "created_at", "label": "Joined" } + ], + "showSearch": true, + "operations": { "create": true, "update": true, "delete": true, "export": true } + } +} +``` + +### Dashboard + +A row of live KPI tiles. A `dashboard` node's `widgets[]` takes `metric-card` entries and +dataset-bound charts instead; see [Dashboard Plugin](/docs/frontend/plugins/plugin-dashboard). + +```json +{ + "type": "grid", + "columns": 4, + "children": [ + { "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%" }, + { "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%" }, + { "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%" }, + { "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%" } + ] +} +``` + +### Kanban Board + +```json +{ + "type": "object-kanban", + "objectName": "tasks", + "groupBy": "status", + "cardTitle": "title", + "cardFields": ["assignee", "priority", "due_date"], + "columns": [ + { "id": "todo", "title": "To Do", "className": "border-t-2 border-indigo-500" }, + { "id": "in_progress", "title": "In Progress", "className": "border-t-2 border-amber-500" }, + { "id": "review", "title": "In Review", "className": "border-t-2 border-blue-500" }, + { "id": "done", "title": "Done", "className": "border-t-2 border-green-500" } + ] +} +``` + +More schemas: [`ui/examples/schema-catalog`](https://github.com/objectstack-ai/objectstack/tree/main/ui/examples/schema-catalog) +is the canonical catalog — the JSON schemas the docs render, a smoke test mounts, and AI agents +use as a few-shot corpus. + +## Next Steps + +### Add Actions + +Actions are data, not inline functions. Declare one as an `action:button` +node whose `properties` bag carries the block's props, as `@objectstack/spec` +declares them: `actionType` names the built-in executor the action runner +dispatches to, and the bag's other keys carry that executor's arguments — +`target` is the location a `url` action navigates to. Written flat on the node, +`actionType` and `target` are refused by the spec's strict page component: + +```json +{ + "type": "action:button", + "properties": { + "label": "Open details", + "actionType": "url", + "target": "/users/ada" + } +} +``` + +Learn the full action model in [Enhanced Actions](/docs/ui/actions). + +### Connect a Data Source + +```bash +pnpm add @object-ui/data-objectstack +``` + +```tsx +import { createObjectStackAdapter } from '@object-ui/data-objectstack'; + +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com' +}); +``` + +Pass the adapter to `SchemaRendererProvider` and let data-aware renderers call the `DataSource` interface. See [Data Connectivity](/docs/frontend/data-source). + +### Learn More + +- [Architecture Overview](/docs/frontend/architecture-overview) — Understand how ObjectUI works +- [Schema Rendering](/docs/frontend/schema-rendering) — Deep dive into schema rendering +- [Component Registry](/docs/frontend/component-registry) — Customize and extend components +- [Plugins](/docs/frontend/plugins) — Add views like Grid, Kanban, Charts +- [Fields Guide](/docs/frontend/fields) — Field widgets and cell renderers diff --git a/content/docs/frontend/renderers.mdx b/content/docs/frontend/renderers.mdx new file mode 100644 index 00000000000..848e7cc1a51 --- /dev/null +++ b/content/docs/frontend/renderers.mdx @@ -0,0 +1,117 @@ +--- +title: "Renderer types" +description: "The renderer types the ObjectUI component packages register, for apps that render their own JSON with the SDK" +--- + + + These types exist only inside an app that renders JSON with the ObjectUI SDK. None of them is in + `sdui.manifest.json`, so an ObjectStack page cannot use them. The components a page can hold are in the + [component reference](/docs/ui/components). + + +Importing `@object-ui/components` registers these types with the [component registry](/docs/frontend/component-registry); +`view-switcher`, `filter-ui` and `sort-ui` register when `@object-ui/plugin-view` is imported. Each type's +inputs are declared in its `ComponentRegistry.register` call in the linked source file, and `@object-ui/types` +holds the schema types. + +## Basic + +| Type | What it draws | Source | +| --- | --- | --- | +| `separator` | Visual divider between content sections | [separator.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/basic/separator.tsx) | +| `button-group` | Group multiple buttons together with shared styling | [button-group.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/basic/button-group.tsx) | +| `pagination` | Navigate through pages of content | [pagination.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/basic/pagination.tsx) | +| `navigation-menu` | Accessible navigation menu with dropdown support | [navigation-menu.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/basic/navigation-menu.tsx) | +| `sidebar` | An app-level sidebar region that works in any host | [sidebar.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/navigation/sidebar.tsx) | +| `div` | Deprecated generic container; use the Shadcn-based renderers instead | [div.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/basic/div.tsx) | + +## Form + +| Type | What it draws | Source | +| --- | --- | --- | +| `input` | Basic text input field for user data entry | [input.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/input.tsx) | +| `textarea` | Multi-line text input for longer content | [textarea.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/textarea.tsx) | +| `select` | Dropdown selection from a list of options | [select.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/select.tsx) | +| `combobox` | Searchable dropdown for selecting from a list of options | [combobox.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/combobox.tsx) | +| `checkbox` | Toggle selection with checkboxes | [checkbox.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/checkbox.tsx) | +| `radio-group` | A set of checkable buttons where only one can be selected at a time | [radio-group.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/radio-group.tsx) | +| `switch` | Toggle switch for binary options | [switch.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/switch.tsx) | +| `slider` | Select a value from a range | [slider.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/slider.tsx) | +| `date-picker` | Input for selecting dates with a calendar popup | [date-picker.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/date-picker.tsx) | +| `input-otp` | One-time password input with auto-focus | [input-otp.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/input-otp.tsx) | +| `command` | Fast command menu with search and keyboard navigation | [command.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/command.tsx) | +| `form` | Form container with validation and submission handling | [form.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/form.tsx) | +| `calendar` | Date selection component with single, multiple, and range modes | [calendar.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/calendar.tsx) | +| `file-upload` | Modern file upload component with drag-and-drop support | [file-upload.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/file-upload.tsx) | +| `toggle` | Two-state button for toggling options on and off | [toggle.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/form/toggle.tsx) | + +## Layout + +| Type | What it draws | Source | +| --- | --- | --- | +| `aspect-ratio` | Maintain a consistent width to height ratio | [aspect-ratio.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/layout/aspect-ratio.tsx) | +| `page` | Full-page container with title, description, and content sections | [page.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/layout/page.tsx) | + +## Data display + +| Type | What it draws | Source | +| --- | --- | --- | +| `avatar` | User profile image or fallback initials | [avatar.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/data-display/avatar.tsx) | +| `list` | Display a list of items | [list.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/data-display/list.tsx) | +| `breadcrumb` | Display the current location within a navigational hierarchy | [breadcrumb.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/data-display/breadcrumb.tsx) | +| `kbd` | Display keyboard shortcuts and hotkeys | [kbd.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/data-display/kbd.tsx) | +| `statistic` | Display key metrics and statistical values with trend indicators | [statistic.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/data-display/statistic.tsx) | +| `tree-view` | Hierarchical tree structure for displaying nested data | [tree-view.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/data-display/tree-view.tsx) | + +## Feedback + +| Type | What it draws | Source | +| --- | --- | --- | +| `loading` | Loading spinner to indicate processing | [loading.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/loading.tsx) | +| `progress` | Progress bar to show completion status | [progress.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/progress.tsx) | +| `skeleton` | Placeholder for loading content | [skeleton.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/skeleton.tsx) | +| `spinner` | Loading spinner indicator | [spinner.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/spinner.tsx) | +| `empty` | Empty state placeholder with optional action | [empty.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/empty.tsx) | +| `toast` | Brief notification messages that appear temporarily | [toast.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/toast.tsx) | +| `sonner` | Toast notifications using Sonner library | [sonner.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/feedback/sonner.tsx) | + +## Overlay + +| Type | What it draws | Source | +| --- | --- | --- | +| `dialog` | Modal dialog overlay for focused interactions | [dialog.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/dialog.tsx) | +| `drawer` | Sliding panel from edge of screen | [drawer.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/drawer.tsx) | +| `tooltip` | Contextual information on hover | [tooltip.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/tooltip.tsx) | +| `popover` | Floating content panel | [popover.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/popover.tsx) | +| `alert-dialog` | A modal dialog that interrupts the user with important content and expects a response | [alert-dialog.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/alert-dialog.tsx) | +| `sheet` | Slide-in panel from the edge of the screen | [sheet.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/sheet.tsx) | +| `hover-card` | Display rich content in a popup when hovering over a trigger | [hover-card.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/hover-card.tsx) | +| `context-menu` | Display a menu on right-click or long-press | [context-menu.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/context-menu.tsx) | +| `dropdown-menu` | Display a menu of actions or options triggered by a button | [dropdown-menu.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/dropdown-menu.tsx) | +| `menubar` | Horizontal menu bar with dropdown menus | [menubar.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/overlay/menubar.tsx) | + +## Disclosure + +| Type | What it draws | Source | +| --- | --- | --- | +| `collapsible` | Toggle visibility of content | [collapsible.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/disclosure/collapsible.tsx) | +| `toggle-group` | A group of toggle buttons where one or more can be selected | [toggle-group.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/disclosure/toggle-group.tsx) | + +## Navigation + +| Type | What it draws | Source | +| --- | --- | --- | +| `header-bar` | Application header with sidebar trigger and breadcrumb navigation | [header-bar.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/navigation/header-bar.tsx) | + +## Complex + +| Type | What it draws | Source | +| --- | --- | --- | +| `data-table` | Enterprise-grade data table with sorting, filtering, pagination, and row actions | [data-table.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/complex/data-table.tsx) | +| `carousel` | Slideshow component for cycling through content | [carousel.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/complex/carousel.tsx) | +| `scroll-area` | Customizable scrollable container with styled scrollbars | [scroll-area.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/complex/scroll-area.tsx) | +| `resizable` | Resizable panel groups with draggable handles | [resizable.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/complex/resizable.tsx) | +| `filter-builder` | Visual query builder for constructing complex filter conditions | [filter-builder.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/components/src/renderers/complex/filter-builder.tsx) | +| `view-switcher` | Switch between multiple view configurations in one container | [index.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/plugin-view/src/index.tsx) | +| `filter-ui` | Build flexible filter panels for list and view toolbars | [index.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/plugin-view/src/index.tsx) | +| `sort-ui` | Configure sorting with dropdowns or button toggles | [index.tsx](https://github.com/objectstack-ai/objectstack/blob/main/ui/packages/plugin-view/src/index.tsx) | diff --git a/content/docs/frontend/schema-rendering.mdx b/content/docs/frontend/schema-rendering.mdx new file mode 100644 index 00000000000..bc9f22e74d7 --- /dev/null +++ b/content/docs/frontend/schema-rendering.mdx @@ -0,0 +1,656 @@ +--- +title: "Schema Rendering" +--- + +ObjectUI's schema rendering system is the core mechanism that transforms JSON configurations into live React components. This guide explains how it works and how to use it effectively. + +## Overview + +The schema rendering engine follows a simple principle: + +``` +JSON Schema → SchemaRenderer → React Components → Beautiful UI +``` + +Every visual element in ObjectUI starts as a JSON object that describes what should be rendered, not how it should be rendered. + +## The SchemaRenderer Component + +The `SchemaRenderer` is the primary component that interprets your JSON schemas: + +```tsx +import { SchemaRenderer } from '@object-ui/react' +import { initializeComponents } from '@object-ui/components' +import type { PageNodeSchema } from '@object-ui/types' +// Side-effect import: loading the package runs its own field registration. +import '@object-ui/fields' + +// Register components once at app initialization +initializeComponents() + +function App() { + const schema: PageNodeSchema = { + type: "page", + pageType: "app", + title: "My Dashboard", + children: [{ type: "text", content: "Hello" }] + } + + return +} +``` + +## Schema Structure + +Every schema object must have at minimum a `type` field: + +```typescript +import type { CSSProperties } from 'react' + +interface BaseSchema { + type: string // Component type identifier + id?: string // Optional unique identifier + className?: string // Tailwind CSS classes + style?: CSSProperties // Inline styles (use sparingly) + visibleWhen?: string // Expression for conditional visibility + hidden?: boolean | string // Expression for conditional hiding + disabled?: boolean | string // Expression for conditional disabling +} +``` + +### Example Schema + +```json +{ + "type": "card", + "id": "stats-card", + "className": "p-6 shadow-lg", + "title": "User Statistics", + "visibleWhen": "${user.department === 'finance'}", + "children": { + "type": "text", + "content": "Total Users: ${stats.totalUsers}" + } +} +``` + +## Data Context + +Expression scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, +`schema`, and forwards every other prop it is handed straight through to the component the +schema names — so a `data`, `dataSource` or `debug` written on the element is neither read nor +refused. Nothing throws, and there is one line on the console; the expression simply never +resolves, and an unresolvable template is returned as its own source text, so the characters +you typed are what the reader sees. The scope comes from `PredicateScopeProvider`, which +publishes each name you give it as an expression root: + +```tsx +import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react' +import type { DeclaredNode } from '@object-ui/types' + +// The page schema from the first example on this page. +declare const schema: DeclaredNode + +// Every name here becomes a root the schema's expressions can read. +const scope = { + user: { name: 'John', department: 'finance' }, + stats: { totalUsers: 1234 }, +} + +function App() { + return ( + + + + ) +} +``` + +An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's +`ExpressionProvider` already feeds the same channel with `user` (the signed-in user, also +readable as `current_user`) and `features`. + +The scope the evaluator builds is what you published, plus three names the renderer supplies: + +| name | what it holds | +|---|---| +| every key of `scope` | exactly what you put there — `user`, `stats`, whatever the page needs | +| `page` | page-local variables, for predicates that gate on another component's state | +| `record` | the row a record surface is bound to, when there is one | +| `current_user` | an alias of whatever you published as `user`; the host's `ExpressionProvider` publishes the signed-in user there | + +A name outside that set resolves to nothing, and an unresolvable template is not an error: +the evaluator hands back its own source text and writes one line to the console, so the +characters you typed are what the reader sees. + +> **`dataSource` is not an expression root.** `SchemaRendererProvider`'s `dataSource` carries +> the host's `DataSource` *adapter* — the object renderers call `find()` on. The renderer used +> to publish that adapter under the name `data`; an adapter answers no `data.*` path, so the +> root was constant for every conformant host, and objectui#9308 removed it. A `${data.…}` +> expression now reads whatever *you* published under `data`, and nothing if you published +> none. At the runtime layer the row is `record` (ADR-0089). + +### Accessing Data in Schemas + +Use expression syntax `${}` to reference the scope, by the name you published it under: + +```json +{ + "type": "text", + "content": "Welcome, ${user.name}!" +} +``` + +## Component Registry + +The schema renderer uses a component registry to map schema types to React components: + +```tsx +import { ComponentRegistry } from '@object-ui/core' + +// `ComponentRegistry` is a process-level singleton — import it, do not construct one. +// Register a custom component +ComponentRegistry.register('my-component', MyComponent) + +// Now you can use it in schemas +const schema = { + type: "my-component", + // ... component props +} +``` + +## Nested Schemas + +Schemas can be nested to create complex UIs: + +```json +{ + "type": "page", + "pageType": "app", + "title": "Dashboard", + "children": { + "type": "grid", + "columns": 2, + "children": [ + { + "type": "card", + "title": "Card 1", + "children": { + "type": "text", + "content": "Nested content" + } + }, + { + "type": "card", + "title": "Card 2", + "children": { + "type": "chart", + "chartType": "bar", + "xAxisKey": "month", + "series": [{ "name": "revenue" }], + "data": [ + { "month": "Jan", "revenue": 120 }, + { "month": "Feb", "revenue": 80 } + ] + } + } + ] + } +} +``` + +The chart's rows are written out on the node, and that is the spelling that plots. A +`${…}` on node-level `data` is **not** one of the rows `SchemaRenderer` evaluates: the raw +string reaches `ChartRenderer`, whose `Array.isArray(schema.data) ? schema.data : []` drops +it, and the chart frame draws with nothing in it — no error, no warning. Since objectui#7113 +that node is also refused by name, `ChartSchema.data` being `z.array(z.record(…))`. `series` +is required alongside it: `series[].name` picks the column to plot within each row, and +`xAxisKey` names the category column. `chart` reads no `bind` either, so resolve the array in +your own code and hand `SchemaRenderer` a schema whose rows are already on the node. + +## Array Rendering + +Use arrays for multiple items: + +```json +{ + "type": "container", + "children": [ + { "type": "text", "content": "First item" }, + { "type": "text", "content": "Second item" }, + { "type": "text", "content": "Third item" } + ] +} +``` + +## Expression System + +ObjectUI includes a powerful expression system for dynamic behavior: + +### Simple Expressions + +```json +{ + "type": "text", + "content": "${user.firstName} ${user.lastName}" +} +``` + +### Conditional Expressions + +```json +{ + "type": "card", + "title": "${record.status === 'active' ? 'Active' : 'Inactive'}", + "description": "${record.status === 'active' ? 'This record is in use.' : 'This record is archived.'}" +} +``` + +`record.status` rather than a bare `status`, because this example is about a row: `record` is +the row a record surface is bound to, and it is the only spelling a row field has — the bare +shorthand and the wrong-layer `data.status` were both retired on runtime record surfaces +(objectui#5330 phase 2). A head name the host publishes itself stays bare; this one is not +one of those. + +`card` here rather than `badge`, because an expression is evaluated only on a key the +node's own type carries. `expressionBindableTextKeysFor` — the lookup `SchemaRenderer` +consumes out of `@objectstack/spec` — gives `card` the rows `title` and `description`, +and gives `badge` no rows at all, so a `${…}` written on a badge reaches the DOM as the +characters you typed. Resolve a badge's text before you hand the schema over, and author +it on `label`: `text` is not a `BadgeSchema` key. + +### Visibility Control + +```json +{ + "type": "button", + "label": "Delete", + "visibleWhen": "${user.department === 'finance'}" +} +``` + +### Complex Logic + +```json +{ + "type": "alert", + "variant": "default", + "title": "Welcome!", + "children": { + "type": "text", + "content": "${user.isNew ? 'Start with the quick tour.' : user.tasks.length === 0 ? 'You are all caught up.' : 'You have tasks waiting.'}" + } +} +``` + +The branch sits on the nested `text` node's `content`, which `SchemaRenderer` evaluates +on every node type — the escape hatch for a component that carries no expression rows of +its own, and `alert` is one of those. Its severity could not be chosen by expression in +any case: `AlertSchema.variant` is the closed set `default` | `destructive`, so `info`, +`warning` and `success` are not values it accepts. Pick the variant in the host and +author it as a literal. + +## Event Handling + +Components can emit events that you handle in React: + +```tsx + { + console.log('Action:', action) + console.log('Context:', context) + }} + onSubmit={(data) => { + console.log('Form submitted:', data) + }} +/> +``` + +Reference actions in schemas — the block's props go in its `properties` bag, as +`@objectstack/spec` declares them; the spec's strict page component refuses them +written flat on the node: + +```json +{ + "type": "action:button", + "properties": { + "name": "call_api", + "label": "Click Me", + "actionType": "api", + "target": "/api/action", + "method": "POST" + } +} +``` + +Three things about the shape this replaces. A declarative action is its own NODE TYPE, +`action:button` — a plain `button` has no authorable handler: `ButtonSchema.onClick` is a +runtime slot for a host-supplied function, refused by name by the zod mirror, and (being +on `SDUI_DOM_PASS_THROUGH_KEYS`) forwarded straight to the DOM listener slot, where React +throws on the first click: "Expected `onClick` listener to be a function, instead got a +value of `object` type." The execution type is `actionType`, and the built-in vocabulary +is `script` | `url` | `modal` | `flow` | `api` | `form` (plus objectui's `navigation` +alias) — anything else must be a handler your host registered on `ActionProvider`. `ajax` +is neither. And an `api` action's endpoint is its `target`, with the verb in `method`: the +spec's `action:button` row refuses `endpoint` and names `target` in its place, and `api` is +not a key any action renderer forwards. + +## Performance Optimization + +### Lazy loading: there is no such knob + +There is no authorable lazy-loading key — not on `tabs`, not on any other node. +This section used to show a `tabs` node carrying `"lazyLoad": true` beneath the +sentence "Large schemas are automatically optimized". Nothing in this repository +performs that optimization and nothing reads that key, so the promise is removed +here rather than respelled. + +Two things mislead, so both are worth naming: + +- **`lazyLoad` is a real name in this repository — on a different surface.** It is + a member of `PerformanceConfig`, the argument type of the `usePerformance` + hook: a configuration object you pass in TypeScript, never a key you author on + a node. Finding it in a search does not make it authorable. It gates nothing + even there — `usePerformance` resolves it into the config it hands back and + never branches on it, unlike its sibling `debounceMs`, which the hook lifts + into a local and passes to `setTimeout`. +- **The `tabs` renderer builds every panel on the same render pass.** It maps + `items` twice, unconditionally — once for the triggers and once for the + panels — so no tab's `content` waits for a click. It uses neither `lazy` nor + `Suspense`, and it never calls `usePerformance`. + +To defer real work behind a tab, defer the *component* rather than the node: see +[Code Splitting](#code-splitting) below. + +For reference, a `tabs` node written with the keys `TabsSchema` actually declares: + +```json +{ + "type": "tabs", + "defaultValue": "tab1", + "items": [ + { "value": "tab1", "label": "Tab 1", "content": { "type": "text", "content": "First panel" } }, + { "value": "tab2", "label": "Tab 2", "content": { "type": "text", "content": "Second panel" } } + ] +} +``` + +`items` is the node's tab list — there is no `tabs` key — and every item takes +`value`, `label` and `content`: the identifier, the visible title, and the panel. +All three are required, and the two ways of getting an item wrong fail +differently. An item missing one of them is **refused**. An item that also +carries the older `title` spelling is **accepted with that key dropped**, while +an item-level `body` is **refused by name**, pointed at `content`. On +the node itself, `body` and `children` are refused by name: `tabs` reads neither +content channel. The four keys it does render are `defaultValue`, `items`, +`orientation` and `value`. + +### Memoization + +The renderer automatically memoizes components to prevent unnecessary re-renders. + +### Code Splitting + +Use dynamic imports for heavy components: + +```tsx +import { lazy } from 'react' + +const HeavyChart = lazy(() => import('./HeavyChart')) + +registry.register('heavy-chart', HeavyChart) +``` + +## Error Handling + +The renderer includes built-in error boundaries: + +```tsx + { + console.error('Rendering error:', error) + // Log to error tracking service + }} +/> +``` + +## Render is not a validation door + +`SchemaRenderer` does **not** parse your document against the published Zod schema before it +draws. The one document check on the render path is `validateSchema` from `@object-ui/core` — +a hand-written structural walker — and it runs **in development only**: the call sits behind a +`process.env.NODE_ENV !== 'production'` guard, warns to the console, and marks the offending +host element with `data-obj-schema-invalid` so an app can hang a visual cue off it. In a +production build that pass is skipped entirely — the document is not checked at all before it +is drawn, and `data-obj-schema-invalid` is never emitted. + +The walker and the schema do not share an accept set, so neither one's verdict tells you +anything about the other's. The walker judges node *structure* — is the root an object, does +every node carry a `type`, recursing through `children` — plus a few rules of its own, such as +a short hand-written table of retired node-type spellings. The rest of the published contract +is outside it. Take a `chatbot` node carrying a key the schema has retired: the walker is +silent, whether that key holds a node, an array of nodes, a string, a number or a record, while +`safeValidateSchema` refuses the same document with the retirement's own message. So a document +that renders without a warning has **not** thereby passed the contract, and a warning that does +appear is a report about node structure, not about the schema. + +Validation happens at three doors, all of them outside the render path: + +1. **Load time — the framework's Zod parse.** Every metadata item a package ships is validated + against its Zod schema when the framework loads it; the result travels with the item as a + `_diagnostics` envelope, which Studio surfaces. See + [Metadata Diagnostics](/docs/ui). +2. **Authoring time — `objectui validate`.** It parses one document, JSON or YAML, against the + published schema's strict authoring face — which refuses, by name, any key no schema declares, + on every node — prints the schema's own errors when it fails, and exits non-zero. That exit + code is the CLI's validation verdict. `objectui check` does not give it. `check` sweeps a + project's JSON files and checks the `type` of each file it recognises, and a file whose root + carries an ObjectUI structural key (`children`, `className`, `body`, …) is recognised by that + key alone, without being parsed against the schema — so an invalid document of that shape is + not reported at all. Only a file with none of those keys is parsed; it is listed by name when + its root `type` names a registered component but the document does not validate, and that + list is advisory: being listed does not fail the run. `check` exits non-zero on unreadable + JSON, and on a `${…}` expression on a text key its node never evaluates, in a listed file as + in a recognised one. See [`objectui check`](/docs/frontend/cli#objectui-check). +3. **Wherever else you need it — `StrictAnyComponentSchema.safeParse`.** Exported from + `@object-ui/types/zod`, it is the parse `objectui validate` runs, so a build step, a CI job or + a save handler can apply the identical contract. `safeValidateSchema`, from the same module, + is the tolerant parse: it keeps a key no schema declares, unjudged, the way the renderer does. + +Put the check where documents are authored, saved or loaded — not in the paint. A document that +reaches the browser without passing one of those doors is drawn as best the renderer can, in +development and in production alike. + +## Data attributes + +`SchemaRenderer` stamps each rendered node with two attributes, for tests, debugging and styling: + +- `data-obj-id`: the schema's `id` +- `data-obj-type`: the schema's `type` + +```css +[data-obj-type="card"] { + /* every card the renderer draws */ +} +``` + +## TypeScript Support + +Full type safety for your schemas: + +```tsx +import type { PageNodeSchema, FormSchema } from '@object-ui/types' + +const form: FormSchema = { + type: "form", + // TypeScript will validate this entire structure + fields: [] +} + +const schema: PageNodeSchema = { + type: "page", + pageType: "app", + title: "Typed Page", + children: [form] +} +``` + +The spec's page blocks take their props in a `properties` bag. `SchemaRenderer` +accepts them through `AuthoringNode` from `@object-ui/types`, so the bag is +typed by the spec's `ComponentPropsMap` row and a misspelled key in it does not +compile: + +```tsx +import { SchemaRenderer } from '@object-ui/react' +import type { PublicBlockNodeOf } from '@object-ui/types' + +const heading: PublicBlockNodeOf<'element:text'> = { + type: "element:text", + properties: { content: "Quarterly revenue", variant: "h2" } +} + +function Heading() { + return +} +``` + +## Best Practices + +### 1. Keep Schemas Simple + +Break complex UIs into smaller, reusable schemas: + +```tsx +// ❌ Bad: One massive schema +const massiveSchema = { /* 500 lines of JSON */ } + +// ✅ Good: Composed schemas +const headerSchema = { /* ... */ } +const contentSchema = { /* ... */ } +const footerSchema = { /* ... */ } + +const pageSchema = { + type: "page", + children: [headerSchema, contentSchema, footerSchema] +} +``` + +### 2. Use Data Context Effectively + +Put everything the schema's expressions need on one scope, mounted above the tree — not on +the renderer, which does not read it: + +```tsx +import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react' +import type { DeclaredNode } from '@object-ui/types' + +// The reader's own values. +declare const schema: DeclaredNode +declare const userData: { name: string } +declare const userSettings: { theme: string } +declare const dashboardStats: { totalUsers: number } + +// ✅ Good — one provider, and every expression reads a name published on it +const scope = { + user: userData, + settings: userSettings, + stats: dashboardStats, +} + +function Dashboard() { + return ( + + + + ) +} +``` + +### 3. Leverage Expressions + +Move logic to expressions instead of creating conditional schemas: + +```tsx +// ❌ Bad +const schema = user.isAdmin ? adminSchema : userSchema + +// ✅ Good +const schema = { + type: "page", + children: [ + { + type: "admin-panel", + visibleWhen: "${user.isAdmin}" + }, + { + type: "user-panel", + visibleWhen: "${!user.isAdmin}" + } + ] +} +``` + +### 4. Use TypeScript + +Always type your schemas for better IDE support and fewer runtime errors. + +## Common Patterns + +### Loading States + +```json +{ + "type": "container", + "children": { + "type": "spinner", + "visibleWhen": "${loading}" + } +} +``` + +### Empty States + +```json +{ + "type": "empty", + "visibleWhen": "${items.length === 0}", + "title": "No items found", + "action": { + "type": "button", + "label": "Create New" + } +} +``` + +### Error States + +```json +{ + "type": "alert", + "variant": "destructive", + "visibleWhen": "${error}", + "title": "Something went wrong", + "children": { "type": "text", "content": "${error.message}" } +} +``` + +`visibleWhen` is a condition key and is evaluated on every node type. The message text is a +nested `text` node because `alert` carries no expression rows — and `message` is not an +`AlertSchema` key at all: the alert's own text keys are `title` and `description`, and the +renderer falls back from `description` to `children`. `destructive` is the variant this state +wants; `error` is not in the closed set. + +## Next Steps + +- [Component Registry](/docs/frontend/component-registry) - Learn about component registration +- [Expression System](/docs/ui) - Master expressions +- [Schema Overview](/docs/frontend) - Explore all available schemas + +## Related Documentation + +- [SchemaRenderer](/docs/frontend/schema-rendering) - Technical reference for the renderer +- [Architecture Overview](/docs/frontend/architecture-overview) - System architecture +- [`@object-ui/core` README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/core) - Core package API reference +- [`@object-ui/react` README](https://github.com/objectstack-ai/objectstack/tree/main/ui/packages/react) - React package API reference diff --git a/content/docs/frontend/sidebar-nav.mdx b/content/docs/frontend/sidebar-nav.mdx new file mode 100644 index 00000000000..a12b4a94863 --- /dev/null +++ b/content/docs/frontend/sidebar-nav.mdx @@ -0,0 +1,339 @@ +--- +title: "SidebarNav" +description: "Sidebar navigation component with active state, icons, badges, nested items, and search" +--- + +The SidebarNav component provides a responsive sidebar navigation menu with support for icons, active states, badges, nested collapsible items, grouped navigation, and built-in search filtering. + +## Basic Usage + +```tsx +import { SidebarNav } from '@object-ui/layout'; +import { Home, Users, Settings } from 'lucide-react'; + + +``` + +## Component Props + +```ts +interface SidebarNavProps { + items: NavItem[] | NavGroup[]; // Navigation items or grouped items + title?: string; // Sidebar title (default: "Application") + className?: string; // Additional CSS classes + collapsible?: "offcanvas" | "icon" | "none"; // Sidebar collapse mode + searchEnabled?: boolean; // Show search input to filter items + searchPlaceholder?: string; // Placeholder for search input +} + +interface NavItem { + title: string; // Item label + href: string; // Navigation URL + icon?: React.ComponentType; // Lucide icon component + badge?: string | number; // Badge text or count + badgeVariant?: 'default' | 'destructive' | 'outline'; // Badge style + children?: NavItem[]; // Nested collapsible sub-items +} + +interface NavGroup { + label: string; // Group section label + items: NavItem[]; // Items in the group +} +``` + +## Features + +### Active State + +The SidebarNav automatically highlights the active route based on the current URL: + +```plaintext +// Current URL: /users +// The "Users" menu item will be highlighted +``` + +### Icons + +Add Lucide icons to navigation items: + +```tsx +import { SidebarNav } from '@object-ui/layout'; +import { + LayoutDashboard, + Users, + FolderKanban, + Settings +} from 'lucide-react'; + + +``` + +### Badges + +Add badge counters or labels to navigation items with variant styling: + +```plaintext + +``` + +### Nested Items + +Create collapsible sub-navigation with the `children` property: + +```plaintext + +``` + +### Grouped Navigation + +Organize items into labeled sections using `NavGroup`: + +```plaintext + +``` + +### Search Filtering + +Enable built-in search to filter navigation items and their children: + +```plaintext + +``` + +### Custom Title + +Set a custom title for the sidebar: + +```plaintext + +``` + +## Complete Example + +```tsx +import { SidebarNav, type NavGroup } from '@object-ui/layout'; +import { + Home, + Users, + FolderOpen, + BarChart, + Settings, + HelpCircle, + Mail +} from 'lucide-react'; + +const navigationItems: NavGroup[] = [ + { + label: 'Workspace', + items: [ + { title: 'Dashboard', href: '/', icon: Home }, + { title: 'Inbox', href: '/inbox', icon: Mail, badge: 8, badgeVariant: 'destructive' }, + { + title: 'Projects', + href: '/projects', + icon: FolderOpen, + badge: 3, + children: [ + { title: 'Design System', href: '/projects/design' }, + { title: 'API v2', href: '/projects/api', badge: 'WIP', badgeVariant: 'outline' }, + { title: 'Mobile App', href: '/projects/mobile' }, + ], + }, + ], + }, + { + label: 'System', + items: [ + { title: 'Settings', href: '/settings', icon: Settings }, + { title: 'Help', href: '/help', icon: HelpCircle }, + ], + }, +]; + +function App() { + return ( + + ); +} +``` + +## Usage with AppShell + +The SidebarNav is designed to work with AppShell: + +```tsx +import { AppShell, SidebarNav } from '@object-ui/layout'; +import { Home, Info } from 'lucide-react'; + + + } +> +
Your content
+
+``` + +## Styling + +The SidebarNav uses Shadcn UI Sidebar components: + +- **SidebarGroupLabel**: Section title +- **SidebarMenuItem**: Individual menu items +- **SidebarMenuButton**: Clickable button with active state +- **SidebarMenuSub**: Nested sub-items container +- **Badge**: Badge counters with variant styling + +### Active Item + +Active items receive: +- Background color highlighting +- Bold text +- Visual indicator + +### Hover State + +Menu items on hover: +- Subtle background change +- Smooth transition +- Cursor pointer + +## Responsive Behavior + +The SidebarNav works with the AppShell's responsive behavior: + +- **Desktop**: Full sidebar visible +- **Mobile**: Collapsible sidebar with hamburger menu +- **Tablet**: Medium-width sidebar + +## Navigation + +The SidebarNav uses React Router's `NavLink` for: +- Client-side navigation +- Active state detection +- Browser history management +- Back button support + +## Accessibility + +The SidebarNav includes: +- Semantic `