Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/10881-grouping-needs-header-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@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 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`** — 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
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. 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), 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,
whose group counts are the window's, because the header query carries no
search (objectstack#20358).
12 changes: 12 additions & 0 deletions .changeset/7189-grouped-grid-partial-disclosure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions .changeset/7189-server-side-grid-grouping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
37 changes: 22 additions & 15 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -362,23 +362,30 @@ 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 — 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
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

Expand Down
55 changes: 55 additions & 0 deletions packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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');
});
});
26 changes: 14 additions & 12 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} سجل. ضيّق عامل التصفية.",
},
Expand Down Expand Up @@ -409,13 +409,15 @@ const ar = {
yes: "نعم",
no: "لا",
systemFields: "النظام",
// objectui#7189 — partial-grouping disclosure for the grouped grid.
// 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: {
partialBadge: "جزئي",
partialNotice:
"تم التجميع على أول {{loaded}} سجل من أصل {{total}}. أعداد المجموعات تخص الصفحة المحمَّلة فقط، وأي مجموعة تقع كل سجلاتها خارج الصفوف المحمَّلة لا تظهر هنا.",
partialNoticeUnknownTotal:
"تم التجميع على {{loaded}} سجل محمَّل. قد تتطابق سجلات أخرى مع هذا العرض، لذا قد تكون أعداد المجموعات جزئية وقد لا تظهر إحدى المجموعات هنا.",
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
Expand Down
26 changes: 14 additions & 12 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
Expand Down Expand Up @@ -398,13 +398,15 @@ const de = {
yes: "Ja",
no: "Nein",
systemFields: "System",
// objectui#7189 — partial-grouping disclosure for the grouped grid.
// 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: {
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.",
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
Expand Down
Loading
Loading