From 74879a009de3cb5f71f25beac4c02b46de67f18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B7=E4=BA=AE=E8=BE=89?= Date: Tue, 25 Aug 2026 11:59:54 +0000 Subject: [PATCH 1/2] docs(plugin-form): make the two catalog entries real object-form nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plugin-form` was the last category still ledgered in `OWN_PLUGIN_DEBT`: both entries authored a root `type: "form"` with `input` / `select` / `checkbox` / `textarea` fields, every one of them registered by `@object-ui/components` and none by `@object-ui/plugin-form`. They sat on `content/docs/plugins/ plugin-form.mdx` inside a `PluginLoader plugins={['form']}` wrapper neither of them used. Replaced by two entries that read the docs gallery's demo data source through the registered `object-form` renderer, the way objectui#5113 did for `object-view` and objectui#5856 for `object-grid`. The two hand-built forms are re-seated into `components-form-form` rather than deleted, so none of the corpus-wide node counters moves. Two measurements changed how this landed, both recorded in the code: - `object-form` already resolved in the gallery WITHOUT this change — measured with exactly the eleven imports the host used to carry — because `@object-ui/plugin-view` imports `ObjectForm` from `@object-ui/plugin-form`. So declaring the package is a statement of what the gallery depends on, not a fix for a red tile. - the pin's DATA half read `container.textContent`, which a form's record never reaches: it lands in `input.value`. The instrument now reads both, and a control case pins that it still fails on a form with no record behind it. `renderEntry` also settles past `Loading form...` now. Without it the two new entries' provenance cases passed in a whole-file run and failed under `-t`, because the placeholder is real DOM that satisfies `drewSomething`. Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q Co-Authored-By: Claude Opus 5 --- .../6167-plugin-form-catalog-entries.md | 31 ++++ .../app/components/registerCatalogBlocks.ts | 25 ++- content/docs/plugins/plugin-form.mdx | 23 ++- examples/schema-catalog/package.json | 1 + examples/schema-catalog/src/catalog-meta.json | 40 +++++ examples/schema-catalog/src/index.ts | 48 ++++-- .../basic-form.json | 0 .../demo-request-form.json} | 0 .../plugin-form/object-form-record.json | 9 + .../object-form-tabbed-sections.json | 13 ++ .../test/catalog-gallery-render.test.tsx | 154 +++++++++++++++--- pnpm-lock.yaml | 3 + 12 files changed, 310 insertions(+), 37 deletions(-) create mode 100644 .changeset/6167-plugin-form-catalog-entries.md rename examples/schema-catalog/src/schemas/{plugin-form => components-form-form}/basic-form.json (100%) rename examples/schema-catalog/src/schemas/{plugin-form/contact-form.json => components-form-form/demo-request-form.json} (100%) create mode 100644 examples/schema-catalog/src/schemas/plugin-form/object-form-record.json create mode 100644 examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json diff --git a/.changeset/6167-plugin-form-catalog-entries.md b/.changeset/6167-plugin-form-catalog-entries.md new file mode 100644 index 0000000000..0a31d99a68 --- /dev/null +++ b/.changeset/6167-plugin-form-catalog-entries.md @@ -0,0 +1,31 @@ +--- +--- + +Docs and catalog fixtures only, no published package source touched: the two `plugin-form` +catalog entries are now real `object-form` nodes instead of hand-built `form` schemas. +`content/docs/plugins/plugin-form.mdx` mounted `basic-form` and `contact-form` under +`PluginLoader plugins={['form']}` while both authored a root `type: "form"` with `input` / +`select` / `checkbox` / `textarea` fields — every one of those registered by +`@object-ui/components`, none of them by `@object-ui/plugin-form`, whose own keys are +`object-form`, `embeddable-form`, `form-analytics`, `object-master-detail-form`, +`record:line_items` and `view:form`. They are replaced by `object-form-record` (one `users` +record, fields derived from the object's metadata, `fields` / `columns` shaping the grid) +and `object-form-tabbed-sections` (the same record with `formType: 'tabbed'`, so the +declared `sections` become tab panels of one form). + +The two hand-built forms are legitimate `@object-ui/components` examples filed under the +wrong plugin, so they are re-seated into `components-form-form` as `basic-form` and +`demo-request-form` rather than deleted — deleting a catalog entry moves corpus-wide +counters (`NODE_CENSUS` in `layout-dom-leak-5574.test.tsx` and in +`form-control-dom-leak-5632.test.tsx`, and the `className`-carrying layout node and `stack` +node floors in `layout-props-conversion.test.tsx`), and a counter that moves because a +fixture was deleted is indistinguishable later from one that moved because coverage +regressed. All of them are unchanged, with no floor edited. + +`apps/site/app/components/registerCatalogBlocks.ts` now declares `@object-ui/plugin-form`, +and its header no longer claims the import list is "exactly the packages that census +resolves to". The two `OWN_PLUGIN_DEBT` lines in `catalog-gallery-render.test.tsx` are +deleted, so both entries are held to the pin like every other one, and the pin's +provenance instrument now reads the fixture record out of a form control's value as well as +out of the tile's text — a form puts its record in `input.value`, where `textContent` +cannot see it. diff --git a/apps/site/app/components/registerCatalogBlocks.ts b/apps/site/app/components/registerCatalogBlocks.ts index 97d3ae303e..a9ffe82d33 100644 --- a/apps/site/app/components/registerCatalogBlocks.ts +++ b/apps/site/app/components/registerCatalogBlocks.ts @@ -36,10 +36,30 @@ * components-disclosure-toggle-group 1 core-schema-renderer 1 * * The gallery page's PURPOSE is to render the whole catalog, so its baseline is - * that every type an entry names is registered before the render. The nine - * imports below are exactly the packages that census resolves to — not a + * that every type an entry names is registered before the render. Nine of the + * imports below are exactly the packages that census resolved to — not a * precautionary sweep of the workspace. * + * ## The list is no longer ONLY that census (objectui#6167) + * + * `@object-ui/plugin-form` is here for a different reason, and the header has + * to say so rather than let the sentence above keep describing a property this + * file no longer has. The two `plugin-form` catalog entries are real + * `object-form` nodes as of objectui#6167, and `object-form` is registered by + * `@object-ui/plugin-form` — so the gallery depends on that package by name. + * + * What it did NOT do is change what resolves. Measured before adding it, with + * exactly the eleven imports this file used to carry: `object-form`, + * `plugin-form:object-form`, `embeddable-form`, `form-analytics`, + * `object-master-detail-form`, `record:line_items` and `view:form` all resolved + * already, because `@object-ui/plugin-view` — import #11 below — does + * `import { ObjectForm } from '@object-ui/plugin-form'` + * (`packages/plugin-view/src/ObjectView.tsx:38`), and importing that entry runs + * its `ComponentRegistry.register` calls. The package's graph was therefore + * ALREADY in this route's eager closure; the import below adds a declaration, + * not a payload. Registration is idempotent for the same reason: an ES module + * executes once, so naming it here does not re-run anything. + * * ## The cost, measured before deciding (objectui#4616 ruling 1) * * Eager registration pulls each package's graph into the `/docs/[[...slug]]` @@ -126,3 +146,4 @@ import '@object-ui/plugin-map'; import '@object-ui/plugin-markdown'; import '@object-ui/plugin-timeline'; import '@object-ui/plugin-view'; +import '@object-ui/plugin-form'; diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index f9a50e9722..f734fad386 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -17,9 +17,28 @@ npm install @object-ui/plugin-form ## Interactive Examples - +Each preview below **is** an `object-form` node drawn by this plugin — the JSON +in the Code tab is the whole example, and the fields on screen were built from +the `users` object's own metadata rather than written into that JSON. Both the +metadata and the record come from 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 +[Registration is a side effect of the import](#registration-is-a-side-effect-of-the-import) +below), so in your own app these same nodes read whatever object your data +source serves. - +A plain `form` node with its fields written out inline is the +`@object-ui/components` renderer, not this plugin — that is what these two +examples used to be, and they now live in the catalog under +`components-form-form`. + +### One record, fields from the object + + + +### Sections as tabs + + ## Features diff --git a/examples/schema-catalog/package.json b/examples/schema-catalog/package.json index ec9bd46254..8f37b876ae 100644 --- a/examples/schema-catalog/package.json +++ b/examples/schema-catalog/package.json @@ -39,6 +39,7 @@ "@object-ui/plugin-chatbot": "workspace:*", "@object-ui/plugin-dashboard": "workspace:*", "@object-ui/plugin-editor": "workspace:*", + "@object-ui/plugin-form": "workspace:*", "@object-ui/plugin-gantt": "workspace:*", "@object-ui/plugin-kanban": "workspace:*", "@object-ui/plugin-map": "workspace:*", diff --git a/examples/schema-catalog/src/catalog-meta.json b/examples/schema-catalog/src/catalog-meta.json index 83157eedd9..892662de1f 100644 --- a/examples/schema-catalog/src/catalog-meta.json +++ b/examples/schema-catalog/src/catalog-meta.json @@ -36,6 +36,26 @@ "verification" ] }, + "components-form-form/basic-form": { + "title": "Basic Form", + "description": "A hand-built `form` node from `@object-ui/components`: name, email, country and newsletter fields declared inline, with no object behind them. For a form generated from an object's own metadata, see the `plugin-form` examples.", + "tags": [ + "form", + "input", + "select", + "checkbox" + ] + }, + "components-form-form/demo-request-form": { + "title": "Demo Request Form", + "description": "A longer hand-built `form`: company details, industry and size selects, a message textarea and a terms checkbox, all declared inline. For a form bound to an object, see the `plugin-form` examples.", + "tags": [ + "form", + "select", + "textarea", + "checkbox" + ] + }, "components-layout-card/inventory-table-card": { "title": "Inventory Table Card", "description": "A card that draws a product inventory table by hand — toolbar buttons, header row, stock-status badges. For a table bound to an object, see the `plugin-grid` examples.", @@ -109,6 +129,26 @@ "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-form/object-form-record": { + "title": "Object Form — One Record From Object Metadata", + "description": "object-form editing one `users` record: the fields come from the object's own metadata through `getObjectSchema`, the values from `findOne`, and `fields` / `columns` decide which of them appear and how wide the grid is.", + "tags": [ + "object-form", + "objectql", + "edit", + "columns" + ] + }, + "plugin-form/object-form-tabbed-sections": { + "title": "Object Form — Sections as Tabs", + "description": "The same record with `formType: 'tabbed'`: the declared `sections` become tab panels of ONE form, so a single submit spans them and a tab the user leaves keeps its values.", + "tags": [ + "object-form", + "tabbed", + "sections", + "objectql" + ] + }, "plugin-grid/object-grid-columns": { "title": "Object Grid — Column Definitions", "description": "object-grid rendering the users object: `ListColumn` objects carrying label, width, align, cell `type` and `link`, with `sort`, `searchableFields` and `pagination` shaping the query the host's data source answers.", diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index 11fd014e35..d3bffaee23 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -210,7 +210,9 @@ import components_form_file_upload_images_only from './schemas/components-form-f import components_form_file_upload_multiple_files from './schemas/components-form-file-upload/multiple-files.json' with { type: 'json' }; import components_form_file_upload_simple_upload from './schemas/components-form-file-upload/simple-upload.json' with { type: 'json' }; import components_form_file_upload_single_file from './schemas/components-form-file-upload/single-file.json' with { type: 'json' }; +import components_form_form_basic_form from './schemas/components-form-form/basic-form.json' with { type: 'json' }; import components_form_form_contact_form from './schemas/components-form-form/contact-form.json' with { type: 'json' }; +import components_form_form_demo_request_form from './schemas/components-form-form/demo-request-form.json' with { type: 'json' }; import components_form_form_login_form from './schemas/components-form-form/login-form.json' with { type: 'json' }; import components_form_form_registration_form from './schemas/components-form-form/registration-form.json' with { type: 'json' }; import components_form_input_basic_input from './schemas/components-form-input/basic-input.json' with { type: 'json' }; @@ -414,8 +416,8 @@ import plugin_dashboard_support_dashboard from './schemas/plugin-dashboard/suppo import plugin_editor_javascript_editor from './schemas/plugin-editor/javascript-editor.json' with { type: 'json' }; import plugin_editor_python_editor from './schemas/plugin-editor/python-editor.json' with { type: 'json' }; import plugin_editor_read_only_json_viewer from './schemas/plugin-editor/read-only-json-viewer.json' with { type: 'json' }; -import plugin_form_basic_form from './schemas/plugin-form/basic-form.json' with { type: 'json' }; -import plugin_form_contact_form from './schemas/plugin-form/contact-form.json' with { type: 'json' }; +import plugin_form_object_form_record from './schemas/plugin-form/object-form-record.json' with { type: 'json' }; +import plugin_form_object_form_tabbed_sections from './schemas/plugin-form/object-form-tabbed-sections.json' with { type: 'json' }; import plugin_gantt_construction_project_phases from './schemas/plugin-gantt/construction-project-phases.json' with { type: 'json' }; import plugin_gantt_project_timeline_with_dependencies from './schemas/plugin-gantt/project-timeline-with-dependencies.json' with { type: 'json' }; import plugin_gantt_sprint_development_timeline from './schemas/plugin-gantt/sprint-development-timeline.json' with { type: 'json' }; @@ -2238,6 +2240,16 @@ const REGISTRY: Record = { }, schema: components_form_file_upload_single_file, }, + 'components-form-form/basic-form': { + id: 'components-form-form/basic-form', + meta: { + title: "Basic Form", + description: "A hand-built `form` node from `@object-ui/components`: name, email, country and newsletter fields declared inline, with no object behind them. For a form generated from an object's own metadata, see the `plugin-form` examples.", + category: 'components-form-form', + tags: ["form", "input", "select", "checkbox"], + }, + schema: components_form_form_basic_form, + }, 'components-form-form/contact-form': { id: 'components-form-form/contact-form', meta: { @@ -2247,6 +2259,16 @@ const REGISTRY: Record = { }, schema: components_form_form_contact_form, }, + 'components-form-form/demo-request-form': { + id: 'components-form-form/demo-request-form', + meta: { + title: "Demo Request Form", + description: "A longer hand-built `form`: company details, industry and size selects, a message textarea and a terms checkbox, all declared inline. For a form bound to an object, see the `plugin-form` examples.", + category: 'components-form-form', + tags: ["form", "select", "textarea", "checkbox"], + }, + schema: components_form_form_demo_request_form, + }, 'components-form-form/login-form': { id: 'components-form-form/login-form', meta: { @@ -4079,23 +4101,25 @@ const REGISTRY: Record = { }, schema: plugin_editor_read_only_json_viewer, }, - 'plugin-form/basic-form': { - id: 'plugin-form/basic-form', + 'plugin-form/object-form-record': { + id: 'plugin-form/object-form-record', meta: { - title: "Basic Form", - description: "", + title: "Object Form — One Record From Object Metadata", + description: "object-form editing one `users` record: the fields come from the object's own metadata through `getObjectSchema`, the values from `findOne`, and `fields` / `columns` decide which of them appear and how wide the grid is.", category: 'plugin-form', + tags: ["object-form", "objectql", "edit", "columns"], }, - schema: plugin_form_basic_form, + schema: plugin_form_object_form_record, }, - 'plugin-form/contact-form': { - id: 'plugin-form/contact-form', + 'plugin-form/object-form-tabbed-sections': { + id: 'plugin-form/object-form-tabbed-sections', meta: { - title: "Contact Form", - description: "", + title: "Object Form — Sections as Tabs", + description: "The same record with `formType: 'tabbed'`: the declared `sections` become tab panels of ONE form, so a single submit spans them and a tab the user leaves keeps its values.", category: 'plugin-form', + tags: ["object-form", "tabbed", "sections", "objectql"], }, - schema: plugin_form_contact_form, + schema: plugin_form_object_form_tabbed_sections, }, 'plugin-gantt/construction-project-phases': { id: 'plugin-gantt/construction-project-phases', diff --git a/examples/schema-catalog/src/schemas/plugin-form/basic-form.json b/examples/schema-catalog/src/schemas/components-form-form/basic-form.json similarity index 100% rename from examples/schema-catalog/src/schemas/plugin-form/basic-form.json rename to examples/schema-catalog/src/schemas/components-form-form/basic-form.json diff --git a/examples/schema-catalog/src/schemas/plugin-form/contact-form.json b/examples/schema-catalog/src/schemas/components-form-form/demo-request-form.json similarity index 100% rename from examples/schema-catalog/src/schemas/plugin-form/contact-form.json rename to examples/schema-catalog/src/schemas/components-form-form/demo-request-form.json diff --git a/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json b/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json new file mode 100644 index 0000000000..7f0233776c --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-form/object-form-record.json @@ -0,0 +1,9 @@ +{ + "type": "object-form", + "objectName": "users", + "mode": "edit", + "recordId": "1", + "columns": 2, + "fields": ["name", "email", "department"], + "submitText": "Save changes" +} diff --git a/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json b/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json new file mode 100644 index 0000000000..cb6d7ba75b --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json @@ -0,0 +1,13 @@ +{ + "type": "object-form", + "objectName": "users", + "mode": "edit", + "recordId": "1", + "formType": "tabbed", + "defaultTab": "identity", + "sections": [ + { "name": "identity", "label": "Identity", "columns": 2, "fields": ["name", "email"] }, + { "name": "organisation", "label": "Organisation", "fields": ["department", "created_at"] } + ], + "submitText": "Save changes" +} diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index 34ab74741f..284a9c107c 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -34,8 +34,10 @@ * components-disclosure-toggle-group 1 core-schema-renderer 1 * * That is the red this file was written against. `apps/site/app/components/ - * registerCatalogBlocks.ts` now loads the nine further packages that census - * resolves to, which takes 31 of those 33 tiles from the panel to a drawn + * registerCatalogBlocks.ts` loads the nine further packages that census + * resolved to — further packages have joined that list since, none of them + * from this census (see the host file's own header) — which takes 31 of those + * 33 tiles from the panel to a drawn * component. What registration cannot reach is named — never skipped silently — * in the tables below. Four classes were defects in the entries, one issue each: * @@ -138,6 +140,7 @@ import '@object-ui/plugin-map'; import '@object-ui/plugin-markdown'; import '@object-ui/plugin-timeline'; import '@object-ui/plugin-view'; +import '@object-ui/plugin-form'; import { SidebarProvider } from '@object-ui/components'; import { registerLayout } from '@object-ui/layout'; import { ComponentRegistry } from '@object-ui/core'; @@ -172,6 +175,17 @@ const DATASOURCE_REQUIRED = 'DataSource required for object/api providers'; const ALL_DIAGNOSTICS = [UNKNOWN_COMPONENT, FAILED_TO_RENDER, DATASOURCE_REQUIRED]; +/** + * The placeholder every `@object-ui/plugin-form` container paints while it + * fetches (`ObjectForm.tsx:1092`, and the same string in `TabbedForm`, + * `SplitForm`, `WizardForm`, `DrawerForm`). NOT a diagnostic — it is a frame on + * the way to the tile — but `renderEntry` has to settle past it, so it is a + * literal here for the same reason the three above are: a reworded placeholder + * should turn this file red for review rather than silently stop being waited + * for. + */ +const FORM_LOADING = 'Loading form...'; + /** The packages the gallery host must load, in the host's own order. */ const HOST_PACKAGES = [ '@object-ui/plugin-dashboard', @@ -185,6 +199,7 @@ const HOST_PACKAGES = [ '@object-ui/plugin-markdown', '@object-ui/plugin-timeline', '@object-ui/plugin-view', + '@object-ui/plugin-form', ]; /** @@ -355,6 +370,12 @@ const WRAPPER_ELEMENTS = 2; interface Rendered { text: string; elements: number; + /** + * The values of the tile's form controls. `textContent` cannot see them, and + * for a FORM that is exactly where the data it was given ends up — see + * `carriesFixtureRecord` below. + */ + controlValues: string[]; unmount: () => void; } @@ -396,9 +417,22 @@ async function renderEntry(schema: unknown): Promise { drewSomething(container.querySelectorAll('*').length, container.textContent ?? ''), ).toBe(true), ); + // A third shape (objectui#6167), and the one that makes the two above + // insufficient: a data-bound form holds its first paint behind + // `Loading form...` while it fetches the object schema and the record. That + // placeholder is 5 elements of real DOM, so `drewSomething` accepts it — and + // every assertion after it would then be measured against a frame that was + // never the tile. Measured: without this wait the `plugin-form` entries' + // DATA-provenance cases PASS in a whole-file run (the 570 renders before them + // leave the promises resolved) and FAIL when the file is run with `-t`, which + // is the definition of a reading that is not a measurement. + await waitFor(() => expect(container.textContent ?? '').not.toContain(FORM_LOADING)); return { text: container.textContent ?? '', elements: container.querySelectorAll('*').length, + controlValues: Array.from( + container.querySelectorAll('input, textarea, select'), + ).map((el) => (el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value ?? ''), unmount, }; } @@ -600,8 +634,8 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () * The objectui#4600 separation, restated because objectui#4616 is exactly * the change that would tempt someone to collapse it: the per-page demo * hosts opt into their plugins through `PluginLoader`, and importing this - * module from them would make every docs page carrying a demo load all - * eleven graphs eagerly. + * module from them would make every docs page carrying a demo load every + * graph in that list eagerly. */ it.each(['InteractiveDemo.tsx', 'LiveSplitDemo.tsx'])( '%s still does NOT import it (PluginLoader stays lazy there)', @@ -658,6 +692,24 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () * `dataSource.find`, not out of the entry's own JSON. This is * objectui#5113's original second fact, unweakened. * + * ## What "shows a record" means, after objectui#6167 + * + * The provenance token is looked for in the tile's TEXT **and** in the values + * of its form CONTROLS, because those are two different places the same fact + * lands. A grid puts a row into text; a form puts the record into + * `input.value`, which `textContent` cannot see. Measured on the `plugin-form` + * entries this card added: `Alice Johnson` arrives as the value of + * `input[name]`, while the whole tile's text reads + * `NameEmailDepartmentCancelSave changes`. A text-only reading would have + * called a correctly bound form unbound — the blind-instrument failure, on the + * very half that does the work. + * + * The widening is a SUPERSET of the old reading, so it cannot turn a red entry + * green by relaxing anything; and `it('the provenance instrument still fails + * on a surface with no record behind it')` below is the control that it still + * discriminates — the same renderer in `create` mode paints the same controls + * with nothing in them, and fails. + * * (3) is the strongest of the three and it does NOT generalize, for a reason * that is a property of the entries rather than a gap in this file: the other * eleven categories author their data INLINE (`data`, `staticData`, `messages`, @@ -708,8 +760,9 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () * `packages/components/src/renderers/form/form.tsx`, not by * `@object-ui/plugin-form`, whose own keys are `object-form`, * `embeddable-form`, `form-analytics` and `object-master-detail-form`. The two - * `plugin-form` entries are therefore the third instance of the #5113 defect, - * ledgered in `OWN_PLUGIN_DEBT` below against the card that owns them. An + * `plugin-form` entries were therefore the third instance of the #5113 defect, + * and were ledgered in `OWN_PLUGIN_DEBT` below until objectui#6167 rewrote them + * as real `object-form` nodes; the ledger is empty as of that card. An * enumerated table would have inherited that mistake and reported them green. * * ## Why the set, not a single type @@ -827,6 +880,16 @@ const FIXTURE_OBJECTS = new Set([USERS_SCHEMA.name]); /** The record that exists ONLY in the fixture — the provenance token. */ const FIXTURE_ONLY_RECORD = USERS_ROWS[0].name; +/** + * Did the fixture's record reach the DOM at all? TEXT **or** the value of a + * form control — see "What \"shows a record\" means" in the header. A form is + * the case that needs the second half: its record lands in `input.value`, + * where `textContent` cannot see it. + */ +const carriesFixtureRecord = (r: Rendered) => + r.text.includes(FIXTURE_ONLY_RECORD) || + r.controlValues.some((v) => v.includes(FIXTURE_ONLY_RECORD)); + /** Does the entry bind to an object the fixture serves? Read off its own JSON. */ function bindsFixtureObject(node: unknown): boolean { if (Array.isArray(node)) return node.some((n) => bindsFixtureObject(n)); @@ -845,19 +908,18 @@ function bindsFixtureObject(node: unknown): boolean { * fix, and asserted below to STILL FAIL: an entry that starts conforming fails * this file until its line is deleted, so the ledger cannot rot green. * + * EMPTY as of objectui#6167, and that emptiness is an assertion rather than a + * dormant mechanism: the coverage-split case below pins `ledgered-debt` to `[]`, + * so the day an entry is added here the split moves and says so. The two lines + * it used to carry were `plugin-form/basic-form` and `plugin-form/contact-form`, + * deleted by that card together with the entries they described — the ledger is + * emptied by fixing entries, never by editing this list. + * * ⛔ This is not an exemption list. Nothing may be added here to make a red * turn green — a new entry that does not use its own plugin is a defect in that * entry, and the fix is the entry. */ -const OWN_PLUGIN_DEBT: Record = { - 'plugin-form/basic-form': - 'objectui#6167 — authors `form`, which `packages/components` registers, not the ' + - '`object-form` of `@object-ui/plugin-form`. The gallery host does not load ' + - '`@object-ui/plugin-form` at all, so the fix spans the entries and ' + - '`registerCatalogBlocks.ts`.', - 'plugin-form/contact-form': - 'objectui#6167 — same defect as `basic-form`; see that entry.', -}; +const OWN_PLUGIN_DEBT: Record = {}; const pluginEntries = entries.filter((e) => PLUGIN_CATEGORIES.includes(e.meta.category)); const isDataBound = (e: (typeof pluginEntries)[number]) => bindsFixtureObject(e.schema); @@ -894,6 +956,45 @@ describe('objectui#6024 — the derivation this pin is built on', () => { expect(authoring).toEqual([]); }); + /** + * THE PROVENANCE INSTRUMENT STILL DISCRIMINATES (objectui#6167). + * + * `carriesFixtureRecord` reads text OR control values, which is a WIDER + * reading than the `r.text` one objectui#5113 wrote. A widened instrument has + * to be shown to still fail on the thing it is meant to catch, or the entries + * it newly admits are admitted by relaxation. + * + * The control is the `plugin-form` entries' own shape with ONE thing changed: + * a `recordId` the fixture does not serve. Same renderer, same object, same + * mode, same harness — so what it measures is the record and nothing else. + * Its controls still render, which is asserted first: without that, a form + * that painted nothing would satisfy the line below while measuring nothing. + * + * (`mode: 'create'` was tried first and is NOT usable here: it paints its + * shell before the object schema resolves, so `renderEntry` — which settles + * on "something was drawn" — returns with zero controls. The edit path holds + * its first paint until the fetch lands, which is why this shape settles.) + */ + it('the provenance instrument still fails on a surface with no record behind it', async () => { + const r = await renderEntry({ + type: 'object-form', + objectName: 'users', + mode: 'edit', + recordId: 'no-such-record', + fields: ['name', 'email', 'department'], + }); + try { + expect( + r.controlValues.length, + 'the control form rendered no controls at all, so the assertion below would ' + + 'pass without measuring anything', + ).toBeGreaterThan(0); + expect(carriesFixtureRecord(r)).toBe(false); + } finally { + teardown(r); + } + }); + /** * THE SPLIT, STATED BY THE GATE ITSELF. Breadth is not depth: this case is * what stops "the pin covers all thirteen categories" from being read as @@ -924,8 +1025,11 @@ describe('objectui#6024 — the derivation this pin is built on', () => { categories: 13, entries: 41, // The strong half — the tile shows a record only the fixture holds. - // These are the two categories whose entries bind to `users`. - 'structure+mount+data': { categories: ['plugin-grid', 'plugin-view'], entries: 5 }, + // These are the three categories whose entries bind to `users`. + 'structure+mount+data': { + categories: ['plugin-form', 'plugin-grid', 'plugin-view'], + entries: 7, + }, // The other eleven author their data inline, so no fixture-only record // can reach their tiles. They carry STRUCTURE and MOUNT, and nothing here // claims otherwise. @@ -945,7 +1049,9 @@ describe('objectui#6024 — the derivation this pin is built on', () => { entries: 34, }, // Open defects with an owning card, NOT exemptions. See OWN_PLUGIN_DEBT. - 'ledgered-debt': ['plugin-form/basic-form', 'plugin-form/contact-form'], + // EMPTY as of objectui#6167 — every plugin category is now held to the + // rule, and adding a line to the ledger moves this literal. + 'ledgered-debt': [], }); }); @@ -1018,12 +1124,18 @@ describe.each(PLUGIN_CATEGORIES)( if (dataBound.length > 0) { it.each(dataBound.map((e) => [e.id, e.schema] as const))( '%s puts data from the gallery data source on screen', - async (_id, schema) => { + async (id, schema) => { const r = await renderEntry(schema); try { // Not authored anywhere in the catalog — it exists only in the - // fixture, which the case above pins. - expect(r.text).toContain(FIXTURE_ONLY_RECORD); + // fixture, which the case above pins. Read out of the text OR a + // control's value: a grid shows it, a form holds it. + expect( + carriesFixtureRecord(r), + `${id} renders with ${FIXTURE_ONLY_RECORD} nowhere in it — not in the tile's ` + + "text and not in a form control's value — so nothing on screen came through " + + 'the registered renderer → `dataSource.find`.', + ).toBe(true); } finally { teardown(r); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b053973ace..37ebd339a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,9 @@ importers: '@object-ui/plugin-editor': specifier: workspace:* version: link:../../packages/plugin-editor + '@object-ui/plugin-form': + specifier: workspace:* + version: link:../../packages/plugin-form '@object-ui/plugin-gantt': specifier: workspace:* version: link:../../packages/plugin-gantt From 778aaf135580ae398e5c2a3eef14f1202fb3c172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B7=E4=BA=AE=E8=BE=89?= Date: Tue, 25 Aug 2026 12:07:37 +0000 Subject: [PATCH 2/2] docs(site): declare @object-ui/plugin-grid in the catalog gallery host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object-grid` reached the gallery's registry only because `packages/plugin-view/src/ObjectView.tsx:37` imports `ObjectGrid` — a component import in another package, not a plugin dependency of the host. Since objectui#5856 the two `plugin-grid` catalog entries are real `object-grid` nodes, so the gallery genuinely depends on that path: a refactor of `object-view` that stopped drawing a grid itself would turn two tiles into OBJUI-001 panels, with the cause several files from the symptom. `registerCatalogBlocks.ts` now names the package, and `HOST_PACKAGES` mirrors it. Nothing renders differently — measured with exactly the eleven packages the host used to carry, `object-grid` and every `@object-ui/plugin-form` key already resolved. That measurement is also why the judge for this is NOT `ComponentRegistry.get('object-grid')`: it is truthy in the declared world and in the transitive one alike, so it cannot fail in either and judges nothing here. The case added instead reads the host's DECLARATION — every `plugin-*` category with catalog entries must be loaded by name — and leans on the existing parity case that ties `HOST_PACKAGES` to the host file's literal import list. The rule is derived from the categories, so a new plugin category with entries picks it up with no edit. Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q Co-Authored-By: Claude Opus 5 --- ...025-declare-plugin-grid-in-gallery-host.md | 17 +++++ .../app/components/registerCatalogBlocks.ts | 14 ++++ examples/schema-catalog/package.json | 1 + .../test/catalog-gallery-render.test.tsx | 73 +++++++++++++++++-- pnpm-lock.yaml | 3 + 5 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 .changeset/6025-declare-plugin-grid-in-gallery-host.md diff --git a/.changeset/6025-declare-plugin-grid-in-gallery-host.md b/.changeset/6025-declare-plugin-grid-in-gallery-host.md new file mode 100644 index 0000000000..abfbccde32 --- /dev/null +++ b/.changeset/6025-declare-plugin-grid-in-gallery-host.md @@ -0,0 +1,17 @@ +--- +--- + +Docs-site host and catalog test only: `apps/site/app/components/registerCatalogBlocks.ts` +now declares `@object-ui/plugin-grid` instead of inheriting `object-grid` from +`@object-ui/plugin-view`'s `import { ObjectGrid }` (`ObjectView.tsx:37`). Nothing renders +differently — measured: with exactly the eleven packages the host used to carry, +`object-grid` and every `@object-ui/plugin-form` key already resolved — which is the point. +A component import in another package was the only reason two catalog tiles could draw, and +a refactor of `object-view` that stopped it from drawing a grid itself would have turned +them into OBJUI-001 panels with the cause several files from the symptom. + +`catalog-gallery-render.test.tsx` gains the judge that can tell the two apart: every +`plugin-*` category with catalog entries must be loaded BY NAME in the host's import list, +derived from the categories rather than enumerated, and leaning on the existing parity case +that ties `HOST_PACKAGES` to the host file's literal imports. `ComponentRegistry.get()` +cannot judge this — it is truthy in both worlds. diff --git a/apps/site/app/components/registerCatalogBlocks.ts b/apps/site/app/components/registerCatalogBlocks.ts index a9ffe82d33..6aa4a78d97 100644 --- a/apps/site/app/components/registerCatalogBlocks.ts +++ b/apps/site/app/components/registerCatalogBlocks.ts @@ -60,6 +60,19 @@ * not a payload. Registration is idempotent for the same reason: an ES module * executes once, so naming it here does not re-run anything. * + * `@object-ui/plugin-grid` joins on the same argument and with the same + * measured non-effect (objectui#6025). The `plugin-grid` entries have been real + * `object-grid` nodes since objectui#5856, and `object-grid` reached this + * registry only because `ObjectView.tsx:37` — the line above the one quoted + * above — imports `ObjectGrid`. That is a COMPONENT import, not a plugin + * dependency: a refactor that stopped `object-view` from drawing a grid itself + * would be entirely reasonable, would say nothing about the gallery, and would + * turn two catalog tiles into OBJUI-001 panels several files from the cause. + * + * So the list below is: the census (nine), plus objectui#4600's original two, + * plus two packages the gallery's own entries depend on BY NAME. What it is not + * is a sweep — nothing is here that the catalog does not render. + * * ## The cost, measured before deciding (objectui#4616 ruling 1) * * Eager registration pulls each package's graph into the `/docs/[[...slug]]` @@ -147,3 +160,4 @@ import '@object-ui/plugin-markdown'; import '@object-ui/plugin-timeline'; import '@object-ui/plugin-view'; import '@object-ui/plugin-form'; +import '@object-ui/plugin-grid'; diff --git a/examples/schema-catalog/package.json b/examples/schema-catalog/package.json index 8f37b876ae..c7fb185842 100644 --- a/examples/schema-catalog/package.json +++ b/examples/schema-catalog/package.json @@ -41,6 +41,7 @@ "@object-ui/plugin-editor": "workspace:*", "@object-ui/plugin-form": "workspace:*", "@object-ui/plugin-gantt": "workspace:*", + "@object-ui/plugin-grid": "workspace:*", "@object-ui/plugin-kanban": "workspace:*", "@object-ui/plugin-map": "workspace:*", "@object-ui/plugin-markdown": "workspace:*", diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index 284a9c107c..0103ade28a 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -141,6 +141,7 @@ import '@object-ui/plugin-markdown'; import '@object-ui/plugin-timeline'; import '@object-ui/plugin-view'; import '@object-ui/plugin-form'; +import '@object-ui/plugin-grid'; import { SidebarProvider } from '@object-ui/components'; import { registerLayout } from '@object-ui/layout'; import { ComponentRegistry } from '@object-ui/core'; @@ -200,6 +201,7 @@ const HOST_PACKAGES = [ '@object-ui/plugin-timeline', '@object-ui/plugin-view', '@object-ui/plugin-form', + '@object-ui/plugin-grid', ]; /** @@ -774,12 +776,17 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () * least one type this package registers", which one conforming entry satisfies * while every other entry in the category drifts. * - * The types are asserted to be REGISTERED separately from the render, because - * the gallery's registration of `object-grid` is transitive: the host list - * (`HOST_PACKAGES`) does not name `@object-ui/plugin-grid`, and the type - * arrives because `@object-ui/plugin-view` imports `ObjectGrid` from it, which - * runs that package's `register` calls. That is load-bearing and invisible, so - * it gets its own assertion. + * The types are asserted to be REGISTERED separately from the render, because a + * type that resolves to nothing fails every case below with the OBJUI-001 panel + * rather than with anything about the entries. That separation was written for + * `object-grid`, which used to reach this registry ONLY because + * `@object-ui/plugin-view` imports `ObjectGrid` from `@object-ui/plugin-grid` — + * load-bearing and invisible. objectui#6025 made both `@object-ui/plugin-grid` + * and `@object-ui/plugin-form` declared imports of the gallery host, so + * resolution no longer rides on that component import. The transitive path + * still exists and still works, which is precisely why "is it registered" + * cannot judge whether the declaration is there — see + * `objectui#6025 — the gallery DECLARES the packages its entries need` below. * * ## No environment exclusions, and that is a result rather than an oversight * @@ -1145,6 +1152,60 @@ describe.each(PLUGIN_CATEGORIES)( }, ); +/** + * DECLARED, NOT TRANSITIVE (objectui#6025). + * + * ## Why this is not the same question as "is the type registered" + * + * `object-grid` resolved in this registry long before the host declared it, and + * it still would if the declaration were deleted: `@object-ui/plugin-view` + * imports `ObjectGrid` from `@object-ui/plugin-grid` + * (`packages/plugin-view/src/ObjectView.tsx:37`), and importing that entry runs + * its `register` calls. The same is true of `@object-ui/plugin-form` one line + * below it. Measured on this card's merge-base, importing exactly the eleven + * packages the host then carried: `object-grid`, `object-form`, + * `plugin-form:object-form`, `embeddable-form`, `form-analytics`, + * `object-master-detail-form`, `record:line_items`, `view:form` and + * `import-wizard` ALL resolved. + * + * So `expect(ComponentRegistry.get('object-grid')).toBeTruthy()` — which this + * file does assert, for its own reason, in every category's own case — passes + * in the declared world and in the transitive one alike. For THIS question it + * is a ghost: an assertion that cannot fail in either world. + * + * What is asserted here instead is the host's DECLARATION, and it is a real + * judge because of the case it leans on: the parity case above ties + * `HOST_PACKAGES` to the literal import list in `registerCatalogBlocks.ts`. + * Remove the import from the host and parity reds; remove it from both and this + * case reds. No transitive import can satisfy either, because neither reads the + * registry at all. + * + * The rule is derived, not enumerated: every `plugin-*` CATEGORY that has + * catalog entries names a package the gallery renders through, so the host must + * load that package by name. A new plugin category with entries picks the + * requirement up with no edit here. + */ +describe('objectui#6025 — the gallery DECLARES the packages its entries need', () => { + it('every plugin category with catalog entries is loaded BY NAME by the host', () => { + expect( + PLUGIN_CATEGORIES.length, + 'no plugin categories were derived, so this case would be vacuous', + ).toBeGreaterThanOrEqual(13); + + const undeclared = PLUGIN_CATEGORIES.map((category) => `@object-ui/${category}`).filter( + (pkg) => !HOST_PACKAGES.includes(pkg), + ); + expect( + undeclared, + 'these packages register the types their own category’s entries author, and the ' + + 'gallery host does not name them. Whether the types resolve anyway through some ' + + 'other package’s component import is a different question and not this one — add ' + + 'the side-effect import to `apps/site/app/components/registerCatalogBlocks.ts` and ' + + 'mirror it in `HOST_PACKAGES`.', + ).toEqual([]); + }); +}); + /** * 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37ebd339a3..4405a3fae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -686,6 +686,9 @@ importers: '@object-ui/plugin-gantt': specifier: workspace:* version: link:../../packages/plugin-gantt + '@object-ui/plugin-grid': + specifier: workspace:* + version: link:../../packages/plugin-grid '@object-ui/plugin-kanban': specifier: workspace:* version: link:../../packages/plugin-kanban