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
2 changes: 2 additions & 0 deletions .changeset/11276-object-grid-properties-bag.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ Nothing changes at render time: `SchemaRenderer` hoists every `properties` key o
**The same release's changesets** that describe what the validators say about an `object-grid` written flat (`.changeset/7762-object-grid-export-options-bare-array-refusal.md`, `.changeset/9739-object-grid-operators-tombstone.md`, `.changeset/11068-grid-declared-keys.md`, `.changeset/11068-object-grid-show-filters.md`, `.changeset/11117-datasource-objectname-waiver.md`, `.changeset/10872-flat-arm-responsive-styles.md` and others) describe the flat mirror, which still behaves as they say. On an authored node the props now live in the bag, where the spec row judges them as listed above; the retirements, the record-source rule and `responsiveStyles` hold there as they say.

**What did not move.** The TypeScript `ObjectGridSchema` and its zod mirror `ObjectGridSchema` stay published and unchanged in shape. They are the node as `ObjectGrid` reads it after the hoist, and as code composes it, and the `object-view` `table` slot is still built from the mirror.

**Correction, 2026-10-02 (objectui#6152, round 6).** The list above of keys "the row declares and the flat mirror does not" is no longer true for ten of them: the flat `ObjectGridSchema` mirror now declares `rowHeight`, `grouping`, `aggregations`, `conditionalFormatting`, `rowColor`, `bulkActionDefs`, `navigation`, `singleClickEdit`, `reorderableColumns` and `operations`, each typed as its TypeScript twin types it (`.changeset/6152-objectgrid-round6-mirrored.md`). `resizableColumns` is still the row's alone. The bag is unchanged: the spec row still judges these keys inside `properties`.
20 changes: 20 additions & 0 deletions .changeset/6152-objectgrid-round6-mirrored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/types': minor
---

feat(types): `ObjectGridSchema`'s zod mirror declares ten members its TypeScript twin declares (objectui#6152, round 6)

**Breaking for an invalid document, hence `minor`:** a wrongly typed value for one of these keys inside an `object-view`'s `table` is now refused by `safeValidateSchema` / `validateSchema`, where it used to be kept unexamined.

**What changed.** The flat `ObjectGridSchema` mirror in `@object-ui/types/zod` now declares `aggregations`, `bulkActionDefs`, `conditionalFormatting`, `grouping`, `navigation`, `operations`, `reorderableColumns`, `rowColor`, `rowHeight` and `singleClickEdit`, each typed as the TypeScript `ObjectGridSchema` types it. `ObjectGrid` reads every one of them, and the spec's `object-grid` row declares every one. `grouping`, `navigation`, `rowColor` and `rowHeight` are the spec's `GroupingConfigSchema`, `NavigationConfigSchema`, `RowColorConfigSchema` and `RowHeightSchema` by reference; `reorderableColumns` and `singleClickEdit` are the row's own members. `operations`, `aggregations` and a `bulkActionDefs` entry name an unknown member instead of dropping it.

**Where you see it.** The `object-view` `table` slot is built from this mirror, and `ObjectView` hands nine of these keys to the grid it draws (`navigation` stays refused there, as before):

- the strict authoring face, which `objectui validate` runs, now accepts `table.grouping`, `table.rowHeight` and the other seven. It refused them as unknown keys although the TypeScript slot typed them;
- the tolerant face refuses a wrong value at the key: `table: { rowHeight: 'comfortable' }`, `table: { operations: { creat: false } }`, or a `grouping` field name with surrounding spaces, which the spec's own rule refuses.

`ObjectGridSchema.safeParse` called directly judges the same ten keys.

**What did not move.** An authored `object-grid` node takes its props in the spec's `properties` bag (objectui#11276), and that bag is still the spec row by reference, so a document's `object-grid` node parses exactly as before. `resizableColumns` stays declared on the TypeScript face and is not mirrored. No TypeScript declaration changed shape. The `singleClickEdit` doc comment now says `@default true`, which is what `ObjectGrid` and the spec row do; it said `false`.

**Clause-②: yes** — the strict face widens on the `table` slot, and the tolerant face narrows there for wrongly typed values.
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ const PADDED = [' business_unit ', ' business_unit', 'business_unit '] as cons
type Entry = { field: string };

interface DeclaringArm {
/**
* The component `type` of the arm, when the row's key is not that type: one arm
* can declare `grouping` at more than one path, and each path is its own row.
*/
readonly type?: string;
/** Where the census finds `grouping` on this arm, `*` standing for a record key. */
readonly declaredAt: string;
/** The exported mirror, parsed directly as well as through the union. */
Expand Down Expand Up @@ -119,6 +124,17 @@ const DECLARING: Readonly<Record<string, DeclaringArm>> = {
}),
fieldPath: (i) => ['listViews', 'all', 'grouping', 'fields', i, 'field'],
},
// objectui#6152 round 6: the `object-view` `table` slot is built from the flat
// `ObjectGridSchema` mirror, which now declares `grouping` as the spec's
// `GroupingConfigSchema` by reference, so the slot judges it — the second
// path on that arm, and `ObjectView` hands it to the grid it draws.
'object-view table': {
type: 'object-view',
declaredAt: 'table.grouping',
arm: ObjectViewSchema,
doc: (fields) => ({ type: 'object-view', objectName: 'account', table: { grouping: { fields } } }),
fieldPath: (i) => ['table', 'grouping', 'fields', i, 'field'],
},
};

