diff --git a/.changeset/11468-bag-carrier-node-types.md b/.changeset/11468-bag-carrier-node-types.md new file mode 100644 index 0000000000..bed87507ac --- /dev/null +++ b/.changeset/11468-bag-carrier-node-types.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': minor +--- + +The authored `properties`-bag carriers outside the spec's public blocks have a +TypeScript authoring type, and `AuthoringNode` includes them, so +`SchemaRenderer`'s `schema` prop accepts them (objectui#11468). + +`@object-ui/types` exports, each derived by reference from its zod arm and none +restated: + +- `ObjectQLPublicBlockNode`: one member per arm of the zod face's + `ObjectQLPublicBlockComponentSchema`, each the arm's own input, derived the + way `PublicBlockNode` is. The eight members are also exported by name: + `ObjectMetricBlockNode`, `ObjectMasterDetailFormBlockNode`, + `ObjectTimelineBlockNode`, `ObjectFormBlockNode`, `ObjectMapBlockNode`, + `ObjectChartBlockNode`, `ObjectGanttBlockNode` and `ObjectGridBlockNode`. + Each is the authored node, its props in the `properties` bag. The flat + `ObjectFormSchema`, `ObjectMapSchema`, `ObjectGanttSchema`, + `ObjectChartSchema` and `ObjectGridSchema` stay the node as its renderer + reads it after the hoist. +- `FlexBlockNode`: the `FlexBlockSchema` arm's input, its props and its child + list in the `properties` bag. `FlexSchema` stays the node as the `flex` + renderer reads it. + +The derivation now reads a bag's shape the way it reads the arm's. `flex` and +`object-chart` have no `ComponentPropsMap` row, so each bag is its flat +mirror's own members, kept open for the tolerant zod face; on these node types +the bag is closed, as the strict authoring face closes it, so a misspelled bag +key does not compile. A spec-row bag is already closed: `PublicBlockNode` is +unchanged, which `authoring-nodes-bag-carriers-11468.test.ts` pins. + +Adding members to `AuthoringNode` widens what the prop accepts; every value it +accepted before is still accepted. Reading is a different matter: on a union, a +key that one closed member does not declare is TS2339, so code that reads a key +off `SchemaRendererProps['schema']` or off `toRenderableSchema`'s return needs +to narrow first. diff --git a/README.md b/README.md index 4175f4340a..0c6d469f04 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ declare class MyCustomDataSource implements DataSource { **Stop writing repetitive UI code.** A form is a schema, not a component: ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectFormBlockNode } from '@object-ui/types'; // Traditional React: useState, validation, handlers, JSX — per form function UserForm() { @@ -336,7 +336,7 @@ function UserForm() { // Object UI: declare it — the block's props go in its `properties` bag, // as `@objectstack/spec` declares them -const schema: BaseSchema = { +const schema: ObjectFormBlockNode = { type: "object-form", properties: { objectName: "user", diff --git a/content/docs/guide/objectos-integration.mdx b/content/docs/guide/objectos-integration.mdx index de0fbfe534..169b5c7e36 100644 --- a/content/docs/guide/objectos-integration.mdx +++ b/content/docs/guide/objectos-integration.mdx @@ -259,7 +259,7 @@ const sysUserView: ObjectViewSchema = { ```typescript import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; const dataSource = new ObjectStackAdapter({ @@ -272,7 +272,7 @@ const dataSource = new ObjectStackAdapter({ // (`{ object, view?, filter?, sort?, limit? }`), not the adapter, and // `SchemaRenderer` strips it from the props it spreads. The grid's own props go // in its `properties` bag — the spec's `ComponentPropsMap['object-grid']` row. -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'contact', diff --git a/content/docs/plugins/plugin-gantt.mdx b/content/docs/plugins/plugin-gantt.mdx index 256ab3d6f9..eac58762c7 100644 --- a/content/docs/plugins/plugin-gantt.mdx +++ b/content/docs/plugins/plugin-gantt.mdx @@ -71,9 +71,9 @@ renderer reads it, and as code composes it. ```tsx import '@object-ui/plugin-gantt' -import type { BaseSchema } from '@object-ui/types' +import type { ObjectGanttBlockNode } from '@object-ui/types' -const schema: BaseSchema = { +const schema: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'tasks', // Your ObjectQL object @@ -91,9 +91,9 @@ const schema: BaseSchema = { ### With Static Data ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const schema: BaseSchema = { +const schema: ObjectGanttBlockNode = { type: 'object-gantt', properties: { staticData: [ @@ -215,9 +215,9 @@ with a time part is an instant, and the bar ends exactly there. The timeline's Map your database fields to Gantt properties: ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const fieldMappedGantt: BaseSchema = { +const fieldMappedGantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'project_tasks', @@ -283,9 +283,9 @@ The dependencies field should contain an array of task IDs that this task depend ### Object Provider (Database) ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const objectProviderGantt: BaseSchema = { +const objectProviderGantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'project_tasks', @@ -302,9 +302,9 @@ const objectProviderGantt: BaseSchema = { ### Value Provider (Static) ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const valueProviderGantt: BaseSchema = { +const valueProviderGantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { staticData: [ @@ -323,9 +323,9 @@ const valueProviderGantt: BaseSchema = { ### API Provider ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const apiProviderGantt: BaseSchema = { +const apiProviderGantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { data: { @@ -422,9 +422,9 @@ no object (no `objectName`, no object `data` provider), no mode opens anything. ### Software Project ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const softwareProject: BaseSchema = { +const softwareProject: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'sprint_tasks', @@ -442,9 +442,9 @@ const softwareProject: BaseSchema = { ### Construction Project ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const constructionGantt: BaseSchema = { +const constructionGantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'construction_phases', @@ -461,9 +461,9 @@ const constructionGantt: BaseSchema = { ### Marketing Campaign ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const campaignGantt: BaseSchema = { +const campaignGantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { staticData: [ @@ -544,7 +544,7 @@ const campaignGantt: BaseSchema = { ## TypeScript Support ```ts -import type { BaseSchema, GanttConfig } from '@object-ui/types' +import type { ObjectGanttBlockNode, GanttConfig } from '@object-ui/types' const ganttConfig: GanttConfig = { startDateField: 'startDate', @@ -554,7 +554,7 @@ const ganttConfig: GanttConfig = { dependenciesField: 'dependencies' } -const ganttSchema: BaseSchema = { +const ganttSchema: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'project_tasks', diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx index 262ae1dbea..f2202e9e20 100644 --- a/content/docs/plugins/plugin-map.mdx +++ b/content/docs/plugins/plugin-map.mdx @@ -61,9 +61,9 @@ the renderer reads it, and as code composes it. ```tsx import '@object-ui/plugin-map' -import type { BaseSchema } from '@object-ui/types' +import type { ObjectMapBlockNode } from '@object-ui/types' -const schema: BaseSchema = { +const schema: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'locations', // Your ObjectQL object @@ -80,9 +80,9 @@ const schema: BaseSchema = { ### With Static Data ```tsx -import type { BaseSchema } from '@object-ui/types' +import type { ObjectMapBlockNode } from '@object-ui/types' -const schema: BaseSchema = { +const schema: ObjectMapBlockNode = { type: 'object-map', properties: { staticData: [ @@ -198,9 +198,9 @@ the way in. Both are gone. Declare the binding: When your data has separate latitude and longitude fields: ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const storeMap: BaseSchema = { +const storeMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'stores', @@ -219,9 +219,9 @@ const storeMap: BaseSchema = { When your data has a combined location field: ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const placeMap: BaseSchema = { +const placeMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'places', @@ -254,9 +254,9 @@ Two cases sit outside the fit: Declare either one to take the camera over and opt this view out of the auto-fit: ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const cameraMap: BaseSchema = { +const cameraMap: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'locations', @@ -279,9 +279,9 @@ the centre of the records, `center` on its own at a continental zoom. ### Object Provider (Database) ```tsx -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const retailStores: BaseSchema = { +const retailStores: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'retail_stores', @@ -609,7 +609,7 @@ code hands it to the component directly (``); an authored node writes its props in the bag. ```ts -import type { BaseSchema, ObjectMapSchema, ObjectMapConfig } from '@object-ui/types' +import type { ObjectMapBlockNode, ObjectMapSchema, ObjectMapConfig } from '@object-ui/types' const mapConfig: ObjectMapConfig = { latitudeField: 'lat', @@ -621,7 +621,7 @@ const mapConfig: ObjectMapConfig = { } // The authored node: its props in the `properties` bag. -const mapNode: BaseSchema = { +const mapNode: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'locations', diff --git a/content/docs/utilities/data-objectstack.mdx b/content/docs/utilities/data-objectstack.mdx index 2e3058e761..b60e7d2a84 100644 --- a/content/docs/utilities/data-objectstack.mdx +++ b/content/docs/utilities/data-objectstack.mdx @@ -64,14 +64,14 @@ React wiring lives in `@object-ui/react`, not here. Wrap your tree in ```tsx import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', }); // The block's props go in its `properties` bag, as `@objectstack/spec` declares them. -const mySchema: BaseSchema = { type: 'object-grid', properties: { objectName: 'user' } }; +const mySchema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'user' } }; function App() { return ( diff --git a/content/docs/utilities/index.md b/content/docs/utilities/index.md index 43ef880d17..ac6a3c1fce 100644 --- a/content/docs/utilities/index.md +++ b/content/docs/utilities/index.md @@ -165,7 +165,7 @@ through `@object-ui/react`'s `SchemaRendererProvider`: ```tsx import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', @@ -173,7 +173,7 @@ const dataSource = createObjectStackAdapter({ }); // The block's props go in its `properties` bag, as `@objectstack/spec` declares them. -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'account', diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index bb9027269a..93bd6b493e 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -722,11 +722,11 @@ steps are its `sections` — one step per section. There is no unknown-component placeholder and the fields inside `steps` are never read. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectFormBlockNode } from '@object-ui/types'; // The props go in the node's `properties` bag — the members of // `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. -const schema: BaseSchema = { +const schema: ObjectFormBlockNode = { type: 'object-form', properties: { objectName: 'contacts', // the object whose fields the steps list @@ -972,14 +972,14 @@ context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import '@object-ui/plugin-form'; -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectFormBlockNode } from '@object-ui/types'; const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com', token: 'your-auth-token', }); -const schema: BaseSchema = { +const schema: ObjectFormBlockNode = { type: 'object-form', properties: { objectName: 'users', diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 07eae5827b..73e2a8c0d7 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -894,9 +894,9 @@ a task array. There is no `GanttSchema`. `ObjectGanttSchema` in `@object-ui/type yourself. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGanttBlockNode } from '@object-ui/types'; -const gantt: BaseSchema = { +const gantt: ObjectGanttBlockNode = { type: 'object-gantt', properties: { objectName: 'project_tasks', diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 3b12331a12..7d1bf5d556 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -179,10 +179,10 @@ before the grid runs, so `ObjectGridSchema` (`@object-ui/types`) is the node as grid reads it — and the type of ``'s `schema` prop. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const grid: BaseSchema = { +const grid: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -373,10 +373,10 @@ up leaking `columns="[object Object]"` into the DOM instead of drawing a table ### Basic Grid ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -403,10 +403,10 @@ fixtures, tests — give it a `ViewData` with the `value` provider. The rows go under `items`; a bare array is the deprecated `staticData` spelling. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -487,10 +487,10 @@ Not covered: ### Selectable Grid ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -511,10 +511,10 @@ see [Row callbacks are component props](#row-callbacks-are-component-props). ### Grid with Pagination ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -605,7 +605,7 @@ host installs once, above the whole tree: import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import '@object-ui/plugin-grid'; -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; const dataSource = createObjectStackAdapter({ @@ -613,7 +613,7 @@ const dataSource = createObjectStackAdapter({ token: 'your-auth-token', }); -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -658,10 +658,10 @@ Columns sort by default. `sortable` is a **per-column** key, used to turn a colu off; the grid-level `sort` declares the order the grid opens with. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -681,10 +681,10 @@ baked into the metadata, and a toolbar search over the fields named in `searchableFields`. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -705,10 +705,10 @@ themselves live in the object's action set, so the same action behaves identical wherever it is offered. They are `string[]`, not inline definitions with callbacks. ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -801,10 +801,10 @@ indistinguishable from a broken build (objectui#8674). Enable inline cell editing for quick updates: ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'users', @@ -852,10 +852,10 @@ The schema half is just `editable` — the save/cancel affordances appear on the own once a row has pending changes: ```typescript -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectGridBlockNode } from '@object-ui/types'; import type { ObjectGridProps } from '@objectstack/spec/ui'; -const schema: BaseSchema = { +const schema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'products', diff --git a/packages/plugin-map/README.md b/packages/plugin-map/README.md index 4fa47ae1ab..6ed73d8e60 100644 --- a/packages/plugin-map/README.md +++ b/packages/plugin-map/README.md @@ -68,10 +68,10 @@ bag member, as the spec's own page component does (objectui#10859). ```ts import '@object-ui/plugin-map'; -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; // Object-bound: the markers are the records the query returns. -const schema: BaseSchema = { +const schema: ObjectMapBlockNode = { type: 'object-map', properties: { objectName: 'stores', @@ -88,9 +88,9 @@ const schema: BaseSchema = { A literal record array instead of a query, with the same `map` block: ```ts -import type { BaseSchema } from '@object-ui/types'; +import type { ObjectMapBlockNode } from '@object-ui/types'; -const schema: BaseSchema = { +const schema: ObjectMapBlockNode = { type: 'object-map', properties: { staticData: [ diff --git a/packages/types/README.md b/packages/types/README.md index 0e5e689132..edfb68ebe7 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -131,6 +131,16 @@ restated by hand: - `PublicBlockNode`: every public block the zod face arms (`element:text`, `page:tabs`, `action:button`, ...), each the arm's own input. `PublicBlockNodeOf<'element:text'>` picks one. +- `ObjectQLPublicBlockNode`: every ObjectQL block the zod face arms in + `ObjectQLPublicBlockComponentSchema`, each the arm's own input. Each is also + exported by name: `ObjectGridBlockNode`, `ObjectFormBlockNode`, + `ObjectMapBlockNode`, `ObjectGanttBlockNode`, `ObjectChartBlockNode`, + `ObjectMetricBlockNode`, `ObjectTimelineBlockNode` and + `ObjectMasterDetailFormBlockNode`. +- `FlexBlockNode`: an authored `flex` node, its layout props and its child + list in the bag. `flex` and `object-chart` have no `ComponentPropsMap` row, + so each bag is the flat type's own members (`FlexSchema`, + `ObjectChartSchema`), closed like a row. - `ElementTextInputNode` and `ElementRecordPickerNode`: the spec rows of `element:text_input` and `element:record_picker`. - `PageDocumentNode`: a stored page document under its page kind @@ -403,7 +413,7 @@ function render(schema: AnySchema) { Components can nest indefinitely: ```typescript -import type { BaseSchema, ContainerSchema, FlexLayoutProps, SidebarSchema } from '@object-ui/types'; +import type { ContainerSchema, FlexBlockNode, HeaderBarSchema, SidebarSchema } from '@object-ui/types'; // The two leaves are annotated so the nesting below is checked against the // shipped types rather than absorbed by `BaseSchema`'s index signature. @@ -418,22 +428,24 @@ const main: ContainerSchema = { }; // An authored `flex` node takes its props, the child list included, in its -// `properties` bag; `satisfies FlexLayoutProps` checks each bag. -const page: BaseSchema = { +// `properties` bag. `FlexBlockNode` is that node, its bag closed. The bag's +// child list is `unknown[]`, as `FlexBlockSchema` declares it, so each child +// written inline names its own type with `satisfies`. +const page: FlexBlockNode = { type: 'flex', properties: { direction: 'col', children: [ - { type: 'header-bar', crumbs: [{ label: 'My App' }] }, + { type: 'header-bar', crumbs: [{ label: 'My App' }] } satisfies HeaderBarSchema, { type: 'flex', properties: { direction: 'row', children: [sidebar, main] - } satisfies FlexLayoutProps - } + } + } satisfies FlexBlockNode ] - } satisfies FlexLayoutProps + } }; ``` diff --git a/packages/types/examples/dashboard.ts b/packages/types/examples/dashboard.ts index 8de20790c2..0d9ef939f4 100644 --- a/packages/types/examples/dashboard.ts +++ b/packages/types/examples/dashboard.ts @@ -13,11 +13,14 @@ * sidebar, header, and data table. */ -import type { BaseSchema, FlexLayoutProps, SidebarSchema, HeaderBarSchema, CardSchema, DataTableSchema } from '../src/index'; +import type { FlexBlockNode, SidebarSchema, HeaderBarSchema, CardSchema, DataTableSchema } from '../src/index'; // An authored `flex` node takes its props, the child list included, in its -// `properties` bag (objectui#11276); `satisfies FlexLayoutProps` checks each bag. -export const dashboardSchema: BaseSchema = { +// `properties` bag (objectui#11276). `FlexBlockNode` is that node, its bag +// closed (objectui#11468). The bag's child list is `unknown[]`, as the zod arm +// declares it (the page walk judges each entry), so the nested `flex` node is +// checked with `satisfies FlexBlockNode`. +export const dashboardSchema: FlexBlockNode = { type: 'flex', className: 'h-screen', properties: { @@ -92,8 +95,8 @@ export const dashboardSchema: BaseSchema = { ] } ] - } satisfies FlexLayoutProps - } + } + } satisfies FlexBlockNode ] - } satisfies FlexLayoutProps + } }; diff --git a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts new file mode 100644 index 0000000000..f3b5328330 --- /dev/null +++ b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts @@ -0,0 +1,180 @@ +/** + * 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. + */ + +/** + * objectui#11468: the authored `properties`-bag carriers outside the public + * blocks (the eight `ObjectQLPublicBlockComponentSchema` arms and + * `FlexBlockSchema`) have a TypeScript node type, derived by reference and + * joined into `AuthoringNode`, pinned. + * + * As in `authoring-nodes-11364.test.ts`, most assertions here are TYPE-level, + * judged by this package's `type-check` (its `tsc -p tsconfig.test.json` leg), + * never by vitest. `PostRemovalSchema` is `SchemaRenderer`'s `schema` prop with + * `BaseSchema`'s index signature stripped at the type level, so the pins run + * now, while the signature is still present, and keep running after + * objectui#8347 removes it. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import type { BaseSchema } from '../base.js'; +import type { + AuthoringNode, + FlexBlockNode, + ObjectChartBlockNode, + ObjectFormBlockNode, + ObjectGanttBlockNode, + ObjectGridBlockNode, + ObjectMapBlockNode, + ObjectMasterDetailFormBlockNode, + ObjectMetricBlockNode, + ObjectQLPublicBlockNode, + ObjectTimelineBlockNode, + PublicBlockNode, +} from '../authoring-nodes.js'; +import { + FlexBlockSchema, + ObjectQLPublicBlockComponentSchema, + PublicBlockComponentSchema, +} from '../zod/index.zod.js'; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** `T` without its string / number index signatures: `BaseSchema` as objectui#8347 leaves it. */ +type WithoutIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; + +/** `SchemaRendererProps['schema']` with the removal applied to its `BaseSchema` member. */ +type PostRemovalSchema = WithoutIndexSignature | AuthoringNode | string | null | undefined; + +/** + * The `type` of every member whose `properties` bag accepts an undeclared key: + * a string index signature whose value is not `never`. (`element:divider`'s + * bag is `Record`, which refuses every key, so it is closed.) + */ +type OpenBagTypes = U extends { type: infer T; properties?: infer Bag } + ? string extends keyof NonNullable + ? [NonNullable[string]] extends [never] + ? never + : T + : never + : never; + +/** The derivation before objectui#11468: the arm's shape read closed, its bag read as it stands. */ +type ShapeOnlyArmInput = Arm extends z.ZodObject ? z.input> : never; + +// 1. No arm is lost. The derivation maps anything that is not a zod object to +// `never`, so the derived `type` sets must equal the arms' own. +type _EveryObjectQLArmIsTyped = Assert< + Equal['type']> +>; +type _FlexIsTyped = Assert['type']>>; + +// 2. Each named node type is its arm's member, and every one is in `AuthoringNode`. +type _EachAliasIsItsArm = Assert< + Equal< + [ + ObjectMetricBlockNode['type'], + ObjectMasterDetailFormBlockNode['type'], + ObjectTimelineBlockNode['type'], + ObjectFormBlockNode['type'], + ObjectMapBlockNode['type'], + ObjectChartBlockNode['type'], + ObjectGanttBlockNode['type'], + ObjectGridBlockNode['type'], + ], + [ + 'object-metric', + 'object-master-detail-form', + 'object-timeline', + 'object-form', + 'object-map', + 'object-chart', + 'object-gantt', + 'object-grid', + ] + > +>; +type _JoinedIntoAuthoringNode = Assert< + Equal, ObjectQLPublicBlockNode | FlexBlockNode> +>; + +// 3. No member of `AuthoringNode` carries an open bag. `flex` and `object-chart` +// have no spec row: their bags are the flat mirrors' own members, kept +// `.passthrough()` for the tolerant face, so reading the arm's shape alone +// leaves each bag with `[k: string]: unknown`. The control shows that, which +// is why the derivation reads the bag's shape too. +type _NoAuthoringNodeBagIsOpen = Assert, never>>; +type _ControlShapeOnlyLeavesTwoBagsOpen = Assert< + Equal< + OpenBagTypes>, + 'object-chart' | 'flex' + > +>; + +// 4. Closing the bag changes nothing for a spec-row bag: the 27 public blocks +// are typed exactly as the shape-only derivation typed them. +type _PublicBlocksUnchanged = Assert< + Equal> +>; + +describe('objectui#11468: the authored properties-bag carriers have a TypeScript authoring face', () => { + it('object-map: the spec row is the bag, a misspelled bag key and a flat prop are refused, after the removal', () => { + const spelled: PostRemovalSchema = { + type: 'object-map', + properties: { objectName: 'stores', map: { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' } }, + }; + // @ts-expect-error `objectNme` is not a member of ComponentPropsMap['object-map'] + const misspelled: PostRemovalSchema = { type: 'object-map', properties: { objectNme: 'stores' } }; + // @ts-expect-error `objectName` is a member of the bag, refused flat on the node by name + const flat: ObjectMapBlockNode = { type: 'object-map', objectName: 'stores' }; + expect([spelled, misspelled, flat]).toHaveLength(3); + }); + + it('flex: the bag is closed, and the child list lives in it', () => { + const box: FlexBlockNode = { + type: 'flex', + className: 'h-screen', + properties: { direction: 'col', gap: 4, children: [{ type: 'text', content: 'Hello' }] }, + }; + // @ts-expect-error `direciton` is not a member of the flex bag (the mirror's members, closed) + const misspelled: FlexBlockNode = { type: 'flex', properties: { direciton: 'col' } }; + // @ts-expect-error `direction` is a member of the bag, refused flat on the node by name + const flat: FlexBlockNode = { type: 'flex', direction: 'col' }; + // @ts-expect-error a node-level child list is refused: it is `properties.children` + const nodeChildren: FlexBlockNode = { type: 'flex', children: [] }; + // @ts-expect-error `body` is refused toward `properties.children` + const body: FlexBlockNode = { type: 'flex', body: [] }; + // An inline literal where `SchemaRenderer` takes a schema compiles after the removal, through + // `AuthoringNode` (a typed constant like `box` would fit `BaseSchema` structurally either way). + const inline: PostRemovalSchema = { type: 'flex', properties: { direction: 'row', gap: 2 } }; + expect([box, misspelled, flat, nodeChildren, body, inline]).toHaveLength(6); + }); + + it('object-chart: the bag is closed, though it is no spec row', () => { + const chart: ObjectChartBlockNode = { + type: 'object-chart', + properties: { objectName: 'opportunity', chartType: 'bar' }, + }; + // @ts-expect-error `chartTyp` is not a member of the object-chart bag (the mirror's members, closed) + const misspelled: ObjectChartBlockNode = { type: 'object-chart', properties: { chartTyp: 'bar' } }; + expect([chart, misspelled]).toHaveLength(2); + }); + + it('every carrier arm is a zod object, and so are the two mirror bags: the premises the derivation reads', () => { + const arms: z.ZodType[] = [...ObjectQLPublicBlockComponentSchema.options, FlexBlockSchema]; + expect(arms.filter((arm) => arm.def.type !== 'object')).toEqual([]); + expect(ObjectQLPublicBlockComponentSchema.options.length).toBeGreaterThan(0); + for (const bag of [FlexBlockSchema.shape.properties, ObjectQLPublicBlockComponentSchema.options.find((arm) => arm.shape.type.value === 'object-chart')?.shape.properties]) { + expect(bag?.def.type).toBe('optional'); + expect((bag?.def as { innerType: z.ZodType } | undefined)?.innerType.def.type).toBe('object'); + } + }); +}); diff --git a/packages/types/src/authoring-nodes.ts b/packages/types/src/authoring-nodes.ts index a2e6ac965e..de2c2fa752 100644 --- a/packages/types/src/authoring-nodes.ts +++ b/packages/types/src/authoring-nodes.ts @@ -20,7 +20,9 @@ * with its `properties` bag, a `page:tabs` with `properties.items`, an * `action:button` with its executor keys in the bag (objectui#11183), and a * stored page document handed to `SchemaRenderer` under its page kind. Each of - * them is declared by the spec, and the renderer draws it. + * them is declared by the spec, and the renderer draws it. objectui#11468 adds + * the other authored `properties`-bag carriers the zod face arms: the eight + * ObjectQL blocks (`object-map`, `object-grid`, …) and `flex`. * * ## How each one is derived * @@ -33,6 +35,17 @@ * config is left closed. Every member, value type and refusal is the arm's, * and through the arm the spec row's (`propsBag`); nothing is restated, and * an arm added to that union is typed here the same day. + * - {@link ObjectQLPublicBlockNode} and {@link FlexBlockNode} + * (objectui#11468): the same derivation, over the arms of + * `ObjectQLPublicBlockComponentSchema` (`./zod/objectql.zod.ts`) and over + * `FlexBlockSchema` (`./zod/layout.zod.ts`), the authored + * `properties`-bag carriers outside `PublicBlockComponentSchema`. Two of + * their bags are not spec rows: `flex` and `object-chart` have no + * `ComponentPropsMap` row, so each bag is its flat mirror's own members, + * kept `.passthrough()` for the tolerant face (objectui#11276). The + * derivation reads a bag's shape the way it reads the arm's, so the type + * is closed where the strict authoring face closes it. A spec-row bag is + * already closed, and the derivation changes nothing for it. * - {@link ElementTextInputNode} and {@link ElementRecordPickerNode}: no zod * arm exists for these two. `AnyComponentSchema` still refuses both at * `type`, and `registered-types-validate-ratchet-10859` in @@ -81,17 +94,35 @@ import type { PageSchema as SpecPageSchema, } from '@objectstack/spec/ui'; import type { BaseSchema as BaseSchemaMirror } from './zod/base.zod.js'; +import type { FlexBlockSchema } from './zod/layout.zod.js'; +import type { ObjectQLPublicBlockComponentSchema } from './zod/objectql.zod.js'; import type { NODE_ENVELOPE, PublicBlockComponentSchema } from './zod/public-blocks.zod.js'; import type { retirementTombstone } from './zod/tombstone.zod.js'; +/** + * An arm's `properties` member, read the way {@link ClosedArmInput} reads the + * arm: when the bag is a zod object, its shape with the object config left + * closed. A bag that is a spec row is already closed, so this changes nothing + * for it. A bag built from a flat `.passthrough()` mirror (`flex`'s + * `FlexPropsBag`, `object-chart`'s `ObjectChartPropsBag`, objectui#11276) keeps + * the mirror's tolerant posture, which the strict authoring face closes, and + * this type closes it the same way. Any other member is left as it is. + */ +type ClosedBag = Member extends z.ZodOptional + ? z.ZodOptional> + : Member; + /** * A zod object arm's authoring input, read off its shape with the object * config left closed: the arm's `.passthrough()` index signature does not - * reach the type. Distributes over a union of arms. Anything that is not an - * object arm maps to `never`, and the pin file checks that no arm is lost - * that way. + * reach the type, nor does its bag's ({@link ClosedBag}). Distributes over a + * union of arms. Anything that is not an object arm maps to `never`, and the + * pin file checks that no arm is lost that way. */ -type ClosedArmInput = Arm extends z.ZodObject ? z.input> : never; +type ClosedArmInput = Arm extends z.ZodObject ? z.input>> : never; + +/** An arm's shape with its `properties` member closed ({@link ClosedBag}); every other member as it is. */ +type ClosedArmShape = { [K in keyof Shape]: K extends 'properties' ? ClosedBag : Shape[K] }; /** What `.extend()` does to a shape: `Own`'s keys replace `Base`'s, the rest stay. */ type ExtendedShape = Omit & Own; @@ -142,6 +173,63 @@ export type PublicBlockNode = ClosedArmInput<(typeof PublicBlockComponentSchema) /** The {@link PublicBlockNode} member whose `type` is `T`. */ export type PublicBlockNodeOf = Extract; +/** + * The ObjectQL public blocks the zod face arms in their own union, + * `ObjectQLPublicBlockComponentSchema` (`./zod/objectql.zod.ts`, objectui#10859 + * and objectui#11276): `object-metric`, `object-master-detail-form`, + * `object-timeline`, `object-form`, `object-map`, `object-chart`, + * `object-gantt` and `object-grid`, each the arm's own authoring input + * (objectui#11468), derived exactly as {@link PublicBlockNode} is. The block's + * props are its `properties` bag: the spec's `ComponentPropsMap` row, or, for + * `object-chart`, which has no row, the `ObjectChartSchema` mirror's own + * members, closed ({@link ClosedBag}). + * + * ⚠️ Not the TypeScript twins in `./objectql.ts` (`ObjectFormSchema`, + * `ObjectMapSchema`, `ObjectGanttSchema`, `ObjectChartSchema`, + * `ObjectGridSchema`): each of those is the node as its renderer reads it after + * `SchemaRenderer` hoists the bag, and as code composes it. These are the + * authored node, its props in the bag. + */ +export type ObjectQLPublicBlockNode = ClosedArmInput<(typeof ObjectQLPublicBlockComponentSchema)['options'][number]>; + +/** The {@link ObjectQLPublicBlockNode} member whose `type` is `T`. */ +type ObjectQLPublicBlockNodeOf = Extract; + +/** An authored `object-metric` node: the `ObjectMetricBlockSchema` arm's input. */ +export type ObjectMetricBlockNode = ObjectQLPublicBlockNodeOf<'object-metric'>; + +/** An authored `object-master-detail-form` node: the `ObjectMasterDetailFormBlockSchema` arm's input. */ +export type ObjectMasterDetailFormBlockNode = ObjectQLPublicBlockNodeOf<'object-master-detail-form'>; + +/** An authored `object-timeline` node: the `ObjectTimelineBlockSchema` arm's input. */ +export type ObjectTimelineBlockNode = ObjectQLPublicBlockNodeOf<'object-timeline'>; + +/** An authored `object-form` node: the `ObjectFormBlockSchema` arm's input. */ +export type ObjectFormBlockNode = ObjectQLPublicBlockNodeOf<'object-form'>; + +/** An authored `object-map` node: the `ObjectMapBlockSchema` arm's input. */ +export type ObjectMapBlockNode = ObjectQLPublicBlockNodeOf<'object-map'>; + +/** An authored `object-chart` node: the `ObjectChartBlockSchema` arm's input. */ +export type ObjectChartBlockNode = ObjectQLPublicBlockNodeOf<'object-chart'>; + +/** An authored `object-gantt` node: the `ObjectGanttBlockSchema` arm's input. */ +export type ObjectGanttBlockNode = ObjectQLPublicBlockNodeOf<'object-gantt'>; + +/** An authored `object-grid` node: the `ObjectGridBlockSchema` arm's input. */ +export type ObjectGridBlockNode = ObjectQLPublicBlockNodeOf<'object-grid'>; + +/** + * An authored `flex` node: the `FlexBlockSchema` arm's input (objectui#11468), + * derived as {@link PublicBlockNode} is. Its props, the child list included, + * are its `properties` bag: the `FlexSchema` mirror's own members + * (`direction`, `justify`, `align`, `gap`, `wrap`, `children`), closed + * ({@link ClosedBag}), because `@objectstack/spec` has no `flex` row + * (objectui#11276). The TypeScript `FlexSchema` (`./layout.ts`) stays the node + * as the `flex` renderer reads it after the hoist. + */ +export type FlexBlockNode = ClosedArmInput; + /** * `element:text_input`: `ComponentPropsMap['element:text_input']` * (`ElementTextInputPropsSchema`) as its `properties` bag, by reference. @@ -177,4 +265,10 @@ export type PageDocumentNode = SpecPageInput & { type: NonNullable