diff --git a/.changeset/ga-object-block-authoring-surfaces.md b/.changeset/ga-object-block-authoring-surfaces.md new file mode 100644 index 0000000000..c3efb0777f --- /dev/null +++ b/.changeset/ga-object-block-authoring-surfaces.md @@ -0,0 +1,31 @@ +--- +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-grid': patch +--- + +Publish the authoring surfaces of the four GA `object-*` blocks + +`object-form`, `object-grid`, `object-master-detail-form` and `object-metric` +each honoured far more keys than they declared as registry `inputs`. An author — +very often an AI author — who wrote one of the undeclared keys got an +`unknown-prop` report from `sdui-parser` on a key that works, while the designer +panel and the generated `sdui-intrinsics.d.ts` denied it existed. + +68 keys are now declared with descriptions written to teach correct authoring: +`object-form` +20 (record binding, button labels, post-submit behaviour, mobile +overrides), `object-grid` +21 (sorting, pagination, grouping, selection, row and +bulk actions, navigation, export), `object-master-detail-form` +10, and +`object-metric` +14 (formatting, comparison, drill-down). No renderer behaviour +changes — this documents what already shipped, so the manifest, the generated +`.d.ts`, the designer panel and the renderers finally agree. + +Ten of `object-grid`'s spec-declared keys are deliberately NOT published: +its own `@deprecated` legacy spellings (`fields`, `staticData`, `selectable`, +`pageSize`, `showSearch`, `showPagination`, `defaultSort`, `defaultFilters`, +`resizableColumns`, `title`). The renderer keeps reading them so existing +documents render, but recommending a deprecated alias as new authoring surface +would harden it into a second dialect. Each canonical replacement — `columns`, +`data`, `selection`, `pagination`, `searchableFields`, `sort`, `filter`, +`resizable`, `label` — is declared, and each carries a description naming the +legacy spelling it supersedes. diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx index be94fd4111..ab22aa38e8 100644 --- a/apps/console/src/__tests__/public-block-binding-reach.test.tsx +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -218,10 +218,27 @@ const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) => * instances of one lesson: a plausible value for EVERY input is not the same as * a plausible CONFIGURATION. * + * `customFields` is the second, and it arrived the same way `data` did — as a + * red on this probe the moment objectui#4648 declared `object-form`'s full + * authoring surface. Its guard is `ObjectForm.tsx:426`, + * `const hasInlineFields = schema.customFields && schema.customFields.length > 0`, + * read at `:455` under the comment *"Skip fetching if we have inline fields"* — + * which substitutes a minimal in-memory object schema for the + * `getObjectSchema(schema.objectName)` call — and again at `:479` to skip the + * record fetch. The repo's own type says the same in prose: *"When used with + * inline field definitions (without dataSource), this becomes the primary field + * source"* (`ObjectFormSchema.customFields`, `packages/types/src/objectql.ts`). + * So a non-empty `customFields` is the author declaring the fields inline, i.e. + * telling the block not to fetch — the `data` case exactly. Note the guard is + * LENGTH-sensitive: `sampleFor` returns a non-empty `['name']` for an array + * input, which is what tripped it; an empty array would have left the binding + * intact. That is the "plausible value ≠ plausible configuration" lesson a + * fourth time. + * * Add to this list only with the guard quoted, so the next reader can check the * claim instead of trusting it. */ -const SUPERSEDES_BINDING = new Set(['data']); +const SUPERSEDES_BINDING = new Set(['data', 'customFields']); /** * A plausible value for one declared input. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 2b6f2bb381..05681e83fc 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -586,6 +586,71 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'GA declares it and RecordDetailsRenderer HONOURS it already — renderers/record-details.tsx:234, `(schema.inlineEdit ?? true) && objectInlineEditable` gates the inline-edit affordance. Not published only because the pinned @objectstack/spec@17.0.0-rc.6 does not declare it. Declared by objectui#4668 on the GA pin (objectui#4636 / PR #4639); dormant on this pin, dies when the input lands.', 'record:details.showHeader': 'GA declares it and RecordDetailsRenderer HONOURS it already — renderers/record-details.tsx:257, `showHeader: schema.showHeader ?? false` reaches DetailView, which reads it at DetailView.tsx:909/:1183. Not published only because the pinned @objectstack/spec@17.0.0-rc.6 does not declare it. Declared by objectui#4668 on the GA pin (objectui#4636 / PR #4639); dormant on this pin, dies when the input lands.', + + // ── object-grid's own @deprecated legacy spellings — the RULED carve-out ─── + // (10 keys) + // A class of its own, and the only part of objectui#4648's option B that is + // NOT declared. The maintainer ruling of 2026-08-16 on that card reads: + // "object-grid's own `@deprecated` legacy spellings … are NOT published as new + // authoring surface — they get reasoned, cited exemptions so a deprecated + // alias is not hardened." + // + // So the reason here is NOT the one every other entry gives. These ten are not + // undecided, not upstream-owned, and not blocked on anything: the renderer + // reads all ten and will keep reading them, because documents authored under + // the old spellings must keep rendering. What is refused is PUBLISHING them — + // an `inputs` entry is a recommendation to write the key, and recommending a + // deprecated alias is how a second dialect gets hardened (AGENTS.md #0.1). + // A back-compat read is not an authoring surface: the same split + // `page:card.body` records above, and the one `page-header-subtitle-alias` + // established in `packages/layout`. + // + // Each is tagged `@deprecated` in this repo's own `ObjectGridSchema` + // (`packages/types/src/objectql.ts`), and GA's own `.describe()` text says the + // same thing from the producer side ("Legacy … fallback, read only when + // `filter` is absent. Prefer `filter`"). Both authorities agree, which is why + // this is an exemption rather than an open question. The CANONICAL spelling of + // each is declared by objectui#4648 in `plugin-grid/src/index.tsx` and named in + // the reason below, so the pair reads as "write this one instead" rather than + // as an unexplained hole. + // + // THESE DO NOT SELF-RETIRE ON A PIN BUMP, and that is deliberate: unlike the + // GA-pending five above, no issue owns declaring them later. They retire only + // if `@objectstack/spec` retires the keys upstream (an ADR-0087 D2 tombstone, + // which by itself would NOT make them stale here — see the record_picker trio) + // or if objectui un-deprecates a spelling. Do not resolve one by declaring the + // input; that is the move the ruling refused. + // + // Measurement note, reported on objectui#4648 with this change: the ruling + // enumerated FIVE (`fields` / `staticData` / `selectable` / `pageSize` / + // `showSearch`) from the fork report's list. Re-deriving the class it named — + // `@deprecated` in `ObjectGridSchema`, AND declared by GA — measures TEN. The + // five extra (`showPagination`, `defaultSort`, `defaultFilters`, + // `resizableColumns`, `title`) are the same class by the same test, so they are + // carved out with it. Trimming back to exactly five is a one-line reversal + // (delete the entry, declare the input); publishing first and withdrawing later + // is not, which is why the exemption is the direction taken while the card is + // open. + 'object-grid.fields': + '@deprecated in ObjectGridSchema ("Use columns instead"); GA describes it as the "Field list fallback used when `columns` is absent". Read as back-compat, deliberately not published as authoring surface — the canonical `columns` IS declared. Ruled carve-out, objectui#4648 (maintainer 2026-08-16).', + 'object-grid.staticData': + '@deprecated in ObjectGridSchema ("Use data with provider: \'value\' instead"); GA describes it as the "Alternate spelling of `data`". Read as back-compat, deliberately not published — the canonical `data` IS declared. Ruled carve-out, objectui#4648 (maintainer 2026-08-16).', + 'object-grid.selectable': + '@deprecated in ObjectGridSchema ("Use selection.type instead"); GA describes it as the "Legacy selection shorthand, read only when `selection` is absent. Prefer `selection`". Read as back-compat, deliberately not published — the canonical `selection` IS declared. Ruled carve-out, objectui#4648 (maintainer 2026-08-16).', + 'object-grid.pageSize': + '@deprecated in ObjectGridSchema ("Use pagination.pageSize instead"); GA describes it as the "Flat page-size shorthand; `pagination.pageSize` wins when both are set". Read as back-compat, deliberately not published — the canonical `pagination` IS declared. Ruled carve-out, objectui#4648 (maintainer 2026-08-16).', + 'object-grid.showSearch': + '@deprecated in ObjectGridSchema ("Use searchableFields instead"); GA describes it as "read only when `searchableFields` is absent". A boolean cannot say WHICH fields to search, which is why the list is the surface. Read as back-compat, deliberately not published — the canonical `searchableFields` IS declared. Ruled carve-out, objectui#4648 (maintainer 2026-08-16).', + 'object-grid.showPagination': + '@deprecated in ObjectGridSchema ("Use pagination config instead"); GA describes it as "read only when `pagination` is absent". Read as back-compat, deliberately not published — the canonical `pagination` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', + 'object-grid.defaultSort': + '@deprecated in ObjectGridSchema ("Use sort instead"); GA describes it as the "Legacy single-sort fallback ({ field, order }), read only when `sort` is absent. Prefer `sort`". Read as back-compat, deliberately not published — the canonical `sort` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', + 'object-grid.defaultFilters': + '@deprecated in ObjectGridSchema ("Use filter instead"); GA describes it as the "Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`". Read as back-compat, deliberately not published — the canonical `filter` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', + 'object-grid.resizableColumns': + '@deprecated in ObjectGridSchema ("Moved to top-level resizable"); GA describes it as the "Alternate spelling of `resizable`". Read as back-compat, deliberately not published — the canonical `resizable` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', + 'object-grid.title': + '@deprecated in ObjectGridSchema ("Use label instead"); GA describes it as the "Fallback for `label` (the renderer reads `label || title`)". Read as back-compat, deliberately not published — the canonical `label` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', }; /** @@ -598,11 +663,21 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * on the pinned 17.0.0-rc.6 they describe nothing yet. * * Pinning them as a SET rather than skipping "any entry the spec does not - * declare" is the whole safety of the mechanism: only these five may be + * declare" is the whole safety of the mechanism: only these entries may be * dormant, and `every GA-pending exemption arms exactly with the installed * spec` asserts that their dormancy tracks the installed element set in BOTH * directions — so a GA release that dropped one of them fails here instead of * leaving an entry that quietly covers nothing. + * + * TWO GROUPS, and they are dormant for the same reason but retire differently: + * + * - the first five are keys on blocks this pin already carries, awaiting + * declaration by objectui#4668 once the pin moves. Each dies when its input + * lands; + * - the ten `object-grid` entries are objectui#4648's RULED carve-out. They + * are dormant here only because rc.6 does not carry `object-grid` at all, so + * none of its keys resolves; on a GA tree they are fully judged. They are + * not awaiting declaration — see their reasons above. */ const GA_PENDING_UNPUBLISHED_KEYS = [ 'page:header.maxVisible', @@ -610,6 +685,16 @@ const GA_PENDING_UNPUBLISHED_KEYS = [ 'page:tabs.alwaysShowStrip', 'record:details.inlineEdit', 'record:details.showHeader', + 'object-grid.fields', + 'object-grid.staticData', + 'object-grid.selectable', + 'object-grid.pageSize', + 'object-grid.showSearch', + 'object-grid.showPagination', + 'object-grid.defaultSort', + 'object-grid.defaultFilters', + 'object-grid.resizableColumns', + 'object-grid.title', ]; /** Split a `BLOCK.KEY` exemption id into its two halves. */ @@ -630,23 +715,35 @@ const isDormantOnThisPin = (exemptionKey: string): boolean => { }; /* - * THE FOUR GA BLOCKS HAVE NO ENTRIES HERE, DELIBERATELY — objectui#4648. + * THE FOUR GA BLOCKS — RULED AND RESOLVED, objectui#4648 (maintainer 2026-08-16). * * On a GA tree `object-form` / `object-grid` / `object-master-detail-form` / * `object-metric` enter `covered` (see `GA_ONLY_BLOCKS`) and the reverse - * direction goes RED on 78 spec keys their `inputs` do not publish. That red is - * unresolved on purpose: the card's ruling chose reasoned exemptions on the - * premise that nothing in this repo reads those keys, and the implementation - * measurement contradicted the premise — all 78 are honoured today, either off - * `schema.*` in the renderer or through `SchemaRenderer`'s rest-prop spread - * onto components whose props declare them one for one (`ObjectGridSchema` in - * `@object-ui/types`, `ObjectMetricWidget`'s props, the form renderers'). - * - * Under this file's stated bar that makes them A-class defects to DECLARE, not - * keys to exempt — so writing the exemptions would have required rewriting the - * bar as well as acting on a falsified premise. Both are the maintainer's call - * and neither is silent: the measurement went back to objectui#4648 as a fork - * report. Add nothing here until that card rules again. + * direction judges the 78 spec keys their `inputs` did not publish. That red is + * now resolved the way this file's own bar prescribes — "a spec key the renderer + * HONOURS and `inputs` omits is a plain defect and gets declared" — because the + * implementation measurement that forked the card's first ruling showed all 78 + * ARE honoured today, either off `schema.*` in the renderer or through + * `SchemaRenderer`'s rest-prop spread onto components whose props declare them + * one for one. The maintainer re-ruled on that measurement (option B): + * + * - 68 keys are DECLARED, at the four registration sites — + * `plugin-form/src/index.tsx` (`object-form` +20, `object-master-detail-form` + * +10), `plugin-grid/src/index.tsx` (`object-grid` +21 in + * `GRID_QUERY_INPUTS`), `plugin-dashboard/src/index.tsx` (`object-metric` + * +14). No exemption is owed for a declared key and none is written; + * - 10 are the ruled CARVE-OUT — `object-grid`'s own `@deprecated` legacy + * spellings, which are exempted rather than declared so a deprecated alias is + * not hardened into a second dialect. Their entries are in + * `UNPUBLISHED_EXEMPTIONS` above, each naming the canonical spelling that IS + * declared in its place. + * + * The bar was NOT amended: option B is the arm that needed no amendment, which + * is one of the reasons the ruling chose it. + * + * The exemption list is therefore the carve-out and nothing else. A future key + * these blocks gain is a plain A-class defect: declare it at the registration + * site. Do not add an entry here to silence one. */ const exemptedFor = (type: string): string[] => @@ -785,7 +882,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(dangling).toEqual([]); }); - it('every GA-pending exemption arms exactly with the installed spec, all five together', () => { + it('every GA-pending exemption arms exactly with the installed spec, all fifteen together', () => { // The non-vacuity and self-arming half of `GA_PENDING_UNPUBLISHED_KEYS`. // Without it the pinned set could name keys no entry covers (licensing // nothing while reading as cover) or stay dormant forever on a GA tree that @@ -828,6 +925,74 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect([0, GA_ONLY_BLOCKS.length]).toContain(carried.length); }); + it('the four GA blocks resolve their ruled split — declared vs carved out', () => { + // objectui#4648's ruling, pinned by name rather than left to the derived + // reverse-direction loop above, for exactly the reason the `#3808 / #3830` + // and `rc.6 record_picker` pins next door exist: that loop goes green just as + // readily if a declaration is REPLACED by an exemption, which is the cheap + // move under time pressure and the one thing option B forbids. So the split + // is asserted as "declared" and "exempted, not declared", not merely as + // "not failing". + // + // Dormant on a pin that predates the GA element set — there is nothing to + // judge when the spec carries none of the four — and the dormancy is not + // silent: `the four GA blocks enter coverage exactly when the installed spec + // carries them` above owns that fact for both pins. + if (!specCarriesGaBlocks) { + expect(covered.filter((type) => GA_ONLY_BLOCKS.includes(type))).toEqual([]); + return; + } + + // The carve-out, by name: `@deprecated` in this repo's own ObjectGridSchema, + // declared by GA, and deliberately NOT published (maintainer 2026-08-16). + const CARVED_OUT_GRID_KEYS = [ + 'defaultFilters', + 'defaultSort', + 'fields', + 'pageSize', + 'resizableColumns', + 'selectable', + 'showPagination', + 'showSearch', + 'staticData', + 'title', + ]; + + for (const key of CARVED_OUT_GRID_KEYS) { + expect( + specTopLevelKeys('object-grid'), + `spec no longer declares object-grid.${key} — re-check the carve-out`, + ).toContain(key); + expect( + declaredInputs('object-grid') ?? [], + `object-grid publishes ${key}; the ruling carved it out as a deprecated alias`, + ).not.toContain(key); + expect( + Object.keys(UNPUBLISHED_EXEMPTIONS), + `object-grid.${key} is carved out but carries no cited exemption`, + ).toContain(`object-grid.${key}`); + } + + // Everything else the four blocks' spec schemas declare is DECLARED, and + // carries no exemption. Stated as an exact set difference rather than a + // spot-check so a key added by a later GA cannot slip through as neither. + for (const type of GA_ONLY_BLOCKS) { + const carved = type === 'object-grid' ? CARVED_OUT_GRID_KEYS : []; + const shouldPublish = specTopLevelKeys(type) + .filter((key) => !(key in GLOBALLY_UNPUBLISHED_SPEC_KEYS)) + .filter((key) => !carved.includes(key)); + const declared = new Set(declaredInputs(type) ?? []); + expect( + shouldPublish.filter((key) => !declared.has(key)), + `${type} does not publish these spec keys, and they are not the ruled carve-out`, + ).toEqual([]); + expect( + shouldPublish.filter((key) => Object.keys(UNPUBLISHED_EXEMPTIONS).includes(`${type}.${key}`)), + `${type} exempts a key it declares — an exemption may not stand in for a declaration here`, + ).toEqual([]); + } + }); + it('every unpublished-key exemption states a reason and references a tracking issue', () => { // The discipline that separates "deliberately not published, and here is who // owns the decision" from "we forgot". Four of the nine entries once here diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index fbefc7b500..f95b5ce7a9 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -159,6 +159,20 @@ ComponentRegistry.register( { name: 'label', type: 'string', label: 'Label' }, { name: 'aggregate', type: 'object', label: 'Aggregate', description: 'Aggregation config: { field, function, groupBy }' }, { name: 'icon', type: 'string', label: 'Icon (Lucide name)' }, + { name: 'description', type: 'string', label: 'Description', description: 'Helper text rendered under the value.' }, + { name: 'title', type: 'string', label: 'Drill-down Title', description: 'Heading of the drill-down panel. Defaults to `label` — set it only when the records list wants a different name from the tile.' }, + { name: 'filter', type: 'array', label: 'Filter', description: 'Criteria the aggregation is scoped by. The same filter narrows the drill-down list, so the number and the records behind it always agree.' }, + { name: 'colorVariant', type: 'enum', label: 'Color Variant', enum: ['default', 'blue', 'teal', 'orange', 'purple', 'success', 'warning', 'danger'], description: 'Colour of the icon container. Semantic, not decorative: `success` / `warning` / `danger` should track what the number means.' }, + { name: 'variant', type: 'enum', label: 'Layout Variant', enum: ['card', 'bare'], description: '`card` draws the tile’s own surface; `bare` drops it, for a metric already sitting inside a card.' }, + { name: 'format', type: 'string', label: 'Number Format', description: 'Numeral-style format pattern, e.g. `0,0`, `$0,0`, `0%`. Use `currency` instead of hard-coding a currency symbol here.' }, + { name: 'currency', type: 'string', label: 'Currency Code', description: 'ISO 4217 code, e.g. `USD`. Enables locale-aware currency formatting of the value.' }, + { name: 'prefix', type: 'string', label: 'Prefix', description: 'Static text placed before the formatted value.' }, + { name: 'suffix', type: 'string', label: 'Suffix', description: 'Static text placed after the formatted value.' }, + { name: 'invert', type: 'boolean', label: 'Invert', description: 'Display `1 - value` — for gauges whose good direction is down, such as error rate shown as uptime.' }, + { name: 'fallbackValue', type: 'string', label: 'Fallback Value', description: 'Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it.' }, + { name: 'trend', type: 'object', label: 'Trend', description: 'Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data.' }, + { name: 'compareTo', type: 'object', label: 'Compare To', description: 'Period-over-period comparison, `{ kind: "previousPeriod" }` or `{ kind: "previousYear" }` — the computed alternative to a static `trend`.' }, + { name: 'drillDown', type: 'object', label: 'Drill Down', description: 'Click-through config that opens the records behind the number.' }, ], defaultProps: { label: 'Metric', diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 31e5ab3964..d1d80db324 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -126,6 +126,30 @@ ComponentRegistry.register('object-form', ObjectFormRenderer, { { name: 'drawerWidth', type: 'string', label: 'Drawer Width' }, // Modal { name: 'modalSize', type: 'enum', label: 'Modal Size', enum: ['sm', 'default', 'lg', 'xl', 'full'] }, + { name: 'modalCloseButton', type: 'boolean', label: 'Modal Close Button', description: 'Show the modal presentation’s close button. Read at ObjectForm.tsx:361 and honoured by ModalForm.' }, + { name: 'contentLayout', type: 'enum', label: 'Modal Content Layout', enum: ['simple', 'tabbed'], description: 'How the modal presentation lays out sections. `tabbed` needs more than one section to differ from `simple` (ModalForm.tsx:638).' }, + { name: 'confirmOnDiscard', type: 'boolean', label: 'Confirm On Discard', description: 'Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set `false` to close immediately.' }, + // Record binding + { name: 'recordId', type: 'string', label: 'Record Id', description: 'The record to load in `edit` / `view` mode. Leave unset for `create`.' }, + { name: 'customFields', type: 'array', label: 'Custom Fields', description: 'Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source.' }, + { name: 'initialValues', type: 'object', label: 'Initial Values', description: 'Values to prefill in `create` mode.' }, + { name: 'initialData', type: 'object', label: 'Initial Data', description: 'Alternate spelling of `initialValues` that the drawer/modal presentations read FIRST (`schema.initialData || schema.initialValues`). Prefer `initialValues` in new schemas.' }, + { name: 'readOnly', type: 'boolean', label: 'Read Only', description: 'Render every field read-only, whatever `mode` says.' }, + // Buttons + { name: 'submitText', type: 'string', label: 'Submit Button Text' }, + { name: 'cancelText', type: 'string', label: 'Cancel Button Text' }, + { name: 'nextText', type: 'string', label: 'Next Button Text', description: 'Label of the next-step button (wizard).' }, + { name: 'prevText', type: 'string', label: 'Previous Button Text', description: 'Label of the previous-step button (wizard).' }, + { name: 'showSubmit', type: 'boolean', label: 'Show Submit Button' }, + { name: 'showCancel', type: 'boolean', label: 'Show Cancel Button' }, + { name: 'showReset', type: 'boolean', label: 'Show Reset Button' }, + // After a successful submit + { name: 'submitBehavior', type: 'object', label: 'Submit Behavior', description: 'Declarative post-submit behaviour, one of `{ kind: "thank-you", title?, message? }`, `{ kind: "redirect", url, delayMs? }`, `{ kind: "continue" }`, `{ kind: "next-record" }`. When present it takes precedence over `successMessage` / `navigateOnSuccess` / `resetOnSuccess`.' }, + { name: 'successMessage', type: 'string', label: 'Success Message', description: 'Toast shown after a successful submit. Ignored when `submitBehavior` or `navigateOnSuccess` is set.' }, + { name: 'navigateOnSuccess', type: 'string', label: 'Navigate On Success', description: 'Path to navigate to after a successful create/update. Supports `{id}` / `{recordId}` interpolation from the saved record and is same-origin-guarded. Takes precedence over `successMessage`.' }, + { name: 'resetOnSuccess', type: 'boolean', label: 'Reset On Success', description: 'Clear the form after a successful submit instead of keeping the saved values.' }, + // Mobile + { name: 'mobile', type: 'object', label: 'Mobile Overrides', description: 'Phone-only presentation overrides, e.g. `{ stepper: "auto", stepperMinFields: 8, fullscreenLongText: true }`.' }, ] }); @@ -258,6 +282,16 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer { name: 'mode', type: 'enum', label: 'Mode', enum: ['create', 'edit'] }, { name: 'sections', type: 'array', label: 'Parent Sections' }, { name: 'details', type: 'array', label: 'Detail Collections', required: true }, + { name: 'recordId', type: 'string', label: 'Parent Record Id', description: 'The parent record to load in `edit` mode. Leave unset for `create`.' }, + { name: 'formType', type: 'string', label: 'Parent Form Presentation', description: 'How the PARENT half of the form is presented. The detail grids below it are unaffected.' }, + { name: 'fields', type: 'array', label: 'Parent Fields', description: 'Which parent fields to show, in order. Ignored when `sections` is given — sections carry their own field lists.' }, + { name: 'title', type: 'string', label: 'Title' }, + { name: 'submitText', type: 'string', label: 'Submit Button Text', description: 'Label of the button that saves the parent and every detail row in one batch.' }, + { name: 'cancelText', type: 'string', label: 'Cancel Button Text' }, + { name: 'showSubmit', type: 'boolean', label: 'Show Submit Button' }, + { name: 'initialValues', type: 'object', label: 'Initial Values', description: 'Values to prefill on the PARENT record in `create` mode.' }, + { name: 'initialData', type: 'object', label: 'Initial Data', description: 'Alternate spelling of `initialValues` the renderer also reads (MasterDetailForm.tsx:602). Prefer `initialValues` in new schemas.' }, + { name: 'taxRateField', type: 'string', label: 'Tax Rate Field', description: 'Name of the field ON THE CHILD object that holds each line’s tax rate. Feeds the line-items totals row; leave unset when the detail rows carry no tax.' }, ], }); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 79a95f4280..65564ebcfd 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, useSchemaContext, @@ -119,12 +119,64 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * second de-facto contract (AGENTS.md #0.1). * * Shared by both registrations below so the alias cannot drift from the block. + * + * ## The rest of the surface (objectui#4648) + * + * Everything after `filter` was declared by the maintainer ruling of 2026-08-16 + * on objectui#4648 (option B + carve-out). These are NOT new capabilities — every + * one of them is read by `ObjectGrid` today, and until this landed an author who + * wrote one got `unknown-prop` from `sdui-parser` on a key that works, while the + * designer panel and the generated `sdui-intrinsics.d.ts` denied it existed. + * Publishing them is what makes the manifest, the `.d.ts`, the designer and the + * renderer finally agree. + * + * TEN keys `@objectstack/spec` 17.0.0 GA also declares are deliberately NOT here + * — this block's own `@deprecated` legacy spellings (`fields`, `staticData`, + * `selectable`, `pageSize`, `showSearch`, `showPagination`, `defaultSort`, + * `defaultFilters`, `resizableColumns`, `title` — all tagged `@deprecated` in + * `ObjectGridSchema`, `packages/types/src/objectql.ts`). The renderer still reads + * them as back-compat fallbacks, but publishing a deprecated alias as NEW + * authoring surface would harden it into a second dialect (AGENTS.md #0.1), so + * each gets a cited exemption in the console parity gate instead. Their canonical + * spellings — `columns`, `data`, `selection`, `pagination`, `searchableFields`, + * `sort`, `filter`, `resizable`, `label` — are all declared here, and each + * description below says so, so the exemption teaches rather than merely omits. */ -const GRID_QUERY_INPUTS = [ +const GRID_QUERY_INPUTS: ComponentInput[] = [ { name: 'objectName', type: 'string', label: 'Object Name', required: true }, - { name: 'columns', type: 'array', label: 'Columns' }, - { name: 'filter', type: 'array', label: 'Filter' }, -] as const; + { name: 'columns', type: 'array', label: 'Columns', description: 'Columns to show, either field names (`["name", "email"]`) or column objects (`[{ field: "name", label: "Full Name", width: 200 }]`). The canonical spelling — the deprecated `fields` is only read when this is absent.' }, + { name: 'filter', type: 'array', label: 'Filter', description: 'Filter criteria in JSON-rules form. The canonical spelling — the deprecated `defaultFilters` is only read when this is absent.' }, + // ── identity ────────────────────────────────────────────────────────────── + { name: 'label', type: 'string', label: 'Label', description: 'Grid label, used as the table caption and as the export file title. The canonical spelling — the deprecated `title` is only read when this is absent.' }, + // ── query shaping ───────────────────────────────────────────────────────── + { name: 'sort', type: 'array', label: 'Sort', description: 'Initial sort order, `[{ field, order }]`. The canonical spelling — the deprecated single-sort `defaultSort` is only read when this is absent.' }, + { name: 'pagination', type: 'object', label: 'Pagination', description: 'Pagination config, `{ pageSize, pageSizeOptions, … }`. Its presence is what enables paging; prefer it over the deprecated flat `pageSize` / `showPagination` pair.' }, + { name: 'searchableFields', type: 'array', label: 'Searchable Fields', description: 'Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search.' }, + { name: 'data', type: 'array', label: 'Static Data', description: 'Inline rows, which bypass the object query entirely. For demos and fixtures; the canonical spelling — the deprecated `staticData` is the same thing.' }, + // ── presentation ────────────────────────────────────────────────────────── + { name: 'rowHeight', type: 'enum', label: 'Row Height', enum: ['compact', 'short', 'medium', 'tall', 'extra_tall'], description: 'Row density. An unrecognised value falls back to `compact` rather than erroring.' }, + { name: 'frozenColumns', type: 'number', label: 'Frozen Columns', description: 'How many leading columns stay pinned while the grid scrolls horizontally.' }, + { name: 'resizable', type: 'boolean', label: 'Resizable Columns', description: 'Let users drag column borders to resize. The canonical spelling — the deprecated `resizableColumns` is only read when this is absent.' }, + { name: 'reorderableColumns', type: 'boolean', label: 'Reorderable Columns', description: 'Let users drag columns into a different order.' }, + { name: 'showColumnTypeIcons', type: 'boolean', label: 'Show Column Type Icons', description: 'Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels.' }, + { name: 'rowColor', type: 'object', label: 'Row Color', description: 'Rules that colour whole rows from a field value.' }, + { name: 'conditionalFormatting', type: 'array', label: 'Conditional Formatting', description: 'Row/cell styling rules. Accepts both the ObjectUI `{ field, operator, value }` form and the spec expression form `{ condition, style }`.' }, + // ── grouping and roll-ups ───────────────────────────────────────────────── + { name: 'grouping', type: 'object', label: 'Grouping', description: 'Group rows by one or more fields into collapsible sections.' }, + { name: 'aggregations', type: 'array', label: 'Aggregations', description: 'Per-group roll-ups shown in group headers, `[{ field, type: "sum" | "count" | "avg" | "min" | "max" | "count_distinct" }]`. Needs `grouping` to have anything to roll up.' }, + // ── selection and actions ───────────────────────────────────────────────── + { name: 'selection', type: 'object', label: 'Selection', description: 'Selection config, `{ type: "none" | "single" | "multiple" }`. The canonical spelling — the deprecated boolean/string `selectable` is only read when this is absent.' }, + { name: 'rowActions', type: 'array', label: 'Row Actions', description: 'Names of actions offered on each row’s menu.' }, + { name: 'bulkActions', type: 'array', label: 'Bulk Actions', description: 'Names of actions offered once rows are selected. Needs a multi-row `selection` to be reachable.' }, + { name: 'batchActions', type: 'array', label: 'Batch Actions (legacy alias)', description: 'Legacy alias of `bulkActions`, and the one the renderer reads FIRST when both are set. Prefer `bulkActions` in new schemas.' }, + { name: 'bulkActionDefs', type: 'array', label: 'Bulk Action Definitions', description: 'Full inline bulk-action definitions, for actions that are not named entries in the object’s action set. Use `bulkActions` when the action already exists.' }, + // ── behaviour ───────────────────────────────────────────────────────────── + { name: 'editable', type: 'boolean', label: 'Editable', description: 'Enable inline cell editing (double-click or Enter opens a cell).' }, + { name: 'singleClickEdit', type: 'boolean', label: 'Single-click Edit', description: 'With `editable`, a single click opens the cell instead of a double-click. Has no effect on a non-editable grid.' }, + { name: 'navigation', type: 'object', label: 'Navigation', description: 'What a row click does, `{ mode: "page" | "drawer" | "modal" | "split" | "none", … }`.' }, + { name: 'operations', type: 'object', label: 'Operations', description: 'Toggles for the built-in create/read/update/delete/export/import affordances, e.g. `{ delete: false }`.' }, + { name: 'exportOptions', type: 'object', label: 'Export Options', description: 'Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix }`. Needs `operations.export` to be reachable from the toolbar.' }, +]; ComponentRegistry.register('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid',