Skip to content

Commit b41f401

Browse files
os-zhuangclaude
andauthored
refactor(types)!: authoring types are input types — ActionParam takes the spec's declaration forms, ListViewSchema stops promising phantom defaults (objectstack#4074) (#3020)
Three public types said something different from what the platform accepts, found by making packages/types' tests compile (#3009) and resolving the declared p1-spec-alignment.test.ts debt site-by-site. ActionParam is now the AUTHORING shape, aligned with the spec's input: name/label/type optional, field/objectOverride added. The spec's primary declaration form — a bare field reference inheriting label/type/validation/ options from an object field — was unrepresentable while all three were required. The RESOLVED shape the dialog consumes (after app-shell's resolveActionParams() inlines the reference) remains core's ActionParamDef, all three required: authoring and resolved are different types on purpose. label is the spec's I18nLabel by import — which the new compile-time guard promptly revealed to be aliased to plain string in the current spec, so no behavioural widening today; importing the alias means objectui tracks any future widening automatically. ListViewInferred is z.input, not z.infer. The spec sub-schemas flowing into the list-view surface (userActions, tabs→ViewTab, sharing) carry .default()s, so the inferred output type made userActions.refresh or a tab's pinned/visible REQUIRED — but nothing on the render path runs .parse(): normalizeListViewSchema deliberately applies no defaults ("an absent flag stays absent", its own suite). The output type rejected valid authored metadata while promising renderers defaults that never arrive; input matches both author and runtime. FormField.dependsOn is DependsOnInput. The runtime reader (resolveCascadingOptions) has always accepted a bare name, a list of names, or {field, param} entries; the public property said string, so array-authored metadata type-errored while working and the form renderer read the key through (f as any).dependsOn to get past its own type. The shape now lives in @object-ui/types next to FormField; core imports and re-exports it; the two as-any reads in components are typed. The p1-spec-alignment.test.ts exclusion is gone — its 14 errors resolved: the "sharing in ObjectUI format" and legacy-ARIA fixtures are re-seated as normalizer-input dialects (folded by normalizeListViewSchema; asserted branch-by-branch in core's normalize-list-view.test.ts, the seam where the fold runs), and the one fixture no surface ever admitted (an ObjectQL triplet as a spec ViewTab.filter) is corrected to the rule-object form. Every test file in @object-ui/types is compiled, no exclusions. Discrimination-checked: reverting ListViewInferred to z.infer, dependsOn to string, or ActionParam.name to required each fails compile in the now-compiled tests (TS2739 / TS2322 / TS2741); restored, all projects clean. Claude-Session: https://claude.ai/code/session_014DBMo8gnFduzgpuSHd5Eje Co-authored-by: Claude <noreply@anthropic.com>
1 parent 571c438 commit b41f401

10 files changed

