diff --git a/apps/site/app/components/InteractiveDemo.tsx b/apps/site/app/components/InteractiveDemo.tsx
index 62a48be5d9..b55e341f80 100644
--- a/apps/site/app/components/InteractiveDemo.tsx
+++ b/apps/site/app/components/InteractiveDemo.tsx
@@ -5,6 +5,7 @@ import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@obje
import { SidebarProvider } from '@object-ui/components';
// Registers `page-header` & friends — see the module header (objectui#3787).
import './registerLayoutBlocks';
+import { galleryDataSource } from './galleryDataSource';
import type { SchemaNode } from '@object-ui/core';
import { Tabs, Tab } from 'fumadocs-ui/components/tabs';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
@@ -12,8 +13,24 @@ import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
// Re-export SchemaNode type for use in MDX files
export type { SchemaNode } from '@object-ui/core';
-/** Minimal provider so plugins can find SchemaRendererContext */
-const defaultCtx = { dataSource: {} };
+/**
+ * The provider the demos render under. `dataSource` is the docs gallery's
+ * stand-in fixture — the SAME module `SchemaThumbnail` supplies to the catalog
+ * gallery, rather than a second one — because a demo whose schema is
+ * object-bound has no other way to reach data: `dataSource` is not a schema
+ * key, it is what the registered renderer pulls off this context
+ * (`packages/plugin-view/src/index.tsx`).
+ *
+ * It was `{}` until objectui#5113, which is why the three `plugin-view`
+ * examples on `content/docs/plugins/plugin-view.mdx` could only be hand-drawn
+ * pictures of a view rather than the view itself.
+ *
+ * Importing the fixture pulls in NO plugin package: this host keeps its plugin
+ * registration lazy through `PluginLoader`, per page — the gallery's eager
+ * block-registration module stays out of here, and `galleryDataSource` imports
+ * nothing at all (objectui#4600/#4616).
+ */
+const defaultCtx = { dataSource: galleryDataSource };
function DemoProvider({ children }: { children: React.ReactNode }) {
const value = useMemo(() => defaultCtx, []);
return (
@@ -71,7 +88,10 @@ export function InteractiveDemo({
-
+
@@ -116,7 +136,7 @@ export function InteractiveDemo({
-
+
diff --git a/apps/site/app/components/galleryDataSource.ts b/apps/site/app/components/galleryDataSource.ts
index 3686463281..685bb04a91 100644
--- a/apps/site/app/components/galleryDataSource.ts
+++ b/apps/site/app/components/galleryDataSource.ts
@@ -4,22 +4,42 @@
*/
/**
- * The docs gallery's stand-in data source (objectui#4600).
+ * The docs gallery's stand-in data source (objectui#4600, extended by #5113).
*
* The catalog is a **presentation corpus**: it ships JSON, not a backend, and
* the gallery renders it with no application behind it. Most entries need
- * nothing — their data is inline. Dataset-bound dashboard widgets are the
- * exception: `DatasetWidget` routes through `dataSource.queryDataset`, and with
- * no such function it renders "This data source does not support dataset
- * queries." — which is what `plugin-dashboard/filtered-dashboard-dataset-
- * widgets` showed in the gallery until this file existed.
- *
- * So this is the smallest thing that lets a dataset-bound widget draw: canned
- * rows shaped from the query's own `dimensions` / `measures`, so any widget
- * gets a two-bucket series (or a single value when it selects no dimension)
- * regardless of which dataset it names. It is a DEMO fixture — it does not
- * filter, aggregate or honour `runtimeFilter`, and it must never be mistaken
- * for a data-source implementation. Real ones live in `@object-ui/data-*`.
+ * nothing — their data is inline. Two kinds of entry are the exception, and
+ * both route through the host's `dataSource`:
+ *
+ * - dataset-bound dashboard widgets — `DatasetWidget` calls
+ * `dataSource.queryDataset`, and with no such function it renders "This data
+ * source does not support dataset queries." (objectui#4600);
+ * - object-bound entries — `object-view` / `object-grid` / `object-form` call
+ * `getObjectSchema` and `find`, and `dataSource` is not a schema key: it is
+ * a prop the registered renderer pulls off `SchemaRendererContext`
+ * (`packages/plugin-view/src/index.tsx`). With nothing behind it, a
+ * `plugin-view` example can only be a hand-drawn picture of one — which is
+ * what the three `plugin-view` catalog entries were until objectui#5113.
+ *
+ * So this is the smallest thing that lets both draw. It is a DEMO fixture, not
+ * a data-source implementation — real ones live in `@object-ui/data-*`.
+ *
+ * ## What it honours, and what it does not
+ *
+ * Stated because a fixture that silently ignores a query parameter turns any
+ * example authoring that parameter into a lie on the page — the exact defect
+ * objectui#5113 exists to remove.
+ *
+ * - `queryDataset` — canned rows shaped from the query's own `dimensions` /
+ * `measures`. Does NOT filter, aggregate or honour `runtimeFilter`.
+ * - `find` — honours `$search` (case-insensitive substring over the object's
+ * string fields), `$orderby` (all of the shapes `QueryParams` declares) and
+ * the `$skip` / `$top` window. Does NOT honour `$filter` or `$expand`,
+ * which is why no catalog entry authors a `filter` on a view it renders
+ * through this fixture.
+ * - writes (`create` / `update` / `delete`) — applied to this module's own
+ * in-memory rows, so a record a reader creates in a demo drawer shows up in
+ * that page's list. They live as long as the tab does and reach nothing.
*
* It is gallery-only on purpose: `apps/site` is `private`, so nothing here is
* a published package surface.
@@ -34,6 +54,99 @@ interface GalleryDatasetQuery {
/** One canned row: dimension values plus measure values. */
type GalleryRow = Record;
+/** A record in the demo object below. `id` is what row clicks resolve. */
+type GalleryRecord = Record & { id: string };
+
+/**
+ * The one object the gallery serves, in the shape `getObjectSchema` returns
+ * everywhere else in the repo — `{ label, fields: { : { label, type } } }`
+ * — because that is what `ObjectGrid` / `ObjectForm` read to pick a cell
+ * renderer and a field widget.
+ *
+ * `users` and these field names are the ones `content/docs/plugins/
+ * plugin-view.mdx` teaches in its own prose examples, so a reader comparing the
+ * live example against the snippet above it sees one object, not two.
+ */
+const USERS_SCHEMA = {
+ name: 'users',
+ label: 'Users',
+ fields: {
+ name: { label: 'Name', type: 'text' },
+ email: { label: 'Email', type: 'email' },
+ role: {
+ label: 'Role',
+ type: 'select',
+ options: [
+ { label: 'Admin', value: 'admin' },
+ { label: 'Member', value: 'member' },
+ { label: 'Viewer', value: 'viewer' },
+ ],
+ },
+ department: { label: 'Department', type: 'text' },
+ status: {
+ label: 'Status',
+ type: 'select',
+ options: [
+ { label: 'Active', value: 'active' },
+ { label: 'Invited', value: 'invited' },
+ { label: 'Suspended', value: 'suspended' },
+ ],
+ },
+ created_at: { label: 'Created', type: 'date' },
+ },
+} as const;
+
+const USERS_ROWS: GalleryRecord[] = [
+ { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', department: 'Engineering', status: 'active', created_at: '2024-01-14' },
+ { id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'member', department: 'Design', status: 'active', created_at: '2024-02-03' },
+ { id: '3', name: 'Carla Gómez', email: 'carla@example.com', role: 'member', department: 'Sales', status: 'invited', created_at: '2024-03-21' },
+ { id: '4', name: 'Dan Whitfield', email: 'dan@example.com', role: 'viewer', department: 'Support', status: 'suspended', created_at: '2024-04-09' },
+ { id: '5', name: 'Emily Novak', email: 'emily@example.com', role: 'member', department: 'Engineering', status: 'active', created_at: '2024-05-30' },
+];
+
+/** Rows per object name. Unknown names resolve to an empty collection. */
+const OBJECTS: Record = {
+ users: { schema: USERS_SCHEMA, rows: USERS_ROWS },
+};
+
+/** `$orderby` in every shape `QueryParams` declares, as [field, direction]. */
+function orderPairs(orderby: unknown): Array<[string, 'asc' | 'desc']> {
+ if (!orderby) return [];
+ if (typeof orderby === 'string') {
+ return orderby
+ .split(',')
+ .map((clause) => clause.trim())
+ .filter(Boolean)
+ .map((clause) => {
+ const [field, direction] = clause.split(/\s+/);
+ return [field, direction?.toLowerCase() === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc'];
+ });
+ }
+ if (Array.isArray(orderby)) {
+ return orderby.flatMap((entry) =>
+ typeof entry === 'string'
+ ? orderPairs(entry)
+ : entry && typeof entry === 'object' && 'field' in entry
+ ? [[(entry as { field: string }).field, (entry as { order?: string }).order === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc']]
+ : [],
+ );
+ }
+ if (typeof orderby === 'object') {
+ return Object.entries(orderby as Record).map(
+ ([field, direction]) => [field, direction === 'desc' ? 'desc' : 'asc'] as [string, 'asc' | 'desc'],
+ );
+ }
+ return [];
+}
+
+function compare(a: unknown, b: unknown): number {
+ if (a == null && b == null) return 0;
+ if (a == null) return -1;
+ if (b == null) return 1;
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
+ return String(a).localeCompare(String(b));
+}
+
export const galleryDataSource = {
async queryDataset(dataset: string, query: GalleryDatasetQuery) {
const dimensions = query?.dimensions ?? [];
@@ -49,4 +162,58 @@ export const galleryDataSource = {
// drill INTO, so it is deliberately omitted along with `dimensionFields`.
return { rows, fields: [] };
},
+
+ async getObjectSchema(objectName: string) {
+ return OBJECTS[objectName]?.schema ?? { name: objectName, label: objectName, fields: {} };
+ },
+
+ async find(objectName: string, params?: Record) {
+ const rows = OBJECTS[objectName]?.rows ?? [];
+ const fields = OBJECTS[objectName]?.schema.fields ?? {};
+ let result = [...rows];
+
+ const search = typeof params?.$search === 'string' ? params.$search.trim().toLowerCase() : '';
+ if (search) {
+ const searchable = Array.isArray(params?.$searchFields)
+ ? (params.$searchFields as string[])
+ : Object.keys(fields);
+ result = result.filter((row) =>
+ searchable.some((field) => String(row[field] ?? '').toLowerCase().includes(search)),
+ );
+ }
+
+ for (const [field, direction] of orderPairs(params?.$orderby).reverse()) {
+ result.sort((a, b) => (direction === 'desc' ? -1 : 1) * compare(a[field], b[field]));
+ }
+
+ const total = result.length;
+ const skip = typeof params?.$skip === 'number' ? params.$skip : 0;
+ const top = typeof params?.$top === 'number' ? params.$top : undefined;
+ return { data: result.slice(skip, top === undefined ? undefined : skip + top), total };
+ },
+
+ async findOne(objectName: string, id: string | number) {
+ return (OBJECTS[objectName]?.rows ?? []).find((row) => String(row.id) === String(id)) ?? null;
+ },
+
+ async create(objectName: string, data: Record) {
+ const record: GalleryRecord = { ...data, id: `demo-${Date.now()}` };
+ OBJECTS[objectName]?.rows.unshift(record);
+ return record;
+ },
+
+ async update(objectName: string, id: string | number, data: Record) {
+ const rows = OBJECTS[objectName]?.rows ?? [];
+ const index = rows.findIndex((row) => String(row.id) === String(id));
+ if (index === -1) return { ...data, id } as GalleryRecord;
+ rows[index] = { ...rows[index], ...data };
+ return rows[index];
+ },
+
+ async delete(objectName: string, id: string | number) {
+ const rows = OBJECTS[objectName]?.rows ?? [];
+ const index = rows.findIndex((row) => String(row.id) === String(id));
+ if (index !== -1) rows.splice(index, 1);
+ return true;
+ },
};
diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx
index 99a03befce..a21996a93c 100644
--- a/content/docs/plugins/plugin-view.mdx
+++ b/content/docs/plugins/plugin-view.mdx
@@ -17,17 +17,27 @@ npm install @object-ui/plugin-view
## Interactive Examples
-### User Directory View
+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
-### Record Detail View
+
-
+### Saved views
-### Form Create View
+
-
+### The record surface
+
+
## Features
diff --git a/examples/schema-catalog/src/catalog-meta.json b/examples/schema-catalog/src/catalog-meta.json
index 48e6e0550a..26d74e0a78 100644
--- a/examples/schema-catalog/src/catalog-meta.json
+++ b/examples/schema-catalog/src/catalog-meta.json
@@ -36,6 +36,36 @@
"verification"
]
},
+ "components-layout-card/profile-detail-card": {
+ "title": "Profile Detail Card",
+ "description": "A card that draws one record by hand — avatar header, label/value rows, edit and delete footer. For a record surface bound to an object, see the `plugin-view` examples.",
+ "tags": [
+ "card",
+ "detail",
+ "profile",
+ "avatar"
+ ]
+ },
+ "components-layout-card/user-list-card": {
+ "title": "User List Card",
+ "description": "A card that draws a directory table by hand — header row, striped rows, status badges. For a table bound to an object, see the `plugin-view` / `plugin-grid` examples.",
+ "tags": [
+ "card",
+ "list",
+ "badge",
+ "layout"
+ ]
+ },
+ "forms/create-user-form": {
+ "title": "Create User Form",
+ "description": "Two-column name fields, email, role select and a submit/cancel footer.",
+ "tags": [
+ "form",
+ "create",
+ "select",
+ "grid"
+ ]
+ },
"plugin-dashboard/filtered-dashboard": {
"description": "Dashboard-level date + region filters driving multiple charts over different objects"
},
@@ -58,5 +88,35 @@
"plugin-dashboard/filtered-dashboard-target-widgets": {
"title": "Filtered Dashboard — Target Widgets Allow-list",
"description": "Legacy targetWidgets allow-list: only listed widgets get the default binding; an explicit filterBindings entry still wins"
+ },
+ "plugin-view/object-view-list": {
+ "title": "Object View — List Surface",
+ "description": "object-view rendering the users object: the columns declared in `table`, with search and sort served by the host's data source.",
+ "tags": [
+ "object-view",
+ "grid",
+ "list",
+ "objectql"
+ ]
+ },
+ "plugin-view/object-view-named-views": {
+ "title": "Object View — Saved Views",
+ "description": "Two `listViews` entries over one object, each with its own label and column set; `defaultListView` picks which opens first.",
+ "tags": [
+ "object-view",
+ "listViews",
+ "tabs",
+ "objectql"
+ ]
+ },
+ "plugin-view/object-view-record-surface": {
+ "title": "Object View — Record Surface",
+ "description": "Create, read and edit as one surface: `layout` decides where it opens, `form` decides what it contains, and a row click chooses the record.",
+ "tags": [
+ "object-view",
+ "form",
+ "drawer",
+ "crud"
+ ]
}
}
diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts
index aad9687df2..22e4f23856 100644
--- a/examples/schema-catalog/src/index.ts
+++ b/examples/schema-catalog/src/index.ts
@@ -253,6 +253,8 @@ import components_layout_aspect_ratio_square from './schemas/components-layout-a
import components_layout_aspect_ratio_ultrawide from './schemas/components-layout-aspect-ratio/ultrawide.json' with { type: 'json' };
import components_layout_aspect_ratio_video_aspect_ratio from './schemas/components-layout-aspect-ratio/video-aspect-ratio.json' with { type: 'json' };
import components_layout_card_basic_card from './schemas/components-layout-card/basic-card.json' with { type: 'json' };
+import components_layout_card_profile_detail_card from './schemas/components-layout-card/profile-detail-card.json' with { type: 'json' };
+import components_layout_card_user_list_card from './schemas/components-layout-card/user-list-card.json' with { type: 'json' };
import components_layout_card_with_footer from './schemas/components-layout-card/with-footer.json' with { type: 'json' };
import components_layout_container_basic_container from './schemas/components-layout-container/basic-container.json' with { type: 'json' };
import components_layout_flex_horizontal_layout from './schemas/components-layout-flex/horizontal-layout.json' with { type: 'json' };
@@ -381,6 +383,7 @@ import fields_user_single_user_selection from './schemas/fields-user/single-user
import fields_vector_basic_vector_display from './schemas/fields-vector/basic-vector-display.json' with { type: 'json' };
import fields_vector_high_dimensional_vector from './schemas/fields-vector/high-dimensional-vector.json' with { type: 'json' };
import forms_contact_form from './schemas/forms/contact-form.json' with { type: 'json' };
+import forms_create_user_form from './schemas/forms/create-user-form.json' with { type: 'json' };
import forms_newsletter_signup from './schemas/forms/newsletter-signup.json' with { type: 'json' };
import forms_payment_form from './schemas/forms/payment-form.json' with { type: 'json' };
import forms_settings_form from './schemas/forms/settings-form.json' with { type: 'json' };
@@ -427,9 +430,9 @@ import plugin_markdown_markdown_tables from './schemas/plugin-markdown/markdown-
import plugin_timeline_gantt_style_timeline from './schemas/plugin-timeline/gantt-style-timeline.json' with { type: 'json' };
import plugin_timeline_horizontal_timeline from './schemas/plugin-timeline/horizontal-timeline.json' with { type: 'json' };
import plugin_timeline_vertical_timeline from './schemas/plugin-timeline/vertical-timeline.json' with { type: 'json' };
-import plugin_view_detail_view_mode from './schemas/plugin-view/detail-view-mode.json' with { type: 'json' };
-import plugin_view_form_view_mode from './schemas/plugin-view/form-view-mode.json' with { type: 'json' };
-import plugin_view_grid_view_mode from './schemas/plugin-view/grid-view-mode.json' with { type: 'json' };
+import plugin_view_object_view_list from './schemas/plugin-view/object-view-list.json' with { type: 'json' };
+import plugin_view_object_view_named_views from './schemas/plugin-view/object-view-named-views.json' with { type: 'json' };
+import plugin_view_object_view_record_surface from './schemas/plugin-view/object-view-record-surface.json' with { type: 'json' };
import report_report_breakdown_table from './schemas/report/report-breakdown-table.json' with { type: 'json' };
import report_report_header_with_kpis from './schemas/report/report-header-with-kpis.json' with { type: 'json' };
import report_report_scheduling from './schemas/report/report-scheduling.json' with { type: 'json' };
@@ -2620,6 +2623,26 @@ const REGISTRY: Record = {
},
schema: components_layout_card_basic_card,
},
+ 'components-layout-card/profile-detail-card': {
+ id: 'components-layout-card/profile-detail-card',
+ meta: {
+ title: "Profile Detail Card",
+ description: "A card that draws one record by hand — avatar header, label/value rows, edit and delete footer. For a record surface bound to an object, see the `plugin-view` examples.",
+ category: 'components-layout-card',
+ tags: ["card", "detail", "profile", "avatar"],
+ },
+ schema: components_layout_card_profile_detail_card,
+ },
+ 'components-layout-card/user-list-card': {
+ id: 'components-layout-card/user-list-card',
+ meta: {
+ title: "User List Card",
+ description: "A card that draws a directory table by hand — header row, striped rows, status badges. For a table bound to an object, see the `plugin-view` / `plugin-grid` examples.",
+ category: 'components-layout-card',
+ tags: ["card", "list", "badge", "layout"],
+ },
+ schema: components_layout_card_user_list_card,
+ },
'components-layout-card/with-footer': {
id: 'components-layout-card/with-footer',
meta: {
@@ -3772,6 +3795,16 @@ const REGISTRY: Record = {
},
schema: forms_contact_form,
},
+ 'forms/create-user-form': {
+ id: 'forms/create-user-form',
+ meta: {
+ title: "Create User Form",
+ description: "Two-column name fields, email, role select and a submit/cancel footer.",
+ category: 'forms',
+ tags: ["form", "create", "select", "grid"],
+ },
+ schema: forms_create_user_form,
+ },
'forms/newsletter-signup': {
id: 'forms/newsletter-signup',
meta: {
@@ -4186,32 +4219,35 @@ const REGISTRY: Record = {
},
schema: plugin_timeline_vertical_timeline,
},
- 'plugin-view/detail-view-mode': {
- id: 'plugin-view/detail-view-mode',
+ 'plugin-view/object-view-list': {
+ id: 'plugin-view/object-view-list',
meta: {
- title: "Detail View Mode",
- description: "",
+ title: "Object View — List Surface",
+ description: "object-view rendering the users object: the columns declared in `table`, with search and sort served by the host's data source.",
category: 'plugin-view',
+ tags: ["object-view", "grid", "list", "objectql"],
},
- schema: plugin_view_detail_view_mode,
+ schema: plugin_view_object_view_list,
},
- 'plugin-view/form-view-mode': {
- id: 'plugin-view/form-view-mode',
+ 'plugin-view/object-view-named-views': {
+ id: 'plugin-view/object-view-named-views',
meta: {
- title: "Form View Mode",
- description: "",
+ title: "Object View — Saved Views",
+ description: "Two `listViews` entries over one object, each with its own label and column set; `defaultListView` picks which opens first.",
category: 'plugin-view',
+ tags: ["object-view", "listViews", "tabs", "objectql"],
},
- schema: plugin_view_form_view_mode,
+ schema: plugin_view_object_view_named_views,
},
- 'plugin-view/grid-view-mode': {
- id: 'plugin-view/grid-view-mode',
+ 'plugin-view/object-view-record-surface': {
+ id: 'plugin-view/object-view-record-surface',
meta: {
- title: "Grid View Mode",
- description: "",
+ title: "Object View — Record Surface",
+ description: "Create, read and edit as one surface: `layout` decides where it opens, `form` decides what it contains, and a row click chooses the record.",
category: 'plugin-view',
+ tags: ["object-view", "form", "drawer", "crud"],
},
- schema: plugin_view_grid_view_mode,
+ schema: plugin_view_object_view_record_surface,
},
'report/report-breakdown-table': {
id: 'report/report-breakdown-table',
diff --git a/examples/schema-catalog/src/schemas/plugin-view/detail-view-mode.json b/examples/schema-catalog/src/schemas/components-layout-card/profile-detail-card.json
similarity index 100%
rename from examples/schema-catalog/src/schemas/plugin-view/detail-view-mode.json
rename to examples/schema-catalog/src/schemas/components-layout-card/profile-detail-card.json
diff --git a/examples/schema-catalog/src/schemas/plugin-view/grid-view-mode.json b/examples/schema-catalog/src/schemas/components-layout-card/user-list-card.json
similarity index 100%
rename from examples/schema-catalog/src/schemas/plugin-view/grid-view-mode.json
rename to examples/schema-catalog/src/schemas/components-layout-card/user-list-card.json
diff --git a/examples/schema-catalog/src/schemas/plugin-view/form-view-mode.json b/examples/schema-catalog/src/schemas/forms/create-user-form.json
similarity index 100%
rename from examples/schema-catalog/src/schemas/plugin-view/form-view-mode.json
rename to examples/schema-catalog/src/schemas/forms/create-user-form.json
diff --git a/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json b/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json
new file mode 100644
index 0000000000..25343781a0
--- /dev/null
+++ b/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json
@@ -0,0 +1,17 @@
+{
+ "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", "role", "department", "status"],
+ "sort": "name asc",
+ "pagination": { "pageSize": 5 }
+ }
+}
diff --git a/examples/schema-catalog/src/schemas/plugin-view/object-view-named-views.json b/examples/schema-catalog/src/schemas/plugin-view/object-view-named-views.json
new file mode 100644
index 0000000000..8d31dfb930
--- /dev/null
+++ b/examples/schema-catalog/src/schemas/plugin-view/object-view-named-views.json
@@ -0,0 +1,20 @@
+{
+ "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", "role", "department", "status"]
+ },
+ "contacts": {
+ "label": "Contact Details",
+ "columns": ["name", "email", "department"]
+ }
+ }
+}
diff --git a/examples/schema-catalog/src/schemas/plugin-view/object-view-record-surface.json b/examples/schema-catalog/src/schemas/plugin-view/object-view-record-surface.json
new file mode 100644
index 0000000000..3bb81824ed
--- /dev/null
+++ b/examples/schema-catalog/src/schemas/plugin-view/object-view-record-surface.json
@@ -0,0 +1,21 @@
+{
+ "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", "role", "status"]
+ },
+ "form": {
+ "fields": ["name", "email", "role", "department", "status"],
+ "showSubmit": true,
+ "submitText": "Save user",
+ "showCancel": true
+ }
+}
diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx
index dc20f8d109..f042097e2b 100644
--- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx
+++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx
@@ -270,8 +270,38 @@ const AUTHORED_TEXT_EXEMPT: Record = {};
* The gallery's data source, in the shape `SchemaThumbnail` supplies it. Kept
* as a local literal rather than imported from `apps/site` because `apps/**` is
* outside every root Vitest project (`vitest.config.mts` `sharedExclude`); the
- * host-parity case at the end guards the two from drifting apart.
+ * host-parity cases at the end guard the two from drifting apart.
+ *
+ * objectui#5113 added the object surface (`getObjectSchema` / `find` / the
+ * writes) to the host fixture, because `object-view` reaches its data through
+ * exactly this context value — `dataSource` is not a schema key. The three
+ * `plugin-view` entries render through it, which is what
+ * `THE PLUGIN-VIEW ENTRIES` below asserts. What the mirror reproduces is the
+ * host's SURFACE and its rows; the query semantics ($search / $orderby /
+ * windowing) are the host's, and the parity case pins the method names rather
+ * than re-deriving them here.
*/
+const USERS_ROWS = [
+ { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', department: 'Engineering', status: 'active', created_at: '2024-01-14' },
+ { id: '2', name: 'Bob Chen', email: 'bob@example.com', role: 'member', department: 'Design', status: 'active', created_at: '2024-02-03' },
+ { id: '3', name: 'Carla Gómez', email: 'carla@example.com', role: 'member', department: 'Sales', status: 'invited', created_at: '2024-03-21' },
+ { id: '4', name: 'Dan Whitfield', email: 'dan@example.com', role: 'viewer', department: 'Support', status: 'suspended', created_at: '2024-04-09' },
+ { id: '5', name: 'Emily Novak', email: 'emily@example.com', role: 'member', department: 'Engineering', status: 'active', created_at: '2024-05-30' },
+];
+
+const USERS_SCHEMA = {
+ name: 'users',
+ label: 'Users',
+ fields: {
+ name: { label: 'Name', type: 'text' },
+ email: { label: 'Email', type: 'email' },
+ role: { label: 'Role', type: 'select' },
+ department: { label: 'Department', type: 'text' },
+ status: { label: 'Status', type: 'select' },
+ created_at: { label: 'Created', type: 'date' },
+ },
+};
+
const galleryDataSource = {
queryDataset: async (
_dataset: string,
@@ -288,8 +318,30 @@ const galleryDataSource = {
: [{ [measure]: 69 }];
return { rows, fields: [] };
},
+ getObjectSchema: async (objectName: string) =>
+ objectName === 'users' ? USERS_SCHEMA : { name: objectName, label: objectName, fields: {} },
+ find: async (objectName: string) =>
+ objectName === 'users'
+ ? { data: [...USERS_ROWS], total: USERS_ROWS.length }
+ : { data: [], total: 0 },
+ findOne: async (objectName: string, id: string | number) =>
+ (objectName === 'users' ? USERS_ROWS : []).find((row) => String(row.id) === String(id)) ?? null,
+ create: async (_objectName: string, data: Record) => ({ ...data, id: 'demo' }),
+ update: async (_objectName: string, id: string | number, data: Record) => ({ ...data, id }),
+ delete: async () => true,
};
+/** The method names the host fixture must expose for the mirror to be one. */
+const GALLERY_DATA_SOURCE_METHODS = [
+ 'queryDataset',
+ 'getObjectSchema',
+ 'find',
+ 'findOne',
+ 'create',
+ 'update',
+ 'delete',
+];
+
/** The two wrapper elements this harness adds around the entry's own root. */
const WRAPPER_ELEMENTS = 2;
@@ -552,3 +604,102 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', ()
);
});
});
+
+/**
+ * THE PLUGIN-VIEW ENTRIES ACTUALLY USE THE PLUGIN (objectui#5113).
+ *
+ * The sweep above answers "does every tile draw". It cannot answer the
+ * question objectui#5113 was filed on: whether an example mounted under a
+ * PLUGIN's docs page exercises that plugin. The three `plugin-view` entries
+ * used to be hand-built static card layouts — `card` / `flex` / `text` /
+ * `badge`, no `object-view` node anywhere — sitting on
+ * `content/docs/plugins/plugin-view.mdx` under an "Interactive Examples"
+ * heading and inside a `PluginLoader plugins={['view']}` wrapper none of them
+ * used. Every check in the repo was green on them: the types they named ARE
+ * registered, and the tiles DID draw.
+ *
+ * Two facts are pinned here, and the second is the one that cannot be
+ * satisfied by a picture of a view:
+ *
+ * 1. STRUCTURE — every entry in the category authors an `object-view` node.
+ * 2. RENDER — the tile shows a record that exists only in the gallery's data
+ * source, so the rows on screen came through `ObjectViewRenderer` →
+ * `ObjectGrid` → `dataSource.find`, not out of the entry's own JSON. An
+ * entry that went back to drawing its own table would keep (1) satisfiable
+ * by a stray node and would fail (2) outright.
+ *
+ * Deliberately scoped to `plugin-view` rather than generalized to every
+ * `plugin-*` category: the general rule needs a per-plugin map of which types
+ * each package registers, and other categories (`plugin-grid`'s two entries,
+ * for one) are hand-built mock-ups of exactly this kind today. That is a
+ * separate card, not a silent exemption list here.
+ */
+describe('objectui#5113 — the plugin-view entries render THROUGH object-view', () => {
+ const pluginViewEntries = entries.filter((e) => e.meta.category === 'plugin-view');
+
+ /** Every `type` string anywhere in a schema tree. */
+ function nodeTypes(node: unknown, acc: Set = new Set()): Set {
+ if (Array.isArray(node)) {
+ for (const n of node) nodeTypes(n, acc);
+ return acc;
+ }
+ if (node && typeof node === 'object') {
+ for (const [k, v] of Object.entries(node as Record)) {
+ if (k === 'type' && typeof v === 'string') acc.add(v);
+ nodeTypes(v, acc);
+ }
+ }
+ return acc;
+ }
+
+ it('the category is populated (guard is not vacuous)', () => {
+ expect(pluginViewEntries.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it.each(pluginViewEntries.map((e) => [e.id, e.schema] as const))(
+ '%s authors an object-view node',
+ (_id, schema) => {
+ expect([...nodeTypes(schema)]).toContain('object-view');
+ },
+ );
+
+ it.each(pluginViewEntries.map((e) => [e.id, e.schema] as const))(
+ '%s puts data from the gallery data source on screen',
+ async (_id, schema) => {
+ const r = await renderEntry(schema);
+ try {
+ // Not authored anywhere in the catalog — it exists only in the fixture.
+ expect(r.text).toContain('Alice Johnson');
+ } finally {
+ teardown(r);
+ }
+ },
+ );
+
+ /**
+ * HOST PARITY for the fixture, same technique and same reason as the
+ * registration-set parity above: the mirror at the top of this file is what
+ * the assertions run against, so a host fixture that lost `find` would leave
+ * this file green while the docs page went back to an empty view.
+ */
+ describe('the docs-site hosts supply the same fixture', () => {
+ const siteDir = path.join(process.cwd(), 'apps/site/app/components');
+ const read = (f: string) => fs.readFileSync(path.join(siteDir, f), 'utf8');
+
+ it('the host fixture exposes every method this mirror implements', () => {
+ const source = read('galleryDataSource.ts');
+ const missing = GALLERY_DATA_SOURCE_METHODS.filter(
+ (method) => !new RegExp(`\\basync ${method}\\s*\\(`).test(source),
+ );
+ expect(missing).toEqual([]);
+ });
+
+ it.each(['SchemaThumbnail.tsx', 'InteractiveDemo.tsx'])(
+ '%s hands it to the renderer',
+ (host) => {
+ expect(read(host)).toMatch(/^import \{ galleryDataSource \} from '\.\/galleryDataSource';$/m);
+ expect(read(host)).toContain('dataSource: galleryDataSource');
+ },
+ );
+ });
+});
diff --git a/examples/schema-catalog/test/grid-columns-key.test.tsx b/examples/schema-catalog/test/grid-columns-key.test.tsx
index dad2833a51..058661acdc 100644
--- a/examples/schema-catalog/test/grid-columns-key.test.tsx
+++ b/examples/schema-catalog/test/grid-columns-key.test.tsx
@@ -73,8 +73,13 @@ const REPAIRED: ReadonlyArray = [
['components-layout-page/full-dashboard', 4],
['components-layout-page/page-with-header', 3],
['forms/contact-form', 2],
+ // Was `plugin-view/form-view-mode` until objectui#5113. The entry is the same
+ // file with the same two-column grid node — it moved out of `plugin-view`
+ // because it is a hand-composed form layout, not an example of that plugin,
+ // whose three entries now author `object-view`. Renamed here rather than
+ // dropped: the repair this row pins is still in the catalog.
+ ['forms/create-user-form', 2],
['forms/payment-form', 3],
- ['plugin-view/form-view-mode', 2],
['report/report-header-with-kpis', 4],
['theme/semantic-color-palette', 4],
['theme/theme-aware-ui-elements', 2],