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
66 changes: 66 additions & 0 deletions .changeset/authoring-types-are-input-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
'@object-ui/types': major
'@object-ui/core': patch
'@object-ui/components': patch
---

**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.**

Three public types said something different from what the platform accepts. All
three divergences were found by making `packages/types`' tests compile (#3009)
and then resolving the declared `p1-spec-alignment.test.ts` debt site-by-site
instead of papering over it.

**`ActionParam` is now the authoring shape, aligned with the spec's input.**
`name` / `label` / `type` become optional and `field` / `objectOverride` appear:
the spec's primary way to declare a param — a bare field reference that inherits
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
`@object-ui/core`'s `ActionParamDef`, with all three required. Authoring and
resolved are different types on purpose. `label` and option labels take the
spec's `I18nLabel` by import — which the new compile-time guard promptly
revealed to be aliased to plain `string` in the current spec (the per-locale
record is the separate `I18nObject`), so this is not a behavioural widening
today; importing the alias means objectui tracks any future widening
automatically.

**Breaking:** code destructuring `param.name` / `param.label` / `param.type` as
guaranteed must now handle the field-backed form (or consume the resolved
`ActionParamDef` instead, which is what dialog-side code should be doing).

**`ListViewInferred` is `z.input`, not `z.infer`.** The spec sub-schemas that
flow into the list-view surface (`userActions`, `tabs` → `ViewTab`, `sharing`)
carry `.default()`s, so the inferred output type made fields like
`userActions.refresh` or a tab's `pinned`/`visible` *required* — but nothing on
the render path ever runs `.parse()`: `normalizeListViewSchema` deliberately
applies no defaults ("an absent flag stays absent", its own suite). The output
type therefore rejected valid authored metadata (`userActions: { sort: true }`)
while promising renderers defaults that never arrive. Typing the surface as
input matches both the author and the runtime object. Code that *trusted* those
phantom defaults now gets an optionality error — which is a latent bug surfacing,
not a regression: the value really could be absent.

