diff --git a/.changeset/10222-retire-timeline-metafields-reads.md b/.changeset/10222-retire-timeline-metafields-reads.md new file mode 100644 index 0000000000..0b88a1cd0a --- /dev/null +++ b/.changeset/10222-retire-timeline-metafields-reads.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-timeline': minor +'@object-ui/plugin-list': minor +--- + +Retire the undeclared timeline `metaFields` reads in both packages (objectui#10222). + +The spec's `TimelineConfigSchema` is a strict object that declares no `metaFields` member and +refuses one, yet `ObjectTimeline` read it (through an `as any` cast) to pick the chip fields +beside each title, and `ListView` read it three times: two field collectors put the listed +fields into `$expand` / `$select`, and the status / priority auto-projection was skipped when +the list was present. All four reads are deleted. + +**Behaviour change:** a stored interface-page view carrying `options.timeline.metaFields` falls +back to the default card labels (the built-in `status` / `priority` chips, limited to the fields +the object declares), and the fields that list named are no longer fetched for it. No such view +has been found; the stored rows are not censused. A timeline without the key renders and fetches +exactly what it did before, because `ListView` now projects `status` / `priority` for every +timeline view. + +`cardFields` stays the reserved spelling if authored timeline chip fields are ever asked for; +nothing is declared. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index dcb6ad3722..37d5565ca8 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1834,10 +1834,12 @@ export const ListView = React.forwardRef(({ v.coverField, v.imageField, v.swimlaneField, v.valueField, // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). + // ⛔ No timeline chip-field list (the retired `metaFields`) is + // collected: the spec declares none, and the timeline no longer reads + // one (objectui#10222). ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), - ...(Array.isArray(v.metaFields) ? v.metaFields : []), ]; for (const f of candidates) { if (typeof f === 'string' && f) collected.add(f); @@ -2206,10 +2208,12 @@ export const ListView = React.forwardRef(({ v.coverField, v.imageField, v.swimlaneField, v.valueField, // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). + // ⛔ No timeline chip-field list (the retired `metaFields`) is + // collected: the spec declares none, and the timeline no longer + // reads one (objectui#10222). ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), - ...(Array.isArray(v.metaFields) ? v.metaFields : []), ]; for (const f of candidates) addSpeculative(f); }; @@ -2222,14 +2226,20 @@ export const ListView = React.forwardRef(({ collectViewFields(schema.timeline); collectViewFields(schema.options?.timeline); // Timeline plugin shows status / priority chips inline. Auto-include - // them when no explicit metaFields was configured so views like - // `task_timeline` ({ columns: ['subject', 'status'] }) still get - // priority badges out of the box. Gated through addSpeculative: only - // added when the object actually has these fields (a `product` with - // no status/priority must not get them, or the list goes empty). + // them for every timeline view so views like `task_timeline` + // ({ columns: ['subject', 'status'] }) still get priority badges out + // of the box. Gated through addSpeculative: only added when the + // object actually has these fields (a `product` with no + // status/priority must not get them, or the list goes empty). + // + // Unconditional on purpose (objectui#10222, ruling batch #223 item + // 5b, letter A): this used to be skipped when the block carried an + // undeclared `metaFields` list, which the spec refuses and the + // timeline no longer reads. The chips are always the built-in pair + // now, so their values are always fetched. { - const tCfg: any = schema.timeline ?? schema.options?.timeline; - if (tCfg && !Array.isArray(tCfg.metaFields)) { + const tCfg = schema.timeline ?? schema.options?.timeline; + if (tCfg) { addSpeculative('status'); addSpeculative('priority'); } diff --git a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx index 99e1b63220..ed42640ca5 100644 --- a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx @@ -198,13 +198,29 @@ describe('ListView — speculative view bindings are FLS-gated (objectui#7216)', }); // ── PIN 4: timeline `dateField` ───────────────────────────────────────── + // + // REWRITTEN by objectui#10222 (ruling batch #223 item 5b, letter A), not + // deleted: what it guards, the DENIED `dateField` binding, is unchanged. + // Its fixture used to carry `metaFields: ['region']` as the permitted + // binding beside it. That key is undeclared on the spec's timeline block and + // the projection no longer reads it, so it can no longer be the live + // control. It was never a discriminating one either: `region` is in + // `COLUMNS`, so it reached `$select` through the column path whatever the + // binding did. The permitted binding is now `endDateField: 'end_date'`, + // which only the timeline binding can put in `$select`, the same shape PIN + // 3 uses for gantt. That the retired key projects nothing is pinned in + // `ListView.timelineMetaFieldsRetired-10222.test.tsx`. it('does not project a DENIED timeline date binding', async () => { const select = await selectFor({ columns: COLUMNS, - timeline: { dateField: 'due_date', metaFields: ['region'] }, + timeline: { dateField: 'due_date', endDateField: 'end_date' }, }); expect(select).not.toContain('due_date'); - expect(select).toContain('region'); + expect( + select, + 'both bindings ride the same helper, so the pin is only meaningful if the permitted ' + + 'one survives the same call', + ).toContain('end_date'); }); // ── PIN 5: calendar bindings ──────────────────────────────────────────── @@ -242,8 +258,10 @@ describe('ListView — speculative view bindings are FLS-gated (objectui#7216)', // ── PIN 8: the timeline's AUTO-ADDED status / priority badge fields ───── // - // These two are not authored anywhere — the builder adds them itself when a - // timeline is configured with no explicit `metaFields`. Nothing upstream can + // These two are not authored anywhere — the builder adds them itself for + // every configured timeline. (It used to skip them when the block carried a + // `metaFields` list; that undeclared key is retired and no longer read, + // objectui#10222, ruling batch #223 item 5b, letter A.) Nothing upstream can // have filtered them, so this caller is the one with no other line of // defence at all. it('does not project the auto-added `status` / `priority` badges when denied', async () => { diff --git a/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx new file mode 100644 index 0000000000..48160b21f0 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx @@ -0,0 +1,278 @@ +/** + * 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#10222 — `ListView` reads no timeline `metaFields` list any more. + * + * Ruling batch #223 item 5b, letter A (comment 5824012155 on the card): every + * undeclared `metaFields` read is deleted in both packages. The spec's + * `TimelineConfigSchema` is a strict object that declares no such member, so + * the only way the key reached this projection was a stored view's unjudged + * `options` bag (objectui#10380). `ListView` read it three times: + * + * 1. the `expandFields` collector, which put a lookup named there into + * `$expand` (and so, through the expand roots, into `$select`); + * 2. the `$select` collector, which put every field named there into + * `$select`; + * 3. the status / priority auto-projection, which was SKIPPED whenever the + * block carried the list. + * + * All three are gone. The timeline's chips are now always the built-in + * status / priority pair (`plugin-timeline`'s + * `ObjectTimeline.metaFieldsRetired-10222.test.tsx` pins that half), so the + * projection must always fetch those two for a timeline view. ⛔ `cardFields` + * is the reserved spelling for authored chip fields; nothing is declared here. + * + * ## What each pin discriminates + * + * - IDENTICAL: a timeline view with no `metaFields` projects exactly what it + * projected before this change. The expected array was measured on the base + * tree before any edit and is green there too. + * - PROJECTION: `metaFields: ['region']` no longer adds `region`, and status / + * priority are still projected. On the base tree `region` was added and + * status / priority were dropped, so this pin goes red if read 2 or read 3 + * comes back. + * - EXPAND: a lookup named only in `metaFields` is neither expanded nor + * selected. On the base tree it was both, so this pin goes red if read 1 + * comes back. + * - The nested `options.timeline` spelling is the one the bypass actually + * delivers, so it is pinned too, with and without a `timeline` block beside + * it. + * - SOURCE: no `metaFields` identifier or string key is left in the non-test + * source of this package OR `plugin-timeline`, with firing controls on the + * same scanner. `plugin-timeline`'s scan lives here, not in that package, + * because only this package's `tsconfig.test.json` names the `node` types a + * disk read needs. + * + * `region` is deliberately NOT a column here. In + * `ListView.speculativeFls-7216.test.tsx` it is, which is why that file's + * PIN 4 could not tell the binding path from the column path. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import ts from 'typescript'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +const OBJECT = 'duly_task'; + +const objectDef = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name' }, + start_date: { name: 'start_date', type: 'date', label: 'Start' }, + region: { name: 'region', type: 'select', label: 'Region' }, + status: { name: 'status', type: 'select', label: 'Status' }, + priority: { name: 'priority', type: 'select', label: 'Priority' }, + account: { name: 'account', type: 'lookup', label: 'Account', reference: 'account' }, + }, +}; + +/** + * `plugin-list` does not depend on `plugin-timeline`, so nothing else answers + * `object-timeline` in this file: every `find` recorded below is `ListView`'s + * own projection, never a second query from a mounted timeline. + */ +ComponentRegistry.register( + 'object-timeline', + () =>
, + { namespace: 'test', label: 'Timeline spy', category: 'view' }, +); + +const TIMELINE = { startDateField: 'start_date', titleField: 'name' }; + +/** Mount a timeline list view and return the params of its last `find`. */ +async function paramsFor(schemaExtra: Record) { + const find = vi.fn(async (..._args: unknown[]) => ({ data: [], total: 0 })); + // Cast at the prop: a stub, and a schema that carries a key the type refuses. + const dataSource = { + find, + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + } as unknown as React.ComponentProps['dataSource']; + const schema = { + type: 'list-view', + objectName: OBJECT, + viewType: 'timeline', + columns: ['name'], + timeline: TIMELINE, + ...schemaExtra, + } as unknown as React.ComponentProps['schema']; + render( + + + , + ); + await waitFor(() => expect(find).toHaveBeenCalled()); + const call = (find.mock.calls.at(-1)?.[1] ?? {}) as { $select?: string[]; $expand?: string[] }; + return { + select: call.$select ?? [], + expand: call.$expand ?? [], + }; +} + +afterEach(() => cleanup()); + +describe('ListView — the timeline `metaFields` reads are retired (objectui#10222)', () => { + it('IDENTICAL: a timeline view with no `metaFields` projects what it projected before the change', async () => { + const { select } = await paramsFor({}); + expect( + select, + 'measured on the base tree before any edit; the auto-projection became unconditional, ' + + 'so the view that never carried the key must not move', + ).toEqual(['id', 'name', 'start_date', 'status', 'priority']); + }); + + it('PROJECTION: `metaFields` adds nothing, and status / priority are still projected', async () => { + const { select } = await paramsFor({ + timeline: { ...TIMELINE, metaFields: ['region'] }, + }); + expect(select, 'the retired key must not reach `$select`').not.toContain('region'); + expect( + select, + 'the timeline always renders the built-in status / priority chips now, so their values ' + + 'must be fetched even when the block carries the retired key', + ).toEqual(expect.arrayContaining(['status', 'priority'])); + expect(select, 'the key changes nothing at all').toEqual( + ['id', 'name', 'start_date', 'status', 'priority'], + ); + }); + + it('PROJECTION, `options.timeline` beside a `timeline` block: the bypass spelling adds nothing either', async () => { + const { select } = await paramsFor({ + options: { timeline: { metaFields: ['region'] } }, + }); + expect(select).not.toContain('region'); + expect(select).toEqual(expect.arrayContaining(['status', 'priority'])); + }); + + it('PROJECTION, `options.timeline` alone: status / priority are no longer dropped', async () => { + const { select } = await paramsFor({ + timeline: undefined, + options: { timeline: { ...TIMELINE, metaFields: ['region'] } }, + }); + expect(select).not.toContain('region'); + expect( + select, + 'on the base tree this shape fetched no status / priority at all, so its timeline drew ' + + 'no chips once the renderer stopped reading the list', + ).toEqual(expect.arrayContaining(['status', 'priority'])); + }); + + it('EXPAND: a lookup named only in `metaFields` is neither expanded nor selected', async () => { + const { select, expand } = await paramsFor({ + timeline: { ...TIMELINE, metaFields: ['account'] }, + }); + expect(expand, 'the `expandFields` collector read the retired key').not.toContain('account'); + expect(select, 'the expand roots are unioned into `$select`').not.toContain('account'); + }); + + it('EXPAND control: the same lookup named in `columns` IS expanded', async () => { + const { expand } = await paramsFor({ columns: ['name', 'account'] }); + expect( + expand, + 'without this the pin above could pass because nothing is ever expanded here', + ).toContain('account'); + }); +}); + +// ── SOURCE: no `metaFields` read left in either package ─────────────────── +// +// Both packages' scans live here because this package's `tsconfig.test.json` +// names the `node` types a disk read needs and `plugin-timeline`'s does not. + +/** Rooted at THIS file, never at `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const RETIRED = 'metaFields'; + +/** Every non-test `.ts` / `.tsx` file under `dir`. */ +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + if (name === '__tests__') continue; + out.push(...sourceFiles(full)); + } else if (/\.tsx?$/.test(name) && !/\.(test|spec)\.tsx?$/.test(name)) { + out.push(full); + } + } + return out; +} + +/** + * The 1-based lines where `name` is used as CODE: an identifier (`x.name`, + * `x?.name`, `{ name }`, `name:`) or a string literal (`x['name']`, + * `'name' in x`). Comments are trivia, not tokens, so prose naming the retired + * key is not a read. + */ +function codeUses(text: string, file: string, name: string): number[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const hits: number[] = []; + const walk = (node: ts.Node): void => { + if ((ts.isIdentifier(node) || ts.isStringLiteralLike(node)) && node.text === name) { + hits.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1); + } + ts.forEachChild(node, walk); + }; + walk(sf); + return hits; +} + +/** + * Each package's non-test source, the file that held its read(s), and an + * identifier read beside the retired one in that file (the firing control). + */ +const PACKAGES = [ + { pkg: 'plugin-list', holder: 'ListView.tsx', sibling: 'cardFields' }, + { pkg: 'plugin-timeline', holder: 'ObjectTimeline.tsx', sibling: 'colorField' }, +] as const; + +describe.each(PACKAGES)('SOURCE: no `metaFields` read remains in $pkg (objectui#10222)', ({ pkg, holder, sibling }) => { + const src = join(REPO_ROOT, 'packages', pkg, 'src'); + const files = sourceFiles(src); + const rel = (f: string) => relative(src, f).replace(/\\/g, '/'); + + it(`POPULATION: the scan covers ${holder}, the file that held the read`, () => { + expect(files.map(rel)).toContain(holder); + }); + + it('no non-test source names `metaFields` as code', () => { + // The location string is computed here for the failure message only. + const hits = files.flatMap((f) => + codeUses(readFileSync(f, 'utf8'), rel(f), RETIRED).map((line) => `${pkg}/src/${rel(f)} line ${line}`)); + expect(hits, 'a `metaFields` read came back; the key is undeclared (objectui#10222)').toEqual([]); + }); + + it(`FIRING CONTROL: the same scanner finds \`${sibling}\`, read beside it in ${holder}`, () => { + const file = files.find((f) => rel(f) === holder)!; + expect(codeUses(readFileSync(file, 'utf8'), holder, sibling).length).toBeGreaterThan(0); + }); +}); + +it('SOURCE FIRING CONTROL: the scanner catches each shape the retired reads took, and skips comments', () => { + const probe = [ + '// metaFields in a comment is prose', + 'const a = (timelineConfig as any)?.metaFields;', + 'const b = tCfg?.metaFields;', + "const c = v['metaFields'];", + 'const { metaFields } = v;', + ].join('\n'); + expect(codeUses(probe, 'probe.ts', RETIRED), 'the probe lines, 1-based').toEqual([2, 3, 4, 5]); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx new file mode 100644 index 0000000000..f7c358e039 --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.metaFieldsRetired-10222.test.tsx @@ -0,0 +1,121 @@ +/** + * 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#10222 — `ObjectTimeline` reads no `timeline.metaFields` list. + * + * Ruling batch #223 item 5b, letter A (comment 5824012155 on the card): the + * undeclared `metaFields` reads are deleted in both packages. The spec's + * `TimelineConfigSchema` is a strict object that declares no such member and + * refuses one, so an author could only reach this read through a stored view's + * unjudged `options` bag (objectui#10380). The chips beside each title are now + * always the built-in `status` / `priority` pair, limited to the fields the + * object declares. ⛔ `cardFields` is the reserved spelling if a producer ever + * asks for authored chip fields; nothing is declared here. + * + * Behaviour change, named in the changeset: a stored interface-page view that + * carries `options.timeline.metaFields` falls back to the default chips. + * `plugin-list`'s `ListView.timelineMetaFieldsRetired-10222.test.tsx` pins the + * projection half, which keeps fetching status / priority for every timeline. + * + * `./renderer` is deliberately NOT mocked: the chips are read off the real + * `TimelineRenderer` output, the surface a user sees. Harness as in + * `__tests__/timeline-shared-safe-field-label-5623.test.tsx`. + * + * The SOURCE pin for this package (no `metaFields` read left in any non-test + * file under `src/`) lives in that `plugin-list` file beside the projection + * pin, not here: it reads files off disk, and this package's + * `tsconfig.test.json` names no `node` types, so a `node:fs` import here would + * not type-check. It runs in the same `pnpm test`. + */ +import React from 'react'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectTimeline } from './ObjectTimeline'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + }; +}); + +afterEach(() => cleanup()); + +const OBJECT_DEF = { + fields: { + status: { type: 'select', options: [{ value: 'open', label: 'Open' }] }, + priority: { type: 'select', options: [{ value: 'high', label: 'High' }] }, + region: { type: 'select', options: [{ value: 'emea', label: 'EMEA' }] }, + }, +}; + +const ROWS = [ + { id: '1', name: 'Item 1', date: '2024-01-01', status: 'open', priority: 'high', region: 'emea' }, +]; + +function makeDataSource() { + return { + find: vi.fn(), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => OBJECT_DEF), + }; +} + +/** `MetaChip`'s own classes; the title, date and markers carry none of them. */ +const chipTexts = (root: HTMLElement): string[] => + Array.from(root.querySelectorAll('span.rounded-full.border.text-xs')).map((el) => el.textContent ?? ''); + +/** Mount with this `timeline` block and return the chip labels once the object definition has landed. */ +async function chipsFor(timeline: Record): Promise { + const props = { + schema: { type: 'object-timeline', objectName: 'lead', titleField: 'name', timeline }, + data: ROWS, + dataSource: makeDataSource(), + } as unknown as React.ComponentProps; + const { container, unmount } = render(); + // The chips appear only after `getObjectSchema` resolves; the title is there + // from the first render, so wait on a chip rather than on `Item 1`. + await waitFor(() => expect(chipTexts(container).length).toBeGreaterThan(0)); + const chips = chipTexts(container); + expect(screen.getByText('Item 1'), 'the row itself rendered').toBeDefined(); + unmount(); + return chips; +} + +const BLOCK = { startDateField: 'date' }; + +describe('ObjectTimeline — the `metaFields` read is retired (objectui#10222)', () => { + it('CONTROL: with no `metaFields`, the built-in status / priority chips render', async () => { + expect(await chipsFor(BLOCK)).toEqual(['Open', 'High']); + }); + + it('RENDER: a block carrying `metaFields` renders the SAME chips as one without', async () => { + const without = await chipsFor(BLOCK); + const withKey = await chipsFor({ ...BLOCK, metaFields: ['region'] }); + expect( + withKey, + 'the key is undeclared on the spec timeline block; the chips must not change with it', + ).toEqual(without); + expect(withKey, 'the field named by the retired key must not become a chip').not.toContain('EMEA'); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index c53f8dc891..6b21ccaf96 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -512,14 +512,21 @@ export const ObjectTimeline: React.FC = ({ return map; }; - /** Which fields appear as inline chips beside the title. - * Spec config: `timeline.metaFields: string[]`. - * Heuristic default: `['status', 'priority']` — limited to fields that - * actually exist in objectDef so non-CRM objects don't render fake - * chips. */ - const metaFieldNames: string[] = Array.isArray((timelineConfig as any)?.metaFields) - ? (timelineConfig as any).metaFields.filter((f: any) => typeof f === 'string' && f) - : ['status', 'priority'].filter((f) => fields[f]); + /** Which fields appear as inline chips beside the title: the built-in + * `['status', 'priority']`, limited to fields that actually exist in + * objectDef so non-CRM objects don't render fake chips. + * + * ⛔ Not authorable, and nothing reads an authored list here + * (objectui#10222, ruling batch #223 item 5b, letter A). The spec's + * `TimelineConfigSchema` is a strict object that declares no chip-field + * member and refuses one, so the retired `metaFields` read off this + * block reached the renderer only through a stored view's unjudged + * `options` bag (objectui#10380). If a producer ever asks for authored + * chip fields, the reserved spelling is `cardFields` (the kanban / + * gallery spelling), declared on the spec first; it is not declared + * today. `ListView`'s status / priority auto-projection keys on the + * same default, so the two must move together. */ + const metaFieldNames: string[] = ['status', 'priority'].filter((f) => fields[f]); const metaOptionMaps: Record> = {}; for (const f of metaFieldNames) metaOptionMaps[f] = optionMap(f);