Skip to content
Merged
29 changes: 29 additions & 0 deletions .changeset/12670-list-view-page-face.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/types': minor
---

A page `list-view` node is judged in its `properties` bag, in `@objectstack/spec`'s list view spelling, and the retired `objectName` / `viewType` are refused by name (objectui#12670; ruling C on objectstack-ai/objectstack#22444).

```json
{
"type": "list-view",
"properties": {
"data": { "provider": "object", "object": "deal" },
"type": "kanban",
"kanban": { "groupByField": "stage" }
}
}
```

- **zod (`@object-ui/types/zod`).** The `list-view` arm of `AnyComponentSchema` is the new `ListViewBlockSchema`, so `safeValidateSchema`, `StrictAnyComponentSchema` and `objectui validate` judge a page node by its bag. The bag's members are `ListViewSchema`'s own, by reference (the spec has no `ComponentPropsMap['list-view']` row), with the list kind as the bag's `type`, the spec's view-kind enum. The record source is `properties.data` or the node's `dataSource.object`; with neither, the node is refused with `RECORD_SOURCE_REQUIRED` at `properties.data`. The spec's `checkListViewCalendarVisualization` and `checkListViewChartBinding` run in the bag, so a `chart` list with no dataset binding is refused at `properties.chart`, as the spec's list view refuses it.
- `ListViewBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`, the public block union. `ListViewSchema` leaves `ObjectQLComponentSchema`, so `ObjectQLComponentSchema.safeParse` of a `list-view` node now fails on the discriminator.
- **TypeScript.** `ListViewBlockNode` is exported: the authored page node, its bag closed. `ListViewSchema` (zod and TypeScript) is unchanged and stays the `ListView` component's props, `objectName` / `viewType` included; it is no longer an arm of the zod `ObjectQLComponentSchema` union.

BREAKING (`@object-ui/types`), for a page `list-view` node in the old spelling:

- `objectName`, flat or in `properties`, is refused by name. Write `properties.data: { "provider": "object", "object": "…" }`, or bind the object with the node's `dataSource.object`.
- `viewType`, flat or in `properties`, is refused by name. Write `properties.type`.
- Any other list view prop written flat on the node (`columns`, `filter`, `kanban`, …) is refused by name. Move it into `properties`, as `@objectstack/spec`'s own page component already requires.
- Code that parses with `ObjectQLComponentSchema` directly no longer accepts any `list-view` node. Parse a page node with `AnyComponentSchema`, whose `list-view` arm is `ListViewBlockSchema`, and the `ListView` component's props with `ListViewSchema`, which is still exported.

