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
25 changes: 25 additions & 0 deletions .changeset/25923-spec-only-keys-crud-action-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/types': minor
---

refactor(types)!: `object-form` and `object-calendar` declare the four spec keys their renderers read, and `@object-ui/types/zod` renames its legacy `ActionSchema` to `CRUDActionSchema` (#25923)

Clause-②: yes (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No `@objectstack/spec` key, object definition, stored row or stored shape moves. The four keys are members the spec's `ComponentPropsMap['object-form']` and `['object-calendar']` rows already declare, now declared by reference on the `@object-ui/types` mirror of those rows, so `objectui validate` refuses exactly the values the spec refuses and `objectstack migrate meta` has nothing to rewrite. The renamed zod const validates a legacy `type: 'action'` node that is not metadata and has no stored representation; the affected party is a JavaScript or TypeScript consumer importing it by name, reached by its own compiler or bundler. `runtime-export-retired` would be the natural category, but it reads only a package's main entry and refuses an operand declared in a `*.zod.ts`, and this export lives on the `./zod` subpath in `crud.zod.ts`. The package publishes (not unpublished); no ADR-0087 id covers this change (not registered or already-registered); and the symbols are zod-backed (not runtime-interface-only or type-surface-only). -->

`@object-ui/types` mirrors `@objectstack/spec` schemas so that `objectui validate` and `os validate` give one verdict. Over every schema a mirror pairs with (same-name exports, and the `ComponentPropsMap` row of each node type), eleven spec keys were neither declared nor refused on objectui's side. Four of them are read by a renderer, and those are now declared:

- **`ObjectFormSchema`** (TypeScript and zod) declares `contentLayout` and `confirmOnDiscard`, the `ComponentPropsMap['object-form']` members by reference. `ModalForm` reads `contentLayout`; `ModalForm` and `DrawerForm` read `confirmOnDiscard`.
- **`ObjectCalendarSchema`** (TypeScript and zod) declares `locale` and `loading`, the `ComponentPropsMap['object-calendar']` members by reference. `plugin-calendar` reads both off the node. They are node keys, not the calendar field-name keys that live in the `calendar` block.

The other seven are the spec form-view entry's keys on `FormFieldSchema`, which is objectui's runtime form field, a different layer under the same name. `normalizeSectionField` in `@object-ui/plugin-form` translates between the two layers, and each key's handling is now recorded beside the mirrors.

The legacy `type: 'action'` node's zod schema was exported as `ActionSchema`, the name of `@objectstack/spec/ui`'s action metadata schema, with which it shares no vocabulary. It is now `CRUDActionSchema`, beside `CRUDDialogSchema` and `CRUDComponentSchema`; what it accepts is unchanged. Its TypeScript twin keeps the deprecated `ActionSchema` name in `@object-ui/types`.

**BREAKING** for code that imports the zod const by its old name, and for nodes that carry a value the spec refuses on the four keys.

- **Before.** `import { ActionSchema } from '@object-ui/types/zod'` resolved to the legacy action node's schema. `ObjectFormSchema.safeParse({ type: 'object-form', objectName: 'account', mode: 'create', contentLayout: 'tabs' })` succeeded and kept the value, which stacked the sections.
- **Now.** The old import name is gone. The zod mirrors refuse `contentLayout: 'tabs'`, a non-boolean `confirmOnDiscard` or `loading`, and a non-string `locale`, at the key's own path, as the spec does.
- **Fix.** FROM `import { ActionSchema } from '@object-ui/types/zod'` TO `import { CRUDActionSchema } from '@object-ui/types/zod'`. Write `contentLayout` as `'simple'` or `'tabbed'`.
9 changes: 5 additions & 4 deletions ui/content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,9 @@ for an either/or.
> `else`. The runner has always read this key as the predicate above, and an
> object without a `source` reads as "no gate declared", so an action written
> that way ran **unconditionally**, with no error at author time and no
> diagnostic at runtime. The shape is now refused by `ActionSchema`'s zod schema
> instead of being accepted and ignored.
> diagnostic at runtime. The shape is now refused by the zod schema
> (`CRUDActionSchema` in `@object-ui/types/zod`) instead of being accepted and
> ignored.

## Post-success behaviour

Expand Down Expand Up @@ -483,7 +484,7 @@ const complexAction: ActionSchema = {
## Runtime Validation

```ts
import { ActionSchema } from '@object-ui/types/zod';
import { CRUDActionSchema } from '@object-ui/types/zod';

const myAction = {
type: 'action',
Expand All @@ -492,7 +493,7 @@ const myAction = {
confirmText: 'Are you sure?',
};

const result = ActionSchema.safeParse(myAction);
const result = CRUDActionSchema.safeParse(myAction);

if (result.success) {
console.log('Valid action configuration');
Expand Down
2 changes: 1 addition & 1 deletion ui/content/docs/guide/schema-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ For runtime validation, use the included Zod schemas:
```typescript
import {
AppComponentSchema,
ActionSchema,
CRUDActionSchema,
ReportComponentSchema
} from '@object-ui/types/zod';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import ts from 'typescript';
import { ActionSchema as SpecActionSchema } from '@objectstack/spec/ui';
import * as crudZod from '../zod/crud.zod.js';
import * as zodBarrel from '../zod/index.zod.js';
import { ActionSchema, DetailSchema } from '../zod/crud.zod.js';
import { CRUDActionSchema, DetailSchema } from '../zod/crud.zod.js';
import { BaseSchema } from '../zod/base.zod.js';
import { safeValidateSchema } from '../zod/index.zod.js';
import type { ActionSchema as ActionSchemaTS } from '../crud.js';
Expand Down Expand Up @@ -105,9 +105,9 @@ type Issue = { code: string; path: PropertyKey[]; message: string; expected?: st
const flatIssues = (issues: Issue[]): Issue[] =>
issues.flatMap((i) => (i.code === 'invalid_union' && i.errors ? i.errors.flat().flatMap((e) => flatIssues([e])) : [i]));

/** The lazy mirror's object shape — `ActionSchema` is `z.lazy(() => BaseSchema.extend(…))`. */
/** The lazy mirror's object shape — `CRUDActionSchema` is `z.lazy(() => BaseSchema.extend(…))`. */
const actionShape = (): Record<string, { description?: string }> =>
(ActionSchema as unknown as { _def: { getter: () => { shape: Record<string, { description?: string }> } } })._def.getter().shape;
(CRUDActionSchema as unknown as { _def: { getter: () => { shape: Record<string, { description?: string }> } } })._def.getter().shape;

/* ── the Zod half: refused BY NAME, with the guidance in the message ─────── */

Expand All @@ -120,7 +120,7 @@ describe.each([
// .optional()`). Asserting the ENVELOPE — code, path, expected and the exact
// message — so the pin cannot be satisfied by an unrelated rejection, and a
// WELL-FORMED callback so it is the KEY that is refused, not its members.
const result = ActionSchema.safeParse({ ...LEGACY_ACTION, [key]: value });
const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, [key]: value });
expect(result.success, `an authored \`${key}\` was ACCEPTED`).toBe(false);
if (result.success) return;

Expand Down Expand Up @@ -157,7 +157,7 @@ describe.each([
// exactly this acceptance, kept the callback unvalidated, and stayed green.
const doc = { ...LEGACY_ACTION, [key]: CALLBACKS[0][1] };
expect(BaseSchema.safeParse(doc).success).toBe(true);
expect(ActionSchema.safeParse(doc).success).toBe(false);
expect(CRUDActionSchema.safeParse(doc).success).toBe(false);
});

it('keeps the key DECLARED, with the same guidance on `.describe()` — one string, both channels', () => {
Expand All @@ -171,34 +171,34 @@ describe.each([

describe('the retirement narrows exactly `onSuccess` / `onFailure` (objectui#7068)', () => {
it('a legacy action WITHOUT the two keys still parses, and its values survive', () => {
const result = ActionSchema.safeParse(LEGACY_ACTION);
const result = CRUDActionSchema.safeParse(LEGACY_ACTION);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.api).toBe('/api/data');
});

it('control: the adjacent `successMessage` / `errorMessage` — NOT retired — still parse and survive', () => {
const result = ActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 'Loaded', errorMessage: 'Failed' });
const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 'Loaded', errorMessage: 'Failed' });
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.successMessage).toBe('Loaded');
expect(result.data.errorMessage).toBe('Failed');
});

it('control: the mirror did not stop validating — a wrong-typed `successMessage` is still refused', () => {
const result = ActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 42 });
const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 42 });
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.issues.map((i) => i.path.join('.'))).toContain('successMessage');
});

