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
26 changes: 26 additions & 0 deletions .changeset/11266-subforms-columns-mirror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/types': minor
---

A form view's `subforms[].columns` entry is judged by `@objectstack/spec`'s `InlineGridColumnSchema` now, by reference, so `objectui validate` and `os validate` give one verdict on a column (objectui#11266).

BREAKING (`@object-ui/types`): the accept set of the tolerant face narrows. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)

`@objectstack/spec` 17.6.0 holds `FormViewSchema.subforms[].columns` to its closed inline grid column schema (objectstack-ai/objectstack#20927). The `object-form` mirror still read `z.array(z.any())` there, so `objectui validate` accepted columns that `os validate` refuses.

What each face does now:

- **zod (`@object-ui/types/zod`).** NARROWS on the tolerant face (`safeValidateSchema`, which `objectui validate` runs) and on the strict authoring face, wherever `subforms` is read: the object-view `form` slot and the `object-form` mirror. A column with an undeclared key is refused at the column, with one `unrecognized_keys` issue naming the key. A column that declares `type: 'currency'` and carries `scale` is refused at that `scale`, in the spec's own words. A bare field-name string is refused at the column with `invalid_type`. Each verdict is the spec schema's, because the column is handed to it.
- **TypeScript.** A `subforms[].columns` entry on `ObjectFormSchema` (and so on `ObjectViewSchema['form']`) is `InlineGridColumn` from `@objectstack/spec/data`, by reference, where it was `any`. A string column no longer compiles, and neither does an object literal with an undeclared column key.

**Migration.**

- FROM `columns: ['product', 'quantity']` → TO `columns: [{ name: 'product' }, { name: 'quantity' }]`
- FROM a column carrying a key `InlineGridColumn` does not declare → TO the same column without that key. A column that declares no `type` takes its label, type and the rest from the child object's field.
- FROM `{ name: 'amount', type: 'currency', scale: 2 }` → TO `{ name: 'amount', type: 'currency' }`. A currency amount's decimal places come from its currency's minor unit, not from the column.

Not refused here: a `scale` on a column that declares no `type` (`{ name: 'amount', scale: 2 }`) when its child field is a currency. Seeing that takes the child object's fields, which are not in the document the validator judges. `defineStack` refuses it at publish, and the master-detail form reports it at render.

The parse output is the spec schema's too: a column `readonlyWhen` or `requiredWhen` written as a string comes back from `safeValidateSchema` as the spec's `{ dialect: 'cel', source }` envelope. The document you pass in is not changed.

**Clause-②: yes (narrowing)**: a `subforms[].columns` entry that is not a valid `InlineGridColumn` used to parse with its value kept, and is now refused at the column.
7 changes: 5 additions & 2 deletions content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ const orderView: ObjectViewSchema = {
{
childObject: 'order_items',
title: 'Line items',
columns: ['product', 'quantity', 'price'],
columns: [{ name: 'product' }, { name: 'quantity' }, { name: 'price' }],
},
],
},
Expand All @@ -605,7 +605,10 @@ const orderView: ObjectViewSchema = {

Only `childObject` is required — the relationship field and the grid columns are
derived from the child object's metadata unless you override them
(`relationshipField`, `columns`).
(`relationshipField`, `columns`). A column is `@objectstack/spec`'s
`InlineGridColumn`: an object keyed by `name`, never a bare field name. One that
declares no `type` takes its label, type and the rest from the child field;
an undeclared column key is refused by the validator.

### View tabs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
* `@objectstack/spec` 17.5.0 refuses `scale` on an inline grid column that
* declares `type: 'currency'`. A column that declares no `type`
* (`{ name: 'amount', scale: 2 }`) still parses: `hydrateColumns` fills its
* `type` from the child field at render time, which the spec cannot see, and
* `objectui validate` cannot either (a subform's `columns` is
* `z.array(z.any())`, and the child object's fields are not in the document).
* `type` from the child field at render time. The spec's column schema cannot
* see that, and `objectui validate` cannot either: it judges a subform's
* `columns` with that same schema (objectui#11266), and the child object's
* fields are not in the document. Only `defineStack` refuses it, at publish, by
* resolving `name` against the child object the stack declares
* (objectstack-ai/objectstack#20927); nothing in objectui does.
* `GridField`'s `currencyWidth` no longer reads a currency column's `scale`, so
* without a report here the key would be accepted everywhere and read by
* nothing.
Expand Down Expand Up @@ -69,7 +72,7 @@ describe('hydrateColumns reports a `scale` on a column that hydrates to currency
expect(computeRow(cols, { quantity: 3, unit_price: 1.2345 }, 'KWD').amount).toBe(3.704);
});

it('a declared currency column carrying `scale` is reported too (the form-view `subforms[].columns` path the spec does not judge)', () => {
it('a declared currency column carrying `scale` is reported too (a stored or code-built form view reaches the render without passing either validator)', () => {
const warnings = spyWarn();
hydrateColumns([{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 2 }], lineSchema('line_declared'));
const reports = warnings().filter((m) => m.includes('`scale`'));
Expand Down
22 changes: 15 additions & 7 deletions packages/plugin-form/src/deriveMasterDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,22 @@ export function deriveColumns(
* docblock says the reach stops there: an identity-only entry
* (`{ name: 'amount', scale: 2 }`) takes its type from the child field at
* render time, here, so it still parses. `objectui validate` cannot see it
* either: a subform's `columns` is `z.array(z.any())` in the `object-form`
* mirror, and the child object's fields are not in the document it judges.
* This is the first place the child field is known, so the report is made
* here — ⛔ never a silent drop.
* either: the `object-form` mirror judges a subform's `columns` with the spec's
* own `InlineGridColumnSchema` (objectui#11266), and the child object's fields
* are not in the document it judges. Only `defineStack` refuses it, at publish, by resolving
* `name` against the child object's fields: on a relationship field's
* `inlineColumns` and on a form view's `subforms[].columns`, and only when the
* stack declares that child object (objectstack-ai/objectstack#20927). Nothing
* in objectui does that, and this is the first place in objectui the child
* field is known, so the report is made here — ⛔ never a silent drop.
*
* The declared arm is reported too. A form view's `subforms[].columns` is
* `z.array(z.any())` in the spec's `FormViewSchema`, so a typed currency column
* carrying `scale` reaches this function unjudged on that path.
* The declared arm is reported too. On a form view's `subforms[].columns` both
* validators refuse a typed currency column carrying `scale`: the spec's
* `FormViewSchema` holds each column to `InlineGridColumnSchema` from 17.6.0,
* and the `object-form` mirror takes that schema by reference (objectui#11266).
* Neither runs between a stored or code-built form view and this function, so
* such a column can still arrive here, and it is reported rather than read in
* silence.
*
* Once per column per page load (the `sectionFields.ts` convention): this runs
* on every child-schema resolve. The first sentence is the spec's refusal with
Expand Down
7 changes: 5 additions & 2 deletions packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ const schema: ObjectViewSchema = {
{
childObject: 'order_items',
title: 'Line items',
columns: ['product', 'quantity', 'price'],
columns: [{ name: 'product' }, { name: 'quantity' }, { name: 'price' }],
},
],
},
Expand All @@ -706,7 +706,10 @@ const schema: ObjectViewSchema = {

Only `childObject` is required — the relationship field and the grid columns are
derived from the child object's metadata unless you override them
(`relationshipField`, `columns`).
(`relationshipField`, `columns`). A column is `@objectstack/spec`'s
`InlineGridColumn`: an object keyed by `name`, never a bare field name. One that
declares no `type` takes its label, type and the rest from the child field;
an undeclared column key is refused by the validator.

### View tabs

Expand Down
12 changes: 11 additions & 1 deletion packages/types/src/__tests__/imported-defaults-8317.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ import {
checkDashboardWidgetStageOrder,
checkDashboardWidgetMetricMeasureArity,
} from '@objectstack/spec/ui';
import { FieldSchema as SpecFieldSchema, SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data';
import {
FieldSchema as SpecFieldSchema,
SelectOptionSchema as SpecSelectOptionSchema,
// objectui#11266 — one `ObjectFormSchema.subforms[].columns` entry.
InlineGridColumnSchema as SpecInlineGridColumnSchema,
} from '@objectstack/spec/data';
import {
EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema,
EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema,
Expand Down Expand Up @@ -372,6 +377,11 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
// spec's `.default(false)` —
// exactly what this boundary exists to keep out of a parse output.
['FieldSchema', SpecFieldSchema],
// objectui#11266: one `ObjectFormSchema.subforms[].columns` entry is the spec's
// inline grid column, crossed through this boundary. It carries no default and
// reaches no `z.lazy`, so the strip is the identity function: the row is here
// because the census below requires every imported symbol to be measured.
['InlineGridColumnSchema', SpecInlineGridColumnSchema],
] as const;

/** The subset that actually carries an imported default — where the strip does work. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11266 — one `ObjectFormSchema.subforms[].columns` entry is the spec's
* `InlineGridColumnSchema`, by reference.
*
* `@objectstack/spec` 17.6.0 judges `FormViewSchema.subforms[].columns` with its
* closed inline grid column schema (objectstack#20927). The mirror held
* `z.array(z.any())` there, so `objectui validate` accepted a column with an
* undeclared key, and a typed `currency` column carrying `scale`, both of which
* `os validate` refuses. Two authoring doors gave two verdicts on one document.
*
* An authored document reaches `subforms` through the object-view `form` slot:
* `subforms` is a form-VIEW member, which the `object-form` row refuses in its
* `properties` bag (objectui#10859 batch 4), and the slot is this mirror minus
* its identity keys. So the face rows below author it there. The mirror rows
* parse the flat node as `ObjectForm` reads it.
*
* ## What is pinned
*
* - BY REFERENCE: a column's schema IS the spec schema as it crosses the
* import boundary, so a faithful hand copy turns this red. The TypeScript
* face IS the spec's `InlineGridColumn` (judged by `tsc -p tsconfig.test.json`).
* - THE VERDICTS, on the tolerant face (`safeValidateSchema`, which
* `objectui validate` runs) and on the strict authoring face. An undeclared
* column key is refused at the column, with the key named. A `scale` on a
* column that DECLARES `type: 'currency'` is refused at that `scale`. A bare
* field-name string is refused at the column. A column `{ name }` is
* accepted: the lit control.
* - ONE VERDICT ACROSS THE TWO DOORS: each probe column gets the verdict the
* spec's own `FormViewSchema` gives it, read live in the same run.
*
* ⛔ Not refused here, and pinned as ACCEPTED so that reaching for it is a
* deliberate change: a `scale` on an identity-only column (`{ name, scale }`)
* whose child field is a currency. Refusing it takes the child object's fields,
* which are not in the document this mirror judges. The spec's column schema
* accepts it too; `defineStack` refuses it at publish, by resolving `name`
* through `childObject`, and `plugin-form`'s `hydrateColumns` reports it at
* render (objectui#10783).
*/

import { describe, it, expect } from 'vitest';
import {
InlineGridColumnSchema as SpecInlineGridColumnSchema,
type InlineGridColumn as SpecInlineGridColumn,
} from '@objectstack/spec/data';
import { FormViewSchema as SpecFormViewSchema } from '@objectstack/spec/ui';
import type { ObjectFormSchema as DeclaredObjectFormSchema } from '../objectql';
import { ObjectFormSchema as ObjectFormMirror } from '../zod/objectql.zod.js';
import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js';
import { stripImportedDefaults } from '../zod/imported-defaults.js';

/* ── Type level ───────────────────────────────────────────────────────────── */

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type DeclaredSubform = NonNullable<DeclaredObjectFormSchema['subforms']>[number];
type DeclaredColumn = NonNullable<DeclaredSubform['columns']>[number];

// By reference: a declared column IS the spec's authoring type. A local
// restatement, or the `any` this replaced, turns this red.
export type _ColumnIsTheSpecInput = Expect<Equal<DeclaredColumn, SpecInlineGridColumn>>;

export const identityOnlyColumnIsAuthorable: DeclaredSubform = {
childObject: 'order_line',
columns: [{ name: 'quantity' }, { name: 'amount', type: 'currency', label: 'Amount' }],
};
export const stringColumnIsRefused: DeclaredSubform = {
childObject: 'order_line',
// @ts-expect-error a column is an object keyed by `name`, never a bare field-name string
columns: ['quantity'],
};

/* ── Runtime ──────────────────────────────────────────────────────────────── */

const CHILD = 'order_line';

/** An authored object-view whose form declares one subform with one column. */
const viewWith = (column: unknown) => ({
type: 'object-view',
objectName: 'order',
form: { subforms: [{ childObject: CHILD, columns: [column] }] },
});
/** The flat `object-form` node as `ObjectForm` reads it, with the same subform. */
const flatWith = (column: unknown) => ({
type: 'object-form',
objectName: 'order',
mode: 'create',
subforms: [{ childObject: CHILD, columns: [column] }],
});
/** The spec's form view with the same subform: the other door's verdict. */
const specFormViewWith = (column: unknown) => ({
type: 'simple',
subforms: [{ childObject: CHILD, columns: [column] }],
});

const VALID = { name: 'quantity' };
const BOGUS_KEY = { name: 'quantity', bogusKey: 1 };
const TYPED_CURRENCY_WITH_SCALE = { name: 'amount', type: 'currency', scale: 2 };
const IDENTITY_ONLY_WITH_SCALE = { name: 'amount', scale: 2 };
const BARE_STRING = 'quantity';

/** The column's own position on the object-view face. */
const VIEW_COLUMN = ['form', 'subforms', 0, 'columns', 0];
/** …and on the flat mirror. */
const FLAT_COLUMN = ['subforms', 0, 'columns', 0];

type Face = { name: string; parse: (doc: unknown) => ReturnType<typeof safeValidateSchema> };
const FACES: ReadonlyArray<Face> = [
{ name: 'tolerant (`safeValidateSchema`, which `objectui validate` runs)', parse: (doc) => safeValidateSchema(doc) },
{ name: 'strict authoring face', parse: (doc) => StrictAnyComponentSchema.safeParse(doc) as ReturnType<typeof safeValidateSchema> },
];

/** One column's schema on the mirror, unwrapped from `subforms`' and `columns`' `optional` / `array`. */
const mirrorColumnSchema = () => ObjectFormMirror.shape.subforms.unwrap().element.shape.columns.unwrap().element;

describe('objectui#11266 — `subforms[].columns` is the spec\'s `InlineGridColumnSchema`, by reference', () => {
it('a column\'s schema IS the spec schema as it crosses the import boundary', () => {
expect(mirrorColumnSchema()).toBe(stripImportedDefaults(SpecInlineGridColumnSchema));
// The crossing is the identity here (the column schema carries no default),
// so the member is the spec's own object.
expect(mirrorColumnSchema()).toBe(SpecInlineGridColumnSchema);
});

describe.each(FACES)('on the $name', ({ parse }) => {
it('CONTROL: a column `{ name }` is accepted', () => {
const parsed = parse(viewWith(VALID));
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});

it('an undeclared column key is refused at the column, with the key named', () => {
const parsed = parse(viewWith(BOGUS_KEY));
expect(parsed.success).toBe(false);
expect(parsed.error!.issues).toHaveLength(1);
const [issue] = parsed.error!.issues;
expect(issue.code).toBe('unrecognized_keys');
expect(issue.path).toEqual(VIEW_COLUMN);
expect((issue as { keys?: string[] }).keys).toEqual(['bogusKey']);
});

it('a `scale` on a column that declares `type: \'currency\'` is refused at that `scale`', () => {
const parsed = parse(viewWith(TYPED_CURRENCY_WITH_SCALE));
expect(parsed.success).toBe(false);
expect(parsed.error!.issues).toHaveLength(1);
const [issue] = parsed.error!.issues;
expect(issue.code).toBe('custom');
expect(issue.path).toEqual([...VIEW_COLUMN, 'scale']);
// The refusal is the spec's own, sentence included: the same column
// judged by the spec's schema in this run yields the same message.
const spec = SpecInlineGridColumnSchema.safeParse(TYPED_CURRENCY_WITH_SCALE);
expect(spec.success).toBe(false);
expect(issue.message).toBe(spec.error!.issues[0].message);
});

it('a bare field-name string is refused at the column', () => {
const parsed = parse(viewWith(BARE_STRING));
expect(parsed.success).toBe(false);
expect(parsed.error!.issues).toHaveLength(1);
const [issue] = parsed.error!.issues;
expect(issue.code).toBe('invalid_type');
expect(issue.path).toEqual(VIEW_COLUMN);
});

it('⛔ an identity-only column with `scale` is accepted: the currency is the child field\'s, which this document does not carry', () => {
const parsed = parse(viewWith(IDENTITY_ONLY_WITH_SCALE));
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});
});

it('the flat mirror gives the same verdicts at the same column', () => {
expect(ObjectFormMirror.safeParse(flatWith(VALID)).success).toBe(true);

const bogus = ObjectFormMirror.safeParse(flatWith(BOGUS_KEY));
expect(bogus.success).toBe(false);
expect(bogus.error!.issues.map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', FLAT_COLUMN]]);

const currency = ObjectFormMirror.safeParse(flatWith(TYPED_CURRENCY_WITH_SCALE));
expect(currency.success).toBe(false);
expect(currency.error!.issues.map((i) => [i.code, i.path])).toEqual([['custom', [...FLAT_COLUMN, 'scale']]]);
});

it.each([
['a column `{ name }`', VALID, true],
['an undeclared column key', BOGUS_KEY, false],
['a typed currency column with `scale`', TYPED_CURRENCY_WITH_SCALE, false],
['an identity-only column with `scale`', IDENTITY_ONLY_WITH_SCALE, true],
['a bare field-name string', BARE_STRING, false],
] as const)('one verdict across the two doors: %s', (_label, column, expected) => {
// The other door, read live: the spec's form view judging the same column.
const spec = SpecFormViewSchema.safeParse(specFormViewWith(column));
expect(spec.success).toBe(expected);
expect(safeValidateSchema(viewWith(column)).success).toBe(spec.success);
});
});
Loading
Loading