The ruling carries stored page nodes in the old spelling by an ADR-0087 conversion in `@objectstack/spec` (objectstack-ai/objectstack#22444), never by a fallback read in objectui. (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.)
10 changes: 7 additions & 3 deletions packages/plugin-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,6 @@ list it is. On a metadata page both sit in the node's `properties`. The node's
own `type` stays `list-view`, because it is the key the page renderer looks the
component up by:

<!-- doc-json: fragment — the spec's page spelling of a list view, which `objectui validate` does not judge yet: its `list-view` face still reads `objectName` and has no `properties` member (objectui#12099) -->
```json
{
"type": "list-view",
Expand Down Expand Up @@ -384,8 +383,13 @@ the old spelling is the platform's job (an ADR-0087 conversion in
React props (the `ListViewSchema` examples above) are a separate surface and
still spell the two `objectName` and `viewType`.

`objectui validate` does not judge the page spelling yet: its `list-view` face
still reads `objectName` and counts no `data` as a record source.
`objectui validate` judges a page `list-view` node in this spelling. Its record
source is `properties.data` or a bound `dataSource.object`, and a node with
neither is refused at `properties.data`. `objectName` and `viewType` are refused
by name, on the node or in `properties`, each naming what to write instead
(`properties.data`, `properties.type`). A `properties.type` that names no kind a
list view draws is refused, and so is a list view prop such as `columns` written
on the node itself rather than in `properties`.

## Page binding — `dataSource` (referencing a saved view by name)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,20 @@ const CANONICAL_VIEW = {
calendar: { startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'name' },
} as const;

const issuesAt = (doc: unknown, suffix: string) => {
const result = safeValidateSchema(doc);
/**
* The read door judges the AUTHORED page node (objectui#12670, ruling C on
* objectstack-ai/objectstack#22444): its props in the `properties` bag, its
* object named in `data`, its kind as the bag's `type`. The fixtures above are
* the `ListView` component's props, which half 1 renders; half 2 hands the
* door the same list view in the page spelling.
*/
const asPageNode = ({ type, objectName, viewType, ...props }: Record<string, unknown>) => ({
type,
properties: { data: { provider: 'object', object: objectName }, type: viewType, ...props },
});

const issuesAt = (doc: Record<string, unknown>, suffix: string) => {
const result = safeValidateSchema(asPageNode(doc));
if (result.success) return [];
return result.error.issues.filter((i) => i.path.join('.').endsWith(suffix));
};
Expand Down Expand Up @@ -208,7 +220,7 @@ describe('objectui#8355 · half 2 — the retired spellings are REFUSED at the r
});

it('DARK CONTROL: the canonical fixture parses GREEN through the same door', () => {
const result = safeValidateSchema(CANONICAL_VIEW);
const result = safeValidateSchema(asPageNode(CANONICAL_VIEW));
expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,30 @@ const DISTINGUISHING_VIEW = {
kanban: { groupByField: CANONICAL },
} as const;

/**
* The read door judges the AUTHORED page node (objectui#12670, ruling C on
* objectstack-ai/objectstack#22444): its props in the `properties` bag, its
* object named in `data`, its kind as the bag's `type`. The fixture above is
* the `ListView` component's props, which the lane half renders; the refusal
* half hands the door the same list view in the page spelling, and reads each
* issue path relative to the bag.
*/
const asPageNode = ({ type, objectName, viewType, ...props }: Record<string, unknown>) => ({
type,
properties: {
data: { provider: 'object', object: objectName },
...(viewType === undefined ? {} : { type: viewType }),
...props,
},
});
const readDoor = (doc: Record<string, unknown>) => safeValidateSchema(asPageNode(doc));
const bagPath = (path: readonly PropertyKey[]) => (path[0] === 'properties' ? path.slice(1) : path).join('.');

/** Issues the read door raises at `kanban.groupBy`, if any. */
const refusalIssuesFor = (doc: unknown) => {
const result = safeValidateSchema(doc);
const refusalIssuesFor = (doc: Record<string, unknown>) => {
const result = readDoor(doc);
if (result.success) return [];
return result.error.issues.filter((i) => i.path.join('.').endsWith('kanban.groupBy'));
return result.error.issues.filter((i) => bagPath(i.path).endsWith('kanban.groupBy'));
};

beforeEach(() => {
Expand Down Expand Up @@ -231,7 +250,7 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door'
// nests the declared slot's own block, so the arm is a member there too:
// `invalid_type`, the same code and ONE message in both nestings.
expect(issues[0].code).toBe('invalid_type');
expect(issues[0].path.join('.')).toBe('options.kanban.groupBy');
expect(bagPath(issues[0].path)).toBe('options.kanban.groupBy');
// The lead sentence is the one the protocol's own `strictObject({ aliases })`
// answers with, so an author meets ONE remedy on both faces.
expect(issues[0].message).toContain('Unrecognized key(s) on this kanban configuration: `groupBy`.');
Expand All @@ -248,15 +267,15 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door'
// `aliasKeyRefusal` is a `z.never()` ARM here, so the envelope is
// `invalid_type` at the key's own path.
expect(declared[0].code).toBe('invalid_type');
expect(declared[0].path.join('.')).toBe('kanban.groupBy');
expect(bagPath(declared[0].path)).toBe('kanban.groupBy');
// ⭐ ONE STRING, BOTH NESTINGS — the assertion that keeps the check and the
// arm from drifting into two dialects of one remedy.
const legacy = refusalIssuesFor(DISTINGUISHING_VIEW);
expect(declared[0].message).toBe(legacy[0].message);
});

it('DARK CONTROL: the canonical config alone parses GREEN through the same door', () => {
const result = safeValidateSchema({
const result = readDoor({
type: 'list-view',
objectName: OBJECT,
kanban: { groupByField: CANONICAL },
Expand All @@ -269,15 +288,15 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door'
// own path with `invalid_type`; a key no arm names is refused at `kanban`
// with the spec slot's `unrecognized_keys` — two codes, so the arm cannot be
// read as a plain strictness refusal.
const result = safeValidateSchema({
const result = readDoor({
type: 'list-view',
objectName: OBJECT,
kanban: { groupByField: CANONICAL, zzzBogusKey: 'refused now' },
});
expect(result.success).toBe(false);
const issue = result.success
? undefined
: (result.error.issues.find((i) => i.path.join('.') === 'kanban') as { code?: string; keys?: string[] } | undefined);
: (result.error.issues.find((i) => bagPath(i.path) === 'kanban') as { code?: string; keys?: string[] } | undefined);
expect(issue?.code).toBe('unrecognized_keys');
expect(issue?.keys).toEqual(['zzzBogusKey']);
});
Expand Down
5 changes: 3 additions & 2 deletions packages/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ restated by hand:
`ObjectQLPublicBlockComponentSchema`, each the arm's own input. Each is also
exported by name: `ObjectGridBlockNode`, `ObjectFormBlockNode`,
`ObjectMapBlockNode`, `ObjectGanttBlockNode`, `ObjectChartBlockNode`,
`ObjectMetricBlockNode`, `ObjectTimelineBlockNode` and
`ObjectMasterDetailFormBlockNode`.
`ObjectMetricBlockNode`, `ObjectTimelineBlockNode`,
`ObjectMasterDetailFormBlockNode`, `ObjectPivotBlockNode`,
`EmbeddableFormBlockNode` and `ListViewBlockNode`.
- `FlexBlockNode`: an authored `flex` node, its layout props and its child
list in the bag. `flex` and `object-chart` have no `ComponentPropsMap` row,
so each bag is the flat type's own members (`FlexSchema`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
ObjectMetricBlockNode,
ObjectPivotBlockNode,
EmbeddableFormBlockNode,
ListViewBlockNode,
ObjectQLPublicBlockNode,
ObjectTimelineBlockNode,
PublicBlockNode,
Expand Down Expand Up @@ -93,6 +94,7 @@ type _EachAliasIsItsArm = Assert<
ObjectGridBlockNode['type'],
ObjectPivotBlockNode['type'],
EmbeddableFormBlockNode['type'],
ListViewBlockNode['type'],
],
[
'object-metric',
Expand All @@ -105,6 +107,7 @@ type _EachAliasIsItsArm = Assert<
'object-grid',
'object-pivot',
'embeddable-form',
'list-view',
]
>
>;
Expand All @@ -118,12 +121,14 @@ type _JoinedIntoAuthoringNode = Assert<
// leaves each bag with `[k: string]: unknown`. `object-pivot` and
// `embeddable-form` (objectui#11440) have no row either: their required bags
// are built from the registration inputs, kept `.passthrough()` the same way.
// The page `list-view` (objectui#12670) has no row either: its bag is the
// `ListViewSchema` mirror's own members, kept `.passthrough()` like `object-chart`'s.
// The control shows that, which is why the derivation reads the bag's shape too.
type _NoAuthoringNodeBagIsOpen = Assert<Equal<OpenBagTypes<AuthoringNode>, never>>;
type _ControlShapeOnlyLeavesTheOwnMemberBagsOpen = Assert<
Equal<
OpenBagTypes<ShapeOnlyArmInput<(typeof ObjectQLPublicBlockComponentSchema)['options'][number] | typeof FlexBlockSchema>>,
'object-chart' | 'flex' | 'object-pivot' | 'embeddable-form'
'object-chart' | 'flex' | 'object-pivot' | 'embeddable-form' | 'list-view'
>
>;

Expand Down Expand Up @@ -176,6 +181,20 @@ describe('objectui#11468: the authored properties-bag carriers have a TypeScript
expect([chart, misspelled]).toHaveLength(2);
});

it('list-view (objectui#12670): the bag is closed, spelled `data` / `type`, and the retired pair is refused in both positions', () => {
const ruled: ListViewBlockNode = {
type: 'list-view',
properties: { data: { provider: 'object', object: 'deal' }, type: 'kanban', kanban: { groupByField: 'stage' } },
};
// @ts-expect-error `objectName` is refused in the bag: the object is named in `data`
const bagObjectName: ListViewBlockNode = { type: 'list-view', properties: { objectName: 'deal' } };
// @ts-expect-error `viewType` is refused flat on the node: the kind is `properties.type`
const flatViewType: ListViewBlockNode = { type: 'list-view', viewType: 'kanban' };
// @ts-expect-error `viewTyp` is not a member of the list-view bag (the mirror's members, closed)
const misspelled: ListViewBlockNode = { type: 'list-view', properties: { viewTyp: 'kanban' } };
expect([ruled, bagObjectName, flatViewType, misspelled]).toHaveLength(4);
});

it('every carrier arm is a zod object, and so are the two mirror bags: the premises the derivation reads', () => {
const arms: z.ZodType[] = [...ObjectQLPublicBlockComponentSchema.options, FlexBlockSchema];
expect(arms.filter((arm) => arm.def.type !== 'object')).toEqual([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ const ROWS: ReadonlyArray<readonly [type: string, mirror: Mirror, required: Reco
['list-view', ListViewMirror as unknown as Mirror, { objectName: 'account' }],
];

/**
* The node `AnyComponentSchema` judges, where it is not the mirror's own node
* (objectui#12670): a page `list-view` is armed by `ListViewBlockSchema`, whose
* props live in the `properties` bag and whose object is named in `data`. The
* mirror rows above stay the `ListView` component's props. That arm refuses
* both channels by name too, with its own string.
*/
const PUBLISHED_REQUIRED: Readonly<Record<string, Record<string, unknown>>> = {
'list-view': { properties: { data: { provider: 'object', object: 'account' } } },
};

const CHANNELS = ['body', 'children'] as const;
const CONTENT = [{ type: 'text', content: 'measured' }];
const issues = (m: Mirror, doc: unknown) => {
Expand Down Expand Up @@ -155,8 +166,12 @@ describe('objectui#9256 re-measure — both content channels are refused where t
});

it.each(CASES)('%s — the refusal reaches the node through `AnyComponentSchema`, not only its own arm', (_label, type, _mirror, key, required) => {
expect(AnyComponentSchema.safeParse({ ...required, type }).success).toBe(true);
expect(AnyComponentSchema.safeParse({ ...required, type, [key]: CONTENT }).success).toBe(false);
const published = PUBLISHED_REQUIRED[type] ?? required;
expect(AnyComponentSchema.safeParse({ ...published, type }).success).toBe(true);
const refused = AnyComponentSchema.safeParse({ ...published, type, [key]: CONTENT });
expect(refused.success).toBe(false);
const issue = refused.error!.issues.find((i) => i.path.join('.') === key);
expect(issue?.message).toContain(`\`${type}\` reads NEITHER content channel`);
});
});

