diff --git a/.changeset/10685-drilldown-per-block.md b/.changeset/10685-drilldown-per-block.md index 9e73e744e0..b0fb24de6a 100644 --- a/.changeset/10685-drilldown-per-block.md +++ b/.changeset/10685-drilldown-per-block.md @@ -46,3 +46,10 @@ TSX code now fails to compile in two places. The first is a refused member insid `ObjectDataTableSchema.drillDown`, or inside `ObjectPivotTable`'s `schema.drillDown`. The second is a value typed as the shared `DrillDownConfig` handed to either. Delete the key. Runtime rendering is unchanged. + +⚠️ **Dated note, 2026-09-28 — the plain `pivot` node has a mirror now — objectui#10859.** +Later in this same release `PivotTableSchema` (the `pivot` node, not `object-pivot`) gains a +zod mirror whose `drillDown` is the shared `DrillDownConfigSchema`, so a validator does read +the members of a `pivot` node's `drillDown`, and accepts `mode` there, as `DrillDownConfig` +declares it. `object-pivot` still has no mirror, so what the paragraph above says of a stored +`object-pivot` config stands. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10859-pivot-object-block-zod-arms.md b/.changeset/10859-pivot-object-block-zod-arms.md new file mode 100644 index 0000000000..0d3adc3015 --- /dev/null +++ b/.changeset/10859-pivot-object-block-zod-arms.md @@ -0,0 +1,15 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema` — and so `objectui validate` — accepts three more registered node types: `pivot`, `object-metric` and `object-master-detail-form` (objectui#10859, batch 2). + +**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by three `type` literals, and `@object-ui/types/zod` exports four new schemas. Nothing that parsed before is refused now: every document naming one of these types was refused at `type`, at the root and at every child slot. + +**What it was.** `pivot` is registered by `@object-ui/plugin-dashboard` and declared on the published TypeScript face (`PivotTableSchema`); `object-metric` and `object-master-detail-form` are ADR-0080 public blocks, registered by `@object-ui/plugin-dashboard` and `@object-ui/plugin-form`, whose props `@objectstack/spec` declares as `ComponentPropsMap` rows. `AnyComponentSchema` carried no arm for any of them, so every document naming one was refused with `invalid_union` at `type` — the objectstack showcase's own `object-metric` KPIs and its master-detail form page included. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `PivotTableSchema`, a member for member restatement of the TypeScript `PivotTableSchema`: `rowField`, `columnField`, `valueField` and `data` are required, as they are there; `aggregation` is one of `sum`, `count`, `avg`, `min`, `max`; `drillDown` is the shared `DrillDownConfigSchema`; `body` and `children` are refused by name, as the declaration's `?: never` members refuse them (objectui#9256). `DataDisplaySchema` lists it. +- `@object-ui/types/zod` exports `ObjectMetricBlockSchema` and `ObjectMasterDetailFormBlockSchema`, built the way the objectui#10872 public-block arms are: `BaseSchema`, the `type` literal, and `properties`, which is the block's `ComponentPropsMap` row by reference, so the spec's members, value types and strictness apply to the bag unchanged. `ObjectMasterDetailFormBlockSchema` also refuses `onSuccess`, `onError` and `onCancel` by name: they are runtime slots a React host fills (objectui#6124). Both reach `AnyComponentSchema` through a new union, `ObjectQLPublicBlockComponentSchema`. +- The strict authoring face (`StrictAnyComponentSchema`) derives from `AnyComponentSchema` and accepts the same three types, closed to undeclared keys like every other arm. As on the public blocks, a prop written flat on an `object-metric` or `object-master-detail-form` node instead of inside `properties` is not judged against the spec row. A key `BaseSchema` does not declare passes the tolerant face unjudged and is refused by name on the strict face; a key it does declare (`label` and `description`, both also members of the `object-metric` row) is judged by `BaseSchema`'s own type on both faces. diff --git a/.changeset/10895-plugin-detail-relatedlist-client-sort-gate.md b/.changeset/10895-plugin-detail-relatedlist-client-sort-gate.md new file mode 100644 index 0000000000..b934ceaed3 --- /dev/null +++ b/.changeset/10895-plugin-detail-relatedlist-client-sort-gate.md @@ -0,0 +1,9 @@ +--- +--- + +Test-only, no package released: `RelatedList.unmaterializedSort.test.tsx`'s case "keeps that button in +client mode" now awaits the object schema before it reads the `Total` sort button. Since +objectui#10728 the sort-button row reads the masked stamp, which covers every column until +`getObjectSchema` resolves, and the case gated only on the view type, which client mode reaches on the +first commit. So whether the button existed when it was read depended on scheduling; on one merge-queue +build it did not (objectui#10895). No source file changes. diff --git a/.changeset/7352-drill-down-config-mirror.md b/.changeset/7352-drill-down-config-mirror.md index 56f13e4de8..09adc9f781 100644 --- a/.changeset/7352-drill-down-config-mirror.md +++ b/.changeset/7352-drill-down-config-mirror.md @@ -47,3 +47,8 @@ The blocks now take per-block drill shapes. `mode` is read only by (`ObjectMetricDrillDownConfig`, objectui#9002), whose click points are always aggregates. `report` is read by `object-pivot` and `object-metric`, through the drawer they open. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-09-28 — `PivotTableSchema` has a mirror now — objectui#10859.** +Later in this same release `PivotTableSchema` gains a zod mirror, and its `drillDown` +is this entry's `DrillDownConfigSchema`, so the opening paragraph's two referencing declarations are three for the release, and the first half of the second "Unchanged" bullet above (`PivotTableSchema.drillDown` has no zod mirror at all) is this change's reading, not the release's. The rest of this entry is kept as +the reading of this change. diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index f3212bf59e..7819d71f8e 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -65,10 +65,11 @@ import { validate } from '../commands/validate.js'; /** * The head's refused count (objectui#10859 batch 1: 79 on `main` before it, - * minus the three `@object-ui/plugin-ai` arms). LOWER it when a batch arms more - * keys; never raise it. + * minus the three `@object-ui/plugin-ai` arms; batch 2: minus `pivot`, + * `object-metric` and `object-master-detail-form`). LOWER it when a batch arms + * more keys; never raise it. */ -const REFUSED_AT_TYPE = 76; +const REFUSED_AT_TYPE = 73; /** * The head's refused count over the NAMESPACED keys (objectui#10872 batch 1: @@ -144,6 +145,13 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 expect(refusedAtType(key), key).toBe(false); } }); + + it('counts the three keys batch 2 armed (objectui#10859 batch 2)', () => { + for (const key of ['pivot', 'object-metric', 'object-master-detail-form']) { + expect(BARE_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + } + }); }); describe('registered NAMESPACED component types refused at `type` — a ratchet (objectui#10872)', () => { diff --git a/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx index 9dacf076bb..2ef1291793 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx @@ -216,8 +216,11 @@ describe('RelatedList — the sort-button row for a `list` card (#3950)', () => }); it('keeps that button in client mode', async () => { - renderList({ type: 'list', data: items }); + const dataSource = renderList({ type: 'list', data: items }); await waitFor(() => expect(h.schema?.type).toBe('data-list')); + // Since objectui#10728 the sort-button row reads the masked stamp, which + // covers every column until the object schema lands (#7007). + await settleObjectSchema(dataSource); expect(screen.getByRole('button', { name: /Total/ })).toBeTruthy(); }); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index f7ffc33a61..8d06e4a2ff 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -80,6 +80,10 @@ import { ChartDrillDownSchema as SpecChartDrillDownSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, ViewFilterRuleSchema as SpecViewFilterRuleSchema, + // objectui#10859 batch 2 — the `ComponentPropsMap` rows of the two public + // blocks `objectql.zod.ts` arms. + ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, + ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -228,6 +232,11 @@ const IMPORTED: Array = [ // `listViews` record, crossed through this boundary, so the container is // measured here like every other crossing. ['ViewSchema', SpecViewSchema], + // objectui#10859 batch 2: the `object-metric` and `object-master-detail-form` + // arms' `properties` are their `ComponentPropsMap` rows, crossed through this + // boundary, so both rows are measured here like every other crossing. + ['ObjectMetricPropsSchema', SpecObjectMetricPropsSchema], + ['ObjectMasterDetailFormPropsSchema', SpecObjectMasterDetailFormPropsSchema], // objectui#10872: each ADR-0080 public-block arm's `properties` is the // block's `ComponentPropsMap` row, crossed through this boundary, so every // row is measured here like every other crossing (`page:section`, diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts new file mode 100644 index 0000000000..21aa8cdd74 --- /dev/null +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -0,0 +1,316 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `pivot`, `object-metric` and `object-master-detail-form` have a zod arm in + * `AnyComponentSchema` (objectui#10859, batch 2). + * + * ## The defect these pin + * + * All three are registered (`@object-ui/plugin-dashboard`, + * `@object-ui/plugin-form`) and declared — `pivot` on this package's TypeScript + * face (`PivotTableSchema` in `../data-display.ts`), the other two by their + * `@objectstack/spec` `ComponentPropsMap` row — and `AnyComponentSchema` + * carried no arm for any of them, so `safeValidateSchema`, and + * `objectui validate` with it, refused every document naming one with + * `invalid_union` at `type`. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` block below is TYPE-level: `tsc -p tsconfig.test.json` + * (the third leg of this package's `type-check` script) reads it, and vitest — + * which strips types — does not. The `describe` blocks are RUNTIME and vitest + * reads them. A green run of either one alone says nothing about the other. + * + * ## What "mirrors its declaration" means for each arm + * + * - `pivot` restates `PivotTableSchema` by hand, so the parity half holds the + * arm's key set equal to the declaration's and each member's accepted type + * invariantly equal to the declaration's. + * - `object-metric` and `object-master-detail-form` restate nothing: each arm's + * `properties` IS the spec row, by reference (`../zod/objectql.zod.ts` says + * why), so the parity half holds the bag's accepted type equal to the spec's + * published props type, and the runtime half holds the bag to BE the row. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, + ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, + type ObjectMasterDetailFormProps as SpecObjectMasterDetailFormProps, + type ObjectMetricProps as SpecObjectMetricProps, +} from '@objectstack/spec/ui'; + +import { + DataDisplaySchema, + ObjectMasterDetailFormBlockSchema, + ObjectMetricBlockSchema, + ObjectQLPublicBlockComponentSchema, + PivotTableSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; +import type { PivotTableSchema as Ts_PivotTableSchema } from '../data-display'; + +/* ── Type-level parity: the `tsc` channel ────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The mirror's own shape. */ +type ShapeOf = M extends { shape: infer S } ? S : never; +/** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ +type InputOf = T extends z.ZodType ? z.input : never; +/** The declaration's DECLARED keys — the `BaseSchema` index signature dropped. */ +type DeclaredKeys = Extract< + keyof { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K] }, + string +>; +/** The mirror's declared keys, read from its own shape. */ +type MirroredKeys = Extract, string>; + +/** Declared members whose accepted type differs from the declaration's. */ +type MismatchedKeys = { + [K in DeclaredKeys & MirroredKeys]: Equal[K]>, D[K]> extends true ? never : K; +}[DeclaredKeys & MirroredKeys]; + +/** `pivot`: the arm declares exactly the declaration's keys, each accepting exactly its type. */ +export type assertionPivotMirrorsItsDeclaration = [ + Expect, DeclaredKeys>>, + Expect, never>>, +]; + +/** + * The two spec-row arms: the bag accepts exactly the spec's published props + * type (absent allowed), and the node adds nothing to `BaseSchema` but `type`, + * the bag and — on the master-detail form — its three runtime slots. + */ +export type assertionSpecRowArmsAreTheRow = [ + Expect['properties']>, SpecObjectMetricProps | undefined>>, + Expect< + Equal< + InputOf['properties']>, + SpecObjectMasterDetailFormProps | undefined + > + >, + Expect['onSuccess']>, undefined>>, + Expect['onError']>, undefined>>, + Expect['onCancel']>, undefined>>, +]; + +/** + * Non-vacuity: the comparisons can fail. A member typed differently on the two + * sides is reported by name, an unmirrored key is caught by the key-set row, + * and a bag narrower than the spec's props type is not Equal to it. + */ +export type assertionInstrumentFires = [ + Expect, { a: number }>, 'a'>>, + Expect>, DeclaredKeys<{ a: string; b: string }>>, false>>, + Expect, false>>, +]; + +/* ── Runtime: the documents that were refused ────────────────────────────── */ + +/** One minimal document per armed key — what the declaration requires, and nothing more. */ +const MINIMAL = [ + { type: 'pivot', rowField: 'region', columnField: 'quarter', valueField: 'revenue', data: [] }, + { type: 'object-metric' }, + { type: 'object-master-detail-form' }, +] as const; + +/** + * Shipped documents: the objectstack showcase authors both spec-row blocks in + * the spec's `{ type, properties }` form. Copied from + * `examples/app-showcase/src/ui/pages/index.ts` (the home page's first KPI) and + * `examples/app-showcase/src/ui/pages/project-workspace.page.ts` (the + * "New Project + Tasks" form) in objectstack-ai/objectstack, whose tree this + * repository cannot read at test time — so these are copies, and a change to + * the originals does not reach them. + */ +const SHOWCASE_OBJECT_METRIC = { + type: 'object-metric', + properties: { + objectName: 'showcase_project', + label: 'Projects', + icon: 'folder-kanban', + colorVariant: 'blue', + description: 'active & planned', + aggregate: { field: 'id', function: 'count' }, + }, +}; +const SHOWCASE_MASTER_DETAIL_FORM = { + type: 'object-master-detail-form', + properties: { + objectName: 'showcase_project', + mode: 'create', + formType: 'simple', + submitText: 'Create Project + Tasks', + fields: ['name', 'account', 'status', 'health', 'budget', 'end_date'], + details: [{ title: 'Tasks', childObject: 'showcase_task', addLabel: 'Add task' }], + }, +}; + +/** The first issue of a refused parse, or a message that says it was not refused. */ +function firstIssue(result: ReturnType) { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error.issues[0]; +} + +describe('the registered types armed in batch 2 validate (objectui#10859)', () => { + it.each(MINIMAL)('$type is accepted by safeValidateSchema', (doc) => { + const result = safeValidateSchema(doc); + expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true); + }); + + it.each(MINIMAL)('$type is accepted by the strict authoring face', (doc) => { + const result = StrictAnyComponentSchema.safeParse(doc); + expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true); + }); + + it.each([ + ['object-metric', SHOWCASE_OBJECT_METRIC], + ['object-master-detail-form', SHOWCASE_MASTER_DETAIL_FORM], + ] as const)('accepts the showcase\'s shipped `%s` document on both faces', (type, doc) => { + // Lit control on the fixture: it is spec-valid by the spec's own row. + const row = type === 'object-metric' ? SpecObjectMetricPropsSchema : SpecObjectMasterDetailFormPropsSchema; + expect(row.safeParse(doc.properties).success).toBe(true); + expect(safeValidateSchema(doc).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true); + }); + + it('accepts a fully populated `pivot` document on both faces', () => { + const doc = { + type: 'pivot', + title: 'Revenue by region', + rowField: 'region', + columnField: 'quarter', + valueField: 'revenue', + aggregation: 'avg', + data: [{ region: 'EU', quarter: 'Q1', revenue: 10 }], + showRowTotals: true, + showColumnTotals: false, + format: '$,.2f', + columnColors: { Q1: 'text-emerald-600' }, + drillDown: { enabled: true, target: 'drawer', title: 'Revenue records' }, + }; + expect(safeValidateSchema(doc).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true); + }); + + it('reaches each arm at a child slot too — a page of the three', () => { + const page = { type: 'page', children: [...MINIMAL, SHOWCASE_OBJECT_METRIC] }; + expect(safeValidateSchema(page).success).toBe(true); + }); +}); + +describe('the batch-2 arms are closed where their declaration is (objectui#10859)', () => { + it.each([ + ['pivot', { ...MINIMAL[0], inventedKey10859: true }], + ['object-metric', { type: 'object-metric', inventedKey10859: true }], + ['object-master-detail-form', { type: 'object-master-detail-form', inventedKey10859: true }], + ] as const)('%s: an undeclared node key is refused on the strict face, by name — the strictness control', (_type, doc) => { + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success).toBe(false); + if (strict.success) return; + const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, JSON.stringify(strict.error.issues)).toBeDefined(); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedKey10859']); + // The rendering face keeps its `.passthrough()`, as on every other arm. + expect(safeValidateSchema(doc).success).toBe(true); + }); + + it.each([ + ['object-metric', { type: 'object-metric', properties: { objectName: 'order', inventedKey10859: 1 } }], + [ + 'object-master-detail-form', + { type: 'object-master-detail-form', properties: { objectName: 'order', inventedKey10859: 1 } }, + ], + ] as const)('%s: an undeclared key inside the bag is refused on the TOLERANT face, by name', (_type, doc) => { + const issue = firstIssue(safeValidateSchema(doc)); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['properties']); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedKey10859']); + }); + + it.each([ + ['pivot', { ...MINIMAL[0], aggregation: 'median' }, ['aggregation']], + ['object-metric', { type: 'object-metric', properties: { colorVariant: 'pink' } }, ['properties', 'colorVariant']], + [ + 'object-master-detail-form', + { type: 'object-master-detail-form', properties: { formType: 'wizzard' } }, + ['properties', 'formType'], + ], + ] as const)('%s: a spec-invalid member value is refused at that member', (_type, doc, path) => { + const issue = firstIssue(safeValidateSchema(doc)); + expect(issue.code).toBe('invalid_value'); + expect(issue.path).toEqual(path); + }); + + it('pivot: a member the declaration requires is refused when absent', () => { + const withoutData = { type: 'pivot', rowField: 'region', columnField: 'quarter', valueField: 'revenue' }; + const issue = firstIssue(safeValidateSchema(withoutData)); + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['data']); + }); + + it('pivot: `drillDown` is judged by the shared drill-down mirror', () => { + const issue = firstIssue(safeValidateSchema({ ...MINIMAL[0], drillDown: { enabled: 'yes' } })); + expect(issue.path).toEqual(['drillDown', 'enabled']); + }); + + it.each(['body', 'children'] as const)('pivot refuses the `%s` content channel by name (objectui#9256)', (key) => { + const issue = firstIssue(safeValidateSchema({ ...MINIMAL[0], [key]: [{ type: 'text', content: 'x' }] })); + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('`pivot`'); + }); + + it.each(['onSuccess', 'onError', 'onCancel'] as const)( + 'object-master-detail-form refuses an authored `%s` as a RUNTIME SLOT (objectui#6124)', + (key) => { + const issue = firstIssue(safeValidateSchema({ type: 'object-master-detail-form', [key]: 'handleIt' })); + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual([key]); + expect(issue.message).toContain(`\`${key}\``); + expect(issue.message).toContain('RUNTIME SLOT'); + }, + ); +}); + +describe('the spec-row arms read the row by reference (objectui#10859)', () => { + it.each([ + ['object-metric', ObjectMetricBlockSchema, SpecObjectMetricPropsSchema], + ['object-master-detail-form', ObjectMasterDetailFormBlockSchema, SpecObjectMasterDetailFormPropsSchema], + ] as const)('%s: the bag is the spec row, through the import boundary', (_type, arm, row) => { + const bag = arm.shape.properties.unwrap(); + const keysOf = (schema: unknown) => Object.keys((schema as { shape: Record }).shape).sort(); + // The same members, read off the installed spec on every run — not a + // transcribed list that could drift from it. + expect(keysOf(bag)).toEqual(keysOf(row)); + expect(keysOf(bag).length).toBeGreaterThan(5); + // Neither row carries a spec default, so the import boundary hands the + // spec's own export back untouched; the bag is what that export resolves + // to (the spec exports each row behind a lazy facade), and it answers + // every probe exactly as the spec does. + expect(stripImportedDefaults(row)).toBe(row); + for (const probe of [{}, { objectName: 'order' }, { inventedKey10859: 1 }, { objectName: 7 }]) { + expect(bag.safeParse(probe).success, JSON.stringify(probe)).toBe(row.safeParse(probe).success); + } + }); + + it('each arm is reachable from AnyComponentSchema through its category union', () => { + const literals = (union: { options: readonly { shape: { type: z.ZodLiteral } }[] }) => + union.options.map((arm) => arm.shape.type.value); + expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual(['object-metric', 'object-master-detail-form']); + expect(literals(DataDisplaySchema)).toContain('pivot'); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index cb205754c9..2b6297e47f 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -730,7 +730,7 @@ import { AppComponentSchema, MenuItemSchema as AppMenuItemSchema, NavigationArea import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, FilterGroupSchema } from '../zod/complex.zod.js'; import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; -import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemBarSchema, TimelineGanttItemSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; +import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, PivotTableSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemBarSchema, TimelineGanttItemSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; import { EmptySchema, LoadingSchema, ProgressSchema, SkeletonSchema, SonnerSchema, SpinnerSchema, ToasterSchema, ToastSchema } from '../zod/feedback.zod.js'; import { ButtonSchema, CalendarSchema, CheckboxSchema, CodeEditorSchema, ComboboxOptionSchema, ComboboxSchema, CommandGroupSchema, CommandItemSchema, CommandSchema, DatePickerSchema, FieldConditionSchema, FieldConstraintsSchema, FileUploadSchema, FormFieldSchema, FormSchema, InputOTPSchema, InputSchema, InputShorthandSchema, LabelSchema, RadioGroupSchema, RadioOptionSchema, SelectOptionSchema, SelectSchema, SliderSchema, SwitchSchema, TextareaSchema, ToggleSchema, UiCalendarSchema } from '../zod/form.zod.js'; @@ -747,7 +747,7 @@ import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; import type { CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; -import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineFeedItem as Ts_TimelineFeedItem, TimelineGanttItem as Ts_TimelineGanttItem, TimelineGanttItemBar as Ts_TimelineGanttItemBar, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; +import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, PivotTableSchema as Ts_PivotTableSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineFeedItem as Ts_TimelineFeedItem, TimelineGanttItem as Ts_TimelineGanttItem, TimelineGanttItemBar as Ts_TimelineGanttItemBar, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; import type { AccordionItem as Ts_AccordionItem, AccordionSchema as Ts_AccordionSchema, CollapsibleSchema as Ts_CollapsibleSchema, ToggleGroupItem as Ts_ToggleGroupItem, ToggleGroupSchema as Ts_ToggleGroupSchema } from '../disclosure'; import type { EmptySchema as Ts_EmptySchema, LoadingSchema as Ts_LoadingSchema, ProgressSchema as Ts_ProgressSchema, SkeletonSchema as Ts_SkeletonSchema, SonnerSchema as Ts_SonnerSchema, SpinnerSchema as Ts_SpinnerSchema, ToasterSchema as Ts_ToasterSchema, ToastSchema as Ts_ToastSchema } from '../feedback'; import type { ButtonSchema as Ts_ButtonSchema, CalendarSchema as Ts_CalendarSchema, CheckboxSchema as Ts_CheckboxSchema, CodeEditorSchema as Ts_CodeEditorSchema, ComboboxOption as Ts_ComboboxOption, ComboboxSchema as Ts_ComboboxSchema, CommandGroup as Ts_CommandGroup, CommandItem as Ts_CommandItem, CommandSchema as Ts_CommandSchema, DatePickerSchema as Ts_DatePickerSchema, FieldCondition as Ts_FieldCondition, FieldValidationRules as Ts_FieldValidationRules, FileUploadSchema as Ts_FileUploadSchema, FormField as Ts_FormField, FormSchema as Ts_FormSchema, InputOTPSchema as Ts_InputOTPSchema, InputSchema as Ts_InputSchema, InputShorthandSchema as Ts_InputShorthandSchema, UiCalendarSchema as Ts_UiCalendarSchema, LabelSchema as Ts_LabelSchema, RadioGroupSchema as Ts_RadioGroupSchema, RadioOption as Ts_RadioOption, SelectOption as Ts_SelectOption, SelectSchema as Ts_SelectSchema, SliderSchema as Ts_SliderSchema, SwitchSchema as Ts_SwitchSchema, TextareaSchema as Ts_TextareaSchema, ToggleSchema as Ts_ToggleSchema } from '../form'; @@ -1608,6 +1608,8 @@ const MIRRORS = { 'data-display.zod.ts#ListItemSchema': ListItemSchema, 'data-display.zod.ts#ListSchema': ListSchema, 'data-display.zod.ts#MarkdownSchema': MarkdownSchema, + // objectui#10859 batch 2: `pivot` gained its arm, a hand mirror of the declaration. + 'data-display.zod.ts#PivotTableSchema': PivotTableSchema, 'data-display.zod.ts#StaticTableColumnSchema': StaticTableColumnSchema, 'data-display.zod.ts#StatisticSchema': StatisticSchema, 'data-display.zod.ts#TableColumnSchema': TableColumnSchema, @@ -1781,6 +1783,7 @@ interface Declared { 'data-display.zod.ts#ListItemSchema': Ts_ListItem; 'data-display.zod.ts#ListSchema': Ts_ListSchema; 'data-display.zod.ts#MarkdownSchema': Ts_MarkdownSchema; + 'data-display.zod.ts#PivotTableSchema': Ts_PivotTableSchema; 'data-display.zod.ts#StaticTableColumnSchema': Ts_StaticTableColumn; 'data-display.zod.ts#StatisticSchema': Ts_StatisticSchema; 'data-display.zod.ts#TableColumnSchema': Ts_TableColumn; @@ -4120,6 +4123,15 @@ const EXCLUSIONS: Readonly> = { "the DECLARATION is derived FROM this mirror — `ListViewSchema = ListViewInferred & ListViewRuntimeProps`, and `ListViewInferred = z.input` (`../objectql.ts`). Asserting parity here would be true no matter what either side said: a phantom assertion, not a check.", 'objectql.zod.ts#ObjectQLComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", + // objectui#10859 batch 2 — the two ADR-0080 public blocks of this family, + // built the objectui#10872 way: `BaseSchema` + a `type` literal + + // `properties`, which is the block's `ComponentPropsMap` row by reference. + 'objectql.zod.ts#ObjectMetricBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `object-metric` literal + `properties`, which IS `ComponentPropsMap['object-metric']`; no TS declaration in this package restates the node", + 'objectql.zod.ts#ObjectMasterDetailFormBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `object-master-detail-form` literal + `properties`, which IS `ComponentPropsMap['object-master-detail-form']`, + three `handlerKeyRefusal` runtime slots; no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `MasterDetailFormSchema` is the type of `MasterDetailForm`'s `schema` prop, the renderer's post-hoist reading)", + 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': + "a union OVER the two public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': "recursive; a `z.lazy` exposes no `.shape` to read, so there is no key set for the per-key comparison. Since objectui#7760 it carries its TS declaration as BOTH type arguments, so the pair IS compared — as a whole type, by `tsc`, at the annotation itself", 'overlay.zod.ts#OverlaySchema': @@ -4274,7 +4286,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 169; +const EXPECTED_MIRROR_PAIRS = 170; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 5fd178c4fe..4c06da7b74 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -2495,9 +2495,11 @@ export interface ObjectMetricDrillDownConfig extends DrillDownConfig { * an AI) sees. * * ⚠️ This is a TypeScript declaration, so it refuses the key where an author - * types against it (`ObjectPivotTable`'s `schema.drillDown`). Neither - * `object-pivot` nor `PivotTableSchema` has a zod mirror, so a stored JSON - * config is checked by no validator and reaches the block unchanged. + * types against it (`ObjectPivotTable`'s `schema.drillDown`). `object-pivot` + * has no zod mirror, so a stored JSON config is checked by no validator and + * reaches the block unchanged. (`PivotTableSchema`, the plain `pivot` node, + * gained one in objectui#10859 batch 2; its `drillDown` is the shared + * `DrillDownConfig`, as declared, so it does not refuse `mode` either.) */ export interface ObjectPivotDrillDownConfig extends DrillDownConfig { /** diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 77768c3838..4221366177 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -202,11 +202,11 @@ function validateComponent(config: unknown) { - `InputOTPSchema`, `ComboboxSchema`, `LabelSchema` - `CommandSchema`, `FormSchema` -### Data Display Components (14) +### Data Display Components (15) - `AlertSchema`, `BadgeSchema`, `AvatarSchema` - `ListSchema`, `TableSchema`, `DataTableSchema` - `MarkdownSchema`, `TreeViewSchema`, `ChartSchema` -- `TimelineSchema`, `BreadcrumbSchema` +- `TimelineSchema`, `BreadcrumbSchema`, `PivotTableSchema` - `KbdSchema`, `HtmlSchema`, `StatisticSchema` ### Feedback Components (8) @@ -231,6 +231,10 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` +### ObjectQL Public Blocks (2) +The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. +- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema` + ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 826d87e4ef..57491ffe68 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -23,7 +23,7 @@ import { I18nLabelSchema, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; -import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { TABLE_COLUMN_TYPES, type TreeNode, type TimelineFeedItem, type TimelineGanttItem } from '../data-display.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -871,10 +871,11 @@ const CHART_Y_AXIS_IS_A_LIST_GUIDANCE = * (`../data-display.ts`), key for key (objectui#7352). * * Shared by the declarations that carry `drillDown`: `ChartSchema` below and - * `ObjectDataTableSchema` (`objectql.zod.ts`) reference it. `PivotTableSchema` - * declares the key too but has no mirror of its own, so it sits in no ledger; - * this is the home that key uses whenever the pivot pair is mirrored. Until - * this mirror existed neither declaring mirror had heard of the key, so under + * `ObjectDataTableSchema` (`objectql.zod.ts`) reference it, and so does + * `PivotTableSchema` below since objectui#10859 (batch 2) mirrored the pivot + * pair — the home this docblock had named for that key while the pair had no + * mirror. Until this mirror existed neither declaring mirror had heard of the + * key, so under * `BaseSchema`'s `.passthrough()` a `drillDown: { enabled: 'yes' }` parsed green * and reached a widget that reads `enabled` as truthy — `declared !== enforced`, * ledgered in `zod-mirror-parity.test.ts` (`UnmirroredDeclared`) by @@ -1027,6 +1028,55 @@ export const ChartSchema = BaseSchema.extend({ drillDown: DrillDownConfigSchema.optional().describe('Drill-down: clicking a chart segment opens a filtered list view (drawer / dialog)'), }).overwrite(foldChartXAxisAlias); +/** objectui#9256 (family D): ONE refusal string for both content channels of `PivotTableSchema`. */ +const PIVOT_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'pivot', + 'its registration hands the node to `PivotTable`, which reads it as `PivotTableSchema` and destructures only the members below', + 'a cross-tab of `data` — `rowField` down, `columnField` across, `valueField` aggregated by `aggregation` — ' + + 'with `title`, `showRowTotals`, `showColumnTotals`, `format`, `columnColors` and `drillDown`', +); + +/** + * Pivot Table Schema — mirrors `PivotTableSchema` in `../data-display.ts`, + * member for member (objectui#10859, batch 2). + * + * `pivot` is registered by `@object-ui/plugin-dashboard` (`PivotTable`) and + * declared on the published TypeScript face, and until this arm + * `AnyComponentSchema` carried none for it — so `safeValidateSchema`, and + * `objectui validate`, refused every document naming it with `invalid_union` at + * `type`. The count of registered types still refused there is ratcheted by + * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`. + * + * Requiredness is the declaration's: `rowField`, `columnField`, `valueField` + * and `data` are required there, so they are required here. `drillDown` is the + * shared `DrillDownConfigSchema` above, the home its docblock names for this + * pair. Both content channels are refused by name, as the declaration's + * `?: never` members refuse them (objectui#9256). The pin is + * `../__tests__/registered-type-arms-10859-b2.test.ts`. + */ +export const PivotTableSchema = BaseSchema.extend({ + type: z.literal('pivot'), + title: z.string().optional().describe('Pivot table title'), + rowField: z.string().describe('Field used for row headers'), + columnField: z.string().describe('Field used for column headers'), + valueField: z.string().describe('Field whose values are aggregated in cells'), + aggregation: z + .enum(['sum', 'count', 'avg', 'min', 'max']) + .optional() + .describe('Aggregation function applied to valueField — one of sum, count, avg, min, max (the renderer treats an absent value as sum)'), + data: z.array(z.record(z.string(), z.unknown())).describe('Source data rows — one object per row, keyed by field name'), + showRowTotals: z.boolean().optional().describe('Show a totals column on the right'), + showColumnTotals: z.boolean().optional().describe('Show a totals row at the bottom'), + format: z.string().optional().describe('Numeric format string (e.g. "$,.2f") — applied via simple prefix/suffix/decimals'), + columnColors: z + .record(z.string(), z.string()) + .optional() + .describe('Mapping of column header values to Tailwind text-color classes'), + drillDown: DrillDownConfigSchema.optional().describe('Drill-down config (the shared `DrillDownConfig`). Inert on a `pivot` node: `PivotTable` drills only when its host passes `onDrillDown`, and the `pivot` registration passes none; `object-pivot` is the block whose clicks open the records behind a value'), + body: retirementTombstone(PIVOT_NEITHER_CHANNEL), + children: retirementTombstone(PIVOT_NEITHER_CHANNEL), +}); + /** * Timeline Event Schema — mirrors `TimelineEvent` in `../data-display.ts`, the * element type of the RETIRED `TimelineSchema.events` (objectui#6170, ADR-0049 @@ -1435,6 +1485,7 @@ export const DataDisplaySchema = z.discriminatedUnion('type', [ MarkdownSchema, TreeViewSchema, ChartSchema, + PivotTableSchema, TimelineSchema, KbdSchema, HtmlSchema, diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 0a44662875..bdd57555a9 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -190,6 +190,7 @@ export { ChartDataSeriesSchema, DrillDownConfigSchema, ChartSchema, + PivotTableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemSchema, @@ -325,6 +326,11 @@ export { ObjectDataTableSchema, ListViewSchema, ObjectQLComponentSchema, + // objectui#10859 batch 2 — two ADR-0080 public blocks armed from their + // `ComponentPropsMap` rows, and the union they reach `AnyComponentSchema` by. + ObjectMetricBlockSchema, + ObjectMasterDetailFormBlockSchema, + ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; // ============================================================================ @@ -450,7 +456,7 @@ import { DisclosureSchema } from './disclosure.zod.js'; import { OverlaySchema } from './overlay.zod.js'; import { NavigationSchema } from './navigation.zod.js'; import { ComplexSchema } from './complex.zod.js'; -import { ObjectQLComponentSchema } from './objectql.zod.js'; +import { ObjectQLComponentSchema, ObjectQLPublicBlockComponentSchema } from './objectql.zod.js'; import { CRUDComponentSchema } from './crud.zod.js'; import { ReportUnionSchema } from './reports.zod.js'; import { ViewComponentSchema } from './views.zod.js'; @@ -518,6 +524,7 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( NavigationSchema, ComplexSchema, ObjectQLComponentSchema, + ObjectQLPublicBlockComponentSchema, CRUDComponentSchema, ReportUnionSchema, ViewComponentSchema, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index a371682d6f..8327a47f1c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -45,6 +45,11 @@ import { // objectui#7928 — the spec's view CONTAINER, read for ONE slot: its // `listViews` record (`ObjectViewSchema.listViews` below, by reference). ViewSchema as SpecViewSchema, + // objectui#10859 (batch 2) — the `ComponentPropsMap` rows of the two + // ADR-0080 public blocks this module arms, each read as its arm's + // `properties` bag, by reference (`ObjectQLPublicBlockComponentSchema` below). + ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, + ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; @@ -3101,3 +3106,115 @@ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectDataTableSchema, ListViewSchema, ]); + +/* ── ADR-0080 public blocks of this family, armed from their spec rows ───── */ + +/** + * The `properties` member of one of the two blocks below: the spec row, + * optional, with the provenance spelled into its description — the same + * helper `./public-blocks.zod.ts` uses. The row is passed in already through + * the import boundary, so this helper never touches a spec binding. + */ +function objectBlockPropsBag(type: string, row: T) { + return row + .optional() + .describe( + `The \`${type}\` props bag — \`@objectstack/spec\` \`ComponentPropsMap['${type}']\`, by reference. ` + + 'Judged only when present, as the spec\'s props gate judges it.', + ); +} + +/** + * `object-metric` — `ComponentPropsMap['object-metric']` (objectui#10859, + * batch 2). + * + * ## Why it is armed here, and from what + * + * `object-metric` is registered by `@object-ui/plugin-dashboard` + * (`ObjectMetricBlock`, which renders `ObjectMetricWidget`), curated by + * ADR-0080 as a public block (`PUBLIC_BLOCKS` in `@object-ui/core`) and + * declared by the spec — and until this arm `AnyComponentSchema` carried none + * for it, so `safeValidateSchema` and `objectui validate` refused every + * document naming it with `invalid_union` at `type`. + * + * `@object-ui/types` has no TypeScript declaration of this node: the spec's + * row is the one published declaration of what it takes. (The widget's React + * props, `ObjectMetricWidgetProps`, describe the component after the node is + * resolved — a translator function and a React-node icon among them — and are + * not a document shape.) So the arm is built the way `./public-blocks.zod.ts` + * builds every arm whose declaration is a `ComponentPropsMap` row + * (objectui#10872): `BaseSchema` + the `type` literal + `properties`, which IS + * the row, by reference through the objectui#8317 import boundary. Members, + * value types, strictness and the spec's own refusals all arrive from the + * spec; nothing is restated, so nothing can drift. + * + * ## Where the props live + * + * The bag is the spelling the platform's authored documents use — every + * `object-metric` the objectstack showcase ships is `{ type, properties }` — + * and the one the page designer writes; `SchemaRenderer` hoists it onto the + * node before `ObjectMetricBlock` runs. ⚠️ A key written FLAT on the node is + * not judged against the row, exactly as on the public blocks next door: one + * `BaseSchema` does not declare passes the tolerant face unjudged and is + * refused by the strict authoring face. Whether the flat spelling is also an + * authoring channel for these blocks is the question objectui#10872 left open + * for the whole family; declaring it later is additive. + */ +export const ObjectMetricBlockSchema = BaseSchema.extend({ + type: z.literal('object-metric'), + properties: objectBlockPropsBag('object-metric', stripImportedDefaults(SpecObjectMetricPropsSchema)), +}); + +/** + * `object-master-detail-form` — `ComponentPropsMap['object-master-detail-form']`, + * plus the three handler keys its renderer reads off the node (objectui#10859, + * batch 2). + * + * Registered by `@object-ui/plugin-form` (`MasterDetailFormRenderer`, which + * renders `MasterDetailForm`), curated by ADR-0080 and declared by the spec, + * with no arm until this one. Built exactly as `ObjectMetricBlockSchema` above + * is, and for the same reason: the spec row is the published declaration of + * the node's props, and the objectstack showcase's master-detail page authors + * `{ type, properties }`. + * + * ⚠️ `@object-ui/plugin-form` also exports `MasterDetailFormSchema`, the type + * of `MasterDetailForm`'s `schema` prop: the node as the renderer reads it + * after the `properties` hoist. It is not restated here: it carries three host + * callbacks and an optional `type`, and it is the renderer's reading rather + * than the authored document shape, and where it and the spec row disagree — + * it requires `objectName` and `details` and types `title`, `submitText` and + * `cancelText` as a plain string, while the row keeps both keys optional and + * takes an `I18nLabel` for the three labels — the spec is the contract this + * validator answers to. + * + * `onSuccess`, `onError` and `onCancel` are not props the spec declares: they + * are the host callbacks `ObjectForm`, `DrawerForm` and `ModalForm` hand + * `MasterDetailForm`, which calls each one off the node. So each is a RUNTIME + * SLOT (objectui#6124): refused by name when authored, because JSON has no + * function value, rather than left to `.passthrough()` to keep an authored + * value and hand it to a call site. + */ +export const ObjectMasterDetailFormBlockSchema = BaseSchema.extend({ + type: z.literal('object-master-detail-form'), + properties: objectBlockPropsBag( + 'object-master-detail-form', + stripImportedDefaults(SpecObjectMasterDetailFormPropsSchema), + ), + onSuccess: handlerKeyRefusal('onSuccess', 'runtime-slot', 'Called with the saved parent record after a successful save'), + onError: handlerKeyRefusal('onError', 'runtime-slot', 'Called after a refused save, for bookkeeping only'), + onCancel: handlerKeyRefusal('onCancel', 'runtime-slot', 'Cancel button callback'), +}); + +/** + * The two public blocks above, as one arm of `AnyComponentSchema` + * (objectui#10859, batch 2). + * + * A union of its own rather than two more members of `ObjectQLComponentSchema`, + * deliberately: that union mirrors the TypeScript union in `../objectql.ts` + * member for member, and neither block has a declaration there — their + * declaration is the spec row each `properties` member reads. + */ +export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ + ObjectMetricBlockSchema, + ObjectMasterDetailFormBlockSchema, +]);