From 95e2271645d05f50307c1c3d790ada005b3e74c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:00:48 +0000 Subject: [PATCH 1/5] test(plugin-grid): pin the footer's scope against a paged grid, red on main (objectui#12081 item 3) The footer sums the rows the grid holds, which under server paging are one page, and draws the figure beside the count of the whole set. Eleven pins fail on main ("Amount: Sum: 32,500" where the 300-row sum is 4,515,000); the two CONTROL pins (rows handed in whole, a one-page set) pass. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .../footerSummaryScope-12081.test.tsx | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx diff --git a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx new file mode 100644 index 0000000000..c745ec4f95 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx @@ -0,0 +1,387 @@ +/** + * 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#12081 item 3 — the footer's figures describe the rows the count + * describes, or say they describe only the page. + * + * Measured on objectstack-ai/hotclm#87 (17.7.0): "Planned Amount: Sum: + * 3,685,900.00 · 300 records", where the 300-row sum is 40,607,000. The grid + * pages on the server, so the rows it holds are ONE page, and the footer + * summed them and drew the figure beside the server's count of the whole set. + * + * ## What the double serves + * + * One store of 300 rows and ONE matcher, applied by both reads: `find` (the + * `$filter` AST lowered through the spec's own `parseFilterAST`, then + * `$search` over `$searchFields`) and `queryGroupHeaders` (the compiled + * `where`, then `search` over `searchFields`). So a whole-set figure that + * reads a different row set than the count is a different number here, not a + * coincidence the double happens to agree with. + * + * ## The cases + * + * - (a) more rows than a page: the footer reads the WHOLE set, on both data + * paths (the grid's own fetch, and a host — `ListView` in the console — + * handing it one page with `manualPagination` + `rowCount` + `findParams`); + * and a page turn does not ask again. + * - (b) no whole-set answer — no door, a refusal, a filter that cannot be + * asked, a host that names no query: the figure is the page's, and its + * label says so. + * - (c) a filter and a search narrow the count and the footer alike. + * - every footer aggregate the spec declares takes the same route. + * - CONTROL: rows handed in whole, and a grid whose one page is the whole + * set, keep the footer they had and ask the server nothing. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { parseFilterAST } from '@objectstack/spec/data'; + +import { ObjectGrid } from '../ObjectGrid'; +import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts'; +import { installExplainDouble } from './explainDouble'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, notifyDataChanged } from '@object-ui/react'; + +beforeAll(() => { + registerAllFields(); + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); +beforeEach(() => { + __clearRecordCrudVerdictCache(); + installExplainDouble(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const OBJECT = 'plan_line'; +const PAGE = 25; + +interface Row { id: string; name: string; stage: string; amount: number; note: string | null } +/** 300 rows; every fifth `note` is empty, every third row is `won`. */ +const ROWS: Row[] = Array.from({ length: 300 }, (_, i) => ({ + id: `r${i}`, + name: `Line ${i}`, + stage: i % 3 === 0 ? 'won' : 'open', + amount: (i + 1) * 100, + note: i % 5 === 0 ? null : `n${i}`, +})); + +const OBJECT_FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + amount: { type: 'number', label: 'Amount' }, + note: { type: 'text', label: 'Note' }, +}; + +/** The `FilterCondition` subset the lowered filters here use. */ +function matchesWhere(row: Record, cond: unknown): boolean { + if (cond === undefined || cond === null) return true; + return Object.entries(cond as Record).every(([key, value]) => { + if (key === '$and') return (value as unknown[]).every((c) => matchesWhere(row, c)); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const op = value as Record; + if ('$eq' in op) return row[key] === op.$eq; + if ('$gt' in op) return Number(row[key]) > Number(op.$gt); + throw new Error(`double: unsupported operator ${JSON.stringify(op)}`); + } + return row[key] === value; + }); +} + +/** ADR-0061 as the double serves it: a substring over the searched fields. */ +function matchesSearch(row: Record, term: unknown, fields: unknown): boolean { + if (term === undefined) return true; + const over = Array.isArray(fields) ? (fields as string[]) : ['name', 'note']; + return over.some((f) => String(row[f] ?? '').toLowerCase().includes(String(term).toLowerCase())); +} + +type Agg = { function: string; field?: string; alias: string }; +type AggregateQuery = { where?: unknown; groupBy?: string[]; aggregations?: Agg[]; search?: unknown; searchFields?: unknown }; + +/** One aggregation node over a row set, as `engine.aggregate` answers it. */ +function aggregateOf(rows: Row[], node: Agg): number | null { + const values = node.field === undefined ? [] : rows.map((r) => (r as any)[node.field!]).filter((v) => v !== null && v !== undefined); + switch (node.function) { + case 'count': return node.field === undefined ? rows.length : values.length; + case 'count_distinct': return new Set(values).size; + case 'sum': return values.reduce((a, b) => a + Number(b), 0); + case 'avg': return values.length === 0 ? null : values.reduce((a, b) => a + Number(b), 0) / values.length; + case 'min': return values.length === 0 ? null : Math.min(...values.map(Number)); + case 'max': return values.length === 0 ? null : Math.max(...values.map(Number)); + default: throw new Error(`double: unsupported aggregation ${node.function}`); + } +} + +const matching = (where: unknown, search: unknown, searchFields: unknown) => + ROWS.filter((r) => matchesWhere(r as any, where) && matchesSearch(r as any, search, searchFields)); + +function makeDataSource(opts: { door?: 'answers' | 'absent' | 'refuses' } = {}) { + const door = opts.door ?? 'answers'; + const ds: Record = { + find: vi.fn(async (_object: string, params: Record) => { + const where = params.$filter === undefined ? undefined : parseFilterAST(params.$filter as any); + const rows = matching(where, params.$search, params.$searchFields); + const skip = (params.$skip as number | undefined) ?? 0; + const top = (params.$top as number | undefined) ?? rows.length; + return { data: rows.slice(skip, skip + top).map((r) => ({ ...r })), total: rows.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), + }; + if (door !== 'absent') { + ds.queryGroupHeaders = vi.fn(async (_object: string, query: AggregateQuery) => { + if (door === 'refuses') throw Object.assign(new Error('refused'), { code: 'UNSUPPORTED_QUERY_PARAM' }); + const groupBy = query.groupBy ?? []; + if (groupBy.length > 0) throw new Error('double: this suite asks no grouped query'); + const rows = matching(query.where, query.search, query.searchFields); + return [Object.fromEntries((query.aggregations ?? []).map((node) => [node.alias, aggregateOf(rows, node)]))]; + }); + } + return ds; +} + +const sumOf = (rows: Row[]) => rows.reduce((a, r) => a + r.amount, 0); +const fmt = (n: number) => n.toLocaleString('en'); + +const AMOUNT_SUM = [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number', summary: 'sum' }, +]; + +const ownFetchGrid = (ds: any, columns: unknown[] = AMOUNT_SUM, extra: Record = {}) => ( + + + +); + +/** The host's page, as `ListView` hands one down (objectui#4501's literal, key for key). */ +function hostGrid(ds: any, opts: { + rows: Row[]; + page?: number; + findParams?: Record | null; + columns?: unknown[]; +}) { + const page = opts.page ?? 1; + const findParams = opts.findParams === undefined + ? { $orderby: [{ field: 'name', order: 'asc' }], $top: PAGE, ...(page > 1 ? { $skip: (page - 1) * PAGE } : {}), $select: ['id', 'name', 'amount'] } + : opts.findParams; + return ( + + {}} + {...(findParams ? { findParams } : {})} + /> + + ); +} + +const footerText = (field: string) => screen.queryByTestId(`summary-${field}`)?.textContent ?? null; + +describe('the footer reads the whole matched set when the grid shows one page of more (objectui#12081 item 3)', () => { + it('(a) own fetch: 300 rows over pages of 25 — the footer sums all 300, not page 1', async () => { + const ds = makeDataSource(); + render(ownFetchGrid(ds)); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); + expect(footerText('amount')).not.toContain(fmt(sumOf(ROWS.slice(0, PAGE)))); + }); + + it('(a) host-driven page: the footer sums the host query\'s whole set', async () => { + const ds = makeDataSource(); + render(hostGrid(ds, { rows: ROWS })); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); + }); + + it('(a) a page turn does not ask again — the question is the query, not the window', async () => { + const ds = makeDataSource(); + const view = render(hostGrid(ds, { rows: ROWS })); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); + const asked = ds.queryGroupHeaders.mock.calls.length; + expect(asked).toBe(1); + + view.rerender(hostGrid(ds, { rows: ROWS, page: 2 })); + await waitFor(() => expect(screen.getByText('Line 25')).toBeInTheDocument()); + expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`); + expect(ds.queryGroupHeaders.mock.calls.length).toBe(asked); + }); + + it('(a) own fetch: turning the page re-fetches the rows and keeps the whole-set answer', async () => { + const ds = makeDataSource(); + render(ownFetchGrid(ds)); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); + const asked = ds.queryGroupHeaders.mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + await waitFor(() => expect(screen.getByText('Line 25')).toBeInTheDocument()); + expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`); + expect(ds.queryGroupHeaders.mock.calls.length).toBe(asked); + }); + + it('(c) a filter and a search narrow the count and the footer alike', async () => { + const ds = makeDataSource(); + const filter = ['stage', '=', 'won']; + const narrowed = matching(parseFilterAST(filter as any), 'Line 1', ['name']); + // Not a coincidence the assertions could pass by: a strict subset, larger than a page. + expect(narrowed.length).toBeGreaterThan(PAGE); + expect(narrowed.length).toBeLessThan(ROWS.length); + + render(hostGrid(ds, { + rows: narrowed, + columns: [ + { field: 'name', label: 'Name', summary: 'count' }, + { field: 'amount', label: 'Amount', type: 'number', summary: 'sum' }, + ], + findParams: { $filter: filter, $orderby: [{ field: 'name', order: 'asc' }], $top: PAGE, $search: 'Line 1', $searchFields: ['name'] }, + })); + + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(narrowed))}`)); + // The footer's count is the count's own number: the same rows. + expect(footerText('name')).toBe(`Name: Count: ${fmt(narrowed.length)}`); + // …asked as ONE query carrying the view's filter and the search pair, and nothing about the window. + expect(ds.queryGroupHeaders).toHaveBeenCalledTimes(1); + const [object, query] = ds.queryGroupHeaders.mock.calls[0]; + expect(object).toBe(OBJECT); + expect(query).toEqual({ + where: parseFilterAST(filter as any), + search: 'Line 1', + searchFields: ['name'], + aggregations: [ + { function: 'count', alias: 'count' }, + { function: 'sum', field: 'amount', alias: 'sum_amount' }, + ], + }); + }); + + it('every footer aggregate reads the whole set (count, filled, empty, unique, percent, avg, min, max)', async () => { + const ds = makeDataSource(); + const notes = ROWS.filter((r) => r.note !== null); + render(hostGrid(ds, { + rows: ROWS, + columns: [ + { field: 'name', label: 'Name', summary: 'count' }, + { field: 'note', label: 'Note', summary: 'count_filled' }, + { field: 'stage', label: 'Stage', summary: 'count_unique' }, + { field: 'id', label: 'Id', summary: { type: 'percent_empty', field: 'note' } }, + { field: 'amount', label: 'Amount', type: 'number', summary: 'avg' }, + ], + })); + await waitFor(() => expect(footerText('name')).toBe(`Name: Count: ${fmt(ROWS.length)}`)); + expect(footerText('note')).toBe(`Note: Filled: ${fmt(notes.length)}`); + expect(footerText('stage')).toBe('Stage: Unique: 2'); + expect(footerText('id')).toBe(`Id: Empty: ${((ROWS.length - notes.length) / ROWS.length * 100).toLocaleString('en', { maximumFractionDigits: 1 })}%`); + expect(footerText('amount')).toBe(`Amount: Avg: ${fmt(sumOf(ROWS) / ROWS.length)}`); + + cleanup(); + render(hostGrid(makeDataSource(), { + rows: ROWS, + columns: [ + { field: 'name', label: 'Name', summary: { type: 'min', field: 'amount' } }, + { field: 'amount', label: 'Amount', type: 'number', summary: 'max' }, + { field: 'note', label: 'Note', summary: 'count_empty' }, + ], + })); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Max: ${fmt(ROWS.length * 100)}`)); + expect(footerText('name')).toBe('Name: Min: 100'); + expect(footerText('note')).toBe(`Note: Empty: ${fmt(ROWS.length - notes.length)}`); + }); + + it('a write to the object asks again, so the footer follows the data', async () => { + const ds = makeDataSource(); + render(hostGrid(ds, { rows: ROWS })); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); + expect(ds.queryGroupHeaders).toHaveBeenCalledTimes(1); + + notifyDataChanged({ objectName: OBJECT }); + await waitFor(() => expect(ds.queryGroupHeaders).toHaveBeenCalledTimes(2)); + }); +}); + +describe('no whole-set answer: the figure is the page\'s, and the label says so (objectui#12081 item 3)', () => { + const pageLabel = `Amount: Sum (this page): ${fmt(sumOf(ROWS.slice(0, PAGE)))}`; + + it('(b) the data source declares no aggregate door', async () => { + const ds = makeDataSource({ door: 'absent' }); + render(hostGrid(ds, { rows: ROWS })); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + await waitFor(() => expect(footerText('amount')).toBe(pageLabel)); + }); + + it('(b) the door refuses the query', async () => { + const ds = makeDataSource({ door: 'refuses' }); + render(ownFetchGrid(ds)); + await waitFor(() => expect(ds.queryGroupHeaders).toHaveBeenCalled()); + await waitFor(() => expect(footerText('amount')).toBe(pageLabel)); + }); + + it('(b) a host that hands a page but names no query', async () => { + const ds = makeDataSource(); + render(hostGrid(ds, { rows: ROWS, findParams: null })); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + await waitFor(() => expect(footerText('amount')).toBe(pageLabel)); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); + }); + + it('(b) a query carrying a key the whole-set read cannot repeat is not asked a narrower or wider question', async () => { + const ds = makeDataSource(); + render(hostGrid(ds, { rows: ROWS, findParams: { $top: PAGE, $unknownNarrowing: 'x' } })); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + await waitFor(() => expect(footerText('amount')).toBe(pageLabel)); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); + }); +}); + +describe('CONTROL: rows that ARE the whole set keep their footer and ask nothing (objectui#12081 item 3)', () => { + it('rows handed in whole (no host paging declared)', async () => { + const ds = makeDataSource(); + const rows = ROWS.slice(0, 40); + render( + + + , + ); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(rows))}`)); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); + }); + + it('a grid whose one page is the whole matched set', async () => { + const ds = makeDataSource(); + const filter = ['amount', '>', 29000]; + const few = matching(parseFilterAST(filter as any), undefined, undefined); + expect(few.length).toBeLessThan(PAGE); + render(ownFetchGrid(ds, AMOUNT_SUM, { filter })); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(few))}`)); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); + }); +}); From 961fed18a24f6f0107e1accbf679e94966777a90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:07:53 +0000 Subject: [PATCH 2/5] fix(plugin-grid,i18n): a paged grid's footer reads the whole matched set, or says it reads the page (objectui#12081 item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer summed the rows the grid holds, which under server paging are one page, and drew the figure beside the count of the whole set ("Sum: 3,685,900.00 · 300 records" where the 300 rows sum to 40,607,000). When the rows are one page of more, the grid now asks the whole set's figures as ONE aggregate query through DataSource.queryGroupHeaders with no groupBy: the query behind the rows (filter lowered through parseFilterAST, search and searchFields) less its window, with the node set compiled from the spec's COLUMN_SUMMARY_AGGREGATION and read back through deriveColumnSummary. A page turn or a sort asks nothing; a refresh or a write re-asks. The answer is used only when its count equals the count the grid shows. Otherwise (no door, a refusal, an unaskable query, a count that differs) the figure is the page's and the label says so: new key grid.summary.pagePattern in all ten packs. useColumnSummary's public signature is unchanged; the grid reads the new module-internal useScopedColumnSummary. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 7 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + packages/plugin-grid/src/ObjectGrid.tsx | 116 ++++++--- .../footerSummaryScope-12081.test.tsx | 88 ++++++- packages/plugin-grid/src/useColumnSummary.ts | 108 +++++++- .../plugin-grid/src/useWholeSetSummary.ts | 239 ++++++++++++++++++ 14 files changed, 514 insertions(+), 53 deletions(-) create mode 100644 packages/plugin-grid/src/useWholeSetSummary.ts diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index a570412560..344c427a8d 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -552,6 +552,7 @@ const ar = { // pack controls its own separator and word order. summary: { pattern: "{{label}}: {{value}}", + pagePattern: "{{label}} (هذه الصفحة): {{value}}", count: "العدد", countEmpty: "فارغة", countFilled: "ممتلئة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5c10a3df16..83dff43581 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -488,6 +488,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", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7dd5ff540b..8cf31577db 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -689,11 +689,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', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 424818f0a7..e5276a052c 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -505,6 +505,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", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 73b4a1ed9d..1328e33e94 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -501,6 +501,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", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 9f1ac20a6a..043f117d2b 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -488,6 +488,7 @@ const ja = { // pack controls its own separator and word order. summary: { pattern: "{{label}}: {{value}}", + pagePattern: "{{label}}(このページ): {{value}}", count: "件数", countEmpty: "空欄", countFilled: "入力済み", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 39ea95d5ac..fe41391ce7 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -488,6 +488,7 @@ const ko = { // pack controls its own separator and word order. summary: { pattern: "{{label}}: {{value}}", + pagePattern: "{{label}} (이 페이지): {{value}}", count: "개수", countEmpty: "빈 값", countFilled: "채워짐", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 3bd4de0aa4..d427e3b994 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -500,6 +500,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", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 45c7a66fad..dfa69a10d9 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -522,6 +522,7 @@ const ru = { // pack controls its own separator and word order. summary: { pattern: "{{label}}: {{value}}", + pagePattern: "{{label}} (эта страница): {{value}}", count: "Количество", countEmpty: "Пустые", countFilled: "Заполненные", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index d5f861068c..e2d3d04992 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -471,6 +471,7 @@ const zh = { // pack controls its own separator and word order. summary: { pattern: '{{label}}:{{value}}', + pagePattern: '{{label}}(本页):{{value}}', count: '计数', countEmpty: '空值', countFilled: '非空', diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 36bfe509aa..9544ef65a4 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -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'; @@ -3225,6 +3226,51 @@ export const ObjectGrid: React.FC = ({ 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 | 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); @@ -3233,7 +3279,34 @@ export const ObjectGrid: React.FC = ({ } 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). @@ -4923,22 +4996,6 @@ export const ObjectGrid: React.FC = ({ // 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 | 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 @@ -5292,29 +5349,6 @@ export const ObjectGrid: React.FC = ({ ? 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) diff --git a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx index c745ec4f95..da0471e161 100644 --- a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx +++ b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx @@ -48,7 +48,7 @@ import { ObjectGrid } from '../ObjectGrid'; import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts'; import { installExplainDouble } from './explainDouble'; import { registerAllFields } from '@object-ui/fields'; -import { ActionProvider, notifyDataChanged } from '@object-ui/react'; +import { ActionProvider, I18nProvider, notifyDataChanged } from '@object-ui/react'; beforeAll(() => { registerAllFields(); @@ -128,7 +128,7 @@ function aggregateOf(rows: Row[], node: Agg): number | null { const matching = (where: unknown, search: unknown, searchFields: unknown) => ROWS.filter((r) => matchesWhere(r as any, where) && matchesSearch(r as any, search, searchFields)); -function makeDataSource(opts: { door?: 'answers' | 'absent' | 'refuses' } = {}) { +function makeDataSource(opts: { door?: 'answers' | 'absent' | 'refuses' | 'no-row' | 'strings' } = {}) { const door = opts.door ?? 'answers'; const ds: Record = { find: vi.fn(async (_object: string, params: Record) => { @@ -149,8 +149,14 @@ function makeDataSource(opts: { door?: 'answers' | 'absent' | 'refuses' } = {}) if (door === 'refuses') throw Object.assign(new Error('refused'), { code: 'UNSUPPORTED_QUERY_PARAM' }); const groupBy = query.groupBy ?? []; if (groupBy.length > 0) throw new Error('double: this suite asks no grouped query'); + if (door === 'no-row') return []; const rows = matching(query.where, query.search, query.searchFields); - return [Object.fromEntries((query.aggregations ?? []).map((node) => [node.alias, aggregateOf(rows, node)]))]; + // `strings`: a driver answering decimals and 64-bit counts as their string + // spelling (node-postgres does, for `numeric` and `bigint`). + return [Object.fromEntries((query.aggregations ?? []).map((node) => { + const value = aggregateOf(rows, node); + return [node.alias, door === 'strings' && value !== null ? String(value) : value]; + }))]; }); } return ds; @@ -179,6 +185,8 @@ function hostGrid(ds: any, opts: { page?: number; findParams?: Record | null; columns?: unknown[]; + /** The count the host shows, when it is not the rows' own length. */ + rowCount?: number; }) { const page = opts.page ?? 1; const findParams = opts.findParams === undefined @@ -191,7 +199,7 @@ function hostGrid(ds: any, opts: { dataSource={ds} data={opts.rows.slice((page - 1) * PAGE, page * PAGE)} manualPagination - rowCount={opts.rows.length} + rowCount={opts.rowCount ?? opts.rows.length} page={page} pageSize={PAGE} onPageChange={() => {}} @@ -240,7 +248,11 @@ describe('the footer reads the whole matched set when the grid shows one page of await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); const asked = ds.queryGroupHeaders.mock.calls.length; - fireEvent.click(screen.getByRole('button', { name: /next page/i })); + // The pager's icon buttons carry no accessible name; the next-page one is + // the button holding the full-size right chevron. + const next = document.querySelector('button svg.lucide-chevron-right.h-4')?.closest('button'); + expect(next).toBeTruthy(); + fireEvent.click(next!); await waitFor(() => expect(screen.getByText('Line 25')).toBeInTheDocument()); expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`); expect(ds.queryGroupHeaders.mock.calls.length).toBe(asked); @@ -314,6 +326,34 @@ describe('the footer reads the whole matched set when the grid shows one page of expect(footerText('note')).toBe(`Note: Empty: ${fmt(ROWS.length - notes.length)}`); }); + it('a currency column — the issue\'s own shape — formats the whole-set figure as its cells', async () => { + const ds = makeDataSource(); + render(hostGrid(ds, { + rows: ROWS, + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Planned Amount', type: 'currency', summary: 'sum' }, + ], + })); + // No code resolved: a plain number at two decimals, as the cell renders it. + await waitFor(() => expect(footerText('amount')).toBe(`Planned Amount: Sum: ${sumOf(ROWS).toLocaleString('en', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)); + }); + + it('a driver answering its figures as strings is read as numbers', async () => { + const ds = makeDataSource({ door: 'strings' }); + render(hostGrid(ds, { + rows: ROWS, + columns: [ + { field: 'name', label: 'Name', summary: 'count' }, + { field: 'amount', label: 'Amount', type: 'number', summary: 'sum' }, + { field: 'note', label: 'Note', summary: 'percent_filled' }, + ], + })); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(ROWS))}`)); + expect(footerText('name')).toBe(`Name: Count: ${fmt(ROWS.length)}`); + expect(footerText('note')).toBe('Note: Filled: 80%'); + }); + it('a write to the object asks again, so the footer follows the data', async () => { const ds = makeDataSource(); render(hostGrid(ds, { rows: ROWS })); @@ -342,6 +382,30 @@ describe('no whole-set answer: the figure is the page\'s, and the label says so await waitFor(() => expect(footerText('amount')).toBe(pageLabel)); }); + it('(b) the answer is not one row', async () => { + const ds = makeDataSource({ door: 'no-row' }); + render(hostGrid(ds, { rows: ROWS })); + await waitFor(() => expect(ds.queryGroupHeaders).toHaveBeenCalled()); + await waitFor(() => expect(footerText('amount')).toBe(pageLabel)); + }); + + it('(b) the answer counts other rows than the count shown — the platform\'s paged count under a search is an estimate', async () => { + // Measured on the 17.7.0 data door: under `$search` a paged find answers + // `total` as offset + page + 1 (26 for a page of 25 over 138 matches), + // because `engine.count` takes no search. The whole-set figures describe the + // 138; drawn beside "26" they would describe other rows than the count's. + const ds = makeDataSource(); + const searched = matching(undefined, 'Line 1', ['name']); + expect(searched.length).toBeGreaterThan(PAGE + 1); + render(hostGrid(ds, { + rows: searched, + rowCount: PAGE + 1, + findParams: { $orderby: [{ field: 'name', order: 'asc' }], $top: PAGE, $search: 'Line 1', $searchFields: ['name'] }, + })); + await waitFor(() => expect(ds.queryGroupHeaders).toHaveBeenCalled()); + await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum (this page): ${fmt(sumOf(searched.slice(0, PAGE)))}`)); + }); + it('(b) a host that hands a page but names no query', async () => { const ds = makeDataSource(); render(hostGrid(ds, { rows: ROWS, findParams: null })); @@ -385,3 +449,17 @@ describe('CONTROL: rows that ARE the whole set keep their footer and ask nothing expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); }); }); + +describe('the page label is the pack\'s, not an English suffix (objectui#12081 item 3)', () => { + it('zh: the pack places the scope', async () => { + const ds = makeDataSource({ door: 'absent' }); + render( + + {hostGrid(ds, { rows: ROWS })} + , + ); + await waitFor(() => expect(footerText('amount')).toContain('(本页)')); + expect(footerText('amount')).toContain('合计'); + expect(footerText('amount')).not.toMatch(/this page|Sum/); + }); +}); diff --git a/packages/plugin-grid/src/useColumnSummary.ts b/packages/plugin-grid/src/useColumnSummary.ts index 77bd9172fa..9cb8d937d1 100644 --- a/packages/plugin-grid/src/useColumnSummary.ts +++ b/packages/plugin-grid/src/useColumnSummary.ts @@ -8,7 +8,8 @@ import { useMemo } from 'react'; import type { ListColumn } from '@object-ui/types'; -import type { ColumnSummary } from '@objectstack/spec/ui'; +import type { ColumnSummary, ListViewGroupHeaderRow } from '@objectstack/spec/ui'; +import { deriveColumnSummary } from '@objectstack/spec/ui'; import type { CurrencyConfig } from '@objectstack/spec/data'; import { resolveFieldScale } from '@objectstack/spec/data'; import { useLocalization, useDisplayLocale, resolveFieldCurrency, createSafeTranslation } from '@object-ui/i18n'; @@ -113,6 +114,7 @@ const TYPE_LABEL_KEYS: Record = { */ const SUMMARY_DEFAULT_TRANSLATIONS: Record = { 'grid.summary.pattern': '{{label}}: {{value}}', + 'grid.summary.pagePattern': '{{label}} (this page): {{value}}', 'grid.summary.count': 'Count', 'grid.summary.countEmpty': 'Empty', 'grid.summary.countFilled': 'Filled', @@ -370,14 +372,22 @@ function formatSummaryLabel( // it, and only for a column with no fixed width. Required for the reason // `displayLocale` is: a dropped argument must not type-check into a width. inputFractionDigits: number, + // objectui#12081 item 3 — the figure covers only the page on screen of a + // larger matched set (see `ColumnSummaryScope`), so the label says so. + // Required for the reason the two above are: a dropped argument must not + // type-check into a label that reads as the whole set's total. + pageOnly: boolean, ): string { if (value === null) return ''; const labelKey = TYPE_LABEL_KEYS[type as ColumnSummaryType]; // `|| type` is the backstop for an aggregation with no key — it echoes the // raw kind, as before, never a half-resolved `grid.summary.`. const label = labelKey ? t(labelKey) : type; + // The page scope is its own whole pattern, not a suffix glued onto the + // label: where the scope sits, and its brackets, are the translator's, as + // the separator is (objectui#4024). const join = (formatted: string): string => - t('grid.summary.pattern', { label, value: formatted }); + t(pageOnly ? 'grid.summary.pagePattern' : 'grid.summary.pattern', { label, value: formatted }); if (PERCENT_TYPES.has(type)) { return join(`${value.toLocaleString(displayLocale, { maximumFractionDigits: 1 })}%`); @@ -497,6 +507,45 @@ function formatSummaryLabel( return join(formatted); } +/** The `objectSchema.fields` slice the footer formats with. */ +type SummaryFieldMetadata = Record; + +/** + * Which rows the footer's figures describe (objectui#12081 item 3). + * + * The footer is drawn beside a count, and its figures read as that count's + * rows' figures. Under server paging the rows a grid holds are ONE page of the + * matched set, so a figure computed from them is the page's, and drawn beside + * the set's count it read as the set's total — "Sum: 3,685,900.00 · 300 + * records" where the 300 rows sum to 40,607,000 (objectstack-ai/hotclm#87). + * + * - `rows` — the rows handed in ARE the set: inline rows, a host's whole + * result, a grid whose one page holds every match. Computed from them, as + * the footer always has been. + * - `whole` — the rows are one page of more, and the server answered the + * whole matched set's figures (`useWholeSetSummary`): each figure is read + * off that answer through the spec's `deriveColumnSummary`. + * - `page` — the rows are one page of more and no whole-set answer is in + * hand (no door, a refusal, a query that cannot be repeated, or the answer + * still on its way). The figure is the page's, and the label says so + * (`grid.summary.pagePattern`). ⛔ Never a page figure under the plain + * label beside the set's count. + */ +export type ColumnSummaryScope = 'rows' | 'whole' | 'page'; + +/** + * A server figure as a number: `null` is the aggregate's own "no value" (an + * `avg` over no values), `undefined` is no figure at all. A driver may answer + * a decimal or a 64-bit count as its string spelling, so a string is parsed; + * anything that does not parse is no figure. + */ +function serverFigure(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + const n = typeof value === 'number' ? value : typeof value === 'string' ? parseFloat(value) : Number.NaN; + return Number.isFinite(n) ? n : undefined; +} + /** * Hook to compute column summary/aggregation values. * @@ -518,7 +567,28 @@ export function useColumnSummary( data: any[], // `currencyConfig` is the spec's own type, not a restated subset, so a field // def written in the spec's shape (`currencyMode` included) is accepted as is. - fieldMetadata?: Record + fieldMetadata?: SummaryFieldMetadata +): { summaries: Map; hasSummary: boolean } { + // The rows a caller hands this public hook are the set it summarises, as + // they always were. Only the grid knows when its rows are one page of more, + // and it says so through `useScopedColumnSummary`. + return useScopedColumnSummary(columns, data, fieldMetadata, 'rows', undefined); +} + +/** + * {@link useColumnSummary}, told which rows its figures describe — the grid's + * entry (objectui#12081 item 3). `wholeRow` is the server's answer for the + * whole matched set, read only under the `whole` scope. + * + * Module-internal: the package barrel exports `useColumnSummary` alone, so + * its signature is unchanged for every caller outside this package. + */ +export function useScopedColumnSummary( + columns: ListColumn[] | undefined, + data: any[], + fieldMetadata: SummaryFieldMetadata | undefined, + scope: ColumnSummaryScope, + wholeRow: ListViewGroupHeaderRow | undefined, ): { summaries: Map; hasSummary: boolean } { // Tenant default currency (ADR-0053) backstops a currency column that // declares no explicit code, so the footer agrees with the cells above it. @@ -551,7 +621,24 @@ export function useColumnSummary( if (config.type === 'none' || !SUPPORTED_SUMMARY_TYPES.has(config.type)) continue; const targetField = config.field || col.field; - const result = computeAggregation(config.type, data, targetField); + + // objectui#12081 item 3 — the whole matched set's figure, read off the + // server's answer through the spec's own read-back (the four derived + // members included). The two percents come back as a ratio in `0..1`; + // this footer's percent unit is points, as `computeAggregation` answers + // it. A figure the answer does not carry is NOT the whole set's: that + // one column falls back to the page, labelled as the page's. + let result: number | null; + let pageOnly = scope === 'page'; + const fromServer = scope === 'whole' && wholeRow + ? serverFigure(deriveColumnSummary(wholeRow, col.summary as ColumnSummarySetting, col.field)) + : undefined; + if (fromServer !== undefined) { + result = fromServer !== null && PERCENT_TYPES.has(config.type) ? fromServer * 100 : fromServer; + } else { + result = computeAggregation(config.type, data, targetField); + if (scope === 'whole') pageOnly = true; + } // The column contributes its declared `type` (a `ListColumn` member, // which outranks the field's); every other hint is read off the FIELD @@ -579,10 +666,16 @@ export function useColumnSummary( // The widest decimal count among the numeric values this aggregation // read — what a no-fixed-width `number` column rounds its result to. - // The count and percent families read raw cells, not numbers. + // The count and percent families read raw cells, not numbers. A + // whole-set figure read values this grid never held, so the widest it + // can know is the page's, and a `min` / `max` is itself one of the + // values it read, so its own width counts too. const inputFractionDigits = NON_NUMERIC_TYPES.has(config.type) ? 0 - : widestFractionDigits(numericValues(data, targetField)); + : widestFractionDigits([ + ...numericValues(data, targetField), + ...(fromServer != null && (config.type === 'min' || config.type === 'max') ? [fromServer] : []), + ]); summaries.set(col.field, { field: col.field, @@ -595,10 +688,11 @@ export function useColumnSummary( tenantCurrency, displayLocale, inputFractionDigits, + pageOnly, ), }); } return { summaries, hasSummary: summaries.size > 0 }; - }, [columns, data, fieldMetadata, tenantCurrency, displayLocale, t]); + }, [columns, data, fieldMetadata, tenantCurrency, displayLocale, t, scope, wholeRow]); } diff --git a/packages/plugin-grid/src/useWholeSetSummary.ts b/packages/plugin-grid/src/useWholeSetSummary.ts new file mode 100644 index 0000000000..0e8b5d84fe --- /dev/null +++ b/packages/plugin-grid/src/useWholeSetSummary.ts @@ -0,0 +1,239 @@ +/** + * 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. + */ + +/** + * The whole matched set's footer figures, for a grid whose rows are one page + * of it (objectui#12081 item 3). + * + * ## The question, and the door it rides + * + * The footer is drawn beside a count of the WHOLE matched set, so its figures + * must be that set's figures. Under server paging the grid holds one page, so + * those figures are a property of the QUERY, not of any fetched page — the + * same reading objectui#7189 made of a group header's numbers. They are asked + * the same way: ONE `EngineAggregateOptions` through + * `DataSource.queryGroupHeaders`, with no `groupBy`, so the answer is the one + * "header row" of the whole set. The platform's data door routes a body + * carrying `aggregations` to `engine.aggregate` with or without a `groupBy` + * (the `hasGroupBy || hasAggregations` branch of `findData`), and hands it the + * query's `where`, `search` and `searchFields` — the same `where` its count + * reads and the same search expander its flat `find` uses. + * + * Why this door and not `DataSource.aggregate()`: that member takes the + * analytics-shaped `AggregateParams` (one field, one function), carries no + * search on either of its branches, and may DEGRADE to a client-side reduction + * over a `find()` — none of which can answer "these rows' figures". This one + * sends the compiled query verbatim and never degrades: a source that cannot + * answer throws, and the footer then says its figure is the page's. + * + * ## The query is compiled from the spec's own table + * + * `count` first (`COUNT(*)`, under `LIST_VIEW_GROUP_COUNT_ALIAS`), then one + * node per declared column summary off `COLUMN_SUMMARY_AGGREGATION`, aliased + * by `columnSummaryAlias`, deduplicated — the node set + * `compileListViewGroupQuery` gives a grouped view, which that compiler will + * not build without a grouping (`grouping_empty`). The answer is read back + * through `deriveColumnSummary` (`useScopedColumnSummary`), so both directions + * stay the spec's. + * + * ## ⛔ Never a figure over different rows than the count's + * + * - The query is the one behind the rows on screen — the host's `findParams` + * or the grid's own — less its window: `$filter` (lowered through the + * spec's `parseFilterAST`, as the grouped header query lowers it), + * `$search` and `$searchFields`. A key outside the window keys and those + * three is a narrowing this read cannot repeat, so it is not asked. + * - The answer's `count` must equal the count the grid shows. When it does + * not — the platform's paged count under a search is a page-local + * estimate, not a count — the two describe different numbers of rows, + * and the footer stays on the page, labelled as the page's. + * + * ## Asked once per question + * + * The question is the object, the query and the summary columns; the window + * (`$top`, `$skip`), the order and the projection are not in it, so a page + * turn or a header sort asks nothing. A refresh or a write to the object + * (the data-invalidation bus) re-asks the SAME question and keeps the answer + * on screen until the new one lands (objectui#11574's reading). + */ + +import { useEffect, useState } from 'react'; +import type { DataSource, ListColumn } from '@object-ui/types'; +import { parseFilterAST } from '@objectstack/spec/data'; +import type { AggregationNode, EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { + COLUMN_SUMMARY_AGGREGATION, + LIST_VIEW_GROUP_COUNT_ALIAS, + columnSummaryAlias, + type ColumnSummary, + type ListViewGroupHeaderRow, +} from '@objectstack/spec/ui'; +import { useDataInvalidation } from '@object-ui/react'; + +/** Find-param keys that shape the WINDOW or the row's SHAPE — never which rows match. */ +const WINDOW_KEYS: ReadonlySet = new Set(['$top', '$skip', '$orderby', '$select', '$expand', '$count']); + +/** + * The aggregate query for the whole matched set behind `findParams`, or + * `null` when it cannot be asked as the same question (see the module note). + */ +export function compileWholeSetSummaryQuery( + columns: readonly ListColumn[] | undefined, + findParams: Readonly>, +): EngineAggregateOptions | null { + for (const key of Object.keys(findParams)) { + if (!WINDOW_KEYS.has(key) && key !== '$filter' && key !== '$search' && key !== '$searchFields') return null; + } + + let where: FilterCondition | undefined; + const filter = findParams.$filter; + if (filter !== undefined && filter !== null) { + let lowered: unknown; + try { + lowered = parseFilterAST(filter as Parameters[0]); + } catch { + return null; + } + // A filter that lowers to nothing, or to anything but a condition object, + // is not one this read can repeat; dropping it would sum the unfiltered set. + if (!lowered || typeof lowered !== 'object' || Array.isArray(lowered)) return null; + where = lowered as FilterCondition; + } + + const search = findParams.$search; + const searchFields = findParams.$searchFields; + if (search !== undefined && typeof search !== 'string') return null; + if (searchFields !== undefined && !(Array.isArray(searchFields) && searchFields.every((f) => typeof f === 'string'))) return null; + // The search pair as the find carried it: fields only beside a term, as + // `groupSearchOf` reads the grouped grid's pair. + const term = typeof search === 'string' ? search.trim() : ''; + + const nodes = new Map([[LIST_VIEW_GROUP_COUNT_ALIAS, { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }]]); + for (const column of columns ?? []) { + const summary = column?.summary; + if (summary === undefined || summary === null) continue; + const member = (typeof summary === 'string' ? summary : summary.type) as ColumnSummary; + const mapping = Object.prototype.hasOwnProperty.call(COLUMN_SUMMARY_AGGREGATION, member) + ? COLUMN_SUMMARY_AGGREGATION[member] + : undefined; + // `none`, an unknown member, and a member with no counterpart ask nothing: + // the footer draws no figure for the first two and keeps the third on the + // page (`deriveColumnSummary` finds no column for it). + if (!mapping || mapping.kind === 'none' || mapping.kind === 'unmapped') continue; + const field = typeof summary === 'string' ? column.field : (summary.field ?? column.field); + if (typeof field !== 'string' || field === '') continue; + const fn = mapping.kind === 'derived' ? mapping.from : mapping.function; + const aggregated = mapping.fieldless ? undefined : field; + const alias = columnSummaryAlias(fn, aggregated); + if (!nodes.has(alias)) nodes.set(alias, { function: fn, ...(aggregated !== undefined ? { field: aggregated } : {}), alias }); + } + + return { + ...(where ? { where } : {}), + ...(term ? { search: term, ...(Array.isArray(searchFields) && searchFields.length > 0 ? { searchFields: searchFields as string[] } : {}) } : {}), + aggregations: [...nodes.values()], + } as EngineAggregateOptions; +} + +export interface WholeSetSummaryInput { + /** + * The rows on screen are one page of a larger matched set, so the footer + * needs the set's own figures. `false` asks nothing. + */ + enabled: boolean; + dataSource?: DataSource; + objectName?: string; + /** The grid's columns; only those carrying a `summary` contribute. */ + columns: ListColumn[] | undefined; + /** + * The find params behind the rows on screen — the host's `findParams`, or + * the query this grid's own loader sent. `null` when none is known: the + * figures cannot be asked, and the footer stays on the page. + */ + findParams: Readonly> | null; + /** The count the grid shows beside the footer, when it has one. */ + count: number | undefined; + /** A change re-asks the same question (the grid's refresh). */ + reloadKey: string; +} + +/** + * - `off` — not asked: the rows are the whole set. + * - `pending` — asked, no answer to THIS question in hand yet. + * - `answered` — the whole set's header row, its `count` agreeing with the grid's. + * - `unavailable` — no door, a refusal, an unaskable query, or an answer whose + * count is not the grid's. + */ +export type WholeSetSummary = + | { status: 'off' | 'pending' | 'unavailable'; row?: undefined } + | { status: 'answered'; row: ListViewGroupHeaderRow }; + +const OFF: WholeSetSummary = { status: 'off' }; +const PENDING: WholeSetSummary = { status: 'pending' }; +const UNAVAILABLE: WholeSetSummary = { status: 'unavailable' }; + +/** The hook's state, recorded against the question it answers. */ +interface Held { + question: string; + answer: WholeSetSummary; +} + +const NOTHING_HELD: Held = { question: '', answer: PENDING }; + +/** Ask the server for the whole matched set's footer figures. */ +export function useWholeSetSummary(input: WholeSetSummaryInput): WholeSetSummary { + const { enabled, dataSource, objectName, columns, findParams, count, reloadKey } = input; + + const query = enabled && findParams ? compileWholeSetSummaryQuery(columns, findParams) : null; + const canAsk = enabled + && !!objectName + && query !== null + && typeof dataSource?.queryGroupHeaders === 'function'; + // Keyed on CONTENT, never on the identity of an object a host may rebuild + // every render (AGENTS.md #10). + const question = canAsk ? JSON.stringify([objectName, query]) : ''; + // A write to the object changes the set's figures; only a grid that asks + // listens (a hook without a record id is a list/aggregate reader). + const invalidationNonce = useDataInvalidation(canAsk ? objectName : undefined); + const [held, setHeld] = useState(NOTHING_HELD); + + useEffect(() => { + if (!canAsk || !dataSource || !objectName) return; + let cancelled = false; + // Re-asking the question in hand keeps its answer on screen until the + // new one lands; a different question starts with none. + setHeld((prev) => (prev.question === question ? prev : { question, answer: PENDING })); + const asked = (JSON.parse(question) as [string, EngineAggregateOptions])[1]; + // Through a resolved promise, so a source that throws synchronously is a + // refusal like any other rather than a render error. + Promise.resolve().then(() => dataSource.queryGroupHeaders!(objectName, asked)).then( + (rows) => { + if (cancelled) return; + // No `groupBy`: the whole set is ONE row. Anything else is not an + // answer to this question. + const row = Array.isArray(rows) && rows.length === 1 && rows[0] && typeof rows[0] === 'object' ? rows[0] : undefined; + setHeld({ question, answer: row ? { status: 'answered', row } : UNAVAILABLE }); + }, + () => { + if (!cancelled) setHeld({ question, answer: UNAVAILABLE }); + }, + ); + return () => { cancelled = true; }; + }, [canAsk, dataSource, objectName, question, reloadKey, invalidationNonce]); + + if (!enabled) return OFF; + if (!canAsk) return UNAVAILABLE; + // Read in the render the question changes, never one render late. + const answer = held.question === question ? held.answer : PENDING; + // The answer's count is the count the grid shows, or its figures describe + // other rows than the ones that count counts. + if (answer.status === 'answered' && typeof count === 'number' && Number(answer.row.count) !== count) { + return UNAVAILABLE; + } + return answer; +} From c482b603c714478abd66d83b42c9c91bcad1a9cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:12:41 +0000 Subject: [PATCH 3/5] docs(plugin-grid,types): the footer's scope, the changeset, and what queryGroupHeaders answers with no groupBy (objectui#12081 item 3) Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .changeset/12081-footer-sum-scope.md | 15 +++++++++++++++ content/docs/plugins/plugin-grid.mdx | 18 ++++++++++++++++++ packages/plugin-grid/README.md | 18 ++++++++++++++++++ packages/types/src/data.ts | 7 +++++++ 4 files changed, 58 insertions(+) create mode 100644 .changeset/12081-footer-sum-scope.md diff --git a/.changeset/12081-footer-sum-scope.md b/.changeset/12081-footer-sum-scope.md new file mode 100644 index 0000000000..40563baa0e --- /dev/null +++ b/.changeset/12081-footer-sum-scope.md @@ -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. `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. diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 03da740367..da53fb9c62 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -248,6 +248,24 @@ 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. +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. diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 7b3f1a6f90..ee48141dff 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -381,6 +381,24 @@ 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. +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. diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index f04df8ec99..68fe41f535 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -797,6 +797,13 @@ export interface DataSource { * fetches no rows (objectui#10881). Rows a host hands in whole are still * grouped where they are. * + * The same query with NO `groupBy` asks for the whole filtered set as ONE + * header row: a flat grid whose rows are one page of more reads its column + * footer's figures off that row, so they describe the rows the grid's count + * counts rather than the page on screen (objectui#12081). A source that + * answers grouped queries only may refuse it; the footer then says its + * figure is the page's. + * * ⛔ Not a second spelling of {@link aggregate}: that member takes the * analytics-shaped {@link AggregateParams} (one field, one function, one * `groupBy`) and may degrade to a client-side reduction; this one takes the From d61feee36148e0139fad0686751cc12637d83d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:25:55 +0000 Subject: [PATCH 4/5] refactor(plugin-grid): no synchronous setState in the whole-set read's effect, and a typed test double (objectui#12081 item 3) The pending state needs no write: the read compares the held question with the render's. The new test file and hook carry no explicit any. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .../footerSummaryScope-12081.test.tsx | 44 +++++++++++-------- packages/plugin-grid/src/useColumnSummary.ts | 2 +- .../plugin-grid/src/useWholeSetSummary.ts | 7 +-- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx index da0471e161..92718b2f94 100644 --- a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx +++ b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx @@ -43,6 +43,7 @@ import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/re import '@testing-library/jest-dom'; import React from 'react'; import { parseFilterAST } from '@objectstack/spec/data'; +import type { DataSource, ObjectGridSchema } from '@object-ui/types'; import { ObjectGrid } from '../ObjectGrid'; import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts'; @@ -53,7 +54,7 @@ import { ActionProvider, I18nProvider, notifyDataChanged } from '@object-ui/reac beforeAll(() => { registerAllFields(); if (!Element.prototype.scrollIntoView) { - Element.prototype.scrollIntoView = vi.fn() as any; + Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView']; } }); beforeEach(() => { @@ -68,7 +69,7 @@ afterEach(() => { const OBJECT = 'plan_line'; const PAGE = 25; -interface Row { id: string; name: string; stage: string; amount: number; note: string | null } +type Row = { id: string; name: string; stage: string; amount: number; note: string | null } & Record; /** 300 rows; every fifth `note` is empty, every third row is `won`. */ const ROWS: Row[] = Array.from({ length: 300 }, (_, i) => ({ id: `r${i}`, @@ -113,26 +114,29 @@ type AggregateQuery = { where?: unknown; groupBy?: string[]; aggregations?: Agg[ /** One aggregation node over a row set, as `engine.aggregate` answers it. */ function aggregateOf(rows: Row[], node: Agg): number | null { - const values = node.field === undefined ? [] : rows.map((r) => (r as any)[node.field!]).filter((v) => v !== null && v !== undefined); + const values: unknown[] = node.field === undefined ? [] : rows.map((r) => r[node.field!]).filter((v) => v !== null && v !== undefined); + const total = (): number => values.reduce((a: number, b) => a + Number(b), 0); switch (node.function) { case 'count': return node.field === undefined ? rows.length : values.length; case 'count_distinct': return new Set(values).size; - case 'sum': return values.reduce((a, b) => a + Number(b), 0); - case 'avg': return values.length === 0 ? null : values.reduce((a, b) => a + Number(b), 0) / values.length; + case 'sum': return total(); + case 'avg': return values.length === 0 ? null : total() / values.length; case 'min': return values.length === 0 ? null : Math.min(...values.map(Number)); case 'max': return values.length === 0 ? null : Math.max(...values.map(Number)); default: throw new Error(`double: unsupported aggregation ${node.function}`); } } +type FilterInput = Parameters[0]; + const matching = (where: unknown, search: unknown, searchFields: unknown) => - ROWS.filter((r) => matchesWhere(r as any, where) && matchesSearch(r as any, search, searchFields)); + ROWS.filter((r) => matchesWhere(r, where) && matchesSearch(r, search, searchFields)); function makeDataSource(opts: { door?: 'answers' | 'absent' | 'refuses' | 'no-row' | 'strings' } = {}) { const door = opts.door ?? 'answers'; - const ds: Record = { + const ds: Record> = { find: vi.fn(async (_object: string, params: Record) => { - const where = params.$filter === undefined ? undefined : parseFilterAST(params.$filter as any); + const where = params.$filter === undefined ? undefined : parseFilterAST(params.$filter as FilterInput); const rows = matching(where, params.$search, params.$searchFields); const skip = (params.$skip as number | undefined) ?? 0; const top = (params.$top as number | undefined) ?? rows.length; @@ -162,6 +166,8 @@ function makeDataSource(opts: { door?: 'answers' | 'absent' | 'refuses' | 'no-ro return ds; } +type TestDataSource = ReturnType; + const sumOf = (rows: Row[]) => rows.reduce((a, r) => a + r.amount, 0); const fmt = (n: number) => n.toLocaleString('en'); @@ -170,17 +176,17 @@ const AMOUNT_SUM = [ { field: 'amount', label: 'Amount', type: 'number', summary: 'sum' }, ]; -const ownFetchGrid = (ds: any, columns: unknown[] = AMOUNT_SUM, extra: Record = {}) => ( +const ownFetchGrid = (ds: TestDataSource, columns: unknown[] = AMOUNT_SUM, extra: Record = {}) => ( ); /** The host's page, as `ListView` hands one down (objectui#4501's literal, key for key). */ -function hostGrid(ds: any, opts: { +function hostGrid(ds: TestDataSource, opts: { rows: Row[]; page?: number; findParams?: Record | null; @@ -195,8 +201,8 @@ function hostGrid(ds: any, opts: { return ( { const ds = makeDataSource(); const filter = ['stage', '=', 'won']; - const narrowed = matching(parseFilterAST(filter as any), 'Line 1', ['name']); + const narrowed = matching(parseFilterAST(filter as FilterInput), 'Line 1', ['name']); // Not a coincidence the assertions could pass by: a strict subset, larger than a page. expect(narrowed.length).toBeGreaterThan(PAGE); expect(narrowed.length).toBeLessThan(ROWS.length); @@ -283,7 +289,7 @@ describe('the footer reads the whole matched set when the grid shows one page of const [object, query] = ds.queryGroupHeaders.mock.calls[0]; expect(object).toBe(OBJECT); expect(query).toEqual({ - where: parseFilterAST(filter as any), + where: parseFilterAST(filter as FilterInput), search: 'Line 1', searchFields: ['name'], aggregations: [ @@ -430,8 +436,8 @@ describe('CONTROL: rows that ARE the whole set keep their footer and ask nothing render( , ); @@ -442,7 +448,7 @@ describe('CONTROL: rows that ARE the whole set keep their footer and ask nothing it('a grid whose one page is the whole matched set', async () => { const ds = makeDataSource(); const filter = ['amount', '>', 29000]; - const few = matching(parseFilterAST(filter as any), undefined, undefined); + const few = matching(parseFilterAST(filter as FilterInput), undefined, undefined); expect(few.length).toBeLessThan(PAGE); render(ownFetchGrid(ds, AMOUNT_SUM, { filter })); await waitFor(() => expect(footerText('amount')).toBe(`Amount: Sum: ${fmt(sumOf(few))}`)); diff --git a/packages/plugin-grid/src/useColumnSummary.ts b/packages/plugin-grid/src/useColumnSummary.ts index 9cb8d937d1..45edd7ae53 100644 --- a/packages/plugin-grid/src/useColumnSummary.ts +++ b/packages/plugin-grid/src/useColumnSummary.ts @@ -585,7 +585,7 @@ export function useColumnSummary( */ export function useScopedColumnSummary( columns: ListColumn[] | undefined, - data: any[], + data: SummaryRow[], fieldMetadata: SummaryFieldMetadata | undefined, scope: ColumnSummaryScope, wholeRow: ListViewGroupHeaderRow | undefined, diff --git a/packages/plugin-grid/src/useWholeSetSummary.ts b/packages/plugin-grid/src/useWholeSetSummary.ts index 0e8b5d84fe..23d26b176b 100644 --- a/packages/plugin-grid/src/useWholeSetSummary.ts +++ b/packages/plugin-grid/src/useWholeSetSummary.ts @@ -205,9 +205,10 @@ export function useWholeSetSummary(input: WholeSetSummaryInput): WholeSetSummary useEffect(() => { if (!canAsk || !dataSource || !objectName) return; let cancelled = false; - // Re-asking the question in hand keeps its answer on screen until the - // new one lands; a different question starts with none. - setHeld((prev) => (prev.question === question ? prev : { question, answer: PENDING })); + // Nothing is set before the answer lands: re-asking the question in hand + // keeps its answer on screen until the new one replaces it, and a + // different question reads as pending by itself (the read below compares + // the held question with this render's). const asked = (JSON.parse(question) as [string, EngineAggregateOptions])[1]; // Through a resolved promise, so a source that throws synchronously is a // refusal like any other rather than a render error. From 1da27b41b2dd7f7d646111f09a010afaf01fec60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:56:24 +0000 Subject: [PATCH 5/5] fix(plugin-grid): a grid with no footer figure sends no whole-set aggregate (objectui#12081 item 3, rework round 1) summaryColumns is every column object, and the whole-set query always seeded the count node, so every paged grid with object-form columns and no declared summary (every console list view ListView hosts) sent one queryGroupHeaders request per question for a footer it never draws. wholeSetSummaryNodes now answers null when no column declares a summary that maps to an aggregate (none at all, only none, only unknown members); compileWholeSetSummaryQuery answers null for it, and useWholeSetSummary is off: no request, no invalidation subscription. Pinned through the real ObjectGrid with a control leg; changeset and docs name the case. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .changeset/12081-footer-sum-scope.md | 2 +- content/docs/plugins/plugin-grid.mdx | 2 + packages/plugin-grid/README.md | 2 + .../footerSummaryScope-12081.test.tsx | 32 +++++++ .../plugin-grid/src/useWholeSetSummary.ts | 89 +++++++++++++------ 5 files changed, 98 insertions(+), 29 deletions(-) diff --git a/.changeset/12081-footer-sum-scope.md b/.changeset/12081-footer-sum-scope.md index 40563baa0e..1e1a254667 100644 --- a/.changeset/12081-footer-sum-scope.md +++ b/.changeset/12081-footer-sum-scope.md @@ -10,6 +10,6 @@ Under server paging a grid holds one page of the matched set, and the footer sum - **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. `useColumnSummary` keeps its signature and still summarises the rows it is handed. +- **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. diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index c43110399f..cbbda04ec5 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -258,6 +258,8 @@ 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 diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index ee48141dff..f883886c2c 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -391,6 +391,8 @@ 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 diff --git a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx index 92718b2f94..0ed489df2b 100644 --- a/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx +++ b/packages/plugin-grid/src/__tests__/footerSummaryScope-12081.test.tsx @@ -456,6 +456,38 @@ describe('CONTROL: rows that ARE the whole set keep their footer and ask nothing }); }); +describe('a grid with no footer figure asks nothing (objectui#12081 item 3, rework round 1)', () => { + /** Long enough for an effect's request to have gone out; the last leg below proves it is. */ + const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + + it('a host-driven paged grid whose columns declare no summary, only `none`, or only unknown members keeps queryGroupHeaders uncalled', async () => { + // Every console list view is this grid: object-form columns, one page of + // more, handed down with manualPagination + rowCount + findParams. + const cases: Array<[string, unknown[]]> = [ + ['no summary', [{ field: 'name', label: 'Name' }, { field: 'amount', label: 'Amount', type: 'number' }]], + ['only none', [{ field: 'name', label: 'Name', summary: 'none' }, { field: 'amount', label: 'Amount', summary: { type: 'none' } }]], + ['only unknown members', [{ field: 'name', label: 'Name', summary: 'median' }, { field: 'amount', label: 'Amount', summary: { type: 'total' } }]], + ]; + for (const [label, columns] of cases) { + const ds = makeDataSource(); + render(hostGrid(ds, { rows: ROWS, columns })); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + await settle(); + expect({ label, asked: ds.queryGroupHeaders.mock.calls.length }).toEqual({ label, asked: 0 }); + expect(screen.queryByTestId('column-summary-footer')).toBeNull(); + cleanup(); + } + + // Control leg: the same harness and the same wait DO see a request once a + // column declares a summary, so the zeros above are readings. + const ds = makeDataSource(); + render(hostGrid(ds, { rows: ROWS })); + await waitFor(() => expect(screen.getByText('Line 0')).toBeInTheDocument()); + await settle(); + expect(ds.queryGroupHeaders.mock.calls.length).toBe(1); + }); +}); + describe('the page label is the pack\'s, not an English suffix (objectui#12081 item 3)', () => { it('zh: the pack places the scope', async () => { const ds = makeDataSource({ door: 'absent' }); diff --git a/packages/plugin-grid/src/useWholeSetSummary.ts b/packages/plugin-grid/src/useWholeSetSummary.ts index 23d26b176b..5b0510e328 100644 --- a/packages/plugin-grid/src/useWholeSetSummary.ts +++ b/packages/plugin-grid/src/useWholeSetSummary.ts @@ -53,7 +53,13 @@ * estimate, not a count — the two describe different numbers of rows, * and the footer stays on the page, labelled as the page's. * - * ## Asked once per question + * ## Asked only for a footer, once per question + * + * A grid whose columns declare no summary that maps to an aggregate (none at + * all, only `none`, only unknown members) asks nothing: `wholeSetSummaryNodes` + * answers `null`, and the read is `off`. Every console list view is a paged + * grid with object-form columns, so without this gate each of them would send + * an aggregate query for a footer it never draws. * * The question is the object, the query and the summary columns; the window * (`$top`, `$skip`), the order and the projection are not in it, so a page @@ -78,14 +84,53 @@ import { useDataInvalidation } from '@object-ui/react'; /** Find-param keys that shape the WINDOW or the row's SHAPE — never which rows match. */ const WINDOW_KEYS: ReadonlySet = new Set(['$top', '$skip', '$orderby', '$select', '$expand', '$count']); +/** + * The aggregation nodes the columns' summaries map to — `count` first + * (`COUNT(*)`, which a `count` summary rides), then one node per declared + * summary, deduplicated — or `null` when NO column declares a summary that + * maps to an aggregate: none at all, only `none`, only members the table does + * not know or cannot map, or a summary over no field. A grid with no footer + * figure has nothing to ask, so `null` here is what keeps the read off + * (objectui#12081 item 3, rework round 1): the `count` node alone is not a + * question anybody asked. + */ +export function wholeSetSummaryNodes(columns: readonly ListColumn[] | undefined): AggregationNode[] | null { + const nodes = new Map([[LIST_VIEW_GROUP_COUNT_ALIAS, { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }]]); + let declared = false; + for (const column of columns ?? []) { + const summary = column?.summary; + if (summary === undefined || summary === null) continue; + const member = (typeof summary === 'string' ? summary : summary.type) as ColumnSummary; + const mapping = Object.prototype.hasOwnProperty.call(COLUMN_SUMMARY_AGGREGATION, member) + ? COLUMN_SUMMARY_AGGREGATION[member] + : undefined; + // `none`, an unknown member, and a member with no counterpart ask nothing: + // the footer draws no figure for the first two and keeps the third on the + // page (`deriveColumnSummary` finds no column for it). + if (!mapping || mapping.kind === 'none' || mapping.kind === 'unmapped') continue; + const field = typeof summary === 'string' ? column.field : (summary.field ?? column.field); + if (typeof field !== 'string' || field === '') continue; + declared = true; + const fn = mapping.kind === 'derived' ? mapping.from : mapping.function; + const aggregated = mapping.fieldless ? undefined : field; + const alias = columnSummaryAlias(fn, aggregated); + if (!nodes.has(alias)) nodes.set(alias, { function: fn, ...(aggregated !== undefined ? { field: aggregated } : {}), alias }); + } + return declared ? [...nodes.values()] : null; +} + /** * The aggregate query for the whole matched set behind `findParams`, or - * `null` when it cannot be asked as the same question (see the module note). + * `null` when there is nothing to ask ({@link wholeSetSummaryNodes}) or it + * cannot be asked as the same question (see the module note). */ export function compileWholeSetSummaryQuery( columns: readonly ListColumn[] | undefined, findParams: Readonly>, ): EngineAggregateOptions | null { + const aggregations = wholeSetSummaryNodes(columns); + if (aggregations === null) return null; + for (const key of Object.keys(findParams)) { if (!WINDOW_KEYS.has(key) && key !== '$filter' && key !== '$search' && key !== '$searchFields') return null; } @@ -113,30 +158,10 @@ export function compileWholeSetSummaryQuery( // `groupSearchOf` reads the grouped grid's pair. const term = typeof search === 'string' ? search.trim() : ''; - const nodes = new Map([[LIST_VIEW_GROUP_COUNT_ALIAS, { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }]]); - for (const column of columns ?? []) { - const summary = column?.summary; - if (summary === undefined || summary === null) continue; - const member = (typeof summary === 'string' ? summary : summary.type) as ColumnSummary; - const mapping = Object.prototype.hasOwnProperty.call(COLUMN_SUMMARY_AGGREGATION, member) - ? COLUMN_SUMMARY_AGGREGATION[member] - : undefined; - // `none`, an unknown member, and a member with no counterpart ask nothing: - // the footer draws no figure for the first two and keeps the third on the - // page (`deriveColumnSummary` finds no column for it). - if (!mapping || mapping.kind === 'none' || mapping.kind === 'unmapped') continue; - const field = typeof summary === 'string' ? column.field : (summary.field ?? column.field); - if (typeof field !== 'string' || field === '') continue; - const fn = mapping.kind === 'derived' ? mapping.from : mapping.function; - const aggregated = mapping.fieldless ? undefined : field; - const alias = columnSummaryAlias(fn, aggregated); - if (!nodes.has(alias)) nodes.set(alias, { function: fn, ...(aggregated !== undefined ? { field: aggregated } : {}), alias }); - } - return { ...(where ? { where } : {}), ...(term ? { search: term, ...(Array.isArray(searchFields) && searchFields.length > 0 ? { searchFields: searchFields as string[] } : {}) } : {}), - aggregations: [...nodes.values()], + aggregations, } as EngineAggregateOptions; } @@ -148,7 +173,10 @@ export interface WholeSetSummaryInput { enabled: boolean; dataSource?: DataSource; objectName?: string; - /** The grid's columns; only those carrying a `summary` contribute. */ + /** + * The grid's columns; only those carrying a `summary` that maps to an + * aggregate contribute, and with none of them the read is `off`. + */ columns: ListColumn[] | undefined; /** * The find params behind the rows on screen — the host's `findParams`, or @@ -163,7 +191,8 @@ export interface WholeSetSummaryInput { } /** - * - `off` — not asked: the rows are the whole set. + * - `off` — not asked: the rows are the whole set, or no column declares a + * summary that maps to an aggregate. * - `pending` — asked, no answer to THIS question in hand yet. * - `answered` — the whole set's header row, its `count` agreeing with the grid's. * - `unavailable` — no door, a refusal, an unaskable query, or an answer whose @@ -189,8 +218,12 @@ const NOTHING_HELD: Held = { question: '', answer: PENDING }; export function useWholeSetSummary(input: WholeSetSummaryInput): WholeSetSummary { const { enabled, dataSource, objectName, columns, findParams, count, reloadKey } = input; - const query = enabled && findParams ? compileWholeSetSummaryQuery(columns, findParams) : null; - const canAsk = enabled + // The gate (rework round 1): a grid whose columns declare no summary that + // maps to an aggregate draws no footer, so it asks nothing and listens to + // nothing — the read is `off`, exactly as for rows that are the whole set. + const active = enabled && wholeSetSummaryNodes(columns) !== null; + const query = active && findParams ? compileWholeSetSummaryQuery(columns, findParams) : null; + const canAsk = active && !!objectName && query !== null && typeof dataSource?.queryGroupHeaders === 'function'; @@ -227,7 +260,7 @@ export function useWholeSetSummary(input: WholeSetSummaryInput): WholeSetSummary return () => { cancelled = true; }; }, [canAsk, dataSource, objectName, question, reloadKey, invalidationNonce]); - if (!enabled) return OFF; + if (!active) return OFF; if (!canAsk) return UNAVAILABLE; // Read in the render the question changes, never one render late. const answer = held.question === question ? held.answer : PENDING;