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],