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
15 changes: 15 additions & 0 deletions .changeset/12081-footer-sum-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/i18n': minor
'@object-ui/types': patch
---

A paged grid's column footer reads the whole matched set, or says it reads the page (objectui#12081 item 3).

Under server paging a grid holds one page of the matched set, and the footer summed that page and drew the figure beside the count of the whole set: "Planned Amount: Sum: 3,685,900.00 · 300 records", where the 300 rows sum to 40,607,000. Every footer aggregate had the same fault (`count`, `avg`, `min`, `max`, the filled, empty and unique counts, and the two percents).

- **The whole set's figures, from the server.** When the rows are one page of more, the grid asks for the figures in ONE aggregate query through the data source's `queryGroupHeaders`, with no `groupBy`. The query is the one behind the rows: its filter, search and search fields, without its page, sort or projection. Turning the page or sorting asks nothing. A refresh or a write to the object asks again. These figures are the server's, so "empty" means a stored `null`. This holds both when the grid fetches its own rows and when a host such as `ListView` hands it one page with `manualPagination`, `rowCount` and `findParams`.
- **Otherwise, the page's figure, labelled as the page's.** The grid uses the answer only when its row count equals the count the grid shows. Otherwise the footer shows the page's figure as `Sum (this page): 32,500`. That happens when the data source has no `queryGroupHeaders`, refuses the query or answers without one row, when the query carries a key this read cannot repeat, and when the count shown is an estimate. The platform's paged count under a search is one.
- **Unchanged:** rows handed in whole, and a grid whose one page holds every match, keep the footer they had and ask the server nothing. A grid whose columns declare no summary that maps to an aggregate (none at all, only `none`, or only unknown members) asks nothing either: it has no footer. `useColumnSummary` keeps its signature and still summarises the rows it is handed.

