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
43 changes: 43 additions & 0 deletions .changeset/column-summary-spec-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@object-ui/plugin-grid": minor
"@object-ui/types": minor
"@object-ui/react": minor
---

feat(grid): compute all eleven spec column summary aggregations (#2890)

`ColumnSummarySchema` accepts eleven aggregation names; `useColumnSummary` computed
five. The other six — `none`, `count_empty`, `count_filled`, `count_unique`,
`percent_empty`, `percent_filled` — passed validation at authoring time and then
rendered a blank footer cell, with no error raised on either side.

The computation now splits into two families. Count and percent read *raw* cell
values, before the numeric parse, so they work on text, select and lookup columns and
a value that does not parse as a number still counts as a filled row; a cell is empty
when it is `null`, `undefined`, `""` or an empty array. `sum`/`avg`/`min`/`max` keep
the existing numeric parse and column formatting.

Two behavior changes follow from the enum carrying both `count` and `count_filled`,
which cannot mean the same thing:

- `count` is now every row; `count_filled` is the non-empty variant. Only a column
whose values are all empty renders differently than before.
- a zero count renders `Empty: 0` instead of collapsing to a blank cell.

Column currency/percent formatting is gated to the numeric family, so `count_unique`
on a currency column reads `Unique: 3` and not `$3.00`. `none` and unrecognized names
skip the entry entirely, so a view whose columns all opt out renders no footer row.

`ListColumnSchema`'s objectui-local `{ type, field }` arm now takes its vocabulary
from `SpecColumnSummarySchema` by reference — it was stuck at the same five names,
which left the per-column `field` override unavailable for the six new aggregations.

A parity test asserts the renderer's supported set equals the spec enum in both
directions: a spec name the renderer omits is the bug above, and a renderer name the
spec omits would be local dialect (Commandment #0).

**Removed:** `useColumnSummary` from `@object-ui/react`. It was a second, unrelated
hook of the same name with no callers — a different API, a comment claiming it
implemented spec v2.0.7, and a `distinct` aggregation that is not in the spec
vocabulary at all (the spec calls it `count_unique`). Use `useColumnSummary` from
`@object-ui/plugin-grid`, which implements the spec enum.
45 changes: 45 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,51 @@ interface GridColumn {
}
```

### Column Summaries

A column can declare a footer aggregation with `summary`, either as a shorthand
string or as an object that aggregates a different field than the one displayed:

```json
{
"columns": [
{ "field": "name", "summary": "count_filled" },
{ "field": "amount", "type": "currency", "summary": "sum" },
{ "field": "owner", "summary": { "type": "count_unique", "field": "owner_id" } }
]
}
```

The accepted values are `ColumnSummarySchema` from `@objectstack/spec`:

| `summary` | Footer shows | Reads |
|---|---|---|
| `none` | nothing — the column opts out | — |
| `count` | number of rows | every row |
| `count_filled` | rows whose cell is non-empty | raw values |
| `count_empty` | rows whose cell is empty | raw values |
| `count_unique` | distinct non-empty values | raw values |
| `percent_filled` | share of rows that are non-empty | raw values |
| `percent_empty` | share of rows that are empty | raw values |
| `sum` | total | numeric values |
| `avg` | mean | numeric values |
| `min` | smallest | numeric values |
| `max` | largest | numeric values |

A cell counts as empty when it is `null`, `undefined`, `""` or an empty array,
so an unset multi-select or lookup reads as empty rather than as a filled `[]`.

The count and percent families read raw cell values, so they work on text,
select and lookup columns. `sum`/`avg`/`min`/`max` need numeric values (numeric
strings are parsed) and render nothing when the column has none.

A `currency` or `percent` column formats its `sum`/`avg`/`min`/`max` in that
unit. Counts stay plain cardinalities and percentages carry their own `%`, so
`count_unique` on a currency column reads `Unique: 3`, not `$3.00`.

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.

## Usage

### Auto-registration (Side-effect Import)
Expand Down
45 changes: 45 additions & 0 deletions packages/plugin-grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,51 @@ interface GridColumn {
}
```

### Column Summaries

A column can declare a footer aggregation with `summary`, either as a shorthand
string or as an object that aggregates a different field than the one displayed:

```json
{
"columns": [
{ "field": "name", "summary": "count_filled" },
{ "field": "amount", "type": "currency", "summary": "sum" },
{ "field": "owner", "summary": { "type": "count_unique", "field": "owner_id" } }
]
}
```

The accepted values are `ColumnSummarySchema` from `@objectstack/spec`:

| `summary` | Footer shows | Reads |
|---|---|---|
| `none` | nothing — the column opts out | — |
| `count` | number of rows | every row |
| `count_filled` | rows whose cell is non-empty | raw values |
| `count_empty` | rows whose cell is empty | raw values |
| `count_unique` | distinct non-empty values | raw values |
| `percent_filled` | share of rows that are non-empty | raw values |
| `percent_empty` | share of rows that are empty | raw values |
| `sum` | total | numeric values |
| `avg` | mean | numeric values |
| `min` | smallest | numeric values |
| `max` | largest | numeric values |

A cell counts as empty when it is `null`, `undefined`, `""` or an empty array,
so an unset multi-select or lookup reads as empty rather than as a filled `[]`.

The count and percent families read raw cell values, so they work on text,
select and lookup columns. `sum`/`avg`/`min`/`max` need numeric values (numeric
strings are parsed) and render nothing when the column has none.

A `currency` or `percent` column formats its `sum`/`avg`/`min`/`max` in that
unit. Counts stay plain cardinalities and percentages carry their own `%`, so
`count_unique` on a currency column reads `Unique: 3`, not `$3.00`.

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.

## Examples

### Basic Grid
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
},
"devDependencies": {
"@object-ui/data-objectstack": "workspace:*",
"@objectstack/spec": "^16.0.0-rc.0",
"@vitejs/plugin-react": "^6.0.4",
"msw": "^2.15.0",
"typescript": "^6.0.3",
Expand Down
123 changes: 123 additions & 0 deletions packages/plugin-grid/src/__tests__/column-features.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,129 @@ describe('useColumnSummary', () => {
});
});