/**
Expand Down Expand Up @@ -255,10 +271,9 @@ describe('objectui#7347 — census: the table above is every arm that declares `
// skipping it would be a silent hole in the census.
if (at.length) found[componentTypeOf(arm) ?? '?'] = at;
}
const expected = {
...Object.fromEntries(Object.entries(DECLARING).map(([type, spec]) => [type, [spec.declaredAt]])),
...NOT_COVERED,
};
const expected: Record<string, string[]> = Object.fromEntries(Object.entries(NOT_COVERED).map(([type, paths]) => [type, [...paths]]));
for (const [key, spec] of Object.entries(DECLARING)) (expected[spec.type ?? key] ??= []).push(spec.declaredAt);
for (const type of Object.keys(expected)) expected[type] = [...expected[type]].sort();
expect(found).toEqual(expected);
});

Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/__tests__/imported-defaults-8317.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ import {
ObjectMapPropsSchema as SpecObjectMapPropsSchema,
ObjectGanttPropsSchema as SpecObjectGanttPropsSchema,
ObjectGridPropsSchema as SpecObjectGridPropsSchema,
// objectui#6152 round 6 — the spec schemas `ObjectGridSchema`'s mirror reads.
RowColorConfigSchema as SpecRowColorConfigSchema,
RowHeightSchema as SpecRowHeightSchema,
BulkActionDefSchema as SpecBulkActionDefSchema,
BulkActionOperationSchema as SpecBulkActionOperationSchema,
// objectui#10872 — the `ComponentPropsMap` rows the public-block arms read.
PageHeaderProps as SpecPageHeaderProps,
PageTabsProps as SpecPageTabsProps,
Expand Down Expand Up @@ -286,6 +291,13 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
// the row's key set off the crossed copy. This row carries defaults too
// (`data`'s `api` provider), so the boundary returns a rebuilt copy.
['ObjectGridPropsSchema', SpecObjectGridPropsSchema],
// objectui#6152 round 6: the flat `ObjectGridSchema` mirror reads `rowColor` /
// `rowHeight` as the spec schemas its twin names, and one `bulkActionDefs`
// entry's `operation` and `visible` slot, each crossed through this boundary.
['RowColorConfigSchema', SpecRowColorConfigSchema],
['RowHeightSchema', SpecRowHeightSchema],
['BulkActionDefSchema', SpecBulkActionDefSchema],
['BulkActionOperationSchema', SpecBulkActionOperationSchema],
// objectui#10872: each ADR-0080 public-block arm's `properties` is the
// block's `ComponentPropsMap` row, crossed through this boundary, so every
// row is measured here like every other crossing (`page:section`,
Expand Down
170 changes: 170 additions & 0 deletions packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* 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#6152 round 6 — the ten `ObjectGridSchema` keys this round MIRRORED.
*
* Each key is read by `ObjectGrid` and is a member of the spec's
* `ComponentPropsMap['object-grid']` row. The flat mirror is the node `ObjectGrid`
* reads after `SchemaRenderer` hoists the authored `properties` bag (objectui#11276),
* and it is the source of the `object-view` `table` slot. So the move is visible on
* two doors:
*
* - the mirror itself judges each value instead of keeping it unexamined
* (`BaseSchema` is `.passthrough()`);
* - an `object-view`'s `table` slot, which `ObjectView` relays to the grid it draws,
* now accepts each of the nine slot keys on the strict face (it refused them as
* unknown keys while the TypeScript slot typed them) and refuses a wrongly typed
* value on the tolerant face (it kept one before).
*
* The authored `object-grid` node does not move: its bag is the spec row by
* reference, and a prop written flat on the node stays refused by name.
*
* `resizableColumns`, the eleventh key, is NOT mirrored. Its route is open on the
* card, and `zod-mirror-parity.test.ts` keeps it in `UnmirroredDeclared`.
*/
import { describe, it, expect } from 'vitest';

