From 6ff0aaac9c56f3b6e8c0aaf10d82d0e6d7926776 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:26:13 +0000 Subject: [PATCH 1/7] feat(plugin-grid,plugin-list,i18n): a grouped grid over a data source with no group header query refuses grouping; the Partial marker is retired (objectui#10881) Maintainer ruling F on objectui#10881. A grouped grid that fetches its own rows over a data source declaring no `queryGroupHeaders` renders an error panel naming that member and issues no row query, instead of fetching a window and bucketing it. ListView makes the same refusal before it mounts a grouped grid over such a source, since it would otherwise hand the grid a window the grid takes as whole. Rows handed in whole still group in the browser, exactly. The Partial marker is retired everywhere: the three grid.grouping.partial* keys (ObjectGrid defaults and the ten packs), GroupRow's partialLabel / partialTitle, and groupedPartialDisclosure-7189.test.tsx. One new key, grid.grouping.needsHeaderQuery, carries the refusal in all ten packs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-grid.mdx | 34 +- .../groupingPartialRetired-10881.test.ts | 55 +++ packages/i18n/src/locales/ar.ts | 23 +- packages/i18n/src/locales/de.ts | 23 +- packages/i18n/src/locales/en.ts | 29 +- packages/i18n/src/locales/es.ts | 23 +- packages/i18n/src/locales/fr.ts | 23 +- packages/i18n/src/locales/ja.ts | 23 +- packages/i18n/src/locales/ko.ts | 23 +- packages/i18n/src/locales/pt.ts | 23 +- packages/i18n/src/locales/ru.ts | 23 +- packages/i18n/src/locales/zh.ts | 23 +- packages/plugin-grid/README.md | 34 +- packages/plugin-grid/src/GroupRow.tsx | 36 -- packages/plugin-grid/src/ObjectGrid.tsx | 134 ++---- .../gridArrayArmOrderby-8973.test.tsx | 4 +- .../gridGroupingMembers-8071.test.tsx | 4 +- .../groupedPartialDisclosure-7189.test.tsx | 446 ------------------ .../groupingNeedsHeaderQuery-10881.test.tsx | 166 +++++++ .../__tests__/serverGrouping-7189.test.tsx | 31 +- packages/plugin-grid/src/useGroupedData.ts | 9 +- packages/plugin-list/README.md | 24 +- packages/plugin-list/src/ListView.tsx | 74 ++- ...istView.groupedGridOwnsFetch-7189.test.tsx | 18 +- ...ew.groupingNeedsHeaderQuery-10881.test.tsx | 148 ++++++ .../react/src/utils/nonGridRowCeiling.tsx | 9 +- packages/types/src/data.ts | 6 +- 28 files changed, 705 insertions(+), 765 deletions(-) create mode 100644 packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts delete mode 100644 packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 29cbcca08a..07f95b97ef 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -781,7 +781,7 @@ A data grid that auto-fetches from an ObjectQL object definition. Includes searc | `operations` | `object` | Enabled CRUD operations. | | `rowActions` / `bulkActions` | `string[]` | Action identifiers for rows and batch selection. `bulkActions` is the spec-aligned key; `batchActions` is a legacy alias that takes precedence when both are set. | | `editable` | `boolean` | Enable inline cell editing. | -| `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query the grid groups the page it fetched and marks the counts partial. | +| `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 5d81c27f75..f51640731c 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -362,23 +362,27 @@ filter AND the group's key, `limit` / `offset` per group). So: - A `lookup` / `master_detail` / `user` grouping key is the referenced record's id on the wire; the grid labels it from the referenced record. -When a `ListView` hosts the grid (the console's list views), it hands a grouped -grid its own fetch and the view's effective filter, rather than a window of -rows. - -Two cases still group **in the browser**: - -- **Rows handed in whole** (`data: { provider: 'value', items }`, or a host's - whole result set): nothing was withheld, so grouping them there is exact. -- **A data source that declares no `queryGroupHeaders`**: the grid can only - group the page it fetched. Every count is then a page slice and a group whose - records all fall past the page is absent, so the grid says so where the - numbers are — a short `Partial` marker beside every group count and a line - above the group list (*"Grouped over the first 100 of 186 records…"*). With - counts from the server the marker never appears. +When a `ListView` hosts the grid (the console's list views) over a data source +that answers the group header query, it hands a grouped grid its own fetch and +the view's effective filter, rather than a window of rows. + +**Rows handed in whole** (`data: { provider: 'value', items }`, or a host's +whole result set) are grouped where they are, in the browser: nothing was +withheld, so the grouping is exact. The grid takes rows a host hands it to be +the whole set. + +**Everything else needs the group header query** (objectui#10881). Over a data +source that declares no `queryGroupHeaders`, a grouped grid that fetches its +own rows does not group a page of them — every count would be a page slice, +and a group whose records all fall past the page would be missing. It shows an +error naming `queryGroupHeaders` instead, and asks for no rows. A `ListView` +makes the same refusal before it mounts such a grid. To group, implement +`queryGroupHeaders` on the data source, or hand the rows in whole. A toolbar **search** has no counterpart on the group header query, so a -`ListView` keeps grouping its own window while a search term is active. +`ListView` over a data source that answers it keeps grouping its own window +while a search term is active: those group counts are the window's, not the +query's (objectstack#20358). ### ObjectQL Integration diff --git a/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts b/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts new file mode 100644 index 0000000000..d56299b142 --- /dev/null +++ b/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts @@ -0,0 +1,55 @@ +/** + * 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#10881 — the grouped grid's `Partial` marker is retired from every + * pack, and the refusal that replaced it is in every pack. + * + * Maintainer ruling F: a grouped grid over a data source that declares no + * `queryGroupHeaders` refuses grouping loudly, naming that member, instead of + * grouping the page it fetched and marking the counts partial — so the + * marker's three `grid.grouping.partial*` rows go from all ten packs, and one + * `grid.grouping.needsHeaderQuery` row arrives in all ten. + * + * The absence half is only a measurement if the walker below can see that + * namespace at all, so the presence half is its control: the SAME walk over + * the SAME packs must find the new key in every one of them. A walker that + * never reached `grid.grouping` would pass the first assertion and fail the + * second. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +/** Every leaf key of a pack, dotted, with its value. */ +function leaves(node: unknown, prefix = ''): Array<[string, unknown]> { + if (node === null || typeof node !== 'object' || Array.isArray(node)) return [[prefix, node]]; + return Object.entries(node as Record).flatMap(([key, value]) => + leaves(value, prefix ? `${prefix}.${key}` : key), + ); +} + +const PACKS = Object.entries(builtInLocales); + +describe('the grouped grid marker is retired from every locale pack (objectui#10881)', () => { + it('reads all ten packs', () => { + expect(PACKS.map(([code]) => code).sort()).toEqual(['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'zh']); + }); + + it.each(PACKS)('%s carries no grid.grouping.partial* key', (_code, pack) => { + const partial = leaves(pack).map(([key]) => key).filter((key) => key.startsWith('grid.grouping.partial')); + expect(partial).toEqual([]); + }); + + // CONTROL — the same walk reaches `grid.grouping`: the refusal is there, and + // names the member it refuses on in every language. + it.each(PACKS)('%s carries grid.grouping.needsHeaderQuery, naming queryGroupHeaders', (_code, pack) => { + const value = new Map(leaves(pack)).get('grid.grouping.needsHeaderQuery'); + expect(typeof value).toBe('string'); + expect(value as string).toContain('queryGroupHeaders'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 3d198a3ff5..0d6c04569b 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -139,12 +139,12 @@ const ar = { printDialogHint: "يفتح مربع حوار الطباعة في المتصفح (ليس تصديرًا إلى PDF)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "يتم عرض أول {{shown}} من أصل {{total}} سجل. ضيّق عامل التصفية.", rowCeilingNoteUnknownTotal: "يتم عرض أول {{shown}} سجل. ضيّق عامل التصفية.", }, @@ -409,13 +409,12 @@ const ar = { yes: "نعم", no: "لا", systemFields: "النظام", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "جزئي", - partialNotice: - "تم التجميع على أول {{loaded}} سجل من أصل {{total}}. أعداد المجموعات تخص الصفحة المحمَّلة فقط، وأي مجموعة تقع كل سجلاتها خارج الصفوف المحمَّلة لا تظهر هنا.", - partialNoticeUnknownTotal: - "تم التجميع على {{loaded}} سجل محمَّل. قد تتطابق سجلات أخرى مع هذا العرض، لذا قد تكون أعداد المجموعات جزئية وقد لا تظهر إحدى المجموعات هنا.", + needsHeaderQuery: + "هذا العرض مُجمَّع، لكن مصدر بياناته لا يطبّق queryGroupHeaders، لذا لا يمكن عدّ المجموعات. أزِل التجميع لعرض السجلات.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7ed892f6fc..36ad27685b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -128,12 +128,12 @@ const de = { printDialogHint: "Öffnet den Druckdialog Ihres Browsers (kein PDF-Export)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "Erste {{shown}} von {{total}} Datensätzen. Filter eingrenzen.", rowCeilingNoteUnknownTotal: "Erste {{shown}} Datensätze. Filter eingrenzen.", }, @@ -398,13 +398,12 @@ const de = { yes: "Ja", no: "Nein", systemFields: "System", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "Teilweise", - partialNotice: - "Gruppiert über die ersten {{loaded}} von {{total}} Datensätzen. Gruppenanzahlen gelten nur für die geladene Seite; eine Gruppe, deren Datensätze alle jenseits der geladenen Zeilen liegen, fehlt hier.", - partialNoticeUnknownTotal: - "Gruppiert über die {{loaded}} geladenen Datensätze. Möglicherweise passen weitere Datensätze zu dieser Ansicht, daher können Gruppenanzahlen unvollständig sein und eine Gruppe kann hier fehlen.", + needsHeaderQuery: + "Diese Ansicht ist gruppiert, aber ihre Datenquelle implementiert queryGroupHeaders nicht, daher können die Gruppen nicht gezählt werden. Entfernen Sie die Gruppierung, um die Datensätze anzuzeigen.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 214f9f1b41..3965e21536 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -152,12 +152,12 @@ const en = { printDialogHint: 'Opens your browser’s print dialog (not a PDF export)', // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: 'Showing the first {{shown}} of {{total}} records. Narrow the filter.', rowCeilingNoteUnknownTotal: 'Showing the first {{shown}} records. Narrow the filter.', }, @@ -510,19 +510,12 @@ const en = { yes: 'Yes', no: 'No', systemFields: 'System', - // objectui#7189 — the grouped grid says, where the group counts are, that - // it grouped a PAGE. `useGroupedData` buckets only the rows the browser - // holds, so a group beyond the page boundary is absent entirely and every - // count is a page slice. The paging footer is not a statement about what - // was grouped, and it demonstrably did not prevent the wrong reading. - // Two sentences because two conditions: a known total states the fact - // with both numbers; a full window with no total can only say "may". + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: 'Partial', - partialNotice: - 'Grouped over the first {{loaded}} of {{total}} records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here.', - partialNoticeUnknownTotal: - 'Grouped over the {{loaded}} records loaded. More may match this view, so group counts may be partial and a group may be missing here.', + needsHeaderQuery: + 'This view is grouped, but its data source does not implement queryGroupHeaders, so the groups cannot be counted. Remove the grouping to show the records.', }, // Column-footer aggregate prefixes, keyed by the spec's `ColumnSummary` // vocabulary (objectui#4024). The footer already formatted its NUMBER diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index f74b8249d5..c433a6d37a 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -127,12 +127,12 @@ const es = { printDialogHint: "Abre el cuadro de diálogo de impresión de tu navegador (no es una exportación a PDF)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "Mostrando los primeros {{shown}} de {{total}} registros. Acota el filtro.", rowCeilingNoteUnknownTotal: "Mostrando los primeros {{shown}} registros. Acota el filtro.", }, @@ -402,13 +402,12 @@ const es = { yes: "Sí", no: "No", systemFields: "Sistema", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "Parcial", - partialNotice: - "Agrupado sobre los primeros {{loaded}} de {{total}} registros. Los recuentos por grupo se limitan a la página cargada y un grupo cuyos registros quedan todos fuera de las filas cargadas no aparece aquí.", - partialNoticeUnknownTotal: - "Agrupado sobre los {{loaded}} registros cargados. Puede haber más registros que coincidan con esta vista, por lo que los recuentos por grupo pueden ser parciales y puede faltar algún grupo.", + needsHeaderQuery: + "Esta vista está agrupada, pero su origen de datos no implementa queryGroupHeaders, por lo que no se pueden contar los grupos. Quite la agrupación para mostrar los registros.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d9fb4dd1e6..3593c5caa2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -128,12 +128,12 @@ const fr = { printDialogHint: "Ouvre la boîte de dialogue d'impression de votre navigateur (ce n'est pas un export PDF)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "Affichage des {{shown}} premiers enregistrements sur {{total}}. Affinez le filtre.", rowCeilingNoteUnknownTotal: "Affichage des {{shown}} premiers enregistrements. Affinez le filtre.", }, @@ -398,13 +398,12 @@ const fr = { yes: "Oui", no: "Non", systemFields: "Système", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "Partiel", - partialNotice: - "Regroupement effectué sur les {{loaded}} premiers enregistrements sur {{total}}. Les décomptes par groupe ne portent que sur la page chargée, et un groupe dont tous les enregistrements se trouvent au-delà des lignes chargées est absent ici.", - partialNoticeUnknownTotal: - "Regroupement effectué sur les {{loaded}} enregistrements chargés. D'autres enregistrements peuvent correspondre à cette vue : les décomptes par groupe peuvent donc être partiels et un groupe peut manquer ici.", + needsHeaderQuery: + "Cette vue est groupée, mais sa source de données n'implémente pas queryGroupHeaders : les groupes ne peuvent donc pas être comptés. Supprimez le regroupement pour afficher les enregistrements.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index b6cd2c9f37..3a62f8d1c2 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -128,12 +128,12 @@ const ja = { printDialogHint: "ブラウザーの印刷ダイアログを開きます(PDF エクスポートではありません)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "{{total}} 件中、最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", rowCeilingNoteUnknownTotal: "最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", }, @@ -398,13 +398,12 @@ const ja = { yes: "はい", no: "いいえ", systemFields: "システム", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "一部", - partialNotice: - "{{total}} 件中、読み込み済みの先頭 {{loaded}} 件のみでグループ化しています。グループの件数は読み込み済みのページだけの集計で、レコードがすべて読み込み範囲の外にあるグループはここに表示されません。", - partialNoticeUnknownTotal: - "読み込み済みの {{loaded}} 件のみでグループ化しています。このビューに該当するレコードはさらに存在する可能性があるため、グループの件数が不完全であったり、グループが表示されないことがあります。", + needsHeaderQuery: + "このビューはグループ化されていますが、データソースが queryGroupHeaders を実装していないため、グループの件数を数えられません。レコードを表示するにはグループ化を解除してください。", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 799262f6d0..0f23ed067b 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -128,12 +128,12 @@ const ko = { printDialogHint: "브라우저의 인쇄 대화 상자를 엽니다(PDF 내보내기가 아닙니다)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "전체 {{total}}개 중 처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", rowCeilingNoteUnknownTotal: "처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", }, @@ -398,13 +398,12 @@ const ko = { yes: "예", no: "아니요", systemFields: "시스템", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "일부", - partialNotice: - "전체 {{total}}개 중 불러온 처음 {{loaded}}개 레코드만으로 그룹화했습니다. 그룹 개수는 불러온 페이지 기준이며, 레코드가 모두 불러온 행 밖에 있는 그룹은 여기에 표시되지 않습니다.", - partialNoticeUnknownTotal: - "불러온 {{loaded}}개 레코드만으로 그룹화했습니다. 이 뷰에 해당하는 레코드가 더 있을 수 있으므로 그룹 개수가 일부일 수 있고 일부 그룹이 표시되지 않을 수 있습니다.", + needsHeaderQuery: + "이 뷰는 그룹화되어 있지만 데이터 소스가 queryGroupHeaders를 구현하지 않아 그룹 개수를 셀 수 없습니다. 레코드를 표시하려면 그룹화를 제거하세요.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 98895a16b9..132b87c733 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -127,12 +127,12 @@ const pt = { printDialogHint: "Abre a caixa de diálogo de impressão do navegador (não é uma exportação para PDF)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "Mostrando os primeiros {{shown}} de {{total}} registros. Restrinja o filtro.", rowCeilingNoteUnknownTotal: "Mostrando os primeiros {{shown}} registros. Restrinja o filtro.", }, @@ -397,13 +397,12 @@ const pt = { yes: "Sim", no: "Não", systemFields: "Sistema", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "Parcial", - partialNotice: - "Agrupado sobre os primeiros {{loaded}} de {{total}} registros. As contagens por grupo referem-se apenas à página carregada, e um grupo cujos registros estão todos além das linhas carregadas não aparece aqui.", - partialNoticeUnknownTotal: - "Agrupado sobre os {{loaded}} registros carregados. Pode haver mais registros correspondentes a esta visão, portanto as contagens por grupo podem estar incompletas e um grupo pode não aparecer aqui.", + needsHeaderQuery: + "Esta visão está agrupada, mas sua fonte de dados não implementa queryGroupHeaders, portanto os grupos não podem ser contados. Remova o agrupamento para mostrar os registros.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index e30f60ff5b..9f20e76c77 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -138,12 +138,12 @@ const ru = { printDialogHint: "Открывает диалог печати браузера (это не экспорт в PDF)", // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: "Показаны первые {{shown}} из {{total}} записей. Сузьте фильтр.", rowCeilingNoteUnknownTotal: "Показаны первые {{shown}} записей. Сузьте фильтр.", }, @@ -408,13 +408,12 @@ const ru = { yes: "Да", no: "Нет", systemFields: "Система", - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: "Частично", - partialNotice: - "Группировка выполнена по первым {{loaded}} из {{total}} записей. Счётчики групп относятся только к загруженной странице, а группа, все записи которой находятся за пределами загруженных строк, здесь отсутствует.", - partialNoticeUnknownTotal: - "Группировка выполнена по {{loaded}} загруженным записям. Этому представлению могут соответствовать и другие записи, поэтому счётчики групп могут быть неполными, а какая-либо группа может здесь отсутствовать.", + needsHeaderQuery: + "Это представление сгруппировано, но его источник данных не реализует queryGroupHeaders, поэтому группы невозможно подсчитать. Уберите группировку, чтобы показать записи.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 7e8a415313..e4d0b3c5c2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -135,12 +135,12 @@ const zh = { printDialogHint: '打开浏览器打印对话框(不是导出 PDF)', // The non-grid row ceiling's footnote (objectui#7210). Two keys because // there are two conditions: a reported `total` states the fact with BOTH - // numbers, a missing one cannot name how many. Same split as - // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is - // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is - // deliberately NOT restated here — it moves on every re-baseline, and the - // figure that was here went stale. `pnpm check:eager-closure` prints it. + // numbers, a missing one cannot name how many. Kept terse deliberately — + // this copy is eagerly loaded, and since objectui#7399 these bytes are + // budgeted by the `i18n-locales` chunk, not `framework`. ⛔ That ceiling's + // headroom is deliberately NOT restated here — it moves on every + // re-baseline, and the figure that was here went stale. + // `pnpm check:eager-closure` prints it. rowCeilingNote: '仅显示 {{total}} 条记录中的前 {{shown}} 条。请缩小筛选范围。', rowCeilingNoteUnknownTotal: '仅显示前 {{shown}} 条记录。请缩小筛选范围。', }, @@ -384,13 +384,12 @@ const zh = { yes: '是', no: '否', systemFields: '系统字段', - // objectui#7189 — partial-grouping disclosure for the grouped grid. + // objectui#10881 — the grouped grid's refusal over a data source that + // cannot answer the group header query. `queryGroupHeaders` is the + // member's name and stays untranslated. grouping: { - partialBadge: '部分', - partialNotice: - '仅按已加载的前 {{loaded}} 条(共 {{total}} 条)记录分组。分组计数只统计当前页,记录全部落在已加载行之外的分组不会出现在这里。', - partialNoticeUnknownTotal: - '仅按已加载的 {{loaded}} 条记录分组。可能还有更多记录符合此视图,因此分组计数可能不完整,某个分组也可能不会出现在这里。', + needsHeaderQuery: + '此视图已分组,但其数据源未实现 queryGroupHeaders,因此无法统计分组。移除分组即可显示记录。', }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 93b931e286..7f79014612 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -480,23 +480,27 @@ filter AND the group's key, `limit` / `offset` per group). So: - A `lookup` / `master_detail` / `user` grouping key is the referenced record's id on the wire; the grid labels it from the referenced record. -When a `ListView` hosts the grid (the console's list views), it hands a grouped -grid its own fetch and the view's effective filter, rather than a window of -rows. - -Two cases still group **in the browser**: - -- **Rows handed in whole** (`data: { provider: 'value', items }`, or a host's - whole result set): nothing was withheld, so grouping them there is exact. -- **A data source that declares no `queryGroupHeaders`**: the grid can only - group the page it fetched. Every count is then a page slice and a group whose - records all fall past the page is absent, so the grid says so where the - numbers are — a short `Partial` marker beside every group count and a line - above the group list (*"Grouped over the first 100 of 186 records…"*). With - counts from the server the marker never appears. +When a `ListView` hosts the grid (the console's list views) over a data source +that answers the group header query, it hands a grouped grid its own fetch and +the view's effective filter, rather than a window of rows. + +**Rows handed in whole** (`data: { provider: 'value', items }`, or a host's +whole result set) are grouped where they are, in the browser: nothing was +withheld, so the grouping is exact. The grid takes rows a host hands it to be +the whole set. + +**Everything else needs the group header query** (objectui#10881). Over a data +source that declares no `queryGroupHeaders`, a grouped grid that fetches its +own rows does not group a page of them — every count would be a page slice, +and a group whose records all fall past the page would be missing. It shows an +error naming `queryGroupHeaders` instead, and asks for no rows. A `ListView` +makes the same refusal before it mounts such a grid. To group, implement +`queryGroupHeaders` on the data source, or hand the rows in whole. A toolbar **search** has no counterpart on the group header query, so a -`ListView` keeps grouping its own window while a search term is active. +`ListView` over a data source that answers it keeps grouping its own window +while a search term is active: those group counts are the window's, not the +query's (objectstack#20358). ## Integration with Data Sources diff --git a/packages/plugin-grid/src/GroupRow.tsx b/packages/plugin-grid/src/GroupRow.tsx index c96d790b13..45d6fc6458 100644 --- a/packages/plugin-grid/src/GroupRow.tsx +++ b/packages/plugin-grid/src/GroupRow.tsx @@ -50,30 +50,6 @@ export interface GroupRowProps { * `style` verbatim; leave unset on the palette-family path. */ labelColorStyle?: React.CSSProperties; - /** - * Short marker rendered beside `count` when the grouping was computed over - * a PAGE of the result set rather than the whole of it (objectui#7189). - * - * `count` is then a page slice, not the group's size, and a group whose - * records all fall beyond the loaded rows is absent from the list entirely. - * A server-grouped grid never sets it: its counts are the query's own. It - * remains for a grid grouping a page it fetched from a data source that - * cannot answer the group header query. - * The marker lives here, next to the number, because that is the number a - * reader treats as authoritative — the paging footer says nothing about - * what was grouped, and demonstrably does not prevent the wrong reading. - * - * Presence is the switch: leave it unset and no marker renders. The host - * owns the wording (this package's translation bundle), so this component - * stays free of i18n wiring like the rest of its props. - */ - partialLabel?: string; - /** - * Full sentence behind `partialLabel` — the marker's `title` and its - * accessible name. Carries the numbers when the host can support them. - * Ignored unless `partialLabel` is set. - */ - partialTitle?: string; /** Callback when the group header is clicked to toggle collapse */ onToggle: (key: string) => void; /** Children to render when not collapsed (the group content) */ @@ -98,8 +74,6 @@ export const GroupRow: React.FC = ({ fieldLabel, labelColorClass, labelColorStyle, - partialLabel, - partialTitle, onToggle, children, }) => { @@ -125,16 +99,6 @@ export const GroupRow: React.FC = ({ : } {label} {count} - {partialLabel && ( - - {partialLabel} - - )} {aggregations && aggregations.length > 0 && ( {aggregations.map((agg) => ( diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index f868c204b1..e615191013 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -440,15 +440,13 @@ const GRID_DEFAULT_TRANSLATIONS: Record = { 'grid.yes': 'Yes', 'grid.no': 'No', 'grid.systemFields': 'System', - // Grouped-view partial-grouping disclosure (objectui#7189). Both sentences - // must exist HERE as well as in the locale packs: a provider-less host (a - // standalone grid, this package's own tests) never reaches them, and this - // is a statement about whether the numbers on screen are true. - 'grid.grouping.partialBadge': 'Partial', - 'grid.grouping.partialNotice': - 'Grouped over the first {{loaded}} of {{total}} records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here.', - 'grid.grouping.partialNoticeUnknownTotal': - 'Grouped over the {{loaded}} records loaded. More may match this view, so group counts may be partial and a group may be missing here.', + // The grouped grid's refusal over a data source that cannot answer the + // group header query (objectui#10881, ruling F). It must exist HERE as well + // as in the locale packs: a provider-less host (a standalone grid, this + // package's own tests) never reaches them, and it is the only thing such a + // grid draws. + 'grid.grouping.needsHeaderQuery': + 'This view is grouped, but its data source does not implement queryGroupHeaders, so the groups cannot be counted. Remove the grouping to show the records.', // Reused by the grouped-view pager (falls back here when no I18nProvider). 'table.rowsPerPage': 'Rows per page', 'table.pageInfo': 'Page {{current}} of {{total}}', @@ -2088,8 +2086,10 @@ export const ObjectGrid: React.FC = ({ // Presence of `queryGroupHeaders` is the capability. Rows handed in whole // (`hasInlineData`) are grouped where they are, which is exact because // nothing was withheld. A data source that cannot answer the header query - // keeps grouping the page it fetched — and the grid says so on screen (the - // "Partial" marker below), because only there are the counts page slices. + // cannot group (objectui#10881, maintainer ruling F): the grid refuses, + // naming the missing member, and asks for no rows — see + // `groupingNeedsHeaderQuery`. It used to group the page it fetched, whose + // every count was a page slice. // // Decided on the grouping AS CONFIGURED less the entries refused on a KNOWN // masked type — NOT less the entries merely withheld while the object's @@ -2112,6 +2112,23 @@ export const ObjectGrid: React.FC = ({ && usableGroupingFields(groupingFieldsRaw).some( (gf) => !groupingRefusedKnownMasked.includes(gf.field) && groupingKeyReadable(gf.field), ); + // [objectui#10881] The refusal: this grid would fetch its own rows and group + // them, and its data source cannot answer the group header query. There is + // no honest grouping to draw — a fetched window grouped in the browser shows + // page slices as group counts and drops every group whose rows fall past the + // window — so the grid names the missing member instead, and the load effect + // below asks for no rows (the object definition is still read: it is what + // tells a KNOWN masked grouping key, which groups nothing, from one that + // does). Rows handed in whole never reach it (`hasInlineData`). The same + // grouping entries count as for `serverGroupedFetch`, less the readability + // gate: over such a source a grouping by a key this principal may not read + // is refused too, rather than falling through to a fetched page. + const groupingNeedsHeaderQuery = + !hasInlineData + && !!objectName + && !!dataSource + && typeof dataSource.queryGroupHeaders !== 'function' + && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); // The grid's own row query — projection, expansion, order and the view's // filter — resolved by the load effect below exactly as the flat fetch // resolves it, and handed to each group's row page. `null` until resolved. @@ -2175,6 +2192,9 @@ export const ObjectGrid: React.FC = ({ } // --- Step 2: Fetch data --- + // [objectui#10881] A grouping this data source cannot count is refused + // before any row is asked for; the render names the missing member. + if (groupingNeedsHeaderQuery) return; if (dataSource && objectName) { // [objectui#7179] The fields the view GROUPS BY. `grouping` is a // sibling of `columns` in the spec, not a subset of it, so a grid may @@ -2651,7 +2671,10 @@ export const ObjectGrid: React.FC = ({ // held with `filter`. It changes only when the authored alias changes by // structure or the scope changes (a new signed-in user), and either one // changes the `$filter` this effect sends when `filter` is absent. - }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce, serverGroupedFetch]); + // `groupingNeedsHeaderQuery` (objectui#10881): it flips when the object's + // types land and show the only grouping key masked, and the flat fetch it + // withheld must then go out. + }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce, serverGroupedFetch, groupingNeedsHeaderQuery]); // The same reset, for the path the loader above never runs on (objectui#4501 // clause 2). "All N matching are selected" is a claim about ONE query, so it @@ -4019,6 +4042,22 @@ export const ObjectGrid: React.FC = ({ ); } + // [objectui#10881] A grouping the data source cannot count, refused in the + // load-error panel's own shape: nothing was fetched, and the sentence names + // the member the data source would need (`groupingNeedsHeaderQuery`). + if (groupingNeedsHeaderQuery) { + return ( +
+

{t('grid.errorLoading')}

+

{t('grid.grouping.needsHeaderQuery')}

+
+ ); + } + // [objectui#7189] A server-grouped grid fails like any other load: the // header query refused, or a view filter that would not lower for it. const loadError = error @@ -4951,62 +4990,6 @@ export const ObjectGrid: React.FC = ({ ? hostOnPageSizeChange : (size: number) => { setServerPageSize(size); setServerPage(1); }; - // ── Is the grouping on screen page-scoped? (objectui#7189) ─────────────── - // - // `useGroupedData` buckets the rows THIS COMPONENT HOLDS and computes every - // per-group aggregate from that same array, so both the set of groups and - // every number in a group header are properties of the fetched page rather - // than of the query. That is a correct implementation of client-side - // grouping and is deliberately untouched here; what was missing is any - // statement that client-side grouping is what you are looking at. A group - // whose records all fall beyond the page does not appear AT ALL — and a - // wrong number invites a second look where an absent row invites none. - // - // Two knowable conditions, deliberately different in strength, because the - // wording each one can honestly support differs with it: - // - // 1. A real match total to compare against — `resolvedTotalMatching`, the - // same ONE derived value the pager and both bulk-bar sites read (do not - // re-spell the `externalManualPagination` conditional here; two copies - // of it is how one of them got missed in #4464). Exceeding the rows in - // hand makes the grouping partial as a FACT, with both numbers. - // 2. No total, but we asked the server for a window and it came back full. - // `plugin-list`'s own footer draws exactly this inference when no total - // is known (`items.length >= effectivePageSize`), and it can only ever - // say "may": a result set that exactly fills the window trips it too. - // - // Rows handed to us inline are NOT a page — nothing was asked for and - // nothing was withheld — so `hasInlineData` gates condition 2 out. The - // host-driven paging mode still reaches condition 1, through `hostRowCount`. - // A result set that fits leaves the grid silent, and that silence is what - // makes the marker mean something when it does appear. - // - // ⭐ RETIRED where the counts are server-true (objectui#7189, ruling A — - // enforce-or-remove). A server-grouped grid's group set and every header - // number are the header query's own, so there is nothing partial to - // disclose and the marker would be a lie. What still reaches it is the one - // shape whose counts ARE page slices: a grid grouping the window it fetched - // from a data source that declares no `queryGroupHeaders`. - const groupingRowsLoaded = data.length; - const groupingTotalKnown = typeof resolvedTotalMatching === 'number'; - const groupingPartialWithTotal = - groupingTotalKnown && (resolvedTotalMatching as number) > groupingRowsLoaded; - const groupingPartialWindowFull = - !groupingTotalKnown && !hasInlineData && groupingRowsLoaded >= serverPageSize; - const groupingIsPartial = - isGrouped && !serverGroupedFetch && (groupingPartialWithTotal || groupingPartialWindowFull); - // ONE sentence, used in both places it belongs: the notice above the group - // list, and the accessible name of the marker beside every group count. - const groupingPartialNotice = groupingIsPartial - ? (groupingPartialWithTotal - ? t('grid.grouping.partialNotice', { - loaded: groupingRowsLoaded, - total: resolvedTotalMatching, - }) - : t('grid.grouping.partialNoticeUnknownTotal', { loaded: groupingRowsLoaded })) - : undefined; - const groupingPartialLabel = groupingIsPartial ? t('grid.grouping.partialBadge') : undefined; - // Before anyone clicks, the headers show the sort the view was authored with // — read from the same `schemaSort` the fetch path above lowers, so the // arrow on screen and the `$orderby` on the wire are the same sort. (The @@ -6044,8 +6027,6 @@ export const ObjectGrid: React.FC = ({ fieldLabel={group.depth === 0 ? fieldLabel : undefined} labelColorClass={labelColorClass} labelColorStyle={labelColorStyle} - partialLabel={groupingPartialLabel} - partialTitle={groupingPartialNotice} onToggle={toggleGroup} > {group.subgroups.length > 0 ? ( @@ -6113,19 +6094,6 @@ export const ObjectGrid: React.FC = ({ // bottom of an overflow-hidden ancestor and clips it. const gridContent = isGrouped ? (
- {/* The partial-grouping disclosure sits INSIDE the grouped region, - directly above the first group header — not in the paging footer. - The footer is paging chrome and says nothing about what was - grouped; the number a reader trusts is the one next to the group's - name, so the statement has to be where that number is. */} - {groupingIsPartial && ( -
- {groupingPartialNotice} -
- )} {/* Single shared horizontal scroll container: every group's sub-table overflows into this one scroller (disableInnerScroll), so columns stay aligned and there is exactly one x-axis scrollbar. */} diff --git a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx index 3420344fba..1c3ab5d173 100644 --- a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx @@ -106,8 +106,8 @@ registerAllFields(); * * - `import.meta.url` IS a `file:` URL in this project. objectui#7800 * (comment 5555131785) measured it across three packages and both cwds; the - * sibling `packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx` - * has derived its root this way since PR #7806. What Vite rewrites is the + * sibling `groupedPartialDisclosure-7189.test.tsx` derived its root this way + * from PR #7806 until objectui#10881 retired it. What Vite rewrites is the * TWO-ARGUMENT `new URL(rel, import.meta.url)`, which is why only the bare * form is read here and taken apart by hand. * - "the vitest root" and `process.cwd()` are not the same directory. This diff --git a/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx b/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx index 13d58ee2e6..8240ddce2b 100644 --- a/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx @@ -19,8 +19,8 @@ * PRIOR ART, stated rather than credited: `groupingProjection-7179` pins that a * grouping field reaches `$select`, `groupingNullEntry-7217` pins that a `null` * hole does not take the grid down, `groupedBooleanLabel` pins the Yes/No - * fallback, `groupedPagination` and `groupedPartialDisclosure-7189` pin the - * paging around groups. None of them states the member set, and none says what + * fallback, `groupedPagination` pins the paging around groups. None of them + * states the member set, and none says what * `order` or `collapsed` do. * * ⭐ **`grouping` and `columns` ARE NOT DISJOINT, and this file is where that diff --git a/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx b/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx deleted file mode 100644 index dba87632ab..0000000000 --- a/packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx +++ /dev/null @@ -1,446 +0,0 @@ -/** - * 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#7189 — a grouped grid says, WHERE THE GROUP COUNTS ARE, that it - * grouped a page. - * - * ## ⭐ SCOPE SINCE RULING A LANDED — this file now pins the FALLBACK - * - * Maintainer ruling A made grouping server-side: a grid whose data source - * answers the group header query (`DataSource.queryGroupHeaders`) groups on the - * server, its counts are the query's own, and the marker below is RETIRED there - * — `serverGrouping-7189.test.tsx` pins both, including the marker's absence. - * What still reaches this disclosure is the one shape whose counts ARE page - * slices: a grid grouping the window it fetched from a data source that - * declares no `queryGroupHeaders`. Every data source in this file is that - * shape (`find` only), which is why every pin below still holds unchanged; the - * history that follows is the page-scoped behaviour those sources still get. - * - * ## The defect this discloses (measured on `main` at a6d8b8d44) - * - * `useGroupedData` buckets the rows the browser already holds and computes - * every per-group aggregate from that same array, so both the set of groups - * and every number in a group header are properties of the FETCHED PAGE, not - * of the query. Over a 186-row store distributed 86/61/31/7/1 across five - * units with `$top: 100`, `main` rendered: - * - * - contiguous rows → TWO group headers, `86` and `14`. Three of the five - * units were absent from the screen entirely. - * - interleaved rows → five headers reading 31/31/30/1/7 — every count a - * page slice of the real 86/61/31/7/1. - * - * and in both cases the document contained no "186", no "partial", no - * "loaded". A wrong number invites a second look; an absent row invites none. - * - * ## What is under test here, and what is NOT - * - * The grouping and aggregation algorithm is CORRECT for what it does and is - * untouched — the counts asserted below are still page slices afterwards. - * These pins are about the *statement*: the grid now says so beside the - * numbers a reader treats as authoritative, and stays silent when the result - * set genuinely fits. Server-side grouping is a separate, undecided question - * (escalated on #5560) and nothing here anticipates it. - * - * The conditional half is as load-bearing as the disclosure half. A marker - * that is simply always on says nothing, so every positive pin below is - * paired with a control that must NOT show it. - * - * ## Why jsdom is a measurement here and not a guess - * - * jsdom applies CSS media-query rules irrespective of `innerWidth`, so a - * width-dependent reading taken in it is not a measurement. Nothing in this - * disclosure is width-dependent: it carries no `hidden`/`md:` visibility - * utility, and the grouped render path is the only one grouping can reach — - * ObjectGrid's mobile card view is gated `useCardView && … && !isGrouped`. - * The last pin in this file asserts that gate directly rather than trusting - * the reading. - * - * ## Test-source note - * - * This file imports `../ObjectGrid` relatively and the root vitest config - * aliases `@object-ui/*` to each package's `src`, so no build step stands - * between the edit and the run (the same standing arrangement - * `groupingProjection-7179.test.tsx` records). - */ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, cleanup, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; - -import { ObjectGrid } from '../ObjectGrid'; -import { ActionProvider } from '@object-ui/react'; - -/** - * The repo root, derived from THIS FILE's own location — never from - * `process.cwd()` (objectui#7799). - * - * It was `process.cwd()` until then, on the reasoning that - * `scripts/vitest-invocation-guard.mjs` refuses any invocation whose vitest root - * is not the repo root. That guard is real, but it is a DIFFERENT invariant: - * `--root` moves VITEST's root and moves nothing about `process.cwd()`. This - * package's own `test` script — `vitest run --root ../.. packages/plugin-grid/`, which - * is what `pnpm --filter … test` and `turbo run test` both run — leaves cwd at - * `packages/plugin-grid/`, so every read below resolved against the package directory - * and the assertions guarding them failed. - * - * Spelled in string operations, copying the landed precedent of objectui#7791 - * (PR #7796): `new URL(rel, import.meta.url)` is REWRITTEN by Vite into a - * `http://localhost:3000/@fs/…` dev-server URL, so only bare `import.meta.url` - * is read here and taken apart by hand. Measured on this card under both cwds - * and in both the `unit` and the `dom` project, it is - * `file:///…/packages/plugin-grid/src/__tests__/`. - */ -const SELF_DEPTH_BELOW_REPO_ROOT = 5; // packages / plugin-grid / src / __tests__ / this file -const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) - .split('/') - .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) - .join('/'); - - -const OBJECT = 'duly_task'; - -const OBJECT_FIELDS = { - id: { type: 'text', label: 'Id' }, - subject: { type: 'text', label: 'Subject' }, - business_unit: { type: 'text', label: 'Business Unit' }, - region: { type: 'text', label: 'Region' }, -}; - -/** - * The card's own distribution: 186 records over five business units, sized - * 86 / 61 / 31 / 7 / 1. Ordering is a parameter because it decides WHICH - * shape of the defect you see — contiguous hides whole groups, interleaved - * shrinks every count — and the disclosure has to be true of both. - */ -const UNITS: Array<[string, number]> = [ - ['Northgate Operations', 86], - ['Northgate Plant', 61], - ['Northgate Quality', 31], - ['Riverside Plant', 7], - ['Riverside Depot', 1], -]; - -interface Row { - id: string; - subject: string; - business_unit: string; - region: string; -} - -const buildRows = (interleaved: boolean): Row[] => { - const buckets = UNITS.map(([unit, n]) => - Array.from({ length: n }, (_, i) => ({ - id: `${unit}-${i}`, - subject: `${unit} #${i}`, - business_unit: unit, - region: unit.startsWith('Northgate') ? 'North' : 'River', - })), - ); - if (!interleaved) return buckets.flat(); - const out: Row[] = []; - for (let i = 0; ; i++) { - let took = false; - for (const b of buckets) { - if (i < b.length) { out.push(b[i]); took = true; } - } - if (!took) return out; - } -}; - -const ALL_186 = buildRows(true); -expect(ALL_186).toHaveLength(186); - -/** - * A data source that honours `$top`/`$skip`. `total` is optional so the - * "no total is reachable" branch can be measured rather than assumed. - */ -const makeDataSource = (rows: Row[], opts?: { withTotal?: boolean }) => ({ - find: vi.fn(async (_object: string, params: Record) => { - const skip = (params.$skip as number | undefined) ?? 0; - const top = (params.$top as number | undefined) ?? rows.length; - const page = rows.slice(skip, skip + top); - return opts?.withTotal === false - ? { data: page } - : { data: page, total: rows.length }; - }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), -}); - -const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; -const groupHeaders = () => - groupRows().map((r) => ({ - label: r.querySelector('.group-label')?.textContent, - count: r.querySelector('.group-count')?.textContent, - })); -const partialMarkers = () => [...document.querySelectorAll('.group-count-partial')]; -const notice = () => screen.queryByTestId('grouping-partial-notice'); - -const renderGrid = async (props: Record) => { - const result = render( - - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - , - ); - await vi.waitFor(() => expect(document.querySelector('[data-testid]')).toBeTruthy()); - return result; -}; - -const groupedSchema = (extra: Record = {}) => ({ - type: 'object-grid', - objectName: OBJECT, - columns: ['subject'], - grouping: { fields: [{ field: 'business_unit' }] }, - ...extra, -}); - -afterEach(() => cleanup()); - -describe('ObjectGrid — a grouped grid discloses that it grouped a page (objectui#7189)', () => { - // ── PIN 1: THE DISCLOSURE, WHERE THE COUNTS ARE ───────────────────────── - it('marks every group count when the result set exceeds the rows in hand', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); - - // The counts are STILL page slices — the algorithm is untouched. What - // changed is that each of them now carries the marker. - expect( - partialMarkers().length, - 'every group header count is a page slice, so every one of them is marked', - ).toBe(groupRows().length); - expect(partialMarkers().length).toBeGreaterThan(0); - }); - - it('states the partial-ness above the group list, with both real numbers', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(notice()).toBeInTheDocument()); - const text = notice()!.textContent ?? ''; - // 100 rows loaded of a 186-row result set. Both numbers are measured - // here, not copied: 100 is the `$top` the grid sent, 186 the fixture. - expect(text).toContain('100'); - expect(text).toContain('186'); - expect(text).toMatch(/missing/i); - }); - - it('is reached by the group-count marker as its accessible name', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(partialMarkers().length).toBeGreaterThan(0)); - const marker = partialMarkers()[0]; - // The marker beside the number is not a bare glyph: the whole sentence, - // numbers included, is its `title` AND its accessible name, so the - // disclosure is not lost to a reader who never sees the banner. - expect(marker.getAttribute('title')).toContain('186'); - expect(marker.getAttribute('aria-label')).toContain('186'); - }); - - // ── PIN 2: THE DEFECT IT DISCLOSES IS REAL, IN BOTH SHAPES ────────────── - it('discloses the shape where whole groups are missing (contiguous rows)', async () => { - const ds = makeDataSource(buildRows(false)); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); - // Measured on `main` before the disclosure existed, and unchanged by it: - // the first 100 of these rows hold only two of the five units. - expect(groupHeaders()).toEqual([ - { label: 'Northgate Operations', count: '86' }, - { label: 'Northgate Plant', count: '14' }, - ]); - expect(screen.queryByText('Riverside Depot')).not.toBeInTheDocument(); - // Three units are absent from the screen. THAT is what the notice is for. - expect(notice()).toBeInTheDocument(); - }); - - it('discloses the shape where every count is a slice (interleaved rows)', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(groupRows().length).toBe(5)); - // All five units resolve (that is objectui#7179's fix, live here), and - // every count is short of the store's 86/61/31/7/1. - expect(groupHeaders().map((g) => g.count)).toEqual(['31', '31', '30', '1', '7']); - expect(partialMarkers().length).toBe(5); - }); - - // ── PIN 3: THE CONTROLS — the marker is CONDITIONAL ───────────────────── - it('stays silent when the whole result set fits in the page', async () => { - const fits = ALL_186.slice(0, 40); - const ds = makeDataSource(fits); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(groupRows().length).toBe(5)); - // 40 of 40: every group is whole and every count is the truth. - const counts = groupHeaders().map((g) => Number(g.count)); - expect(counts.reduce((a, b) => a + b, 0)).toBe(40); - expect( - notice(), - 'a marker that is always on says nothing — silence here is what makes it mean something', - ).not.toBeInTheDocument(); - expect(partialMarkers()).toHaveLength(0); - }); - - it('leaves an UNGROUPED grid untouched, however short of the total it is', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: { - type: 'object-grid', - objectName: OBJECT, - columns: ['subject'], - pagination: { pageSize: 100 }, - }, - dataSource: ds, - }); - - await vi.waitFor(() => expect(ds.find).toHaveBeenCalled()); - await vi.waitFor(() => expect(screen.queryAllByText(/Northgate Operations #0/).length).toBeGreaterThan(0)); - expect(groupRows()).toHaveLength(0); - expect(notice()).not.toBeInTheDocument(); - expect(partialMarkers()).toHaveLength(0); - }); - - it('does not mark rows handed to it inline — nothing was asked for, nothing withheld', async () => { - await renderGrid({ - schema: groupedSchema({ - data: { provider: 'value', items: ALL_186 }, - pagination: { pageSize: 100 }, - }), - }); - - await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); - expect(notice()).not.toBeInTheDocument(); - expect(partialMarkers()).toHaveLength(0); - }); - - // ── PIN 4: WHERE THE TOTAL COMES FROM ─────────────────────────────────── - it('reads the total a HOST supplies (the ListView paging shape)', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: groupedSchema(), - dataSource: ds, - data: ALL_186.slice(0, 100), - manualPagination: true, - rowCount: 186, - page: 1, - pageSize: 100, - onPageChange: () => {}, - // objectui#10657: the ListView paging shape now hands the grid the - // object's field catalogue with its rows. Without it the grouping key - // (`business_unit`, no column type) is WITHHELD — refused — until the - // grid's own schema read lands, so the notice this case reads would sit - // behind that re-render of 100 rows. - objectFields: OBJECT_FIELDS, - }); - - await vi.waitFor(() => expect(notice()).toBeInTheDocument()); - // The host owns the fetch here, so the grid's own `totalMatching` is - // never written; the number has to come from `rowCount`. - expect(notice()!.textContent).toContain('186'); - expect(partialMarkers().length).toBe(groupRows().length); - }); - - it('falls back to the weaker, honest sentence when NO total is reachable', async () => { - const ds = makeDataSource(ALL_186, { withTotal: false }); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(notice()).toBeInTheDocument()); - const text = notice()!.textContent ?? ''; - // The window came back full, which is the same inference `plugin-list`'s - // own footer draws with no total — so it may only say "may", and it must - // not invent a figure it does not have. - expect(text).toContain('100'); - expect(text).toMatch(/\bmay\b/i); - expect(text).not.toContain('186'); - expect(text).not.toMatch(/of \d+ records/i); - }); - - it('stays silent with no total when the window came back SHORT', async () => { - const ds = makeDataSource(ALL_186.slice(0, 40), { withTotal: false }); - await renderGrid({ - schema: groupedSchema({ pagination: { pageSize: 100 } }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(groupRows().length).toBe(5)); - // 40 rows came back against a 100-row window: nothing was withheld. - expect(notice()).not.toBeInTheDocument(); - expect(partialMarkers()).toHaveLength(0); - }); - - // ── PIN 5: EVERY LEVEL, not just the top one ──────────────────────────── - it('marks nested subgroup counts too — they are page slices at every depth', async () => { - const ds = makeDataSource(ALL_186); - await renderGrid({ - schema: groupedSchema({ - pagination: { pageSize: 100 }, - grouping: { fields: [{ field: 'region' }, { field: 'business_unit' }] }, - }), - dataSource: ds, - }); - - await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(2)); - const depths = new Set( - groupRows().map((r) => (r.getAttribute('data-testid') ?? '').includes('__') ? 'nested' : 'top'), - ); - expect(depths.has('nested'), 'the fixture must actually produce a second level').toBe(true); - expect(partialMarkers().length).toBe(groupRows().length); - }); - - // ── PIN 6: the jsdom caveat, closed by source rather than by reading ──── - it('is not width-conditional: grouping never reaches the mobile card view', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - // The repo root, derived from THIS FILE (objectui#7799). It was - // `process.cwd()`, on the reasoning that the vitest guard refuses any - // invocation whose root is not the repo root (objectui#3378) — but that - // guard pins VITEST's root, and the package `test` script sets that root - // correctly while leaving cwd at `packages/plugin-grid/`. - const src = fs.readFileSync( - path.join(REPO_ROOT, 'packages/plugin-grid/src/ObjectGrid.tsx'), - 'utf8', - ); - // The one width-driven branch in this component excludes grouping, so the - // grouped render path — and therefore this disclosure — is the same at - // every viewport. Pinned in source because jsdom cannot measure it: - // it applies media-query rules irrespective of `innerWidth`. - expect(src).toContain('if (useCardView && data.length > 0 && !isGrouped) {'); - // And the disclosure itself carries no responsive visibility utility. - const noticeMarkup = src.slice(src.indexOf('data-testid="grouping-partial-notice"')); - expect(noticeMarkup.slice(0, 400)).not.toMatch(/\b(hidden|sm:hidden|md:hidden|md:block|lg:block)\b/); - }); -}); diff --git a/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx b/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx new file mode 100644 index 0000000000..a851de43f0 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx @@ -0,0 +1,166 @@ +/** + * 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#10881 — a grouped grid over a data source with no group header + * query REFUSES grouping, and rows handed in whole still group exactly. + * + * Maintainer ruling F: grouping is a property of the query (ruling A on + * objectui#7189), so a data source that cannot answer the group header query + * (`DataSource.queryGroupHeaders`) cannot group. A grid that fetches its own + * rows over such a source used to fetch one window and bucket it — on the + * 186-row, five-unit fixture below with a 100-row page, two headers (86, 14) + * and three units missing — and mark the counts "Partial". It now renders an + * error panel naming `queryGroupHeaders` and asks for no rows at all. + * + * Kept, and pinned here beside it: rows handed in whole — a page schema's + * `value` provider, or a parent view's `data` — are grouped in the browser, + * where the counts are exact because nothing was withheld. + * + * Every refusal pin has a lit control: the SAME find-only source, ungrouped, + * is queried and draws its rows (so "no row query" is a measurement, not a + * harness that never queries), and the same grouping over a source that + * declares the member is not refused. + */ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, cleanup, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { ActionProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; + +beforeAll(() => { + registerAllFields(); +}); + +const OBJECT = 'duly_task'; + +const OBJECT_FIELDS = { + id: { type: 'text', label: 'Id' }, + subject: { type: 'text', label: 'Subject' }, + business_unit: { type: 'text', label: 'Business Unit' }, +}; + +/** 186 rows over five units sized 86/61/31/7/1, stored contiguously. */ +const UNITS: Array<[string, number]> = [ + ['Northgate Operations', 86], + ['Northgate Plant', 61], + ['Northgate Quality', 31], + ['Riverside Plant', 7], + ['Riverside Depot', 1], +]; +const EXPECTED: Record = Object.fromEntries(UNITS); +const ROWS = UNITS.flatMap(([unit, n]) => + Array.from({ length: n }, (_, i) => ({ id: `${unit}-${i}`, subject: `${unit} #${i}`, business_unit: unit })), +); + +/** A data source that can only `find` — no `queryGroupHeaders`. */ +const makeFindOnlyDataSource = () => ({ + find: vi.fn(async (_object: string, params: Record) => { + const skip = (params.$skip as number | undefined) ?? 0; + const top = (params.$top as number | undefined) ?? ROWS.length; + return { data: ROWS.slice(skip, skip + top), total: ROWS.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), +}); + +const GROUPING = { fields: [{ field: 'business_unit' }] }; + +const renderGrid = (dataSource: unknown, schemaExtra: Record = {}, props: Record = {}) => + render( + + + , + ); + +/** Let the load effect run to its end: the schema read, then whatever follows it. */ +const settle = async () => { + await act(async () => { + for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +const refusal = () => screen.queryByTestId('grid-grouping-needs-header-query'); +const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; +const headerCounts = () => Object.fromEntries(groupRows().map((row) => [ + row.querySelector('.group-label')?.textContent ?? '', + Number(row.querySelector('.group-count')?.textContent ?? NaN), +])); + +afterEach(() => cleanup()); + +describe('ObjectGrid refuses grouping over a data source with no header query (objectui#10881)', () => { + it('renders the refusal naming queryGroupHeaders, and issues no row query', async () => { + const ds = makeFindOnlyDataSource(); + renderGrid(ds, { grouping: GROUPING }); + + await vi.waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await settle(); + + const panel = refusal(); + expect(panel).toBeInTheDocument(); + expect(panel).toHaveAttribute('role', 'alert'); + expect(panel!.textContent).toContain('queryGroupHeaders'); + expect(ds.find).not.toHaveBeenCalled(); + expect(groupRows()).toHaveLength(0); + }); + + // CONTROL — the same source is queried, and draws, when nothing is grouped. + it('CONTROL — the same find-only source, ungrouped, is queried and draws its rows', async () => { + const ds = makeFindOnlyDataSource(); + renderGrid(ds); + + await vi.waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(screen.getByText('Northgate Operations #0')).toBeInTheDocument()); + expect(refusal()).not.toBeInTheDocument(); + }); + + // CONTROL — the refusal keys on the member, not on grouping. + it('CONTROL — the same grouping over a source that declares queryGroupHeaders is not refused', async () => { + const ds = { + ...makeFindOnlyDataSource(), + queryGroupHeaders: vi.fn(async () => UNITS.map(([business_unit, count]) => ({ business_unit, count }))), + }; + renderGrid(ds, { grouping: GROUPING }); + + await vi.waitFor(() => expect(ds.queryGroupHeaders).toHaveBeenCalled()); + await vi.waitFor(() => expect(groupRows()).toHaveLength(5)); + expect(refusal()).not.toBeInTheDocument(); + }); +}); + +describe('rows handed in whole still group in the browser, exactly (objectui#10881)', () => { + it.each([ + ['a page schema (`value` provider)', { data: { provider: 'value', items: ROWS } }, {}], + ['a parent view (`data` prop)', {}, { data: ROWS }], + ])('%s: five headers reading 86/61/31/7/1 over a find-only source, no refusal, no query', async (_name, schemaExtra, props) => { + const ds = makeFindOnlyDataSource(); + renderGrid(ds, { grouping: GROUPING, ...schemaExtra }, props); + + await vi.waitFor(() => expect(groupRows()).toHaveLength(5)); + expect(headerCounts()).toEqual(EXPECTED); + expect(refusal()).not.toBeInTheDocument(); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx b/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx index 530189c443..0bf9972027 100644 --- a/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx @@ -15,8 +15,10 @@ * the fetched page; rows inside a group are paged. The acceptance carried from * objectstack#14556: the 186-row, five-unit, `$top: 100` fixture renders five * group headers reading 86/61/31/7/1 regardless of row order, opening the - * 86-row group pages its rows, no "Partial" marker renders when counts are - * true, and every row is reachable through the UI. + * 86-row group pages its rows, and every row is reachable through the UI. + * (The acceptance also asked that no "Partial" marker render when counts are + * true; objectui#10881, ruling F, retired that marker everywhere, so there is + * no marker left to be absent.) * * ## What the double answers, and why that is enough here * @@ -31,8 +33,10 @@ * what THIS file pins is the grid consuming those answers — and not the page. * * Every positive pin has a control that must come out differently: the SAME - * fixture through a data source that cannot answer the header query still - * groups the fetched page (86 + 14 contiguous) and still says so. + * fixture through a data source that cannot answer the header query does not + * group at all — it used to group the fetched page (86 + 14 contiguous) and + * mark it partial, and since objectui#10881 (ruling F) it refuses, naming + * `queryGroupHeaders`, and asks for no rows. */ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { render, cleanup, screen, within, fireEvent } from '@testing-library/react'; @@ -160,7 +164,6 @@ const headerCounts = () => Object.fromEntries(groupRows().map((r) => { const h = const groupRowEl = (label: string) => groupRows().find((r) => headerOf(r).label === label)!; const subjectsIn = (el: Element, unit: string) => within(el as HTMLElement).queryAllByText(new RegExp(`^${unit} #\\d+$`)).map((n) => n.textContent); -const partialMarkers = () => [...document.querySelectorAll('.group-count-partial')]; const renderGrid = (dataSource: unknown, schemaExtra: Record = {}) => render( @@ -185,7 +188,7 @@ describe('ObjectGrid groups on the server (objectui#7189)', () => { it.each([ ['contiguous', false], ['interleaved', true], - ])('%s rows: five headers reading 86/61/31/7/1, no Partial marker', async (_name, interleaved) => { + ])('%s rows: five headers reading 86/61/31/7/1', async (_name, interleaved) => { const ds = makeServerDataSource(buildRows(interleaved)); renderGrid(ds); @@ -195,8 +198,6 @@ describe('ObjectGrid groups on the server (objectui#7189)', () => { expect(groupRows().map((r) => headerOf(r).label)).toEqual([ 'Northgate Operations', 'Northgate Plant', 'Northgate Quality', 'Riverside Depot', 'Riverside Plant', ]); - expect(partialMarkers()).toHaveLength(0); - expect(screen.queryByTestId('grouping-partial-notice')).not.toBeInTheDocument(); }); it('asks the header query the SPEC compiles, and never fetches a flat window', async () => { @@ -335,14 +336,14 @@ describe('ObjectGrid groups on the server (objectui#7189)', () => { }); // ── CONTROL: the instrument can see the defect ────────────────────────── - it('CONTROL — a data source with no header query still groups the page, and says so', async () => { + it('CONTROL — a data source with no header query does not group the page: it refuses, and asks for no rows', async () => { const ds = makePageOnlyDataSource(buildRows(false)); renderGrid(ds); - await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); - // The page-scoped answer the ruling retired where the server can answer: - // two headers for five units, and the marker on every one of them. - expect(headerCounts()).toEqual({ 'Northgate Operations': 86, 'Northgate Plant': 14 }); - expect(partialMarkers()).toHaveLength(2); - expect(screen.getByTestId('grouping-partial-notice')).toBeInTheDocument(); + await vi.waitFor(() => expect(screen.getByTestId('grid-grouping-needs-header-query')).toBeInTheDocument()); + // It used to answer two headers (86, 14) for five units and mark them + // partial; ruling F (objectui#10881) refuses instead of grouping a page. + await vi.waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + expect(ds.find).not.toHaveBeenCalled(); + expect(groupRows()).toHaveLength(0); }); }); diff --git a/packages/plugin-grid/src/useGroupedData.ts b/packages/plugin-grid/src/useGroupedData.ts index 9778ecbfee..b6a918a344 100644 --- a/packages/plugin-grid/src/useGroupedData.ts +++ b/packages/plugin-grid/src/useGroupedData.ts @@ -374,8 +374,13 @@ function sameGroupKey(a: unknown, b: unknown): boolean { * retired, since a group whose rows all fall past the page would not * appear at all and every count would be a page slice. * - `server` absent — the rows in `data` are grouped here. That is exact - * when the grid holds every row (inline rows, a host's whole result set); - * over a fetched page it is page-scoped, and the grid says so on screen. + * when they are every row, which is what the grid takes rows a host hands + * it to be (inline rows, a host's whole result set). A grid that fetches + * its own rows never groups them here: over a data source with no header + * query it refuses grouping instead (objectui#10881, ruling F). A host + * that hands in a WINDOW gets that window grouped — `ListView` still does + * while a toolbar search is active, since the header query carries no + * search (objectstack#20358). * * @param config - GroupingConfig from the grid schema (optional) * @param data - flat data rows (grouped only when `server` is absent) diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 902c0585bd..ce011ca56c 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -110,6 +110,21 @@ import { ListView } from '@object-ui/plugin-list'; When both are present, `grouping` wins. End users can also add or remove grouping fields at runtime via the Group toolbar button. +A grouped **grid** is grouped on the server: over a data source that answers +the group header query (`dataSource.queryGroupHeaders`), `ListView` hands the +grid its own fetch, and the group set, every group count and each group's +rows come from the query (see *Grouping is server-side* in the +`@object-ui/plugin-grid` README). While a toolbar search is active it hands the +grid its window instead, since the header query carries no search. + +Over a data source that declares no `queryGroupHeaders`, `ListView` does not +fetch a window and group it — every count would be a page slice, and groups +past the window would be missing. It shows an error naming +`queryGroupHeaders` in place of the grid instead, fetches no rows, and keeps +the toolbar, so removing the grouping there lifts it (objectui#10881). Rows +handed in whole (`data` as an array, or `{ provider: 'value', items }`) are +still grouped in the browser, exactly. + ### With Multiple View Types ```tsx @@ -284,11 +299,10 @@ the loaded rows. So on that path a relational or formula column carries no clickable header either (`objectui#3950`); offering one would have been the same illusion through a different control. -Where the sort really does stay in the browser — inline `data`, or the grouped -view, which holds every row it groups — both kinds of header stay live and order -by the value the cell shows: the resolved label for a relational column, the -server-hydrated result for a formula one (see `getSortValue` in -`@object-ui/core`). +Where the sort really does stay in the browser — inline `data` — both kinds of +header stay live and order by the value the cell shows: the resolved label for +a relational column, the server-hydrated result for a formula one (see +`getSortValue` in `@object-ui/core`). ## View Persistence diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 76833a87ff..8d97a556b5 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -839,6 +839,12 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.loadErrorApiDisabledTitle': 'This object isn’t available through the API', 'list.loadErrorApiDisabledMessage': 'This page can’t load its records because the object is not exposed through the API. That is a setting on the object itself, not a permission — an administrator has to enable API access for it before this page can work.', 'list.retry': 'Retry', + // objectui#10881 — the refusal of a grouped grid over a data source that + // cannot answer the group header query. Borrowed from the `grid.*` + // namespace: `ObjectGrid` refuses with the same sentence, and one refusal + // should not get two translations that can drift apart. + 'grid.grouping.needsHeaderQuery': + 'This view is grouped, but its data source does not implement queryGroupHeaders, so the groups cannot be counted. Remove the grouping to show the records.', // The bare NOUN, for the search button's tooltip. It is deliberately NOT the // input placeholder: that is `table.search` below (objectui#4375). 'list.search': 'Search', @@ -2114,6 +2120,35 @@ export const ListView = React.forwardRef(({ typeof dataSource?.queryGroupHeaders === 'function' && !searchTerm; + /** + * Is a GROUPED grid refused here? (objectui#10881, maintainer ruling F) + * + * The other half of `gridOwnsGroupedFetch`: a grouped grid over a data + * source that cannot answer the group header query. This component would + * fetch one window and hand it down as `data`, and the grid takes rows it + * is handed as the whole set — it would group the window as if nothing + * were withheld, page-slice counts and missing groups included, and it + * cannot tell. So the refusal is made HERE, before a grid is mounted: the + * same sentence the grid refuses with, no window fetched + * (`groupingNeedsHeaderQuery` stands the fetch effect down), and no + * record-count bar. + * + * Only where THIS component fetches: rows handed in whole (`schema.data` as + * an array, or a `value` provider) are grouped where they are, exactly. The + * grouping counts the entries the grid would group by + * (`collectGroupingFieldRefs`: a named `field`), not the raw array, so a + * grouping of empty holes refuses nothing. A toolbar search does not lift + * the refusal: the data source cannot answer the header query either way. + */ + const groupingNeedsHeaderQuery = + currentView === 'grid' && + collectGroupingFieldRefs(groupingConfig).length > 0 && + !Array.isArray(schema.data) && + (schema.data as any)?.provider !== 'value' && + !!schema.objectName && + !!dataSource && + typeof dataSource.queryGroupHeaders !== 'function'; + /** * Does the surface rendered below draw the rows THIS component fetched? * @@ -2157,8 +2192,10 @@ export const ListView = React.forwardRef(({ * grouped grid grouping on the server (`gridOwnsGroupedFetch`). Its group * headers carry the true counts and every group pages its own rows, so this * bar's "N records · Showing first N" would describe a window it never drew. + * objectui#10881 adds the refused grouped grid (`groupingNeedsHeaderQuery`), + * which draws no rows at all. */ - const surfaceDrawsFetchedRows = currentView !== 'gantt' && !gridOwnsGroupedFetch; + const surfaceDrawsFetchedRows = currentView !== 'gantt' && !gridOwnsGroupedFetch && !groupingNeedsHeaderQuery; // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a @@ -2221,6 +2258,17 @@ export const ListView = React.forwardRef(({ return; } + // [objectui#10881] A grouped grid this data source cannot count is refused + // before it is mounted (`groupingNeedsHeaderQuery`), so no window is + // fetched for it, and none held: the rows of an earlier query would + // otherwise still feed the filter option counts and the client export. + if (groupingNeedsHeaderQuery) { + setData([]); + setLoading(false); + setDataLimitReached(false); + return; + } + // Wait for objectDef to load before fetching data so that $expand is computed if (!objectDefLoaded) return; @@ -2745,13 +2793,18 @@ export const ListView = React.forwardRef(({ // the user leaves it for a board — a second identical request under the old // list. Read it as a claim about this definition, ⛔ not as a measured one. // + // objectui#10881 — `groupingNeedsHeaderQuery` is named for the reason + // `ganttOwnsData` is: it flips this effect between fetching and standing + // down. It reads `currentView`, so leaving a refused grouped grid for a + // board re-runs this effect once, and that run is the board's fetch. + // // ⚠️ The directive below governs the NEXT LINE. Anything written between it // and the dependency array detaches it from the array and turns it into an // unused directive — which `eslint .` reports as an ERROR, and which also // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData, invalidationNonce]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change + }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, ganttOwnsData, invalidationNonce, groupingNeedsHeaderQuery]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -4932,7 +4985,22 @@ export const ListView = React.forwardRef(({ the ListView level so every inner view (grid/kanban/calendar/...) gets a consistent indicator instead of momentarily showing an empty state on slow networks. */} - {loadError && data.length === 0 ? ( + {/* objectui#10881 — a grouped grid this data source cannot count is + refused before it is mounted, with the grid's own sentence (see + `groupingNeedsHeaderQuery`). No Retry: the verdict is a property + of the data source, so a retry re-reaches the same refusal. The + toolbar stays, and removing the grouping there lifts it. */} + {groupingNeedsHeaderQuery ? ( + } + iconWrapperClassName="mb-3" + title={t('list.loadErrorTitle')} + message={t('grid.grouping.needsHeaderQuery')} + /> + ) : loadError && data.length === 0 ? ( { + // ── CONTROLS: no grid owns a grouped fetch where the server cannot group ─ + it('CONTROL — a data source with no header query mounts no grid at all (objectui#10881)', async () => { const ds = makeDataSource(false); renderList(ds, listSchema()); - await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); - // …with the AUTHORED filter, exactly as before. - expect(lastGridProps.schema.filter).toEqual([{ field: 'status', operator: 'equals', value: 'open' }]); - await waitFor(() => expect(screen.getByTestId('record-count-bar')).toBeInTheDocument()); + // It used to hand the grid its window with the AUTHORED filter; ruling F + // refuses there instead, before a grid is mounted. + await waitFor(() => expect(screen.getByTestId('list-grouping-needs-header-query')).toBeInTheDocument()); + expect(lastGridProps).toBeNull(); + expect(screen.queryByTestId('record-count-bar')).toBeNull(); }); it('CONTROL — an UNGROUPED grid is still handed its window, header query or not', async () => { diff --git a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx new file mode 100644 index 0000000000..2707a844e8 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx @@ -0,0 +1,148 @@ +/** + * 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#10881 — ListView refuses a GROUPED grid over a data source with no + * group header query, before it mounts one. + * + * Maintainer ruling F: a grouped grid over a source that declares no + * `queryGroupHeaders` refuses grouping loudly. The grid refuses when it owns + * its fetch (`groupingNeedsHeaderQuery-10881.test.tsx` in `plugin-grid`), but + * the console mounts it THROUGH this component, which fetches one window + * (`$top: pageSize`) and hands it down as `data` — and the grid takes rows it + * is handed as the whole set, so it would group the window with page-slice + * counts and nothing to tell it otherwise. So the refusal is made here: the + * grid's own sentence in place of the grid, and no window fetched. + * + * Controls: the same source is queried and handed to the grid when the view + * is not grouped (so "no row query" is a measurement), and rows handed in + * whole — a `value` provider — still reach the grid grouped, over the very same + * find-only source (so the refusal keys on "this component fetched a window", + * never on the source alone). + * + * A stub grid stands in for the renderer: what is under test is whether the + * host mounts it and what it hands down. + */ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; + +const OBJECT = 'duly_task'; + +let lastGridProps: any = null; + +const ROWS = Array.from({ length: 186 }, (_, i) => ({ id: `t-${i}`, subject: `Task ${i}`, business_unit: `bu_${i % 5}` })); + +/** A data source that can only `find` — no `queryGroupHeaders`. */ +const makeFindOnlyDataSource = () => ({ + find: vi.fn(async (_object: string, params: any) => { + const top = params?.$top ?? 100; + return { data: ROWS.slice(0, top), total: ROWS.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + subject: { type: 'text' }, + business_unit: { type: 'text' }, + }, + })), +}) as any; + +const GROUPING = { fields: [{ field: 'business_unit', order: 'asc', collapsed: false }] }; + +const listSchema = (over: Record = {}): ListViewSchema => ({ + type: 'list-view', + objectName: OBJECT, + viewType: 'grid', + columns: ['subject'], + pagination: { pageSize: 100 }, + grouping: GROUPING, + ...over, +} as unknown as ListViewSchema); + +let prevObjectGrid: any; +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', (props: any) => { + lastGridProps = props; + return
; + }); +}); +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); +beforeEach(() => { lastGridProps = null; }); +afterEach(() => { cleanup(); lastGridProps = null; }); + +const renderList = (ds: any, schema: ListViewSchema) => + render( + + + , + ); + +/** Let the fetch effect run to its end once the object definition is in. */ +const settle = async () => { + await act(async () => { + for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +const refusal = () => screen.queryByTestId('list-grouping-needs-header-query'); + +describe('ListView refuses a grouped grid over a data source with no header query (objectui#10881)', () => { + it('renders the refusal naming queryGroupHeaders, mounts no grid, and issues no row query', async () => { + const ds = makeFindOnlyDataSource(); + renderList(ds, listSchema()); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await settle(); + + const panel = refusal(); + expect(panel).not.toBeNull(); + expect(panel!.getAttribute('role')).toBe('alert'); + expect(panel!.textContent).toContain('queryGroupHeaders'); + expect(ds.find).not.toHaveBeenCalled(); + expect(lastGridProps, 'a grid handed a window would group it as if it were whole').toBeNull(); + expect(screen.queryByTestId('grid-stub')).toBeNull(); + expect(screen.queryByTestId('record-count-bar')).toBeNull(); + }); + + // CONTROL — the same source is queried, and handed down, when nothing is grouped. + it('CONTROL — the same find-only source, ungrouped, is queried and the grid is handed its window', async () => { + const ds = makeFindOnlyDataSource(); + renderList(ds, listSchema({ grouping: undefined })); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); + expect(refusal()).toBeNull(); + }); + + // CONTROL — rows handed in whole are grouped where they are, over the same source. + it('CONTROL — rows handed in whole (`value` provider) still reach the grouped grid, whole, with no query', async () => { + const ds = makeFindOnlyDataSource(); + renderList(ds, listSchema({ data: { provider: 'value', items: ROWS } })); + + await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument()); + await settle(); + expect(lastGridProps.data).toHaveLength(ROWS.length); + expect(lastGridProps.schema.grouping.fields.map((f: any) => f.field)).toEqual(['business_unit']); + expect(refusal()).toBeNull(); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/utils/nonGridRowCeiling.tsx b/packages/react/src/utils/nonGridRowCeiling.tsx index 65c249b59d..13e6e15631 100644 --- a/packages/react/src/utils/nonGridRowCeiling.tsx +++ b/packages/react/src/utils/nonGridRowCeiling.tsx @@ -27,11 +27,10 @@ import { useObjectTranslation, en } from '@object-ui/i18n'; * fraction of itself; "some rows are missing" leaves it indistinguishable from * a complete one, and `2,000 of 40,000` is the bit that was missing. * - * Two sentences because there are two conditions, the same split - * `grid.grouping.partialNotice` carries: a known total states the fact with - * both numbers; an adapter that reported no `total` still gets a DEFINITE - * sentence (the probe row proves more rows exist), it simply cannot name how - * many. + * Two sentences because there are two conditions: a known total states the + * fact with both numbers; an adapter that reported no `total` still gets a + * DEFINITE sentence (the probe row proves more rows exist), it simply cannot + * name how many. * * ## It takes the RESULT, and nothing else (objectui#7508, ruling A′) * diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index 3a3c5c8729..adfd3cdd75 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -792,8 +792,10 @@ export interface DataSource { * * Optional, and presence is the capability: a grid only asks a data source * that declares this member. A source that cannot answer it must leave it - * undeclared rather than approximate it — a grouped grid over such a source - * keeps grouping the rows it fetched, and says on screen that it did. + * undeclared rather than approximate it — a grouped grid that fetches its + * own rows over such a source refuses grouping, naming this member, and + * fetches no rows (objectui#10881). Rows a host hands in whole are still + * grouped where they are. * * ⛔ Not a second spelling of {@link aggregate}: that member takes the * analytics-shaped {@link AggregateParams} (one field, one function, one From 12c2a5cfc2266e661b1a0a856376cbc9507aef87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:38:42 +0000 Subject: [PATCH 2/7] test(plugin-grid): re-decide six grouped-grid pins that fetched over a find-only source (objectui#10881) Ruling F refuses a grouped grid that fetches its own rows over a data source with no group header query, so six files whose doubles were find-only now reach the path each one pins another way: - groupingProjection-7179, gridGroupingMembers-8071, badgeHexCrossSurface and maskedColumnSurfaces-10583: the double answers queryGroupHeaders, and the projection / group label is read off the server-grouped path. - expandFls-7215: the double declares queryGroupHeaders; PIN 7 groups only by an unreadable key, so its grid still fetches the flat window. - ObjectGrid.pageSizeNonPositive-9853: the grouped cases hand their seven rows in whole; the page of groups is the same on either path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- ...jectGrid.pageSizeNonPositive-9853.test.tsx | 9 +++++++- .../__tests__/badgeHexCrossSurface.test.tsx | 7 ++++++ .../src/__tests__/expandFls-7215.test.tsx | 6 +++++ .../gridGroupingMembers-8071.test.tsx | 14 ++++++++--- .../groupingProjection-7179.test.tsx | 18 +++++++++++++-- .../maskedColumnSurfaces-10583.test.tsx | 23 ++++++++++++++++++- 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx index 62eba5c20c..e3d24b3b22 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx @@ -115,8 +115,15 @@ const gridSchema = (extra: Record = {}) => ({ ...extra, }); +// The seven rows handed in WHOLE: since objectui#10881 a grouped grid that +// fetches its own rows over a data source with no group header query refuses +// grouping, and the page of groups read here is the same on either path. const groupedSchema = (extra: Record = {}) => - gridSchema({ grouping: { fields: [{ field: 'business_unit' }] }, ...extra }); + gridSchema({ + grouping: { fields: [{ field: 'business_unit' }] }, + data: { provider: 'value', items: ROWS }, + ...extra, + }); let warnings: string[] = []; beforeEach(() => { diff --git a/packages/plugin-grid/src/__tests__/badgeHexCrossSurface.test.tsx b/packages/plugin-grid/src/__tests__/badgeHexCrossSurface.test.tsx index fe01cb43c3..cbaa76dd72 100644 --- a/packages/plugin-grid/src/__tests__/badgeHexCrossSurface.test.tsx +++ b/packages/plugin-grid/src/__tests__/badgeHexCrossSurface.test.tsx @@ -70,6 +70,13 @@ const ROWS = [ function makeDataSource() { return { find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false, pageSize: 50 })), + // objectui#10881: a grouped grid that fetches its own rows groups only + // over a data source that answers the group header query; a find-only one + // is refused. One header row per stage, as the server would count them. + queryGroupHeaders: vi.fn(async () => [ + { stage: 'emerald', count: 1 }, + { stage: 'forest', count: 1 }, + ]), getObjectSchema: vi.fn(async (name: string) => ({ name, fields: { diff --git a/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx b/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx index f541c49e1d..bba1e35f4d 100644 --- a/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx +++ b/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx @@ -117,6 +117,12 @@ const OBJECT_FIELDS = { const makeDataSource = () => ({ find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + // objectui#10881: over a data source with no group header query a grouped + // grid refuses and asks for nothing, so PIN 7's grouping needs one. It is + // answered with no groups: PIN 7 groups only by a key this principal cannot + // read, which the server is never asked to group by, so that grid fetches + // its flat window — the projection under test. + queryGroupHeaders: vi.fn().mockResolvedValue([]), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx b/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx index 8240ddce2b..605363de92 100644 --- a/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx @@ -118,6 +118,13 @@ const settled = async () => { function makeAdapter() { return { + // objectui#10881: a grouped grid fetches rows only over a data source that + // answers the group header query (a find-only one is refused), so the + // first `find` below is a group's row query — it carries the grid's own + // projection, as the flat window did. + queryGroupHeaders: vi.fn(async (_object: string, query: { groupBy?: string[] }) => [ + { ...Object.fromEntries((query.groupBy ?? []).map((field) => [field, 'won'])), count: 2 }, + ]), find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }), findOne: vi.fn(), create: vi.fn(), @@ -216,9 +223,10 @@ describe('object-grid `grouping` reaches the QUERY, not just the screen (objectu columns: [{ field: 'name' }], grouping: { fields: [{ field: 'stage' }] }, }); - // Without this the server never returns `stage`, `useGroupedData` reads - // `undefined` on every row, and ONE `(empty)` group holds every record — - // a plausible, wrong statement about the data (objectui#7179). + // The grouped field is asked for with the rows it heads. objectui#7179 + // closed this where a fetched window was grouped in the browser, whose + // every row would otherwise read `undefined` there and land in ONE + // `(empty)` group. expect(params.$select).toEqual(['id', 'name', 'stage']); }); diff --git a/packages/plugin-grid/src/__tests__/groupingProjection-7179.test.tsx b/packages/plugin-grid/src/__tests__/groupingProjection-7179.test.tsx index c507ffeee6..7f24f0a199 100644 --- a/packages/plugin-grid/src/__tests__/groupingProjection-7179.test.tsx +++ b/packages/plugin-grid/src/__tests__/groupingProjection-7179.test.tsx @@ -108,6 +108,14 @@ const OBJECT_FIELDS = { owner: { type: 'lookup', reference: 'sys_user', label: 'Owner' }, }; +/** + * Since objectui#10881 (ruling F) a grouped grid that fetches its own rows + * asks for rows only over a data source that answers the group header query — + * over a find-only one it refuses grouping and sends nothing. So this double + * answers `queryGroupHeaders` with one group (every grouped field `'x'`), and + * a grouped grid's projection is read off that group's row query, which + * carries the grid's own `$select` / `$expand` exactly as the flat window did. + */ const makeDataSource = () => ({ find: vi.fn().mockResolvedValue({ data: [], total: 0 }), findOne: vi.fn(), @@ -115,6 +123,9 @@ const makeDataSource = () => ({ update: vi.fn(), delete: vi.fn(), getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), + queryGroupHeaders: vi.fn(async (_object: string, query: { groupBy?: string[] }) => [ + { ...Object.fromEntries((query.groupBy ?? []).map((field) => [field, 'x'])), count: 1 }, + ]), }); /** Render a grid and return the params it actually asked the server for. */ @@ -126,8 +137,11 @@ const paramsFor = async (schemaExtra: Record) => { , ); - await vi.waitFor(() => expect(ds.find).toHaveBeenCalled()); - const call = ds.find.mock.calls.at(-1)?.[1] ?? {}; + // Rows of THIS object: a lookup grouping key also reads the referenced + // object, to label its groups. + const rowCalls = () => ds.find.mock.calls.filter((c) => c[0] === OBJECT); + await vi.waitFor(() => expect(rowCalls().length).toBeGreaterThan(0)); + const call = rowCalls().at(-1)?.[1] ?? {}; return { select: (call.$select ?? []) as string[], expand: (call.$expand ?? []) as string[], diff --git a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx index f1a8bc7abf..7759f0f3f7 100644 --- a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx +++ b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx @@ -312,10 +312,31 @@ describe('ObjectGrid — a masked field is refused as a grouping key (objectui#1 ]; const groupRows = () => Array.from(document.querySelectorAll('[data-testid^="group-row-"]')); + // objectui#10881: a grouped grid that fetches its own rows groups only over + // a data source that answers the group header query (a find-only one is + // refused), so this one answers it by counting ROWS under the query's own + // `groupBy`. A masked key never reaches that query: it is dropped from the + // grouping before the header query is asked. + const groupedDataSource = () => ({ + ...makeDataSource(ROWS, FIELDS), + queryGroupHeaders: vi.fn(async (_object: string, query: { groupBy?: string[] }) => { + const by = query.groupBy ?? []; + const counts = new Map(); + for (const row of ROWS as Array>) { + const key = JSON.stringify(by.map((field) => row[field] ?? null)); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const values = JSON.parse(key) as unknown[]; + return { ...Object.fromEntries(by.map((field, i) => [field, values[i]])), count }; + }); + }), + }); + function renderGrouped(fields: Array<{ field: string }>) { renderGrid( { objectName: 'masked_group_probe', columns: COLUMNS, grouping: { fields } }, - makeDataSource(ROWS, FIELDS), + groupedDataSource(), ); } From dfd9bb18799440386c365fed82b3621639decc74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:44:22 +0000 Subject: [PATCH 3/7] test(plugin-list): re-decide three grouping projection pins over a find-only source (objectui#10881) Ruling F: ListView refuses a grouped grid over a data source with no group header query before it fetches anything. ListView.groupingProjection-7179, ListView.expandFls-7215 and ListView.speculativeFls-7216 read the grouping projection off ListView's window fetch, so their doubles now declare queryGroupHeaders; over such a source the window is still fetched (the rows a searched grouped view is handed, and what the chip counts and the client export read). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- .../src/__tests__/ListView.expandFls-7215.test.tsx | 7 +++++++ .../__tests__/ListView.groupingProjection-7179.test.tsx | 7 +++++++ .../src/__tests__/ListView.speculativeFls-7216.test.tsx | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx b/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx index cfe24c76de..310360a6ba 100644 --- a/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx @@ -89,6 +89,13 @@ const objectDef = { const makeDataSource = () => ({ find: vi.fn(async () => ({ data: [], total: 0 })), + // objectui#10881: over a data source that declares no `queryGroupHeaders` + // a grouped grid view is refused before this component fetches anything. + // Declaring it keeps the window fetch the grouping pins read the + // projection from: the rows a grouped grid is handed while a toolbar + // search is active, and what the filter chip counts and the client export + // read. + queryGroupHeaders: vi.fn(async () => []), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx index 437497ca36..c59ac37200 100644 --- a/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx @@ -59,6 +59,13 @@ const objectDef = { const makeDataSource = () => ({ find: vi.fn(async () => ({ data: [], total: 0 })), + // objectui#10881: over a data source that declares no `queryGroupHeaders` + // a grouped grid view is refused before this component fetches anything. + // Declaring it keeps the window fetch the grouping pins read the + // projection from: the rows a grouped grid is handed while a toolbar + // search is active, and what the filter chip counts and the client export + // read. + queryGroupHeaders: vi.fn(async () => []), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), 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 ed42640ca5..02b4669157 100644 --- a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx @@ -116,6 +116,13 @@ const READABLE = ['id', 'subject', 'region', 'end_date']; const makeDataSource = () => ({ find: vi.fn(async () => ({ data: [], total: 0 })), + // objectui#10881: over a data source that declares no `queryGroupHeaders` + // a grouped grid view is refused before this component fetches anything. + // Declaring it keeps the window fetch the grouping pins read the + // projection from: the rows a grouped grid is handed while a toolbar + // search is active, and what the filter chip counts and the client export + // read. + queryGroupHeaders: vi.fn(async () => []), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), From 09d6c338a1ca4adc4ca4f37f411f184e2aa5bbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:51:07 +0000 Subject: [PATCH 4/7] docs(changeset): a grouped grid over a source with no header query refuses grouping; dated notes on the two 7189 entries (objectui#10881) One minor changeset for @object-ui/plugin-grid, @object-ui/plugin-list and @object-ui/i18n, with the breaking semantics and the migration (implement queryGroupHeaders, or hand the rows in whole). The pending 7189-grouped-grid-partial-disclosure and 7189-server-side-grid-grouping entries each gain a dated note naming the sentences ruling F falsified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- .../10881-grouping-needs-header-query.md | 49 +++++++++++++++++++ .../7189-grouped-grid-partial-disclosure.md | 12 +++++ .changeset/7189-server-side-grid-grouping.md | 12 +++++ 3 files changed, 73 insertions(+) create mode 100644 .changeset/10881-grouping-needs-header-query.md diff --git a/.changeset/10881-grouping-needs-header-query.md b/.changeset/10881-grouping-needs-header-query.md new file mode 100644 index 0000000000..8130215716 --- /dev/null +++ b/.changeset/10881-grouping-needs-header-query.md @@ -0,0 +1,49 @@ +--- +'@object-ui/plugin-grid': minor +'@object-ui/plugin-list': minor +'@object-ui/i18n': minor +--- + +A grouped grid over a data source that declares no `queryGroupHeaders` now +**refuses grouping** instead of grouping a page of rows (objectui#10881, +maintainer ruling F). Grouping is a property of the query (ruling A on +objectui#7189): a data source that cannot answer the group header query cannot +group, so the grid names the member it is missing rather than drawing counts it +cannot know. + +- **`@object-ui/plugin-grid`** — a grouped `object-grid` that fetches its own + rows over such a source renders an error panel naming `queryGroupHeaders` + and issues no row query. It used to fetch one window and group it in the + browser, so every group count was a page slice and a group whose rows all + fell past the window was missing. Rows handed in whole — a + `data: { provider: 'value', items }` block, or a `data` prop from a parent + view — are still grouped in the browser, exactly. +- **`@object-ui/plugin-list`** — `ListView` makes the same refusal, with the + same sentence, in place of a grouped grid over such a source, and fetches no + window for it. It used to fetch one window and hand it to the grid as + `data`, which the grid grouped as if the rows were whole. Rows handed to + `ListView` in whole are still handed to the grid. +- **`@object-ui/i18n`** — one new key, `grid.grouping.needsHeaderQuery`, in all + ten locale packs. + +The `Partial` marker described by the pending +`7189-grouped-grid-partial-disclosure` entry is retired everywhere before any +release carried it: its three `grid.grouping.partial*` strings, `GroupRow`'s +`partialLabel` / `partialTitle` props and the notice above the group list are +gone. + +**Breaking semantics (declared `minor` per this repo's version policy):** a +grouped grid that fetches its own rows over a data source without +`queryGroupHeaders` — `ApiDataSource`, `ValueDataSource`, or a host adapter — +grouped the page it fetched in the last release, and now renders an error panel +instead. So does a grouped grid view in `ListView` over such a source. To keep +grouping, either: + +- implement `queryGroupHeaders` on the data source (the ObjectStack adapter + does), or +- hand the rows in whole (`data: { provider: 'value', items }`). + +Not changed here: while a toolbar search is active, `ListView` over a data +source that answers the header query still hands a grouped grid its window, +whose group counts are the window's, because the header query carries no +search (objectstack#20358). diff --git a/.changeset/7189-grouped-grid-partial-disclosure.md b/.changeset/7189-grouped-grid-partial-disclosure.md index 946c03e7cd..59ac3755a2 100644 --- a/.changeset/7189-grouped-grid-partial-disclosure.md +++ b/.changeset/7189-grouped-grid-partial-disclosure.md @@ -67,3 +67,15 @@ described above never render there. On a data source without as described above, and the marker and the notice are kept there. The rest of this entry is kept as the reading of this change; the `7189-server-side-grid-grouping` entry states what a grouped grid does now. + +⚠️ **Dated note, 2026-09-28 — the `Partial` marker is retired before release — +objectui#10881.** Later in this same release the marker and the notice described +above were retired, with the three `grid.grouping.partial*` strings and +`GroupRow`'s `partialLabel` / `partialTitle` props that carried them (maintainer +ruling F). A grouped grid that fetches its own rows over a data source that +declares no `queryGroupHeaders` no longer groups the page it fetched: it +refuses grouping with an error naming that member. So the last sentences of the +note above — that on such a source the grid still groups the page it fetched, +and that the marker and the notice are kept there — no longer describe the +grid. The rest of this entry is kept as the reading of this change; the +`10881-grouping-needs-header-query` entry states what ships. diff --git a/.changeset/7189-server-side-grid-grouping.md b/.changeset/7189-server-side-grid-grouping.md index ad7e7ce1b3..db34be1ca4 100644 --- a/.changeset/7189-server-side-grid-grouping.md +++ b/.changeset/7189-server-side-grid-grouping.md @@ -66,3 +66,15 @@ This supersedes the last paragraph of the pending `7189-grouped-grid-partial-disclosure` changeset, which said server-side grouping was not built: it now is, and the `Partial` marker described there remains only for the data sources that cannot answer the header query. + +⚠️ **Dated note, 2026-09-28 — a source with no header query now refuses +grouping — objectui#10881.** Later in this same release two sentences above +stopped describing the grid: that a source with no header query still groups +the page it fetched and still marks it partial, and that the `Partial` marker +remains for the data sources that cannot answer the header query. Maintainer +ruling F retired both. Over such a source a grouped grid that fetches its own +rows, and a `ListView` hosting a grouped grid, refuse grouping with an error +naming `queryGroupHeaders`, and the marker is gone everywhere. Rows handed in +whole are still grouped in the browser. The rest of this entry is kept as the +reading of this change; the `10881-grouping-needs-header-query` entry states +what ships. From 638a8250c9850113f566d10f6d9878bdf477822e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:01:34 +0000 Subject: [PATCH 5/7] refactor(plugin-list): type the refusal's provider read without an any cast (objectui#10881) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- packages/plugin-list/src/ListView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 8d97a556b5..7d0c5482ca 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2144,7 +2144,7 @@ export const ListView = React.forwardRef(({ currentView === 'grid' && collectGroupingFieldRefs(groupingConfig).length > 0 && !Array.isArray(schema.data) && - (schema.data as any)?.provider !== 'value' && + (schema.data as { provider?: unknown } | undefined)?.provider !== 'value' && !!schema.objectName && !!dataSource && typeof dataSource.queryGroupHeaders !== 'function'; From 156eb51033e350674d2139a9d16311411b237def Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:45:32 +0000 Subject: [PATCH 6/7] feat(plugin-grid): refuse grouping over rows a host declares to be one page of more; the header-query panel waits behind the spinner (objectui#10881) Patch round 2, the seat's decision B. A grouped grid handed rows by a host that also declares them one page of a larger set (manualPagination, onPageChange and a rowCount above the rows handed) now refuses grouping in the load-error panel idiom, with its own key grid.grouping.needsWholeRows in all ten packs: grouping needs every record, so hand the rows in whole or let the grid fetch them. Rows handed with no rowCount, or one not above them, still group exactly. The header-query refusal now renders after the spinner branch, so a grouping whose only key turns out to be a masked type shows loading, not an error it withdraws. The card-view exclusion pin, lost with the retired disclosure file, is re-homed in the grid pin file; ListView is measured to hand a grouped grid no host paging. Changeset and docs updated. Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- .../10881-grouping-needs-header-query.md | 24 ++-- content/docs/plugins/plugin-grid.mdx | 5 +- packages/i18n/src/locales/ar.ts | 9 +- packages/i18n/src/locales/de.ts | 9 +- packages/i18n/src/locales/en.ts | 9 +- packages/i18n/src/locales/es.ts | 9 +- packages/i18n/src/locales/fr.ts | 9 +- packages/i18n/src/locales/ja.ts | 9 +- packages/i18n/src/locales/ko.ts | 9 +- packages/i18n/src/locales/pt.ts | 9 +- packages/i18n/src/locales/ru.ts | 9 +- packages/i18n/src/locales/zh.ts | 9 +- packages/plugin-grid/README.md | 5 +- packages/plugin-grid/src/ObjectGrid.tsx | 55 +++++++- .../groupingNeedsHeaderQuery-10881.test.tsx | 127 ++++++++++++++++++ packages/plugin-grid/src/useGroupedData.ts | 8 +- ...ew.groupingNeedsHeaderQuery-10881.test.tsx | 25 ++++ 17 files changed, 290 insertions(+), 49 deletions(-) diff --git a/.changeset/10881-grouping-needs-header-query.md b/.changeset/10881-grouping-needs-header-query.md index 8130215716..64b16e69cb 100644 --- a/.changeset/10881-grouping-needs-header-query.md +++ b/.changeset/10881-grouping-needs-header-query.md @@ -15,16 +15,21 @@ cannot know. rows over such a source renders an error panel naming `queryGroupHeaders` and issues no row query. It used to fetch one window and group it in the browser, so every group count was a page slice and a group whose rows all - fell past the window was missing. Rows handed in whole — a + fell past the window was missing. Rows handed in WHOLE — a `data: { provider: 'value', items }` block, or a `data` prop from a parent - view — are still grouped in the browser, exactly. + view that declares no larger `rowCount` — are still grouped in the browser, + exactly. A host that hands rows in while declaring them one page of more + (the external-pagination props: `manualPagination`, `onPageChange` and a + `rowCount` above the rows it handed) is now refused as well, with a sentence + of its own: grouping needs every record. It used to have that page grouped as + if it were whole. - **`@object-ui/plugin-list`** — `ListView` makes the same refusal, with the same sentence, in place of a grouped grid over such a source, and fetches no window for it. It used to fetch one window and hand it to the grid as `data`, which the grid grouped as if the rows were whole. Rows handed to `ListView` in whole are still handed to the grid. -- **`@object-ui/i18n`** — one new key, `grid.grouping.needsHeaderQuery`, in all - ten locale packs. +- **`@object-ui/i18n`** — two new keys, `grid.grouping.needsHeaderQuery` and + `grid.grouping.needsWholeRows`, in all ten locale packs. The `Partial` marker described by the pending `7189-grouped-grid-partial-disclosure` entry is retired everywhere before any @@ -36,12 +41,15 @@ gone. grouped grid that fetches its own rows over a data source without `queryGroupHeaders` — `ApiDataSource`, `ValueDataSource`, or a host adapter — grouped the page it fetched in the last release, and now renders an error panel -instead. So does a grouped grid view in `ListView` over such a source. To keep -grouping, either: +instead. So does a grouped grid view in `ListView` over such a source. So does +a grouped grid handed rows by a host that declares them one page of more +(`manualPagination`, `onPageChange` and a `rowCount` above the rows handed), +whatever its data source. To keep grouping, either: - implement `queryGroupHeaders` on the data source (the ObjectStack adapter - does), or -- hand the rows in whole (`data: { provider: 'value', items }`). + does), and let the grid fetch its own rows, or +- hand the rows in whole (`data: { provider: 'value', items }`, or a `data` + prop with no `rowCount` above it). Not changed here: while a toolbar search is active, `ListView` over a data source that answers the header query still hands a grouped grid its window, diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index f51640731c..dddb9c8735 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -369,7 +369,10 @@ the view's effective filter, rather than a window of rows. **Rows handed in whole** (`data: { provider: 'value', items }`, or a host's whole result set) are grouped where they are, in the browser: nothing was withheld, so the grouping is exact. The grid takes rows a host hands it to be -the whole set. +the whole set — unless that host declares them one page of more +(`manualPagination`, `onPageChange` and a `rowCount` above the rows it +handed). A grouped grid refuses such a window with an error saying grouping +needs every record; hand the rows in whole, or let the grid fetch them. **Everything else needs the group header query** (objectui#10881). Over a data source that declares no `queryGroupHeaders`, a grouped grid that fetches its diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 0d6c04569b..35b4c55bb7 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -409,12 +409,15 @@ const ar = { yes: "نعم", no: "لا", systemFields: "النظام", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "هذا العرض مُجمَّع، لكن مصدر بياناته لا يطبّق queryGroupHeaders، لذا لا يمكن عدّ المجموعات. أزِل التجميع لعرض السجلات.", + needsWholeRows: + "يتطلب التجميع جميع السجلات، لكن هذه الشبكة تلقّت صفحة واحدة منها فقط، لذا لا يمكن عدّ المجموعات. مرّر جميع السجلات، أو دع الشبكة تجلبها من مصدر بيانات يطبّق queryGroupHeaders.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 36ad27685b..5b6237d865 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -398,12 +398,15 @@ const de = { yes: "Ja", no: "Nein", systemFields: "System", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "Diese Ansicht ist gruppiert, aber ihre Datenquelle implementiert queryGroupHeaders nicht, daher können die Gruppen nicht gezählt werden. Entfernen Sie die Gruppierung, um die Datensätze anzuzeigen.", + needsWholeRows: + "Die Gruppierung benötigt alle Datensätze, aber dieses Raster hat nur eine Seite davon erhalten, daher können die Gruppen nicht gezählt werden. Übergeben Sie alle Datensätze oder lassen Sie das Raster sie aus einer Datenquelle laden, die queryGroupHeaders implementiert.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 3965e21536..092ff66646 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -510,12 +510,15 @@ const en = { yes: 'Yes', no: 'No', systemFields: 'System', - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: 'This view is grouped, but its data source does not implement queryGroupHeaders, so the groups cannot be counted. Remove the grouping to show the records.', + needsWholeRows: + 'Grouping needs every record, but this grid was handed one page of them, so the groups cannot be counted. Hand in every record, or let the grid fetch them from a data source that implements queryGroupHeaders.', }, // Column-footer aggregate prefixes, keyed by the spec's `ColumnSummary` // vocabulary (objectui#4024). The footer already formatted its NUMBER diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c433a6d37a..8d4f614bc0 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -402,12 +402,15 @@ const es = { yes: "Sí", no: "No", systemFields: "Sistema", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "Esta vista está agrupada, pero su origen de datos no implementa queryGroupHeaders, por lo que no se pueden contar los grupos. Quite la agrupación para mostrar los registros.", + needsWholeRows: + "La agrupación necesita todos los registros, pero esta cuadrícula recibió solo una página de ellos, por lo que no se pueden contar los grupos. Entregue todos los registros o deje que la cuadrícula los obtenga de un origen de datos que implemente queryGroupHeaders.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3593c5caa2..d299e441b3 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -398,12 +398,15 @@ const fr = { yes: "Oui", no: "Non", systemFields: "Système", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "Cette vue est groupée, mais sa source de données n'implémente pas queryGroupHeaders : les groupes ne peuvent donc pas être comptés. Supprimez le regroupement pour afficher les enregistrements.", + needsWholeRows: + "Le regroupement a besoin de tous les enregistrements, mais cette grille n'en a reçu qu'une page : les groupes ne peuvent donc pas être comptés. Fournissez tous les enregistrements, ou laissez la grille les charger depuis une source de données qui implémente queryGroupHeaders.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3a62f8d1c2..20eb1e2b41 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -398,12 +398,15 @@ const ja = { yes: "はい", no: "いいえ", systemFields: "システム", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "このビューはグループ化されていますが、データソースが queryGroupHeaders を実装していないため、グループの件数を数えられません。レコードを表示するにはグループ化を解除してください。", + needsWholeRows: + "グループ化にはすべてのレコードが必要ですが、このグリッドにはその一部のページしか渡されていないため、グループの件数を数えられません。すべてのレコードを渡すか、queryGroupHeaders を実装したデータソースからグリッドに読み込ませてください。", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 0f23ed067b..3484e60ad3 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -398,12 +398,15 @@ const ko = { yes: "예", no: "아니요", systemFields: "시스템", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "이 뷰는 그룹화되어 있지만 데이터 소스가 queryGroupHeaders를 구현하지 않아 그룹 개수를 셀 수 없습니다. 레코드를 표시하려면 그룹화를 제거하세요.", + needsWholeRows: + "그룹화에는 모든 레코드가 필요하지만 이 그리드에는 한 페이지만 전달되어 그룹 개수를 셀 수 없습니다. 모든 레코드를 전달하거나 queryGroupHeaders를 구현하는 데이터 소스에서 그리드가 불러오도록 하세요.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 132b87c733..ab58511e6f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -397,12 +397,15 @@ const pt = { yes: "Sim", no: "Não", systemFields: "Sistema", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "Esta visão está agrupada, mas sua fonte de dados não implementa queryGroupHeaders, portanto os grupos não podem ser contados. Remova o agrupamento para mostrar os registros.", + needsWholeRows: + "O agrupamento precisa de todos os registros, mas esta grade recebeu apenas uma página deles, portanto os grupos não podem ser contados. Entregue todos os registros ou deixe a grade buscá-los em uma fonte de dados que implemente queryGroupHeaders.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 9f20e76c77..5ebe178897 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -408,12 +408,15 @@ const ru = { yes: "Да", no: "Нет", systemFields: "Система", - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: "Это представление сгруппировано, но его источник данных не реализует queryGroupHeaders, поэтому группы невозможно подсчитать. Уберите группировку, чтобы показать записи.", + needsWholeRows: + "Для группировки нужны все записи, но этой таблице передана только одна их страница, поэтому группы невозможно подсчитать. Передайте все записи или позвольте таблице загрузить их из источника данных, который реализует queryGroupHeaders.", }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index e4d0b3c5c2..47df70bd56 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -384,12 +384,15 @@ const zh = { yes: '是', no: '否', systemFields: '系统字段', - // objectui#10881 — the grouped grid's refusal over a data source that - // cannot answer the group header query. `queryGroupHeaders` is the - // member's name and stays untranslated. + // objectui#10881 — the grouped grid's two refusals: over a data source + // that cannot answer the group header query, and over rows a host + // handed in while declaring them one page of more. `queryGroupHeaders` + // is the member's name and stays untranslated. grouping: { needsHeaderQuery: '此视图已分组,但其数据源未实现 queryGroupHeaders,因此无法统计分组。移除分组即可显示记录。', + needsWholeRows: + '分组需要全部记录,但此表格只收到其中一页,因此无法统计分组。请传入全部记录,或让表格从实现了 queryGroupHeaders 的数据源获取记录。', }, // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 7f79014612..1367f370c8 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -487,7 +487,10 @@ the view's effective filter, rather than a window of rows. **Rows handed in whole** (`data: { provider: 'value', items }`, or a host's whole result set) are grouped where they are, in the browser: nothing was withheld, so the grouping is exact. The grid takes rows a host hands it to be -the whole set. +the whole set — unless that host declares them one page of more +(`manualPagination`, `onPageChange` and a `rowCount` above the rows it +handed). A grouped grid refuses such a window with an error saying grouping +needs every record; hand the rows in whole, or let the grid fetch them. **Everything else needs the group header query** (objectui#10881). Over a data source that declares no `queryGroupHeaders`, a grouped grid that fetches its diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index e615191013..1a462a769d 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -447,6 +447,9 @@ const GRID_DEFAULT_TRANSLATIONS: Record = { // grid draws. 'grid.grouping.needsHeaderQuery': 'This view is grouped, but its data source does not implement queryGroupHeaders, so the groups cannot be counted. Remove the grouping to show the records.', + // …and over rows a host handed in while declaring them one page of more. + 'grid.grouping.needsWholeRows': + 'Grouping needs every record, but this grid was handed one page of them, so the groups cannot be counted. Hand in every record, or let the grid fetch them from a data source that implements queryGroupHeaders.', // Reused by the grouped-view pager (falls back here when no I18nProvider). 'table.rowsPerPage': 'Rows per page', 'table.pageInfo': 'Page {{current}} of {{total}}', @@ -2129,6 +2132,25 @@ export const ObjectGrid: React.FC = ({ && !!dataSource && typeof dataSource.queryGroupHeaders !== 'function' && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); + // [objectui#10881] The second refusal: rows a host handed in that the SAME + // host declares to be one page of more — the external-pagination props + // (`externalManualPagination`) with a `rowCount` above the rows it handed. + // Only rows handed in WHOLE group where they are; a declared window grouped + // here shows page-slice counts and drops every group whose rows fall past + // it (on 100 of 186 rows over five units, two groups: 86 and 14). The + // header-query sentence is the wrong one here: the host owns this fetch, + // and its data source may even declare `queryGroupHeaders`. So this names + // its own remedy — hand the rows in whole, or let the grid fetch them. + // Counted against the rows as HANDED (`dataConfig.items`), not the `data` + // state that mirrors them one render later, so whole rows never flash it. + // Same grouping entries as `groupingNeedsHeaderQuery`. A `rowCount` absent + // or not above the handed rows is whole rows, and groups as before. + const handedRows = hasInlineData ? (dataConfig as { items?: unknown } | undefined)?.items : undefined; + const groupingNeedsWholeRows = + externalManualPagination + && Array.isArray(handedRows) + && (hostRowCount as number) > handedRows.length + && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); // The grid's own row query — projection, expansion, order and the view's // filter — resolved by the load effect below exactly as the flat fetch // resolves it, and handed to each group's row page. `null` until resolved. @@ -4042,18 +4064,19 @@ export const ObjectGrid: React.FC = ({ ); } - // [objectui#10881] A grouping the data source cannot count, refused in the - // load-error panel's own shape: nothing was fetched, and the sentence names - // the member the data source would need (`groupingNeedsHeaderQuery`). - if (groupingNeedsHeaderQuery) { + // [objectui#10881] A grouping of rows a host declared to be one page of + // more, refused in the load-error panel's own shape with its own sentence + // (`groupingNeedsWholeRows`). Nothing to wait for: the rows are the host's, + // so this comes before the spinner. + if (groupingNeedsWholeRows) { return (

{t('grid.errorLoading')}

-

{t('grid.grouping.needsHeaderQuery')}

+

{t('grid.grouping.needsWholeRows')}

); } @@ -4099,6 +4122,26 @@ export const ObjectGrid: React.FC = ({ ); } + // [objectui#10881] A grouping the data source cannot count, refused in the + // load-error panel's own shape: nothing was fetched, and the sentence names + // the member the data source would need (`groupingNeedsHeaderQuery`). AFTER + // the spinner, deliberately: the refusal is decided before the object's + // field types are known, and a grouping whose only key turns out to be a + // masked type is no grouping at all — so while that one read is in flight + // the grid shows it is loading, never an error it is about to withdraw. + if (groupingNeedsHeaderQuery) { + return ( +
+

{t('grid.errorLoading')}

+

{t('grid.grouping.needsHeaderQuery')}

+
+ ); + } + const columns: ObjectGridColumn[] = generateColumns().map((col): ObjectGridColumnDraft => { // Enrich each column with its field type so the data-table's type-aware // inline editor can pick the matching control (dropdown for select, diff --git a/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx b/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx index a851de43f0..cd9e740e09 100644 --- a/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx +++ b/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx @@ -26,6 +26,19 @@ * is queried and draws its rows (so "no row query" is a measurement, not a * harness that never queries), and the same grouping over a source that * declares the member is not refused. + * + * The second refusal (patch round 2, the seat's decision B): rows a host + * hands in while DECLARING them one page of more — `manualPagination` with a + * `rowCount` above the rows handed — are a window, not whole rows, and a + * grouped grid refuses them with a sentence of its own. On the fixture's first + * 100 rows with `rowCount: 186` the grid used to draw two groups (86, 14), + * three units missing, and nothing saying so. Its controls: the same host + * declaring `rowCount` equal to the rows it handed, and a host declaring no + * `rowCount` at all, both group as whole rows. + * + * Also here, re-homed from the retired `groupedPartialDisclosure-7189` file: + * grouping never reaches the mobile card view (`useCardView` requires + * `!isGrouped`), with the ungrouped mobile render as its lit control. */ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { render, cleanup, screen, act } from '@testing-library/react'; @@ -102,6 +115,16 @@ const settle = async () => { }; const refusal = () => screen.queryByTestId('grid-grouping-needs-header-query'); +const wholeRowsRefusal = () => screen.queryByTestId('grid-grouping-needs-whole-rows'); + +/** The host-driven paging props (`ObjectGridExternalPaginationProps`) declaring `rowCount`. */ +const hostPaging = (rowCount: number) => ({ + manualPagination: true, + rowCount, + page: 1, + pageSize: 100, + onPageChange: vi.fn(), +}); const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; const headerCounts = () => Object.fromEntries(groupRows().map((row) => [ row.querySelector('.group-label')?.textContent ?? '', @@ -164,3 +187,107 @@ describe('rows handed in whole still group in the browser, exactly (objectui#108 expect(ds.find).not.toHaveBeenCalled(); }); }); + +describe('a host-declared window is refused too, with its own sentence (objectui#10881, round 2)', () => { + it('100 rows handed with rowCount 186: the refusal, no group drawn, no query', async () => { + const ds = makeFindOnlyDataSource(); + renderGrid(ds, { grouping: GROUPING }, { data: ROWS.slice(0, 100), ...hostPaging(ROWS.length) }); + + await vi.waitFor(() => expect(wholeRowsRefusal()).toBeInTheDocument()); + await settle(); + const panel = wholeRowsRefusal()!; + expect(panel).toHaveAttribute('role', 'alert'); + expect(panel.textContent).toMatch(/every record/i); + // Not the header-query refusal: the host owns this fetch. + expect(refusal()).not.toBeInTheDocument(); + expect(groupRows()).toHaveLength(0); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('refuses the same window over a source that DOES declare queryGroupHeaders', async () => { + const ds = { + ...makeFindOnlyDataSource(), + queryGroupHeaders: vi.fn(async () => UNITS.map(([business_unit, count]) => ({ business_unit, count }))), + }; + renderGrid(ds, { grouping: GROUPING }, { data: ROWS.slice(0, 100), ...hostPaging(ROWS.length) }); + + await vi.waitFor(() => expect(wholeRowsRefusal()).toBeInTheDocument()); + await settle(); + expect(groupRows()).toHaveLength(0); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); + }); + + // CONTROL — whole rows under the same props: rowCount equals the rows handed. + it('CONTROL — 186 rows handed with rowCount 186 still group exactly: 86/61/31/7/1', async () => { + const ds = makeFindOnlyDataSource(); + renderGrid(ds, { grouping: GROUPING }, { data: ROWS, ...hostPaging(ROWS.length) }); + + await vi.waitFor(() => expect(groupRows()).toHaveLength(5)); + expect(headerCounts()).toEqual(EXPECTED); + expect(wholeRowsRefusal()).not.toBeInTheDocument(); + }); + + // CONTROL — no rowCount declared: the rows are the whole set, as handed. + it('CONTROL — 100 rows handed with no rowCount group as handed, unrefused', async () => { + const ds = makeFindOnlyDataSource(); + renderGrid(ds, { grouping: GROUPING }, { data: ROWS.slice(0, 100) }); + + await vi.waitFor(() => expect(groupRows()).toHaveLength(2)); + expect(headerCounts()).toEqual({ 'Northgate Operations': 86, 'Northgate Plant': 14 }); + expect(wholeRowsRefusal()).not.toBeInTheDocument(); + }); +}); + +describe('a masked-only grouping key shows loading, never an error it withdraws (objectui#10881, round 2)', () => { + it('while the object definition is in flight: the spinner, not the refusal; then the flat rows', async () => { + // ONE definition read in flight, answered once: every call (the load + // effect re-runs when the grouping flips) waits on the same answer. + let answer!: (value: unknown) => void; + const definition = new Promise((resolve) => { answer = resolve; }); + const ds = { + ...makeFindOnlyDataSource(), + getObjectSchema: vi.fn(() => definition), + }; + renderGrid(ds, { grouping: { fields: [{ field: 'secret' }] } }); + + await vi.waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await settle(); + // The key's type is not known yet, so the grouping still counts as one. + expect(refusal()).not.toBeInTheDocument(); + expect(screen.getByText('Loading grid…')).toBeInTheDocument(); + + // It is a masked type: no grouping at all, so the flat window goes out. + await act(async () => { + answer({ name: OBJECT, fields: { ...OBJECT_FIELDS, secret: { type: 'password', label: 'Secret' } } }); + }); + await vi.waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(screen.getByText('Northgate Operations #0')).toBeInTheDocument()); + expect(refusal()).not.toBeInTheDocument(); + expect(groupRows()).toHaveLength(0); + }); +}); + +describe('grouping never reaches the mobile card view (re-homed from groupedPartialDisclosure-7189)', () => { + const originalWidth = window.innerWidth; + const setWidth = (value: number) => + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value }); + afterEach(() => setWidth(originalWidth)); + + it('a grouped grid under 768px draws its groups, not cards', async () => { + setWidth(390); + renderGrid(makeFindOnlyDataSource(), { grouping: GROUPING, data: { provider: 'value', items: ROWS } }); + + await vi.waitFor(() => expect(groupRows()).toHaveLength(5)); + expect(document.querySelector('table')).not.toBeNull(); + }); + + // CONTROL — the same rows ungrouped at the same width ARE the card view. + it('CONTROL — the same rows ungrouped under 768px are drawn as cards, with no table', async () => { + setWidth(390); + renderGrid(makeFindOnlyDataSource(), { data: { provider: 'value', items: ROWS } }); + + await vi.waitFor(() => expect(screen.getAllByText('Northgate Operations #0').length).toBeGreaterThan(0)); + expect(document.querySelector('table')).toBeNull(); + expect(groupRows()).toHaveLength(0); + }); +}); diff --git a/packages/plugin-grid/src/useGroupedData.ts b/packages/plugin-grid/src/useGroupedData.ts index b6a918a344..b0c0cddb1e 100644 --- a/packages/plugin-grid/src/useGroupedData.ts +++ b/packages/plugin-grid/src/useGroupedData.ts @@ -378,9 +378,11 @@ function sameGroupKey(a: unknown, b: unknown): boolean { * it to be (inline rows, a host's whole result set). A grid that fetches * its own rows never groups them here: over a data source with no header * query it refuses grouping instead (objectui#10881, ruling F). A host - * that hands in a WINDOW gets that window grouped — `ListView` still does - * while a toolbar search is active, since the header query carries no - * search (objectstack#20358). + * that DECLARES the rows one page of more (`manualPagination`, + * `onPageChange` and a larger `rowCount`) is refused too; one that hands in a window without + * saying so gets that window grouped — `ListView` does while a toolbar + * search is active, since the header query carries no search + * (objectstack#20358). * * @param config - GroupingConfig from the grid schema (optional) * @param data - flat data rows (grouped only when `server` is absent) diff --git a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx index 2707a844e8..415729ddc4 100644 --- a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx @@ -131,6 +131,10 @@ describe('ListView refuses a grouped grid over a data source with no header quer await waitFor(() => expect(ds.find).toHaveBeenCalled()); await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); expect(refusal()).toBeNull(); + // Ungrouped, the window comes WITH host paging: the grid is told it holds + // one page of `rowCount`. The lit half of the searched-window pin below. + await waitFor(() => expect(lastGridProps.manualPagination).toBe(true)); + expect(lastGridProps.rowCount).toBe(ROWS.length); }); // CONTROL — rows handed in whole are grouped where they are, over the same source. @@ -145,4 +149,25 @@ describe('ListView refuses a grouped grid over a data source with no header quer expect(refusal()).toBeNull(); expect(ds.find).not.toHaveBeenCalled(); }); + + // Round 2 (the grid's second refusal, a host-DECLARED window): does ListView + // ever declare one? The one shape in which it hands a GROUPED grid a window + // is a source that answers the header query while a toolbar search is + // active (objectstack#20358). Measured there: no host paging, because + // `paginate` is off while grouped — the grid takes those rows as handed. + it('a grouped grid handed a searched window is handed no host paging', async () => { + const ds = { ...makeFindOnlyDataSource(), queryGroupHeaders: vi.fn(async () => []) }; + render( + + + , + ); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); + expect(lastGridProps.schema.grouping.fields.map((f: any) => f.field)).toEqual(['business_unit']); + expect(lastGridProps.manualPagination).toBeUndefined(); + expect(lastGridProps.rowCount).toBeUndefined(); + expect(refusal()).toBeNull(); + }); }); From 94df460146e9fb82958386e622ce44aa8ec15cb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:10:39 +0000 Subject: [PATCH 7/7] test(plugin-list): type the searched-window pin's field callback (objectui#10881) Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- .../__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx index 415729ddc4..f81e1a0e0c 100644 --- a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx @@ -165,7 +165,7 @@ describe('ListView refuses a grouped grid over a data source with no header quer await waitFor(() => expect(ds.find).toHaveBeenCalled()); await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); - expect(lastGridProps.schema.grouping.fields.map((f: any) => f.field)).toEqual(['business_unit']); + expect(lastGridProps.schema.grouping.fields.map((f: { field: string }) => f.field)).toEqual(['business_unit']); expect(lastGridProps.manualPagination).toBeUndefined(); expect(lastGridProps.rowCount).toBeUndefined(); expect(refusal()).toBeNull();