**`FormField.dependsOn` is `DependsOnInput`.** The runtime reader
(`resolveCascadingOptions`) has always accepted a bare name, a list of names, or
lookup-parameter entries `{ field, param }` — its parameter type says so. 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` (single source
of truth next to `FormField`), `@object-ui/core` imports and re-exports it, and
the two `as any` reads in the components form renderer are typed.

**The `p1-spec-alignment.test.ts` exclusion is gone.** Its 14 errors resolved:
the two "sharing in ObjectUI format" tests and the legacy-ARIA-spelling fixture
are deleted/rewritten — those dialects are *normalizer input*, folded by
`normalizeListViewSchema` and asserted branch-by-branch in core's
`normalize-list-view.test.ts`, the seam where the fold actually runs; asserting
them on the canonical type only ever "passed" because nothing compiled the file.
One fixture claimed a shape no surface ever admitted (an ObjectQL triplet as a
spec `ViewTab.filter`) and was corrected to the rule-object form. Every test
file in `@object-ui/types` is now compiled, with no exclusions.

Discrimination-checked: reverting `ListViewInferred` to `z.infer`, `dependsOn`
to `string`, or `ActionParam.name` to required each produces the expected
compile error in the now-compiled test files (`TS2739` / `TS2322` / `TS2741`);
restored, all projects are clean.
4 changes: 2 additions & 2 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ ComponentRegistry.register('form',
continue;
const opts = (f as any).options as SelectOption[] | undefined;
if (!opts || opts.length === 0) continue;
const dependsOn = (f as any).dependsOn;
const dependsOn = f.dependsOn;
const hasOptionPredicate = opts.some((o) => (o as any)?.visibleWhen != null);
if (!hasOptionPredicate && !dependsOn) continue;
const current = form.getValues(name);
Expand Down Expand Up @@ -1092,7 +1092,7 @@ ComponentRegistry.register('form',
// `useCascadingOptions` hook (#2715). Non-option fields pass
// their options through untouched.
const cascade = isOptionField
? resolveCascadingOptions(rawOptions, ruleRecord, (field as any).dependsOn, predicateScope)
? resolveCascadingOptions(rawOptions, ruleRecord, field.dependsOn, predicateScope)
: null;
const optionGroupGated = cascade?.gated ?? false;
const dependsOnFields = cascade?.dependsOnFields ?? [];
Expand Down
15 changes: 9 additions & 6 deletions packages/core/src/evaluator/optionRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
* security boundary: when an option is gated for access-control reasons the
* server must also reject writes of its value.
*/
import type { DependsOnInput } from '@object-ui/types';
import { evalFieldPredicate, type FieldRulePredicate } from './fieldRules.js';

/**
Expand All @@ -43,12 +44,14 @@ export interface OptionLike {
visibleWhen?: FieldRulePredicate;
}

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

/**
* Normalize a field's `dependsOn` to the list of sibling field names it reacts
Expand Down
65 changes: 32 additions & 33 deletions packages/types/src/__tests__/p1-spec-alignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,23 @@ describe('P1.1 ListView Spec Alignment', () => {
});

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

it('should accept sharing in ObjectUI format { visibility, enabled }', () => {
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'Account',
sharing: {
visibility: 'team',
enabled: true,
},
};
expect(schema.sharing?.visibility).toBe('team');
expect(schema.sharing?.enabled).toBe(true);
});

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

it('should accept exportOptions as spec string[] format', () => {
const schema: ListViewSchema = {
Expand Down Expand Up @@ -568,16 +562,21 @@ describe('P1.5 Record Components', () => {
// ============================================================================
describe('P1.6 i18n & ARIA Protocol Alignment', () => {
it('should accept ARIA props on ListViewSchema', () => {
// Canonical spellings: the spec's AriaProps (`ariaLabel`/`ariaDescribedBy`/
// `role`) plus objectui's `live` extension. The legacy `label`/`describedBy`
// spellings this test used to author are a normalizer-input dialect —
// `normalizeListViewSchema` folds them onto the canonical keys, and core's
// suite asserts that fold at its own seam (framework#4074).
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'Account',
aria: {
label: 'Accounts List',
describedBy: 'accounts-description',
ariaLabel: 'Accounts List',
ariaDescribedBy: 'accounts-description',
live: 'polite',
},
};
expect(schema.aria?.label).toBe('Accounts List');
expect(schema.aria?.ariaLabel).toBe('Accounts List');
expect(schema.aria?.live).toBe('polite');
});

Expand Down
15 changes: 14 additions & 1 deletion packages/types/src/__tests__/spec-derived-unions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* implements it, so a host app typing against @object-ui/types got an error
* on working code.
*
* The `satisfies` checks below ARE enforcement — but only since #3005 added
* The `satisfies` checks below ARE enforcement — but only since #3009 added
* `packages/types/tsconfig.test.json` and chained it from this package's
* `type-check` script.
*
Expand Down Expand Up @@ -72,6 +72,7 @@ import type {
ActionType,
RunnableActionType,
ActionComponent,
ActionParam,
ActionParamFieldType,
ResolvableParamFieldType,
} from '../ui-action';
Expand Down Expand Up @@ -100,8 +101,20 @@ const _componentCovers = null as unknown as
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;
// framework#4074 steps 2–3: `ActionParam` is the AUTHORING shape, aligned with
// the spec's input. The spec's primary declaration form — a bare field
// reference — must compile; it was a type error while `name`/`label`/`type`
// were required and `field` was undeclared. `label` is typed as the spec's own
// `I18nLabel` import — which THIS check revealed to be aliased to plain
// `string` in the current spec (the per-locale record is the separate
// `I18nObject`); importing the alias rather than restating it means objectui
// tracks any future widening automatically. (These annotations are enforcement
// since objectui#3009 made this file compile.)
const _fieldBackedParam: ActionParam = { field: 'status' };
const _minimalTypedParam: ActionParam = { name: 'priority', label: 'Priority', type: 'select' };
void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers;
void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers;
void _fieldBackedParam; void _minimalTypedParam;

/** Read a spec enum's members, failing loudly if the shape ever changes. */
const optionsOf = (schema: unknown, name: string): string[] => {
Expand Down
31 changes: 28 additions & 3 deletions packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,23 @@ export interface FormFieldPane {
containerClass?: string;
}

/**
* A field's `dependsOn` as authored: a bare parent-field name, or a list of
* names / lookup-parameter entries `{ field, param }`.
*
* This is the single source of truth for the shape (framework#4074).
* `@object-ui/core`'s `resolveDependsOnFields` / `resolveCascadingOptions` —
* the runtime readers — type their parameter with this import and re-export it,
* so the authoring surface and the reader can no longer disagree the way they
* did (the reader accepted arrays for as long as it has existed, while
* `FormField.dependsOn` said `string`).
*/
export type DependsOnInput =
| string
| Array<string | { field: string; param?: string }>
| undefined
| null;

/**
* Form field configuration
*/
Expand Down Expand Up @@ -914,12 +931,20 @@ export interface FormField {
*/
widget?: string;
/**
* Parent field name for cascading/dependent fields.
* Parent field(s) for cascading/dependent fields.
* Aligns with @objectstack/spec FormField.dependsOn.
* When the parent field value changes, this field is re-evaluated.
* When a parent field value changes, this field is re-evaluated.
*
* The runtime reader (`resolveCascadingOptions` in `@object-ui/core`) has
* always accepted a bare name, a list of names, or lookup-parameter entries
* `{ field, param }` — its parameter type is exactly {@link DependsOnInput}.
* This property used to say `string` only, 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 (framework#4074).
* @example 'country' (field 'state' dependsOn 'country')
* @example ['industry'] (multi-parent gating)
*/
dependsOn?: string;
dependsOn?: DependsOnInput;
/**
* Visibility condition expression (view-level, from the form view's
* FormField). Aligns with @objectstack/spec FormField.visibleOn — the wire
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type {
CalendarSchema,
ValidationRule,
FieldCondition,
DependsOnInput,
FormField,
FormFieldTab,
FormFieldPane,
Expand Down
46 changes: 33 additions & 13 deletions packages/types/src/ui-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
Action as SpecAction,
ActionLocation,
ActionType as SpecActionType,
I18nLabel,
} from '@objectstack/spec/ui';
import { FieldType as SpecFieldTypeEnum } from '@objectstack/spec/data';
import type { FieldType as SpecFieldType } from '@objectstack/spec/data';
Expand Down Expand Up @@ -187,34 +188,53 @@ export type ResolvableParamFieldType = ActionParamFieldType | ObjectUiLocalParam
* `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.
* This is the AUTHORING shape, aligned with the spec's `ActionParamSchema`
* input (#4074 steps 2–3): `name` / `label` / `type` are optional because the
* `field` reference form supplies them from an existing object field, and
* labels take the spec's `I18nLabel` (a string or a per-locale record). The
* RESOLVED shape the dialog consumes — after `resolveActionParams()` in
* `@object-ui/app-shell` inlines the field reference — is `@object-ui/core`'s
* `ActionParamDef`, which keeps `name`/`label`/`type` required. Authoring and
* resolved are different types on purpose; conflating them is what made a
* spec-valid `{ field: 'status' }` param a type error here for as long as this
* interface required all three.
*/
export interface ActionParam {
/** Parameter name (snake_case) */
name: string;
/**
* Parameter name (snake_case) — the request-body key. Optional: defaults to
* {@link field} when the param is field-backed.
*/
name?: string;

/** Display label */
label: string;
/**
* Reference an existing object field to inherit its label / type /
* validation / options (the spec's primary way to declare a param). The
* resolver inlines the referenced config; explicit properties here override
* the inherited ones.
*/
field?: string;

/** Object that owns {@link field} (defaults to the action's parent object). */
objectOverride?: string;

/** Display label. Overrides the resolved field label for field-backed params. */
label?: I18nLabel;

/**
* Field type for input.
* Field type for input. Optional: field-backed params inherit the referenced
* field's type.
*
* 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;
type?: ResolvableParamFieldType;

/** Whether parameter is required */
required?: boolean;

/** Options for select/picklist types */
options?: Array<{ label: string; value: string }>;
options?: Array<{ label: I18nLabel; value: string }>;

/** Default value */
defaultValue?: unknown;
Expand Down
Loading
Loading