// =========================================================================
// Count and percent aggregation families
//
// `ColumnSummarySchema` accepts eleven aggregation names. The renderer used to
// compute five of them, so a view configured with e.g. `count_unique` passed
// validation and then rendered a blank footer cell. These cover the six that
// were missing, on raw (non-numeric) cell values.
// =========================================================================
describe('useColumnSummary count/percent families', () => {
// 4 rows with deliberate holes: an empty string, a null, an empty array and
// a missing key — every shape the emptiness test has to recognize.
const sparseData = [
{ id: '1', name: 'Alice', dept: 'Eng', tags: ['a'] },
{ id: '2', name: 'Bob', dept: 'Eng', tags: [] },
{ id: '3', name: '', dept: 'Sales', tags: ['a'] },
{ id: '4', name: 'Dave', dept: null },
];
const summaryFor = (field: string, type: string, data: any[] = sparseData) => {
const columns = [{ field, label: field, summary: { type } }] as any[];
const { result } = renderHook(() => useColumnSummary(columns, data));
return result.current.summaries.get(field);
};

it('counts every row for `count`, filled or not', () => {
// Distinct from count_filled: `dept` has one null, but count is all rows.
const summary = summaryFor('dept', 'count');
expect(summary!.value).toBe(4);
expect(summary!.label).toBe('Count: 4');
});

it('counts only non-empty cells for `count_filled`', () => {
expect(summaryFor('dept', 'count_filled')!.value).toBe(3);
expect(summaryFor('name', 'count_filled')!.value).toBe(3);
});

it('counts empty cells for `count_empty`, including missing keys', () => {
expect(summaryFor('dept', 'count_empty')!.value).toBe(1);
// `name` holds an empty string; `tags` has an empty array plus a row that
// omits the key entirely.
expect(summaryFor('name', 'count_empty')!.value).toBe(1);
expect(summaryFor('tags', 'count_empty')!.value).toBe(2);
});

it('reports a zero count rather than a blank cell', () => {
// 0 is a meaningful answer — "no empty cells" — and must not be swallowed
// into an empty label the way a null result is.
const summary = summaryFor('name', 'count_empty', [{ name: 'Alice' }, { name: 'Bob' }]);
expect(summary!.value).toBe(0);
expect(summary!.label).toBe('Empty: 0');
});

it('counts distinct non-empty values for `count_unique`', () => {
// 'Eng' twice, 'Sales' once, one null → 2.
expect(summaryFor('dept', 'count_unique')!.value).toBe(2);
});

it('compares array cells by value for `count_unique`', () => {
// Two rows hold a *different* ['a'] array instance. A raw Set would key on
// reference and report 2 distinct values for what an author sees as one.
expect(summaryFor('tags', 'count_unique')!.value).toBe(1);
});

it('computes `percent_filled` and `percent_empty` over all rows', () => {
expect(summaryFor('dept', 'percent_filled')!.value).toBe(75);
expect(summaryFor('dept', 'percent_empty')!.value).toBe(25);
});

it('renders percent aggregations with a percent sign', () => {
expect(summaryFor('dept', 'percent_filled')!.label).toBe('Filled: 75%');
expect(summaryFor('dept', 'percent_empty')!.label).toBe('Empty: 25%');
});

it('rounds a repeating percentage to one decimal', () => {
const data = [{ v: 1 }, { v: null }, { v: null }];
expect(summaryFor('v', 'percent_filled', data)!.label).toBe('Filled: 33.3%');
});

it('counts non-numeric text values, which the numeric family cannot', () => {
// The whole point of the count family: `sum` over a text column has nothing
// to aggregate, but "how many are filled" is still a valid question.
expect(summaryFor('name', 'count_filled')!.value).toBe(3);
expect(summaryFor('name', 'sum')!.value).toBeNull();
});

it('skips a column whose summary is `none`', () => {
const columns = [{ field: 'dept', label: 'Dept', summary: 'none' }] as any[];
const { result } = renderHook(() => useColumnSummary(columns, sparseData));
// No entry at all, so a view with only `none` columns renders no footer row
// rather than an empty one.
expect(result.current.summaries.size).toBe(0);
expect(result.current.hasSummary).toBe(false);
});

it('keeps other columns when one is `none`', () => {
const columns = [
{ field: 'dept', label: 'Dept', summary: 'none' },
{ field: 'name', label: 'Name', summary: 'count_filled' },
] as any[];
const { result } = renderHook(() => useColumnSummary(columns, sparseData));
expect(result.current.summaries.size).toBe(1);
expect(result.current.summaries.get('name')!.value).toBe(3);
});

it('skips an unrecognized aggregation name instead of rendering a blank cell', () => {
const columns = [{ field: 'dept', label: 'Dept', summary: 'median' }] as any[];
const { result } = renderHook(() => useColumnSummary(columns, sparseData));
expect(result.current.hasSummary).toBe(false);
});

it('does not mistake an inherited Object key for an aggregation name', () => {
// The supported-name check must not be an `in` test against the label map.
const columns = [{ field: 'dept', label: 'Dept', summary: 'toString' }] as any[];
const { result } = renderHook(() => useColumnSummary(columns, sparseData));
expect(result.current.hasSummary).toBe(false);
});

it('honours the per-column field override for the count family', () => {
const columns = [{ field: 'name', label: 'Name', summary: { type: 'count_unique', field: 'dept' } }] as any[];
const { result } = renderHook(() => useColumnSummary(columns, sparseData));
expect(result.current.summaries.get('name')!.value).toBe(2);
});
});

