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
5 changes: 5 additions & 0 deletions .changeset/6152-list-view-blocks-strict.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ What did not move: the renderers. `normalizeListViewSchema` still folds the four
canonical keys, and `ListView` still lifts `calendar.defaultView` and spreads the rest of the kanban
and calendar blocks onto the node it builds. A view stored with one of these keys therefore renders as
before; only authored metadata meets the refusal. The legacy `options.KIND` bag is unchanged.

**Note added 2026-10-08 (objectui#6152 round 12):** the legacy `options.KIND` bag is no longer
unchanged. It is now the spec's list-overlay bag by reference, with these four blocks in it, so each
refusal above also applies under `options.KIND`, with the same message; see
`.changeset/6152-list-view-options-bag.md`. The renderers' reads still have not moved.
72 changes: 72 additions & 0 deletions .changeset/6152-list-view-options-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
'@object-ui/types': minor
'@object-ui/app-shell': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-timeline': patch
---

feat(types)!: a list view's legacy `options` bag is the `@objectstack/spec` list overlay's bag by reference, `ListViewTimelineConfig` is the list view's own `timeline` block, and `ListViewGalleryConfig` is retired (objectui#6152, round 12)

Clause-②: no

**Narrowed (breaking), `@object-ui/types`.** `@objectstack/spec`'s authoring list view declares no
`options` bag. Its one home is the flattened list overlay on the view write door
(`VIEW_METADATA_MEMBERS.listOverlay`), where it is a strict object of the eight kinds that name a
block, each judged by its own list-view block with every key optional. `ListViewSchema.options` was
a record of `any` with three named refusals, so `ListViewSchema`, `AnyComponentSchema`,
`safeValidateSchema` (`objectui validate`), `StrictAnyComponentSchema` and the TypeScript
`ListViewSchema['options']` accepted what that door refuses. It is now that member's own bag, taken
by reference, with this package's `kanban`, `calendar`, `gallery` and `timeline` blocks in it, so
each named refusal gives the same message under `options.KIND` as under the top-level `KIND`. What is
refused now, and what to write instead:

- A key that is not one of the eight kinds is refused with the spec's own `unrecognized_keys` at
`options`. `options.grid` gets the spec's guidance: a grid has no per-kind block, so its settings
(`columns`, `sort`, `filter`, …) are top-level keys of the view. Remove it.
- An undeclared key in any kind (for example `options.kanban.swimlaneField`,
`options.timeline.descriptionField`, `options.tree.titleField`, `options.chart.xAxisField`) is
refused with the spec's own `unrecognized_keys` at that kind. Before this change it was kept and
never examined.
- `options.kanban.groupField` is refused by name: write `groupByField`.
- `options.kanban.cardFields` is refused by name: write `columns`.
- `options.gallery.imageField` is refused by name: write `coverField`.
- `options.timeline.dateField` is refused by name: write `startDateField`.
- `options.calendar.defaultView` is refused by name: the initial view mode is a member of the
`object-calendar` element (its flat `defaultView`), not of a list view's calendar block.
- `options.chart`'s legacy axes (`xAxisField`, `yAxisFields`, `categoryField`, `valueField`,
`aggregation`) are refused: write the dataset-bound block, `chart: { dataset, dimensions, values }`.
- A value of the wrong type (for example `options.kanban: 42`, or a number where a field name
belongs) is refused at its path.