Expand Down
43 changes: 42 additions & 1 deletion packages/types/src/__tests__/flat-props-refusal-10872.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,32 @@ const VALID_BAG: Readonly<Record<string, Record<string, unknown>>> = {
// `description` are the keys a flat node shares with the node base.
'object-pivot': { objectName: 'deal', title: 'Pipeline', rowField: 'stage', columnField: 'owner', valueField: 'amount' },
'embeddable-form': { formId: 'contact-us', objectName: 'lead', title: 'Contact us', description: 'We reply within a day' },
// objectui#12670: the authored page `list-view` arm spreads this helper over its bag, the
// `ListViewSchema` mirror's own members; `data`, `name` and `description` are the keys a flat
// node shares with the node base. The bag's `type` (the list kind) is left out here: written
// flat it is the node's own `type`, the registry key, so `flattened` below could not carry it.
'list-view': {
data: { provider: 'object', object: 'deal' },
name: 'open_deals',
label: 'Open deals',
description: 'Deals not yet closed',
columns: ['name', 'amount'],
kanban: { groupByField: 'stage' },
},
};
const COVERED = Object.keys(VALID_BAG);

/**
* Bag members an arm refuses flat with its OWN refusal, declared after the spread — the
* helper's "whatever the arm declares AFTER the spread" — rather than the generated one:
* the page `list-view`'s five runtime slots (objectui#12670). `ListView` reads each of them,
* so a flat one is refused as the slot it is, never pointed at a bag member.
*/
const OWN_FLAT_REFUSALS: Readonly<Record<string, readonly string[]>> = {
'list-view': ['onAddRecord', 'onBulkAction', 'onDensityChange', 'onNavigate', 'onPageSizeChange'],
};
const ownFlatRefusal = (type: string, key: string): boolean => (OWN_FLAT_REFUSALS[type] ?? []).includes(key);