// =========================================================================
// Summary footer rendering in ObjectGrid
// =========================================================================
Expand Down
44 changes: 44 additions & 0 deletions packages/plugin-grid/src/__tests__/summary-spec-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Column summary ↔ spec vocabulary parity.
*
* `ColumnSummarySchema` is the contract: a name it accepts is a name an author
* can publish, and every published name has to render something. When the
* renderer implemented five of the eleven aggregations, a view configured with
* `count_unique` validated at authoring time and then rendered a blank footer
* cell — the failure was invisible on both sides.
*
* This test closes that gap in the direction that matters: the spec widening
* without the renderer following. It fails the moment a twelfth aggregation
* lands in the spec, which is exactly when someone needs to decide how the
* footer should show it.
*/
import { describe, it, expect } from 'vitest';
import { ColumnSummarySchema } from '@objectstack/spec/ui';
import { SUPPORTED_SUMMARY_TYPES } from '../useColumnSummary';

describe('useColumnSummary covers the spec summary vocabulary', () => {
const rawOptions = (ColumnSummarySchema as unknown as { options?: readonly string[] }).options;
const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : [];

it('reads a non-empty enum from the spec', () => {
// Guards the assertions below against silently passing on an empty list if
// the spec's lazy-schema wrapper ever stops exposing `.options`.
expect(specNames, 'could not read ColumnSummarySchema.options from the spec').not.toEqual([]);
});

it('implements every aggregation the spec accepts', () => {
const unimplemented = specNames.filter((name) => !SUPPORTED_SUMMARY_TYPES.has(name));
expect(
unimplemented,
'these pass schema validation but render a blank footer cell — implement them in useColumnSummary',
).toEqual([]);
});

it('does not accept aggregations the spec rejects', () => {
const extra = [...SUPPORTED_SUMMARY_TYPES].filter((name) => !specNames.includes(name));
expect(
extra,
'these are renderer-local dialect — promote them into @objectstack/spec instead',
).toEqual([]);
});
});
49 changes: 49 additions & 0 deletions packages/plugin-grid/src/useColumnSummary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,52 @@ describe('useColumnSummary tenant-default currency', () => {
expect(label).toMatch(/1,234\.00/);
});
});