The three refusals already in place (`options.kanban.groupBy`, objectui#8365;
`options.calendar.dateField` / `endField`, objectui#8355) keep their messages, and now report
`invalid_type` at the key, as the top-level blocks do, where they reported `custom`. Each kind is
`.partial()`, as the spec's bag is: the renderer reads the bag as a per-key underlay of the top-level
block, so a required member is not asked of it.

**Changed, `@object-ui/types`.** `ListViewTimelineConfig` is `NonNullable<ListViewSchema['timeline']>`:
the spec's list-view slot, strict and `.partial()`, with the legacy `dateField` refused by name
(write `startDateField`). It was the spec's `TimelineConfig` plus `dateField?: string` and a string
index signature of `any`, so a block with any key compiled.

**Retired, `@object-ui/types`.** The `ListViewGalleryConfig` type export is gone. Nothing in this
repository used it, and the spec has no element of that shape. Write the spec's `GalleryConfig`,
which this package re-exports.

**`@object-ui/app-shell`.** The object page's relay writes the spec's spellings into the bag it hands
`ListView`: `options.kanban.columns` where it wrote `cardFields`, no `options.gallery.imageField`
beside `coverField`, and no `options.timeline.descriptionField`. What renders does not change: the
board reads `columns` for its cards, the gallery reads `coverField` first, and nothing drew the
timeline's nested `descriptionField`.

**`@object-ui/plugin-list`.** `ListView`'s capability gate also reads `options.gallery.coverField`, so
a bag binding its cover under the spec's key offers the Gallery view; it read only the legacy
`imageField` there. The README's examples author the top-level per-kind blocks and a dataset-bound
chart, and no longer show the bag.

**`@object-ui/plugin-timeline`.** `ObjectTimeline`'s nested `schema.timeline` prop takes the new
`ListViewTimelineConfig`.

What did not move: the renderers' reads. `ListView` still merges each `options.KIND` under the
top-level block and still reads the legacy spellings, so a view stored before these doors closed
renders as before; only authored metadata meets the refusal.
Original file line number Diff line number Diff line change
Expand Up @@ -91,28 +91,33 @@ describe('galleryViewOptions — the object page forwards, it does not invent (o
expect(out).not.toHaveProperty('coverField');
});