it('control: `chain` and `redirect` — the declared follow-up and post-action keys — still parse', () => {
const result = ActionSchema.safeParse({ ...LEGACY_ACTION, chain: [{ type: 'action', label: 'Next' }], redirect: '/done' });
const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, chain: [{ type: 'action', label: 'Next' }], redirect: '/done' });
expect(result.success).toBe(true);
});

it('an UNDECLARED key still rides `.passthrough()` — the contrast the tombstones exist for, measured live', () => {
const result = ActionSchema.safeParse({ ...LEGACY_ACTION, notAKeyAtAll: 'anything' });
const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, notAKeyAtAll: 'anything' });
expect(result.success).toBe(true);
if (!result.success) return;
expect((result.data as unknown as Record<string, unknown>).notAKeyAtAll).toBe('anything');
Expand All @@ -212,7 +212,7 @@ describe('`ActionCallback` / `ActionCallbackSchema` are DELETED, not tombstoned
expect('ActionCallbackSchema' in crudZod).toBe(false);
expect('ActionCallbackSchema' in zodBarrel).toBe(false);
// Non-vacuity: the neighbours the barrel still carries.
expect('ActionSchema' in crudZod).toBe(true);
expect('CRUDActionSchema' in crudZod).toBe(true);
expect('ActionExecutionModeSchema' in zodBarrel).toBe(true);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ import type {
ToggleSchema,
} from '../form';

import { ActionSchema as ActionMirror } from '../zod/crud.zod';
import { CRUDActionSchema as ActionMirror } from '../zod/crud.zod';
import {
AccordionItemSchema,
CollapsibleSchema as CollapsibleMirror,
Expand Down Expand Up @@ -199,7 +199,7 @@ const IN_SCOPE: readonly MirrorCase[] = [
{ name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } },
{ name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } },
{ name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } },
{ name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{ name: 'crud.zod.ts#CRUDActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } },
{
name: 'disclosure.zod.ts#CollapsibleSchema',
mirror: CollapsibleMirror,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type { ToastSchema as ToastInterface } from '../feedback';
import type { HoverCardSchema as HoverCardInterface, TooltipSchema as TooltipInterface } from '../overlay';
import type { ReportComponentSchema as ReportInterface } from '../reports';
import {
ActionSchema,
CRUDActionSchema,
CarouselSchema,
ChatbotEnhancedSchema,
ChatbotFloatingSchema,
Expand Down Expand Up @@ -70,8 +70,8 @@ const CASES: readonly RenameCase[] = [
{ schema: ChatbotSchema, base: { type: 'chatbot', messages: [] }, at: [], retired: 'autoResponseDelay', renamed: 'autoResponseDelayMs', unit: 'milliseconds' },
{ schema: ChatbotEnhancedSchema, base: { type: 'chatbot-enhanced', messages: [] }, at: [], retired: 'autoResponseDelay', renamed: 'autoResponseDelayMs', unit: 'milliseconds' },
{ schema: ChatbotFloatingSchema, base: { type: 'chatbot-floating', messages: [] }, at: [], retired: 'autoResponseDelay', renamed: 'autoResponseDelayMs', unit: 'milliseconds' },
{ schema: ActionSchema, base: { type: 'action', label: 'Go' }, at: [], retired: 'timeout', renamed: 'timeoutMs', unit: 'milliseconds' },
{ schema: ActionSchema, base: { type: 'action', label: 'Go', retry: { maxAttempts: 2 } }, at: ['retry'], retired: 'delay', renamed: 'delayMs', unit: 'milliseconds' },
{ schema: CRUDActionSchema, base: { type: 'action', label: 'Go' }, at: [], retired: 'timeout', renamed: 'timeoutMs', unit: 'milliseconds' },
{ schema: CRUDActionSchema, base: { type: 'action', label: 'Go', retry: { maxAttempts: 2 } }, at: ['retry'], retired: 'delay', renamed: 'delayMs', unit: 'milliseconds' },
{ schema: ToastSchema, base: { type: 'toast' }, at: [], retired: 'duration', renamed: 'durationMs', unit: 'milliseconds' },
{ schema: TooltipSchema, base: { type: 'tooltip', content: 'Hi' }, at: [], retired: 'delayDuration', renamed: 'delayDurationMs', unit: 'milliseconds' },
{ schema: HoverCardSchema, base: { type: 'hover-card', ...node }, at: [], retired: 'openDelay', renamed: 'openDelayMs', unit: 'milliseconds' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ import { z } from 'zod';

import { CalendarViewSchema as CalendarViewZod } from '../zod/complex.zod';
import {
ActionSchema as ActionZod,
CRUDActionSchema as ActionZod,
CRUDDialogSchema as CRUDDialogZod,
DetailSchema as DetailZod,
} from '../zod/crud.zod';
Expand Down Expand Up @@ -191,7 +191,7 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject<z.ZodRawShape> =>
/** The six keys whose function value REACHES a renderer (channels above). */
const RUNTIME_SLOT: readonly Site[] = [
['views.zod.ts', 'DetailViewSchema', 'onBack', DetailViewZod],
['crud.zod.ts', 'ActionSchema', 'onClick', ActionZod],
['crud.zod.ts', 'CRUDActionSchema', 'onClick', ActionZod],
['crud.zod.ts', 'DetailSchema', 'onBack', DetailZod],
// objectui#7804 — the two keys `DetailView` reads off a `'detail'` document
// that its arm never declared. They arrive by a DIFFERENT route from the four
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,12 @@ describe('the source schemas still declare their full member sets', () => {
expect(declaresStringIndex(grid)).toBe(false);
});

it('ObjectFormSchema declares 69 members and no longer carries the #5155 index signature', () => {
// 71 since objectstack#25923 (work item 4) declared `contentLayout` and
// `confirmOnDiscard` by reference to `ComponentPropsMap['object-form']`; the
// `form` slot carries both.
it('ObjectFormSchema declares 71 members and no longer carries the #5155 index signature', () => {
const form = exportedType('ObjectFormSchema');
expect(memberNames(form)).toHaveLength(69);
expect(memberNames(form)).toHaveLength(71);
expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText']));
expect(declaresStringIndex(form)).toBe(false);
});
Expand Down
24 changes: 12 additions & 12 deletions ui/packages/types/src/__tests__/phase2-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
AppComponentSchema,
ReportComponentSchema,
ReportBuilderSchema,
ActionSchema,
CRUDActionSchema,
DetailViewSchema,
ViewSwitcherSchema,
FilterUISchema,
Expand Down Expand Up @@ -364,7 +364,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
errorMessage: 'Failed to load data',
};

const result = ActionSchema.safeParse(ajaxAction);
const result = CRUDActionSchema.safeParse(ajaxAction);
expect(result.success).toBe(true);
});

Expand All @@ -378,7 +378,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
method: 'DELETE',
};

const result = ActionSchema.safeParse(confirmAction);
const result = CRUDActionSchema.safeParse(confirmAction);
expect(result.success).toBe(true);
});

Expand All @@ -394,7 +394,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
// value is a LOUD parse rejection — never a silent strip, which would let
// the author believe the dialog they configured exists. Absent stays
// valid (accept case above).
const result = ActionSchema.safeParse(structured);
const result = CRUDActionSchema.safeParse(structured);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((i) => i.path.join('.'))).toContain('confirm');
Expand All @@ -408,7 +408,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
// nothing else. The author now reads the remedy (`confirmText`) in the
// message itself, which is what `packages/cli`'s `validate` / `check`
// print verbatim beside the path and code.
const result = ActionSchema.safeParse({
const result = CRUDActionSchema.safeParse({
type: 'action',
label: 'Delete Record',
actionType: 'confirm',
Expand All @@ -427,7 +427,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {

// Non-vacuity, IN THIS TEST: the canonical spelling still parses green.
expect(
ActionSchema.safeParse({ type: 'action', label: 'Delete Record', confirmText: 'Sure?' }).success,
CRUDActionSchema.safeParse({ type: 'action', label: 'Delete Record', confirmText: 'Sure?' }).success,
).toBe(true);
});

Expand Down Expand Up @@ -462,7 +462,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
},
};

const result = ActionSchema.safeParse(dialogAction);
const result = CRUDActionSchema.safeParse(dialogAction);
expect(result.success).toBe(true);
});

Expand Down Expand Up @@ -492,7 +492,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
chainMode: 'sequential',
};