/** The arms whose bag is REQUIRED — its registration-required members live in it (objectui#11440). */
const REQUIRES_BAG: ReadonlySet<string> = new Set(['object-pivot', 'embeddable-form']);

Expand Down Expand Up @@ -219,7 +242,9 @@ describe('objectui#10872 batch 10 — the population is read off the unions', ()

describe('objectui#10872 batch 10 — each row member written flat is refused by name, on both faces', () => {
const CASES = COVERED.flatMap((type) =>
owedRefusals(Object.keys(rowOf(ARMS.get(type)!).shape)).map((key) => [`${type}.${key}`, type, key] as const),
owedRefusals(Object.keys(rowOf(ARMS.get(type)!).shape))
.filter((key) => !ownFlatRefusal(type, key))
.map((key) => [`${type}.${key}`, type, key] as const),
);

it('the population of row members is not empty (non-vacuity)', () => {
Expand Down Expand Up @@ -326,6 +351,22 @@ describe('objectui#10872 batch 10 — what the refusal leaves alone', () => {
},
);

it.each(Object.entries(OWN_FLAT_REFUSALS).flatMap(([type, keys]) => keys.map((key) => [`${type}.${key}`, type, key] as const)))(
'%s — a bag member the arm refuses flat with its own refusal keeps it, and is not pointed at the bag',
(_label, type, key) => {
const arm = ARMS.get(type)!;
// A member of the bag, so the generated refusal would have applied without the arm's own.
expect(Object.keys(rowOf(arm).shape)).toContain(key);
expect(messageOf(arm.shape[key])).toContain('RUNTIME SLOT');
expect(messageOf(arm.shape[key])).not.toContain(`properties.${key}`);
for (const [face, judge] of FACES) {
const issue = issuesOf(judge({ type, [key]: { action: 'toast' } })).find((i) => i.path.join('.') === key);
expect(issue?.code, face).toBe('custom');
expect(issue?.message, face).toContain('RUNTIME SLOT');
}
},
);

it('an arm\'s own refusal of a row key outranks the generated one', () => {
// `record:alert`'s `body` (objectui#10872 batch 3) says why `children` is no remedy here.
expect(messageOf(ARMS.get('record:alert')!.shape.body)).toContain('objectui#9256');
Expand Down
Loading
Loading