import {
GroupingConfigSchema as SpecGroupingConfigSchema,
ObjectGridPropsSchema as SpecObjectGridPropsSchema,
} from '@objectstack/spec/ui';
import { ListViewSchema, ObjectGridSchema } from '../zod/objectql.zod.js';
import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js';

type Issue = { code: string; path: PropertyKey[] };
type Parsed = { success: boolean; error?: { issues: readonly Issue[] } };
type Parse = (doc: unknown) => Parsed;

const codeAndPath = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path })));

/** Each mirrored key, a value the twin declares, and a value it does not. */
const MIRRORED: ReadonlyArray<{ key: string; valid: unknown; invalid: unknown; slot: boolean }> = [
{ key: 'aggregations', valid: [{ field: 'amount', type: 'count_distinct' }], invalid: [{ field: 'amount', type: 'median' }], slot: true },
{
key: 'bulkActionDefs',
valid: [{ name: 'close', operation: 'update', patch: { status: 'closed' }, params: [{ name: 'note', type: 'textarea' }] }],
invalid: [{ name: 'close', operation: 'archive' }],
slot: true,
},
{
key: 'conditionalFormatting',
valid: [{ field: 'status', operator: 'equals', value: 'open', backgroundColor: '#fee2e2' }, { condition: "record.status == 'late'", style: { color: 'red' } }],
invalid: [{ field: 'status', operator: 'matches', value: 'open' }],
slot: true,
},
{ key: 'grouping', valid: { fields: [{ field: 'region' }] }, invalid: { fields: [] }, slot: true },
{ key: 'navigation', valid: { mode: 'drawer' }, invalid: { mode: 'sideways' }, slot: false },
{ key: 'operations', valid: { create: false, export: false }, invalid: { creat: false }, slot: true },
{ key: 'reorderableColumns', valid: true, invalid: 'yes', slot: true },
{ key: 'rowColor', valid: { field: 'status', colors: { open: 'bg-green-200' } }, invalid: 42, slot: true },
{ key: 'rowHeight', valid: 'extra_tall', invalid: 'comfortable', slot: true },
{ key: 'singleClickEdit', valid: false, invalid: 'no', slot: true },
];

const GRID = { type: 'object-grid', objectName: 'task' };
const VIEW = { type: 'object-view', objectName: 'task' };

const FACES: ReadonlyArray<readonly [string, Parse]> = [
['the tolerant face', (d) => AnyComponentSchema.safeParse(d)],
['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)],
];

describe('objectui#6152 round 6 — the flat `ObjectGridSchema` mirror judges ten members it never declared', () => {
it('CONTROL: the minimal node parses, so a refusal below is the key\'s', () => {
expect(ObjectGridSchema.safeParse(GRID).success).toBe(true);
});

it.each(MIRRORED)('`$key` is a member: a declared value parses, a wrong one is refused at the key', ({ key, valid, invalid }) => {
expect(key in ObjectGridSchema.shape).toBe(true);
const ok = ObjectGridSchema.safeParse({ ...GRID, [key]: valid });
expect(ok.success, JSON.stringify(codeAndPath(ok))).toBe(true);
const refused = codeAndPath(ObjectGridSchema.safeParse({ ...GRID, [key]: invalid }));
expect(refused.length).toBeGreaterThan(0);
expect(refused.every((i) => i.path[0] === key), JSON.stringify(refused)).toBe(true);
});

it('`resizableColumns` is NOT mirrored: its route is open, and the parity ledger keeps it', () => {
expect('resizableColumns' in ObjectGridSchema.shape).toBe(false);
});

it('the members the twin takes from the spec by name are the spec\'s, by reference', () => {
// The row's own boolean members carry no default, so the import boundary hands
// them back unchanged.
expect(ObjectGridSchema.shape.reorderableColumns).toBe(SpecObjectGridPropsSchema.shape.reorderableColumns);
expect(ObjectGridSchema.shape.singleClickEdit).toBe(SpecObjectGridPropsSchema.shape.singleClickEdit);
// `GroupingConfigSchema` carries a default (`order`), so the boundary rebuilds
// it; what travels is the spec's own rule — a padded field name is refused.
expect(codeAndPath(SpecGroupingConfigSchema.safeParse({ fields: [{ field: ' region ' }] }))).toEqual([
{ code: 'custom', path: ['fields', 0, 'field'] },
]);
expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, grouping: { fields: [{ field: ' region ' }] } }))).toEqual([
{ code: 'custom', path: ['grouping', 'fields', 0, 'field'] },
]);
});

