From ae3a7bd017c2ba43376d1432a51d697ddbbea404 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:00:22 +0000 Subject: [PATCH 1/7] fix(plugin-kanban): each column header totals the view's summarizeField (objectui#11629) The spec declares `summarizeField` on the view-level KanbanConfig and ListView's kanban branch relays it onto the object-kanban node, but the board never read it. ObjectKanban now hands the field, its label and its cell renderer to the column headers through a package-private context; KanbanImpl sums the field over each lane's cards and paints the total beside the count on the flat header and the swimlane title row. Absent / null / empty count as 0, an empty lane totals 0, a non-numeric value suppresses that lane's total (never NaN), a no-scale sum rounds to the widest input, a windowed fetch marks the total `+` like the count, and an FLS-denied or undeclared field gets no total. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11629-kanban-column-sum.md | 15 + .../plugin-kanban/src/KanbanColumnSummary.ts | 104 ++++++ packages/plugin-kanban/src/KanbanImpl.tsx | 45 +++ packages/plugin-kanban/src/ObjectKanban.tsx | 67 +++- .../columnSummarizeField-11629.test.tsx | 313 ++++++++++++++++++ 5 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 .changeset/11629-kanban-column-sum.md create mode 100644 packages/plugin-kanban/src/KanbanColumnSummary.ts create mode 100644 packages/plugin-kanban/src/__tests__/columnSummarizeField-11629.test.tsx diff --git a/.changeset/11629-kanban-column-sum.md b/.changeset/11629-kanban-column-sum.md new file mode 100644 index 0000000000..e71c3db946 --- /dev/null +++ b/.changeset/11629-kanban-column-sum.md @@ -0,0 +1,15 @@ +--- +'@object-ui/plugin-kanban': 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`. +- The field's label is the total's tooltip and its screen-reader name. No translation key is added. + +A board whose node carries no `summarizeField` renders exactly as before. 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/packages/plugin-kanban/src/KanbanColumnSummary.ts b/packages/plugin-kanban/src/KanbanColumnSummary.ts new file mode 100644 index 0000000000..3705aa2bf0 --- /dev/null +++ b/packages/plugin-kanban/src/KanbanColumnSummary.ts @@ -0,0 +1,104 @@ +/** + * 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. + */ + +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; +import { isEmptyValue } from '@object-ui/core'; + +/** + * The per-lane total a board's column headers paint (objectui#11629). + * + * `@objectstack/spec` declares `summarizeField` on the view-level + * `KanbanConfig` — "Field to sum at top of column (e.g. amount)" — and + * `ListView`'s kanban branch relays it onto the generated `object-kanban` node. + * Until objectui#11629 nothing under this package read it, so a board authored + * with `summarizeField` showed the card count and no total. + * + * ## Why a context rather than a prop + * + * The same reason, and the same shape, as `KanbanRecordsSettledContext`: the + * producer is `ObjectKanban` (it holds the object definition, so it knows how + * the cards format the field) and the consumer is `KanbanImpl`'s column header, + * with `KanbanBoardCore`'s `Suspense`/`lazy` boundary in between. A member of + * the published `KanbanRendererProps` (or of its `schema` bag) would be a new + * key on a published payload for a value no caller outside this package sets. + * This module is NOT re-exported from `index.tsx`, so nothing here reaches the + * published surface. + * + * ## ⚠️ The default is `null`, and that is load-bearing + * + * No provider means no total: `KanbanRenderer` (the exported React component) + * and every board whose node carries no `summarizeField` render the header + * exactly as they did before. + */ +export interface KanbanColumnSummary { + /** The record field each lane totals — the view's `summarizeField`. */ + field: string; + /** The field's display label: the tooltip and the screen-reader name of the total. */ + label: string; + /** + * Paints a total through the field's own cell renderer — the one the cards + * use for that field — so a currency field totals as currency and a number + * field keeps its declared `scale`. No format code of its own. + */ + renderTotal: (total: number) => 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..03a2878fc0 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -32,6 +32,7 @@ import type { KanbanCard, KanbanColumn } from './types' import { createSafeTranslation } from "@object-ui/i18n" import { Plus } 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,46 @@ 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, 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 ( + + {`${summary.label} `} + {summary.renderTotal(total)} + {countsAreWindowed ? '+' : null} + + ) +} + function KanbanColumnView({ column, cards, @@ -598,6 +639,7 @@ function KanbanColumnView({ Full )} + {!isCollapsed && } @@ -1231,6 +1273,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..e771801f6c 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: any = 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 any }; +}); + +// 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 }, +]; + +function makeAdapter(rows: any[]): Record { + return { + find: vi.fn(async () => ({ 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: any[], 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); + }); +}); From 124060fd7da120badc0ceed059b3e91fcc603aae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:54:49 +0000 Subject: [PATCH 2/7] chore(plugin-kanban): type the lane-total field and the objectui#11629 pin without explicit any Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- packages/plugin-kanban/src/ObjectKanban.tsx | 2 +- .../__tests__/columnSummarizeField-11629.test.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index e771801f6c..de000271d9 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -1296,7 +1296,7 @@ export const ObjectKanban: React.FC = ({ } const fieldType = resolveCellRendererType(def ?? { type: 'number' }); const CellRenderer = getCellRenderer(fieldType); - const fieldForCell: any = def ?? { name: summarizeField, type: fieldType }; + const fieldForCell = def ?? { name: summarizeField, type: fieldType }; return { field: summarizeField, label: fieldLabel(objectDef?.name || schema.objectName || '', summarizeField, def?.label || summarizeField), diff --git a/packages/plugin-kanban/src/__tests__/columnSummarizeField-11629.test.tsx b/packages/plugin-kanban/src/__tests__/columnSummarizeField-11629.test.tsx index 06d733a05e..5a07aae1aa 100644 --- a/packages/plugin-kanban/src/__tests__/columnSummarizeField-11629.test.tsx +++ b/packages/plugin-kanban/src/__tests__/columnSummarizeField-11629.test.tsx @@ -59,7 +59,7 @@ const { permsStub, permsState } = vi.hoisted(() => { vi.mock('@object-ui/permissions', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, usePermissions: () => permsStub as any }; + return { ...actual, usePermissions: () => permsStub as never }; }); // Registers `object-kanban`. Module scope, not a hook: the import IS the @@ -109,9 +109,11 @@ const MIXED_ROWS = [ { id: 't5', name: 'Win', stage: 'won', region: 'east', hours: 4, amount: 1250 }, ]; -function makeAdapter(rows: any[]): Record { +type Row = Record; + +function makeAdapter(rows: Row[]) { return { - find: vi.fn(async () => ({ data: rows })), + find: vi.fn(async (_object: string, _query?: Record) => ({ data: rows })), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -127,11 +129,11 @@ const BOARD = { columns: LANES, }; -function renderBoard(rows: any[], extra: Record = {}) { +function renderBoard(rows: Row[], extra: Record = {}) { const adapter = makeAdapter(rows); const view = render( - - + + , ); return { ...view, adapter }; From 24fc5200a2183a3670665742c91e7e3c0b529b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:09:49 +0000 Subject: [PATCH 3/7] fix(plugin-kanban): mark the lane total with a Sigma glyph so it reads apart from the count badge (objectui#11629) The glyph is aria-hidden; the field label stays the total's tooltip and screen-reader name, so no user-facing string is added. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- packages/plugin-kanban/src/KanbanImpl.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 03a2878fc0..c16184f074 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -30,7 +30,7 @@ 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' @@ -497,8 +497,9 @@ function laneCountLabel(count: number, countsAreWindowed?: boolean): string { * 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, so no new - * user-facing string enters the product. + * 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, @@ -515,10 +516,11 @@ function LaneTotal({ if (total === null) return null return ( +