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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/11629-kanban-column-sum.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
10 changes: 10 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,14 @@ export function galleryViewOptions(viewDef: any): Record<string, unknown> {
* 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<string, unknown> {
Expand All @@ -454,10 +462,12 @@ export function kanbanViewOptions(viewDef: any, objectDef: any): Record<string,
viewDef?.kanban?.groupField ||
detectStatusField(objectDef as any) ||
undefined;
const summarizeField = viewDef?.kanban?.summarizeField;
return {
...(lane ? { groupByField: lane } : {}),
titleField: viewDef?.kanban?.titleField || 'name',
cardFields: viewDef?.kanban?.columns,
...(summarizeField ? { summarizeField } : {}),
};
}

Expand Down
104 changes: 104 additions & 0 deletions packages/plugin-kanban/src/KanbanColumnSummary.ts
Original file line number Diff line number Diff line change
@@ -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<KanbanColumnSummary | null>(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<Record<string, unknown>>,
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));
}
49 changes: 48 additions & 1 deletion packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | false | null | undefined>) => classes.filter(Boolean).join(' ')
Expand Down Expand Up @@ -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 (
<span
className={cn("inline-flex items-center gap-0.5 text-[11px] font-medium text-muted-foreground tabular-nums whitespace-nowrap", className)}
title={summary.label}
data-kanban-lane-total=""
>
<Sigma aria-hidden="true" className="h-3 w-3 shrink-0" />
<span className="sr-only">{`${summary.label} `}</span>
{summary.renderTotal(total)}
{countsAreWindowed ? '+' : null}
</span>
)
}

function KanbanColumnView({
column,
cards,
Expand Down Expand Up @@ -598,6 +641,7 @@ function KanbanColumnView({
Full
</Badge>
)}
{!isCollapsed && <LaneTotal cards={safeCards} countsAreWindowed={countsAreWindowed} />}
</div>
</div>
</div>
Expand Down Expand Up @@ -1231,6 +1275,9 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
{!collapsed && (
<span className="ml-2 text-xs text-muted-foreground">({laneCountLabel(col.cards.length, countsAreWindowed)})</span>
)}
{!collapsed && (
<LaneTotal cards={col.cards} countsAreWindowed={countsAreWindowed} className="ml-2" />
)}
</div>
)
})}
Expand Down
Loading
Loading