it('CONTROL: a declared `coverField` cross-fills the legacy `imageField`', () => {
// The spec spelling is `coverField`; `ObjectGallery` still consults the
// legacy `imageField`, and `ListView`'s gate reads `imageField` out of this
// bag. Cross-filling a DECLARED name is forwarding, not inventing.
it('CONTROL: a declared `coverField` goes out as `coverField` ALONE (objectui#6152 round 12)', () => {
// The spec spelling is `coverField`. This used to cross-fill the legacy
// `imageField` beside it, because `ListView`'s gate read only `imageField`
// out of this bag. Since objectui#6152 round 12 the typed bag refuses
// `options.gallery.imageField` by name and the gate reads `coverField` too,
// so the cover goes out under the spec key only.
const out = galleryViewOptions({ gallery: { coverField: 'photo' } });
expect(out.coverField).toBe('photo');
expect(out.imageField).toBe('photo');
expect(out).not.toHaveProperty('imageField');
});

it('CONTROL: a declared legacy `imageField` cross-fills `coverField`', () => {
it('CONTROL: a stored legacy `imageField` still answers `coverField`', () => {
// The legacy READ is not this round's to retire: a row stored before the
// view write door judged it can still carry `imageField`, and its cover
// still binds. The block spread forwards the stored key as it was stored;
// nothing here SYNTHESIZES it.
const out = galleryViewOptions({ gallery: { imageField: 'logo' } });
expect(out.imageField).toBe('logo');
expect(out.coverField).toBe('logo');
expect(out.imageField).toBe('logo');
});

it('CONTROL: both spellings declared — each keeps its own value', () => {
// The pre-#7547 precedence, unchanged: `imageField` prefers itself then
// `coverField`, `coverField` prefers itself then `imageField`. Only the
// `'image'` tail was removed, so a view declaring both is unaffected.
it('CONTROL: both spellings stored — `coverField` keeps its own value', () => {
// `coverField` prefers itself, then the legacy spelling; the stored legacy
// key rides the spread unchanged.
const out = galleryViewOptions({ gallery: { imageField: 'logo', coverField: 'photo' } });
expect(out.imageField).toBe('logo');
expect(out.coverField).toBe('photo');
expect(out.imageField).toBe('logo');
});

it('CONTROL: forwards a fully declared block verbatim — every spec key survives', () => {
Expand All @@ -127,9 +132,9 @@ describe('galleryViewOptions — the object page forwards, it does not invent (o
titleField: 'subject',
},
});
expect(out).not.toHaveProperty('imageField');
expect(out).toMatchObject({
coverField: 'photo',
imageField: 'photo',
coverFit: 'contain',
cardSize: 'small',
visibleFields: ['name', 'owner'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@
* through 17.4.0, one since) — which reddens for a new undeclared key, and
* would have reddened for `groupBy`.
*
* ⭐ THE RATCHET REACHED ZERO (objectui#6152 round 12, seat answer Q1 → A). The
* producer writes the card fields as the spec's `columns` now, because
* `@object-ui/types` judges this bag as the spec's list overlay does and refuses
* `options.kanban.cardFields` by name, and `ListView` reads `columns` for the
* cards (the node and the `$select` measured identical under either spelling).
* So the refused set is EMPTY, and the arms below say so; any undeclared key
* the producer emits from here on reddens them.
*
* ⚠️ NO ARM HERE ASSERTS THE `...restKanban` OVERRIDE, deliberately. `ListView`
* spread the rest of the merged kanban config AFTER its own `groupBy:
* laneField`, so a surviving `groupBy` won over the lane it had just resolved.
Expand Down Expand Up @@ -101,11 +109,13 @@ const laneKeysOf = (bag: Record<string, unknown>) =>
LANE_SPELLINGS.filter((k) => k in bag).sort();

/**
* The key the producer emits that the spec refuses and this card keeps. Two
* through `@objectstack/spec` 17.4.0; `titleField` left the set when 17.5.0
* declared it (objectui#11073), which is the ratchet moving the way it may.
* The keys the producer emits that the spec refuses. Two through
* `@objectstack/spec` 17.4.0 (`cardFields`, `titleField`); `titleField` left the
* set when 17.5.0 declared it (objectui#11073), and `cardFields` when the
* producer moved to the spec's `columns` (objectui#6152 round 12). Empty: the
* ratchet may only stay here.
*/
const KNOWN_REFUSED_RESIDUAL = ['cardFields'];
const KNOWN_REFUSED_RESIDUAL: string[] = [];

describe('the instrument can answer both ways on this exact call (objectui#8213)', () => {
it('CONTROL: the strict schema refuses an unknown key BY NAME on the producer output', () => {
Expand Down Expand Up @@ -180,7 +190,7 @@ describe('the RATCHET on what the spec still refuses here (objectui#8213)', () =
// ⭐ THE DURABLE ARM. Not "the emitted bag validates" — it does not, and this
// file's header says why. This is the exact refused SET, so `groupBy` cannot
// come back and a FOURTH undeclared key cannot join quietly.
it('refuses exactly the known residual key, and nothing else', () => {
it('refuses nothing the producer emits: the residual set is empty', () => {
const emitted = kanbanViewOptions({ kanban: { groupByField: 'stage' } }, OBJECT_WITH_STAGE);
expect(refusedKeys(emitted)).toEqual(KNOWN_REFUSED_RESIDUAL);
});
Expand All @@ -192,10 +202,15 @@ describe('the RATCHET on what the spec still refuses here (objectui#8213)', () =
).toEqual(KNOWN_REFUSED_RESIDUAL);
});

it('CONTROL: the residual set is what it says it is, and `groupBy` is not in it', () => {
it('CONTROL: the residual set is what it says it is: empty, so neither `groupBy` nor `cardFields` is in it', () => {
// Guards the ratchet against being satisfied by a residual list that
// quietly grew to include the very key this card retired.
expect(KNOWN_REFUSED_RESIDUAL).not.toContain('groupBy');
expect(KNOWN_REFUSED_RESIDUAL.length).toBe(1);
// quietly grew to include a key this card or objectui#6152 retired.
expect(KNOWN_REFUSED_RESIDUAL).toEqual([]);
});

it('writes the card fields under the spec\'s `columns`, never the retired `cardFields` (objectui#6152 round 12)', () => {
const emitted = kanbanViewOptions({ kanban: { groupByField: 'stage', columns: ['name', 'amount'] } }, OBJECT_WITH_STAGE);
expect(emitted.columns).toEqual(['name', 'amount']);
expect('cardFields' in emitted).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('the object page writes the SPEC lane key (objectui#8193)', () => {
OBJECT_WITH_STAGE,
);
expect(out.titleField).toBe('subject');
expect(out.cardFields).toEqual(['amount']);
expect(out.columns).toEqual(['amount']);
});

it('floors `titleField` at `name`, as the four sibling faces do', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('the object page relays the view\'s `summarizeField` (objectui#11629)',
expect(out.summarizeField).toBe('amount');
// The neighbouring forwards are untouched by the relay.
expect(out.groupByField).toBe('stage');
expect(out.cardFields).toEqual(['name']);
expect(out.columns).toEqual(['name']);
});

it('carries it on the detector path too, where the view names no lane', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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 12 — what the object page writes into the `options` bag
* it hands `ListView` is a bag `@object-ui/types` accepts.
*
* `ListViewSchema.options` became the `@objectstack/spec` list overlay's own
* bag that round: strict, each kind its list-view block with every key
* optional. The census before it found three keys this page's producers wrote
* that the bag refuses (`kanban.cardFields`, `gallery.imageField`,
* `timeline.descriptionField`), and the seat ruled the producer moves first
* (Q1 → A on objectui#6152). So this file RUNS each exported producer on views
* declaring their kind's block in full, and parses the block it returns at the
* bag's own door for that kind. A producer that writes an undeclared key again
* reddens here by name.
*
* ⚠️ The render path parses nothing: the claim is about the contract this page's
* output meets, not about anything that is enforced while it renders.
* ⚠️ `map` and `tree` are built inline in `renderListView`, not by an exported
* producer, so they are not run here; the census read both as spec keys only.
*/

import { describe, it, expect } from 'vitest';
import { ListViewSchema } from '@object-ui/types/zod';
import {
calendarViewOptions,
galleryViewOptions,
ganttViewOptions,
kanbanViewOptions,
timelineViewOptions,
} from './ObjectView';

const OBJECT_WITH_STAGE = { name: 'deal', fields: { name: { type: 'text' }, stage: { type: 'select' } } };

type Issue = { code: string; path: PropertyKey[]; keys?: string[] };
const bag = (ListViewSchema.shape.options as unknown as { unwrap: () => { safeParse: (v: unknown) => { success: boolean; error?: { issues: Issue[] } } } }).unwrap();
const verdictOf = (options: Record<string, unknown>) => {
const r = bag.safeParse(options);
return r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), keys: i.keys }));
};

describe('every block the object page synthesizes parses at the bag\'s door (objectui#6152 round 12)', () => {
it.each([
['kanban, a declared block', { kanban: kanbanViewOptions({ kanban: { groupByField: 'stage', columns: ['name'], titleField: 'name', summarizeField: 'amount' } }, OBJECT_WITH_STAGE) }],
['kanban, the detector path', { kanban: kanbanViewOptions({}, OBJECT_WITH_STAGE) }],
['calendar, a declared block', { calendar: calendarViewOptions({ calendar: { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' } }) }],
['timeline, a declared block', { timeline: timelineViewOptions({ timeline: { startDateField: 'starts_at', endDateField: 'ends_at', groupByField: 'stage', scale: 'month' } }) }],
['timeline, no block', { timeline: timelineViewOptions({}) }],
['gallery, a declared block', { gallery: galleryViewOptions({ gallery: { coverField: 'logo', coverFit: 'contain', cardSize: 'large', visibleFields: ['name'] } }) }],
['gallery, no block', { gallery: galleryViewOptions({}) }],
['gantt, a declared block', { gantt: ganttViewOptions({ gantt: { startDateField: 'starts_at', endDateField: 'ends_at', progressField: 'progress' } }) }],
])('%s', (_label, options) => {
expect(verdictOf(options)).toEqual([]);
});

it('CONTROL: the door still refuses the three keys these producers used to write', () => {
// Without this, a bag that had stopped judging anything would satisfy every
// row above. Each key is refused at its own kind, by name.
expect(verdictOf({ kanban: { groupByField: 'stage', cardFields: ['name'] } })).toContainEqual(
expect.objectContaining({ path: 'kanban.cardFields' }),
);
expect(verdictOf({ gallery: { coverField: 'logo', imageField: 'logo' } })).toContainEqual(
expect.objectContaining({ path: 'gallery.imageField' }),
);
expect(verdictOf({ timeline: { titleField: 'name', descriptionField: 'notes' } })).toContainEqual(
{ code: 'unrecognized_keys', path: 'timeline', keys: ['descriptionField'] },
);
});
});
Loading
Loading