it('`conditionalFormatting` is one rule declaration, shared with the list view\'s mirror', () => {
expect(ObjectGridSchema.shape.conditionalFormatting.unwrap().element)
.toBe(ListViewSchema.shape.conditionalFormatting.unwrap().element);
});

it('`operations`, `aggregations` and a `bulkActionDefs` entry name an unknown member', () => {
expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, operations: { creat: false } })))
.toEqual([{ code: 'unrecognized_keys', path: ['operations'] }]);
expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, aggregations: [{ field: 'amount', type: 'sum', label: 'Total' }] })))
.toEqual([{ code: 'unrecognized_keys', path: ['aggregations', 0] }]);
expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, bulkActionDefs: [{ name: 'close', operation: 'update', colour: 'red' }] })))
.toEqual([{ code: 'unrecognized_keys', path: ['bulkActionDefs', 0] }]);
});

it('a `bulkActionDefs` param keeps widget configuration, as its twin\'s catch-all declares', () => {
// `BulkActionParam` forwards extra keys to the field widget as-is, so the mirror
// entry is loose. (The strict face closes every undeclared key at every depth by
// its own construction; that is its rule, not this member's.)
const r = ObjectGridSchema.safeParse({ ...GRID, bulkActionDefs: [{ name: 'close', operation: 'update', params: [{ name: 'note', type: 'textarea', rows: 3 }] }] });
expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true);
});

it('a `bulkActionDefs` entry takes both predicate spellings its twin declares for `visible`', () => {
for (const visible of ["record.status == 'open'", { source: "record.status == 'open'" }, { dialect: 'cel', source: 'true' }]) {
const r = ObjectGridSchema.safeParse({ ...GRID, bulkActionDefs: [{ name: 'close', operation: 'custom', visible }] });
expect(r.success, JSON.stringify(visible)).toBe(true);
}
});
});

describe('objectui#6152 round 6 — the `object-view` `table` slot follows the mirror', () => {
const SLOT = MIRRORED.filter((m) => m.slot);

describe.each(FACES)('%s', (_face, parse) => {
it('CONTROL: a view with a plain table parses', () => {
expect(parse({ ...VIEW, table: { columns: ['subject'] } }).success).toBe(true);
});

it.each(SLOT)('`table.$key`: a declared value parses, a wrong one is refused inside the slot', ({ key, valid, invalid }) => {
const ok = parse({ ...VIEW, table: { [key]: valid } });
expect(ok.success, JSON.stringify(codeAndPath(ok))).toBe(true);
const refused = codeAndPath(parse({ ...VIEW, table: { [key]: invalid } }));
expect(refused.length).toBeGreaterThan(0);
expect(refused.every((i) => i.path[0] === 'table' && i.path[1] === key), JSON.stringify(refused)).toBe(true);
});

it('`table.navigation` and `table.resizableColumns` stay withheld, refused by name', () => {
for (const [key, value] of [['navigation', { mode: 'drawer' }], ['resizableColumns', false]] as const) {
expect(codeAndPath(parse({ ...VIEW, table: { [key]: value } }))).toEqual([{ code: 'invalid_type', path: ['table', key] }]);
}
});
});
});

describe('objectui#6152 round 6 — the authored `object-grid` node does not move', () => {
it.each(MIRRORED)('`$key` written flat on the node is still refused by name, and parses in the bag', ({ key, valid }) => {
const flat = codeAndPath(safeValidateSchema({ type: 'object-grid', properties: { objectName: 'task' }, [key]: valid }));
expect(flat).toEqual([{ code: 'invalid_type', path: [key] }]);
const bag = safeValidateSchema({ type: 'object-grid', properties: { objectName: 'task', [key]: valid } });
expect(bag.success, JSON.stringify(codeAndPath(bag))).toBe(true);
});
});
Loading
Loading