From 215edf59d57f8922c8fad4e9f9c705f44dd4e25e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:14:29 +0000 Subject: [PATCH 1/2] feat(plugin-list): list-view publishes the spec keys its renderer honours on a page node Measure each of the 38 keys the spec's list-view row declares and the registration did not publish, through the real SchemaRenderer and the list-view registration. Publish the 33 the renderer honours in one shared input list for list-view and view:list; record name, requiredPermissions, sharing, addRecord and filterableFields as unpublished by measurement in the console parity gate. Write the six booked member pins and a pin for every structured key published, strike the OWED rows and restore the caps. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13047-list-view-spec-inputs.md | 19 + .../registry-inputs-spec-parity.test.ts | 254 ++++-- .../ListViewBlock.specInputs-13047.test.tsx | 812 ++++++++++++++++++ packages/plugin-list/src/index.tsx | 268 +++++- 4 files changed, 1271 insertions(+), 82 deletions(-) create mode 100644 .changeset/13047-list-view-spec-inputs.md create mode 100644 packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx diff --git a/.changeset/13047-list-view-spec-inputs.md b/.changeset/13047-list-view-spec-inputs.md new file mode 100644 index 0000000000..6be0353fef --- /dev/null +++ b/.changeset/13047-list-view-spec-inputs.md @@ -0,0 +1,19 @@ +--- +'@object-ui/plugin-list': minor +--- + +`list-view` publishes the keys of its `@objectstack/spec` row that the renderer honours on a page node (objectui#13047). + +The spec's `ComponentPropsMap` gained a `list-view` row in 18 (objectstack-ai/objectstack#22444): the list view's own `ListViewSchema`. The registration published six of its keys (`data`, `type`, `columns`, `filter`, `sort`, `options`). Every other key of the row was measured on a page node, authored in the node's `properties` and rendered through `SchemaRenderer` and this registration, and the 33 the renderer honours are now published in `inputs`, with the member shape the spec declares named in each description: + +- **The query:** `pagination`, `searchableFields`. +- **Identity:** `label`, `description` (each a plain string or an inline locale map). +- **The toolbar:** `userFilters`, `userActions`, `compactToolbar`, `appearance`, `exportOptions`, `allowPrinting`, `showRecordCount`, `emptyState`. +- **The grid:** `hiddenFields`, `fieldOrder`, `rowHeight`, `resizable`, `selection`, `navigation`, `grouping`, `rowColor`, `conditionalFormatting`, `inlineEdit`, `rowActions`, `bulkActions`, `bulkActionDefs`. +- **Each kind's own block:** `kanban`, `calendar`, `gantt`, `gallery`, `timeline`, `map`, `tree`, `chart`. + +`appearance.allowedVisualizations` narrows the kinds a view switcher offers, and a page node draws no switcher, so on a page it changes nothing; its description says so. + +Five keys of the row stay unpublished, because the renderer does not honour them on a page node: `name` and `requiredPermissions` are read by nothing there, `sharing` and `addRecord` each draw a button that does nothing there, and `filterableFields` is read as the Filter panel's field list, not as the quick-filter shorthand the row declares it to be. + +The `view:list` alias registers the same renderer and now publishes the same list. The descriptions of the six keys already published are filled in. Nothing changes at render time: no reader, export, prop or type member is added. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 76d092cd5d..d75b33e3f2 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -791,9 +791,12 @@ function isRetiredUpstream(type: string): boolean { * (objectstack#22444), so this file judges it: * * - objectui#13047 — every entry on `list-view`: the 38 keys its spec row - * declares that `inputs` does not publish, and the 6 member pins its - * array/object-armed inputs owe. Expires 2026-11-18, thirty days from the - * decision, or when that card lands. + * declares that `inputs` did not publish, and the 6 member pins its + * array/object-armed inputs owed. STRUCK by that card's landing, which + * measured each key through the real `SchemaRenderer` and this block's + * registration, published the 33 the renderer honours with a member pin + * for every structured one, and recorded the other five in + * `UNPUBLISHED_EXEMPTIONS` with the measured reason. * * Each owner card decides every key by its own measurement: declare what the * renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an @@ -889,31 +892,15 @@ const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'required /** Every entry the ruling books starts with this, so the caps can count them. */ const OWED_PREFIX = 'OWED TO '; -// The two helpers that write a booked entry: `OWED_TO` (the reason: owner, +// The two helpers that wrote a booked entry: `OWED_TO` (the reason: owner, // what is owed, the booking record and the expiry) and `owedEntries` (one // entry per key of a block). They left with `action:button.undoable`, struck by -// objectui#11168's last slice, and came back with the objectstack `main` pin -// bump (objectui#12971), whose `list-view` differences the seat booked to -// objectui#13047 under decision 3 = B (record 6267645900). - -/** The reason a booked entry carries: owner, what is owed, the booking record and the expiry. */ -const OWED_TO = (owner: Objectui11111Owner, what: string): string => { - const { bump, bookedBy, expires } = OBJECTUI_11111_BOOKINGS[owner]; - return ( - `${OWED_PREFIX}${owner}. ${what} Booked by ${bookedBy}: ` + - `the ${bump} bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` + - `Expires ${expires}, or when ${owner} lands, whichever is first.` - ); -}; - -/** `BLOCK.KEY` entries for every listed key of one block, all with the same owner and reason. */ -const owedEntries = ( - type: string, - keys: readonly string[], - owner: Objectui11111Owner, - what: string, -): Record => - Object.fromEntries(keys.map((key) => [`${type}.${key}`, OWED_TO(owner, what)])); +// objectui#11168's last slice, came back with the objectstack `main` pin bump +// (objectui#12971), whose `list-view` differences the seat booked to +// objectui#13047 under decision 3 = B (record 6267645900), and left again when +// objectui#13047 struck the last of those entries. A future bump that books an +// entry writes them back with its entries; every reason still starts with +// `OWED_PREFIX`, which the cap test counts. /** Which owner the ruling routes an entry id to — asserted against every entry's reason. */ function objectui11111OwnerOf(id: string): Objectui11111Owner { @@ -944,9 +931,9 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 38, // objectui#13047: 38 (list-view, booked by the seat decision D1 = A on objectui#12971, record 6267645900); objectui#11168: 0 (its last slice declared action:button undoable, ruling B on objectui#11754; the two `endpoint` entries had left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0, objectui#8649: 0, objectui#11536: 0 and objectui#11068: 0 (each struck by its landing; objectui#11536 declared all ten record:line_items keys, objectui#11068's build published object-grid keyboardNavigation) + unpublishedKeys: 0, // objectui#13047: 0 (its landing measured all 38 list-view keys, published the 33 the renderer honours and recorded the other five in UNPUBLISHED_EXEMPTIONS with their reasons); objectui#11168: 0 (its last slice declared action:button undoable, ruling B on objectui#11754; the two `endpoint` entries had left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0, objectui#8649: 0, objectui#11536: 0 and objectui#11068: 0 (each struck by its landing; objectui#11536 declared all ten record:line_items keys, objectui#11068's build published object-grid keyboardNavigation) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout - memberPins: 6, // objectui#13047: 6 (list-view data, columns, filter, sort, options and dataSource, booked by the same decision); objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536 pinned record:line_items columns and dataSource + memberPins: 0, // objectui#13047 pinned list-view data, columns, filter, sort, options and dataSource, with every structured key it published; objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536 pinned record:line_items columns and dataSource } as const; /** @@ -1651,27 +1638,30 @@ const UNPUBLISHED_EXEMPTIONS: Record = { /* * ⚠️ THE OBJECTSTACK `main` PIN BOOKINGS — the seat decision D1 = A on * objectui#12971 (record 6267645900), which applies objectui#11111 decision - * 3 = B to the pin bump. + * 3 = B to the pin bump. STRUCK by objectui#13047. * - * The spec keys the `list-view` row declares from that pin on (objectstack#22444) - * that the registration's `inputs` do not publish. The bump declares nothing: - * objectui#13047 measures each key against `ListViewBlock` / `ListView`, - * declares what is honoured and leaves the rest out. Capped at - * `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`. + * The bump booked the 38 spec keys the `list-view` row declares from that + * pin on (objectstack#22444) that the registration's `inputs` did not + * publish, OWED to objectui#13047. That card measured each key through the + * real `SchemaRenderer` and the `list-view` registration + * (`packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx`), + * published the 33 the renderer honours on a page node, and left the five + * below out, each UNPUBLISHED BY MEASUREMENT: the first bar of this map's + * header, a key the renderer does not honour, so publishing it would + * advertise configuration the platform drops. Each reason names what the + * renderer does instead, and each leaves this map the day a reader lands + * and the key is published. */ - ...owedEntries( - 'list-view', - [ - 'name', 'label', 'description', 'searchableFields', 'filterableFields', 'userFilters', 'resizable', - 'compactToolbar', 'selection', 'navigation', 'pagination', 'kanban', 'calendar', 'gantt', 'gallery', - 'timeline', 'chart', 'map', 'tree', 'sharing', 'requiredPermissions', 'rowHeight', 'grouping', - 'rowColor', 'hiddenFields', 'fieldOrder', 'rowActions', 'bulkActions', 'bulkActionDefs', - 'conditionalFormatting', 'inlineEdit', 'exportOptions', 'userActions', 'appearance', 'addRecord', - 'showRecordCount', 'allowPrinting', 'emptyState', - ], - 'objectui#13047', - 'A SPEC KEY NOT PUBLISHED: `list-view` entered `covered` with the objectstack `main` pin and its `inputs` omit this key its spec row declares.', - ), + 'list-view.name': + 'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.', + 'list-view.requiredPermissions': + 'Unpublished by measurement (objectui#13047): no audience gate on this block. A capability nobody holds still draws and queries the list, where the record blocks put an insufficient-permissions notice in place of the block. The server enforces the key on a served list view; nothing enforces it on an embedded one. A renderer gap the seat cards (objectui#13047\'s report).', + 'list-view.sharing': + 'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', + 'list-view.addRecord': + 'Unpublished by measurement (objectui#13047): `addRecord.enabled` draws an Add button that calls the host\'s `onAddRecord`, and a page node has no host to pass one, so the button does nothing there. A renderer gap the seat cards (objectui#13047\'s report).', + 'list-view.filterableFields': + 'Unpublished by measurement (objectui#13047): the row declares it as the legacy shorthand of `userFilters.fields` (end-user quick filters), and the list reads it as something else — the field list of the Filter panel — while it draws no quick-filter bar. Publishing it would advertise the row\'s meaning, which nothing honours; which meaning stands is a contract question for the seat (objectui#13047\'s report).', }; /** @@ -2842,6 +2832,12 @@ const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record = { ...actionLeafMemberPins('action:button'), ...actionLeafMemberPins('action:icon'), @@ -2930,6 +2926,143 @@ const MEMBER_PINS: Record = { file: 'packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts', pins: 'The I18nLabel trio — see `element:text_input.description` (objectui#5717).', }, + // objectui#13047 — every array/object-armed input `list-view` publishes, + // measured through the real `SchemaRenderer` and the `list-view` registration + // in one file (`LIST_VIEW_PIN_FILE`); `dataSource` in the binding's own file. + // The six the objectstack `main` pin bump booked (`data`, `columns`, `filter`, + // `sort`, `options`, `dataSource`) are among them. + 'list-view.appearance': { + file: LIST_VIEW_PIN_FILE, + pins: 'Two members, measured apart. `showDescription: false` hides the description the same node declares (the info control is gone). `allowedVisualizations` narrows the kinds a view SWITCHER offers, and a page node draws no switcher (`showViewSwitcher` is a host prop, not a key of the row), so a node whitelisting `[\'grid\']` still draws its authored kanban: the member is inert on a page node, which the registration\'s description says rather than hides (objectui#13047).', + }, + 'list-view.bulkActionDefs': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are the spec\'s `BulkActionDef` objects (`of: \'object\'`, the contract\'s single member kind): a definition list reaches the `object-grid` node `ListView` builds unchanged, member for member, and an absent key puts none there. The grid\'s own execution of a definition is `object-grid.bulkActionDefs`\'s pin (objectui#13047).', + }, + 'list-view.bulkActions': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are action NAMES (`of: \'string\'`): the list reaches the `object-grid` node `ListView` builds as its `batchActions`, in order, and an absent key puts none there. The bar the grid draws from them is `object-grid.bulkActions`\'s pin (objectui#13047).', + }, + 'list-view.calendar': { + file: LIST_VIEW_PIN_FILE, + pins: 'The calendar block\'s binding members `startDateField`, `endDateField` and `titleField`, each reaching the `object-calendar` node `ListView` builds under its own name; with no block, a `type: \'calendar\'` node hands the calendar no start date (objectui#13047).', + }, + 'list-view.chart': { + file: LIST_VIEW_PIN_FILE, + pins: 'The ADR-0021 binding members `dataset`, `values` and `dimensions`, reaching the `object-chart` node `ListView` builds for a `type: \'chart\'` node unchanged (objectui#13047).', + }, + 'list-view.columns': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are field NAMES in the authored order: they project the query (`$select` carries them and not a field they leave out) and the `object-grid` node `ListView` builds draws them in that order (objectui#13047).', + }, + 'list-view.conditionalFormatting': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are the list view\'s own `{ condition, style }` rules (`of: \'object\'`): the rule list reaches the `object-grid` node `ListView` builds unchanged, and an absent key puts none there. Painting a matched row is `object-grid.conditionalFormatting`\'s pin (objectui#13047).', + }, + 'list-view.data': { + file: LIST_VIEW_PIN_FILE, + pins: 'The spec\'s `ViewData`, two providers measured apart: `{ provider: \'object\', object }` lists that object (the adapter\'s `find` receives it and the grid is handed its rows), and `{ provider: \'value\', items }` hands the grid those rows and queries nothing (objectui#13047).', + }, + 'list-view.dataSource': { + file: 'packages/plugin-list/src/__tests__/ListView.elementDataSource.test.tsx', + pins: 'The per-element binding\'s members on a page `list-view` node, asserted at the query the adapter receives: `object` is the queried object; a named `view` contributes its columns (`$select`), filter and sort; the binding\'s own `filter` is AND-combined with the view\'s, never replacing it; its own `sort` and `limit` (as `$top`) outrank the view\'s; columns authored on the node win over the view\'s; and an unresolvable `view` renders a configuration error naming the view, the object and the views it has, and queries nothing. The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), so the per-block half is what this pin carries. Pre-existing file (objectstack#5576), read end to end before being cited (objectui#13047).', + }, + 'list-view.description': { + file: LIST_VIEW_PIN_FILE, + pins: 'The I18nLabel arms: a plain string is the toolbar info control\'s accessible name, an inline locale map paints its `zh-CN` entry under a `zh-CN` display locale with `en` listed FIRST so a fallback fails the row, and an absent key draws no info control (objectui#13047).', + }, + 'list-view.emptyState': { + file: LIST_VIEW_PIN_FILE, + pins: 'Two of the strict `EmptyState` members, `title` and `message`, drawn in place of the default empty copy when no record comes back; the same node with no `emptyState` draws neither. `icon` and `link` are this package\'s `ListView.emptyState*` pins on the bare `ListView` (objectui#13047).', + }, + 'list-view.exportOptions': { + file: LIST_VIEW_PIN_FILE, + pins: 'Two members: declaring `{ formats: [\'csv\'] }` offers the toolbar export in that format (an absent key offers none), and `fileNamePrefix` leads the downloaded file\'s name, read off the download anchor (objectui#13047).', + }, + 'list-view.fieldOrder': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are field NAMES (`of: \'string\'`): they order the columns left after `hiddenFields` on the `object-grid` node `ListView` builds, and a surviving column the list does not name sorts LAST (objectui#13047).', + }, + 'list-view.filter': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are the spec\'s `ViewFilterRule` objects `{ field, operator, value }`: they reach the query\'s `$filter` carrying the field and the value, and an absent key sends no `$filter` (objectui#13047).', + }, + 'list-view.gallery': { + file: LIST_VIEW_PIN_FILE, + pins: 'The gallery block\'s `coverField` (reaching the `object-gallery` node\'s nested `gallery` config) and `titleField` (its flat title binding); with no block, a `type: \'gallery\'` node hands no cover field (objectui#13047).', + }, + 'list-view.gantt': { + file: LIST_VIEW_PIN_FILE, + pins: 'The gantt block\'s binding members `startDateField`, `endDateField` and `titleField`, each reaching the `object-gantt` node `ListView` builds; with no block, a `type: \'gantt\'` node hands the chart no start date (objectui#13047).', + }, + 'list-view.grouping': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are `{ field, order }` entries under `fields`: over an adapter that answers the group header query the entry reaches the `object-grid` node `ListView` builds, and over one that cannot, the list draws its refusal instead of grouping one page and mounts no grid (objectui#13047).', + }, + 'list-view.hiddenFields': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are field NAMES (`of: \'string\'`): each subtracts its column from the `object-grid` node `ListView` builds, and a name the columns never projected subtracts nothing (objectui#13047).', + }, + 'list-view.kanban': { + file: LIST_VIEW_PIN_FILE, + pins: 'The kanban block\'s `groupByField` (the board\'s lane field, handed as `groupBy`), `titleField` and `columns` (handed as the card fields); with no block, the board lanes on a field the list derives rather than the one this row names (objectui#13047).', + }, + 'list-view.label': { + file: LIST_VIEW_PIN_FILE, + pins: 'The I18nLabel arms at the export file name: a plain string names the downloaded file, an inline locale map names it by its `zh-CN` entry under a `zh-CN` display locale with `en` listed FIRST (and never as `[object Object]`), and the same node with no label does not. The record-detail drawer heading carries the string arm in the `navigation` row (objectui#13047).', + }, + 'list-view.map': { + file: LIST_VIEW_PIN_FILE, + pins: 'The map block\'s `latitudeField` and `longitudeField`, each reaching the `object-map` node `ListView` builds; with no block, a `type: \'map\'` node hands no coordinate binding (objectui#13047).', + }, + 'list-view.navigation': { + file: LIST_VIEW_PIN_FILE, + pins: 'The `mode` member: `drawer` opens the clicked row\'s record in a dialog headed by the list\'s `label`, and the same node with no `navigation` opens none (objectui#13047).', + }, + 'list-view.options': { + file: LIST_VIEW_PIN_FILE, + pins: 'The per-kind bag: `options.kanban.groupByField` lanes the board the same way the top-level kanban block does, read off the `object-kanban` node `ListView` builds (objectui#13047).', + }, + 'list-view.pagination': { + file: LIST_VIEW_PIN_FILE, + pins: 'The `pageSize` member caps the query (`$top`), and the same node with no `pagination` sends the default page (objectui#13047).', + }, + 'list-view.rowActions': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are action NAMES (`of: \'string\'`): the list reaches the `object-grid` node `ListView` builds unchanged, and an absent key puts none there. The row menu the grid draws is `object-grid.rowActions`\'s pin (objectui#13047).', + }, + 'list-view.rowColor': { + file: LIST_VIEW_PIN_FILE, + pins: 'The `field` and `colors` members reach the `object-grid` node `ListView` builds unchanged, and an absent key puts none there. Painting the rows is `object-grid.rowColor`\'s pin (objectui#13047).', + }, + 'list-view.searchableFields': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are field NAMES (`of: \'string\'`): a term typed into the toolbar search box is sent with `$searchFields` naming exactly them, and the same term on a node with no list sends no `$searchFields` (objectui#13047).', + }, + 'list-view.selection': { + file: LIST_VIEW_PIN_FILE, + pins: 'The `type` member reaches the `object-grid` node `ListView` builds unchanged, and an absent key puts no selection there (objectui#13047).', + }, + 'list-view.sort': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are `{ field, order }` objects: they reach the query\'s `$orderby` in the order written, each with its own order (objectui#13047).', + }, + 'list-view.timeline': { + file: LIST_VIEW_PIN_FILE, + pins: 'The timeline block\'s `startDateField` (on the `object-timeline` node both flat and in its nested `timeline` config) and `titleField`; with no block, a `type: \'timeline\'` node hands no start date (objectui#13047).', + }, + 'list-view.tree': { + file: LIST_VIEW_PIN_FILE, + pins: 'The tree block\'s `parentField` and `labelField`, each reaching the `object-tree` node `ListView` builds; with no block, a `type: \'tree\'` node hands no parent field (objectui#13047).', + }, + 'list-view.userActions': { + file: LIST_VIEW_PIN_FILE, + pins: 'Members are per-control toggles: `sort: false` takes the Sort control off the toolbar while the Filter control the same object does not name stays, and an absent key keeps both (objectui#13047).', + }, + 'list-view.userFilters': { + file: LIST_VIEW_PIN_FILE, + pins: 'The `element` and `fields` members: a `dropdown` over `stage` draws the quick-filter bar labelled by that field, and the same node with no `userFilters` draws none (objectui#13047).', + }, 'object-calendar.calendar': { file: 'packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx', pins: 'Members are FIELD NAMES the calendar projects onto an event: `startDateField` and `endDateField` name the record fields that become the event\'s `start`/`end` (an unauthored `endDateField` leaves `end` undefined rather than inventing one), and `titleField` OUTRANKS the object\'s own default display-name resolution (`getRecordDisplayName`) rather than only supplementing it. The sharp claim is PRECEDENCE: `getCalendarConfig` returns `schema.calendar` OUTRIGHT the moment it is set — never merged against the flat legacy spelling (`startDateField/endDateField/titleField/colorField` authored directly on the schema) it falls back to only when `calendar` is absent — so a schema carrying BOTH, with the two disagreeing, is read entirely from the nested object and not at all from the flat siblings, even for the keys the nested object leaves unset. `colorField`\'s own resolution ladder and `allDayField` are each pinned narrowly already and are not re-asserted here: `ObjectCalendar.colorFieldLadder-7243.test.tsx`, `ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`. The spec side is a strict object of exactly the five documented keys — `allDayField` among them since `@objectstack/spec` 17.5.0, objectui-local before that (text corrected by objectui#11168 slice 3) — so it fixes the NAMES but nothing about how they are used once read: the read site is the whole member contract (objectui#8071).', @@ -3684,16 +3817,11 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // ⚠️ OBJECTSTACK `main` PIN BOOKINGS — the seat decision D1 = A on // objectui#12971 (record 6267645900), which applies objectui#11111 decision - // 3 = B to the pin bump. The array/object-armed inputs of `list-view`, the - // block that pin newly judges, owed a member pin by objectui#13047. Counted - // into `MEMBER_PIN_EXEMPTION_CEILING` (0 -> 6) and capped at these six; that - // card's pins strike them. - ...owedEntries( - 'list-view', - ['data', 'columns', 'filter', 'sort', 'options', 'dataSource'], - 'objectui#13047', - 'A MEMBER PIN OWED: this `list-view` input is array/object-armed on a block the objectstack `main` pin newly judges, and no pin states its members.', - ), + // 3 = B to the pin bump. The six array/object-armed inputs of `list-view` + // owed a member pin by objectui#13047 (`data`, `columns`, `filter`, `sort`, + // `options`, `dataSource`) stood here. That card pinned all six, and every + // structured key it published with them, each registered in `MEMBER_PINS`, + // so no booking is left. Header kept as a landmark for a future grep. }; /** @@ -4538,8 +4666,16 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * objectui#13047. 0 + 6 booked = 6, and `the objectui#11111 ledger is capped * at exactly the entries it lists` caps the six and routes each to * objectui#13047, whose pins take this back to 0. + * + * ## 6 -> 0 (objectui#13047) — the objectstack `main` pin ruling paid off + * + * The card registered member pins for all six, measured through the real + * `SchemaRenderer` and the `list-view` registration, and deleted their booked + * entries. The structured keys it published beside them entered the + * population already pinned, so nothing stands in the list and the ceiling + * follows it back to 0. */ -const MEMBER_PIN_EXEMPTION_CEILING = 6; +const MEMBER_PIN_EXEMPTION_CEILING = 0; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. @@ -6223,7 +6359,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'objectui#8649': 0, 'objectui#11536': 0, 'objectui#11068': 0, - 'objectui#13047': 44, + 'objectui#13047': 0, }); }); }); diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx new file mode 100644 index 0000000000..8ca6b2f955 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -0,0 +1,812 @@ +/** + * 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#13047 — `list-view` publishes the keys of its `@objectstack/spec` + * `ComponentPropsMap` row that the renderer honours, and this file is the + * measurement each publication rests on. + * + * The rule the card was ruled under (objectui#11111 decision 3 = B): publish + * what the renderer honours, leave out what it does not, and never publish a + * key no renderer reads. A source read alone cannot settle "honours": + * `SchemaRenderer` hoists the `properties` bag onto the node, `ListViewBlock` + * rewrites the page spelling and runs the `ElementDataSourceGate`, and only + * then does `ListView` see the node. So every key is authored here the way the + * spec authors it, in the node's `properties` bag, rendered through the REAL + * `SchemaRenderer` and this package's own `list-view` registration, and + * asserted at what it does: the DOM the list draws, the query the adapter + * receives, or the node `ListView` builds for the view it draws. Every row has + * a control beside it that differs only by that key, so a key the list ignored + * would read the same twice and fail. + * + * The child views are stand-ins that record the node `ListView` hands them — + * the device every other test in this package uses, because `plugin-grid`, + * `plugin-kanban` and the other view packages are not dependencies of this + * one. For the keys that act on the view's node (the grid's row keys, each + * kind's own block) the measurement is therefore the hand-off: the member + * reaches the node of the view that draws it, on the page path. What each + * child view then draws from its own node is pinned in that view's package. + * + * Every fixture is first parsed by the spec's own `list-view` row, so a key + * measured here is measured in a shape the platform accepts. + * + * The last describe block is the five keys measured NOT honoured on a page + * node, each held out of `inputs` with its reason in the console's repo-wide + * parity gate: `name` and `requiredPermissions` (read by nothing here), + * `sharing` and `addRecord` (each draws a button that does nothing on a page + * node), and `filterableFields` (read, but as the Filter panel's field list, + * not as the quick-filter shorthand the row declares it to be). One MEMBER is + * recorded the same way beside the key that carries it: + * `appearance.allowedVisualizations` narrows a view switcher a page node does + * not draw, while `appearance.showDescription` acts. + * + * The rows double as the member pins the console's repo-wide parity gate + * registers for this block's array/object-armed inputs (`list-view.data`, + * `list-view.columns`, `list-view.filter`, `list-view.sort`, + * `list-view.options`, and every structured key published here). + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, waitFor, cleanup, act, fireEvent, screen, within } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +// Registers the REAL `list-view` (and the `object-gallery` this file replaces below). +import '../index'; + +const OBJECT = 'crm_task'; + +type Row = Record; + +const ROWS: Row[] = [ + { id: 't-1', name: 'Draft the brief', stage: 'todo', amount: 5, owner: 'ada', due: '2026-10-20', finish: '2026-10-22', parent: null, location: 'Room 4' }, + { id: 't-2', name: 'Ship the fold', stage: 'doing', amount: 7, owner: 'bo', due: '2026-10-21', finish: '2026-10-25', parent: 't-1', location: 'Zoom' }, +]; + +const OBJECT_DEF = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + name: { name: 'name', type: 'text', label: 'Name' }, + stage: { + name: 'stage', + type: 'select', + label: 'Stage', + options: [ + { label: 'To do', value: 'todo' }, + { label: 'Doing', value: 'doing' }, + ], + }, + amount: { name: 'amount', type: 'number', label: 'Amount' }, + owner: { name: 'owner', type: 'text', label: 'Owner' }, + due: { name: 'due', type: 'date', label: 'Due' }, + finish: { name: 'finish', type: 'date', label: 'Finish' }, + parent: { name: 'parent', type: 'lookup', label: 'Parent', reference_to: OBJECT }, + location: { name: 'location', type: 'text', label: 'Location' }, + }, +}; + +/** The node `ListView` built for each child view, in render order. */ +const handed: Record = {}; +/** The rows `ListView` handed each child view beside its node, in render order. */ +const handedRows: Record = {}; +const CHILD_VIEWS = [ + 'object-grid', + 'object-kanban', + 'object-calendar', + 'object-gallery', + 'object-timeline', + 'object-gantt', + 'object-map', + 'object-tree', + 'object-chart', +] as const; +type ChildView = (typeof CHILD_VIEWS)[number]; + +for (const type of CHILD_VIEWS) { + ComponentRegistry.register( + type, + (props: { schema: Row; data?: unknown }) => { + (handed[type] ??= []).push(props.schema); + (handedRows[type] ??= []).push(props.data); + return
; + }, + { namespace: 'test', label: `${type} stand-in`, category: 'view' }, + ); +} + +const lastHanded = (type: ChildView): Row => { + const nodes = handed[type] ?? []; + return nodes[nodes.length - 1]; +}; + +function makeDataSource(rows: Row[] = ROWS, extra: Row = {}) { + return { + ...extra, + find: vi.fn(async (_object: string, _params?: Row) => ({ data: rows, total: rows.length })), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => rows.length), + getObjectSchema: vi.fn(async () => OBJECT_DEF), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }; +} +type Adapter = ReturnType; + +/** The `properties` every node carries: the object, and three columns. */ +const BASE: Row = { + data: { provider: 'object', object: OBJECT }, + columns: ['name', 'stage', 'amount'], +}; + +/** + * A page `list-view` node in the spec's `{ type, properties }` form. The + * properties are first parsed by the spec's own `list-view` row, so every + * fixture is one the platform's save gate accepts. + */ +function node(properties: Row = {}): Row { + const bag = { ...BASE, ...properties }; + const parsed = (ComponentPropsMap as Record { success: boolean; error?: unknown } }>)[ + 'list-view' + ].safeParse(bag); + if (!parsed.success) { + throw new Error(`fixture refused by the spec's list-view row: ${JSON.stringify(parsed.error)}`); + } + return { type: 'list-view', properties: bag }; +} + +function mount(properties: Row = {}, rows: Row[] = ROWS, locale?: string, adapterExtra: Row = {}) { + const dataSource = makeDataSource(rows, adapterExtra); + const tree = ( + + + + ); + // `LocalizationProvider` pins the display locale, the first limb of + // `useDisplayLocale()` — the channel this package's other locale pins use. + const view = render(locale ? {tree} : tree); + return { dataSource, ...view }; +} + +/** The query params of the first `find` the list sent. */ +async function firstQuery(dataSource: Adapter): Promise { + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + expect(dataSource.find.mock.calls[0][0]).toBe(OBJECT); + return (dataSource.find.mock.calls[0][1] ?? {}) as Row; +} + +/** Waits until `ListView` has handed a node to this child view. */ +async function handedTo(type: ChildView): Promise { + await waitFor(() => expect((handed[type] ?? []).length).toBeGreaterThan(0)); + return lastHanded(type); +} + +/** Waits for the object definition, so an absence below is not an unfinished load. */ +async function loaded(dataSource: Adapter): Promise { + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); +} + +const resetHanded = () => { + for (const type of CHILD_VIEWS) { + handed[type] = []; + handedRows[type] = []; + } +}; + +beforeEach(resetHanded); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('objectui#13047 — every `list-view` fixture here is one the spec row accepts', () => { + it('CONTROL: the row refuses a key it does not declare, so the guard in `node()` can fail', () => { + expect(() => node({ groupField: 'stage' })).toThrow(/refused by the spec/); + }); +}); + +describe('objectui#13047 — the toolbar and chrome keys, drawn by `ListView` itself', () => { + it('`description`: a string draws the info control with that text; absent draws none', async () => { + const control = mount(); + await loaded(control.dataSource); + expect(screen.queryByTestId('view-description-info')).toBeNull(); + cleanup(); + + const { dataSource } = mount({ description: 'Open work only' }); + await loaded(dataSource); + expect(screen.getByTestId('view-description-info').getAttribute('aria-label')).toBe('Open work only'); + }); + + it('`description`: an inline locale map resolves against the UI language', async () => { + const { dataSource } = mount({ description: { en: 'Open work only', 'zh-CN': '仅未完成' } }, ROWS, 'zh-CN'); + await loaded(dataSource); + expect(screen.getByTestId('view-description-info').getAttribute('aria-label')).toBe('仅未完成'); + }); + + it('`appearance.showDescription: false` hides the description the same node declares', async () => { + const { dataSource } = mount({ description: 'Open work only', appearance: { showDescription: false } }); + await loaded(dataSource); + expect(screen.queryByTestId('view-description-info')).toBeNull(); + }); + + it('`appearance.allowedVisualizations` changes nothing on a page node: the authored kind still draws, and no switcher is drawn to narrow', async () => { + // The whitelist narrows the kinds the view SWITCHER offers, and a page + // node draws no switcher (it is a host prop, `showViewSwitcher`, not a + // key of the row). So this member is inert here; `showDescription` above + // is the member that acts. Recorded, not hidden. + const { dataSource } = mount({ + type: 'kanban', + kanban: { groupByField: 'owner', columns: ['name'] }, + appearance: { allowedVisualizations: ['grid'] }, + }); + await handedTo('object-kanban'); + await loaded(dataSource); + expect(handed['object-grid']).toHaveLength(0); + }); + + it('`userActions`: `sort: false` takes the Sort control off the toolbar; absent keeps it', async () => { + const control = mount(); + await loaded(control.dataSource); + expect(screen.queryByTestId('toolbar-sort-trigger')).not.toBeNull(); + cleanup(); + + const { dataSource } = mount({ userActions: { sort: false } }); + await loaded(dataSource); + expect(screen.queryByTestId('toolbar-sort-trigger')).toBeNull(); + // Only the member it names: Filter stays. + expect(screen.queryByTestId('toolbar-filter-trigger')).not.toBeNull(); + }); + + it('`compactToolbar: false` puts the inline-edit toggle back on the toolbar; the default keeps it in the Display form', async () => { + const control = mount({ inlineEdit: false }); + await loaded(control.dataSource); + const compactButtons = control.container.querySelectorAll('[data-print-hide] button').length; + cleanup(); + + const { dataSource, container } = mount({ inlineEdit: false, compactToolbar: false }); + await loaded(dataSource); + expect(container.querySelectorAll('[data-print-hide] button').length).toBeGreaterThan(compactButtons); + }); + + it('`allowPrinting: true` draws the Print button; absent draws none', async () => { + const control = mount(); + await loaded(control.dataSource); + expect(screen.queryByTestId('print-button')).toBeNull(); + cleanup(); + + const { dataSource } = mount({ allowPrinting: true }); + await loaded(dataSource); + expect(screen.getByTestId('print-button')).toBeTruthy(); + }); + + it('`showRecordCount: false` takes the record count off the footer; absent shows it', async () => { + const control = mount(); + await loaded(control.dataSource); + await waitFor(() => expect(screen.queryByTestId('record-count-bar')).not.toBeNull()); + cleanup(); + + const { dataSource } = mount({ showRecordCount: false }); + await loaded(dataSource); + expect(screen.queryByTestId('record-count-bar')).toBeNull(); + }); + + it('`emptyState`: `title` and `message` replace the default empty copy when no record comes back', async () => { + const control = mount({}, []); + await loaded(control.dataSource); + expect(control.container.textContent ?? '').not.toContain('Nothing planned'); + cleanup(); + + const { dataSource, container } = mount({ emptyState: { title: 'Nothing planned', message: 'Add the first task' } }, []); + await loaded(dataSource); + await waitFor(() => expect(container.textContent ?? '').toContain('Nothing planned')); + expect(container.textContent ?? '').toContain('Add the first task'); + }); + + it('`userFilters`: a dropdown over `stage` draws the quick-filter bar; absent draws none', async () => { + const control = mount(); + await loaded(control.dataSource); + expect(screen.queryByTestId('user-filters')).toBeNull(); + cleanup(); + + const { dataSource } = mount({ userFilters: { element: 'dropdown', fields: [{ field: 'stage' }] } }); + await loaded(dataSource); + const bar = await screen.findByTestId('user-filters'); + expect(bar.textContent ?? '').toContain('Stage'); + }); + + it('`exportOptions`: `{ formats: [\'csv\'] }` offers the export, and `fileNamePrefix` names the file; absent offers none', async () => { + const control = mount({ compactToolbar: false }); + await loaded(control.dataSource); + expect(screen.queryByRole('button', { name: /export/i })).toBeNull(); + cleanup(); + + const downloads: string[] = []; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + downloads.push(this.download); + }); + const { dataSource } = mount({ compactToolbar: false, exportOptions: { formats: ['csv'], fileNamePrefix: 'tasks_export' } }); + await loaded(dataSource); + fireEvent.click(screen.getByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as csv/i })); + await waitFor(() => expect(downloads.length).toBeGreaterThan(0)); + expect(downloads[0]).toContain('tasks_export'); + expect(downloads[0]).toMatch(/\.csv$/); + }); + + it('`label`: names the exported file, a string as written and a locale map resolved against the UI language', async () => { + const downloads: string[] = []; + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + downloads.push(this.download); + }); + const exportCsv = async () => { + fireEvent.click(screen.getByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as csv/i })); + await waitFor(() => expect(downloads.length).toBeGreaterThan(0)); + return downloads.splice(0).pop() ?? ''; + }; + const exporting = { compactToolbar: false, exportOptions: { formats: ['csv'] } }; + + const control = mount(exporting); + await loaded(control.dataSource); + expect(await exportCsv()).not.toContain('Quarterly'); + cleanup(); + + const plain = mount({ ...exporting, label: 'Quarterly Review' }); + await loaded(plain.dataSource); + expect(await exportCsv()).toContain('Quarterly'); + cleanup(); + + const map = mount({ ...exporting, label: { en: 'Quarterly Review', 'zh-CN': '季度回顾' } }, ROWS, 'zh-CN'); + await loaded(map.dataSource); + const name = await exportCsv(); + expect(name).toContain('季度回顾'); + expect(name).not.toContain('object'); + }); + + it('`pagination.pageSize` caps the query; absent sends the default page', async () => { + const control = mount(); + const controlTop = (await firstQuery(control.dataSource)).$top; + expect(controlTop).not.toBe(7); + cleanup(); + + const { dataSource } = mount({ pagination: { pageSize: 7 } }); + expect((await firstQuery(dataSource)).$top).toBe(7); + }); + + it('`searchableFields`: a typed term is searched over the listed fields only', async () => { + const control = mount(); + await loaded(control.dataSource); + fireEvent.click(screen.getByTestId('search-icon-button')); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'Dune' } }); + await waitFor(() => + expect(control.dataSource.find.mock.calls.some((call) => (call[1] as Row | undefined)?.$search === 'Dune')).toBe(true), + ); + const controlSearch = control.dataSource.find.mock.calls.find((call) => (call[1] as Row | undefined)?.$search === 'Dune')?.[1] as Row; + expect(controlSearch.$searchFields).toBeUndefined(); + cleanup(); + + const { dataSource } = mount({ searchableFields: ['name', 'owner'] }); + await loaded(dataSource); + fireEvent.click(screen.getByTestId('search-icon-button')); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'Dune' } }); + await waitFor(() => + expect(dataSource.find.mock.calls.some((call) => (call[1] as Row | undefined)?.$search === 'Dune')).toBe(true), + ); + const searched = dataSource.find.mock.calls.find((call) => (call[1] as Row | undefined)?.$search === 'Dune')?.[1] as Row; + expect(searched.$searchFields).toEqual(['name', 'owner']); + }); + +}); + +describe('objectui#13047 — the record-source and query keys (the member pins the bump booked)', () => { + it('`data`: an `object` provider lists that object; a `value` provider hands its `items` to the view and queries nothing', async () => { + const { dataSource } = mount(); + await firstQuery(dataSource); + await waitFor(() => expect(JSON.stringify(handedRows['object-grid'])).toContain('Draft the brief')); + cleanup(); + + const inline = makeDataSource(); + render( + + + , + ); + await handedTo('object-grid'); + await waitFor(() => expect(JSON.stringify(handedRows['object-grid'])).toContain('Inline row')); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(inline.find).not.toHaveBeenCalled(); + }); + + it('`columns`: field names project the query in authored order, and the grid draws them in that order', async () => { + const { dataSource } = mount({ columns: ['owner', 'name'] }); + const query = await firstQuery(dataSource); + expect(query.$select).toEqual(expect.arrayContaining(['owner', 'name'])); + expect(query.$select).not.toContain('amount'); + const grid = await handedTo('object-grid'); + const names = (grid.columns as Array).map((c) => + typeof c === 'string' ? c : (c.field ?? c.name), + ); + expect(names).toEqual(['owner', 'name']); + }); + + it('`filter`: `ViewFilterRule` members reach `$filter` as `[field, operator, value]` triples; absent sends none', async () => { + const control = mount(); + expect((await firstQuery(control.dataSource)).$filter).toBeUndefined(); + cleanup(); + + const { dataSource } = mount({ filter: [{ field: 'stage', operator: 'equals', value: 'todo' }] }); + const filter = (await firstQuery(dataSource)).$filter; + expect(JSON.stringify(filter)).toContain('"stage"'); + expect(JSON.stringify(filter)).toContain('"todo"'); + }); + + it('`sort`: `{ field, order }` members reach `$orderby` in authored order', async () => { + const { dataSource } = mount({ sort: [{ field: 'amount', order: 'desc' }, { field: 'name', order: 'asc' }] }); + const orderby = (await firstQuery(dataSource)).$orderby as Array<{ field: string; order: string }>; + expect(orderby.map((s) => `${s.field} ${s.order}`)).toEqual(['amount desc', 'name asc']); + }); + + it('`options`: the per-kind bag binds the kind it names — `options.kanban.groupByField` lanes the board', async () => { + const { dataSource } = mount({ type: 'kanban', options: { kanban: { groupByField: 'stage', columns: ['name'] } } }); + const board = await handedTo('object-kanban'); + await loaded(dataSource); + expect(lastHanded('object-kanban').groupBy).toBe('stage'); + expect(board.type).toBe('object-kanban'); + }); +}); + +describe('objectui#13047 — the grid keys, handed to the `object-grid` node `ListView` builds', () => { + async function gridFor(properties: Row): Promise { + const { dataSource } = mount(properties); + await handedTo('object-grid'); + await loaded(dataSource); + return lastHanded('object-grid'); + } + async function controlGrid(): Promise { + const grid = await gridFor({}); + cleanup(); + resetHanded(); + return grid; + } + + it('`resizable: false` reaches the grid; absent leaves the grid its own default', async () => { + expect((await controlGrid()).resizable).toBeUndefined(); + expect((await gridFor({ resizable: false })).resizable).toBe(false); + }); + + it('`selection.type` reaches the grid', async () => { + expect((await controlGrid()).selection).toBeUndefined(); + expect((await gridFor({ selection: { type: 'single' } })).selection).toEqual({ type: 'single' }); + }); + + it('`rowHeight` sets the grid\'s density', async () => { + const control = (await controlGrid()).rowHeight; + const tall = (await gridFor({ rowHeight: 'tall' })).rowHeight; + expect(tall).not.toBe(control); + expect(tall).toBe('tall'); + }); + + it('`grouping.fields` groups the grid by the named field, over an adapter that answers the group header query', async () => { + expect((await controlGrid()).grouping).toBeUndefined(); + const { dataSource } = mount({ grouping: { fields: [{ field: 'stage', order: 'asc' }] } }, ROWS, undefined, { + queryGroupHeaders: vi.fn(async () => []), + }); + await handedTo('object-grid'); + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + const grouping = lastHanded('object-grid').grouping as { fields: Array<{ field: string }> }; + expect(grouping.fields.map((f) => f.field)).toEqual(['stage']); + }); + + it('`grouping.fields` over an adapter that cannot answer the group header query refuses instead of grouping one page', async () => { + const { dataSource } = mount({ grouping: { fields: [{ field: 'stage', order: 'asc' }] } }); + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + await screen.findByTestId('list-grouping-needs-header-query'); + expect(handed['object-grid']).toHaveLength(0); + }); + + it('`rowColor.field` colours the grid\'s rows by that field', async () => { + expect((await controlGrid()).rowColor).toBeUndefined(); + const rowColor = (await gridFor({ rowColor: { field: 'stage', colors: { todo: '#ff0000' } } })).rowColor as Row; + expect(rowColor.field).toBe('stage'); + expect(rowColor.colors).toEqual({ todo: '#ff0000' }); + }); + + it('`hiddenFields`: a member subtracts that column from the grid; a name no column projects subtracts nothing', async () => { + const names = (grid: Row) => + (grid.columns as Array).map((c) => (typeof c === 'string' ? c : (c.field ?? c.name))); + expect(names(await controlGrid())).toEqual(['name', 'stage', 'amount']); + expect(names(await gridFor({ hiddenFields: ['stage', 'owner'] }))).toEqual(['name', 'amount']); + }); + + it('`fieldOrder`: members order the surviving columns, and an unlisted column sorts last', async () => { + const names = (grid: Row) => + (grid.columns as Array).map((c) => (typeof c === 'string' ? c : (c.field ?? c.name))); + expect(names(await controlGrid())).toEqual(['name', 'stage', 'amount']); + expect(names(await gridFor({ fieldOrder: ['amount', 'name'] }))).toEqual(['amount', 'name', 'stage']); + }); + + it('`rowActions`: the action names reach the grid\'s row menu', async () => { + expect((await controlGrid()).rowActions).toBeUndefined(); + expect((await gridFor({ rowActions: ['complete_task', 'reassign'] })).rowActions).toEqual(['complete_task', 'reassign']); + }); + + it('`bulkActions`: the action names reach the grid as its batch actions', async () => { + expect((await controlGrid()).batchActions).toBeUndefined(); + expect((await gridFor({ bulkActions: ['complete_task'] })).batchActions).toEqual(['complete_task']); + }); + + it('`bulkActionDefs`: the definitions reach the grid unchanged', async () => { + expect((await controlGrid()).bulkActionDefs).toBeUndefined(); + const defs = [{ name: 'mark_doing', label: 'Mark doing', operation: 'update', patch: { stage: 'doing' } }]; + expect((await gridFor({ bulkActionDefs: defs })).bulkActionDefs).toEqual(defs); + }); + + it('`conditionalFormatting`: `{ condition, style }` rules reach the grid', async () => { + expect((await controlGrid()).conditionalFormatting).toBeUndefined(); + const rules = [{ condition: "record.stage == 'todo'", style: { backgroundColor: '#ff0000' } }]; + expect((await gridFor({ conditionalFormatting: rules })).conditionalFormatting).toEqual(rules); + }); + + it('`inlineEdit: true` renders the grid editable', async () => { + expect((await controlGrid()).editable).toBe(false); + expect((await gridFor({ inlineEdit: true })).editable).toBe(true); + }); + + it('`navigation.mode: \'drawer\'` opens the record in a drawer titled by the list\'s `label` when a row is clicked', async () => { + const clickFirstRow = async () => { + const grid = lastHanded('object-grid'); + await act(async () => { + (grid.onRowClick as (row: Row) => void)(ROWS[0]); + }); + }; + + const control = mount({ label: 'Quarterly' }); + await handedTo('object-grid'); + await loaded(control.dataSource); + await clickFirstRow(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(screen.queryByRole('dialog')).toBeNull(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ label: 'Quarterly', navigation: { mode: 'drawer' } }); + await handedTo('object-grid'); + await loaded(dataSource); + await clickFirstRow(); + const dialog = await screen.findByRole('dialog'); + expect(dialog.textContent ?? '').toContain('Quarterly'); + }); +}); + +describe('objectui#13047 — each kind\'s own block, handed to the node of the view that draws it', () => { + it('`kanban`: `groupByField` lanes the board and `titleField` titles its cards', async () => { + const { dataSource } = mount({ type: 'kanban', kanban: { groupByField: 'owner', titleField: 'name', columns: ['amount'] } }); + await handedTo('object-kanban'); + await loaded(dataSource); + const board = lastHanded('object-kanban'); + expect(board.groupBy).toBe('owner'); + expect(board.titleField).toBe('name'); + expect(board.cardFields).toEqual(['amount']); + }); + + it('CONTROL: `type: \'kanban\'` without a kanban block lanes the board on a field the list derives, not on `owner`', async () => { + const { dataSource } = mount({ type: 'kanban' }); + await loaded(dataSource); + const board = lastHanded('object-kanban'); + expect(board?.groupBy).not.toBe('owner'); + }); + + it('`calendar`: `startDateField` / `endDateField` / `titleField` bind the calendar', async () => { + const control = mount({ type: 'calendar' }); + await loaded(control.dataSource); + expect(lastHanded('object-calendar')?.startDateField).toBeUndefined(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'calendar', calendar: { startDateField: 'due', endDateField: 'finish', titleField: 'owner' } }); + await handedTo('object-calendar'); + await loaded(dataSource); + const calendar = lastHanded('object-calendar'); + expect(calendar.startDateField).toBe('due'); + expect(calendar.endDateField).toBe('finish'); + expect(calendar.titleField).toBe('owner'); + }); + + it('`gantt`: `startDateField` / `endDateField` / `titleField` bind the chart', async () => { + const control = mount({ type: 'gantt' }); + await loaded(control.dataSource); + expect(lastHanded('object-gantt')?.startDateField).toBeUndefined(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'gantt', gantt: { startDateField: 'due', endDateField: 'finish', titleField: 'owner' } }); + await handedTo('object-gantt'); + await loaded(dataSource); + const gantt = lastHanded('object-gantt'); + expect(gantt.startDateField).toBe('due'); + expect(gantt.endDateField).toBe('finish'); + expect(gantt.titleField).toBe('owner'); + }); + + it('`gallery`: `coverField` and `titleField` reach the gallery', async () => { + const control = mount({ type: 'gallery' }); + await loaded(control.dataSource); + expect((lastHanded('object-gallery')?.gallery as Row | undefined)?.coverField).toBeUndefined(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'gallery', gallery: { coverField: 'location', titleField: 'owner' } }); + await handedTo('object-gallery'); + await loaded(dataSource); + const gallery = lastHanded('object-gallery'); + expect((gallery.gallery as Row).coverField).toBe('location'); + expect(gallery.titleField).toBe('owner'); + }); + + it('`timeline`: `startDateField` and `titleField` bind the timeline', async () => { + const control = mount({ type: 'timeline' }); + await loaded(control.dataSource); + expect(lastHanded('object-timeline')?.startDateField).toBeUndefined(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'timeline', timeline: { startDateField: 'due', titleField: 'owner' } }); + await handedTo('object-timeline'); + await loaded(dataSource); + const timeline = lastHanded('object-timeline'); + expect(timeline.startDateField).toBe('due'); + expect(timeline.titleField).toBe('owner'); + expect((timeline.timeline as Row).startDateField).toBe('due'); + }); + + it('`map`: `latitudeField` / `longitudeField` bind the map', async () => { + const control = mount({ type: 'map' }); + await loaded(control.dataSource); + expect(lastHanded('object-map')?.latitudeField).toBeUndefined(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'map', map: { latitudeField: 'amount', longitudeField: 'amount' } }); + await handedTo('object-map'); + await loaded(dataSource); + const map = lastHanded('object-map'); + expect(map.latitudeField).toBe('amount'); + expect(map.longitudeField).toBe('amount'); + }); + + it('`tree`: `parentField` and `labelField` bind the tree', async () => { + const control = mount({ type: 'tree' }); + await loaded(control.dataSource); + expect(lastHanded('object-tree')?.parentField).toBeUndefined(); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'tree', tree: { parentField: 'parent', labelField: 'owner' } }); + await handedTo('object-tree'); + await loaded(dataSource); + const tree = lastHanded('object-tree'); + expect(tree.parentField).toBe('parent'); + expect(tree.labelField).toBe('owner'); + }); + + it('`chart`: the `dataset` binding and its `values` / `dimensions` reach the chart', async () => { + const { dataSource } = mount({ + type: 'chart', + chart: { dataset: 'task_metrics', values: ['total_amount'], dimensions: ['stage'] }, + }); + await handedTo('object-chart'); + await loaded(dataSource); + const chart = lastHanded('object-chart'); + expect(chart.dataset).toBe('task_metrics'); + expect(chart.values).toEqual(['total_amount']); + expect(chart.dimensions).toEqual(['stage']); + }); +}); + +describe('objectui#13047 — measured NOT honoured on a page `list-view` node (held out of `inputs`)', () => { + it('`name`: the node draws and queries the same with and without it', async () => { + const control = mount(); + const controlQuery = await firstQuery(control.dataSource); + await loaded(control.dataSource); + const controlText = control.container.textContent; + const controlGrid = JSON.stringify({ ...lastHanded('object-grid'), onRowClick: null }); + cleanup(); + resetHanded(); + + const { dataSource, container } = mount({ name: 'open_tasks' }); + expect(await firstQuery(dataSource)).toEqual(controlQuery); + await loaded(dataSource); + expect(container.textContent).toBe(controlText); + expect(JSON.stringify({ ...lastHanded('object-grid'), onRowClick: null })).toBe(controlGrid); + expect(container.textContent ?? '').not.toContain('open_tasks'); + }); + + it('`requiredPermissions`: a capability nobody holds still draws and queries the list', async () => { + const { dataSource } = mount({ requiredPermissions: ['capability_nobody_holds'] }); + await firstQuery(dataSource); + await handedTo('object-grid'); + }); + + it('`sharing`: draws a Share button that carries no click handler — the sharing config is enforced nowhere here', async () => { + const { dataSource } = mount({ sharing: { type: 'personal' } }); + await loaded(dataSource); + const share = screen.getByTestId('share-button'); + // React attaches no handler: a click changes nothing on the page. + const before = document.body.innerHTML; + fireEvent.click(share); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(document.body.innerHTML).toBe(before); + }); + + it('`filterableFields`: the row calls it the shorthand of `userFilters.fields`, yet it draws no quick-filter bar', async () => { + const { dataSource } = mount({ filterableFields: ['stage'] }); + await loaded(dataSource); + expect(screen.queryByTestId('user-filters')).toBeNull(); + }); + + it('`filterableFields`: what the list reads it as instead — the Filter panel\'s field list', async () => { + const firstFilterField = async (): Promise => { + fireEvent.click(screen.getByRole('button', { name: /^filter/i })); + await screen.findByText('Filter Records'); + const panel = screen.getByText('Filter Records').closest('[role="dialog"]') as HTMLElement; + fireEvent.click(within(panel).getByRole('button', { name: /add filter/i })); + let text = ''; + await waitFor(() => { + text = within(panel).getAllByRole('combobox')[0].textContent ?? ''; + expect(text).not.toBe(''); + }); + return text; + }; + + const control = mount(); + await loaded(control.dataSource); + expect(await firstFilterField()).toBe('Name'); + cleanup(); + + const { dataSource } = mount({ filterableFields: ['amount', 'owner'] }); + await loaded(dataSource); + expect(await firstFilterField()).toBe('Amount'); + }); + it('`addRecord.enabled`: draws an Add button that does nothing on a page node, which has no host `onAddRecord`', async () => { + const { dataSource } = mount({ addRecord: { enabled: true } }); + await loaded(dataSource); + const add = screen.getByTestId('add-record-button'); + const before = document.body.innerHTML; + fireEvent.click(add); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(document.body.innerHTML).toBe(before); + expect(dataSource.create).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 47869e46d7..2ed1ea1932 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -104,26 +104,255 @@ const LIST_VIEW_TYPE_INPUT: ComponentInput = { 'The list kind, the spec\'s list view `type`. Absent, a bound saved view\'s kind applies, else a grid. On a metadata page it is written `properties.type`; the node\'s own `type` stays `list-view`.', } as const; +/** + * The keys a page `list-view` node is written in, spelled once for both + * registrations below. + * + * Every key here is a key of the `list-view` row of `@objectstack/spec`'s + * `ComponentPropsMap` (objectstack-ai/objectstack#22444), which is the list + * view's own `ListViewSchema`, and each one is published because the renderer + * was measured to honour it on a page node: authored in the node's + * `properties`, rendered through `SchemaRenderer` and this registration, and + * asserted at what it draws, what it queries or what it hands the view that + * draws it (`__tests__/ListViewBlock.specInputs-13047.test.tsx`, + * objectui#13047). The member shapes are the spec's, named in each + * description; the spec row is the judge of values. + * + * Four keys of the row are NOT here, by the same measurement: `name` (read by + * nothing on a page node), `requiredPermissions` (no audience gate on this + * block), `sharing` (draws a Share button that does nothing) and `addRecord` + * (draws an Add button with no handler on a page node). So is + * `filterableFields`: the row declares it as the shorthand of + * `userFilters.fields`, and the list reads it as the Filter panel's field list + * instead. The console's repo-wide parity gate + * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) records + * each with its reason. + * + * `data` is NOT required (objectui#11605): the page compile reads this list, + * so `required: true` would refuse a node bound by `dataSource.object`, which + * the renderer accepts. A node with neither gets the "no object named" hint + * `ListViewBlock` draws. + */ +const LIST_VIEW_INPUTS: ComponentInput[] = [ + LIST_VIEW_DATA_INPUT, + LIST_VIEW_TYPE_INPUT, + // ── the query ───────────────────────────────────────────────────────────── + { + name: 'columns', + type: 'array', + description: + 'The fields the list shows, in order: field names (`["name", "stage"]`) or the spec\'s `ListColumn` objects. They are also the query\'s `$select`. Optional on a page node: a node bound to a saved view through `dataSource.view` draws that view\'s columns.', + }, + { + name: 'filter', + type: 'array', + description: 'The base query filter, the spec\'s `ViewFilterRule` array `[{ field, operator, value }]`, lowered to `$filter`.', + }, + { + name: 'sort', + type: 'array', + description: 'The initial sort order, `[{ field, order }]`, lowered to `$orderby` in the order written.', + }, + { + name: 'pagination', + type: 'object', + description: + 'The spec\'s pagination config, `{ pageSize, pageSizeOptions }`: `pageSize` caps each query (`$top`), and `pageSizeOptions` offers those sizes to the user.', + }, + { + name: 'searchableFields', + type: 'array', + of: 'string', + description: 'The field names the toolbar search box searches. A term typed into the box is sent with these fields only.', + }, + // ── identity ────────────────────────────────────────────────────────────── + { + name: 'label', + type: ['string', 'object'], + description: + 'The list\'s label: the export file name, the record-detail overlay heading, and the title of the compact list in a narrow page region. A plain string, or an inline per-locale map (`{ en: "Open tasks", "zh-CN": "未完成任务" }`) resolved against the display locale.', + }, + { + name: 'description', + type: ['string', 'object'], + description: + 'One line about the list, drawn as the toolbar\'s info control (its tooltip and accessible name). A plain string, or an inline per-locale map resolved against the display locale; a map with no usable entry draws nothing. `appearance.showDescription: false` hides it.', + }, + // ── the toolbar ─────────────────────────────────────────────────────────── + { + name: 'userFilters', + type: 'object', + description: + 'The quick-filter bar above the list, the spec\'s `UserFilters` config: `{ element: "dropdown" | "toggle" | "tabs", fields | tabs }`. Each filter narrows the query.', + }, + { + name: 'userActions', + type: 'object', + description: + 'Which toolbar controls the user gets, `{ search, sort, filter, rowHeight, group, refresh, hideFields, rowColor, editInline, … }`. `false` takes that control off the toolbar; `hideFields` and `rowColor` are off unless `true`.', + }, + { + name: 'compactToolbar', + type: 'boolean', + description: + 'Gather the grouping, row colour, density and hide-fields controls into one Display form. On by default; `false` draws one toolbar button per control.', + }, + { + name: 'appearance', + type: 'object', + description: + 'The spec\'s appearance config, `{ showDescription, allowedVisualizations }`. `showDescription: false` hides the description. `allowedVisualizations` narrows the kinds a view switcher offers, and a page node draws no switcher, so on a page it changes nothing: the node\'s `type` is the kind drawn.', + }, + { + name: 'exportOptions', + type: 'object', + description: + 'The export menu, `{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. Declaring it offers the export on the toolbar, in the listed formats; `fileNamePrefix` names the file.', + }, + { name: 'allowPrinting', type: 'boolean', description: 'Draw a Print button that opens the browser\'s print dialog for the list.' }, + { + name: 'showRecordCount', + type: 'boolean', + description: 'The record count in the list\'s footer. On by default; `false` takes it off.', + }, + { + name: 'emptyState', + type: 'object', + description: + 'What the list draws when no record comes back, the spec\'s `EmptyState`: `{ title, message, icon, link }`. `title`, `message` and the link\'s `label` each take a plain string or an inline per-locale map.', + }, + // ── the grid ────────────────────────────────────────────────────────────── + { + name: 'hiddenFields', + type: 'array', + of: 'string', + description: 'Field names taken out of `columns`. A name `columns` does not show takes nothing out.', + }, + { + name: 'fieldOrder', + type: 'array', + of: 'string', + description: + 'Field names that order the columns left after `hiddenFields`. A column not listed comes after every listed one; a listed name that is not a column orders nothing.', + }, + { + name: 'rowHeight', + type: 'enum', + enum: ['compact', 'short', 'medium', 'tall', 'extra_tall'], + description: 'Row density. Absent, rows are `compact`.', + }, + { name: 'resizable', type: 'boolean', description: 'Let users drag column borders to resize the grid\'s columns.' }, + { + name: 'selection', + type: 'object', + description: 'Row selection, `{ type: "none" | "single" | "multiple" }`.', + }, + { + name: 'navigation', + type: 'object', + description: + 'What clicking a row does, the spec\'s `NavigationConfig`: `{ mode: "page" | "drawer" | "modal" | "split" | "popover" | "new_window" | "none", … }`. An overlay mode opens the record over the list, headed by the list\'s `label`.', + }, + { + name: 'grouping', + type: 'object', + description: 'Group the rows, `{ fields: [{ field, order, collapsed }] }`. The groups and their header counts come from the server.', + }, + { + name: 'rowColor', + type: 'object', + description: 'Colour whole rows by a field\'s value, `{ field, colors: { VALUE: COLOR } }`.', + }, + { + name: 'conditionalFormatting', + type: 'array', + of: 'object', + description: + 'Row style rules, each `{ condition, style }`: a CEL `condition` over the row\'s `record.*` and a CSS `style` map. The first rule that matches styles the row.', + }, + { + name: 'inlineEdit', + type: 'boolean', + description: 'Open the grid in inline-edit mode, for users who may update the object.', + }, + { + name: 'rowActions', + type: 'array', + of: 'string', + description: 'Names of the actions offered on each row\'s menu.', + }, + { + name: 'bulkActions', + type: 'array', + of: 'string', + description: 'Names of the actions offered once rows are selected. Needs a multi-row `selection`.', + }, + { + name: 'bulkActionDefs', + type: 'array', + of: 'object', + description: + 'Full bulk-action definitions, the spec\'s `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection.', + }, + // ── each kind's own block ───────────────────────────────────────────────── + { + name: 'options', + type: 'object', + description: + 'Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind\'s own block. The top-level block of the same name is the canonical spelling.', + }, + { + name: 'kanban', + type: 'object', + description: + 'The board, the spec\'s kanban block: `{ groupByField, columns, titleField, summarizeField }`. `groupByField` is the lane field and `columns` the fields on each card.', + }, + { + name: 'calendar', + type: 'object', + description: 'The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`.', + }, + { + name: 'gantt', + type: 'object', + description: + 'The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it.', + }, + { + name: 'gallery', + type: 'object', + description: 'The gallery, `{ coverField, titleField, visibleFields, cardSize, … }`.', + }, + { + name: 'timeline', + type: 'object', + description: 'The timeline, `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`.', + }, + { + name: 'map', + type: 'object', + description: 'The map, `{ locationField }` or `{ latitudeField, longitudeField }`, with `titleField`, `zoom` and `center`.', + }, + { + name: 'tree', + type: 'object', + description: 'The tree, `{ parentField, labelField, fields, defaultExpandedDepth }`.', + }, + { + name: 'chart', + type: 'object', + description: + 'The chart a `type: "chart"` list draws, bound to an ADR-0021 dataset: `{ dataset, values, dimensions, chartType }`. There is no default binding.', + }, +]; + // Register ListView component ComponentRegistry.register('list-view', ListViewRenderer, { namespace: 'plugin-list', label: 'List View', category: 'Views', icon: 'LayoutList', - inputs: [ - // `data` is NOT required (objectui#11605). This block has no - // `ComponentPropsMap` row yet; the contract is the binding doc - // (`content/docs/guide/data-source.md`). The page compile reads this list, - // so `required: true` would refuse a node bound by `dataSource.object`, - // which the renderer accepts. A node with neither gets the "no object - // named" hint `ListViewBlock` draws. - { ...LIST_VIEW_DATA_INPUT }, - { ...LIST_VIEW_TYPE_INPUT }, - { name: 'columns', type: 'array' }, - { name: 'filter', type: 'array' }, - { name: 'sort', type: 'array' }, - { name: 'options', type: 'object' }, - ], + inputs: LIST_VIEW_INPUTS.map((input) => ({ ...input })), // No `type` default: an absent kind IS the default (the spec's list view // `type` defaults to `grid`), and leaving it absent lets a bound saved view's // kind apply. A `type` here would also be the registry key's name to any @@ -152,13 +381,6 @@ ComponentRegistry.register('list', ListViewRenderer, { category: 'view', label: 'List', icon: 'LayoutList', - inputs: [ - // Same renderer, same spelling and same reason as `list-view` above. - { ...LIST_VIEW_DATA_INPUT }, - { ...LIST_VIEW_TYPE_INPUT }, - { name: 'columns', type: 'array' }, - { name: 'filter', type: 'array' }, - { name: 'sort', type: 'array' }, - { name: 'options', type: 'object' }, - ] + // Same renderer, therefore the same declared surface as `list-view` above. + inputs: LIST_VIEW_INPUTS.map((input) => ({ ...input })), }); From dea7d3582701a2ba8b22bd015cd457510dd7aee0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:04:31 +0000 Subject: [PATCH 2/2] test(plugin-list): measure list-view options against a field the list cannot derive The options row authored the fixture's only select field, the board's derived default, so it passed with options ignored. Author `owner` and assert a bare kanban node lanes elsewhere. Trim the label, description and userActions descriptions to what the measurement covers. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../registry-inputs-spec-parity.test.ts | 2 +- .../ListViewBlock.specInputs-13047.test.tsx | 17 ++++++++++++++--- packages/plugin-list/src/index.tsx | 6 +++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index d75b33e3f2..48a537677f 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3021,7 +3021,7 @@ const MEMBER_PINS: Record = { }, 'list-view.options': { file: LIST_VIEW_PIN_FILE, - pins: 'The per-kind bag: `options.kanban.groupByField` lanes the board the same way the top-level kanban block does, read off the `object-kanban` node `ListView` builds (objectui#13047).', + pins: 'The per-kind bag: `options.kanban.groupByField: \'owner\'` lanes the board on `owner` and its `columns` become the card fields, read off the `object-kanban` node `ListView` builds, against a CONTROL bare kanban node that lanes on a field the list derives itself (not `owner`), so the row reads the bag and not the default (objectui#13047).', }, 'list-view.pagination': { file: LIST_VIEW_PIN_FILE, diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index 8ca6b2f955..b276e1a971 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -472,12 +472,23 @@ describe('objectui#13047 — the record-source and query keys (the member pins t expect(orderby.map((s) => `${s.field} ${s.order}`)).toEqual(['amount desc', 'name asc']); }); - it('`options`: the per-kind bag binds the kind it names — `options.kanban.groupByField` lanes the board', async () => { - const { dataSource } = mount({ type: 'kanban', options: { kanban: { groupByField: 'stage', columns: ['name'] } } }); + it('`options`: the per-kind bag binds the kind it names — `options.kanban.groupByField` lanes the board on a field the list would not pick', async () => { + // CONTROL first: the bare kanban node lanes on a field the list derives + // itself (the fixture's select field), so the authored `owner` below can + // only come from the bag. + const control = mount({ type: 'kanban' }); + await loaded(control.dataSource); + const derived = lastHanded('object-kanban')?.groupBy; + expect(derived).not.toBe('owner'); + cleanup(); + resetHanded(); + + const { dataSource } = mount({ type: 'kanban', options: { kanban: { groupByField: 'owner', columns: ['name'] } } }); const board = await handedTo('object-kanban'); await loaded(dataSource); - expect(lastHanded('object-kanban').groupBy).toBe('stage'); expect(board.type).toBe('object-kanban'); + expect(lastHanded('object-kanban').groupBy).toBe('owner'); + expect(lastHanded('object-kanban').cardFields).toEqual(['name']); }); }); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 2ed1ea1932..37601fddce 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -170,13 +170,13 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ name: 'label', type: ['string', 'object'], description: - 'The list\'s label: the export file name, the record-detail overlay heading, and the title of the compact list in a narrow page region. A plain string, or an inline per-locale map (`{ en: "Open tasks", "zh-CN": "未完成任务" }`) resolved against the display locale.', + 'The list\'s label: it names the exported file and heads the record-detail overlay. A plain string, or an inline per-locale map (`{ en: "Open tasks", "zh-CN": "未完成任务" }`) resolved against the display locale.', }, { name: 'description', type: ['string', 'object'], description: - 'One line about the list, drawn as the toolbar\'s info control (its tooltip and accessible name). A plain string, or an inline per-locale map resolved against the display locale; a map with no usable entry draws nothing. `appearance.showDescription: false` hides it.', + 'One line about the list, drawn as the toolbar\'s info control (its tooltip and accessible name). A plain string, or an inline per-locale map resolved against the display locale. `appearance.showDescription: false` hides it.', }, // ── the toolbar ─────────────────────────────────────────────────────────── { @@ -189,7 +189,7 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ name: 'userActions', type: 'object', description: - 'Which toolbar controls the user gets, `{ search, sort, filter, rowHeight, group, refresh, hideFields, rowColor, editInline, … }`. `false` takes that control off the toolbar; `hideFields` and `rowColor` are off unless `true`.', + 'Which toolbar controls the user gets, the spec\'s `UserActionsConfig` of per-control toggles: a control set to `false` (for example `sort: false`) is taken off the toolbar, and the controls it does not name stay.', }, { name: 'compactToolbar',