diff --git a/.changeset/11629-kanban-column-sum.md b/.changeset/11629-kanban-column-sum.md new file mode 100644 index 0000000000..3e0e3e40ff --- /dev/null +++ b/.changeset/11629-kanban-column-sum.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-kanban': patch +'@object-ui/app-shell': patch +--- + +The `object-kanban` board totals the view's `summarizeField` in each column header (objectui#11629). + +`@objectstack/spec` declares `summarizeField` on the view-level `KanbanConfig` ("Field to sum at top of column"), and `ListView`'s kanban branch passes it onto the `object-kanban` node it generates. The board never read it, so a view that set it showed the card count and no total. Each column header now shows the sum of that field over the column's cards, beside the count, on both the flat layout and the swimlane layout's column-title row. + +- The total is written by the field's own cell renderer, the one the cards use for that field. A currency field totals as currency, and a number field keeps its declared `scale`. A sum over a number field with no declared `scale` is rounded to the widest input, so `0.1 + 0.2` reads `0.3`. +- An absent, `null` or empty value counts as `0`, and an empty column totals `0`. A numeric string counts as the number the card shows for it. A column holding any other value shows no total, never `NaN`. +- The total covers the cards the board loaded. When the board's own fetch filled its window, the total carries the same `+` the count carries (`6+`). +- No total is shown for a field the viewer may not read, or a field the object does not declare. The rows never carry such a field, so the column would read `0`. +- A `Σ` glyph sets the total apart from the count badge beside it. The field's label is the total's tooltip and its screen-reader name, and the glyph is hidden from assistive technology. No translation key is added. + +A board whose node carries no `summarizeField` renders exactly as before. The console's object page now passes a view's `summarizeField` on to the list view with the lane, title and card fields it already relayed, so a board opened there shows the totals (until now the key was dropped on that page). Nothing is added to the package entry: the total reaches the header through a package-private context, the same channel the records-settled signal uses. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 2a7f43ab48..cbbff11c00 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -72,6 +72,7 @@ const onCardMove = (cardId: string, fromCol: string, toCol: string, index: numbe - **Drag and drop cards** between columns - **Column limits** (WIP limits) +- **Column totals**: a kanban view's `summarizeField` sums that field over each column's loaded cards in the column header, beside the count (with the count's `+` when the fetch window is full) - **Card badges** for status/priority - **Keyboard navigation** - **Lazy-loaded** (~100-150 KB loads only when rendered) diff --git a/packages/app-shell/src/views/ObjectView.kanbanSummarizeField-11629.test.ts b/packages/app-shell/src/views/ObjectView.kanbanSummarizeField-11629.test.ts new file mode 100644 index 0000000000..f8526e8ccf --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.kanbanSummarizeField-11629.test.ts @@ -0,0 +1,65 @@ +/** + * 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#11629: the object page relays the view's `summarizeField`. + * + * `@objectstack/spec` declares `summarizeField` on the view-level + * `KanbanConfig`: "Field to sum at top of column". `plugin-kanban` paints that + * total in each column header, and `ListView` projects the field and passes it + * onto the board it generates. On the console object page, though, + * `kanbanViewOptions` is the only kanban config `ListView` receives when the + * stored row carries no `options.kanban` bag. It relayed only the lane, the + * title and the card fields, so the showcase task board + * (`summarizeField: 'estimate_hours'`) rendered counts with no totals. + * + * The arms assert what this face WRITES. Absence stays absence: a view that + * declares no `summarizeField` gets no key at all, not an invented one. The + * omission arms are the control that keeps the carry arm from passing against a + * producer that writes the key unconditionally. + */ + +import { describe, it, expect } from 'vitest'; +import { kanbanViewOptions } from './ObjectView'; + +/** An object whose lifecycle field the ADR-0085 detector finds by name. */ +const OBJECT_WITH_STAGE = { + name: 'deal', + fields: { name: { type: 'text' }, stage: { type: 'select' }, amount: { type: 'currency' } }, +}; + +describe('the object page relays the view\'s `summarizeField` (objectui#11629)', () => { + it('carries `summarizeField` when the view declares it', () => { + const out = kanbanViewOptions( + { kanban: { groupByField: 'stage', summarizeField: 'amount', columns: ['name'] } }, + OBJECT_WITH_STAGE, + ); + expect(out.summarizeField).toBe('amount'); + // The neighbouring forwards are untouched by the relay. + expect(out.groupByField).toBe('stage'); + expect(out.cardFields).toEqual(['name']); + }); + + it('carries it on the detector path too, where the view names no lane', () => { + const out = kanbanViewOptions({ kanban: { summarizeField: 'amount' } }, OBJECT_WITH_STAGE); + expect(out.groupByField).toBe('stage'); + expect(out.summarizeField).toBe('amount'); + }); + + it('omits the key when the view declares a kanban block without it', () => { + const out = kanbanViewOptions({ kanban: { groupByField: 'stage' } }, OBJECT_WITH_STAGE); + expect(out).not.toHaveProperty('summarizeField'); + }); + + it('omits the key when the view declares no kanban block at all', () => { + const out = kanbanViewOptions({}, OBJECT_WITH_STAGE); + expect(out).not.toHaveProperty('summarizeField'); + // CONTROL: the bag is not empty, so the omission is not a producer that wrote nothing. + expect(out.groupByField).toBe('stage'); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 7d56c0a422..a3d4c106cc 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -446,6 +446,14 @@ export function galleryViewOptions(viewDef: any): Record { * non-linear) and the shared name/type heuristic, which never invents a field * the object doesn't have (the old hard-coded 'status' did). * + * objectui#11629: `summarizeField` (the spec's "Field to sum at top of column") + * is relayed only when the view declares it. This block is the only kanban + * config `ListView` receives on this page when the stored row carries no + * `options.kanban` bag. Without the relay, the key never reached `ListView`'s + * projection or the board's column headers, so a view that declared it + * rendered counts with no totals. Like the lane, an absent key stays absent: + * no default field is invented. + * * Exported for the pin test. */ export function kanbanViewOptions(viewDef: any, objectDef: any): Record { @@ -454,10 +462,12 @@ export function kanbanViewOptions(viewDef: any, objectDef: any): Record ReactNode; +} + +export const KanbanColumnSummaryContext = createContext(null); + +/** Read the lane-total channel above. Package-private — see the interface's doc. */ +export function useKanbanColumnSummary(): KanbanColumnSummary | null { + return useContext(KanbanColumnSummaryContext); +} + +/** + * The decimal places in one number's shortest spelling, exponent included — + * the twin of `widestFractionDigits` in `@object-ui/plugin-grid`'s + * `useColumnSummary` (the grid footer's `Sum`), which rounds a computed + * result to the widest input for the same reason: a sum of `0.1` and `0.2` + * must read `0.3`, never the binary residue `0.30000000000000004` that a + * `number` field declaring no `scale` would otherwise print. Capped at 20, as + * there. + */ +function fractionDigitsOf(value: number): number { + const [mantissa, exponent] = String(value).split('e'); + const point = mantissa.indexOf('.'); + const fraction = point === -1 ? 0 : mantissa.length - point - 1; + return Math.min(Math.max(fraction - (exponent ? Number(exponent) : 0), 0), 20); +} + +/** + * The sum of `field` over one lane's cards, or `null` when the lane holds a + * value that is not a number. + * + * - An absent, `null` or empty value counts as `0` (`isEmptyValue`, the floor + * the card cells use), so a lane of unestimated cards totals `0`, and an + * empty lane totals `0`. + * - A number counts as itself; a numeric string counts as `Number(value)`, + * the coercion the number and currency cell renderers apply before they + * format the same value on the card. + * - Anything else — a non-numeric string, an object, a boolean — makes the + * total unknowable, and `null` says so. The header then shows no total: + * ⛔ never `NaN`, and never a sum that quietly skipped a row. + */ +export function sumLaneField( + cards: ReadonlyArray>, + field: string, +): number | null { + let total = 0; + let widest = 0; + for (const card of cards) { + const raw = card[field]; + if (isEmptyValue(raw)) continue; + const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN; + if (!Number.isFinite(value)) return null; + total += value; + widest = Math.max(widest, fractionDigitsOf(value)); + } + return widest === 0 ? total : Number(total.toFixed(widest)); +} diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index ac7fdf647d..c16184f074 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -30,8 +30,9 @@ import { resolveConditionalFormatting } from "@object-ui/core" import type { KanbanConditionalFormattingRule } from "@object-ui/types" import type { KanbanCard, KanbanColumn } from './types' import { createSafeTranslation } from "@object-ui/i18n" -import { Plus } from "lucide-react" +import { Plus, Sigma } from "lucide-react" import { useKanbanRecordsSettled } from './KanbanRecordsSettled' +import { useKanbanColumnSummary, sumLaneField } from './KanbanColumnSummary' // Utility function to merge class names (inline to avoid external dependency) const cn = (...classes: Array) => classes.filter(Boolean).join(' ') @@ -485,6 +486,48 @@ function laneCountLabel(count: number, countsAreWindowed?: boolean): string { return countsAreWindowed ? `${count}+` : String(count) } +/** + * A column's total of the view's `summarizeField`, painted beside its count + * (objectui#11629). Renders nothing when the board declares no + * `summarizeField` (no provider), so every other header is unchanged. + * + * The total covers the cards the lane holds — the rows the board loaded — and + * says so the way the count does: over a windowed fetch it carries the same + * `+` the count carries (`laneCountLabel`), so a total over a window never + * reads as the total of the group. A lane holding a value that is not a number + * shows no total (`sumLaneField` answers `null`), never `NaN`. + * + * The field's label is the tooltip and the screen-reader name, and a `Sigma` + * glyph (hidden from assistive technology) tells the total from the count + * badge beside it, so no new user-facing string enters the product. + */ +function LaneTotal({ + cards, + countsAreWindowed, + className, +}: { + cards: KanbanCard[] + countsAreWindowed?: boolean + className?: string +}) { + const summary = useKanbanColumnSummary() + if (!summary) return null + const total = sumLaneField(cards, summary.field) + if (total === null) return null + return ( + + + ) +} + function KanbanColumnView({ column, cards, @@ -598,6 +641,7 @@ function KanbanColumnView({ Full )} + {!isCollapsed && } @@ -1231,6 +1275,9 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu {!collapsed && ( ({laneCountLabel(col.cards.length, countsAreWindowed)}) )} + {!collapsed && ( + + )} ) })} diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 868bb43f08..de000271d9 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -52,6 +52,7 @@ import { } from './requiredWhenPrompt'; import { RequiredFieldsDialog } from './RequiredFieldsDialog'; import { KanbanRecordsSettledContext } from './KanbanRecordsSettled'; +import { KanbanColumnSummaryContext, type KanbanColumnSummary } from './KanbanColumnSummary'; /** * English fallbacks for the record-detail drawer heading this board opens on @@ -394,8 +395,9 @@ export interface ObjectKanbanComponentProps { /** * The board node as this component READS it: {@link ObjectKanbanSchema} plus - * the one key `ElementDataSourceGate` writes onto it that the node does not - * declare (objectui#8347). + * the two keys a relay writes onto it that the node does not declare — the + * one `ElementDataSourceGate` writes (objectui#8347), and the one `ListView`'s + * kanban branch writes (objectui#11629). * * `sort` is the gate's carrier for the per-element binding's `dataSource.sort` * (`OBJECT_KANBAN_DATA_SOURCE` in `./index.tsx` maps it, objectui#10068), and @@ -407,6 +409,12 @@ export interface ObjectKanbanComponentProps { * where its only reader lives instead, the way `plugin-timeline`'s * `renderHandoff.ts` types the keys its composer writes (objectui#6356). * + * `summarizeField` is the view's own key: `@objectstack/spec` declares it on + * the view-level `KanbanConfig` ("Field to sum at top of column"), and + * `ListView`'s kanban branch spreads the rest of that config onto the node it + * generates, which is how it arrives here. The `object-kanban` props declare + * no such key, so it is typed here for the same reason as `sort`. + * * ⛔ Deliberately NOT exported, and ⛔ never to be added to * `ObjectKanbanComponentProps` or `@object-ui/types`: a public type naming * `sort` would invite the spelling the authoring faces refuse. @@ -414,6 +422,8 @@ export interface ObjectKanbanComponentProps { type GateBoundKanbanSchema = ObjectKanbanSchema & { /** The binding's (or its view's) ordering, written by the gate. */ sort?: SortConfig[]; + /** The view's `KanbanConfig.summarizeField`, relayed by `ListView` (objectui#11629). */ + summarizeField?: string; }; export const ObjectKanban: React.FC = ({ @@ -1246,6 +1256,54 @@ export const ObjectKanban: React.FC = ({ return []; }, [schema.columns, schema.groupBy, schema.objectName, effectiveData, objectDef, translateOptions]); + /** + * objectui#11629 — the lane total each column header paints, from the + * view's `summarizeField`. `KanbanImpl` sums the field over the lane's cards + * (`sumLaneField`); this decides only WHETHER there is a total and HOW it is + * written, because this component holds the object definition. + * + * - Written through the field's own cell renderer — the call the card + * fields above make — so the total reads the way the cards read that + * field. A field the definition does not describe renders as a number. + * - No total for a field the viewer may not read: the projection never + * asked for it, so every card lacks it and the lane would read `0`. Same + * `checkField` gate and same deferral as `$expand` above. + * - No total for a field the loaded definition does not declare, for the + * same reason: `ListView`'s projection drops a binding the object lacks. + * + * What it covers is what the board drew: the lane's loaded cards. A windowed + * fetch marks the total as it marks the count (`countsAreWindowed`). + */ + const summarizeField = + typeof schema.summarizeField === 'string' && schema.summarizeField !== '' + ? schema.summarizeField + : undefined; + const columnSummary = useMemo(() => { + // Until the definition settles the board does not yet know how the cards + // format the field, or whether the object has it; it paints no total + // rather than one it would repaint (a currency total first read as a bare + // number). A source with no schema read settles with nothing at once. + if (!summarizeField || !objectDefReady) return null; + const def = objectDef?.fields?.[summarizeField]; + if (objectDef?.fields && !def) return null; + if ( + def && + perms?.isLoaded && + schema.objectName && + !perms.checkField(schema.objectName, summarizeField, 'read') + ) { + return null; + } + const fieldType = resolveCellRendererType(def ?? { type: 'number' }); + const CellRenderer = getCellRenderer(fieldType); + const fieldForCell = def ?? { name: summarizeField, type: fieldType }; + return { + field: summarizeField, + label: fieldLabel(objectDef?.name || schema.objectName || '', summarizeField, def?.label || summarizeField), + renderTotal: (total: number) => , + }; + }, [summarizeField, objectDefReady, objectDef, schema.objectName, perms, fieldLabel]); + // Clone schema to inject data and className // Use grouping.fields[0].field as swimlaneField fallback when no explicit swimlaneField const effectiveSwimlaneField = schema.swimlaneField @@ -1638,6 +1696,10 @@ export const ObjectKanban: React.FC = ({ `Suspense`/`React.lazy` boundary normally, which is what makes the private channel possible at all. Full argument on the context. */} + {/* objectui#11629 — the lane total rides the same kind of private + channel, for the same reason. `null` (no `summarizeField`) leaves + every header exactly as it was. See `KanbanColumnSummary`. */} + {/* objectui#11234 — the internal board, not the exported `KanbanRenderer`. It takes the Quick Add pair only as explicit props, and this call passes neither, so the object-bound board draws no @@ -1694,6 +1756,7 @@ export const ObjectKanban: React.FC = ({ }, }} /> + {pendingMove && ( { + const permsState: { isLoaded: boolean; readable: string[] } = { isLoaded: false, readable: [] }; + return { + permsState, + permsStub: { + get isLoaded() { return permsState.isLoaded; }, + checkField: (_object: string, field: string, action: string) => + action === 'read' ? permsState.readable.includes(field) : true, + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, + }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub as never }; +}); + +// Registers `object-kanban`. Module scope, not a hook: the import IS the +// registration (AGENTS.md's test-discipline section). +import '../index'; +// The board renders inside `KanbanBoardCore`'s `React.lazy` boundary; importing +// the chunk at module scope bills the cold transform to the import phase +// instead of racing a `waitFor` budget (objectui#3010). +import '../KanbanImpl'; + +afterEach(() => { + cleanup(); + permsState.isLoaded = false; + permsState.readable = []; +}); + +const OBJECT = 'task'; + +const OBJECT_SCHEMA = { + name: OBJECT, + label: 'Task', + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + region: { type: 'text', label: 'Region' }, + // A `number` with no `scale` — the showcase's `estimate_hours` shape. + hours: { type: 'number', label: 'Estimate (h)' }, + amount: { type: 'currency', label: 'Amount', currency: 'USD' }, + }, +}; + +const LANES = [ + { id: 'open', title: 'Open' }, + { id: 'won', title: 'Won' }, + { id: 'lost', title: 'Lost' }, +]; + +/** + * Mixed values and nulls. `open` holds a number, a `null`, a numeric string + * and a row that lacks the field; `won` holds one number; `lost` is empty. + */ +const MIXED_ROWS = [ + { id: 't1', name: 'Spec', stage: 'open', region: 'east', hours: 10, amount: 1000 }, + { id: 't2', name: 'Build', stage: 'open', region: 'west', hours: null, amount: null }, + { id: 't3', name: 'Ship', stage: 'open', region: 'east', hours: '2.5', amount: 250.5 }, + { id: 't4', name: 'Tidy', stage: 'open', region: 'west' }, + { id: 't5', name: 'Win', stage: 'won', region: 'east', hours: 4, amount: 1250 }, +]; + +type Row = Record; + +function makeAdapter(rows: Row[]) { + return { + find: vi.fn(async (_object: string, _query?: Record) => ({ data: rows })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + }; +} + +const BOARD = { + type: 'object-kanban', + objectName: OBJECT, + groupBy: 'stage', + columns: LANES, +}; + +function renderBoard(rows: Row[], extra: Record = {}) { + const adapter = makeAdapter(rows); + const view = render( + + + , + ); + return { ...view, adapter }; +} + +/** The flat header's controls: the element holding the count badge (and now the total). */ +function headerControls(container: HTMLElement, laneId: string): HTMLElement { + const title = container.querySelector(`#kanban-col-${laneId}`); + const controls = title?.parentElement?.lastElementChild; + if (!controls) throw new Error(`no flat header for ${laneId}`); + return controls as HTMLElement; +} + +/** The text a sighted reader sees in a total: everything but its screen-reader label. */ +function visibleText(total: Element): string { + const clone = total.cloneNode(true) as HTMLElement; + clone.querySelectorAll('.sr-only').forEach((n) => n.remove()); + return (clone.textContent ?? '').trim(); +} + +/** The total painted in one flat column header, or `null` when there is none. */ +function flatTotal(container: HTMLElement, laneId: string): string | null { + const total = headerControls(container, laneId).querySelector('[data-kanban-lane-total]'); + return total ? visibleText(total) : null; +} + +/** Wait until the board has painted the card that proves its rows arrived. */ +async function cardsPainted(container: HTMLElement, cardTitle: string) { + await waitFor(() => + expect(container.querySelector(`[role="listitem"][aria-label="${cardTitle}"]`)).not.toBeNull(), + ); +} + +describe('objectui#11629 — the column header totals `summarizeField`', () => { + it('sums mixed values and nulls, counts absent and null as 0, and totals an empty column 0', async () => { + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'hours' }); + await cardsPainted(container, 'Win'); + + // 10 + null + '2.5' + absent = 12.5; the numeric string counts as the + // number the card cell shows for it. + await waitFor(() => expect(flatTotal(container, 'open')).toBe('12.5')); + expect(flatTotal(container, 'won')).toBe('4'); + // The empty column still has a total, and it is 0. + expect(flatTotal(container, 'lost')).toBe('0'); + // Null and absent never surface as NaN anywhere on the board. + expect(container.textContent).not.toContain('NaN'); + }); + + it('names the total by the field label, for the tooltip and the screen reader', async () => { + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'hours' }); + await cardsPainted(container, 'Win'); + await waitFor(() => expect(flatTotal(container, 'won')).toBe('4')); + + const total = headerControls(container, 'won').querySelector('[data-kanban-lane-total]')!; + expect(total.getAttribute('title')).toBe('Estimate (h)'); + expect(total.querySelector('.sr-only')?.textContent?.trim()).toBe('Estimate (h)'); + }); + + it('NO SUMMARIZE FIELD: the same board paints no total, and the header keeps only its count badge', async () => { + const { container } = renderBoard(MIXED_ROWS); + await cardsPainted(container, 'Win'); + + expect(container.querySelectorAll('[data-kanban-lane-total]')).toHaveLength(0); + for (const lane of LANES) { + const controls = headerControls(container, lane.id); + // Exactly the count badge — the DOM every header had before this card. + expect(controls.children).toHaveLength(1); + expect(controls.textContent?.trim()).toBe(lane.id === 'open' ? '4' : lane.id === 'won' ? '1' : '0'); + } + }); + + it('formats the total with the field\'s own cell renderer — a currency total reads as the cards read it', async () => { + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'amount', cardFields: ['amount'] }); + await cardsPainted(container, 'Win'); + + // `won` holds one card, so its total and that card's amount cell are the + // same value through the same renderer: the two must read identically. + const winCard = container.querySelector('[role="listitem"][aria-label="Win"]')!; + await waitFor(() => expect(winCard.querySelector('dd')?.textContent).toBe('$1,250.00')); + await waitFor(() => expect(flatTotal(container, 'won')).toBe(winCard.querySelector('dd')!.textContent)); + + // 1000 + null + 250.5 + absent, written as currency, not as a bare number. + expect(flatTotal(container, 'open')).toBe('$1,250.50'); + expect(flatTotal(container, 'lost')).toBe('$0.00'); + }); + + it('rounds a sum to the widest input, so 0.1 + 0.2 reads 0.3 on a field with no declared scale', async () => { + const rows = [ + { id: 'r1', name: 'One', stage: 'open', hours: 0.1 }, + { id: 'r2', name: 'Two', stage: 'open', hours: 0.2 }, + ]; + const { container } = renderBoard(rows, { summarizeField: 'hours' }); + await cardsPainted(container, 'Two'); + + await waitFor(() => expect(flatTotal(container, 'open')).toBe('0.3')); + }); + + it('shows no total for a lane holding a value that is not a number — never NaN', async () => { + const rows = [ + { id: 'r1', name: 'Odd', stage: 'open', hours: 'about three' }, + { id: 'r2', name: 'Even', stage: 'won', hours: 3 }, + ]; + const { container } = renderBoard(rows, { summarizeField: 'hours' }); + await cardsPainted(container, 'Even'); + + // CONTROL: the other lane on the same board does total. + await waitFor(() => expect(flatTotal(container, 'won')).toBe('3')); + expect(flatTotal(container, 'open')).toBeNull(); + expect(container.textContent).not.toContain('NaN'); + }); +}); + +describe('objectui#11629 — what the total covers is said, never silently partial', () => { + const WINDOW = 3; + const windowRows = (n: number) => + Array.from({ length: n }, (_, i) => ({ id: `w${i}`, name: `Row ${i}`, stage: 'open', hours: 2 })); + + it('WINDOWED: a saturated fetch marks the total `+`, exactly as it marks the count', async () => { + const { container, adapter } = renderBoard(windowRows(WINDOW), { summarizeField: 'hours', limit: WINDOW }); + await cardsPainted(container, 'Row 2'); + + await waitFor(() => expect(flatTotal(container, 'open')).toBe('6+')); + // The count badge beside it carries the same marker. + expect(headerControls(container, 'open').firstElementChild?.textContent?.trim()).toBe('3+'); + // Non-vacuity: the request really was windowed. + expect(adapter.find.mock.calls[0][1]?.$top).toBe(WINDOW); + }); + + it('UNSATURATED CONTROL: a fetch that came back short keeps the bare total', async () => { + const { container } = renderBoard(windowRows(WINDOW - 1), { summarizeField: 'hours', limit: WINDOW }); + await cardsPainted(container, 'Row 1'); + + await waitFor(() => expect(flatTotal(container, 'open')).toBe('4')); + }); +}); + +describe('objectui#11629 — the swimlane layout totals each column in its title row', () => { + it('paints one total per column, over every lane\'s cards in that column', async () => { + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'hours', swimlaneField: 'region' }); + await cardsPainted(container, 'Win'); + + const region = container.querySelector('[role="region"]'); + expect(region?.getAttribute('aria-label')).toBe('Kanban board with swimlanes'); + const titleRow = region!.firstElementChild as HTMLElement; + + await waitFor(() => + expect( + [...titleRow.querySelectorAll('[data-kanban-lane-total]')].map(visibleText), + ).toEqual(['12.5', '4', '0']), + ); + }); +}); + +describe('objectui#11629 — no total the viewer could not have computed', () => { + it('DENIED: a field the viewer may not read gets no total', async () => { + permsState.isLoaded = true; + permsState.readable = ['name', 'stage']; + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'hours' }); + await cardsPainted(container, 'Win'); + + expect(container.querySelectorAll('[data-kanban-lane-total]')).toHaveLength(0); + }); + + it('READABLE CONTROL: the same stub granting the field paints the totals', async () => { + permsState.isLoaded = true; + permsState.readable = ['name', 'stage', 'hours']; + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'hours' }); + await cardsPainted(container, 'Win'); + + await waitFor(() => expect(flatTotal(container, 'open')).toBe('12.5')); + }); + + it('a field the object does not declare gets no total, rather than a column of zeros', async () => { + const { container } = renderBoard(MIXED_ROWS, { summarizeField: 'not_a_field' }); + await cardsPainted(container, 'Win'); + + expect(container.querySelectorAll('[data-kanban-lane-total]')).toHaveLength(0); + }); +});