/**
* A column's currency/percent formatting describes values *in that column's
* unit*. The count family produces cardinalities and the percent family
* produces percentages, so neither may inherit it — "3 distinct customers" is
* not "$3.00", and "75% filled" is not "75% of a percent column".
*/
describe('useColumnSummary formatting stays with the numeric family', () => {
const CURRENCY_DATA = [{ amount: 1000 }, { amount: 234 }, { amount: 1000 }];

it('does not apply currency formatting to a count on a currency column', () => {
const cols: any[] = [{ field: 'amount', summary: 'count_unique', type: 'currency', currency: 'USD' }];
const { result } = renderHook(() => useColumnSummary(cols, CURRENCY_DATA), {
wrapper: wrapper('USD'),
});
const label = result.current.summaries.get('amount')?.label ?? '';
expect(label).toBe('Unique: 2');
expect(label).not.toMatch(/[¥$€£]/);
});

it('does not apply currency formatting to a percent aggregation', () => {
const cols: any[] = [{ field: 'amount', summary: 'percent_filled', type: 'currency', currency: 'USD' }];
const { result } = renderHook(() => useColumnSummary(cols, CURRENCY_DATA), {
wrapper: wrapper('USD'),
});
const label = result.current.summaries.get('amount')?.label ?? '';
expect(label).toBe('Filled: 100%');
expect(label).not.toMatch(/[¥$€£]/);
});

it('does not route a count through the percent column formatter', () => {
// The percent-column branch scales a fraction to 0-100; a count of 2 must
// not be reinterpreted as a ratio.
const cols: any[] = [{ field: 'rate', summary: 'count_unique', type: 'percent' }];
const { result } = renderHook(() => useColumnSummary(cols, [{ rate: 0.5 }, { rate: 0.25 }]), {
wrapper: wrapper('USD'),
});
expect(result.current.summaries.get('rate')?.label).toBe('Unique: 2');
});

it('still applies currency formatting to the numeric family', () => {
// Guard against over-correcting: sum/avg/min/max keep column formatting.
const cols: any[] = [{ field: 'amount', summary: 'sum', type: 'currency', currency: 'USD' }];
const { result } = renderHook(() => useColumnSummary(cols, CURRENCY_DATA), {
wrapper: wrapper('USD'),
});
expect(result.current.summaries.get('amount')?.label).toMatch(/\$|US\$/);
});
});
Loading
Loading