Added: one language-pack key, `grid.summary.pagePattern`, in all ten packs. The `DataSource.queryGroupHeaders` doc comment now says what the same query with no `groupBy` answers. No export, prop or type member is added.
20 changes: 20 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,26 @@ formatting hint (`currency`, `defaultCurrency`, `currencyConfig`, `precision`,
reads it. `ListColumnSchema` declares none of them, so a column that carries one
does not change the footer (objectui#11588).

**Which rows the figures describe (objectui#12081).** The footer is drawn
beside the count of the matched set, so its figures are that set's. When the
grid holds every matched row (rows handed in whole, or one page that holds every
match) they are computed from those rows. When the grid holds one page of a
larger set — it pages on the server, or a host such as `ListView` hands it one
page with `manualPagination`, `rowCount` and `findParams` — it asks the data
source for the whole set's figures in ONE aggregate query through
`queryGroupHeaders`, with no `groupBy`: the query behind the rows (its filter,
search and search fields), without its page, sort or projection. Turning the
page or sorting asks nothing; a refresh or a write to the object asks again.
A grid whose columns declare no summary that maps to an aggregate (none at
all, only `none`, or only unknown members) has no footer and asks nothing.
These figures are the server's, so "empty" means a stored `null`. The answer is
used only when its row count equals the count the grid shows. Otherwise the
footer shows the page's figure and says so: `Sum (this page): 32,500`. That is
the case when the data source has no `queryGroupHeaders`, refuses the query or
answers without one row, when the query carries a key this read cannot repeat,
and when the count shown is an estimate. The platform's paged count under a
search is one.

The footer row renders only when at least one column resolves to a summary — a
view whose columns are all `none` (or carry no `summary`) has no footer.

Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ const ar = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}} (هذه الصفحة): {{value}}",
count: "العدد",
countEmpty: "فارغة",
countFilled: "ممتلئة",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const de = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}} (diese Seite): {{value}}",
count: "Anzahl",
countEmpty: "Leer",
countFilled: "Ausgefüllt",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,11 +694,18 @@ const en = {
// right-to-left — so a pack owns the whole shape. Same reasoning as
// `collaboration.resolvedSuffix` below.
//
// `pagePattern` is `pattern` for a figure that covers only the page on
// screen of a larger matched set — the footer's fallback when the server
// cannot answer the whole set's figure (objectui#12081 item 3). A whole
// pattern rather than a suffix, for the same reason: where the scope sits
// is the pack's.
//
// `countEmpty`/`percentEmpty` (and the filled pair) deliberately share a
// word: the trailing `%` is what tells the two families apart on screen,
// exactly as the renderer's own comment says.
summary: {
pattern: '{{label}}: {{value}}',
pagePattern: '{{label}} (this page): {{value}}',
count: 'Count',
countEmpty: 'Empty',
countFilled: 'Filled',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ const es = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}} (esta página): {{value}}",
count: "Recuento",
countEmpty: "Vacíos",
countFilled: "Rellenos",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ const fr = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}} : {{value}}",
pagePattern: "{{label}} (cette page) : {{value}}",
count: "Nombre",
countEmpty: "Vides",
countFilled: "Remplis",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const ja = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}}(このページ): {{value}}",
count: "件数",
countEmpty: "空欄",
countFilled: "入力済み",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const ko = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}} (이 페이지): {{value}}",
count: "개수",
countEmpty: "빈 값",
countFilled: "채워짐",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ const pt = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}} (esta página): {{value}}",
count: "Contagem",
countEmpty: "Vazios",
countFilled: "Preenchidos",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ const ru = {
// pack controls its own separator and word order.
summary: {
pattern: "{{label}}: {{value}}",
pagePattern: "{{label}} (эта страница): {{value}}",
count: "Количество",
countEmpty: "Пустые",
countFilled: "Заполненные",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ const zh = {
// pack controls its own separator and word order.
summary: {
pattern: '{{label}}:{{value}}',
pagePattern: '{{label}}(本页):{{value}}',
count: '计数',
countEmpty: '空值',
countFilled: '非空',
Expand Down
20 changes: 20 additions & 0 deletions packages/plugin-grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,26 @@ it was computed from carried — an average of `1`, `2`, `2` reads `Avg: 2`, and
sum of `0.1` and `0.2` reads `Sum: 0.3` (objectstack#19628, objectui#9843). A
column with no `type` keeps the plain widths it always had.

**Which rows the figures describe (objectui#12081).** The footer is drawn
beside the count of the matched set, so its figures are that set's. When the
grid holds every matched row (rows handed in whole, or one page that holds every
match) they are computed from those rows. When the grid holds one page of a
larger set — it pages on the server, or a host such as `ListView` hands it one
page with `manualPagination`, `rowCount` and `findParams` — it asks the data
source for the whole set's figures in ONE aggregate query through
`queryGroupHeaders`, with no `groupBy`: the query behind the rows (its filter,
search and search fields), without its page, sort or projection. Turning the
page or sorting asks nothing; a refresh or a write to the object asks again.
A grid whose columns declare no summary that maps to an aggregate (none at
all, only `none`, or only unknown members) has no footer and asks nothing.
These figures are the server's, so "empty" means a stored `null`. The answer is
used only when its row count equals the count the grid shows. Otherwise the
footer shows the page's figure and says so: `Sum (this page): 32,500`. That is
the case when the data source has no `queryGroupHeaders`, refuses the query or
answers without one row, when the query carries a key this read cannot repeat,
and when the count shown is an estimate. The platform's paged count under a
search is one.

The footer row renders only when at least one column resolves to a summary — a
view whose columns are all `none` (or carry no `summary`) has no footer.

Expand Down
116 changes: 75 additions & 41 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ import { useRowColor } from './useRowColor';
import { useGroupedDataInOptionOrder, usableGroupingFields, type GroupOptionRanks, type ServerGroupSource } from './useGroupedData';
import { groupSearchOf, useServerGroupHeaders, useServerGroupRows, type ServerGroupLeaf } from './useServerGrouping';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { useScopedColumnSummary } from './useColumnSummary';
import { useWholeSetSummary } from './useWholeSetSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
Expand Down Expand Up @@ -3230,6 +3231,51 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
if (serverGroupTotal !== undefined) setTotalMatching(serverGroupTotal);
}, [serverGroupTotal]);

// The query the cross-page fan-out would replay — from whichever side owns the
// fetch (objectui#4501). ONE value, read by the fan-out AND by the affordance
// gate below, so the offer and the thing it promises can never disagree —
// and by the footer, whose whole-set figures are this same query's
// (objectui#12081 item 3). Declared here, above the footer's hook, rather
// than beside the fan-out: hooks run before the component's early returns.
//
// `hasInlineData` is exactly the data loader's own guard (`if (hasInlineData)
// return`), which makes it the precise test for "this grid did not issue the
// query behind the rows on screen". In that case `lastFindParamsRef` is not
// merely empty, it is WRONG — either never written, or left over from an
// earlier own-fetch — so the host's `findParams` is the only admissible
// source, and there is deliberately no fallback to the ref and no `?? {}`
// default: replaying `{}` is what asked the server for the whole object and
// fed up to 5000 unmatched records to bulk delete.
const bulkFanoutParams: Record<string, unknown> | null = hasInlineData
? (hostFindParams ?? null)
: (lastFindParamsRef.current ?? null);

// The real match total, from whichever side owns the fetch — ONE derived
// value with ONE answer (objectui#4464). The pager has always read it this
// way; the cross-page "Select all N matching" affordance read the raw
// `totalMatching` STATE instead, whose only writer is this component's own
// data loader. Under a host that fetches the rows itself (ListView passing
// `manualPagination` + `rowCount`, i.e. the console) that loader never runs,
// so the state stayed `undefined` and `BulkActionBar`'s gate was permanently
// false while the pager, two lines away, showed the correct page count off
// the very number the bar needed.
//
// Every consumer reads this const — the pager's `rowCount`, both
// `BulkActionBar` sites, and the footer's whole-set read, which checks the
// count it is answered against this one (objectui#12081 item 3). Do NOT
// re-spell the conditional at a second consumption site: two copies of the
// fallback is exactly how one of them gets missed again (this defect, and
// #4138 before it).
//
// This answers HOW MANY match, and `canOfferSelectAllMatching` (objectui#4501)
// independently answers WHETHER the escalation may be offered at all. The two
// compose and neither substitutes for the other: the bar sites read this total
// only *through* that floor, so a host with a real `rowCount` but no
// `findParams` still gets no offer — a number to display is not a query to
// replay. The floor also subsumes the `singleSelection` suppression that used
// to be spelled at these sites (`!singleSelection` is its first conjunct).
const resolvedTotalMatching = externalManualPagination ? hostRowCount : totalMatching;

// --- Column summary support ---
const summaryColumns = React.useMemo(() => {
const cols = normalizeColumns(schema.columns);
Expand All @@ -3238,7 +3284,34 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
}
return undefined;
}, [schema.columns]);
const { summaries, hasSummary } = useColumnSummary(summaryColumns, data, objectSchema?.fields);
// [objectui#12081 item 3] Whether the rows the footer reads ARE the set it
// is drawn beside. Under server paging they are one page of it: a host
// (`ListView`) hands this grid one window with the set's `rowCount`, or this
// grid's own loader asked for one page of `fetchWindow` rows. Rows handed in
// without a declared page are the set, and so is a first page that holds
// every match. Only the flat table draws the footer, so a grouped grid asks
// nothing.
const footerRowsAreTheSet = externalManualPagination
? (hostRowCount as number) <= data.length
: hasInlineData || (serverPage === 1 && (typeof totalMatching === 'number'
? totalMatching <= data.length
: data.length < fetchWindow));
const wholeSetSummary = useWholeSetSummary({
enabled: !footerRowsAreTheSet && !isGrouped && !serverGroupedFetch && data.length > 0,
dataSource,
objectName,
columns: summaryColumns,
findParams: bulkFanoutParams,
count: resolvedTotalMatching,
reloadKey: String(refreshKey),
});
const { summaries, hasSummary } = useScopedColumnSummary(
summaryColumns,
data,
objectSchema?.fields,
footerRowsAreTheSet ? 'rows' : (wholeSetSummary.status === 'answered' ? 'whole' : 'page'),
wholeSetSummary.row,
);

// An authored column the renderer cannot read now says so, instead of just
// not being there (objectui#5349 — the Q2 objectui#5068 deferred).
Expand Down Expand Up @@ -4928,22 +5001,6 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// matching" escalation must never be offered.
const singleSelection = selectionMode === 'single';

// The query the cross-page fan-out would replay — from whichever side owns the
// fetch (objectui#4501). ONE value, read by the fan-out AND by the affordance
// gate below, so the offer and the thing it promises can never disagree.
//
// `hasInlineData` is exactly the data loader's own guard (`if (hasInlineData)
// return`), which makes it the precise test for "this grid did not issue the
// query behind the rows on screen". In that case `lastFindParamsRef` is not
// merely empty, it is WRONG — either never written, or left over from an
// earlier own-fetch — so the host's `findParams` is the only admissible
// source, and there is deliberately no fallback to the ref and no `?? {}`
// default: replaying `{}` is what asked the server for the whole object and
// fed up to 5000 unmatched records to bulk delete.
const bulkFanoutParams: Record<string, unknown> | null = hasInlineData
? (hostFindParams ?? null)
: (lastFindParamsRef.current ?? null);

// The floor. With no query to replay there is no honest "all N matching", so
// the escalation is not offered — the same answer as a match set that does not
// exist. This is what makes an unfiltered fan-out structurally unreachable
Expand Down Expand Up @@ -5318,29 +5375,6 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
? schema.searchableFields.length > 0
: (schema.showSearch !== undefined ? schema.showSearch : true);

// The real match total, from whichever side owns the fetch — ONE derived
// value with ONE answer (objectui#4464). The pager has always read it this
// way; the cross-page "Select all N matching" affordance read the raw
// `totalMatching` STATE instead, whose only writer is this component's own
// data loader. Under a host that fetches the rows itself (ListView passing
// `manualPagination` + `rowCount`, i.e. the console) that loader never runs,
// so the state stayed `undefined` and `BulkActionBar`'s gate was permanently
// false while the pager, two lines away, showed the correct page count off
// the very number the bar needed.
//
// All three consumers now read this const — the pager's `rowCount` and both
// `BulkActionBar` sites. Do NOT re-spell the conditional at a second
// consumption site: two copies of the fallback is exactly how one of them
// gets missed again (this defect, and #4138 before it).
//
// This answers HOW MANY match, and `canOfferSelectAllMatching` (objectui#4501)
// independently answers WHETHER the escalation may be offered at all. The two
// compose and neither substitutes for the other: the bar sites read this total
// only *through* that floor, so a host with a real `rowCount` but no
// `findParams` still gets no offer — a number to display is not a query to
// replay. The floor also subsumes the `singleSelection` suppression that used
// to be spelled at these sites (`!singleSelection` is its first conjunct).
const resolvedTotalMatching = externalManualPagination ? hostRowCount : totalMatching;
const manualPage = externalManualPagination ? hostPage : serverPage;
const manualPageSize = externalManualPagination
? (hostPageSize ?? serverPageSize)
Expand Down
Loading
Loading