Skip to content

Commit 9d1966c

Browse files
committed
fix(spec): define list-view-options-bag-folded directly above the entry that follows it
The major-18 merge pin wants a conversion added after its rule defined directly above the next entry's definition (listViewSortStringClauseToArray); the bag kinds constant moves with it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT
1 parent d9deed5 commit 9d1966c

1 file changed

Lines changed: 189 additions & 189 deletions

File tree

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 189 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -10711,6 +10711,195 @@ const viewPageMountRemoved: MetadataConversion = {
1071110711
},
1071210712
};
1071310713

10714+
/**
10715+
* The per-kind blocks the legacy list-overlay `options` bag could carry, as
10716+
* the bag declared them when it closed (#25923): every list-view `type` that
10717+
* also names a block on the list-view shape (`grid` names none). Spelled out
10718+
* rather than derived, because this conversion replays a shape that is gone:
10719+
* a kind added later never had a bag spelling to fold.
10720+
*/
10721+
const LIST_VIEW_OPTIONS_BAG_KINDS = ['kanban', 'calendar', 'gantt', 'timeline', 'map', 'gallery', 'chart', 'tree'] as const;
10722+
10723+
/**
10724+
* The flattened list overlay's legacy `options` bag folds onto the top-level
10725+
* per-kind blocks (protocol 18, #25923 — Prime Directive #12, one key per
10726+
* concept; this supersedes ruling direction A on objectui#10380).
10727+
*
10728+
* Until this release the list overlay door accepted `options.KIND` beside the
10729+
* top-level `KIND` block, judged key by key by the kind's own schema with
10730+
* every key optional, and stored it. The bag was declared an UNDERLAY: where
10731+
* both set a key, the top-level block wins. objectui's `ListView` read it that
10732+
* way for kanban, calendar, gallery, timeline, gantt and map
10733+
* (`{ ...options.KIND, ...KIND }`), so this fold is that same merge, written
10734+
* once into the row:
10735+
*
10736+
* - `options.KIND` with no top-level `KIND` ⇒ it becomes `KIND`.
10737+
* - Both present ⇒ `KIND = { ...options.KIND, ...KIND }`, the top-level value
10738+
* kept where both set a key.
10739+
* - `chart` is the one kind the renderer did NOT merge: it read
10740+
* `chart || options.chart`, the block replacing the bag WHOLE. So a bag
10741+
* beside a top-level `chart` block is dropped (it never drew), and a bag
10742+
* with no block becomes the block. Merging it per key would draw keys the
10743+
* author never saw.
10744+
* - A bag left empty is removed.
10745+
*
10746+
* **Retired from the load path** — the door refuses `options` by name with
10747+
* the prescription (`LIST_OVERLAY_OPTIONS_REFUSED` in `ui/view.zod.ts`), so a
10748+
* live author is taught the top-level spelling. The entry exists because a
10749+
* STORED list overlay can carry the bag, and every read of a stored `view` row
10750+
* replays the chain through `applyConversionsToStoredItem` as
10751+
* `{ views: [row] }`; `os migrate meta` lists the edit for sources.
10752+
*
10753+
* **Left as stored, with a TODO** (no lossless rewrite): a bag that is not an
10754+
* object; an `options.grid` (a grid has no block, and the door refused it
10755+
* already); a bag key that is not a kind; an `options.KIND` that is not an
10756+
* object, or whose top-level `KIND` is not one; and any bag on a FORM overlay
10757+
* (`viewKind: 'form'`), whose blocks have no home on a form. The row keeps its
10758+
* bag and is badged invalid until its owner decides. The fold also cannot
10759+
* supply a key the block REQUIRES: the bag was judged with every key
10760+
* optional, so a bag block can fold into a block still missing one (a
10761+
* `gantt` with no `titleField`, say). That row is served badged invalid,
10762+
* and the D3 entry `ui-list-view-options-bag-folded` names the remedy.
10763+
*
10764+
* ⚠️ Scoped to the FLATTENED spelling, as {@link viewOverlayOwnerHiddenRemoved}
10765+
* is: a body with no `config` and no container slot. A view item record
10766+
* (`config` present) has refused the bag since #20051 with its own
10767+
* prescription, and the two authoring shapes never declared it.
10768+
*/
10769+
const listViewOptionsBagFolded: MetadataConversion = {
10770+
id: 'list-view-options-bag-folded',
10771+
toMajor: 18,
10772+
retiredFromLoadPath: true,
10773+
retiredAfter: '18.2.0',
10774+
surface: 'view.options.<kind> — on a flattened list view overlay ({ name, object, viewKind, …, no config })',
10775+
summary:
10776+
"flattened list overlay 'options.KIND' folded onto the top-level 'KIND' block (the top-level "
10777+
+ "value wins per key; for 'chart' the block wins whole), and the bag removed: the per-kind "
10778+
+ 'blocks are top-level keys of the view, their one spelling',
10779+
apply(stack, emit, context) {
10780+
const fold = (view: Dict, path: string): Dict => {
10781+
if (!('options' in view)) return view;
10782+
if (view.config !== undefined) return view;
10783+
if (view.list !== undefined || view.form !== undefined) return view;
10784+
if (view.listViews !== undefined || view.formViews !== undefined) return view;
10785+
const bag = view.options;
10786+
if (view.viewKind === 'form' || !isDict(bag)) {
10787+
context?.reportTodo?.({
10788+
path: `${path}.options`,
10789+
from: JSON.stringify(bag),
10790+
reason: view.viewKind === 'form'
10791+
? 'a form view overlay carries an `options` bag; its per-kind blocks belong to a list view and have no '
10792+
+ 'home on a form. Remove `options`, or save the body as a list view.'
10793+
: 'the `options` bag is not an object, so there is no per-kind block to fold. Remove `options`.',
10794+
});
10795+
return view;
10796+
}
10797+
const next: Dict = { ...view };
10798+
const rest: Dict = { ...bag };
10799+
let changed = false;
10800+
for (const kind of LIST_VIEW_OPTIONS_BAG_KINDS) {
10801+
if (!(kind in rest)) continue;
10802+
const under = rest[kind];
10803+
const top = next[kind];
10804+
if (!isDict(under) || (top !== undefined && !isDict(top))) continue;
10805+
delete rest[kind];
10806+
changed = true;
10807+
if (top === undefined) {
10808+
next[kind] = under;
10809+
emit({ from: `options.${kind}`, to: kind, path: `${path}.options.${kind}` });
10810+
} else if (kind === 'chart') {
10811+
emit({ from: 'options.chart', to: '(removed: the top-level chart block replaces it whole)', path: `${path}.options.chart` });
10812+
} else {
10813+
next[kind] = { ...under, ...top };
10814+
emit({ from: `options.${kind}`, to: `${kind} (top-level keys kept)`, path: `${path}.options.${kind}` });
10815+
}
10816+
}
10817+
if (Object.keys(rest).length === 0) {
10818+
delete next.options;
10819+
if (!changed) emit({ from: 'options', to: '(removed: empty)', path: `${path}.options` });
10820+
return next;
10821+
}
10822+
context?.reportTodo?.({
10823+
path: `${path}.options`,
10824+
from: JSON.stringify(rest),
10825+
reason: `the bag keeps ${Object.keys(rest).map((k) => `\`${k}\``).join(', ')}, which has no top-level block `
10826+
+ 'to fold onto (a grid\'s settings are top-level keys of the view; a non-object block or one beside a '
10827+
+ 'non-object top-level block cannot be merged). Move what you meant to the view\'s own keys, then remove `options`.',
10828+
});
10829+
if (!changed) return view;
10830+
next.options = rest;
10831+
return next;
10832+
};
10833+
return mapCollection(
10834+
mapCollection(stack, 'views', fold),
10835+
ASSEMBLED_VIEW_ITEMS_KEY,
10836+
fold,
10837+
);
10838+
},
10839+
fixture: {
10840+
before: {
10841+
// The assembled-manifest channel: an overlay a package export carried.
10842+
viewItems: [
10843+
{ name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', options: { map: { latitudeField: 'lat', longitudeField: 'lng' } } },
10844+
],
10845+
views: [
10846+
// Per key: the top-level `kanban` keeps `groupByField`, the bag adds
10847+
// `titleField`; the bag's `groupByField` is the one that never drew.
10848+
{
10849+
name: 'crm_deal.board',
10850+
object: 'crm_deal',
10851+
viewKind: 'list',
10852+
type: 'kanban',
10853+
kanban: { groupByField: 'stage' },
10854+
options: { kanban: { groupByField: 'owner', titleField: 'name' }, calendar: { startDateField: 'close_date' } },
10855+
},
10856+
// `chart` is replaced whole by its block, so the bag is dropped.
10857+
{
10858+
name: 'crm_deal.chart',
10859+
object: 'crm_deal',
10860+
viewKind: 'list',
10861+
type: 'chart',
10862+
chart: { dataset: 'deals', values: ['amount'] },
10863+
options: { chart: { dataset: 'old_deals', values: ['amount'], dimensions: ['stage'] } },
10864+
},
10865+
// An empty bag is removed.
10866+
{ name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list', options: {} },
10867+
// A record and a container are not flattened overlays: untouched.
10868+
{ name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } },
10869+
{ object: 'crm_deal', list: { type: 'grid', columns: ['name'] } },
10870+
],
10871+
},
10872+
after: {
10873+
viewItems: [
10874+
{ name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } },
10875+
],
10876+
views: [
10877+
{
10878+
name: 'crm_deal.board',
10879+
object: 'crm_deal',
10880+
viewKind: 'list',
10881+
type: 'kanban',
10882+
kanban: { groupByField: 'stage', titleField: 'name' },
10883+
calendar: { startDateField: 'close_date' },
10884+
},
10885+
{
10886+
name: 'crm_deal.chart',
10887+
object: 'crm_deal',
10888+
viewKind: 'list',
10889+
type: 'chart',
10890+
chart: { dataset: 'deals', values: ['amount'] },
10891+
},
10892+
{ name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list' },
10893+
{ name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } },
10894+
{ object: 'crm_deal', list: { type: 'grid', columns: ['name'] } },
10895+
],
10896+
},
10897+
// One notice per folded block (map, kanban, calendar, chart) plus the
10898+
// empty bag's removal.
10899+
expectedNotices: 5,
10900+
},
10901+
};
10902+
1071410903
/**
1071510904
* [#17053] The bare string `sort` clause on a list-view payload becomes the
1071610905
* `{ field, order }[]` array — one sort orthography platform-wide.
@@ -12329,195 +12518,6 @@ const viewOverlayOwnerHiddenRemoved: MetadataConversion = {
1232912518
},
1233012519
};
1233112520

12332-
/**
12333-
* The per-kind blocks the legacy list-overlay `options` bag could carry, as
12334-
* the bag declared them when it closed (#25923): every list-view `type` that
12335-
* also names a block on the list-view shape (`grid` names none). Spelled out
12336-
* rather than derived, because this conversion replays a shape that is gone:
12337-
* a kind added later never had a bag spelling to fold.
12338-
*/
12339-
const LIST_VIEW_OPTIONS_BAG_KINDS = ['kanban', 'calendar', 'gantt', 'timeline', 'map', 'gallery', 'chart', 'tree'] as const;
12340-
12341-
/**
12342-
* The flattened list overlay's legacy `options` bag folds onto the top-level
12343-
* per-kind blocks (protocol 18, #25923 — Prime Directive #12, one key per
12344-
* concept; this supersedes ruling direction A on objectui#10380).
12345-
*
12346-
* Until this release the list overlay door accepted `options.KIND` beside the
12347-
* top-level `KIND` block, judged key by key by the kind's own schema with
12348-
* every key optional, and stored it. The bag was declared an UNDERLAY: where
12349-
* both set a key, the top-level block wins. objectui's `ListView` read it that
12350-
* way for kanban, calendar, gallery, timeline, gantt and map
12351-
* (`{ ...options.KIND, ...KIND }`), so this fold is that same merge, written
12352-
* once into the row:
12353-
*
12354-
* - `options.KIND` with no top-level `KIND` ⇒ it becomes `KIND`.
12355-
* - Both present ⇒ `KIND = { ...options.KIND, ...KIND }`, the top-level value
12356-
* kept where both set a key.
12357-
* - `chart` is the one kind the renderer did NOT merge: it read
12358-
* `chart || options.chart`, the block replacing the bag WHOLE. So a bag
12359-
* beside a top-level `chart` block is dropped (it never drew), and a bag
12360-
* with no block becomes the block. Merging it per key would draw keys the
12361-
* author never saw.
12362-
* - A bag left empty is removed.
12363-
*
12364-
* **Retired from the load path** — the door refuses `options` by name with
12365-
* the prescription (`LIST_OVERLAY_OPTIONS_REFUSED` in `ui/view.zod.ts`), so a
12366-
* live author is taught the top-level spelling. The entry exists because a
12367-
* STORED list overlay can carry the bag, and every read of a stored `view` row
12368-
* replays the chain through `applyConversionsToStoredItem` as
12369-
* `{ views: [row] }`; `os migrate meta` lists the edit for sources.
12370-
*
12371-
* **Left as stored, with a TODO** (no lossless rewrite): a bag that is not an
12372-
* object; an `options.grid` (a grid has no block, and the door refused it
12373-
* already); a bag key that is not a kind; an `options.KIND` that is not an
12374-
* object, or whose top-level `KIND` is not one; and any bag on a FORM overlay
12375-
* (`viewKind: 'form'`), whose blocks have no home on a form. The row keeps its
12376-
* bag and is badged invalid until its owner decides. The fold also cannot
12377-
* supply a key the block REQUIRES: the bag was judged with every key
12378-
* optional, so a bag block can fold into a block still missing one (a
12379-
* `gantt` with no `titleField`, say). That row is served badged invalid,
12380-
* and the D3 entry `ui-list-view-options-bag-folded` names the remedy.
12381-
*
12382-
* ⚠️ Scoped to the FLATTENED spelling, as {@link viewOverlayOwnerHiddenRemoved}
12383-
* is: a body with no `config` and no container slot. A view item record
12384-
* (`config` present) has refused the bag since #20051 with its own
12385-
* prescription, and the two authoring shapes never declared it.
12386-
*/
12387-
const listViewOptionsBagFolded: MetadataConversion = {
12388-
id: 'list-view-options-bag-folded',
12389-
toMajor: 18,
12390-
retiredFromLoadPath: true,
12391-
retiredAfter: '18.2.0',
12392-
surface: 'view.options.<kind> — on a flattened list view overlay ({ name, object, viewKind, …, no config })',
12393-
summary:
12394-
"flattened list overlay 'options.KIND' folded onto the top-level 'KIND' block (the top-level "
12395-
+ "value wins per key; for 'chart' the block wins whole), and the bag removed: the per-kind "
12396-
+ 'blocks are top-level keys of the view, their one spelling',
12397-
apply(stack, emit, context) {
12398-
const fold = (view: Dict, path: string): Dict => {
12399-
if (!('options' in view)) return view;
12400-
if (view.config !== undefined) return view;
12401-
if (view.list !== undefined || view.form !== undefined) return view;
12402-
if (view.listViews !== undefined || view.formViews !== undefined) return view;
12403-
const bag = view.options;
12404-
if (view.viewKind === 'form' || !isDict(bag)) {
12405-
context?.reportTodo?.({
12406-
path: `${path}.options`,
12407-
from: JSON.stringify(bag),
12408-
reason: view.viewKind === 'form'
12409-
? 'a form view overlay carries an `options` bag; its per-kind blocks belong to a list view and have no '
12410-
+ 'home on a form. Remove `options`, or save the body as a list view.'
12411-
: 'the `options` bag is not an object, so there is no per-kind block to fold. Remove `options`.',
12412-
});
12413-
return view;
12414-
}
12415-
const next: Dict = { ...view };
12416-
const rest: Dict = { ...bag };
12417-
let changed = false;
12418-
for (const kind of LIST_VIEW_OPTIONS_BAG_KINDS) {
12419-
if (!(kind in rest)) continue;
12420-
const under = rest[kind];
12421-
const top = next[kind];
12422-
if (!isDict(under) || (top !== undefined && !isDict(top))) continue;
12423-
delete rest[kind];
12424-
changed = true;
12425-
if (top === undefined) {
12426-
next[kind] = under;
12427-
emit({ from: `options.${kind}`, to: kind, path: `${path}.options.${kind}` });
12428-
} else if (kind === 'chart') {
12429-
emit({ from: 'options.chart', to: '(removed: the top-level chart block replaces it whole)', path: `${path}.options.chart` });
12430-
} else {
12431-
next[kind] = { ...under, ...top };
12432-
emit({ from: `options.${kind}`, to: `${kind} (top-level keys kept)`, path: `${path}.options.${kind}` });
12433-
}
12434-
}
12435-
if (Object.keys(rest).length === 0) {
12436-
delete next.options;
12437-
if (!changed) emit({ from: 'options', to: '(removed: empty)', path: `${path}.options` });
12438-
return next;
12439-
}
12440-
context?.reportTodo?.({
12441-
path: `${path}.options`,
12442-
from: JSON.stringify(rest),
12443-
reason: `the bag keeps ${Object.keys(rest).map((k) => `\`${k}\``).join(', ')}, which has no top-level block `
12444-
+ 'to fold onto (a grid\'s settings are top-level keys of the view; a non-object block or one beside a '
12445-
+ 'non-object top-level block cannot be merged). Move what you meant to the view\'s own keys, then remove `options`.',
12446-
});
12447-
if (!changed) return view;
12448-
next.options = rest;
12449-
return next;
12450-
};
12451-
return mapCollection(
12452-
mapCollection(stack, 'views', fold),
12453-
ASSEMBLED_VIEW_ITEMS_KEY,
12454-
fold,
12455-
);
12456-
},
12457-
fixture: {
12458-
before: {
12459-
// The assembled-manifest channel: an overlay a package export carried.
12460-
viewItems: [
12461-
{ name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', options: { map: { latitudeField: 'lat', longitudeField: 'lng' } } },
12462-
],
12463-
views: [
12464-
// Per key: the top-level `kanban` keeps `groupByField`, the bag adds
12465-
// `titleField`; the bag's `groupByField` is the one that never drew.
12466-
{
12467-
name: 'crm_deal.board',
12468-
object: 'crm_deal',
12469-
viewKind: 'list',
12470-
type: 'kanban',
12471-
kanban: { groupByField: 'stage' },
12472-
options: { kanban: { groupByField: 'owner', titleField: 'name' }, calendar: { startDateField: 'close_date' } },
12473-
},
12474-
// `chart` is replaced whole by its block, so the bag is dropped.
12475-
{
12476-
name: 'crm_deal.chart',
12477-
object: 'crm_deal',
12478-
viewKind: 'list',
12479-
type: 'chart',
12480-
chart: { dataset: 'deals', values: ['amount'] },
12481-
options: { chart: { dataset: 'old_deals', values: ['amount'], dimensions: ['stage'] } },
12482-
},
12483-
// An empty bag is removed.
12484-
{ name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list', options: {} },
12485-
// A record and a container are not flattened overlays: untouched.
12486-
{ name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } },
12487-
{ object: 'crm_deal', list: { type: 'grid', columns: ['name'] } },
12488-
],
12489-
},
12490-
after: {
12491-
viewItems: [
12492-
{ name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } },
12493-
],
12494-
views: [
12495-
{
12496-
name: 'crm_deal.board',
12497-
object: 'crm_deal',
12498-
viewKind: 'list',
12499-
type: 'kanban',
12500-
kanban: { groupByField: 'stage', titleField: 'name' },
12501-
calendar: { startDateField: 'close_date' },
12502-
},
12503-
{
12504-
name: 'crm_deal.chart',
12505-
object: 'crm_deal',
12506-
viewKind: 'list',
12507-
type: 'chart',
12508-
chart: { dataset: 'deals', values: ['amount'] },
12509-
},
12510-
{ name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list' },
12511-
{ name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } },
12512-
{ object: 'crm_deal', list: { type: 'grid', columns: ['name'] } },
12513-
],
12514-
},
12515-
// One notice per folded block (map, kanban, calendar, chart) plus the
12516-
// empty bag's removal.
12517-
expectedNotices: 5,
12518-
},
12519-
};
12520-
1252112521
/**
1252212522
* The list view's `filterableFields` leaves the authorable surface (protocol
1252312523
* 18, #24640 — ADR-0049 enforce-or-remove; maintainer ruling C, record

0 commit comments

Comments
 (0)