Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/action-vocabularies-derive-from-spec.md
Original file line number Diff line number Diff line change
@@ -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<Action['component']>`. 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<string, string>` 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.
108 changes: 102 additions & 6 deletions packages/types/src/__tests__/spec-derived-unions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

/**
Expand All @@ -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[] => {
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,9 @@ export type {
ActionType,
ObjectUiLocalActionType,
RunnableActionType,
ActionParamFieldType,
ObjectUiLocalParamFieldType,
ResolvableParamFieldType,
ActionParam,
ActionSchema as UIActionSchema,
ActionGroup,
Expand All @@ -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
Expand Down
Loading
Loading