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
4 changes: 3 additions & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@objectstack/spec": "^17.0.0-rc.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
Expand All @@ -100,7 +101,8 @@
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.1.5",
"vite-plugin-dts": "^5.0.3"
"vite-plugin-dts": "^5.0.3",
"zod": "^4.4.3"
},
"keywords": [
"objectui",
Expand Down
150 changes: 150 additions & 0 deletions packages/components/src/__tests__/data-table-selection-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* 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.
*/

/**
* Selection mode ↔ spec vocabulary parity + behavior (#2941).
*
* `SelectionConfigSchema.type` (`ui/view.zod.ts`) publishes three names:
* `none | single | multiple`. The table used to treat `selectable` as a bare
* truthy, so a view authored with `selection.type: 'single'` rendered the full
* multi-select UX — per-row checkboxes that accumulate AND a select-all
* header. The output looked right and wasn't: `single` was never
* distinguished from `multiple`.
*
* Contract under test:
* - parity: the table's declared mode set equals the spec enum, both ways;
* - `single` offers no select-all and replaces the selection on each pick;
* - `multiple` (and legacy `true`) keeps the accumulate + select-all UX.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SelectionConfigSchema } from '@objectstack/spec/ui';
import { renderComponent } from './test-utils';
import { SUPPORTED_SELECTION_MODES } from '../renderers/complex/data-table';

beforeAll(async () => {
await import('../renderers');
}, 30000);

/** The spec's selection vocabulary, read through the `.default()` wrapper. */
function specSelectionModes(): string[] {
const typeSchema = (SelectionConfigSchema as unknown as { shape?: Record<string, unknown> })
.shape?.type as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
const options = typeSchema?.def?.innerType?.options;
return Array.isArray(options) ? [...options] : [];
}

const baseSchema = {
type: 'data-table' as const,
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Carol' },
],
pagination: false,
searchable: false,
};

describe('data-table selection mode covers the spec selection vocabulary', () => {
const specNames = specSelectionModes();

it('reads a non-empty enum from the spec', () => {
expect(specNames, 'could not read SelectionConfigSchema.shape.type options from the spec').not.toEqual([]);
});

it('implements every selection mode the spec accepts', () => {
const unimplemented = specNames.filter((name) => !SUPPORTED_SELECTION_MODES.has(name));
expect(
unimplemented,
'these pass schema validation but render an undistinguished selection UX — implement them in data-table',
).toEqual([]);
});

it('does not accept selection modes the spec rejects', () => {
const extra = [...SUPPORTED_SELECTION_MODES].filter((name) => !specNames.includes(name));
expect(
extra,
'these are renderer-local dialect — promote them into @objectstack/spec instead',
).toEqual([]);
});
});

describe('data-table selection behavior per mode', () => {
it("'single' renders no select-all header and replaces the selection on each pick", () => {
const onSelectionChange = vi.fn();
const { getAllByRole } = renderComponent({
...baseSchema,
selectable: 'single',
onSelectionChange,
} as any);

// 3 rows → exactly 3 checkboxes; a select-all header would make it 4.
const checkboxes = getAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);

fireEvent.click(checkboxes[0]);
expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ name: 'Alice' })]);

// Picking Bob must REPLACE Alice, never accumulate to two rows.
fireEvent.click(checkboxes[1]);
expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ name: 'Bob' })]);
});

it("'single' allows deselecting the picked row", () => {
const onSelectionChange = vi.fn();
const { getAllByRole } = renderComponent({
...baseSchema,
selectable: 'single',
onSelectionChange,
} as any);

const checkboxes = getAllByRole('checkbox');
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[0]);
expect(onSelectionChange).toHaveBeenLastCalledWith([]);
});

it("'multiple' keeps the select-all header and accumulates picks", () => {
const onSelectionChange = vi.fn();
const { getAllByRole } = renderComponent({
...baseSchema,
selectable: 'multiple',
onSelectionChange,
} as any);

// 3 rows + the select-all header.
const checkboxes = getAllByRole('checkbox');
expect(checkboxes).toHaveLength(4);

// First checkbox is the header select-all; rows follow.
fireEvent.click(checkboxes[1]);
fireEvent.click(checkboxes[2]);
expect(onSelectionChange).toHaveBeenLastCalledWith([
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Bob' }),
]);
});

it('legacy `selectable: true` still means multi-select', () => {
const { getAllByRole } = renderComponent({
...baseSchema,
selectable: true,
} as any);
expect(getAllByRole('checkbox')).toHaveLength(4);
});

it("'none' renders no selection column", () => {
const { queryAllByRole } = renderComponent({
...baseSchema,
selectable: 'none',
} as any);
expect(queryAllByRole('checkbox')).toHaveLength(0);
});
});
51 changes: 44 additions & 7 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,39 @@ export const DataTableBuiltinRowActionItem: React.FC<{
);
};

/**
* The selection modes this table implements — the renderer half of the spec's
* `SelectionConfigSchema.type` vocabulary (`ui/view.zod.ts`). Kept as an
* explicit export so a parity test can fail the moment either side moves
* (#2941; template: plugin-grid `summary-spec-parity.test.ts`).
*/
export const SUPPORTED_SELECTION_MODES: ReadonlySet<string> = new Set(['none', 'single', 'multiple']);

type SelectionMode = 'none' | 'single' | 'multiple';