Lines changed: 207 additions & 83 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
'@object-ui/types': major
3+
'@object-ui/core': patch
4+
'@object-ui/components': patch
5+
---
6+
7+
**Authoring types are input types (framework#4074 steps 2–3): `ActionParam` takes the spec's declaration forms, `ListViewSchema` stops promising parse-output defaults, and `FormField.dependsOn` matches its runtime reader.**
8+
9+
Three public types said something different from what the platform accepts. All
10+
three divergences were found by making `packages/types`' tests compile (#3009)
11+
and then resolving the declared `p1-spec-alignment.test.ts` debt site-by-site
12+
instead of papering over it.
13+
14+
**`ActionParam` is now the authoring shape, aligned with the spec's input.**
15+
`name` / `label` / `type` become optional and `field` / `objectOverride` appear:
16+
the spec's primary way to declare a param — a bare field reference that inherits
17+
label/type/validation/options from an object field — was unrepresentable while
18+
all three were required. The *resolved* shape the dialog consumes (after
19+
app-shell's `resolveActionParams()` inlines the reference) remains
20+
`@object-ui/core`'s `ActionParamDef`, with all three required. Authoring and
21+
resolved are different types on purpose. `label` and option labels take the
22+
spec's `I18nLabel` by import — which the new compile-time guard promptly
23+
revealed to be aliased to plain `string` in the current spec (the per-locale
24+
record is the separate `I18nObject`), so this is not a behavioural widening
25+
today; importing the alias means objectui tracks any future widening
26+
automatically.
27+
28+
**Breaking:** code destructuring `param.name` / `param.label` / `param.type` as
29+
guaranteed must now handle the field-backed form (or consume the resolved
30+
`ActionParamDef` instead, which is what dialog-side code should be doing).
31+
32+
**`ListViewInferred` is `z.input`, not `z.infer`.** The spec sub-schemas that
33+
flow into the list-view surface (`userActions`, `tabs``ViewTab`, `sharing`)
34+
carry `.default()`s, so the inferred output type made fields like
35+
`userActions.refresh` or a tab's `pinned`/`visible` *required* — but nothing on
36+
the render path ever runs `.parse()`: `normalizeListViewSchema` deliberately
37+
applies no defaults ("an absent flag stays absent", its own suite). The output
38+
type therefore rejected valid authored metadata (`userActions: { sort: true }`)
39+
while promising renderers defaults that never arrive. Typing the surface as
40+
input matches both the author and the runtime object. Code that *trusted* those
41+
phantom defaults now gets an optionality error — which is a latent bug surfacing,
42+
not a regression: the value really could be absent.
43+
44+
**`FormField.dependsOn` is `DependsOnInput`.** The runtime reader
45+
(`resolveCascadingOptions`) has always accepted a bare name, a list of names, or
46+
lookup-parameter entries `{ field, param }` — its parameter type says so. The
47+
public property said `string`, so array-authored metadata type-errored while
48+
working, and the form renderer read the key through `(f as any).dependsOn` to
49+
get past its own type. The shape now lives in `@object-ui/types` (single source
50+
of truth next to `FormField`), `@object-ui/core` imports and re-exports it, and
51+
the two `as any` reads in the components form renderer are typed.
52+
53+
**The `p1-spec-alignment.test.ts` exclusion is gone.** Its 14 errors resolved:
54+
the two "sharing in ObjectUI format" tests and the legacy-ARIA-spelling fixture
55+
are deleted/rewritten — those dialects are *normalizer input*, folded by
56+
`normalizeListViewSchema` and asserted branch-by-branch in core's
57+
`normalize-list-view.test.ts`, the seam where the fold actually runs; asserting
58+
them on the canonical type only ever "passed" because nothing compiled the file.
59+
One fixture claimed a shape no surface ever admitted (an ObjectQL triplet as a
60+
spec `ViewTab.filter`) and was corrected to the rule-object form. Every test
61+
file in `@object-ui/types` is now compiled, with no exclusions.
62+
63+
Discrimination-checked: reverting `ListViewInferred` to `z.infer`, `dependsOn`
64+
to `string`, or `ActionParam.name` to required each produces the expected
65+
compile error in the now-compiled test files (`TS2739` / `TS2322` / `TS2741`);
66+
restored, all projects are clean.

packages/components/src/renderers/form/form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ ComponentRegistry.register('form',
591591
continue;
592592
const opts = (f as any).options as SelectOption[] | undefined;
593593
if (!opts || opts.length === 0) continue;
594-
const dependsOn = (f as any).dependsOn;
594+
const dependsOn = f.dependsOn;
595595
const hasOptionPredicate = opts.some((o) => (o as any)?.visibleWhen != null);
596596
if (!hasOptionPredicate && !dependsOn) continue;
597597
const current = form.getValues(name);
@@ -1092,7 +1092,7 @@ ComponentRegistry.register('form',
10921092
// `useCascadingOptions` hook (#2715). Non-option fields pass
10931093
// their options through untouched.
10941094
const cascade = isOptionField
1095-
? resolveCascadingOptions(rawOptions, ruleRecord, (field as any).dependsOn, predicateScope)
1095+
? resolveCascadingOptions(rawOptions, ruleRecord, field.dependsOn, predicateScope)
10961096
: null;
10971097
const optionGroupGated = cascade?.gated ?? false;
10981098
const dependsOnFields = cascade?.dependsOnFields ?? [];

packages/core/src/evaluator/optionRules.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
* security boundary: when an option is gated for access-control reasons the
3030
* server must also reject writes of its value.
3131
*/
32+
import type { DependsOnInput } from '@object-ui/types';
3233
import { evalFieldPredicate, type FieldRulePredicate } from './fieldRules.js';
3334

3435
/**
@@ -43,12 +44,14 @@ export interface OptionLike {
4344
visibleWhen?: FieldRulePredicate;
4445
}
4546

46-
/** A field's `dependsOn` as authored: a bare name, or a list of names / `{field,param}`. */
47-
export type DependsOnInput =
48-
| string
49-
| Array<string | { field: string; param?: string }>
50-
| undefined
51-
| null;
47+
/**
48+
* A field's `dependsOn` as authored — imported from `@object-ui/types`, where
49+
* `FormField.dependsOn` is declared with it (framework#4074; formerly a local
50+
* declaration that only the reader knew about, while the public
51+
* `FormField.dependsOn` said `string`). Re-exported for this module's existing
52+
* consumers.
53+
*/
54+
export type { DependsOnInput } from '@object-ui/types';
5255

5356
/**
5457
* Normalize a field's `dependsOn` to the list of sibling field names it reacts

packages/types/src/__tests__/p1-spec-alignment.test.ts

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,23 @@ describe('P1.1 ListView Spec Alignment', () => {
111111
});
112112

113113
it('should accept tabs configuration', () => {
114+
// A minimal tab is valid AUTHORING input: the spec's ViewTab `.default()`s
115+
// `pinned`/`visible`, so they are optional on the input side — which is what
116+
// `ListViewSchema` types since framework#4074 (nothing on the render path
117+
// parses, so defaults never materialize at runtime either). A tab filter is
118+
// the spec's rule-object shape; the previous fixture wrote an ObjectQL
119+
// triplet (`['owner', '=', 'current_user']`), which no type on this surface
120+
// has ever admitted — this file just never compiled (objectui#3009).
114121
const schema: ListViewSchema = {
115122
type: 'list-view',
116123
objectName: 'Account',
117124
tabs: [
118125
{ name: 'all', label: 'All Records', isDefault: true },
119-
{ name: 'mine', label: 'My Records', filter: ['owner', '=', 'current_user'] },
126+
{
127+
name: 'mine',
128+
label: 'My Records',
129+
filter: [{ field: 'owner', operator: 'equals', value: 'current_user' }],
130+
},
120131
],
121132
};
122133
expect(schema.tabs).toHaveLength(2);
@@ -170,35 +181,18 @@ describe('P1.1 ListView Spec Alignment', () => {
170181
expect(schema.sharing?.lockedBy).toBe('admin@example.com');
171182
});
172183

173-
it('should accept sharing in ObjectUI format { visibility, enabled }', () => {
174-
const schema: ListViewSchema = {
175-
type: 'list-view',
176-
objectName: 'Account',
177-
sharing: {
178-
visibility: 'team',
179-
enabled: true,
180-
},
181-
};
182-
expect(schema.sharing?.visibility).toBe('team');
183-
expect(schema.sharing?.enabled).toBe(true);
184-
});
185-
186-
it('should accept sharing with both spec and ObjectUI fields merged', () => {
187-
const schema: ListViewSchema = {
188-
type: 'list-view',
189-
objectName: 'Account',
190-
sharing: {
191-
type: 'personal',
192-
visibility: 'private',
193-
enabled: true,
194-
lockedBy: 'user@example.com',
195-
},
196-
};
197-
expect(schema.sharing?.type).toBe('personal');
198-
expect(schema.sharing?.visibility).toBe('private');
199-
expect(schema.sharing?.enabled).toBe(true);
200-
expect(schema.sharing?.lockedBy).toBe('user@example.com');
201-
});
184+
// (framework#4074) The two "sharing in ObjectUI format { visibility, enabled }"
185+
// tests that sat here were deleted rather than made to pass. The legacy pair is
186+
// real, but it is a NORMALIZER INPUT dialect, not part of `ListViewSchema`:
187+
// `normalizeListViewSchema` (`@object-ui/core`) folds `visibility`/`enabled`
188+
// onto the spec's `ViewSharing.type` at the ListView boundary, and ITS suite
189+
// asserts every branch of that fold at the seam where it actually runs
190+
// (`normalize-list-view.test.ts` — "collapses the visibility audience onto the
191+
// spec ownership type", the bare-`enabled` mapping, `lockedBy` preservation,
192+
// explicit-`type` precedence). Asserting the dialect on this type instead only
193+
// ever "passed" because nothing compiled this file (objectui#3009) — and
194+
// widening the canonical type to advertise a dialect that already has a fold
195+
// would invite new metadata to author it.
202196

203197
it('should accept exportOptions as spec string[] format', () => {
204198
const schema: ListViewSchema = {
@@ -568,16 +562,21 @@ describe('P1.5 Record Components', () => {
568562
// ============================================================================
569563
describe('P1.6 i18n & ARIA Protocol Alignment', () => {
570564
it('should accept ARIA props on ListViewSchema', () => {
565+
// Canonical spellings: the spec's AriaProps (`ariaLabel`/`ariaDescribedBy`/
566+
// `role`) plus objectui's `live` extension. The legacy `label`/`describedBy`
567+
// spellings this test used to author are a normalizer-input dialect —
568+
// `normalizeListViewSchema` folds them onto the canonical keys, and core's
569+
// suite asserts that fold at its own seam (framework#4074).
571570
const schema: ListViewSchema = {
572571
type: 'list-view',
573572
objectName: 'Account',
574573
aria: {
575-
label: 'Accounts List',
576-
describedBy: 'accounts-description',
574+
ariaLabel: 'Accounts List',
575+
ariaDescribedBy: 'accounts-description',
577576
live: 'polite',
578577
},
579578
};
580-
expect(schema.aria?.label).toBe('Accounts List');
579+
expect(schema.aria?.ariaLabel).toBe('Accounts List');
581580
expect(schema.aria?.live).toBe('polite');
582581
});
583582

packages/types/src/__tests__/spec-derived-unions.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
* implements it, so a host app typing against @object-ui/types got an error
2828
* on working code.
2929
*
30-
* The `satisfies` checks below ARE enforcement — but only since #3005 added
30+
* The `satisfies` checks below ARE enforcement — but only since #3009 added
3131
* `packages/types/tsconfig.test.json` and chained it from this package's
3232
* `type-check` script.
3333
*
@@ -72,6 +72,7 @@ import type {
7272
ActionType,
7373
RunnableActionType,
7474
ActionComponent,
75+
ActionParam,
7576
ActionParamFieldType,
7677
ResolvableParamFieldType,
7778
} from '../ui-action';
@@ -100,8 +101,20 @@ const _componentCovers = null as unknown as
100101
type SpecField = typeof SpecFieldType extends { options: readonly (infer T)[] } ? T : never;
101102
const _paramFieldCovers = null as unknown as SpecField satisfies ActionParamFieldType;
102103
const _resolvableCovers = null as unknown as ActionParamFieldType satisfies ResolvableParamFieldType;
104+
// framework#4074 steps 2–3: `ActionParam` is the AUTHORING shape, aligned with
105+
// the spec's input. The spec's primary declaration form — a bare field
106+
// reference — must compile; it was a type error while `name`/`label`/`type`
107+
// were required and `field` was undeclared. `label` is typed as the spec's own
108+
// `I18nLabel` import — which THIS check revealed to be aliased to plain
109+
// `string` in the current spec (the per-locale record is the separate
110+
// `I18nObject`); importing the alias rather than restating it means objectui
111+
// tracks any future widening automatically. (These annotations are enforcement
112+
// since objectui#3009 made this file compile.)
113+
const _fieldBackedParam: ActionParam = { field: 'status' };
114+
const _minimalTypedParam: ActionParam = { name: 'priority', label: 'Priority', type: 'select' };
103115
void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers;
104116
void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers;
117+
void _fieldBackedParam; void _minimalTypedParam;
105118

106119
/** Read a spec enum's members, failing loudly if the shape ever changes. */
107120
const optionsOf = (schema: unknown, name: string): string[] => {

packages/types/src/form.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,23 @@ export interface FormFieldPane {
854854
containerClass?: string;
855855
}
856856

857+
/**
858+
* A field's `dependsOn` as authored: a bare parent-field name, or a list of
859+
* names / lookup-parameter entries `{ field, param }`.
860+
*
861+
* This is the single source of truth for the shape (framework#4074).
862+
* `@object-ui/core`'s `resolveDependsOnFields` / `resolveCascadingOptions` —
863+
* the runtime readers — type their parameter with this import and re-export it,
864+
* so the authoring surface and the reader can no longer disagree the way they
865+
* did (the reader accepted arrays for as long as it has existed, while
866+
* `FormField.dependsOn` said `string`).
867+
*/
868+
export type DependsOnInput =
869+
| string
870+
| Array<string | { field: string; param?: string }>
871+
| undefined
872+
| null;
873+
857874
/**
858875
* Form field configuration
859876
*/
@@ -914,12 +931,20 @@ export interface FormField {
914931
*/
915932
widget?: string;
916933
/**
917-
* Parent field name for cascading/dependent fields.
934+
* Parent field(s) for cascading/dependent fields.
918935
* Aligns with @objectstack/spec FormField.dependsOn.
919-
* When the parent field value changes, this field is re-evaluated.
936+
* When a parent field value changes, this field is re-evaluated.
937+
*
938+
* The runtime reader (`resolveCascadingOptions` in `@object-ui/core`) has
939+
* always accepted a bare name, a list of names, or lookup-parameter entries
940+
* `{ field, param }` — its parameter type is exactly {@link DependsOnInput}.
941+
* This property used to say `string` only, so array-authored metadata
942+
* type-errored while working, and the form renderer read the key through
943+
* `(f as any).dependsOn` to get past its own type (framework#4074).
920944
* @example 'country' (field 'state' dependsOn 'country')
945+
* @example ['industry'] (multi-parent gating)
921946
*/
922-
dependsOn?: string;
947+
dependsOn?: DependsOnInput;
923948
/**
924949
* Visibility condition expression (view-level, from the form view's
925950
* FormField). Aligns with @objectstack/spec FormField.visibleOn — the wire

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export type {
134134
CalendarSchema,
135135
ValidationRule,
136136
FieldCondition,
137+
DependsOnInput,
137138
FormField,
138139
FormFieldTab,
139140
FormFieldPane,

packages/types/src/ui-action.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
Action as SpecAction,
2424
ActionLocation,
2525
ActionType as SpecActionType,
26+
I18nLabel,
2627
} from '@objectstack/spec/ui';
2728
import { FieldType as SpecFieldTypeEnum } from '@objectstack/spec/data';
2829
import type { FieldType as SpecFieldType } from '@objectstack/spec/data';
@@ -187,34 +188,53 @@ export type ResolvableParamFieldType = ActionParamFieldType | ObjectUiLocalParam
187188
* `paramToField.ts` maps into the shared field renderer, ADR-0059) was
188189
* unrepresentable in the public type while being fully implemented.
189190
*
190-
* Still narrower than the spec's `ActionParamSchema` in two ways, deliberately
191-
* left for a follow-up because both are breaking: `name` / `label` / `type` are
192-
* required here but optional in spec, and the `field` / `objectOverride`
193-
* field-reference form (spec's primary way to declare a param, which supplies
194-
* label/type/validation/options from an existing object field) is absent. See
195-
* #4074 steps 2–3.
191+
* This is the AUTHORING shape, aligned with the spec's `ActionParamSchema`
192+
* input (#4074 steps 2–3): `name` / `label` / `type` are optional because the
193+
* `field` reference form supplies them from an existing object field, and
194+
* labels take the spec's `I18nLabel` (a string or a per-locale record). The
195+
* RESOLVED shape the dialog consumes — after `resolveActionParams()` in
196+
* `@object-ui/app-shell` inlines the field reference — is `@object-ui/core`'s
197+
* `ActionParamDef`, which keeps `name`/`label`/`type` required. Authoring and
198+
* resolved are different types on purpose; conflating them is what made a
199+
* spec-valid `{ field: 'status' }` param a type error here for as long as this
200+
* interface required all three.
196201
*/
197202
export interface ActionParam {
198-
/** Parameter name (snake_case) */
199-
name: string;
203+
/**
204+
* Parameter name (snake_case) — the request-body key. Optional: defaults to
205+
* {@link field} when the param is field-backed.
206+
*/
207+
name?: string;
200208

201-
/** Display label */
202-
label: string;
209+
/**
210+
* Reference an existing object field to inherit its label / type /
211+
* validation / options (the spec's primary way to declare a param). The
212+
* resolver inlines the referenced config; explicit properties here override
213+
* the inherited ones.
214+
*/
215+
field?: string;
216+
217+
/** Object that owns {@link field} (defaults to the action's parent object). */
218+
objectOverride?: string;
219+
220+
/** Display label. Overrides the resolved field label for field-backed params. */
221+
label?: I18nLabel;
203222

204223
/**
205-
* Field type for input.
224+
* Field type for input. Optional: field-backed params inherit the referenced
225+
* field's type.
206226
*
207227
* Accepts the spec vocabulary plus objectui's declared legacy spellings, which
208228
* is what the dialog resolves — prefer a canonical
209229
* {@link ActionParamFieldType} in new metadata.
210230
*/
211-
type: ResolvableParamFieldType;
231+
type?: ResolvableParamFieldType;
212232

213233
/** Whether parameter is required */
214234
required?: boolean;
215235

216236
/** Options for select/picklist types */
217-
options?: Array<{ label: string; value: string }>;
237+
options?: Array<{ label: I18nLabel; value: string }>;
218238

219239
/** Default value */
220240
defaultValue?: unknown;

0 commit comments

Comments
 (0)