diff --git a/.changeset/action-vocabularies-derive-from-spec.md b/.changeset/action-vocabularies-derive-from-spec.md new file mode 100644 index 000000000..2cd9ff0e3 --- /dev/null +++ b/.changeset/action-vocabularies-derive-from-spec.md @@ -0,0 +1,58 @@ +--- +'@object-ui/types': major +--- + +**The action sub-vocabularies derive from `@objectstack/spec` instead of restating it (framework#4074).** + +`packages/types/src/ui-action.ts` imported exactly one of the spec's action +vocabularies — `ActionType`, derived in #2231/#2901 — and hand-declared the rest +under doc comments claiming spec canonicity. `ActionLocation`'s comment said +"Single source of truth lives in `@objectstack/spec/ui` … **re-export** here" +while the code re-*declared* a parallel union, `as const` tuple, and `z.enum`. + +That is why framework#3856 predicted a compile error when spec 17 removed +`action.execute` and there wasn't one: nothing in this package was bound to the +spec's `z.infer`, so a key removal upstream produced no signal here. + +**Already drifted, not merely drift-prone.** `ActionParamSchema.type` is +`FieldType.optional()` and `FieldType` carries **49** members; the hand-written +`ActionParamFieldType` listed **16**. A spec-valid param typed `lookup`, +`multiselect`, `currency`, `user`, `tags` or `json` failed `tsc` against this +package even though `ActionParamDialog` renders it — the same failure `ActionType` +had before it was derived (missing `form` while `ActionRunner.executeForm` +implemented it). + +- `ActionLocation` / `ACTION_LOCATIONS` / `ActionLocationSchema` are now the spec's + own three symbols, re-exported. `ACTION_LOCATIONS` and `ActionLocationSchema` + stay **value** exports, as #2561 decision (a) explicitly keeps them. +- `ActionComponent` is `NonNullable`. Read off the spec's + resolved `Action` rather than `ActionSchema.shape.component`, because spec + exports `ActionSchema` as a `lazySchema` proxy that does not forward `.shape`. +- `ActionParamFieldType` is the spec's `FieldType` (16 → 49 members), with + `ACTION_PARAM_FIELD_TYPES` as its runtime witness. +- `ActionParam` gains the 13 optional capability fields it could not express — + `visible`, `accept`, `maxSize`, `multiple`, and the lookup-picker group + (`referenceTo`, `displayField`, `idField`, `descriptionField`, `titleFormat`, + `lookupColumns`, `lookupFilters`, `lookupPageSize`, `dependsOn`) — all of which + `@object-ui/core`'s `ActionParamDef` already declares and app-shell's + `paramToField.ts` maps into the shared field renderer (ADR-0059). + +**The legacy param spellings are now named, not hidden.** `paramToField.ts` folds +`checkbox` → `boolean`, `reference` → `lookup`, `datetime-local` → `datetime`. +None is a spec `FieldType`, so deriving `ActionParamFieldType` alone would have +made authored metadata a type error. They are declared as +`ObjectUiLocalParamFieldType` / `OBJECTUI_LOCAL_PARAM_FIELD_TYPES` and +`ActionParam.type` accepts `ResolvableParamFieldType` (spec ∪ local) — the same +shape `ObjectUiLocalActionType` / `RunnableActionType` already use for +`navigation`, and for the same reason: a dialect hidden inside a +`Record` in another package is invisible to an importer. + +**Breaking:** `ActionParamFieldType` widens from 16 members to 49, so an +exhaustive `switch` over a param `type` in a host app stops being exhaustive. The +16 old members are all still valid, so no authored metadata breaks. The added +`ActionParam` fields are optional and additive. + +Not included, and still open on framework#4074: `ActionParam`'s `name` / `label` / +`type` stay required where the spec makes them optional, and the +`field` / `objectOverride` field-reference form remains unrepresentable. Both are +breaking in a way that needs its own migration note. diff --git a/packages/types/src/__tests__/spec-derived-unions.test.ts b/packages/types/src/__tests__/spec-derived-unions.test.ts index 1f45bd64d..bbdc25631 100644 --- a/packages/types/src/__tests__/spec-derived-unions.test.ts +++ b/packages/types/src/__tests__/spec-derived-unions.test.ts @@ -27,9 +27,14 @@ * implements it, so a host app typing against @object-ui/types got an error * on working code. * - * The `satisfies` checks below are the real enforcement: they stop compiling if - * the objectui alias stops covering the spec vocabulary. The runtime assertions - * guard against the spec's own enum being emptied or renamed underneath us. + * The `satisfies` checks below document that contract at the type level — but they + * are NOT enforcement today, and the previous version of this comment claiming + * they were "the real enforcement" was wrong (#4074). Every package tsconfig + * excludes test files by glob (see `packages/types/tsconfig.json`) and there is no + * vitest `typecheck` project, so no `tsc` invocation reads this file at all. They would + * bite the day tests are type-checked; until then the RUNTIME assertions are the + * only thing that fails CI, which is why the #4074 cases below are written as + * identity and membership checks. * * The last case is the inverse: `navigation` is a name objectui runs that the * spec does NOT have. It is asserted absent from the spec so that the day the @@ -41,11 +46,27 @@ import { ReportType as SpecReportType, ActionType as SpecActionType, PageTypeSchema as SpecPageTypeSchema, + ACTION_LOCATIONS as SpecACTION_LOCATIONS, + ActionLocationSchema as SpecActionLocationSchema, + ActionSchema as SpecActionSchema, } from '@objectstack/spec/ui'; -import { OBJECTUI_LOCAL_ACTION_TYPES } from '../ui-action'; +import { FieldType as SpecFieldType } from '@objectstack/spec/data'; +import { + OBJECTUI_LOCAL_ACTION_TYPES, + OBJECTUI_LOCAL_PARAM_FIELD_TYPES, + ACTION_LOCATIONS, + ActionLocationSchema, + ACTION_PARAM_FIELD_TYPES, +} from '../ui-action'; import type { ChartType } from '../data-display'; import type { ReportType } from '../reports'; -import type { ActionType, RunnableActionType } from '../ui-action'; +import type { + ActionType, + RunnableActionType, + ActionComponent, + ActionParamFieldType, + ResolvableParamFieldType, +} from '../ui-action'; import type { PageType, PageVisualizationAlias } from '../layout'; /** @@ -60,8 +81,22 @@ const _pageCovers = null as unknown as 'record' | 'home' | 'app' | 'utility' | ' // The sanctioned local extensions are still part of their unions. const _vizCovers = null as unknown as PageVisualizationAlias satisfies PageType; const _runnableCovers = null as unknown as ActionType satisfies RunnableActionType; +// #4074: the action sub-vocabularies that were restated rather than derived. +// `ActionComponent`'s real compile-time enforcement lives in `ui-action.ts`, +// which IS type-checked: `ActionSchema.component?: ActionComponent` stops +// compiling there if the derivation breaks. +const _componentCovers = null as unknown as + | 'action:button' + | 'action:icon' + | 'action:menu' + | 'action:group' satisfies ActionComponent; +// `ActionParamFieldType` is now the spec's `FieldType`, so every spec field type +// must be assignable — the old 16-member subset fails here. +type SpecField = typeof SpecFieldType extends { options: readonly (infer T)[] } ? T : never; +const _paramFieldCovers = null as unknown as SpecField satisfies ActionParamFieldType; +const _resolvableCovers = null as unknown as ActionParamFieldType satisfies ResolvableParamFieldType; void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers; -void _runnableCovers; +void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers; /** Read a spec enum's members, failing loudly if the shape ever changes. */ const optionsOf = (schema: unknown, name: string): string[] => { @@ -116,6 +151,67 @@ describe('unions derived from a spec vocabulary stay derived (#2944)', () => { expect(forked.filter((v) => !spec.includes(v))).toEqual([]); }); + it('ACTION_LOCATIONS / ActionLocationSchema ARE the spec\'s, by reference (#4074)', () => { + // The doc comment claimed "re-export" while the code re-declared a parallel + // union + `as const` tuple + `z.enum`. Identity is the only check that tells + // a re-export from a faithful copy — a copy passes any value comparison. + expect(ACTION_LOCATIONS).toBe(SpecACTION_LOCATIONS); + expect(ActionLocationSchema).toBe(SpecActionLocationSchema); + // #2561 decision (a) drops spec/ui's `…Schema` names from this package, but + // these two are explicitly kept — so they must still be defined VALUES, not + // type-erased to undefined. + expect(ActionLocationSchema).toBeDefined(); + expect(optionsOf(ActionLocationSchema, 'ActionLocationSchema')).toContain('record_header'); + }); + + it('the spec still accepts every ActionComponent value (#4074)', () => { + // `ActionSchema` is a lazySchema proxy that does not forward `.shape`, so the + // enum is asserted behaviorally through parse — stronger than reading + // `.options` anyway, since it proves the value is accepted end-to-end. + const base = { name: 'act', label: 'Act', type: 'script' as const, target: 'run' }; + for (const component of ['action:button', 'action:icon', 'action:menu', 'action:group']) { + const parsed = SpecActionSchema.safeParse({ ...base, component }); + expect(parsed.success, `spec rejected component '${component}'`).toBe(true); + } + // Negative control: the whitelist is a whitelist. + expect(SpecActionSchema.safeParse({ ...base, component: 'action:carousel' }).success).toBe(false); + }); + + it('ACTION_PARAM_FIELD_TYPES IS the spec\'s FieldType list, by reference (#4074)', () => { + // `ActionParamFieldType` is a type alias, so it erases — nothing at runtime + // stops a future edit from restating it as a literal union, which is how the + // 16-member fork got there. This array is the witness: identity fails against + // a hand-listed copy. + expect(ACTION_PARAM_FIELD_TYPES).toBe(SpecFieldType.options); + }); + + it('ActionParamFieldType covers the spec vocabulary the 16-member fork dropped (#4074)', () => { + const spec = optionsOf(SpecFieldType, 'FieldType'); + const forked = [ + 'text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'time', + 'select', 'email', 'phone', 'url', 'password', 'file', 'color', 'slider', 'rating', + ]; + // Everything the fork had is still real, so no consumer of the old union breaks. + expect(forked.filter((v) => !spec.includes(v))).toEqual([]); + // And it was a strict subset — these are the ones that failed `tsc` while + // `ActionParamDialog` rendered them. + expect(spec.length).toBeGreaterThan(forked.length); + for (const missed of ['lookup', 'multiselect', 'currency', 'user', 'tags', 'json']) { + expect(spec).toContain(missed); + expect(forked).not.toContain(missed); + } + }); + + it('the param-only type aliases are NOT spec field types (#4074)', () => { + // Same contract as `navigation` above: these are objectui dialect + // (`PARAM_TYPE_ALIASES` in app-shell's `paramToField.ts`), folded onto a + // canonical widget type. If the spec adopts one, this fails and names the + // alias to retire. + const spec = optionsOf(SpecFieldType, 'FieldType'); + expect([...OBJECTUI_LOCAL_PARAM_FIELD_TYPES]).toEqual(['checkbox', 'reference', 'datetime-local']); + expect(OBJECTUI_LOCAL_PARAM_FIELD_TYPES.filter((v) => spec.includes(v))).toEqual([]); + }); + it('the page visualization names are NOT spec page types', () => { // `ui/page.zod.ts` says so explicitly; they survive only as the sanctioned // local extension in `layout.ts`. If the spec ever adopts one, drop it there. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0143ca61b..106cce209 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -870,6 +870,9 @@ export type { ActionType, ObjectUiLocalActionType, RunnableActionType, + ActionParamFieldType, + ObjectUiLocalParamFieldType, + ResolvableParamFieldType, ActionParam, ActionSchema as UIActionSchema, ActionGroup, @@ -886,7 +889,13 @@ export type { UndoRedoState, } from './ui-action'; -export { ACTION_LOCATIONS, ActionLocationSchema, OBJECTUI_LOCAL_ACTION_TYPES } from './ui-action'; +export { + ACTION_LOCATIONS, + ActionLocationSchema, + OBJECTUI_LOCAL_ACTION_TYPES, + OBJECTUI_LOCAL_PARAM_FIELD_TYPES, + ACTION_PARAM_FIELD_TYPES, +} from './ui-action'; // ============================================================================ // ObjectStack Protocol Namespaces - Protocol Re-exports diff --git a/packages/types/src/ui-action.ts b/packages/types/src/ui-action.ts index 2489b5420..3b9d8c8b9 100644 --- a/packages/types/src/ui-action.ts +++ b/packages/types/src/ui-action.ts @@ -18,53 +18,51 @@ * @module ui-action * @packageDocumentation */ -import { z } from 'zod'; -import type { ActionType as SpecActionType } from '@objectstack/spec/ui'; +import type { z } from 'zod'; +import type { + Action as SpecAction, + ActionLocation, + ActionType as SpecActionType, +} from '@objectstack/spec/ui'; +import { FieldType as SpecFieldTypeEnum } from '@objectstack/spec/data'; +import type { FieldType as SpecFieldType } from '@objectstack/spec/data'; // ============================================================================ // Spec-Canonical Action Sub-types — imported from @objectstack/spec/ui // ============================================================================ /** - * Action placement locations. + * Action placement locations — re-exported from `@objectstack/spec/ui`. * * Single source of truth lives in `@objectstack/spec/ui` as - * `ACTION_LOCATIONS` + `ActionLocationSchema` + `ActionLocation`. Re-export - * here so existing `@object-ui/types` consumers keep working without coupling - * them to a duplicated, drift-prone enum. To add a new location, edit + * `ACTION_LOCATIONS` + `ActionLocationSchema` + `ActionLocation`. These are now + * literally those three values rather than a restatement of them (#4074): the + * comment claimed "re-export" while the code re-*declared* a parallel union, + * `as const` tuple, and `z.enum`. To add a new location, edit * `packages/spec/src/ui/action.zod.ts` — every layer (spec, core, types, * Studio designer dropdowns) picks up the new value automatically. + * + * `ACTION_LOCATIONS` / `ActionLocationSchema` stay **value** exports: #2561 + * decision (a) drops spec/ui's `…Schema` names from this package's surface, but + * these two are explicitly kept (asserted in `spec-ui-schema-reexports.test.ts`), + * so they are re-exported as values — not inside an `export type` block, which + * would erase them to `undefined` at runtime. */ -export type ActionLocation = - | 'list_toolbar' - | 'list_item' - | 'record_header' - | 'record_more' - | 'record_related' - | 'record_section' - | 'global_nav'; - -export const ACTION_LOCATIONS = [ - 'list_toolbar', - 'list_item', - 'record_header', - 'record_more', - 'record_related', - 'record_section', - 'global_nav', -] as const satisfies readonly ActionLocation[]; - -export const ActionLocationSchema = z.enum(ACTION_LOCATIONS); +export type { ActionLocation } from '@objectstack/spec/ui'; +export { ACTION_LOCATIONS, ActionLocationSchema } from '@objectstack/spec/ui'; /** - * Visual component type for actions - * Canonical definition from @objectstack/spec/ui ActionSchema.component. + * Visual component type for actions — derived from the spec's + * `ActionSchema.component` enum (#4074; formerly a hand-written union). + * + * `action:button` | `action:icon` | `action:menu` | `action:group` + * + * Read off `Action` rather than `ActionSchema.shape.component` because the spec + * exports `ActionSchema` as a `lazySchema` proxy that does not forward `.shape`. + * `Action` is the spec's own resolved `z.infer`, so this tracks the enum without + * reaching into zod internals. */ -export type ActionComponent = - | 'action:button' // Standard button - | 'action:icon' // Icon-only button - | 'action:menu' // Menu item - | 'action:group'; // Action group/dropdown +export type ActionComponent = NonNullable; /** * Action execution type — derived from `@objectstack/spec`'s `ActionType` enum @@ -120,58 +118,163 @@ export const OBJECTUI_LOCAL_ACTION_TYPES = [ export type RunnableActionType = ActionType | ObjectUiLocalActionType; /** - * Field type for action parameters - * Subset of field types commonly used in action parameter collection UIs. - * Aligned with the field types available in @objectstack/spec ActionParamSchema. + * Field type for action parameters — derived from the spec's `FieldType` + * (#4074; formerly a hand-written 16-member subset). + * + * `ActionParamSchema.type` is `FieldType.optional()`, and `FieldType` carries 49 + * members. The old union listed 16 of them, so a spec-valid param typed + * `lookup` / `multiselect` / `currency` / `user` / `tags` / `json` / … failed + * `tsc` against this package even though `ActionParamDialog` renders it — the + * same failure `ActionType` had before #2231/#2901 derived it (it was missing + * `form` while `ActionRunner.executeForm` implemented it). + */ +export type ActionParamFieldType = SpecFieldType; + +/** + * Runtime witness for {@link ActionParamFieldType} — the spec's own `FieldType` + * members, by reference. + * + * A type alias erases at runtime, so nothing stops a future edit from replacing + * the alias above with a restated literal union (which is exactly how the 16-member + * fork got there). This array is what the guard in + * `__tests__/spec-derived-unions.test.ts` pins by identity against + * `FieldType.options`, so a hand-listed copy fails. It is also the list a + * param-type dropdown should render. + */ +export const ACTION_PARAM_FIELD_TYPES = SpecFieldTypeEnum.options; + +/** + * Param-only `type` spellings objectui still resolves that the spec's + * `FieldType` does NOT contain (#4074). + * + * These are the keys of `PARAM_TYPE_ALIASES` in + * `@object-ui/app-shell`'s `paramToField.ts`, which folds each onto a canonical + * widget type (`checkbox` → `boolean`, `reference` → `lookup`, + * `datetime-local` → `datetime`). They are legacy dialect kept for params + * already authored with them; new params should use spec `FieldType` values. + * + * Declared here, next to the vocabulary they extend, for the same reason + * {@link ObjectUiLocalActionType} is: a dialect hidden inside a + * `Record` in another package cannot be seen by an importer, and + * silently resolving an unknown spelling to `text` is not a loud failure. If the + * spec ever adopts one of these names, the guard in + * `__tests__/spec-derived-unions.test.ts` fails and names the alias to retire. + */ +export type ObjectUiLocalParamFieldType = 'checkbox' | 'reference' | 'datetime-local'; + +export const OBJECTUI_LOCAL_PARAM_FIELD_TYPES = [ + 'checkbox', + 'reference', + 'datetime-local', +] as const satisfies readonly ObjectUiLocalParamFieldType[]; + +/** + * Every `type` spelling an authored action param may carry: the spec vocabulary + * plus the declared local aliases above. This is what a *reader* of param + * metadata should accept; use {@link ActionParamFieldType} when authoring new + * metadata. */ -export type ActionParamFieldType = - | 'text' - | 'textarea' - | 'number' - | 'boolean' - | 'date' - | 'datetime' - | 'time' - | 'select' - | 'email' - | 'phone' - | 'url' - | 'password' - | 'file' - | 'color' - | 'slider' - | 'rating'; +export type ResolvableParamFieldType = ActionParamFieldType | ObjectUiLocalParamFieldType; /** * Action parameter definition (ObjectStack Spec v2.0.1) + * + * Mirrors `@object-ui/core`'s `ActionParamDef` — the shape `ActionParamDialog` + * actually consumes — so a host app authoring against this package can express + * every param capability objectui renders. Before #4074 it declared 9 of the 22 + * fields `ActionParamDef` carries, so the widget config below (`multiple`, + * upload `accept`/`maxSize`, and the whole lookup-picker group that + * `paramToField.ts` maps into the shared field renderer, ADR-0059) was + * unrepresentable in the public type while being fully implemented. + * + * Still narrower than the spec's `ActionParamSchema` in two ways, deliberately + * left for a follow-up because both are breaking: `name` / `label` / `type` are + * required here but optional in spec, and the `field` / `objectOverride` + * field-reference form (spec's primary way to declare a param, which supplies + * label/type/validation/options from an existing object field) is absent. See + * #4074 steps 2–3. */ export interface ActionParam { /** Parameter name (snake_case) */ name: string; - + /** Display label */ label: string; - - /** Field type for input */ - type: ActionParamFieldType; - + + /** + * Field type for input. + * + * Accepts the spec vocabulary plus objectui's declared legacy spellings, which + * is what the dialog resolves — prefer a canonical + * {@link ActionParamFieldType} in new metadata. + */ + type: ResolvableParamFieldType; + /** Whether parameter is required */ required?: boolean; - + /** Options for select/picklist types */ options?: Array<{ label: string; value: string }>; - + /** Default value */ defaultValue?: unknown; - + /** Help text */ helpText?: string; - + /** Placeholder text */ placeholder?: string; - + /** Validation expression */ validation?: string; + + /** + * Visibility predicate (CEL) evaluated against the same scope as action + * `visible` (`current_user` / `app` / `data` / `features`). When it evaluates + * false the dialog omits this param. Absent = always visible. + */ + visible?: string; + + // ── Widget config (shared form field-widget renderer, ADR-0059) ──────── + // `ActionParamDialog` renders every param through the same field widgets the + // object form uses, so params carry the widget-relevant config for their type. + // Mirrors `ActionParamSchema` in @objectstack/spec. + + /** Accepted upload types (MIME types / extensions) for `file`/`image` params. */ + accept?: string[]; + + /** Max upload size in bytes for `file`/`image` params. */ + maxSize?: number; + + /** Allow multiple values (array value shape); mirrors `FieldSchema.multiple`. */ + multiple?: boolean; + + /** Reference target object for `lookup`/`master_detail` params. */ + referenceTo?: string; + + /** Field on the referenced object to display in the picker. */ + displayField?: string; + + /** Field on the referenced object holding its id. */ + idField?: string; + + /** Field on the referenced object to show as secondary text. */ + descriptionField?: string; + + /** Template composing the picker's row title. */ + titleFormat?: string; + + /** Columns shown in the lookup picker's table. */ + lookupColumns?: unknown[]; + + /** Filters constraining the lookup picker's query. */ + lookupFilters?: unknown[]; + + /** Page size for the lookup picker's query. */ + lookupPageSize?: number; + + /** Params this one's options depend on (cascading selects). */ + dependsOn?: unknown[]; } /**