/**
* Resolve the `selectable` prop — `boolean | 'single' | 'multiple'` (plus
* `'none'` arriving verbatim from raw SDUI JSON) — to a selection mode.
* `'single'` is a real mode (replace-on-select, no select-all), not a truthy
* alias for `'multiple'`; collapsing the two rendered per-row checkboxes AND
* a select-all header for `selection.type: 'single'` (#2941). Legacy `true`
* and unrecognized truthy strings keep their historical multi-select meaning.
*/
function resolveSelectionMode(selectable: DataTableSchema['selectable'] | 'none'): SelectionMode {
if (selectable === 'single') return 'single';
if (selectable === 'none' || !selectable) return 'none';
return 'multiple';
}

/**
* Enterprise-level data table component with Airtable-like features.
*
* Provides comprehensive table functionality including:
* - Multi-column sorting (ascending/descending/none)
* - Real-time search across all columns
* - Pagination with configurable page sizes
* - Row selection with persistence across pages
* - Row selection with persistence across pages (multi-select), or
* replace-on-select when the view declares `selection.type: 'single'`
* - CSV export of filtered/sorted data
* - Row action buttons (edit/delete)
*
Expand Down Expand Up @@ -337,7 +362,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
onPageChange,
onPageSizeChange,
searchable = true,
selectable = false,
selectable: selectableProp = false,
showSelectionCount = true,
selectionResetKey,
sortable = true,
Expand All @@ -359,6 +384,11 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
disableInnerScroll = false,
} = schema;

// 'single' caps the selection at one row (replace-on-select) and drops the
// select-all header; every truthy legacy value keeps meaning 'multiple'.
const selectionMode = resolveSelectionMode(selectableProp);
const selectable = selectionMode !== 'none';

// Ambient design-surface affordance: when a host (Studio) provides it, render
// a trailing "+ add field" column header. `null` for every runtime table, so
// existing tables render unchanged.
Expand Down Expand Up @@ -699,7 +729,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
};

const handleSelectRow = (rowId: any, checked: boolean) => {
const newSelected = new Set(selectedRowIds);
// Single mode replaces the previous selection instead of accumulating —
// the spec's 'single' must never hold two rows (#2941).
const newSelected = new Set(selectionMode === 'single' ? [] : selectedRowIds);
if (checked) {
newSelected.add(rowId);
} else {
Expand Down Expand Up @@ -1283,10 +1315,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
<TableRow ref={headerRowRef}>
{selectable && (
<TableHead className={cn("w-10 bg-background px-3", frozenColumns > 0 && "sticky left-0 z-20")}>
<Checkbox
checked={allPageRowsSelected ? true : somePageRowsSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
/>
{/* Select-all is a multi-select affordance; a 'single' view
keeps the column (alignment) but offers no way to select
more than one row (#2941). */}
{selectionMode === 'multiple' && (
<Checkbox
checked={allPageRowsSelected ? true : somePageRowsSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
/>
)}
</TableHead>
)}
{showRowNumbers && (
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"react-grid-layout": "^2.2.0 || ^1.4.0"
},
"devDependencies": {
"@objectstack/spec": "^17.0.0-rc.0",
"@types/react-grid-layout": "^2.1.0",
"@vitejs/plugin-react": "^6.0.4",
"react-grid-layout": "^2.2.3",
Expand Down
36 changes: 35 additions & 1 deletion packages/plugin-dashboard/src/PivotTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ function displayKey(key: string, labels?: Record<string, string>): string {
return labels?.[key] ?? key;
}

/**
* The aggregations this pivot computes — the renderer half of the spec's
* `ChartAggregateFunctionSchema` (`ui/chart.zod.ts`), the UI-side subset the
* spec deliberately carved out of the engine's 8-name `AggregationFunction`.
* Engine-level names (`count_distinct`, `array_agg`, `string_agg`) used to
* fall into a `default:` branch that returned a SUM — a plausible wrong total
* with no signal (#2941). They now short-circuit to a visible notice before
* any cell is computed. Exported for the spec-parity test.
*/
export const PIVOT_AGGREGATIONS: ReadonlySet<string> = new Set(['sum', 'count', 'avg', 'min', 'max']);

/** Aggregate an array of numbers with the given function. */
function aggregate(values: number[], fn: PivotAggregation): number {
if (values.length === 0) return 0;
Expand All @@ -112,7 +123,10 @@ function aggregate(values: number[], fn: PivotAggregation): number {
case 'max':
return Math.max(...values);
default:
return values.reduce((a, b) => a + b, 0);
// Unreachable: the component refuses to render out-of-vocabulary
// aggregations. NaN (never a silent sum) is the tripwire if a new call
// site skips that gate.
return Number.NaN;
}
}

Expand Down Expand Up @@ -228,6 +242,26 @@ export const PivotTable: React.FC<PivotTableProps> = ({ schema, className, rowLa

const fmt = (v: number) => formatValue(v, format);

// Out-of-vocabulary aggregation (e.g. the engine-level `count_distinct`
// arriving through untyped SDUI JSON): refuse loudly instead of quietly
// summing every cell (#2941). Placed after the hooks so their order stays
// stable across renders.
if (!PIVOT_AGGREGATIONS.has(aggregation)) {
return (
<div className={cn('overflow-auto', className)} data-testid="pivot-unsupported-aggregation">
{title && (
<h3 className="text-sm font-semibold mb-2">{title}</h3>
)}
<div role="alert" className="flex flex-col items-center justify-center py-8 text-destructive">
<p className="text-xs">
Unsupported aggregation &ldquo;{String(aggregation)}&rdquo; — this pivot table renders{' '}
{[...PIVOT_AGGREGATIONS].join(', ')}.
</p>
</div>
</div>
);
}

if (data.length === 0) {
return (
<div className={cn('overflow-auto', className)}>
Expand Down
Loading
Loading