const result = ActionSchema.safeParse(chainedAction);
const result = CRUDActionSchema.safeParse(chainedAction);
expect(result.success).toBe(true);
});

Expand All @@ -516,7 +516,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
condition,
};

const result = ActionSchema.safeParse(gatedAction);
const result = CRUDActionSchema.safeParse(gatedAction);
expect(result.success).toBe(true);
});

Expand Down Expand Up @@ -549,7 +549,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
},
};

const result = ActionSchema.safeParse(branchAction);
const result = CRUDActionSchema.safeParse(branchAction);
expect(result.success).toBe(false);
if (result.success) return;
const issues = result.error.issues.filter((i) => i.path[0] === 'condition');
Expand All @@ -573,7 +573,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
},
};

const result = ActionSchema.safeParse(trackedAction);
const result = CRUDActionSchema.safeParse(trackedAction);
expect(result.success).toBe(true);
});

Expand All @@ -590,7 +590,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => {
},
};

const result = ActionSchema.safeParse(retryAction);
const result = CRUDActionSchema.safeParse(retryAction);
expect(result.success).toBe(true);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
ActionSchema,
CRUDActionSchema,
AppMenuItemSchema,
FilterBuilderConditionSchema,
FilterGroupSchema,
Expand Down Expand Up @@ -143,7 +143,7 @@ const MEMOISED: ReadonlyArray<readonly [string, unknown]> = [
];
/** ⛔ Do not "fix" these — each one's `z.lazy` dodges a real ReferenceError. */
const TDZ_BOUND: ReadonlyArray<readonly [string, unknown]> = [
['ActionSchema', ActionSchema],
['CRUDActionSchema', CRUDActionSchema],
['AppMenuItemSchema', AppMenuItemSchema],
['MenuItemSchema', MenuItemSchema],
['NavLinkSchema', NavLinkSchema],
Expand Down
Loading
Loading