From 711fc4a5c4bcb4a202995c4019a9b15e50f46d95 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 15:29:30 +0700 Subject: [PATCH 01/46] feat(core): take the shared foundation the new modules stand on The sixteen feature modules coming over next all read cells, mark synthetic rows, coerce numbers or name a string, so the ground they stand on has to be here before any of them are. Placed by what each thing is rather than by where it sat: the row marks join `row-node.ts`, the memoized reader joins `value-gate.ts`, and the URL codec becomes `share-link.ts` because `snapshot.ts` next door already means something else. Two duplicates die on the way in. `date-value.ts` is the weaker twin of `components/internal/editor-values.ts`, and `resolve-locale.ts` matched `core/interaction/locale.ts` line for line. The two label tables and the two slot tables become one of each: 214 keys across twelve languages, 140 slots. Both merges collided on the same word. `filterColumn`, `filterOperator` and `filterValue` already name the filter panel's aria-labels, and `filterPanel` and `filterRow` already name the panel and the floating row, so the filter builder's own nine slots and three labels take a `filterBuilder` prefix. One label, for an overlay the grid no longer draws, does not come over. The i18n guard is what holds the merge honest, so it grew with it: plausible arguments for 43 more function labels, and ten loanwords named one by one in German, French and Indonesian rather than exempted in bulk. Budgets move to a serial project of their own. They were running alongside 900 unit tests, which is how a timing assertion learns to flake. --- src/lib/components/datagrid.variants.ts | 109 ++++++++- src/lib/core/columns/auto-columns.test.ts | 252 +++++++++++++++++++++ src/lib/core/columns/auto-columns.ts | 125 ++++++++++ src/lib/core/columns/auto-columns.types.ts | 44 ++++ src/lib/core/columns/index.ts | 2 + src/lib/core/grid/index.ts | 29 ++- src/lib/core/grid/row-node-marks.test.ts | 53 +++++ src/lib/core/grid/row-node.ts | 39 ++++ src/lib/core/grid/share-link.test.ts | 104 +++++++++ src/lib/core/grid/share-link.ts | 98 ++++++++ src/lib/core/grid/value-gate.ts | 40 ++++ src/lib/core/interaction/labels.ts | 182 ++++++++++++++- src/lib/core/types/labels.ts | 165 ++++++++++++++ src/lib/core/utils/base64.ts | 18 ++ src/lib/core/utils/index.ts | 2 + src/lib/core/utils/number.test.ts | 57 +++++ src/lib/core/utils/number.ts | 32 +++ src/lib/locales/de-DE.ts | 186 ++++++++++++++- src/lib/locales/es-ES.ts | 184 ++++++++++++++- src/lib/locales/fr-FR.ts | 185 ++++++++++++++- src/lib/locales/id-ID.ts | 185 ++++++++++++++- src/lib/locales/ja-JP.ts | 184 ++++++++++++++- src/lib/locales/ko-KR.ts | 183 ++++++++++++++- src/lib/locales/locales.test.ts | 67 +++++- src/lib/locales/pt-BR.ts | 184 ++++++++++++++- src/lib/locales/ru-RU.ts | 185 ++++++++++++++- src/lib/locales/th-TH.ts | 184 ++++++++++++++- src/lib/locales/vi-VN.ts | 183 ++++++++++++++- src/lib/locales/zh-CN.ts | 182 ++++++++++++++- vite.config.ts | 18 +- 30 files changed, 3443 insertions(+), 18 deletions(-) create mode 100644 src/lib/core/columns/auto-columns.test.ts create mode 100644 src/lib/core/columns/auto-columns.ts create mode 100644 src/lib/core/columns/auto-columns.types.ts create mode 100644 src/lib/core/grid/row-node-marks.test.ts create mode 100644 src/lib/core/grid/share-link.test.ts create mode 100644 src/lib/core/grid/share-link.ts create mode 100644 src/lib/core/utils/base64.ts create mode 100644 src/lib/core/utils/number.test.ts create mode 100644 src/lib/core/utils/number.ts diff --git a/src/lib/components/datagrid.variants.ts b/src/lib/components/datagrid.variants.ts index 841d69c..b674638 100644 --- a/src/lib/components/datagrid.variants.ts +++ b/src/lib/components/datagrid.variants.ts @@ -146,7 +146,114 @@ export const datagridVariants = tv({ 'fixed z-40 flex w-68 flex-col gap-2 rounded-lg border border-outline-variant bg-surface p-3 shadow-lg', filterChips: 'flex flex-wrap items-center gap-1.5', statusBar: 'flex items-center gap-2 text-xs text-on-surface-variant', - footer: 'flex flex-wrap items-center justify-end gap-x-3 gap-y-2' + footer: 'flex flex-wrap items-center justify-end gap-x-3 gap-y-2', + + // Grouping, range selection, the tool panel, the filter builder, the + // import wizard, conditional formatting, saved views, find and replace + // and the command palette. Registered here rather than in a second + // table so an app overrides every slot the grid has in one `ui` prop. + groupPanel: + 'flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-outline-variant px-3 py-2', + groupPanelLabel: 'text-xs font-medium text-on-surface-variant', + groupPanelChip: + 'inline-flex items-center gap-1 rounded-md bg-surface-container py-0.5 pr-0.5 pl-2', + groupPanelChipLabel: 'text-xs font-medium text-on-surface', + groupPanelSeparator: 'size-3.5 text-on-surface-variant', + groupPanelEmpty: 'text-xs text-on-surface-variant', + + rangeStatusBar: + 'flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-on-surface-variant', + rangeStatusBarValue: 'text-on-surface', + + rangeCell: + 'relative bg-primary/10 before:pointer-events-none before:absolute before:inset-0 before:z-[2] before:border-primary/45', + rangeEdgeTop: 'before:border-t', + rangeEdgeBottom: 'before:border-b', + rangeEdgeStart: 'before:border-s', + rangeEdgeEnd: 'before:border-e', + + fillHandle: + 'after:absolute after:end-0 after:bottom-0 after:z-20 after:size-[7px] after:cursor-crosshair after:bg-primary after:content-[""]', + + cutSource: + 'relative before:pointer-events-none before:absolute before:inset-0 before:z-[3] before:border before:border-dashed before:border-primary', + + moveTarget: + 'relative bg-tertiary/10 before:pointer-events-none before:absolute before:inset-0 before:z-[3] before:border before:border-tertiary/60', + + fillPreview: + 'relative bg-primary/5 before:pointer-events-none before:absolute before:inset-0 before:z-[2] before:border-dashed before:border-primary/45', + + toolPanel: + 'flex w-full flex-col gap-3 rounded-lg border border-outline-variant bg-surface p-3 lg:w-72 lg:shrink-0', + toolPanelCollapsed: + 'flex w-full flex-row items-center justify-between gap-2 rounded-lg border border-outline-variant bg-surface p-2 lg:w-auto lg:flex-col lg:justify-start', + toolPanelHeader: 'flex items-center justify-between gap-2', + toolPanelTitle: 'text-xs font-medium text-on-surface', + toolPanelTabs: 'flex items-center gap-1', + toolPanelBody: 'flex max-h-96 min-h-0 flex-col gap-1 overflow-y-auto lg:max-h-none', + toolPanelRow: + 'flex items-center gap-1 rounded px-1 py-1 hover:bg-surface-container has-[input:checked]:bg-transparent', + toolPanelRowMuted: 'opacity-60', + toolPanelValueRow: 'flex flex-col gap-1 rounded px-1 py-1', + toolPanelRowLabel: 'grow truncate text-xs text-on-surface', + toolPanelPinMark: 'text-[10px] font-medium tracking-wide text-primary uppercase', + toolPanelHint: 'text-xs text-on-surface-variant', + + filterBuilder: + 'flex flex-col gap-2 rounded-lg border border-outline-variant bg-surface p-3', + filterBuilderGroup: 'flex flex-col gap-2 rounded-md border border-outline-variant/70 p-2', + filterBuilderGroupHeader: 'flex flex-wrap items-center gap-2', + filterBuilderRow: 'flex flex-wrap items-center gap-2', + filterBuilderColumnSelect: 'w-full shrink-0 sm:w-44', + filterBuilderOperatorSelect: 'w-full shrink-0 sm:w-48', + filterBuilderValueInput: 'w-full min-w-36 sm:w-auto sm:min-w-0 sm:grow', + filterBuilderValuePlaceholder: 'text-on-surface-variant', + filterBuilderHint: 'text-xs text-on-surface-variant', + + importPanel: 'flex flex-col gap-3 rounded-lg border border-outline-variant bg-surface p-4', + importDropZone: + 'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-outline-variant px-4 py-10 text-center transition-colors', + importDropZoneActive: 'border-primary bg-primary-container/40', + importHint: 'text-xs text-on-surface-variant', + importMapRow: 'flex flex-col gap-1 rounded px-1 py-1', + importMapLabel: 'text-xs font-medium text-on-surface', + importMapSelect: 'w-full', + importIssueList: 'flex max-h-40 flex-col gap-1 overflow-y-auto', + importIssueRow: 'flex items-center justify-between gap-3 text-xs text-on-surface-variant', + importIssueCount: 'shrink-0 tabular-nums text-error', + importStagedCell: 'bg-tertiary/10', + importBadCell: 'bg-error/15 ring-1 ring-inset ring-error/60', + + commandPalette: 'p-0 max-w-lg', + + findPanel: + 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', + findCount: 'text-xs tabular-nums text-on-surface-variant', + + findMatch: 'bg-tertiary/20', + + findMatchCurrent: 'bg-tertiary/40 ring-1 ring-inset ring-tertiary', + + formatHighlight: 'bg-tertiary/20', + + formatPanel: + 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', + formatPanelLabel: 'text-xs font-medium text-on-surface-variant', + formatPanelEmpty: 'text-xs text-on-surface-variant', + formatPanelError: 'text-xs text-error', + formatRuleChip: + 'inline-flex items-center gap-1 rounded-md bg-surface-container-high py-0.5 pr-0.5 pl-2', + formatRuleChipLabel: 'text-xs font-medium text-on-surface', + formatRuleChipInvalid: 'ring-1 ring-error', + + savedViews: + 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', + savedViewsLabel: 'text-xs font-medium text-on-surface-variant', + savedViewsModified: 'text-xs text-warning', + + watermark: + 'pointer-events-none absolute right-2 bottom-2 z-30 rounded-md border border-outline-variant bg-surface/80 px-2 py-1 text-xs font-medium text-on-surface-variant backdrop-blur-sm select-none' }, variants: { align: { diff --git a/src/lib/core/columns/auto-columns.test.ts b/src/lib/core/columns/auto-columns.test.ts new file mode 100644 index 0000000..2dbbe74 --- /dev/null +++ b/src/lib/core/columns/auto-columns.test.ts @@ -0,0 +1,252 @@ +import { createDataGrid } from '../grid/grid.svelte.js' +import { sorting } from '../../features/sorting/index.js' +import type { ColumnDef } from '../types/index.js' +import { describe, expect, it } from 'vitest' +import { autoColumns, headerOf } from './auto-columns.js' +import { markSyntheticRow } from '../grid/row-node.js' + +interface Order { + id: number + customer: string + region: string + total: number + paid: boolean + placedAt: string + note: string | null +} + +const orders: Order[] = [ + { + id: 1, + customer: 'Alice', + region: 'North', + total: 120, + paid: true, + placedAt: '2026-01-05', + note: null + }, + { + id: 2, + customer: 'Bob', + region: 'South', + total: 340, + paid: false, + placedAt: '2026-02-11', + note: 'rush' + }, + { + id: 3, + customer: 'Chi', + region: 'North', + total: 90, + paid: true, + placedAt: '2026-03-02', + note: null + }, + { + id: 4, + customer: 'Dan', + region: 'East', + total: 700, + paid: false, + placedAt: '2026-04-19', + note: null + }, + { + id: 5, + customer: 'Eve', + region: 'North', + total: 210, + paid: true, + placedAt: '2026-05-01', + note: null + }, + { + id: 6, + customer: 'Finn', + region: 'South', + total: 55, + paid: false, + placedAt: '2026-06-14', + note: null + }, + { + id: 7, + customer: 'Gia', + region: 'East', + total: 480, + paid: true, + placedAt: '2026-07-08', + note: null + }, + { + id: 8, + customer: 'Huy', + region: 'North', + total: 130, + paid: false, + placedAt: '2026-08-23', + note: null + } +] + +const byId = (columns: ColumnDef[]) => + Object.fromEntries(columns.map((column) => [column.id, column])) + +describe('headerOf', () => { + it('turns a key into something a person would write', () => { + expect(headerOf('placedAt')).toBe('Placed At') + expect(headerOf('order_total')).toBe('Order Total') + expect(headerOf('customer-name')).toBe('Customer Name') + expect(headerOf('id')).toBe('Id') + expect(headerOf('totalUSD2024')).toBe('Total USD2024') + }) +}) + +describe('autoColumns', () => { + it('reads a number column as a number, aligned right and filtered as one', () => { + const columns = byId(autoColumns(orders)) + expect(columns.total).toMatchObject({ + id: 'total', + header: 'Total', + type: 'number', + filter: 'number', + align: 'right', + sortable: true + }) + }) + + it('reads booleans, dates and free text', () => { + const columns = byId(autoColumns(orders)) + expect(columns.paid).toMatchObject({ type: 'boolean', filter: 'boolean' }) + expect(columns.placedAt).toMatchObject({ type: 'date', filter: 'date' }) + expect(columns.customer).toMatchObject({ type: 'text', filter: 'text' }) + }) + + it('offers a set filter where a column repeats itself', () => { + const columns = byId(autoColumns(orders)) + expect(columns.region).toMatchObject({ type: 'badge', filter: 'set' }) + }) + + it('reads a Date object as a datetime, and an ISO stamp too', () => { + const rows = [ + { at: new Date('2026-01-01T10:00:00Z'), stamp: '2026-01-01T10:00:00Z' }, + { at: new Date('2026-01-02T11:00:00Z'), stamp: '2026-01-02T11:00:00Z' } + ] + const columns = byId(autoColumns(rows) as unknown as ColumnDef[]) + expect(columns.at).toMatchObject({ type: 'datetime', filter: 'date' }) + expect(columns.stamp).toMatchObject({ type: 'datetime', filter: 'date' }) + }) + + it('keeps a column whose sample is all blank, as text', () => { + const columns = byId(autoColumns(orders)) + expect(columns.note).toMatchObject({ type: 'text', filter: 'text' }) + }) + + it('drops a key holding something a cell cannot render', () => { + const rows = [ + { id: 1, tags: ['a'] }, + { id: 2, tags: ['b'] } + ] + expect(autoColumns(rows).map((column) => column.id)).toEqual(['id']) + }) + + it('finds keys that only later rows have', () => { + const rows = [{ id: 1 }, { id: 2, extra: 'x' }] + expect(autoColumns(rows).map((column) => column.id)).toEqual(['id', 'extra']) + }) + + it('never offers a column for this package own row markers', () => { + const rows = [markSyntheticRow({ id: 1 }), markSyntheticRow({ id: 2 })] + expect(autoColumns(rows).map((column) => column.id)).toEqual(['id']) + }) + + it('takes exclude, order, header and per-column overrides', () => { + const columns = autoColumns(orders, { + exclude: ['note', 'paid'], + order: ['total', 'customer'], + header: (key) => key.toUpperCase(), + overrides: { total: { width: 200, editable: true } } + }) + + expect(columns.map((column) => column.id)).toEqual([ + 'total', + 'customer', + 'id', + 'region', + 'placedAt' + ]) + expect(columns[0]).toMatchObject({ header: 'TOTAL', width: 200, editable: true }) + }) + + it('reads only as far as the sample says', () => { + const rows = [{ value: 1 }, { value: 2 }, { value: 'text' }] + expect( + byId(autoColumns(rows, { sample: 2 }) as unknown as ColumnDef[]).value + ).toMatchObject({ + type: 'number' + }) + expect(byId(autoColumns(rows) as unknown as ColumnDef[]).value).toMatchObject({ + type: 'text' + }) + }) + + it('builds a grid that actually runs', () => { + const grid = createDataGrid({ + columns: autoColumns(orders), + data: orders, + getRowId: (row) => String(row.id), + features: [sorting()] + }) + + expect(grid.columns.visible.map((column) => column.id)).toEqual([ + 'id', + 'customer', + 'region', + 'total', + 'paid', + 'placedAt', + 'note' + ]) + expect(grid.nodes).toHaveLength(8) + }) + + it('lets an override rescue a key the guess would have dropped', () => { + const rows = [ + { id: 1, tags: ['a', 'b'] }, + { id: 2, tags: ['c'] } + ] + const columns = autoColumns(rows, { overrides: { tags: { type: 'text' } } }) + + expect(columns.map((column) => column.id)).toEqual(['id', 'tags']) + expect(columns[1]).toMatchObject({ id: 'tags', header: 'Tags', type: 'text' }) + }) + + it('reads a column that mixes plain days and full stamps as a datetime', () => { + const rows = [{ at: '2026-01-01' }, { at: '2026-01-02T03:04:05Z' }, { at: '2026-01-03' }] + expect(autoColumns(rows)[0]).toMatchObject({ type: 'datetime', filter: 'date' }) + }) + + it('keeps a numeric column numeric when one value is not finite', () => { + const rows = [{ ratio: 1 }, { ratio: Number.NaN }, { ratio: 3 }] + expect(autoColumns(rows)[0]).toMatchObject({ type: 'number', align: 'right' }) + }) + + it('names a column once, however often order repeats it', () => { + const rows = [{ a: 1, b: 2 }] + expect(autoColumns(rows, { order: ['b', 'b', 'a'] }).map((column) => column.id)).toEqual([ + 'b', + 'a' + ]) + }) + + it('still reads a row when the sample asked for none', () => { + const rows = [{ total: 1 }, { total: 2 }] + expect(autoColumns(rows, { sample: 0 })).toHaveLength(1) + expect(autoColumns(rows, { sample: -5 })).toHaveLength(1) + }) + + it('returns nothing for nothing', () => { + expect(autoColumns([])).toEqual([]) + }) +}) diff --git a/src/lib/core/columns/auto-columns.ts b/src/lib/core/columns/auto-columns.ts new file mode 100644 index 0000000..bb7ad7e --- /dev/null +++ b/src/lib/core/columns/auto-columns.ts @@ -0,0 +1,125 @@ +import type { ColumnDef, ColumnType, FilterType } from '../types/index.js' +import { isBlank } from '../utils/value.js' + +import type { AutoColumnsOptions } from './auto-columns.types.js' + +const INTERNAL_PREFIX = '__' +const DEFAULT_SAMPLE = 50 +const SET_FILTER_MAX = 8 +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/ +const DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/ + +export function headerOf(key: string): string { + const spaced = key + .replace(/[_-]+/g, ' ') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + return spaced.replace( + /(^|\s)(\w)/g, + (_, lead: string, first: string) => lead + first.toUpperCase() + ) +} + +function keysOf(rows: unknown[]): string[] { + const keys: string[] = [] + const seen = new Set() + for (const row of rows) { + if (row === null || typeof row !== 'object') continue + for (const key of Object.keys(row)) { + if (seen.has(key) || key.startsWith(INTERNAL_PREFIX)) continue + seen.add(key) + keys.push(key) + } + } + return keys +} + +function valuesOf(rows: unknown[], key: string): unknown[] { + const values: unknown[] = [] + for (const row of rows) { + const value = (row as Record | null)?.[key] + if (!isBlank(value)) values.push(value) + } + return values +} + +type Shape = { type: ColumnType; filter: FilterType } + +function dateShape(values: unknown[]): Shape | null { + const dates = values.filter((value) => value instanceof Date).length + const stamps = values.filter( + (value) => typeof value === 'string' && DATE_TIME.test(value) + ).length + const days = values.filter((value) => typeof value === 'string' && DATE_ONLY.test(value)).length + + if (dates + stamps + days < values.length) return null + return { type: days === values.length ? 'date' : 'datetime', filter: 'date' } +} + +function repeatShape(values: unknown[]): Shape { + const distinct = new Set(values.map(String)) + return distinct.size <= SET_FILTER_MAX && distinct.size * 2 <= values.length + ? { type: 'badge', filter: 'set' } + : { type: 'text', filter: 'text' } +} + +function guess(values: unknown[]): Shape | null { + if (values.length === 0) return { type: 'text', filter: 'text' } + + if (values.every((value) => typeof value === 'boolean')) { + return { type: 'boolean', filter: 'boolean' } + } + if (values.every((value) => typeof value === 'number')) { + return { type: 'number', filter: 'number' } + } + + const dates = dateShape(values) + if (dates) return dates + + if (values.some((value) => typeof value === 'object')) return null + return repeatShape(values) +} + +function ordered(keys: string[], order: string[]): string[] { + const named = [...new Set(order)].filter((key) => keys.includes(key)) + return [...named, ...keys.filter((key) => !named.includes(key))] +} + +function defOf( + key: string, + header: string, + shape: Shape | null, + override: Partial> | undefined +): ColumnDef { + const guessed: Partial> = shape + ? { type: shape.type, filter: shape.filter } + : {} + if (shape?.type === 'number') guessed.align = 'right' + + return { id: key, header, sortable: true, ...guessed, ...override } +} + +export function autoColumns( + rows: TRow[], + options: AutoColumnsOptions = {} +): ColumnDef[] { + const asked = options.sample ?? DEFAULT_SAMPLE + const sample = rows.slice(0, Math.max(1, Math.trunc(asked))) + const exclude = new Set(options.exclude ?? []) + const header = options.header ?? headerOf + const overrides = options.overrides ?? {} + + const keys = ordered( + keysOf(sample).filter((key) => !exclude.has(key)), + options.order ?? [] + ) + + const columns: ColumnDef[] = [] + for (const key of keys) { + const override = overrides[key] + const shape = guess(valuesOf(sample, key)) + if (shape || override) columns.push(defOf(key, header(key), shape, override)) + } + return columns +} diff --git a/src/lib/core/columns/auto-columns.types.ts b/src/lib/core/columns/auto-columns.types.ts new file mode 100644 index 0000000..2c8b87a --- /dev/null +++ b/src/lib/core/columns/auto-columns.types.ts @@ -0,0 +1,44 @@ +import type { ColumnDef } from '../types/index.js' + +/** How `autoColumns` reads the data, and what it is allowed to decide. */ +export interface AutoColumnsOptions { + /** + * How many rows to look at when guessing a column's type. Every non-blank + * value in those rows has to agree for a guess to hold, so a column that + * changes shape after the sample is read from what the sample saw. A + * sample larger than the data costs nothing; one below 1 is read as 1. + * @default 50 + */ + sample?: number + + /** + * Keys to leave out entirely. Keys starting with `__` are always left out: + * that is where this package keeps its own row markers. + * @default [] + */ + exclude?: string[] + + /** + * Column order, by key. Keys not named here follow in the order they were + * first seen in the data. + * @default [] + */ + order?: string[] + + /** + * Header text for a key. The default turns `placedAt` into `Placed At` and + * `order_total` into `Order Total`. + */ + header?: (key: string) => string + + /** + * Merged over the generated definition, keyed by column id, for the parts + * a guess cannot know: `width`, `editable`, a `cell` snippet, a better + * `type`. This is the escape hatch that keeps the guess useful when it is + * only mostly right, and it also brings back a key the guess dropped - + * naming one here is how an object or array column gets in, with a `cell` + * snippet to render it. + * @default {} + */ + overrides?: Record>> +} diff --git a/src/lib/core/columns/index.ts b/src/lib/core/columns/index.ts index 443a06c..f66b349 100644 --- a/src/lib/core/columns/index.ts +++ b/src/lib/core/columns/index.ts @@ -36,3 +36,5 @@ export { parentGroupIdOf } from './header-groups.js' export { opensRowSpanGroup, rowSpansOf, type ColumnRowSpans } from './row-span.js' +export { autoColumns, headerOf } from './auto-columns.js' +export type { AutoColumnsOptions } from './auto-columns.types.js' diff --git a/src/lib/core/grid/index.ts b/src/lib/core/grid/index.ts index a1dddf7..f0e70ab 100644 --- a/src/lib/core/grid/index.ts +++ b/src/lib/core/grid/index.ts @@ -10,8 +10,25 @@ export { EventBus, type EventHandler } from './events.js' export { createDataGrid } from './grid.svelte.js' export type { GridState } from './grid.svelte.js' export { composePipeline, PIPELINE_ORDER, type Pipeline } from './pipeline.svelte.js' -export { buildRowNodes, nodeIndexById, nodesById } from './row-node.js' -export { composeReaders, readCell, readerToken } from './value-gate.js' +export { + buildRowNodes, + isDataRow, + isLoadingRow, + isSyntheticRow, + LOADING_KEY, + markSyntheticRow, + nodeIndexById, + nodesById, + SYNTHETIC_KEY +} from './row-node.js' +export { + composeReaders, + gateReader, + rawRead, + readCell, + readerToken, + type CellRead +} from './value-gate.js' export { buildColumnSnapshot, isDensity, @@ -19,3 +36,11 @@ export { resolveColumnSnapshot, type ColumnSnapshotSource } from './snapshot.js' +export { + canonicalJson, + decodeSnapshot, + encodeSnapshot, + sameSnapshot, + SHARE_LIMIT, + ShareTooLongError +} from './share-link.js' diff --git a/src/lib/core/grid/row-node-marks.test.ts b/src/lib/core/grid/row-node-marks.test.ts new file mode 100644 index 0000000..6b1324e --- /dev/null +++ b/src/lib/core/grid/row-node-marks.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { RowNode } from '../types/index.js' +import { + isDataRow, + isLoadingRow, + isSyntheticRow, + LOADING_KEY, + markSyntheticRow +} from './row-node.js' + +interface Row { + id: number + name: string +} + +const nodeOf = (row: unknown, meta?: RowNode['meta']): RowNode => + ({ id: '1', row, index: 0, meta }) as RowNode + +describe('isDataRow', () => { + it('takes an ordinary row', () => { + expect(isDataRow(nodeOf({ id: 1, name: 'Ann' }))).toBe(true) + }) + + it('takes a row a structural feature nested rather than listed', () => { + expect(isDataRow(nodeOf({ id: 2, name: 'child' }, { level: 1 }))).toBe(true) + }) + + it('leaves out a row a feature synthesized', () => { + expect(isDataRow(nodeOf(markSyntheticRow({ id: 0, name: 'Total' })))).toBe(false) + }) + + it('leaves out a placeholder waiting on a server', () => { + expect(isDataRow(nodeOf({ id: -1, [LOADING_KEY]: true }))).toBe(false) + }) + + it('leaves out a full-width row, which is a panel rather than a row of values', () => { + expect(isDataRow(nodeOf({ id: 1, name: 'Ann' }, { fullWidth: true }))).toBe(false) + }) +}) + +describe('markSyntheticRow', () => { + it('marks a copy rather than the row it was given', () => { + const row = { id: 1, name: 'Ann' } + expect(isSyntheticRow(markSyntheticRow(row))).toBe(true) + expect(isSyntheticRow(row)).toBe(false) + }) +}) + +describe('isLoadingRow', () => { + it('says no for null, which a grid holds while it has nothing', () => { + expect(isLoadingRow(null)).toBe(false) + }) +}) diff --git a/src/lib/core/grid/row-node.ts b/src/lib/core/grid/row-node.ts index e1b990a..71c6de9 100644 --- a/src/lib/core/grid/row-node.ts +++ b/src/lib/core/grid/row-node.ts @@ -68,3 +68,42 @@ export function nodeIndexById(nodes: RowNode[]): ReadonlyMap | null)?.[LOADING_KEY]) +} + +export function isSyntheticRow(row: unknown): boolean { + return Boolean((row as Record | null)?.[SYNTHETIC_KEY]) +} + +/** Marks a copy, never the row it was handed: the caller's row is the data. */ +export function markSyntheticRow(row: TRow): TRow { + return { ...(row as object), [SYNTHETIC_KEY]: true } as TRow +} + +/** + * A node carrying real values from the data, which is what a feature reading + * cells means by "a row". A full-width row is excluded even when its row is + * real: it is drawn as one panel across the grid, so it has no cell to read. + */ +export function isDataRow(node: RowNode): boolean { + return node.meta?.fullWidth !== true && !isSyntheticRow(node.row) && !isLoadingRow(node.row) +} diff --git a/src/lib/core/grid/share-link.test.ts b/src/lib/core/grid/share-link.test.ts new file mode 100644 index 0000000..ce87f6d --- /dev/null +++ b/src/lib/core/grid/share-link.test.ts @@ -0,0 +1,104 @@ +import { SNAPSHOT_VERSION, type GridSnapshot } from '../types/index.js' +import { describe, expect, it } from 'vitest' +import { + canonicalJson, + decodeSnapshot, + encodeSnapshot, + sameSnapshot, + SHARE_LIMIT, + ShareTooLongError +} from './share-link.js' + +const base: GridSnapshot = { + version: SNAPSHOT_VERSION, + density: 'compact', + columns: { order: ['a', 'b'], hidden: { b: true } }, + features: { grouping: { by: ['dept'] } } +} + +describe('canonicalJson', () => { + it('reads two snapshots written in a different key order as the same', () => { + const one = { version: 1, density: 'compact', columns: { order: ['a'] } } as GridSnapshot + const two = { columns: { order: ['a'] }, density: 'compact', version: 1 } as GridSnapshot + expect(canonicalJson(one)).toBe(canonicalJson(two)) + expect(sameSnapshot(one, two)).toBe(true) + }) + + it('keeps array order, which carries meaning', () => { + const one = { version: 1, columns: { order: ['a', 'b'] } } as GridSnapshot + const two = { version: 1, columns: { order: ['b', 'a'] } } as GridSnapshot + expect(sameSnapshot(one, two)).toBe(false) + }) + + it('treats an absent key and an undefined one alike', () => { + const one = { version: 1, density: undefined } as GridSnapshot + expect(canonicalJson(one)).toBe(canonicalJson({ version: 1 })) + }) + + it('sees a difference nested inside a feature slice', () => { + expect(sameSnapshot(base, { ...base, features: { grouping: { by: ['country'] } } })).toBe( + false + ) + }) +}) + +describe('encodeSnapshot and decodeSnapshot', () => { + it('round-trips', async () => { + const token = await encodeSnapshot(base) + await expect(decodeSnapshot(token)).resolves.toEqual(base) + }) + + it('produces a token safe to drop in a URL', async () => { + const token = await encodeSnapshot(base) + expect(token).toMatch(/^[01][A-Za-z0-9_-]*$/) + expect(encodeURIComponent(token)).toBe(token) + }) + + it('compresses, so a repetitive state does not grow with its repetition', async () => { + const wide: GridSnapshot = { + version: SNAPSHOT_VERSION, + columns: { + hidden: Object.fromEntries( + Array.from({ length: 200 }, (_, i) => [`column_${i}`, true]) + ) + } + } + const token = await encodeSnapshot(wide) + expect(token.length).toBeLessThan(canonicalJson(wide).length / 4) + await expect(decodeSnapshot(token)).resolves.toEqual(wide) + }) + + it('refuses a state too big for a link instead of handing back a broken one', async () => { + const huge: GridSnapshot = { + version: SNAPSHOT_VERSION, + columns: { + widths: Object.fromEntries( + Array.from({ length: 40_000 }, (_, i) => [ + `${i}-${Math.random().toString(36).slice(2)}`, + i + ]) + ) + } + } + await expect(encodeSnapshot(huge)).rejects.toThrow(ShareTooLongError) + await expect(encodeSnapshot(huge)).rejects.toThrow(String(SHARE_LIMIT)) + }) + + it('reads nonsense as nothing rather than throwing', async () => { + for (const token of [ + '', + 'nope', + '9abc', + '1!!!!', + '0' + btoa('{'), + '1' + btoa('not gzip') + ]) { + await expect(decodeSnapshot(token)).resolves.toBeNull() + } + }) + + it('refuses a snapshot from a version it does not understand', async () => { + const future = await encodeSnapshot({ ...base, version: SNAPSHOT_VERSION + 1 }) + await expect(decodeSnapshot(future)).resolves.toBeNull() + }) +}) diff --git a/src/lib/core/grid/share-link.ts b/src/lib/core/grid/share-link.ts new file mode 100644 index 0000000..383fa59 --- /dev/null +++ b/src/lib/core/grid/share-link.ts @@ -0,0 +1,98 @@ +/** + * A whole grid state in a URL: the snapshot, canonicalized, deflated where the + * browser offers it, and spelled in base64url. + * + * Not to be confused with `snapshot.ts` next door, which builds and restores + * the snapshot itself. This file only carries one. + */ +import { SNAPSHOT_VERSION, type GridSnapshot } from '../types/index.js' +import { base64UrlToBytes, bytesToBase64Url } from '../utils/base64.js' + +export const SHARE_LIMIT = 1800 + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical) + if (value === null || typeof value !== 'object') return value + + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return Object.fromEntries(entries.map(([key, item]) => [key, canonical(item)])) +} + +export function canonicalJson(snapshot: GridSnapshot): string { + return JSON.stringify(canonical(snapshot)) +} + +export function sameSnapshot(a: GridSnapshot, b: GridSnapshot): boolean { + return canonicalJson(a) === canonicalJson(b) +} + +function isSnapshot(value: unknown): value is GridSnapshot { + return ( + typeof value === 'object' && + value !== null && + typeof (value as GridSnapshot).version === 'number' + ) +} + +async function deflate(bytes: Uint8Array): Promise | null> { + try { + const stream = new Blob([bytes as BlobPart]) + .stream() + .pipeThrough(new CompressionStream('deflate-raw')) + return new Uint8Array(await new Response(stream).arrayBuffer()) + } catch { + return null + } +} + +async function inflate(bytes: Uint8Array): Promise | null> { + try { + const stream = new Blob([bytes as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream('deflate-raw')) + return new Uint8Array(await new Response(stream).arrayBuffer()) + } catch { + return null + } +} + +export class ShareTooLongError extends Error { + readonly length: number + + constructor(length: number) { + super( + `The grid state encodes to ${length} characters, past the ${SHARE_LIMIT} a link can carry. ` + + 'Save it as a view and share the view instead.' + ) + this.name = 'ShareTooLongError' + this.length = length + } +} + +export async function encodeSnapshot(snapshot: GridSnapshot): Promise { + const json = new TextEncoder().encode(canonicalJson(snapshot)) + const packed = await deflate(json) + + const token = packed ? `1${bytesToBase64Url(packed)}` : `0${bytesToBase64Url(json)}` + if (token.length > SHARE_LIMIT) throw new ShareTooLongError(token.length) + return token +} + +export async function decodeSnapshot(token: string): Promise { + const marker = token[0] + if (marker !== '0' && marker !== '1') return null + + try { + const bytes = base64UrlToBytes(token.slice(1)) + const json = marker === '1' ? await inflate(bytes) : bytes + if (!json) return null + + const parsed: unknown = JSON.parse(new TextDecoder().decode(json)) + if (!isSnapshot(parsed)) return null + return parsed.version === SNAPSHOT_VERSION ? parsed : null + } catch { + return null + } +} diff --git a/src/lib/core/grid/value-gate.ts b/src/lib/core/grid/value-gate.ts index 1f2dd9d..c8f3388 100644 --- a/src/lib/core/grid/value-gate.ts +++ b/src/lib/core/grid/value-gate.ts @@ -1,11 +1,14 @@ import type { + CellValuePurpose, CellValueReader, CellValueScope, ColumnDef, + ColumnState, GridFeature, RowNode } from '../types/index.js' import { getCellValue } from '../utils/value.js' +import type { GridState } from './grid.svelte.js' /** * A cell's value on its way out of the grid. @@ -71,3 +74,40 @@ export function readerToken(reader: object | undefined): string { } return String(id) } + +/** Reads one cell of one column, however that column has to be read. */ +export type CellRead = (node: RowNode, column: ColumnDef) => unknown + +/** The raw value, past every gate. For a pass that is the data's own. */ +export function rawRead(node: RowNode, column: ColumnDef): unknown { + return getCellValue(node.row, column) +} + +/** + * A reader for one purpose, for the passes that walk many rows over many + * columns: aggregation, export, a fill, a find, the statistics behind a colour + * scale. + * + * `grid.getValue` needs the `ColumnState`, and looking that up per cell turns + * a column scan into a map lookup per cell. Resolving each column once per + * pass is the whole point of handing back a closure rather than exposing a + * function that takes the grid: a caller is meant to build one of these, use + * it for a pass, and drop it. + * + * A column the grid does not know falls through to the raw value rather than + * to nothing, so a pass over a `ColumnDef` a feature made up itself still + * reads. The `null` in the cache is what tells "looked up, not there" from + * "not looked up yet". + */ +export function gateReader(grid: GridState, purpose: CellValuePurpose): CellRead { + const states = new Map | null>() + + return (node, column) => { + let state = states.get(column.id) + if (state === undefined) { + state = grid.columns.get(column.id) ?? null + states.set(column.id, state) + } + return state ? grid.getValue(node, state, purpose) : getCellValue(node.row, column) + } +} diff --git a/src/lib/core/interaction/labels.ts b/src/lib/core/interaction/labels.ts index 3eb5cad..9aaf7bd 100644 --- a/src/lib/core/interaction/labels.ts +++ b/src/lib/core/interaction/labels.ts @@ -140,7 +140,187 @@ export const defaultLabels: DataGridLabels = { exportAllRows: 'All rows', exportLoadedRows: 'Loaded rows', exportSelectedRows: 'Selected rows', - clearSelection: 'Clear selection' + clearSelection: 'Clear selection', + + groupBy: 'Group by', + groupPanelEmpty: 'No grouping: add a column to group by', + addGroup: 'Add group', + clearGroups: 'Clear', + moveGroupEarlier: (column) => `Move ${column} earlier`, + moveGroupLater: (column) => `Move ${column} later`, + removeGroup: (column) => `Remove ${column} grouping`, + + groupByColumn: 'Group by this column', + ungroupColumn: 'Ungroup this column', + + groupFooter: (key) => `Total: ${key}`, + grandTotal: (count) => `Total (${count})`, + groupLoaded: (key, count) => `${key} (${count} loaded)`, + groupFooterLoaded: (key, count) => `Total - ${key} (${count} loaded)`, + grandTotalLoaded: (count) => `Total (${count} loaded)`, + + rangeCells: (count) => `${count} cells`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Sum', + rangeAvg: 'Avg', + rangeMin: 'Min', + rangeMax: 'Max', + + commandPaletteTitle: 'Command palette', + commandPalettePlaceholder: 'Type a command...', + commandPaletteEmpty: 'No matching commands', + commandGroupDefault: 'Commands', + + commandShowColumn: (column) => `Show column: ${column}`, + commandHideColumn: (column) => `Hide column: ${column}`, + commandUnpinColumn: (column) => `Unpin column: ${column}`, + commandPinColumnLeft: (column) => `Pin left: ${column}`, + commandPinColumnRight: (column) => `Pin right: ${column}`, + commandGroupByColumn: (column) => `Group by: ${column}`, + commandUngroupColumn: (column) => `Ungroup: ${column}`, + commandExpandAllGroups: 'Expand all groups', + commandCollapseAllGroups: 'Collapse all groups', + commandClearGrouping: 'Clear grouping', + commandCopyRange: 'Copy selected range', + commandFillDown: 'Fill down', + commandFillRight: 'Fill right', + commandGroupColumns: 'Columns', + commandGroupGrouping: 'Grouping', + commandGroupClipboard: 'Clipboard', + + findTitle: 'Find & replace', + findPlaceholder: 'Find in grid...', + replacePlaceholder: 'Replace with...', + findCaseSensitive: 'Match case', + findWholeCell: 'Match whole cell', + findPrevious: 'Previous match', + findNext: 'Next match', + findClose: 'Close find', + findReplaceOne: 'Replace', + findReplaceAll: 'Replace all', + commandFind: 'Find & replace', + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'No matches', + findNoWritable: (total) => `${total} found, none editable`, + findReplaced: (cells) => `Replaced ${cells}`, + findCountLoaded: (current, total) => `${current} / ${total} loaded`, + findNoMatchLoaded: 'No matches in loaded rows', + findServerReadOnly: 'Replace is off for server-loaded rows', + + viewsLabel: 'Views', + viewsEmpty: 'No saved views', + viewsPick: 'Pick a view', + viewNamePlaceholder: 'Name this view...', + viewSave: 'Save', + viewUpdate: 'Update', + viewRevert: 'Revert', + viewRemove: 'Delete', + viewShare: 'Copy link', + viewShareCopied: 'Link copied', + viewModified: 'edited', + commandSaveView: 'Save current view', + viewShareTooLong: (length) => `Too big to link (${length} chars): save it as a view`, + + formatLabel: 'Formatting', + formatEmpty: 'No formatting rules', + formatAdd: 'Add rule', + formatClear: 'Clear', + formatColumn: 'Column', + formatKind: 'Rule', + formatKindColorScale: 'Colour scale', + formatKindDataBar: 'Data bar', + formatKindDuplicates: 'Duplicates', + formatKindTopN: 'Top values', + formatKindExpression: 'Expression', + formatTopCount: 'How many', + formatExpressionPlaceholder: 'status = "overdue"', + formatWholeRow: 'Whole row', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Remove ${rule}`, + formatBadExpression: (message) => `Will not parse: ${message}`, + + toolPanelTitle: 'Tools', + toolPanelColumns: 'Columns', + toolPanelGroup: 'Groups', + toolPanelValues: 'Values', + toolPanelSearch: 'Search columns...', + toolPanelShowAll: 'Show all', + toolPanelHideAll: 'Hide all', + toolPanelEmpty: 'No column matches', + toolPanelMoveUp: (column) => `Move ${column} earlier`, + toolPanelMoveDown: (column) => `Move ${column} later`, + toolPanelNoAggregation: 'None', + toolPanelActions: (column) => `Column actions: ${column}`, + toolPanelCollapse: 'Collapse panel', + toolPanelExpand: 'Expand panel', + + filterTitle: 'Filter', + filterEmpty: 'No conditions yet', + filterAddCondition: 'Add condition', + filterAddGroup: 'Add group', + filterRemove: 'Remove', + filterJoin: (join) => (join === 'and' ? 'AND' : 'OR'), + filterNot: 'NOT', + filterBuilderColumn: 'Column', + filterBuilderOperator: 'Operator', + filterBuilderValue: 'Value', + filterValueTo: 'To', + filterTrue: 'True', + filterFalse: 'False', + filterOp: (op) => + ( + ({ + contains: 'contains', + notContains: 'does not contain', + equals: 'equals', + notEqual: 'does not equal', + startsWith: 'starts with', + endsWith: 'ends with', + gt: 'greater than', + gte: 'greater or equal', + lt: 'less than', + lte: 'less or equal', + between: 'between', + before: 'before', + after: 'after', + in: 'one of', + blank: 'is blank', + notBlank: 'is not blank' + }) as Record + )[op] ?? op, + filterServerHandled: 'Answered by your backend', + + importTitle: 'Import', + importDropHere: 'Drop a spreadsheet here', + importChooseFile: 'Choose a file', + importPaste: 'Paste from clipboard', + importEmpty: 'That file holds no rows', + importUnknownFormat: (name) => `${name} is not a shape this reads`, + importHeaderRow: 'First row names columns', + importSheet: 'Sheet', + importColumnFrom: 'Source column', + importSkipColumn: 'Do not import', + importMatchTitle: 'Match columns', + importReviewTitle: 'Check before adding', + importBack: 'Back', + importNext: 'Check', + importCancel: 'Cancel', + importAddRows: (count) => `Add all (${count})`, + importAddValid: (count) => `Add ready (${count})`, + importIssues: (count) => `To check: ${count}`, + importReady: 'Every row is ready', + importNotNumber: (header) => `${header} is not a number`, + importNotDate: (header) => `${header} is not a date`, + importNotBoolean: (header) => `${header} is not a yes or a no`, + importInvalid: (header) => `${header} was refused`, + importDuplicate: (key) => `${key} is in this file more than once`, + importExisting: (key) => `${key} is already in this grid`, + importServerRefusal: 'A server owns these rows, so an import cannot stage them here', + importRowCount: (count) => `${count} rows to read`, + importTooBig: (name, megabytes) => `${name} is over a limit of ${megabytes} MB`, + importMatched: (matched, total) => `${matched} of ${total} columns matched`, + importNothingMatched: + 'Nothing matched. Pick a source column for each one you want, or check that first row is a header' } /** Overrides over a base language; operator maps merge one entry at a time. */ diff --git a/src/lib/core/types/labels.ts b/src/lib/core/types/labels.ts index 8e20aac..c19be15 100644 --- a/src/lib/core/types/labels.ts +++ b/src/lib/core/types/labels.ts @@ -95,6 +95,171 @@ export interface DataGridLabels { exportLoadedRows: string exportSelectedRows: string clearSelection: string + + groupBy: string + groupPanelEmpty: string + addGroup: string + clearGroups: string + moveGroupEarlier: (column: string) => string + moveGroupLater: (column: string) => string + removeGroup: (column: string) => string + + groupByColumn: string + ungroupColumn: string + + groupFooter: (key: string, count: number) => string + grandTotal: (count: number) => string + + groupLoaded: (key: string, count: number) => string + groupFooterLoaded: (key: string, count: number) => string + grandTotalLoaded: (count: number) => string + + rangeCells: (count: string) => string + rangeShape: (rows: number, columns: number) => string + rangeSum: string + rangeAvg: string + rangeMin: string + rangeMax: string + + commandPaletteTitle: string + commandPalettePlaceholder: string + commandPaletteEmpty: string + commandGroupDefault: string + + commandShowColumn: (column: string) => string + commandHideColumn: (column: string) => string + commandUnpinColumn: (column: string) => string + commandPinColumnLeft: (column: string) => string + commandPinColumnRight: (column: string) => string + commandGroupByColumn: (column: string) => string + commandUngroupColumn: (column: string) => string + commandExpandAllGroups: string + commandCollapseAllGroups: string + commandClearGrouping: string + commandCopyRange: string + commandFillDown: string + commandFillRight: string + commandGroupColumns: string + commandGroupGrouping: string + commandGroupClipboard: string + + findTitle: string + findPlaceholder: string + replacePlaceholder: string + findCaseSensitive: string + findWholeCell: string + findPrevious: string + findNext: string + findClose: string + findReplaceOne: string + findReplaceAll: string + commandFind: string + + findCount: (current: number, total: number) => string + findNoMatch: string + + findNoWritable: (total: number) => string + + findReplaced: (cells: number) => string + + findCountLoaded: (current: number, total: number) => string + findNoMatchLoaded: string + + findServerReadOnly: string + + viewsLabel: string + viewsEmpty: string + viewsPick: string + viewNamePlaceholder: string + viewSave: string + viewUpdate: string + viewRevert: string + viewRemove: string + viewShare: string + viewShareCopied: string + viewModified: string + commandSaveView: string + viewShareTooLong: (length: number) => string + + formatLabel: string + formatEmpty: string + formatAdd: string + formatClear: string + formatColumn: string + formatKind: string + formatKindColorScale: string + formatKindDataBar: string + formatKindDuplicates: string + formatKindTopN: string + formatKindExpression: string + formatTopCount: string + formatExpressionPlaceholder: string + formatWholeRow: string + formatRuleName: (kind: string, column: string) => string + formatRemoveRule: (rule: string) => string + formatBadExpression: (message: string) => string + + toolPanelTitle: string + toolPanelColumns: string + toolPanelGroup: string + toolPanelValues: string + toolPanelSearch: string + toolPanelShowAll: string + toolPanelHideAll: string + toolPanelEmpty: string + toolPanelMoveUp: (column: string) => string + toolPanelMoveDown: (column: string) => string + toolPanelNoAggregation: string + toolPanelActions: (column: string) => string + toolPanelCollapse: string + toolPanelExpand: string + + filterTitle: string + filterEmpty: string + filterAddCondition: string + filterAddGroup: string + filterRemove: string + filterJoin: (join: 'and' | 'or') => string + filterNot: string + filterBuilderColumn: string + filterBuilderOperator: string + filterBuilderValue: string + filterValueTo: string + filterTrue: string + filterFalse: string + filterOp: (op: string) => string + filterServerHandled: string + + importTitle: string + importDropHere: string + importChooseFile: string + importPaste: string + importEmpty: string + importUnknownFormat: (name: string) => string + importHeaderRow: string + importSheet: string + importColumnFrom: string + importSkipColumn: string + importMatchTitle: string + importReviewTitle: string + importBack: string + importNext: string + importCancel: string + importAddRows: (count: number) => string + importAddValid: (count: number) => string + importIssues: (count: number) => string + importReady: string + importNotNumber: (header: string) => string + importNotDate: (header: string) => string + importNotBoolean: (header: string) => string + importInvalid: (header: string) => string + importDuplicate: (key: string) => string + importExisting: (key: string) => string + importServerRefusal: string + importRowCount: (count: number) => string + importTooBig: (name: string, megabytes: number) => string + importMatched: (matched: number, total: number) => string + importNothingMatched: string } /** Any subset; operator maps override one entry at a time. */ diff --git a/src/lib/core/utils/base64.ts b/src/lib/core/utils/base64.ts new file mode 100644 index 0000000..4a2e69a --- /dev/null +++ b/src/lib/core/utils/base64.ts @@ -0,0 +1,18 @@ +/** + * Base64url, the alphabet a URL carries without escaping: `+` and `/` become + * `-` and `_`, and the padding goes, so a shared grid link survives being + * pasted into a chat window and back out again. + */ +export function bytesToBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +export function base64UrlToBytes(value: string): Uint8Array { + const padded = value.replaceAll('-', '+').replaceAll('_', '/') + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '=')) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes +} diff --git a/src/lib/core/utils/index.ts b/src/lib/core/utils/index.ts index 3e6e4b8..68426f9 100644 --- a/src/lib/core/utils/index.ts +++ b/src/lib/core/utils/index.ts @@ -19,8 +19,10 @@ export { toNumber, type FormatOptions } from './format.js' +export { base64UrlToBytes, bytesToBase64Url } from './base64.js' export { emptyIdSet, idSetOf, idSetWith, idSetWithout } from './id-set.js' export { clamp } from './math.js' +export { numericOrNull } from './number.js' export { isInPortal, popupOpen } from './popup.js' export { rafBatch } from './raf-batch.js' export { mutator } from './reactivity.js' diff --git a/src/lib/core/utils/number.test.ts b/src/lib/core/utils/number.test.ts new file mode 100644 index 0000000..e71cbe6 --- /dev/null +++ b/src/lib/core/utils/number.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { numericOrNull } from './number.js' + +describe('reading a number out of whatever a row holds', () => { + it('takes the numbers', () => { + expect(numericOrNull(42)).toBe(42) + expect(numericOrNull(-0)).toBe(-0) + expect(numericOrNull('21')).toBe(21) + expect(numericOrNull(true)).toBe(1) + }) + + it('refuses what is not a number rather than passing NaN on', () => { + expect(numericOrNull(null)).toBeNull() + expect(numericOrNull(undefined)).toBeNull() + expect(numericOrNull('')).toBeNull() + expect(numericOrNull(NaN)).toBeNull() + expect(numericOrNull('abc')).toBeNull() + expect(numericOrNull({})).toBeNull() + expect(numericOrNull(new Map())).toBeNull() + }) + + it('survives the values that make Number() throw', () => { + expect(() => numericOrNull(Symbol('s'))).not.toThrow() + expect(numericOrNull(Symbol('s'))).toBeNull() + }) + + it('survives an object whose own valueOf throws', () => { + const hostile = { + valueOf() { + throw new Error('no') + } + } + expect(() => numericOrNull(hostile)).not.toThrow() + expect(numericOrNull(hostile)).toBeNull() + }) + + it('keeps Infinity, which is a number a sum can carry', () => { + expect(numericOrNull(Infinity)).toBe(Infinity) + }) +}) + +describe('what is not a number', () => { + it('refuses whitespace, which Number() would call zero', () => { + expect(numericOrNull(' ')).toBeNull() + expect(numericOrNull('\t\n')).toBeNull() + }) + + it('refuses an array or an object, which Number() would also call zero', () => { + expect(numericOrNull([])).toBeNull() + expect(numericOrNull([5])).toBeNull() + expect(numericOrNull({})).toBeNull() + }) + + it('still reads a Date as its instant', () => { + expect(numericOrNull(new Date('2026-01-01T00:00:00.000Z'))).toBe(1767225600000) + }) +}) diff --git a/src/lib/core/utils/number.ts b/src/lib/core/utils/number.ts new file mode 100644 index 0000000..11eb3fa --- /dev/null +++ b/src/lib/core/utils/number.ts @@ -0,0 +1,32 @@ +/** + * Coercion for the passes that do arithmetic over a column: aggregation, the + * statistics a colour scale reads, a data bar's width, a range's sum. + * + * Deliberately not `toNumber` from `format.js`, which answers the narrower + * question a formatter asks. Two differences earn the second function: + * + * - A `Date` is a number here and is not there. Min, max and count over a date + * column are ordinary things to ask for, and a formatter has no reason to + * read one as a timestamp. + * - An array or a plain object answers `null` rather than falling through to + * `Number(value)`, which reads `[]` as 0 and `[7]` as 7. A cell holding a + * list is not a zero, and a sum that quietly counted one would be wrong in a + * way nobody would look for. + */ +function isNumericInput(value: unknown): boolean { + if (value === null || value === undefined || value === '') return false + if (typeof value === 'string') return value.trim() !== '' + return typeof value !== 'object' || value instanceof Date +} + +export function numericOrNull(value: unknown): number | null { + if (typeof value === 'number') return Number.isNaN(value) ? null : value + if (!isNumericInput(value)) return null + + try { + const parsed = Number(value) + return Number.isNaN(parsed) ? null : parsed + } catch { + return null + } +} diff --git a/src/lib/locales/de-DE.ts b/src/lib/locales/de-DE.ts index 2b7641a..fbdede9 100644 --- a/src/lib/locales/de-DE.ts +++ b/src/lib/locales/de-DE.ts @@ -107,7 +107,191 @@ export const deDE: DataGridLocalePack = { exportAllRows: 'Alle Zeilen', exportLoadedRows: 'Geladene Zeilen', exportSelectedRows: 'Ausgewählte Zeilen', - clearSelection: 'Auswahl aufheben' + clearSelection: 'Auswahl aufheben', + + groupBy: 'Gruppieren nach', + groupPanelEmpty: 'Keine Gruppierung: Spalte zum Gruppieren hinzufügen', + addGroup: 'Gruppe hinzufügen', + clearGroups: 'Löschen', + moveGroupEarlier: (column) => `${column} nach vorne`, + moveGroupLater: (column) => `${column} nach hinten`, + removeGroup: (column) => `Gruppierung ${column} entfernen`, + + groupByColumn: 'Nach dieser Spalte gruppieren', + ungroupColumn: 'Gruppierung dieser Spalte aufheben', + + groupFooter: (key) => `Summe: ${key}`, + grandTotal: (count) => `Gesamt (${count})`, + groupLoaded: (key, count) => `${key} (${count} geladen)`, + groupFooterLoaded: (key, count) => `Summe - ${key} (${count} geladen)`, + grandTotalLoaded: (count) => `Gesamt (${count} geladen)`, + + rangeCells: (count) => `${count} Zellen`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Summe', + rangeAvg: 'Mittel', + rangeMin: 'Min', + rangeMax: 'Max', + + commandPaletteTitle: 'Befehlspalette', + commandPalettePlaceholder: 'Befehl eingeben...', + commandPaletteEmpty: 'Keine passenden Befehle', + commandGroupDefault: 'Befehle', + + commandShowColumn: (column) => `Spalte einblenden: ${column}`, + commandHideColumn: (column) => `Spalte ausblenden: ${column}`, + commandUnpinColumn: (column) => `Spalte lösen: ${column}`, + commandPinColumnLeft: (column) => `Links anheften: ${column}`, + commandPinColumnRight: (column) => `Rechts anheften: ${column}`, + commandGroupByColumn: (column) => `Gruppieren nach: ${column}`, + commandUngroupColumn: (column) => `Gruppierung aufheben: ${column}`, + commandExpandAllGroups: 'Alle Gruppen aufklappen', + commandCollapseAllGroups: 'Alle Gruppen zuklappen', + commandClearGrouping: 'Gruppierung aufheben', + commandCopyRange: 'Ausgewählten Bereich kopieren', + commandFillDown: 'Nach unten ausfüllen', + commandFillRight: 'Nach rechts ausfüllen', + commandGroupColumns: 'Spalten', + commandGroupGrouping: 'Gruppierung', + commandGroupClipboard: 'Zwischenablage', + + findTitle: 'Suchen und ersetzen', + findPlaceholder: 'Im Raster suchen...', + replacePlaceholder: 'Ersetzen durch...', + findCaseSensitive: 'Groß-/Kleinschreibung beachten', + findWholeCell: 'Ganze Zelle vergleichen', + findPrevious: 'Vorheriger Treffer', + findNext: 'Nächster Treffer', + findClose: 'Suche schließen', + findReplaceOne: 'Ersetzen', + findReplaceAll: 'Alle ersetzen', + commandFind: 'Suchen und ersetzen', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Keine Treffer', + findNoWritable: (total) => `${total} gefunden, keiner bearbeitbar`, + findReplaced: (cells) => `${cells} ersetzt`, + findCountLoaded: (current, total) => `${current} / ${total} geladen`, + findNoMatchLoaded: 'Keine Treffer in den geladenen Zeilen', + findServerReadOnly: 'Ersetzen ist für servergeladene Zeilen deaktiviert', + + viewsLabel: 'Ansichten', + viewsEmpty: 'Keine gespeicherten Ansichten', + viewsPick: 'Ansicht wählen', + viewNamePlaceholder: 'Ansicht benennen...', + viewSave: 'Speichern', + viewUpdate: 'Aktualisieren', + viewRevert: 'Verwerfen', + viewRemove: 'Löschen', + viewShare: 'Link kopieren', + viewShareCopied: 'Link kopiert', + viewModified: 'geändert', + commandSaveView: 'Aktuelle Ansicht speichern', + viewShareTooLong: (length) => + `Zu groß für einen Link (${length} Zeichen): als Ansicht speichern`, + + formatLabel: 'Formatierung', + formatEmpty: 'Keine Formatierungsregeln', + formatAdd: 'Regel hinzufügen', + formatClear: 'Löschen', + formatColumn: 'Spalte', + formatKind: 'Regel', + formatKindColorScale: 'Farbskala', + formatKindDataBar: 'Datenbalken', + formatKindDuplicates: 'Doppelte Werte', + formatKindTopN: 'Obere Werte', + formatKindExpression: 'Ausdruck', + formatTopCount: 'Wie viele', + formatExpressionPlaceholder: 'status = "überfällig"', + formatWholeRow: 'Ganze Zeile', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `${rule} entfernen`, + formatBadExpression: (message) => `Nicht lesbar: ${message}`, + + toolPanelTitle: 'Werkzeuge', + toolPanelColumns: 'Spalten', + toolPanelGroup: 'Gruppen', + toolPanelValues: 'Werte', + toolPanelSearch: 'Spalten suchen...', + toolPanelShowAll: 'Alle anzeigen', + toolPanelHideAll: 'Alle ausblenden', + toolPanelEmpty: 'Keine Spalte passt', + toolPanelMoveUp: (column) => `${column} nach vorne`, + toolPanelMoveDown: (column) => `${column} nach hinten`, + toolPanelNoAggregation: 'Keine', + toolPanelActions: (column) => `Spaltenaktionen: ${column}`, + toolPanelCollapse: 'Leiste einklappen', + toolPanelExpand: 'Leiste ausklappen', + + filterTitle: 'Filter', + filterEmpty: 'Noch keine Bedingung', + filterAddCondition: 'Bedingung hinzufügen', + filterAddGroup: 'Gruppe hinzufügen', + filterRemove: 'Entfernen', + filterJoin: (join) => (join === 'and' ? 'UND' : 'ODER'), + filterNot: 'NICHT', + filterBuilderColumn: 'Spalte', + filterBuilderOperator: 'Operator', + filterBuilderValue: 'Wert', + filterValueTo: 'Bis', + filterTrue: 'Wahr', + filterFalse: 'Falsch', + filterOp: (op) => + ( + ({ + contains: 'enthält', + notContains: 'enthält nicht', + equals: 'gleich', + notEqual: 'ungleich', + startsWith: 'beginnt mit', + endsWith: 'endet mit', + gt: 'größer als', + gte: 'größer oder gleich', + lt: 'kleiner als', + lte: 'kleiner oder gleich', + between: 'zwischen', + before: 'vor', + after: 'nach', + in: 'eines von', + blank: 'ist leer', + notBlank: 'ist nicht leer' + }) as Record + )[op] ?? op, + filterServerHandled: 'An den Server gesendet', + + importTitle: 'Importieren', + importDropHere: 'Tabelle hierher ziehen', + importChooseFile: 'Datei wählen', + importPaste: 'Aus der Zwischenablage einfügen', + importEmpty: 'Diese Datei enthält keine Zeilen', + importUnknownFormat: (name) => `${name} ist keine lesbare Form`, + importHeaderRow: 'Erste Zeile benennt die Spalten', + importSheet: 'Blatt', + importColumnFrom: 'Quellspalte', + importSkipColumn: 'Nicht importieren', + importMatchTitle: 'Spalten zuordnen', + importReviewTitle: 'Vor dem Hinzufügen prüfen', + importBack: 'Zurück', + importNext: 'Prüfen', + importCancel: 'Abbrechen', + importAddRows: (count) => `Alle hinzufügen (${count})`, + importAddValid: (count) => `Nur die bereiten (${count})`, + importIssues: (count) => `Zu prüfen: ${count}`, + importReady: 'Jede Zeile ist bereit', + importNotNumber: (header) => `${header} ist keine Zahl`, + importNotDate: (header) => `${header} ist kein Datum`, + importNotBoolean: (header) => `${header} ist kein Ja oder Nein`, + importInvalid: (header) => `${header} wurde abgelehnt`, + importDuplicate: (key) => `${key} kommt in dieser Datei mehrfach vor`, + importExisting: (key) => `${key} ist bereits in der Tabelle`, + importServerRefusal: + 'Diese Zeilen gehören einem Server; ein Import kann sie hier nicht vorbereiten', + importRowCount: (count) => `${count} Zeilen zu lesen`, + importTooBig: (name, megabytes) => + `${name} ist größer als die ${megabytes} MB, die hier geöffnet werden`, + importMatched: (matched, total) => `${matched} von ${total} Spalten zugeordnet`, + importNothingMatched: + 'Nichts zugeordnet. Wähle je Spalte eine Quelle, oder prüfe, ob die erste Zeile eine Kopfzeile ist' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/es-ES.ts b/src/lib/locales/es-ES.ts index 9d0f028..fd99c09 100644 --- a/src/lib/locales/es-ES.ts +++ b/src/lib/locales/es-ES.ts @@ -113,7 +113,189 @@ export const esES: DataGridLocalePack = { exportAllRows: 'Todas las filas', exportLoadedRows: 'Filas cargadas', exportSelectedRows: 'Filas seleccionadas', - clearSelection: 'Quitar la selección' + clearSelection: 'Quitar la selección', + + groupBy: 'Agrupar por', + groupPanelEmpty: 'Sin agrupación: añade una columna para agrupar', + addGroup: 'Añadir grupo', + clearGroups: 'Borrar', + moveGroupEarlier: (column) => `Mover ${column} antes`, + moveGroupLater: (column) => `Mover ${column} después`, + removeGroup: (column) => `Quitar la agrupación por ${column}`, + + groupByColumn: 'Agrupar por esta columna', + ungroupColumn: 'Desagrupar esta columna', + + groupFooter: (key) => `Total: ${key}`, + grandTotal: (count) => `Total (${count})`, + groupLoaded: (key, count) => `${key} (${count} cargadas)`, + groupFooterLoaded: (key, count) => `Total - ${key} (${count} cargadas)`, + grandTotalLoaded: (count) => `Total (${count} cargadas)`, + + rangeCells: (count) => `${count} celdas`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Suma', + rangeAvg: 'Media', + rangeMin: 'Mín', + rangeMax: 'Máx', + + commandPaletteTitle: 'Paleta de comandos', + commandPalettePlaceholder: 'Escribe un comando...', + commandPaletteEmpty: 'Ningún comando coincide', + commandGroupDefault: 'Comandos', + + commandShowColumn: (column) => `Mostrar la columna: ${column}`, + commandHideColumn: (column) => `Ocultar la columna: ${column}`, + commandUnpinColumn: (column) => `Dejar de fijar: ${column}`, + commandPinColumnLeft: (column) => `Fijar a la izquierda: ${column}`, + commandPinColumnRight: (column) => `Fijar a la derecha: ${column}`, + commandGroupByColumn: (column) => `Agrupar por: ${column}`, + commandUngroupColumn: (column) => `Desagrupar: ${column}`, + commandExpandAllGroups: 'Expandir todos los grupos', + commandCollapseAllGroups: 'Contraer todos los grupos', + commandClearGrouping: 'Quitar la agrupación', + commandCopyRange: 'Copiar el rango seleccionado', + commandFillDown: 'Rellenar hacia abajo', + commandFillRight: 'Rellenar hacia la derecha', + commandGroupColumns: 'Columnas', + commandGroupGrouping: 'Agrupación', + commandGroupClipboard: 'Portapapeles', + + findTitle: 'Buscar y reemplazar', + findPlaceholder: 'Buscar en la cuadrícula...', + replacePlaceholder: 'Reemplazar con...', + findCaseSensitive: 'Distinguir mayúsculas', + findWholeCell: 'Coincidir con toda la celda', + findPrevious: 'Coincidencia anterior', + findNext: 'Coincidencia siguiente', + findClose: 'Cerrar la búsqueda', + findReplaceOne: 'Reemplazar', + findReplaceAll: 'Reemplazar todo', + commandFind: 'Buscar y reemplazar', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Sin coincidencias', + findNoWritable: (total) => `${total} encontradas, ninguna editable`, + findReplaced: (cells) => `${cells} reemplazadas`, + findCountLoaded: (current, total) => `${current} / ${total} cargadas`, + findNoMatchLoaded: 'Sin coincidencias en las filas cargadas', + findServerReadOnly: 'Reemplazar está desactivado para las filas del servidor', + + viewsLabel: 'Vistas', + viewsEmpty: 'No hay vistas guardadas', + viewsPick: 'Elegir una vista', + viewNamePlaceholder: 'Nombra esta vista...', + viewSave: 'Guardar', + viewUpdate: 'Actualizar', + viewRevert: 'Descartar', + viewRemove: 'Eliminar', + viewShare: 'Copiar el enlace', + viewShareCopied: 'Enlace copiado', + viewModified: 'editada', + commandSaveView: 'Guardar la vista actual', + viewShareTooLong: (length) => + `Demasiado grande para un enlace (${length} caracteres): guárdala como vista`, + + formatLabel: 'Formato', + formatEmpty: 'Sin reglas de formato', + formatAdd: 'Añadir regla', + formatClear: 'Borrar', + formatColumn: 'Columna', + formatKind: 'Regla', + formatKindColorScale: 'Escala de color', + formatKindDataBar: 'Barra de datos', + formatKindDuplicates: 'Valores duplicados', + formatKindTopN: 'Valores más altos', + formatKindExpression: 'Expresión', + formatTopCount: 'Cuántos', + formatExpressionPlaceholder: 'status = "vencido"', + formatWholeRow: 'Fila completa', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Quitar ${rule}`, + formatBadExpression: (message) => `No se puede interpretar: ${message}`, + + toolPanelTitle: 'Herramientas', + toolPanelColumns: 'Columnas', + toolPanelGroup: 'Grupos', + toolPanelValues: 'Valores', + toolPanelSearch: 'Buscar columnas...', + toolPanelShowAll: 'Mostrar todas', + toolPanelHideAll: 'Ocultar todas', + toolPanelEmpty: 'Ninguna columna coincide', + toolPanelMoveUp: (column) => `Mover ${column} antes`, + toolPanelMoveDown: (column) => `Mover ${column} despues`, + toolPanelNoAggregation: 'Ninguna', + toolPanelActions: (column) => `Acciones de columna: ${column}`, + toolPanelCollapse: 'Contraer panel', + toolPanelExpand: 'Expandir panel', + + filterTitle: 'Filtro', + filterEmpty: 'Sin condiciones', + filterAddCondition: 'Anadir condicion', + filterAddGroup: 'Anadir grupo', + filterRemove: 'Quitar', + filterJoin: (join) => (join === 'and' ? 'Y' : 'O'), + filterNot: 'NO', + filterBuilderColumn: 'Columna', + filterBuilderOperator: 'Operador', + filterBuilderValue: 'Valor', + filterValueTo: 'Hasta', + filterTrue: 'Verdadero', + filterFalse: 'Falso', + filterOp: (op) => + ( + ({ + contains: 'contiene', + notContains: 'no contiene', + equals: 'igual a', + notEqual: 'distinto de', + startsWith: 'empieza por', + endsWith: 'termina en', + gt: 'mayor que', + gte: 'mayor o igual', + lt: 'menor que', + lte: 'menor o igual', + between: 'entre', + before: 'antes de', + after: 'despues de', + in: 'uno de', + blank: 'esta vacio', + notBlank: 'no esta vacio' + }) as Record + )[op] ?? op, + filterServerHandled: 'Enviado al servidor', + + importTitle: 'Importar', + importDropHere: 'Suelta una hoja aquí', + importChooseFile: 'Elegir un archivo', + importPaste: 'Pegar desde el portapapeles', + importEmpty: 'Ese archivo no tiene filas', + importUnknownFormat: (name) => `${name} no es una forma legible`, + importHeaderRow: 'La primera fila nombra las columnas', + importSheet: 'Hoja', + importColumnFrom: 'Columna de origen', + importSkipColumn: 'No importar', + importMatchTitle: 'Emparejar las columnas', + importReviewTitle: 'Revisar antes de añadir', + importBack: 'Atrás', + importNext: 'Revisar', + importCancel: 'Cancelar', + importAddRows: (count) => `Añadir todas (${count})`, + importAddValid: (count) => `Añadir las listas (${count})`, + importIssues: (count) => `Por revisar: ${count}`, + importReady: 'Todas las filas están listas', + importNotNumber: (header) => `${header} no es un número`, + importNotDate: (header) => `${header} no es una fecha`, + importNotBoolean: (header) => `${header} no es un sí o un no`, + importInvalid: (header) => `${header} fue rechazado`, + importDuplicate: (key) => `${key} aparece más de una vez en este archivo`, + importExisting: (key) => `${key} ya está en la tabla`, + importServerRefusal: 'Un servidor posee estas filas, así que no se pueden preparar aquí', + importRowCount: (count) => `${count} filas por leer`, + importTooBig: (name, megabytes) => `${name} supera los ${megabytes} MB que se pueden abrir`, + importMatched: (matched, total) => `${matched} de ${total} columnas emparejadas`, + importNothingMatched: + 'Nada emparejado. Elige una columna de origen para cada una, o revisa si la primera fila es cabecera' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/fr-FR.ts b/src/lib/locales/fr-FR.ts index db948a0..2f296a4 100644 --- a/src/lib/locales/fr-FR.ts +++ b/src/lib/locales/fr-FR.ts @@ -113,7 +113,190 @@ export const frFR: DataGridLocalePack = { exportAllRows: 'Toutes les lignes', exportLoadedRows: 'Lignes chargées', exportSelectedRows: 'Lignes sélectionnées', - clearSelection: 'Annuler la sélection' + clearSelection: 'Annuler la sélection', + + groupBy: 'Grouper par', + groupPanelEmpty: 'Aucun groupement: ajoutez une colonne à grouper', + addGroup: 'Ajouter un groupe', + clearGroups: 'Effacer', + moveGroupEarlier: (column) => `Déplacer ${column} avant`, + moveGroupLater: (column) => `Déplacer ${column} après`, + removeGroup: (column) => `Retirer le groupement ${column}`, + + groupByColumn: 'Grouper par cette colonne', + ungroupColumn: 'Dégrouper cette colonne', + + groupFooter: (key) => `Total: ${key}`, + grandTotal: (count) => `Total (${count})`, + groupLoaded: (key, count) => `${key} (${count} chargees)`, + groupFooterLoaded: (key, count) => `Total - ${key} (${count} chargees)`, + grandTotalLoaded: (count) => `Total (${count} chargees)`, + + rangeCells: (count) => `${count} cellules`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Somme', + rangeAvg: 'Moy', + rangeMin: 'Min', + rangeMax: 'Max', + + commandPaletteTitle: 'Palette de commandes', + commandPalettePlaceholder: 'Tapez une commande...', + commandPaletteEmpty: 'Aucune commande correspondante', + commandGroupDefault: 'Commandes', + + commandShowColumn: (column) => `Afficher la colonne : ${column}`, + commandHideColumn: (column) => `Masquer la colonne : ${column}`, + commandUnpinColumn: (column) => `Détacher : ${column}`, + commandPinColumnLeft: (column) => `Épingler à gauche : ${column}`, + commandPinColumnRight: (column) => `Épingler à droite : ${column}`, + commandGroupByColumn: (column) => `Grouper par : ${column}`, + commandUngroupColumn: (column) => `Dégrouper : ${column}`, + commandExpandAllGroups: 'Développer tous les groupes', + commandCollapseAllGroups: 'Réduire tous les groupes', + commandClearGrouping: 'Effacer le groupement', + commandCopyRange: 'Copier la plage sélectionnée', + commandFillDown: 'Recopier vers le bas', + commandFillRight: 'Recopier vers la droite', + commandGroupColumns: 'Colonnes', + commandGroupGrouping: 'Groupement', + commandGroupClipboard: 'Presse-papiers', + + findTitle: 'Rechercher et remplacer', + findPlaceholder: 'Rechercher dans la grille...', + replacePlaceholder: 'Remplacer par...', + findCaseSensitive: 'Respecter la casse', + findWholeCell: 'Cellule entière', + findPrevious: 'Occurrence précédente', + findNext: 'Occurrence suivante', + findClose: 'Fermer la recherche', + findReplaceOne: 'Remplacer', + findReplaceAll: 'Tout remplacer', + commandFind: 'Rechercher et remplacer', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Aucune occurrence', + findNoWritable: (total) => `${total} trouvées, aucune modifiable`, + findReplaced: (cells) => `${cells} remplacées`, + findCountLoaded: (current, total) => `${current} / ${total} chargées`, + findNoMatchLoaded: 'Aucune occurrence dans les lignes chargées', + findServerReadOnly: 'Le remplacement est désactivé pour les lignes du serveur', + + viewsLabel: 'Vues', + viewsEmpty: 'Aucune vue enregistrée', + viewsPick: 'Choisir une vue', + viewNamePlaceholder: 'Nommer cette vue...', + viewSave: 'Enregistrer', + viewUpdate: 'Mettre à jour', + viewRevert: 'Rétablir', + viewRemove: 'Supprimer', + viewShare: 'Copier le lien', + viewShareCopied: 'Lien copié', + viewModified: 'modifiée', + commandSaveView: 'Enregistrer la vue actuelle', + viewShareTooLong: (length) => + `Trop volumineux pour un lien (${length} caractères): enregistrez-la comme vue`, + + formatLabel: 'Mise en forme', + formatEmpty: 'Aucune règle de mise en forme', + formatAdd: 'Ajouter une règle', + formatClear: 'Effacer', + formatColumn: 'Colonne', + formatKind: 'Règle', + formatKindColorScale: 'Nuances de couleurs', + formatKindDataBar: 'Barres de données', + formatKindDuplicates: 'Valeurs en double', + formatKindTopN: 'Valeurs les plus élevées', + formatKindExpression: 'Expression', + formatTopCount: 'Combien', + formatExpressionPlaceholder: 'status = "en retard"', + formatWholeRow: 'Ligne entière', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Retirer ${rule}`, + formatBadExpression: (message) => `Illisible : ${message}`, + + toolPanelTitle: 'Outils', + toolPanelColumns: 'Colonnes', + toolPanelGroup: 'Groupes', + toolPanelValues: 'Valeurs', + toolPanelSearch: 'Rechercher des colonnes...', + toolPanelShowAll: 'Tout afficher', + toolPanelHideAll: 'Tout masquer', + toolPanelEmpty: 'Aucune colonne ne correspond', + toolPanelMoveUp: (column) => `Deplacer ${column} avant`, + toolPanelMoveDown: (column) => `Deplacer ${column} apres`, + toolPanelNoAggregation: 'Aucune', + toolPanelActions: (column) => `Actions de colonne : ${column}`, + toolPanelCollapse: 'Reduire le panneau', + toolPanelExpand: 'Deplier le panneau', + + filterTitle: 'Filtre', + filterEmpty: 'Aucune condition', + filterAddCondition: 'Ajouter une condition', + filterAddGroup: 'Ajouter un groupe', + filterRemove: 'Supprimer', + filterJoin: (join) => (join === 'and' ? 'ET' : 'OU'), + filterNot: 'NON', + filterBuilderColumn: 'Colonne', + filterBuilderOperator: 'Operateur', + filterBuilderValue: 'Valeur', + filterValueTo: 'Jusqu a', + filterTrue: 'Vrai', + filterFalse: 'Faux', + filterOp: (op) => + ( + ({ + contains: 'contient', + notContains: 'ne contient pas', + equals: 'egal a', + notEqual: 'different de', + startsWith: 'commence par', + endsWith: 'se termine par', + gt: 'superieur a', + gte: 'superieur ou egal', + lt: 'inferieur a', + lte: 'inferieur ou egal', + between: 'entre', + before: 'avant', + after: 'apres', + in: 'parmi', + blank: 'est vide', + notBlank: 'n est pas vide' + }) as Record + )[op] ?? op, + filterServerHandled: 'Envoye au serveur', + + importTitle: 'Importer', + importDropHere: 'Déposez un tableur ici', + importChooseFile: 'Choisir un fichier', + importPaste: 'Coller depuis le presse-papiers', + importEmpty: 'Ce fichier ne contient aucune ligne', + importUnknownFormat: (name) => `${name} n'est pas une forme lisible`, + importHeaderRow: 'La première ligne nomme les colonnes', + importSheet: 'Feuille', + importColumnFrom: 'Colonne source', + importSkipColumn: 'Ne pas importer', + importMatchTitle: 'Associer les colonnes', + importReviewTitle: "Vérifier avant d'ajouter", + importBack: 'Retour', + importNext: 'Vérifier', + importCancel: 'Annuler', + importAddRows: (count) => `Tout ajouter (${count})`, + importAddValid: (count) => `Ajouter les prêtes (${count})`, + importIssues: (count) => `À revoir : ${count}`, + importReady: 'Toutes les lignes sont prêtes', + importNotNumber: (header) => `${header} n'est pas un nombre`, + importNotDate: (header) => `${header} n'est pas une date`, + importNotBoolean: (header) => `${header} n'est ni oui ni non`, + importInvalid: (header) => `${header} a été refusé`, + importDuplicate: (key) => `${key} figure plusieurs fois dans ce fichier`, + importExisting: (key) => `${key} est déjà dans la grille`, + importServerRefusal: + 'Un serveur détient ces lignes ; un import ne peut pas les préparer ici', + importRowCount: (count) => `${count} lignes à lire`, + importTooBig: (name, megabytes) => `${name} dépasse les ${megabytes} Mo que ceci ouvre`, + importMatched: (matched, total) => `${matched} colonnes sur ${total} associées`, + importNothingMatched: + 'Aucune correspondance. Choisissez une colonne source pour chacune, ou vérifiez si la première ligne est un en-tête' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/id-ID.ts b/src/lib/locales/id-ID.ts index 2d7499f..fd87df6 100644 --- a/src/lib/locales/id-ID.ts +++ b/src/lib/locales/id-ID.ts @@ -104,7 +104,190 @@ export const idID: DataGridLocalePack = { exportAllRows: 'Semua baris', exportLoadedRows: 'Baris yang dimuat', exportSelectedRows: 'Baris terpilih', - clearSelection: 'Batalkan pilihan' + clearSelection: 'Batalkan pilihan', + + groupBy: 'Kelompokkan menurut', + groupPanelEmpty: 'Belum dikelompokkan: tambahkan kolom untuk mengelompokkan', + addGroup: 'Tambah kelompok', + clearGroups: 'Hapus', + moveGroupEarlier: (column) => `Pindahkan ${column} ke depan`, + moveGroupLater: (column) => `Pindahkan ${column} ke belakang`, + removeGroup: (column) => `Hapus pengelompokan ${column}`, + + groupByColumn: 'Kelompokkan menurut kolom ini', + ungroupColumn: 'Batalkan pengelompokan kolom ini', + + groupFooter: (key) => `Total: ${key}`, + grandTotal: (count) => `Total (${count})`, + groupLoaded: (key, count) => `${key} (${count} dimuat)`, + groupFooterLoaded: (key, count) => `Total - ${key} (${count} dimuat)`, + grandTotalLoaded: (count) => `Total (${count} dimuat)`, + + rangeCells: (count) => `${count} sel`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Jumlah', + rangeAvg: 'Rata-rata', + rangeMin: 'Min', + rangeMax: 'Maks', + + commandPaletteTitle: 'Palet perintah', + commandPalettePlaceholder: 'Ketik sebuah perintah...', + commandPaletteEmpty: 'Tidak ada perintah yang cocok', + commandGroupDefault: 'Perintah', + + commandShowColumn: (column) => `Tampilkan kolom: ${column}`, + commandHideColumn: (column) => `Sembunyikan kolom: ${column}`, + commandUnpinColumn: (column) => `Lepas sematan: ${column}`, + commandPinColumnLeft: (column) => `Sematkan di kiri: ${column}`, + commandPinColumnRight: (column) => `Sematkan di kanan: ${column}`, + commandGroupByColumn: (column) => `Kelompokkan menurut: ${column}`, + commandUngroupColumn: (column) => `Batalkan pengelompokan: ${column}`, + commandExpandAllGroups: 'Buka semua kelompok', + commandCollapseAllGroups: 'Tutup semua kelompok', + commandClearGrouping: 'Hapus pengelompokan', + commandCopyRange: 'Salin rentang terpilih', + commandFillDown: 'Isi ke bawah', + commandFillRight: 'Isi ke kanan', + commandGroupColumns: 'Kolom', + commandGroupGrouping: 'Pengelompokan', + commandGroupClipboard: 'Papan klip', + + findTitle: 'Cari & ganti', + findPlaceholder: 'Cari di dalam grid...', + replacePlaceholder: 'Ganti dengan...', + findCaseSensitive: 'Bedakan huruf besar/kecil', + findWholeCell: 'Cocokkan seluruh sel', + findPrevious: 'Hasil sebelumnya', + findNext: 'Hasil berikutnya', + findClose: 'Tutup pencarian', + findReplaceOne: 'Ganti', + findReplaceAll: 'Ganti semua', + commandFind: 'Cari & ganti', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Tidak ada hasil', + findNoWritable: (total) => `${total} ditemukan, tidak ada yang bisa diubah`, + findReplaced: (cells) => `${cells} diganti`, + findCountLoaded: (current, total) => `${current} / ${total} dimuat`, + findNoMatchLoaded: 'Tidak ada hasil pada baris yang dimuat', + findServerReadOnly: 'Penggantian dimatikan untuk baris dari server', + + viewsLabel: 'Tampilan', + viewsEmpty: 'Belum ada tampilan tersimpan', + viewsPick: 'Pilih tampilan', + viewNamePlaceholder: 'Beri nama tampilan ini...', + viewSave: 'Simpan', + viewUpdate: 'Perbarui', + viewRevert: 'Kembalikan', + viewRemove: 'Hapus', + viewShare: 'Salin tautan', + viewShareCopied: 'Tautan disalin', + viewModified: 'diubah', + commandSaveView: 'Simpan tampilan saat ini', + viewShareTooLong: (length) => + `Terlalu besar untuk tautan (${length} karakter): simpan sebagai tampilan`, + + formatLabel: 'Pemformatan', + formatEmpty: 'Belum ada aturan pemformatan', + formatAdd: 'Tambah aturan', + formatClear: 'Hapus', + formatColumn: 'Kolom', + formatKind: 'Aturan', + formatKindColorScale: 'Skala warna', + formatKindDataBar: 'Batang data', + formatKindDuplicates: 'Nilai duplikat', + formatKindTopN: 'Nilai teratas', + formatKindExpression: 'Ekspresi', + formatTopCount: 'Berapa banyak', + formatExpressionPlaceholder: 'status = "terlambat"', + formatWholeRow: 'Seluruh baris', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Hapus ${rule}`, + formatBadExpression: (message) => `Tidak terbaca: ${message}`, + + toolPanelTitle: 'Alat', + toolPanelColumns: 'Kolom', + toolPanelGroup: 'Grup', + toolPanelValues: 'Nilai', + toolPanelSearch: 'Cari kolom...', + toolPanelShowAll: 'Tampilkan semua', + toolPanelHideAll: 'Sembunyikan semua', + toolPanelEmpty: 'Tidak ada kolom yang cocok', + toolPanelMoveUp: (column) => `Pindahkan ${column} ke depan`, + toolPanelMoveDown: (column) => `Pindahkan ${column} ke belakang`, + toolPanelNoAggregation: 'Tidak ada', + toolPanelActions: (column) => `Tindakan kolom: ${column}`, + toolPanelCollapse: 'Ciutkan panel', + toolPanelExpand: 'Bentangkan panel', + + filterTitle: 'Filter', + filterEmpty: 'Belum ada kondisi', + filterAddCondition: 'Tambah kondisi', + filterAddGroup: 'Tambah grup', + filterRemove: 'Hapus', + filterJoin: (join) => (join === 'and' ? 'DAN' : 'ATAU'), + filterNot: 'BUKAN', + filterBuilderColumn: 'Kolom', + filterBuilderOperator: 'Operator', + filterBuilderValue: 'Nilai', + filterValueTo: 'Sampai', + filterTrue: 'Benar', + filterFalse: 'Salah', + filterOp: (op) => + ( + ({ + contains: 'memuat', + notContains: 'tidak memuat', + equals: 'sama dengan', + notEqual: 'tidak sama dengan', + startsWith: 'diawali', + endsWith: 'diakhiri', + gt: 'lebih besar', + gte: 'lebih besar atau sama', + lt: 'lebih kecil', + lte: 'lebih kecil atau sama', + between: 'antara', + before: 'sebelum', + after: 'sesudah', + in: 'salah satu dari', + blank: 'kosong', + notBlank: 'tidak kosong' + }) as Record + )[op] ?? op, + filterServerHandled: 'Dikirim ke server', + + importTitle: 'Impor', + importDropHere: 'Letakkan lembar kerja di sini', + importChooseFile: 'Pilih berkas', + importPaste: 'Tempel dari papan klip', + importEmpty: 'Berkas itu tidak punya baris', + importUnknownFormat: (name) => `${name} bukan bentuk yang terbaca`, + importHeaderRow: 'Baris pertama menamai kolom', + importSheet: 'Lembar', + importColumnFrom: 'Kolom sumber', + importSkipColumn: 'Jangan impor', + importMatchTitle: 'Cocokkan kolom', + importReviewTitle: 'Periksa sebelum menambah', + importBack: 'Kembali', + importNext: 'Periksa', + importCancel: 'Batal', + importAddRows: (count) => `Tambah semua (${count})`, + importAddValid: (count) => `Tambah yang siap (${count})`, + importIssues: (count) => `Perlu diperiksa: ${count}`, + importReady: 'Semua baris sudah siap', + importNotNumber: (header) => `${header} bukan angka`, + importNotDate: (header) => `${header} bukan tanggal`, + importNotBoolean: (header) => `${header} bukan ya atau tidak`, + importInvalid: (header) => `${header} ditolak`, + importDuplicate: (key) => `${key} muncul lebih dari sekali di berkas ini`, + importExisting: (key) => `${key} sudah ada di tabel`, + importServerRefusal: 'Baris ini milik server, jadi impor tidak bisa menyiapkannya di sini', + importRowCount: (count) => `${count} baris akan dibaca`, + importTooBig: (name, megabytes) => + `${name} lebih besar dari ${megabytes} MB yang bisa dibuka`, + importMatched: (matched, total) => `${matched} dari ${total} kolom cocok`, + importNothingMatched: + 'Tidak ada yang cocok. Pilih kolom sumber untuk tiap kolom, atau periksa apakah baris pertama adalah judul' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/ja-JP.ts b/src/lib/locales/ja-JP.ts index 50389f9..25e3b04 100644 --- a/src/lib/locales/ja-JP.ts +++ b/src/lib/locales/ja-JP.ts @@ -103,7 +103,189 @@ export const jaJP: DataGridLocalePack = { exportAllRows: 'すべての行', exportLoadedRows: '読み込み済みの行', exportSelectedRows: '選択した行', - clearSelection: '選択を解除' + clearSelection: '選択を解除', + + groupBy: 'グループ化', + groupPanelEmpty: 'グループ化なし:列を追加してグループ化します', + addGroup: 'グループを追加', + clearGroups: 'クリア', + moveGroupEarlier: (column) => `${column} を前へ`, + moveGroupLater: (column) => `${column} を後ろへ`, + removeGroup: (column) => `${column} のグループ化を解除`, + + groupByColumn: 'この列でグループ化', + ungroupColumn: 'この列のグループ化を解除', + + groupFooter: (key) => `合計:${key}`, + grandTotal: (count) => `総計 (${count})`, + groupLoaded: (key, count) => `${key} (${count} 件読み込み済み)`, + groupFooterLoaded: (key, count) => `合計 - ${key} (${count} 件読み込み済み)`, + grandTotalLoaded: (count) => `合計 (${count} 件読み込み済み)`, + + rangeCells: (count) => `${count} セル`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: '合計', + rangeAvg: '平均', + rangeMin: '最小', + rangeMax: '最大', + + commandPaletteTitle: 'コマンドパレット', + commandPalettePlaceholder: 'コマンドを入力...', + commandPaletteEmpty: '一致するコマンドがありません', + commandGroupDefault: 'コマンド', + + commandShowColumn: (column) => `列を表示: ${column}`, + commandHideColumn: (column) => `列を非表示: ${column}`, + commandUnpinColumn: (column) => `固定を解除: ${column}`, + commandPinColumnLeft: (column) => `左に固定: ${column}`, + commandPinColumnRight: (column) => `右に固定: ${column}`, + commandGroupByColumn: (column) => `グループ化: ${column}`, + commandUngroupColumn: (column) => `グループ化を解除: ${column}`, + commandExpandAllGroups: 'すべてのグループを展開', + commandCollapseAllGroups: 'すべてのグループを折りたたむ', + commandClearGrouping: 'グループ化をクリア', + commandCopyRange: '選択範囲をコピー', + commandFillDown: '下方向にコピー', + commandFillRight: '右方向にコピー', + commandGroupColumns: '列', + commandGroupGrouping: 'グループ化', + commandGroupClipboard: 'クリップボード', + + findTitle: '検索と置換', + findPlaceholder: 'グリッド内を検索...', + replacePlaceholder: '置換後の文字列...', + findCaseSensitive: '大文字と小文字を区別', + findWholeCell: 'セル内容が完全に同一', + findPrevious: '前を検索', + findNext: '次を検索', + findClose: '検索を閉じる', + findReplaceOne: '置換', + findReplaceAll: 'すべて置換', + commandFind: '検索と置換', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: '一致なし', + findNoWritable: (total) => `${total} 件見つかりましたが、編集できません`, + findReplaced: (cells) => `${cells} 件置換しました`, + findCountLoaded: (current, total) => `${current} / ${total} 読み込み済み`, + findNoMatchLoaded: '読み込み済みの行に一致なし', + findServerReadOnly: 'サーバーから読み込んだ行は置換できません', + + viewsLabel: 'ビュー', + viewsEmpty: '保存されたビューがありません', + viewsPick: 'ビューを選択', + viewNamePlaceholder: 'ビューに名前を付ける...', + viewSave: '保存', + viewUpdate: '更新', + viewRevert: '元に戻す', + viewRemove: '削除', + viewShare: 'リンクをコピー', + viewShareCopied: 'リンクをコピーしました', + viewModified: '編集済み', + commandSaveView: '現在のビューを保存', + viewShareTooLong: (length) => + `リンクにするには大きすぎます (${length} 文字):ビューとして保存してください`, + + formatLabel: '書式', + formatEmpty: '書式ルールがありません', + formatAdd: 'ルールを追加', + formatClear: 'クリア', + formatColumn: '列', + formatKind: 'ルール', + formatKindColorScale: 'カラースケール', + formatKindDataBar: 'データバー', + formatKindDuplicates: '重複する値', + formatKindTopN: '上位の値', + formatKindExpression: '数式', + formatTopCount: '件数', + formatExpressionPlaceholder: 'status = "期限切れ"', + formatWholeRow: '行全体', + formatRuleName: (kind, column) => `${kind}:${column}`, + formatRemoveRule: (rule) => `${rule} を削除`, + formatBadExpression: (message) => `解析できません: ${message}`, + + toolPanelTitle: 'ツール', + toolPanelColumns: '列', + toolPanelGroup: 'グループ', + toolPanelValues: '値', + toolPanelSearch: '列を検索...', + toolPanelShowAll: 'すべて表示', + toolPanelHideAll: 'すべて非表示', + toolPanelEmpty: '一致する列がありません', + toolPanelMoveUp: (column) => `${column} を前へ`, + toolPanelMoveDown: (column) => `${column} を後へ`, + toolPanelNoAggregation: 'なし', + toolPanelActions: (column) => `列の操作: ${column}`, + toolPanelCollapse: 'パネルを折りたたむ', + toolPanelExpand: 'パネルを開く', + + filterTitle: 'フィルター', + filterEmpty: '条件がありません', + filterAddCondition: '条件を追加', + filterAddGroup: 'グループを追加', + filterRemove: '削除', + filterJoin: (join) => (join === 'and' ? 'かつ' : 'または'), + filterNot: '除外', + filterBuilderColumn: '列', + filterBuilderOperator: '演算子', + filterBuilderValue: '値', + filterValueTo: 'まで', + filterTrue: 'はい', + filterFalse: 'いいえ', + filterOp: (op) => + ( + ({ + contains: '含む', + notContains: '含まない', + equals: '等しい', + notEqual: '等しくない', + startsWith: 'で始まる', + endsWith: 'で終わる', + gt: 'より大きい', + gte: '以上', + lt: 'より小さい', + lte: '以下', + between: '範囲内', + before: 'より前', + after: 'より後', + in: 'いずれか', + blank: '空である', + notBlank: '空でない' + }) as Record + )[op] ?? op, + filterServerHandled: 'サーバーに送信済み', + + importTitle: '取り込み', + importDropHere: '表計算ファイルをここへ', + importChooseFile: 'ファイルを選ぶ', + importPaste: 'クリップボードから貼り付け', + importEmpty: 'このファイルに行がありません', + importUnknownFormat: (name) => `${name} は読み取れない形式です`, + importHeaderRow: '先頭行が列名', + importSheet: 'シート', + importColumnFrom: '元の列', + importSkipColumn: '取り込まない', + importMatchTitle: '列を対応づける', + importReviewTitle: '追加する前に確認', + importBack: '戻る', + importNext: '確認', + importCancel: '取り消し', + importAddRows: (count) => `すべて追加 (${count})`, + importAddValid: (count) => `準備できた分を追加 (${count})`, + importIssues: (count) => `要確認: ${count}`, + importReady: 'すべての行が準備できています', + importNotNumber: (header) => `${header} は数値ではありません`, + importNotDate: (header) => `${header} は日付ではありません`, + importNotBoolean: (header) => `${header} ははい・いいえではありません`, + importInvalid: (header) => `${header} は受け付けられません`, + importDuplicate: (key) => `${key} はこのファイル内で重複しています`, + importExisting: (key) => `${key} はすでにグリッドにあります`, + importServerRefusal: 'これらの行はサーバーが持っているため、ここでは取り込めません', + importRowCount: (count) => `${count} 行を読み取ります`, + importTooBig: (name, megabytes) => `${name} は開ける上限 ${megabytes} MB を超えています`, + importMatched: (matched, total) => `${total} 列中 ${matched} 列が対応`, + importNothingMatched: + '対応するものがありません。列ごとに元の列を選ぶか、先頭行が見出しかどうか確認してください' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/ko-KR.ts b/src/lib/locales/ko-KR.ts index 242d422..382fb3f 100644 --- a/src/lib/locales/ko-KR.ts +++ b/src/lib/locales/ko-KR.ts @@ -103,7 +103,188 @@ export const koKR: DataGridLocalePack = { exportAllRows: '모든 행', exportLoadedRows: '불러온 행', exportSelectedRows: '선택한 행', - clearSelection: '선택 해제' + clearSelection: '선택 해제', + + groupBy: '그룹화 기준', + groupPanelEmpty: '그룹화 없음: 그룹화할 열을 추가하세요', + addGroup: '그룹 추가', + clearGroups: '지우기', + moveGroupEarlier: (column) => `${column} 앞으로 이동`, + moveGroupLater: (column) => `${column} 뒤로 이동`, + removeGroup: (column) => `${column} 그룹화 제거`, + + groupByColumn: '이 열로 그룹화', + ungroupColumn: '이 열 그룹화 해제', + + groupFooter: (key) => `합계: ${key}`, + grandTotal: (count) => `총합계 (${count})`, + groupLoaded: (key, count) => `${key} (${count}개 불러옴)`, + groupFooterLoaded: (key, count) => `합계 - ${key} (${count}개 불러옴)`, + grandTotalLoaded: (count) => `합계 (${count}개 불러옴)`, + + rangeCells: (count) => `${count}개 셀`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: '합계', + rangeAvg: '평균', + rangeMin: '최소', + rangeMax: '최대', + + commandPaletteTitle: '명령 팔레트', + commandPalettePlaceholder: '명령을 입력하세요...', + commandPaletteEmpty: '일치하는 명령이 없습니다', + commandGroupDefault: '명령', + + commandShowColumn: (column) => `열 표시: ${column}`, + commandHideColumn: (column) => `열 숨기기: ${column}`, + commandUnpinColumn: (column) => `고정 해제: ${column}`, + commandPinColumnLeft: (column) => `왼쪽에 고정: ${column}`, + commandPinColumnRight: (column) => `오른쪽에 고정: ${column}`, + commandGroupByColumn: (column) => `그룹화 기준: ${column}`, + commandUngroupColumn: (column) => `그룹화 해제: ${column}`, + commandExpandAllGroups: '모든 그룹 펼치기', + commandCollapseAllGroups: '모든 그룹 접기', + commandClearGrouping: '그룹화 지우기', + commandCopyRange: '선택 범위 복사', + commandFillDown: '아래로 채우기', + commandFillRight: '오른쪽으로 채우기', + commandGroupColumns: '열', + commandGroupGrouping: '그룹화', + commandGroupClipboard: '클립보드', + + findTitle: '찾기 및 바꾸기', + findPlaceholder: '표에서 찾기...', + replacePlaceholder: '바꿀 내용...', + findCaseSensitive: '대소문자 구분', + findWholeCell: '셀 내용 전체 일치', + findPrevious: '이전 결과', + findNext: '다음 결과', + findClose: '찾기 닫기', + findReplaceOne: '바꾸기', + findReplaceAll: '모두 바꾸기', + commandFind: '찾기 및 바꾸기', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: '결과 없음', + findNoWritable: (total) => `${total}개 찾았지만 편집할 수 없습니다`, + findReplaced: (cells) => `${cells}개 바꿨습니다`, + findCountLoaded: (current, total) => `${current} / ${total} 불러옴`, + findNoMatchLoaded: '불러온 행에 결과가 없습니다', + findServerReadOnly: '서버에서 불러온 행은 바꿀 수 없습니다', + + viewsLabel: '뷰', + viewsEmpty: '저장된 뷰가 없습니다', + viewsPick: '뷰 선택', + viewNamePlaceholder: '이 뷰의 이름...', + viewSave: '저장', + viewUpdate: '업데이트', + viewRevert: '되돌리기', + viewRemove: '삭제', + viewShare: '링크 복사', + viewShareCopied: '링크를 복사했습니다', + viewModified: '수정됨', + commandSaveView: '현재 뷰 저장', + viewShareTooLong: (length) => `링크로 만들기에 너무 큽니다 (${length}자): 뷰로 저장하세요`, + + formatLabel: '서식', + formatEmpty: '서식 규칙이 없습니다', + formatAdd: '규칙 추가', + formatClear: '지우기', + formatColumn: '열', + formatKind: '규칙', + formatKindColorScale: '색조', + formatKindDataBar: '데이터 막대', + formatKindDuplicates: '중복 값', + formatKindTopN: '상위 값', + formatKindExpression: '수식', + formatTopCount: '개수', + formatExpressionPlaceholder: 'status = "기한 초과"', + formatWholeRow: '전체 행', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `${rule} 제거`, + formatBadExpression: (message) => `해석할 수 없습니다: ${message}`, + + toolPanelTitle: '도구', + toolPanelColumns: '열', + toolPanelGroup: '그룹', + toolPanelValues: '값', + toolPanelSearch: '열 검색...', + toolPanelShowAll: '모두 표시', + toolPanelHideAll: '모두 숨기기', + toolPanelEmpty: '일치하는 열이 없습니다', + toolPanelMoveUp: (column) => `${column} 앞으로`, + toolPanelMoveDown: (column) => `${column} 뒤로`, + toolPanelNoAggregation: '없음', + toolPanelActions: (column) => `열 작업: ${column}`, + toolPanelCollapse: '패널 접기', + toolPanelExpand: '패널 펼치기', + + filterTitle: '필터', + filterEmpty: '조건이 없습니다', + filterAddCondition: '조건 추가', + filterAddGroup: '그룹 추가', + filterRemove: '제거', + filterJoin: (join) => (join === 'and' ? '그리고' : '또는'), + filterNot: '제외', + filterBuilderColumn: '열', + filterBuilderOperator: '연산자', + filterBuilderValue: '값', + filterValueTo: '까지', + filterTrue: '예', + filterFalse: '아니요', + filterOp: (op) => + ( + ({ + contains: '포함', + notContains: '미포함', + equals: '같음', + notEqual: '같지 않음', + startsWith: '시작 문자', + endsWith: '끝 문자', + gt: '초과', + gte: '이상', + lt: '미만', + lte: '이하', + between: '범위', + before: '이전', + after: '이후', + in: '다음 중 하나', + blank: '비어 있음', + notBlank: '비어 있지 않음' + }) as Record + )[op] ?? op, + filterServerHandled: '서버로 전송됨', + + importTitle: '가져오기', + importDropHere: '여기에 스프레드시트를 놓으세요', + importChooseFile: '파일 선택', + importPaste: '클립보드에서 붙여넣기', + importEmpty: '이 파일에는 행이 없습니다', + importUnknownFormat: (name) => `${name} 은(는) 읽을 수 없는 형식입니다`, + importHeaderRow: '첫 행이 열 이름', + importSheet: '시트', + importColumnFrom: '원본 열', + importSkipColumn: '가져오지 않음', + importMatchTitle: '열 맞추기', + importReviewTitle: '추가하기 전에 확인', + importBack: '뒤로', + importNext: '확인', + importCancel: '취소', + importAddRows: (count) => `모두 추가 (${count})`, + importAddValid: (count) => `준비된 것 추가 (${count})`, + importIssues: (count) => `확인 필요: ${count}`, + importReady: '모든 행이 준비되었습니다', + importNotNumber: (header) => `${header} 은(는) 숫자가 아닙니다`, + importNotDate: (header) => `${header} 은(는) 날짜가 아닙니다`, + importNotBoolean: (header) => `${header} 은(는) 예 또는 아니오가 아닙니다`, + importInvalid: (header) => `${header} 이(가) 거부되었습니다`, + importDuplicate: (key) => `${key} 이(가) 이 파일에서 중복됩니다`, + importExisting: (key) => `${key} 이(가) 이미 그리드에 있습니다`, + importServerRefusal: '이 행은 서버가 가지고 있어 여기에서 준비할 수 없습니다', + importRowCount: (count) => `${count} 행을 읽습니다`, + importTooBig: (name, megabytes) => `${name} 은(는) 열 수 있는 ${megabytes} MB를 넘습니다`, + importMatched: (matched, total) => `${total}개 중 ${matched}개 열이 연결됨`, + importNothingMatched: + '연결된 것이 없습니다. 열마다 원본 열을 고르거나, 첫 행이 머리글인지 확인하세요' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/locales.test.ts b/src/lib/locales/locales.test.ts index c06ca17..8ddf164 100644 --- a/src/lib/locales/locales.test.ts +++ b/src/lib/locales/locales.test.ts @@ -39,7 +39,9 @@ const SYMBOLIC = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte']) * a blanket exemption. */ const LOANWORDS: Record = { - 'id-ID': ['openFilter'] + 'de-DE': ['rangeMin', 'rangeMax', 'filterTitle', 'filterBuilderOperator'], + 'fr-FR': ['rangeMin', 'rangeMax', 'formatKindExpression'], + 'id-ID': ['openFilter', 'rangeMin', 'filterTitle', 'filterBuilderOperator'] } /** Plausible arguments for every label that is a function. */ @@ -61,7 +63,68 @@ const ARGS: Record = { pageRange: [1, 25, 300], totalRows: [300], filteredRows: [12, 300], - selectedRows: [4] + selectedRows: [4], + + // Grouping and aggregation + moveGroupEarlier: ['Region'], + moveGroupLater: ['Region'], + removeGroup: ['Region'], + groupFooter: ['North', 12], + grandTotal: [300], + groupLoaded: ['North', 12], + groupFooterLoaded: ['North', 12], + grandTotalLoaded: [300], + + // Range selection + rangeCells: ['12'], + rangeShape: [3, 4], + + // Command palette + commandShowColumn: ['Name'], + commandHideColumn: ['Name'], + commandUnpinColumn: ['Name'], + commandPinColumnLeft: ['Name'], + commandPinColumnRight: ['Name'], + commandGroupByColumn: ['Region'], + commandUngroupColumn: ['Region'], + + // Find and replace + findCount: [2, 9], + findNoWritable: [9], + findReplaced: [4], + findCountLoaded: [2, 9], + + // Saved views + viewShareTooLong: [2400], + + // Conditional formatting + formatRuleName: ['Colour scale', 'Pay'], + formatRemoveRule: ['Colour scale: Pay'], + formatBadExpression: ['unexpected )'], + + // Tool panel + toolPanelMoveUp: ['Name'], + toolPanelMoveDown: ['Name'], + toolPanelActions: ['Name'], + + // Advanced filter + filterJoin: ['and'], + filterOp: ['contains'], + + // Import wizard + importUnknownFormat: ['notes.rtf'], + importAddRows: [120], + importAddValid: [118], + importIssues: [2], + importNotNumber: ['Pay'], + importNotDate: ['Hired'], + importNotBoolean: ['Active'], + importInvalid: ['Email'], + importDuplicate: ['ada@example.com'], + importExisting: ['ada@example.com'], + importRowCount: [120], + importTooBig: ['people.xlsx', 8], + importMatched: [5, 7] } const ANNOUNCER_ARGS: Record = { diff --git a/src/lib/locales/pt-BR.ts b/src/lib/locales/pt-BR.ts index 3e88224..253cdea 100644 --- a/src/lib/locales/pt-BR.ts +++ b/src/lib/locales/pt-BR.ts @@ -113,7 +113,189 @@ export const ptBR: DataGridLocalePack = { exportAllRows: 'Todas as linhas', exportLoadedRows: 'Linhas carregadas', exportSelectedRows: 'Linhas selecionadas', - clearSelection: 'Limpar a seleção' + clearSelection: 'Limpar a seleção', + + groupBy: 'Agrupar por', + groupPanelEmpty: 'Sem agrupamento: adicione uma coluna para agrupar', + addGroup: 'Adicionar grupo', + clearGroups: 'Limpar', + moveGroupEarlier: (column) => `Mover ${column} para antes`, + moveGroupLater: (column) => `Mover ${column} para depois`, + removeGroup: (column) => `Remover o agrupamento por ${column}`, + + groupByColumn: 'Agrupar por esta coluna', + ungroupColumn: 'Desagrupar esta coluna', + + groupFooter: (key) => `Total: ${key}`, + grandTotal: (count) => `Total geral (${count})`, + groupLoaded: (key, count) => `${key} (${count} carregadas)`, + groupFooterLoaded: (key, count) => `Total - ${key} (${count} carregadas)`, + grandTotalLoaded: (count) => `Total (${count} carregadas)`, + + rangeCells: (count) => `${count} células`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Soma', + rangeAvg: 'Média', + rangeMin: 'Mín', + rangeMax: 'Máx', + + commandPaletteTitle: 'Paleta de comandos', + commandPalettePlaceholder: 'Digite um comando...', + commandPaletteEmpty: 'Nenhum comando corresponde', + commandGroupDefault: 'Comandos', + + commandShowColumn: (column) => `Mostrar a coluna: ${column}`, + commandHideColumn: (column) => `Ocultar a coluna: ${column}`, + commandUnpinColumn: (column) => `Desafixar: ${column}`, + commandPinColumnLeft: (column) => `Fixar à esquerda: ${column}`, + commandPinColumnRight: (column) => `Fixar à direita: ${column}`, + commandGroupByColumn: (column) => `Agrupar por: ${column}`, + commandUngroupColumn: (column) => `Desagrupar: ${column}`, + commandExpandAllGroups: 'Expandir todos os grupos', + commandCollapseAllGroups: 'Recolher todos os grupos', + commandClearGrouping: 'Limpar o agrupamento', + commandCopyRange: 'Copiar o intervalo selecionado', + commandFillDown: 'Preencher para baixo', + commandFillRight: 'Preencher para a direita', + commandGroupColumns: 'Colunas', + commandGroupGrouping: 'Agrupamento', + commandGroupClipboard: 'Área de transferência', + + findTitle: 'Localizar e substituir', + findPlaceholder: 'Localizar na grade...', + replacePlaceholder: 'Substituir por...', + findCaseSensitive: 'Diferenciar maiúsculas', + findWholeCell: 'Coincidir a célula inteira', + findPrevious: 'Ocorrência anterior', + findNext: 'Próxima ocorrência', + findClose: 'Fechar a busca', + findReplaceOne: 'Substituir', + findReplaceAll: 'Substituir tudo', + commandFind: 'Localizar e substituir', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Nenhuma ocorrência', + findNoWritable: (total) => `${total} encontradas, nenhuma editável`, + findReplaced: (cells) => `${cells} substituídas`, + findCountLoaded: (current, total) => `${current} / ${total} carregadas`, + findNoMatchLoaded: 'Nenhuma ocorrência nas linhas carregadas', + findServerReadOnly: 'A substituição está desativada para linhas do servidor', + + viewsLabel: 'Exibições', + viewsEmpty: 'Nenhuma exibição salva', + viewsPick: 'Escolher uma exibição', + viewNamePlaceholder: 'Nomeie esta exibição...', + viewSave: 'Salvar', + viewUpdate: 'Atualizar', + viewRevert: 'Reverter', + viewRemove: 'Excluir', + viewShare: 'Copiar o link', + viewShareCopied: 'Link copiado', + viewModified: 'editada', + commandSaveView: 'Salvar a exibição atual', + viewShareTooLong: (length) => + `Grande demais para um link (${length} caracteres): salve como exibição`, + + formatLabel: 'Formatação', + formatEmpty: 'Nenhuma regra de formatação', + formatAdd: 'Adicionar regra', + formatClear: 'Limpar', + formatColumn: 'Coluna', + formatKind: 'Regra', + formatKindColorScale: 'Escala de cores', + formatKindDataBar: 'Barra de dados', + formatKindDuplicates: 'Valores duplicados', + formatKindTopN: 'Primeiros valores', + formatKindExpression: 'Expressão', + formatTopCount: 'Quantos', + formatExpressionPlaceholder: 'status = "atrasado"', + formatWholeRow: 'Linha inteira', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Remover ${rule}`, + formatBadExpression: (message) => `Não foi possível interpretar: ${message}`, + + toolPanelTitle: 'Ferramentas', + toolPanelColumns: 'Colunas', + toolPanelGroup: 'Grupos', + toolPanelValues: 'Valores', + toolPanelSearch: 'Buscar colunas...', + toolPanelShowAll: 'Mostrar todas', + toolPanelHideAll: 'Ocultar todas', + toolPanelEmpty: 'Nenhuma coluna corresponde', + toolPanelMoveUp: (column) => `Mover ${column} para antes`, + toolPanelMoveDown: (column) => `Mover ${column} para depois`, + toolPanelNoAggregation: 'Nenhuma', + toolPanelActions: (column) => `Acoes da coluna: ${column}`, + toolPanelCollapse: 'Recolher painel', + toolPanelExpand: 'Expandir painel', + + filterTitle: 'Filtro', + filterEmpty: 'Nenhuma condicao', + filterAddCondition: 'Adicionar condicao', + filterAddGroup: 'Adicionar grupo', + filterRemove: 'Remover', + filterJoin: (join) => (join === 'and' ? 'E' : 'OU'), + filterNot: 'NAO', + filterBuilderColumn: 'Coluna', + filterBuilderOperator: 'Operador', + filterBuilderValue: 'Valor', + filterValueTo: 'Ate', + filterTrue: 'Verdadeiro', + filterFalse: 'Falso', + filterOp: (op) => + ( + ({ + contains: 'contem', + notContains: 'nao contem', + equals: 'igual a', + notEqual: 'diferente de', + startsWith: 'comeca com', + endsWith: 'termina com', + gt: 'maior que', + gte: 'maior ou igual', + lt: 'menor que', + lte: 'menor ou igual', + between: 'entre', + before: 'antes de', + after: 'depois de', + in: 'um de', + blank: 'esta vazio', + notBlank: 'nao esta vazio' + }) as Record + )[op] ?? op, + filterServerHandled: 'Enviado ao servidor', + + importTitle: 'Importar', + importDropHere: 'Solte uma planilha aqui', + importChooseFile: 'Escolher um arquivo', + importPaste: 'Colar da área de transferência', + importEmpty: 'Esse arquivo não tem linhas', + importUnknownFormat: (name) => `${name} não é um formato legível`, + importHeaderRow: 'A primeira linha nomeia as colunas', + importSheet: 'Planilha', + importColumnFrom: 'Coluna de origem', + importSkipColumn: 'Não importar', + importMatchTitle: 'Combinar as colunas', + importReviewTitle: 'Conferir antes de adicionar', + importBack: 'Voltar', + importNext: 'Conferir', + importCancel: 'Cancelar', + importAddRows: (count) => `Adicionar todas (${count})`, + importAddValid: (count) => `Adicionar as prontas (${count})`, + importIssues: (count) => `A conferir: ${count}`, + importReady: 'Todas as linhas estão prontas', + importNotNumber: (header) => `${header} não é um número`, + importNotDate: (header) => `${header} não é uma data`, + importNotBoolean: (header) => `${header} não é um sim ou um não`, + importInvalid: (header) => `${header} foi recusado`, + importDuplicate: (key) => `${key} aparece mais de uma vez neste arquivo`, + importExisting: (key) => `${key} já está na grade`, + importServerRefusal: 'Um servidor é dono dessas linhas, então não dá para prepará-las aqui', + importRowCount: (count) => `${count} linhas para ler`, + importTooBig: (name, megabytes) => `${name} é maior que os ${megabytes} MB que isto abre`, + importMatched: (matched, total) => `${matched} de ${total} colunas combinadas`, + importNothingMatched: + 'Nada combinou. Escolha uma coluna de origem para cada uma, ou veja se a primeira linha é cabeçalho' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/ru-RU.ts b/src/lib/locales/ru-RU.ts index 0683525..3fe744a 100644 --- a/src/lib/locales/ru-RU.ts +++ b/src/lib/locales/ru-RU.ts @@ -112,7 +112,190 @@ export const ruRU: DataGridLocalePack = { exportAllRows: 'Все строки', exportLoadedRows: 'Загруженные строки', exportSelectedRows: 'Выбранные строки', - clearSelection: 'Снять выделение' + clearSelection: 'Снять выделение', + + groupBy: 'Группировать по', + groupPanelEmpty: 'Без группировки: добавьте столбец для группировки', + addGroup: 'Добавить группу', + clearGroups: 'Очистить', + moveGroupEarlier: (column) => `Переместить ${column} раньше`, + moveGroupLater: (column) => `Переместить ${column} позже`, + removeGroup: (column) => `Убрать группировку по ${column}`, + + groupByColumn: 'Группировать по этому столбцу', + ungroupColumn: 'Убрать группировку по этому столбцу', + + groupFooter: (key) => `Итог: ${key}`, + grandTotal: (count) => `Общий итог (${count})`, + groupLoaded: (key, count) => `${key} (загружено: ${count})`, + groupFooterLoaded: (key, count) => `Итого - ${key} (загружено: ${count})`, + grandTotalLoaded: (count) => `Итого (загружено: ${count})`, + + rangeCells: (count) => `${count} ячеек`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Сумма', + rangeAvg: 'Среднее', + rangeMin: 'Мин', + rangeMax: 'Макс', + + commandPaletteTitle: 'Палитра команд', + commandPalettePlaceholder: 'Введите команду...', + commandPaletteEmpty: 'Подходящих команд нет', + commandGroupDefault: 'Команды', + + commandShowColumn: (column) => `Показать столбец: ${column}`, + commandHideColumn: (column) => `Скрыть столбец: ${column}`, + commandUnpinColumn: (column) => `Открепить: ${column}`, + commandPinColumnLeft: (column) => `Закрепить слева: ${column}`, + commandPinColumnRight: (column) => `Закрепить справа: ${column}`, + commandGroupByColumn: (column) => `Группировать по: ${column}`, + commandUngroupColumn: (column) => `Убрать группировку: ${column}`, + commandExpandAllGroups: 'Развернуть все группы', + commandCollapseAllGroups: 'Свернуть все группы', + commandClearGrouping: 'Очистить группировку', + commandCopyRange: 'Копировать выделенный диапазон', + commandFillDown: 'Заполнить вниз', + commandFillRight: 'Заполнить вправо', + commandGroupColumns: 'Столбцы', + commandGroupGrouping: 'Группировка', + commandGroupClipboard: 'Буфер обмена', + + findTitle: 'Поиск и замена', + findPlaceholder: 'Искать в таблице...', + replacePlaceholder: 'Заменить на...', + findCaseSensitive: 'Учитывать регистр', + findWholeCell: 'Ячейка целиком', + findPrevious: 'Предыдущее совпадение', + findNext: 'Следующее совпадение', + findClose: 'Закрыть поиск', + findReplaceOne: 'Заменить', + findReplaceAll: 'Заменить все', + commandFind: 'Поиск и замена', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Совпадений нет', + findNoWritable: (total) => `Найдено ${total}, ни одно нельзя изменить`, + findReplaced: (cells) => `Заменено: ${cells}`, + findCountLoaded: (current, total) => `${current} / ${total} загружено`, + findNoMatchLoaded: 'В загруженных строках совпадений нет', + findServerReadOnly: 'Замена отключена для строк, загруженных с сервера', + + viewsLabel: 'Представления', + viewsEmpty: 'Сохранённых представлений нет', + viewsPick: 'Выбрать представление', + viewNamePlaceholder: 'Название представления...', + viewSave: 'Сохранить', + viewUpdate: 'Обновить', + viewRevert: 'Вернуть', + viewRemove: 'Удалить', + viewShare: 'Копировать ссылку', + viewShareCopied: 'Ссылка скопирована', + viewModified: 'изменено', + commandSaveView: 'Сохранить текущее представление', + viewShareTooLong: (length) => + `Слишком велико для ссылки (${length} симв.): сохраните как представление`, + + formatLabel: 'Форматирование', + formatEmpty: 'Правил форматирования нет', + formatAdd: 'Добавить правило', + formatClear: 'Очистить', + formatColumn: 'Столбец', + formatKind: 'Правило', + formatKindColorScale: 'Цветовая шкала', + formatKindDataBar: 'Гистограмма', + formatKindDuplicates: 'Повторяющиеся значения', + formatKindTopN: 'Наибольшие значения', + formatKindExpression: 'Выражение', + formatTopCount: 'Сколько', + formatExpressionPlaceholder: 'status = "просрочено"', + formatWholeRow: 'Вся строка', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Убрать ${rule}`, + formatBadExpression: (message) => `Не удалось разобрать: ${message}`, + + toolPanelTitle: 'Инструменты', + toolPanelColumns: 'Столбцы', + toolPanelGroup: 'Группы', + toolPanelValues: 'Значения', + toolPanelSearch: 'Поиск столбцов...', + toolPanelShowAll: 'Показать все', + toolPanelHideAll: 'Скрыть все', + toolPanelEmpty: 'Нет подходящих столбцов', + toolPanelMoveUp: (column) => `Переместить ${column} раньше`, + toolPanelMoveDown: (column) => `Переместить ${column} позже`, + toolPanelNoAggregation: 'Нет', + toolPanelActions: (column) => `Действия со столбцом: ${column}`, + toolPanelCollapse: 'Свернуть панель', + toolPanelExpand: 'Развернуть панель', + + filterTitle: 'Фильтр', + filterEmpty: 'Условий пока нет', + filterAddCondition: 'Добавить условие', + filterAddGroup: 'Добавить группу', + filterRemove: 'Удалить', + filterJoin: (join) => (join === 'and' ? 'И' : 'ИЛИ'), + filterNot: 'НЕ', + filterBuilderColumn: 'Столбец', + filterBuilderOperator: 'Оператор', + filterBuilderValue: 'Значение', + filterValueTo: 'До', + filterTrue: 'Да', + filterFalse: 'Нет', + filterOp: (op) => + ( + ({ + contains: 'содержит', + notContains: 'не содержит', + equals: 'равно', + notEqual: 'не равно', + startsWith: 'начинается с', + endsWith: 'заканчивается на', + gt: 'больше', + gte: 'больше или равно', + lt: 'меньше', + lte: 'меньше или равно', + between: 'между', + before: 'до', + after: 'после', + in: 'один из', + blank: 'пусто', + notBlank: 'не пусто' + }) as Record + )[op] ?? op, + filterServerHandled: 'Отправлено на сервер', + + importTitle: 'Импорт', + importDropHere: 'Перетащите таблицу сюда', + importChooseFile: 'Выбрать файл', + importPaste: 'Вставить из буфера обмена', + importEmpty: 'В этом файле нет строк', + importUnknownFormat: (name) => `${name} - нечитаемый формат`, + importHeaderRow: 'Первая строка задаёт имена столбцов', + importSheet: 'Лист', + importColumnFrom: 'Исходный столбец', + importSkipColumn: 'Не импортировать', + importMatchTitle: 'Сопоставить столбцы', + importReviewTitle: 'Проверить перед добавлением', + importBack: 'Назад', + importNext: 'Проверить', + importCancel: 'Отмена', + importAddRows: (count) => `Добавить все (${count})`, + importAddValid: (count) => `Добавить готовые (${count})`, + importIssues: (count) => `Проверить: ${count}`, + importReady: 'Все строки готовы', + importNotNumber: (header) => `${header} - не число`, + importNotDate: (header) => `${header} - не дата`, + importNotBoolean: (header) => `${header} - не да или нет`, + importInvalid: (header) => `${header} отклонено`, + importDuplicate: (key) => `${key} встречается в этом файле несколько раз`, + importExisting: (key) => `${key} уже есть в таблице`, + importServerRefusal: 'Эти строки принадлежат серверу, поэтому подготовить их здесь нельзя', + importRowCount: (count) => `${count} строк к чтению`, + importTooBig: (name, megabytes) => + `${name} больше, чем ${megabytes} МБ, которые здесь открываются`, + importMatched: (matched, total) => `Сопоставлено ${matched} из ${total} столбцов`, + importNothingMatched: + 'Ничего не сопоставлено. Выберите исходный столбец для каждого или проверьте, является ли первая строка заголовком' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/th-TH.ts b/src/lib/locales/th-TH.ts index 36dec98..3689a7c 100644 --- a/src/lib/locales/th-TH.ts +++ b/src/lib/locales/th-TH.ts @@ -104,7 +104,189 @@ export const thTH: DataGridLocalePack = { exportAllRows: 'ทุกแถว', exportLoadedRows: 'แถวที่โหลดแล้ว', exportSelectedRows: 'แถวที่เลือก', - clearSelection: 'ยกเลิกการเลือก' + clearSelection: 'ยกเลิกการเลือก', + + groupBy: 'จัดกลุ่มตาม', + groupPanelEmpty: 'ยังไม่ได้จัดกลุ่ม: เพิ่มคอลัมน์เพื่อจัดกลุ่ม', + addGroup: 'เพิ่มกลุ่ม', + clearGroups: 'ล้าง', + moveGroupEarlier: (column) => `ย้าย ${column} ไปก่อนหน้า`, + moveGroupLater: (column) => `ย้าย ${column} ไปถัดไป`, + removeGroup: (column) => `เอาการจัดกลุ่มตาม ${column} ออก`, + + groupByColumn: 'จัดกลุ่มตามคอลัมน์นี้', + ungroupColumn: 'ยกเลิกการจัดกลุ่มคอลัมน์นี้', + + groupFooter: (key) => `รวม: ${key}`, + grandTotal: (count) => `รวมทั้งหมด (${count})`, + groupLoaded: (key, count) => `${key} (โหลดแล้ว ${count})`, + groupFooterLoaded: (key, count) => `รวม - ${key} (โหลดแล้ว ${count})`, + grandTotalLoaded: (count) => `รวมทั้งหมด (โหลดแล้ว ${count})`, + + rangeCells: (count) => `${count} เซลล์`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'ผลรวม', + rangeAvg: 'เฉลี่ย', + rangeMin: 'ต่ำสุด', + rangeMax: 'สูงสุด', + + commandPaletteTitle: 'แผงคำสั่ง', + commandPalettePlaceholder: 'พิมพ์คำสั่ง...', + commandPaletteEmpty: 'ไม่มีคำสั่งที่ตรงกัน', + commandGroupDefault: 'คำสั่ง', + + commandShowColumn: (column) => `แสดงคอลัมน์: ${column}`, + commandHideColumn: (column) => `ซ่อนคอลัมน์: ${column}`, + commandUnpinColumn: (column) => `ยกเลิกการปักหมุด: ${column}`, + commandPinColumnLeft: (column) => `ปักหมุดด้านซ้าย: ${column}`, + commandPinColumnRight: (column) => `ปักหมุดด้านขวา: ${column}`, + commandGroupByColumn: (column) => `จัดกลุ่มตาม: ${column}`, + commandUngroupColumn: (column) => `ยกเลิกการจัดกลุ่ม: ${column}`, + commandExpandAllGroups: 'ขยายทุกกลุ่ม', + commandCollapseAllGroups: 'ยุบทุกกลุ่ม', + commandClearGrouping: 'ล้างการจัดกลุ่ม', + commandCopyRange: 'คัดลอกช่วงที่เลือก', + commandFillDown: 'เติมลงล่าง', + commandFillRight: 'เติมไปทางขวา', + commandGroupColumns: 'คอลัมน์', + commandGroupGrouping: 'การจัดกลุ่ม', + commandGroupClipboard: 'คลิปบอร์ด', + + findTitle: 'ค้นหาและแทนที่', + findPlaceholder: 'ค้นหาในตาราง...', + replacePlaceholder: 'แทนที่ด้วย...', + findCaseSensitive: 'ตรงตามตัวพิมพ์ใหญ่-เล็ก', + findWholeCell: 'ตรงทั้งเซลล์', + findPrevious: 'ผลลัพธ์ก่อนหน้า', + findNext: 'ผลลัพธ์ถัดไป', + findClose: 'ปิดการค้นหา', + findReplaceOne: 'แทนที่', + findReplaceAll: 'แทนที่ทั้งหมด', + commandFind: 'ค้นหาและแทนที่', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'ไม่พบผลลัพธ์', + findNoWritable: (total) => `พบ ${total} รายการ แต่แก้ไขไม่ได้`, + findReplaced: (cells) => `แทนที่แล้ว ${cells} เซลล์`, + findCountLoaded: (current, total) => `${current} / ${total} ที่โหลดแล้ว`, + findNoMatchLoaded: 'ไม่พบผลลัพธ์ในแถวที่โหลดแล้ว', + findServerReadOnly: 'แทนที่ไม่ได้สำหรับแถวที่โหลดจากเซิร์ฟเวอร์', + + viewsLabel: 'มุมมอง', + viewsEmpty: 'ยังไม่มีมุมมองที่บันทึกไว้', + viewsPick: 'เลือกมุมมอง', + viewNamePlaceholder: 'ตั้งชื่อมุมมองนี้...', + viewSave: 'บันทึก', + viewUpdate: 'อัปเดต', + viewRevert: 'ย้อนกลับ', + viewRemove: 'ลบ', + viewShare: 'คัดลอกลิงก์', + viewShareCopied: 'คัดลอกลิงก์แล้ว', + viewModified: 'แก้ไขแล้ว', + commandSaveView: 'บันทึกมุมมองปัจจุบัน', + viewShareTooLong: (length) => + `ใหญ่เกินกว่าจะทำลิงก์ (${length} อักขระ): บันทึกเป็นมุมมองแทน`, + + formatLabel: 'การจัดรูปแบบ', + formatEmpty: 'ยังไม่มีกฎการจัดรูปแบบ', + formatAdd: 'เพิ่มกฎ', + formatClear: 'ล้าง', + formatColumn: 'คอลัมน์', + formatKind: 'กฎ', + formatKindColorScale: 'มาตราส่วนสี', + formatKindDataBar: 'แถบข้อมูล', + formatKindDuplicates: 'ค่าที่ซ้ำกัน', + formatKindTopN: 'ค่าสูงสุด', + formatKindExpression: 'นิพจน์', + formatTopCount: 'จำนวน', + formatExpressionPlaceholder: 'status = "เกินกำหนด"', + formatWholeRow: 'ทั้งแถว', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `เอา ${rule} ออก`, + formatBadExpression: (message) => `อ่านนิพจน์ไม่ได้: ${message}`, + + toolPanelTitle: 'เครื่องมือ', + toolPanelColumns: 'คอลัมน์', + toolPanelGroup: 'กลุ่ม', + toolPanelValues: 'ค่า', + toolPanelSearch: 'ค้นหาคอลัมน์...', + toolPanelShowAll: 'แสดงทั้งหมด', + toolPanelHideAll: 'ซ่อนทั้งหมด', + toolPanelEmpty: 'ไม่พบคอลัมน์ที่ตรงกัน', + toolPanelMoveUp: (column) => `ย้าย ${column} ไปก่อน`, + toolPanelMoveDown: (column) => `ย้าย ${column} ไปหลัง`, + toolPanelNoAggregation: 'ไม่มี', + toolPanelActions: (column) => `การทำงานของคอลัมน์: ${column}`, + toolPanelCollapse: 'ย่อแผง', + toolPanelExpand: 'ขยายแผง', + + filterTitle: 'ตัวกรอง', + filterEmpty: 'ยังไม่มีเงื่อนไข', + filterAddCondition: 'เพิ่มเงื่อนไข', + filterAddGroup: 'เพิ่มกลุ่ม', + filterRemove: 'ลบ', + filterJoin: (join) => (join === 'and' ? 'และ' : 'หรือ'), + filterNot: 'ไม่', + filterBuilderColumn: 'คอลัมน์', + filterBuilderOperator: 'ตัวดำเนินการ', + filterBuilderValue: 'ค่า', + filterValueTo: 'ถึง', + filterTrue: 'ใช่', + filterFalse: 'ไม่ใช่', + filterOp: (op) => + ( + ({ + contains: 'ประกอบด้วย', + notContains: 'ไม่ประกอบด้วย', + equals: 'เท่ากับ', + notEqual: 'ไม่เท่ากับ', + startsWith: 'ขึ้นต้นด้วย', + endsWith: 'ลงท้ายด้วย', + gt: 'มากกว่า', + gte: 'มากกว่าหรือเท่ากับ', + lt: 'น้อยกว่า', + lte: 'น้อยกว่าหรือเท่ากับ', + between: 'ระหว่าง', + before: 'ก่อน', + after: 'หลัง', + in: 'อยู่ในรายการ', + blank: 'ว่าง', + notBlank: 'ไม่ว่าง' + }) as Record + )[op] ?? op, + filterServerHandled: 'ส่งไปยังเซิร์ฟเวอร์แล้ว', + + importTitle: 'นำเข้า', + importDropHere: 'วางไฟล์ตารางที่นี่', + importChooseFile: 'เลือกไฟล์', + importPaste: 'วางจากคลิปบอร์ด', + importEmpty: 'ไฟล์นี้ไม่มีแถว', + importUnknownFormat: (name) => `${name} ไม่ใช่รูปแบบที่อ่านได้`, + importHeaderRow: 'แถวแรกคือชื่อคอลัมน์', + importSheet: 'ชีต', + importColumnFrom: 'คอลัมน์ต้นทาง', + importSkipColumn: 'ไม่นำเข้า', + importMatchTitle: 'จับคู่คอลัมน์', + importReviewTitle: 'ตรวจก่อนเพิ่ม', + importBack: 'ย้อนกลับ', + importNext: 'ตรวจ', + importCancel: 'ยกเลิก', + importAddRows: (count) => `เพิ่มทั้งหมด (${count})`, + importAddValid: (count) => `เพิ่มที่พร้อม (${count})`, + importIssues: (count) => `ต้องตรวจ: ${count}`, + importReady: 'ทุกแถวพร้อมแล้ว', + importNotNumber: (header) => `${header} ไม่ใช่ตัวเลข`, + importNotDate: (header) => `${header} ไม่ใช่วันที่`, + importNotBoolean: (header) => `${header} ไม่ใช่ใช่หรือไม่ใช่`, + importInvalid: (header) => `${header} ถูกปฏิเสธ`, + importDuplicate: (key) => `${key} ซ้ำกันในไฟล์นี้`, + importExisting: (key) => `${key} มีอยู่ในตารางแล้ว`, + importServerRefusal: 'แถวเหล่านี้เป็นของเซิร์ฟเวอร์ จึงเตรียมที่นี่ไม่ได้', + importRowCount: (count) => `${count} แถวที่จะอ่าน`, + importTooBig: (name, megabytes) => `${name} ใหญ่กว่า ${megabytes} MB ที่เปิดได้`, + importMatched: (matched, total) => `จับคู่แล้ว ${matched} จาก ${total} คอลัมน์`, + importNothingMatched: + 'ไม่มีอะไรจับคู่ได้ เลือกคอลัมน์ต้นทางให้แต่ละคอลัมน์ หรือดูว่าแถวแรกเป็นหัวตารางหรือไม่' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/vi-VN.ts b/src/lib/locales/vi-VN.ts index b29645d..6816320 100644 --- a/src/lib/locales/vi-VN.ts +++ b/src/lib/locales/vi-VN.ts @@ -103,7 +103,188 @@ export const viVN: DataGridLocalePack = { exportAllRows: 'Tất cả các dòng', exportLoadedRows: 'Các dòng đã tải', exportSelectedRows: 'Các dòng đã chọn', - clearSelection: 'Bỏ chọn' + clearSelection: 'Bỏ chọn', + + groupBy: 'Nhóm theo', + groupPanelEmpty: 'Chưa nhóm: chọn một cột để nhóm theo', + addGroup: 'Thêm nhóm', + clearGroups: 'Xoá hết', + moveGroupEarlier: (column) => `Đưa ${column} lên trước`, + moveGroupLater: (column) => `Đưa ${column} xuống sau`, + removeGroup: (column) => `Bỏ nhóm theo ${column}`, + + groupByColumn: 'Nhóm theo cột này', + ungroupColumn: 'Bỏ nhóm cột này', + + groupFooter: (key) => `Tổng: ${key}`, + grandTotal: (count) => `Tổng cộng (${count})`, + groupLoaded: (key, count) => `${key} (${count} đã tải)`, + groupFooterLoaded: (key, count) => `Tổng: ${key} (${count} đã tải)`, + grandTotalLoaded: (count) => `Tổng cộng (${count} đã tải)`, + + rangeCells: (count) => `${count} ô`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: 'Tổng', + rangeAvg: 'Trung bình', + rangeMin: 'Nhỏ nhất', + rangeMax: 'Lớn nhất', + + commandPaletteTitle: 'Bảng lệnh', + commandPalettePlaceholder: 'Nhập một lệnh...', + commandPaletteEmpty: 'Không có lệnh nào khớp', + commandGroupDefault: 'Lệnh', + + commandShowColumn: (column) => `Hiện cột: ${column}`, + commandHideColumn: (column) => `Ẩn cột: ${column}`, + commandUnpinColumn: (column) => `Bỏ ghim cột: ${column}`, + commandPinColumnLeft: (column) => `Ghim trái: ${column}`, + commandPinColumnRight: (column) => `Ghim phải: ${column}`, + commandGroupByColumn: (column) => `Nhóm theo: ${column}`, + commandUngroupColumn: (column) => `Bỏ nhóm: ${column}`, + commandExpandAllGroups: 'Mở hết các nhóm', + commandCollapseAllGroups: 'Thu hết các nhóm', + commandClearGrouping: 'Xoá cách nhóm', + commandCopyRange: 'Sao chép vùng đang chọn', + commandFillDown: 'Điền xuống', + commandFillRight: 'Điền sang phải', + commandGroupColumns: 'Cột', + commandGroupGrouping: 'Nhóm', + commandGroupClipboard: 'Bộ nhớ tạm', + + findTitle: 'Tìm & thay thế', + findPlaceholder: 'Tìm trong lưới...', + replacePlaceholder: 'Thay bằng...', + findCaseSensitive: 'Phân biệt hoa/thường', + findWholeCell: 'Khớp cả ô', + findPrevious: 'Kết quả trước', + findNext: 'Kết quả sau', + findClose: 'Đóng tìm kiếm', + findReplaceOne: 'Thay', + findReplaceAll: 'Thay hết', + commandFind: 'Tìm & thay thế', + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: 'Không có kết quả', + findNoWritable: (total) => `${total} kết quả, không ô nào sửa được`, + findReplaced: (cells) => `Đã thay ${cells}`, + findCountLoaded: (current, total) => `${current} / ${total} đã tải`, + findNoMatchLoaded: 'Không có kết quả trong phần đã tải', + findServerReadOnly: 'Không thay được trên dữ liệu tải từ server', + + viewsLabel: 'Góc nhìn', + viewsEmpty: 'Chưa có góc nhìn nào', + viewsPick: 'Chọn góc nhìn', + viewNamePlaceholder: 'Đặt tên góc nhìn...', + viewSave: 'Lưu', + viewUpdate: 'Cập nhật', + viewRevert: 'Hoàn nguyên', + viewRemove: 'Xoá', + viewShare: 'Chép link', + viewShareCopied: 'Đã chép link', + viewModified: 'đã chỉnh', + commandSaveView: 'Lưu góc nhìn hiện tại', + viewShareTooLong: (length) => + `Quá dài để tạo link (${length} ký tự): hãy lưu thành góc nhìn`, + + formatLabel: 'Định dạng', + formatEmpty: 'Chưa có luật định dạng', + formatAdd: 'Thêm luật', + formatClear: 'Xoá hết', + formatColumn: 'Cột', + formatKind: 'Loại luật', + formatKindColorScale: 'Thang màu', + formatKindDataBar: 'Thanh dữ liệu', + formatKindDuplicates: 'Giá trị trùng', + formatKindTopN: 'Giá trị cao nhất', + formatKindExpression: 'Biểu thức', + formatTopCount: 'Bao nhiêu', + formatExpressionPlaceholder: 'status = "quá hạn"', + formatWholeRow: 'Cả hàng', + formatRuleName: (kind, column) => `${kind}: ${column}`, + formatRemoveRule: (rule) => `Xoá ${rule}`, + formatBadExpression: (message) => `Không đọc được: ${message}`, + + toolPanelTitle: 'Công cụ', + toolPanelColumns: 'Cột', + toolPanelGroup: 'Nhóm', + toolPanelValues: 'Giá trị', + toolPanelSearch: 'Tìm cột...', + toolPanelShowAll: 'Hiện hết', + toolPanelHideAll: 'Ẩn hết', + toolPanelEmpty: 'Không cột nào khớp', + toolPanelMoveUp: (column) => `Đưa ${column} lên trước`, + toolPanelMoveDown: (column) => `Đưa ${column} xuống sau`, + toolPanelNoAggregation: 'Không', + toolPanelActions: (column) => `Thao tác cột: ${column}`, + toolPanelCollapse: 'Thu gọn bảng', + toolPanelExpand: 'Mở rộng bảng', + + filterTitle: 'Bộ lọc', + filterEmpty: 'Chưa có điều kiện nào', + filterAddCondition: 'Thêm điều kiện', + filterAddGroup: 'Thêm nhóm', + filterRemove: 'Xoá', + filterJoin: (join) => (join === 'and' ? 'VÀ' : 'HOẶC'), + filterNot: 'KHÔNG', + filterBuilderColumn: 'Cột', + filterBuilderOperator: 'Toán tử', + filterBuilderValue: 'Giá trị', + filterValueTo: 'Đến', + filterTrue: 'Có', + filterFalse: 'Không', + filterOp: (op) => + ( + ({ + contains: 'chứa', + notContains: 'không chứa', + equals: 'bằng', + notEqual: 'khác', + startsWith: 'bắt đầu bằng', + endsWith: 'kết thúc bằng', + gt: 'lớn hơn', + gte: 'lớn hơn hoặc bằng', + lt: 'nhỏ hơn', + lte: 'nhỏ hơn hoặc bằng', + between: 'trong khoảng', + before: 'trước', + after: 'sau', + in: 'thuộc danh sách', + blank: 'đang trống', + notBlank: 'không trống' + }) as Record + )[op] ?? op, + filterServerHandled: 'Máy chủ trả lời', + + importTitle: 'Nạp dữ liệu', + importDropHere: 'Thả bảng tính vào đây', + importChooseFile: 'Chọn tệp', + importPaste: 'Dán từ bộ nhớ tạm', + importEmpty: 'Tệp này không có dòng nào', + importUnknownFormat: (name) => `${name} không phải dạng đọc được`, + importHeaderRow: 'Dòng đầu là tên cột', + importSheet: 'Trang tính', + importColumnFrom: 'Cột nguồn', + importSkipColumn: 'Bỏ qua', + importMatchTitle: 'Ghép cột', + importReviewTitle: 'Soát lại trước khi nạp', + importBack: 'Quay lại', + importNext: 'Soát', + importCancel: 'Huỷ', + importAddRows: (count) => `Nạp tất cả (${count})`, + importAddValid: (count) => `Nạp phần sẵn sàng (${count})`, + importIssues: (count) => `Cần xem lại: ${count}`, + importReady: 'Mọi dòng đều sẵn sàng', + importNotNumber: (header) => `${header} không phải số`, + importNotDate: (header) => `${header} không phải ngày`, + importNotBoolean: (header) => `${header} không phải có hoặc không`, + importInvalid: (header) => `${header} bị từ chối`, + importDuplicate: (key) => `${key} xuất hiện nhiều lần trong tệp này`, + importExisting: (key) => `${key} đã có trong lưới`, + importServerRefusal: 'Máy chủ giữ các dòng này, nên không thể nạp tạm ở đây', + importRowCount: (count) => `${count} dòng sẽ nạp`, + importTooBig: (name, megabytes) => `${name} lớn hơn mức ${megabytes} MB đọc được`, + importMatched: (matched, total) => `Khớp ${matched} trong ${total} cột`, + importNothingMatched: + 'Không khớp cột nào. Hãy chọn cột nguồn cho từng cột cần nạp, hoặc xem lại dòng đầu có phải tên cột không' }, announcer: { sorted: (column, direction) => diff --git a/src/lib/locales/zh-CN.ts b/src/lib/locales/zh-CN.ts index a92daa7..5e17697 100644 --- a/src/lib/locales/zh-CN.ts +++ b/src/lib/locales/zh-CN.ts @@ -103,7 +103,187 @@ export const zhCN: DataGridLocalePack = { exportAllRows: '全部行', exportLoadedRows: '已加载的行', exportSelectedRows: '已选行', - clearSelection: '取消选择' + clearSelection: '取消选择', + + groupBy: '分组依据', + groupPanelEmpty: '尚未分组:添加一列作为分组依据', + addGroup: '添加分组', + clearGroups: '清除', + moveGroupEarlier: (column) => `将${column}前移`, + moveGroupLater: (column) => `将${column}后移`, + removeGroup: (column) => `移除按${column}的分组`, + + groupByColumn: '按此列分组', + ungroupColumn: '取消此列的分组', + + groupFooter: (key) => `合计:${key}`, + grandTotal: (count) => `总计 (${count})`, + groupLoaded: (key, count) => `${key}(已加载 ${count})`, + groupFooterLoaded: (key, count) => `合计 - ${key}(已加载 ${count})`, + grandTotalLoaded: (count) => `总计(已加载 ${count})`, + + rangeCells: (count) => `${count} 个单元格`, + rangeShape: (rows, columns) => `${rows}x${columns}`, + rangeSum: '求和', + rangeAvg: '平均值', + rangeMin: '最小值', + rangeMax: '最大值', + + commandPaletteTitle: '命令面板', + commandPalettePlaceholder: '输入命令...', + commandPaletteEmpty: '没有匹配的命令', + commandGroupDefault: '命令', + + commandShowColumn: (column) => `显示列:${column}`, + commandHideColumn: (column) => `隐藏列:${column}`, + commandUnpinColumn: (column) => `取消固定:${column}`, + commandPinColumnLeft: (column) => `固定到左侧:${column}`, + commandPinColumnRight: (column) => `固定到右侧:${column}`, + commandGroupByColumn: (column) => `分组依据:${column}`, + commandUngroupColumn: (column) => `取消分组:${column}`, + commandExpandAllGroups: '展开所有分组', + commandCollapseAllGroups: '折叠所有分组', + commandClearGrouping: '清除分组', + commandCopyRange: '复制所选区域', + commandFillDown: '向下填充', + commandFillRight: '向右填充', + commandGroupColumns: '列', + commandGroupGrouping: '分组', + commandGroupClipboard: '剪贴板', + + findTitle: '查找和替换', + findPlaceholder: '在表格中查找...', + replacePlaceholder: '替换为...', + findCaseSensitive: '区分大小写', + findWholeCell: '单元格匹配', + findPrevious: '上一个匹配项', + findNext: '下一个匹配项', + findClose: '关闭查找', + findReplaceOne: '替换', + findReplaceAll: '全部替换', + commandFind: '查找和替换', + + findCount: (current, total) => `${current} / ${total}`, + findNoMatch: '没有匹配项', + findNoWritable: (total) => `找到 ${total} 处,均不可编辑`, + findReplaced: (cells) => `已替换 ${cells} 处`, + findCountLoaded: (current, total) => `${current} / ${total}(已加载)`, + findNoMatchLoaded: '已加载的行中没有匹配项', + findServerReadOnly: '从服务器加载的行不支持替换', + + viewsLabel: '视图', + viewsEmpty: '没有已保存的视图', + viewsPick: '选择视图', + viewNamePlaceholder: '为该视图命名...', + viewSave: '保存', + viewUpdate: '更新', + viewRevert: '还原', + viewRemove: '删除', + viewShare: '复制链接', + viewShareCopied: '已复制链接', + viewModified: '已修改', + commandSaveView: '保存当前视图', + viewShareTooLong: (length) => `内容过大,无法生成链接(${length} 个字符),请保存为视图`, + + formatLabel: '条件格式', + formatEmpty: '没有格式规则', + formatAdd: '添加规则', + formatClear: '清除', + formatColumn: '列', + formatKind: '规则', + formatKindColorScale: '色阶', + formatKindDataBar: '数据条', + formatKindDuplicates: '重复值', + formatKindTopN: '最大值', + formatKindExpression: '表达式', + formatTopCount: '取几项', + formatExpressionPlaceholder: 'status = "逾期"', + formatWholeRow: '整行', + formatRuleName: (kind, column) => `${kind}:${column}`, + formatRemoveRule: (rule) => `移除${rule}`, + formatBadExpression: (message) => `无法解析:${message}`, + + toolPanelTitle: '工具', + toolPanelColumns: '列', + toolPanelGroup: '分组', + toolPanelValues: '值', + toolPanelSearch: '搜索列...', + toolPanelShowAll: '全部显示', + toolPanelHideAll: '全部隐藏', + toolPanelEmpty: '没有匹配的列', + toolPanelMoveUp: (column) => `将 ${column} 前移`, + toolPanelMoveDown: (column) => `将 ${column} 后移`, + toolPanelNoAggregation: '无', + toolPanelActions: (column) => `列操作:${column}`, + toolPanelCollapse: '收起面板', + toolPanelExpand: '展开面板', + + filterTitle: '筛选', + filterEmpty: '暂无条件', + filterAddCondition: '添加条件', + filterAddGroup: '添加分组', + filterRemove: '移除', + filterJoin: (join) => (join === 'and' ? '并且' : '或者'), + filterNot: '非', + filterBuilderColumn: '列', + filterBuilderOperator: '运算符', + filterBuilderValue: '值', + filterValueTo: '至', + filterTrue: '是', + filterFalse: '否', + filterOp: (op) => + ( + ({ + contains: '包含', + notContains: '不包含', + equals: '等于', + notEqual: '不等于', + startsWith: '开头是', + endsWith: '结尾是', + gt: '大于', + gte: '大于等于', + lt: '小于', + lte: '小于等于', + between: '介于', + before: '早于', + after: '晚于', + in: '属于', + blank: '为空', + notBlank: '不为空' + }) as Record + )[op] ?? op, + filterServerHandled: '已发送到服务器', + + importTitle: '导入', + importDropHere: '把表格拖到这里', + importChooseFile: '选择文件', + importPaste: '从剪贴板粘贴', + importEmpty: '该文件没有任何行', + importUnknownFormat: (name) => `${name} 不是可读的格式`, + importHeaderRow: '首行是列名', + importSheet: '工作表', + importColumnFrom: '来源列', + importSkipColumn: '不导入', + importMatchTitle: '匹配列', + importReviewTitle: '添加前先核对', + importBack: '返回', + importNext: '核对', + importCancel: '取消', + importAddRows: (count) => `全部添加 (${count})`, + importAddValid: (count) => `添加已就绪 (${count})`, + importIssues: (count) => `待处理: ${count}`, + importReady: '每一行都已就绪', + importNotNumber: (header) => `${header} 不是数字`, + importNotDate: (header) => `${header} 不是日期`, + importNotBoolean: (header) => `${header} 不是是或否`, + importInvalid: (header) => `${header} 被拒绝`, + importDuplicate: (key) => `${key} 在本文件中重复出现`, + importExisting: (key) => `${key} 已经在表格中`, + importServerRefusal: '这些行归服务器所有,无法在这里预备导入', + importRowCount: (count) => `将读取 ${count} 行`, + importTooBig: (name, megabytes) => `${name} 超过可打开的 ${megabytes} MB`, + importMatched: (matched, total) => `${total} 列中已匹配 ${matched} 列`, + importNothingMatched: '没有匹配。请为每一列选择来源列,或检查首行是否为列名' }, announcer: { sorted: (column, direction) => `已按${column}${direction === 'asc' ? '升序' : '降序'}排序`, diff --git a/vite.config.ts b/vite.config.ts index cd23a0d..cd689c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,7 +20,23 @@ export default defineConfig({ // or miscounts a version is expensive in its own way, so // the parts of it that can be tested are. include: ['src/**/*.{test,spec}.{js,ts}', 'scripts/**/*.test.{js,mjs,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + exclude: [ + 'src/**/*.svelte.{test,spec}.{js,ts}', + 'src/benchmarks/**/*.{test,spec}.{js,ts}' + ] + } + }, + { + // Budgets rather than benchmarks: each one asserts a ceiling, + // so a regression fails rather than being noted in a number + // nobody reads. Serial, because two of them racing on one + // machine is what makes a timing test flake. + extends: true, + test: { + name: 'benchmarks', + environment: 'node', + include: ['src/benchmarks/**/*.{test,spec}.{js,ts}'], + fileParallelism: false } }, { From d992203145ba3bf3452c8edfa209ddbfe6f5ac79 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 18:36:48 +0700 Subject: [PATCH 02/46] fix(core): make the shared foundation hold what it claims to Three defects the review found, each with a guard that fails without the fix. `gateReader` cached the column but not the composed reader, so `grid.getValue` asked every gate for a reader once per cell rather than once per column: 15,000 compositions for 15,000 reads over 5,000 rows and three columns, against three now. Its own docstring described the behaviour it did not have. `canonical` rebuilt every object from its entries, which turns a `Date` into `{}`. A share link lost the value and `sameSnapshot` called two different snapshots equal, while the same slice through `localStorage` kept it, so the two paths a snapshot travels disagreed. It now honours `toJSON` the way `JSON.stringify` does. `autoColumns` guessed `date` from the shape of a string, so a column of `1234-56-78` part numbers typed as dates and then drew every cell blank, because the renderer asks `toDate` and gets nothing. The guess now asks `toDate` too. --- src/lib/core/columns/auto-columns.test.ts | 12 +++++ src/lib/core/columns/auto-columns.ts | 21 ++++++-- src/lib/core/grid/share-link.test.ts | 23 +++++++++ src/lib/core/grid/share-link.ts | 13 +++++ src/lib/core/grid/value-gate.test.ts | 62 ++++++++++++++++++++++- src/lib/core/grid/value-gate.ts | 33 +++++++----- 6 files changed, 145 insertions(+), 19 deletions(-) diff --git a/src/lib/core/columns/auto-columns.test.ts b/src/lib/core/columns/auto-columns.test.ts index 2dbbe74..3d21f30 100644 --- a/src/lib/core/columns/auto-columns.test.ts +++ b/src/lib/core/columns/auto-columns.test.ts @@ -128,6 +128,18 @@ describe('autoColumns', () => { expect(columns.region).toMatchObject({ type: 'badge', filter: 'set' }) }) + it('leaves a column of date-shaped codes as text, not as dates nobody can read', () => { + // Four digits, two, two: a part number, and every renderer would have + // drawn the column blank had the shape alone been enough. + const rows = [{ part: '1234-56-78' }, { part: '2400-99-01' }] + expect(autoColumns(rows)[0]).toMatchObject({ type: 'text' }) + }) + + it('leaves a day that no calendar has out of the date guess', () => { + const rows = [{ due: '2026-02-30' }, { due: '2026-04-31' }] + expect(autoColumns(rows)[0]).not.toMatchObject({ type: 'date' }) + }) + it('reads a Date object as a datetime, and an ISO stamp too', () => { const rows = [ { at: new Date('2026-01-01T10:00:00Z'), stamp: '2026-01-01T10:00:00Z' }, diff --git a/src/lib/core/columns/auto-columns.ts b/src/lib/core/columns/auto-columns.ts index bb7ad7e..6bb505c 100644 --- a/src/lib/core/columns/auto-columns.ts +++ b/src/lib/core/columns/auto-columns.ts @@ -1,4 +1,5 @@ import type { ColumnDef, ColumnType, FilterType } from '../types/index.js' +import { toDate } from '../utils/format.js' import { isBlank } from '../utils/value.js' import type { AutoColumnsOptions } from './auto-columns.types.js' @@ -46,12 +47,24 @@ function valuesOf(rows: unknown[], key: string): unknown[] { type Shape = { type: ColumnType; filter: FilterType } +/** + * The shape says which of the two date types a column is; `toDate` says + * whether it is a date at all. Shape alone types a column of `1234-56-78` + * part numbers as dates, and every cell in it then draws blank, because the + * renderer asks the same `toDate` and gets nothing. + */ +function isDay(value: unknown): boolean { + return typeof value === 'string' && DATE_ONLY.test(value) && toDate(value) !== null +} + +function isStamp(value: unknown): boolean { + return typeof value === 'string' && DATE_TIME.test(value) && toDate(value) !== null +} + function dateShape(values: unknown[]): Shape | null { const dates = values.filter((value) => value instanceof Date).length - const stamps = values.filter( - (value) => typeof value === 'string' && DATE_TIME.test(value) - ).length - const days = values.filter((value) => typeof value === 'string' && DATE_ONLY.test(value)).length + const stamps = values.filter(isStamp).length + const days = values.filter(isDay).length if (dates + stamps + days < values.length) return null return { type: days === values.length ? 'date' : 'datetime', filter: 'date' } diff --git a/src/lib/core/grid/share-link.test.ts b/src/lib/core/grid/share-link.test.ts index ce87f6d..6aa2aee 100644 --- a/src/lib/core/grid/share-link.test.ts +++ b/src/lib/core/grid/share-link.test.ts @@ -102,3 +102,26 @@ describe('encodeSnapshot and decodeSnapshot', () => { await expect(decodeSnapshot(future)).resolves.toBeNull() }) }) + +describe('canonicalJson', () => { + it('spells a value with a toJSON the way JSON.stringify does', () => { + // A snapshot also travels through localStorage as plain + // `JSON.stringify`. Rebuilding a Date from its own entries gives + // `{}`, and the two paths would then disagree about the same slice. + const at = new Date('2026-03-14T00:00:00.000Z') + const snapshot = { version: SNAPSHOT_VERSION, features: { views: { at } } } as GridSnapshot + + expect(canonicalJson(snapshot)).toContain('2026-03-14T00:00:00.000Z') + }) + + it('tells two snapshots apart when only such a value differs', () => { + const of = (iso: string) => + ({ + version: SNAPSHOT_VERSION, + features: { views: { at: new Date(iso) } } + }) as GridSnapshot + + expect(sameSnapshot(of('2026-03-14'), of('2026-03-15'))).toBe(false) + expect(sameSnapshot(of('2026-03-14'), of('2026-03-14'))).toBe(true) + }) +}) diff --git a/src/lib/core/grid/share-link.ts b/src/lib/core/grid/share-link.ts index 383fa59..c61a6cc 100644 --- a/src/lib/core/grid/share-link.ts +++ b/src/lib/core/grid/share-link.ts @@ -10,10 +10,23 @@ import { base64UrlToBytes, bytesToBase64Url } from '../utils/base64.js' export const SHARE_LIMIT = 1800 +/** + * Key order out of the JSON, so the same state always encodes to the same + * link and `sameSnapshot` can compare two states by their text. + * + * `toJSON` is honoured first, for the reason `JSON.stringify` honours it: a + * `Date` rebuilt from its own entries is `{}`. A snapshot slice is meant to be + * JSON-safe, but the other path a snapshot takes is `localStorage`, which is + * plain `JSON.stringify`, and the two disagreeing about the same slice is + * worse than either rule on its own. + */ function canonical(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonical) if (value === null || typeof value !== 'object') return value + const toJson = (value as { toJSON?: () => unknown }).toJSON + if (typeof toJson === 'function') return canonical(toJson.call(value)) + const entries = Object.entries(value as Record) .filter(([, item]) => item !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) diff --git a/src/lib/core/grid/value-gate.test.ts b/src/lib/core/grid/value-gate.test.ts index 0109960..eb94875 100644 --- a/src/lib/core/grid/value-gate.test.ts +++ b/src/lib/core/grid/value-gate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { createDataGrid } from './grid.svelte.js' import { buildRowNodes } from './row-node.js' -import { composeReaders, readCell, readerToken } from './value-gate.js' +import { composeReaders, gateReader, readCell, readerToken } from './value-gate.js' import type { CellValueScope, ColumnDef, GridFeature } from '../types/index.js' interface Row { @@ -75,3 +75,63 @@ describe('readerToken', () => { expect(readerToken(() => 'x')).not.toBe('-') }) }) + +describe('gateReader', () => { + interface Wide { + id: number + a: number + b: string + c: number + } + + function wideGrid(feature: GridFeature) { + return createDataGrid({ + data: Array.from({ length: 5000 }, (_, i) => ({ id: i, a: i, b: 'x', c: -i })), + columns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + getRowId: (row) => String(row.id), + features: [feature] + }) + } + + it('composes once per column however many cells the pass reads', () => { + // The whole reason the helper hands back a closure. Composing asks + // every gate for a reader, and asking per cell is what this stops: + // 15,000 compositions for the same 15,000 reads before the cache. + let composed = 0 + const grid = wideGrid({ + id: 'counter', + cellValue: () => { + composed++ + return (value) => value + } + }) + + const read = gateReader(grid, 'export') + for (const node of grid.nodes) for (const def of grid.columns.leafDefs) read(node, def) + + expect(composed).toBe(grid.columns.leafDefs.length) + }) + + it('reads through the gate, not around it', () => { + const grid = wideGrid({ + id: 'mask', + cellValue: ({ column }) => (column.id === 'b' ? () => 'hidden' : undefined) + }) + const read = gateReader(grid, 'export') + const [first] = grid.nodes + + expect(read(first!, { id: 'b' })).toBe('hidden') + expect(read(first!, { id: 'a' })).toBe(0) + }) + + it('falls through to the raw value for a column the grid does not know', () => { + const grid = wideGrid({ id: 'none' }) + const read = gateReader(grid, 'export') + const [first] = grid.nodes + + // A def a feature made up itself still reads rather than answering + // undefined, and the miss is cached like any other lookup. + expect(read(first!, { id: 'c' })).toBe(-0) + expect(read(first!, { id: 'nope' })).toBeUndefined() + }) +}) diff --git a/src/lib/core/grid/value-gate.ts b/src/lib/core/grid/value-gate.ts index c8f3388..5d70cae 100644 --- a/src/lib/core/grid/value-gate.ts +++ b/src/lib/core/grid/value-gate.ts @@ -3,7 +3,6 @@ import type { CellValueReader, CellValueScope, ColumnDef, - ColumnState, GridFeature, RowNode } from '../types/index.js' @@ -88,26 +87,32 @@ export function rawRead(node: RowNode, column: ColumnDef): unk * columns: aggregation, export, a fill, a find, the statistics behind a colour * scale. * - * `grid.getValue` needs the `ColumnState`, and looking that up per cell turns - * a column scan into a map lookup per cell. Resolving each column once per - * pass is the whole point of handing back a closure rather than exposing a - * function that takes the grid: a caller is meant to build one of these, use - * it for a pass, and drop it. + * What is cached is the composed reader, not the column. Composing is the + * expensive half: `readerFor` allocates a scope and asks every registered gate + * for its reader, and the answer is fixed for the column and the purpose, so a + * pass that asked per cell would do that work once per cell instead of once + * per column. Its own docstring says to hoist it out of the row loop; this is + * that hoist, for the callers whose loop is over cells rather than rows. * * A column the grid does not know falls through to the raw value rather than * to nothing, so a pass over a `ColumnDef` a feature made up itself still - * reads. The `null` in the cache is what tells "looked up, not there" from - * "not looked up yet". + * reads. The `null` entry is what tells "looked up, not there" from "not + * looked up yet". */ export function gateReader(grid: GridState, purpose: CellValuePurpose): CellRead { - const states = new Map | null>() + interface Gate { + def: ColumnDef + reader: CellValueReader | undefined + } + const gates = new Map() return (node, column) => { - let state = states.get(column.id) - if (state === undefined) { - state = grid.columns.get(column.id) ?? null - states.set(column.id, state) + let gate = gates.get(column.id) + if (gate === undefined) { + const state = grid.columns.get(column.id) + gate = state ? { def: state.def, reader: grid.readerFor(column.id, purpose) } : null + gates.set(column.id, gate) } - return state ? grid.getValue(node, state, purpose) : getCellValue(node.row, column) + return gate ? readCell(node, gate.def, gate.reader) : getCellValue(node.row, column) } } From 95ea349102bbff84e4356c306d1ebb9ba66c6343 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 18:53:35 +0700 Subject: [PATCH 03/46] feat(features): take all sixteen feature modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping, formula, the filter builder, range selection, find and replace, the import wizard, master/detail, saved views, policy, tree, show-values-as, conditional formatting, the server row model, xlsx, the command palette and the worker row model, with their tests beside them. They arrive nearly as they were written: every one consumed the grid only through its public extension points, so the work was pointing the imports at the folder barrels the existing features already use, and sending the declaration merging at the module that declares `GridApi`. Three things they had of their own become things they share. Labels were read from a global. They now come off `grid.labels`, which is the reader the rest of the library uses and, unlike the global, says what this grid's language is rather than the application's. Slot classes were read from a second table. That table is now the same one, moved down to `core/theme` so a feature can name a slot without importing from the layer that imports it, and `slotClass` is what a `cellDecoration` asks. The whole-app config moved with it; its old path re-exports, so nothing an application imports has moved. `isBlank` was written out four more times, once per module that needed it. There is one. Two of them were reading past the value gate, which the guard in `value-gate.test.ts` is there to notice. Both had already built the reader and then not used it: find matched on raw values, so a masked column stayed searchable and a hit pointed at a cell the user cannot read; and a colour scale or data bar was measured against real numbers, which draws the hidden value as a bar width. Both now read through the gate they hold. The worker is listed as allowed instead: it filters and sorts, which RFC EP5 §7 already records as deliberately open, and it takes a `readValue` for callers who want otherwise. The saved views' own `share-link.ts` becomes `share-param.ts`, the kernel having taken that name for the codec, and its storage key drops a suffix that named a tier. --- eslint.config.js | 8 + src/lib/components/datagrid.config.ts | 59 +- src/lib/components/datagrid.variants.ts | 264 +---- src/lib/components/internal/icons.data.ts | 15 + src/lib/core/theme/config.ts | 72 ++ src/lib/core/theme/index.ts | 10 + src/lib/core/theme/slots.ts | 270 ++++++ .../advanced-filter/advanced-filter.svelte.ts | 157 +++ .../advanced-filter/advanced-filter.test.ts | 345 +++++++ .../advanced-filter/advanced-filter.types.ts | 83 ++ .../advanced-filter/community-parity.test.ts | 206 ++++ .../features/advanced-filter/evaluate.test.ts | 185 ++++ src/lib/features/advanced-filter/evaluate.ts | 264 +++++ src/lib/features/advanced-filter/index.ts | 22 + .../advanced-filter/operators.test.ts | 104 ++ src/lib/features/advanced-filter/operators.ts | 80 ++ .../command-palette-commands.ts | 192 ++++ .../command-palette/command-palette.svelte.ts | 66 ++ .../command-palette/command-palette.test.ts | 137 +++ .../command-palette/command-palette.types.ts | 33 + src/lib/features/command-palette/index.ts | 13 + .../conditional-formatting.svelte.ts | 204 ++++ .../conditional-formatting.test.ts | 403 ++++++++ .../conditional-formatting.types.ts | 190 ++++ .../features/conditional-formatting/index.ts | 39 + .../conditional-formatting/paint.test.ts | 195 ++++ .../features/conditional-formatting/paint.ts | 148 +++ .../conditional-formatting/rules.test.ts | 122 +++ .../features/conditional-formatting/rules.ts | 170 ++++ .../conditional-formatting/stats.test.ts | 171 ++++ .../features/conditional-formatting/stats.ts | 195 ++++ src/lib/features/data-import/coerce.test.ts | 146 +++ src/lib/features/data-import/coerce.ts | 200 ++++ src/lib/features/data-import/combine.test.ts | 260 +++++ .../data-import/data-import.svelte.ts | 453 +++++++++ .../features/data-import/data-import.types.ts | 128 +++ .../features/data-import/delimited.test.ts | 109 +++ src/lib/features/data-import/delimited.ts | 156 +++ .../features/data-import/duplicates.test.ts | 215 +++++ src/lib/features/data-import/duplicates.ts | 103 ++ src/lib/features/data-import/ids.ts | 7 + src/lib/features/data-import/index.ts | 49 + src/lib/features/data-import/mapping.ts | 95 ++ src/lib/features/data-import/review.test.ts | 212 ++++ src/lib/features/data-import/security.test.ts | 120 +++ src/lib/features/data-import/stage.test.ts | 236 +++++ src/lib/features/data-import/stage.ts | 171 ++++ src/lib/features/data-import/unzip.ts | 183 ++++ .../features/data-import/xlsx-read.test.ts | 182 ++++ src/lib/features/data-import/xlsx-read.ts | 255 +++++ .../find-replace/find-replace.svelte.ts | 220 +++++ .../find-replace/find-replace.test.ts | 272 ++++++ .../find-replace/find-replace.types.ts | 36 + src/lib/features/find-replace/find.test.ts | 119 +++ src/lib/features/find-replace/find.ts | 99 ++ src/lib/features/find-replace/index.ts | 3 + src/lib/features/find-replace/server.test.ts | 131 +++ src/lib/features/find-replace/verify.test.ts | 109 +++ src/lib/features/formula/compiled.ts | 187 ++++ src/lib/features/formula/evaluate.ts | 430 +++++++++ src/lib/features/formula/formula-grid.test.ts | 265 +++++ src/lib/features/formula/formula-tree.test.ts | 166 ++++ src/lib/features/formula/formula.svelte.ts | 109 +++ src/lib/features/formula/formula.test.ts | 330 +++++++ src/lib/features/formula/formula.types.ts | 46 + src/lib/features/formula/index.ts | 14 + src/lib/features/formula/not-editable.test.ts | 90 ++ src/lib/features/formula/order.ts | 37 + src/lib/features/formula/parse.ts | 259 +++++ src/lib/features/formula/tokenize.ts | 166 ++++ src/lib/features/grouping/aggregate.test.ts | 95 ++ src/lib/features/grouping/aggregate.ts | 103 ++ src/lib/features/grouping/group-nodes.test.ts | 148 +++ src/lib/features/grouping/group-nodes.ts | 107 +++ src/lib/features/grouping/grouping.svelte.ts | 299 ++++++ src/lib/features/grouping/grouping.test.ts | 308 ++++++ src/lib/features/grouping/grouping.types.ts | 88 ++ src/lib/features/grouping/index.ts | 15 + src/lib/features/grouping/seen-groups.ts | 14 + .../features/grouping/totals-nodes.test.ts | 235 +++++ src/lib/features/grouping/totals-nodes.ts | 96 ++ .../master-detail/detail-nodes.test.ts | 55 ++ .../features/master-detail/detail-nodes.ts | 44 + src/lib/features/master-detail/index.ts | 15 + .../master-detail/master-detail.svelte.ts | 79 ++ .../master-detail/master-detail.types.ts | 17 + src/lib/features/policy/index.ts | 3 + src/lib/features/policy/leaks.test.ts | 197 ++++ src/lib/features/policy/masks.test.ts | 41 + src/lib/features/policy/masks.ts | 56 ++ src/lib/features/policy/policy.svelte.ts | 126 +++ src/lib/features/policy/policy.types.ts | 59 ++ src/lib/features/range-selection/fill.test.ts | 223 +++++ src/lib/features/range-selection/fill.ts | 205 ++++ src/lib/features/range-selection/index.ts | 29 + .../range-selection/range-clipboard.test.ts | 89 ++ .../range-selection/range-clipboard.ts | 156 +++ .../range-selection/range-selection.svelte.ts | 734 ++++++++++++++ .../range-selection/range-selection.test.ts | 907 ++++++++++++++++++ .../range-selection/range-selection.types.ts | 69 ++ .../features/range-selection/range.test.ts | 138 +++ src/lib/features/range-selection/range.ts | 101 ++ .../features/range-selection/verify.test.ts | 245 +++++ src/lib/features/saved-views/index.ts | 4 + .../saved-views/saved-views.svelte.ts | 166 ++++ .../features/saved-views/saved-views.test.ts | 295 ++++++ .../features/saved-views/saved-views.types.ts | 46 + src/lib/features/saved-views/share-param.ts | 17 + src/lib/features/saved-views/storage.ts | 52 + .../features/server-row-model/blocks.test.ts | 61 ++ src/lib/features/server-row-model/blocks.ts | 46 + src/lib/features/server-row-model/index.ts | 24 + .../server-row-model/loaded-blocks.ts | 18 + .../server-row-model.svelte.ts | 310 ++++++ .../server-row-model.types.ts | 117 +++ src/lib/features/show-values-as/index.ts | 9 + src/lib/features/show-values-as/shares.ts | 141 +++ .../show-values-as/show-values-as.svelte.ts | 97 ++ .../show-values-as/show-values-as.test.ts | 231 +++++ .../show-values-as/show-values-as.types.ts | 26 + src/lib/features/tree/index.ts | 3 + src/lib/features/tree/tree-nodes.test.ts | 121 +++ src/lib/features/tree/tree-nodes.ts | 75 ++ src/lib/features/tree/tree.svelte.ts | 118 +++ src/lib/features/tree/tree.types.ts | 20 + src/lib/features/worker-row-model/columnar.ts | 231 +++++ .../features/worker-row-model/combine.test.ts | 130 +++ src/lib/features/worker-row-model/engine.ts | 118 +++ src/lib/features/worker-row-model/grouping.ts | 78 ++ .../worker-row-model/hardening.test.ts | 184 ++++ src/lib/features/worker-row-model/index.ts | 6 + .../features/worker-row-model/parity.test.ts | 182 ++++ .../features/worker-row-model/predicates.ts | 117 +++ src/lib/features/worker-row-model/protocol.ts | 50 + src/lib/features/worker-row-model/query.ts | 289 ++++++ .../worker-row-model/worker-row-model.test.ts | 266 +++++ .../worker-row-model/worker-row-model.ts | 331 +++++++ .../worker-row-model.types.ts | 99 ++ src/lib/features/worker-row-model/worker.ts | 9 + .../features/xlsx/conditional-format.test.ts | 105 ++ src/lib/features/xlsx/conditional-format.ts | 180 ++++ src/lib/features/xlsx/export-rows.test.ts | 322 +++++++ src/lib/features/xlsx/export.ts | 286 ++++++ src/lib/features/xlsx/index.ts | 44 + src/lib/features/xlsx/sheet.ts | 294 ++++++ src/lib/features/xlsx/styles.test.ts | 126 +++ src/lib/features/xlsx/styles.ts | 230 +++++ src/lib/features/xlsx/styles.types.ts | 56 ++ src/lib/features/xlsx/workbook.ts | 124 +++ src/lib/features/xlsx/xlsx.test.ts | 749 +++++++++++++++ src/lib/features/xlsx/zip.ts | 211 ++++ src/tests/value-gate.test.ts | 7 +- 152 files changed, 22395 insertions(+), 312 deletions(-) create mode 100644 src/lib/core/theme/config.ts create mode 100644 src/lib/core/theme/index.ts create mode 100644 src/lib/core/theme/slots.ts create mode 100644 src/lib/features/advanced-filter/advanced-filter.svelte.ts create mode 100644 src/lib/features/advanced-filter/advanced-filter.test.ts create mode 100644 src/lib/features/advanced-filter/advanced-filter.types.ts create mode 100644 src/lib/features/advanced-filter/community-parity.test.ts create mode 100644 src/lib/features/advanced-filter/evaluate.test.ts create mode 100644 src/lib/features/advanced-filter/evaluate.ts create mode 100644 src/lib/features/advanced-filter/index.ts create mode 100644 src/lib/features/advanced-filter/operators.test.ts create mode 100644 src/lib/features/advanced-filter/operators.ts create mode 100644 src/lib/features/command-palette/command-palette-commands.ts create mode 100644 src/lib/features/command-palette/command-palette.svelte.ts create mode 100644 src/lib/features/command-palette/command-palette.test.ts create mode 100644 src/lib/features/command-palette/command-palette.types.ts create mode 100644 src/lib/features/command-palette/index.ts create mode 100644 src/lib/features/conditional-formatting/conditional-formatting.svelte.ts create mode 100644 src/lib/features/conditional-formatting/conditional-formatting.test.ts create mode 100644 src/lib/features/conditional-formatting/conditional-formatting.types.ts create mode 100644 src/lib/features/conditional-formatting/index.ts create mode 100644 src/lib/features/conditional-formatting/paint.test.ts create mode 100644 src/lib/features/conditional-formatting/paint.ts create mode 100644 src/lib/features/conditional-formatting/rules.test.ts create mode 100644 src/lib/features/conditional-formatting/rules.ts create mode 100644 src/lib/features/conditional-formatting/stats.test.ts create mode 100644 src/lib/features/conditional-formatting/stats.ts create mode 100644 src/lib/features/data-import/coerce.test.ts create mode 100644 src/lib/features/data-import/coerce.ts create mode 100644 src/lib/features/data-import/combine.test.ts create mode 100644 src/lib/features/data-import/data-import.svelte.ts create mode 100644 src/lib/features/data-import/data-import.types.ts create mode 100644 src/lib/features/data-import/delimited.test.ts create mode 100644 src/lib/features/data-import/delimited.ts create mode 100644 src/lib/features/data-import/duplicates.test.ts create mode 100644 src/lib/features/data-import/duplicates.ts create mode 100644 src/lib/features/data-import/ids.ts create mode 100644 src/lib/features/data-import/index.ts create mode 100644 src/lib/features/data-import/mapping.ts create mode 100644 src/lib/features/data-import/review.test.ts create mode 100644 src/lib/features/data-import/security.test.ts create mode 100644 src/lib/features/data-import/stage.test.ts create mode 100644 src/lib/features/data-import/stage.ts create mode 100644 src/lib/features/data-import/unzip.ts create mode 100644 src/lib/features/data-import/xlsx-read.test.ts create mode 100644 src/lib/features/data-import/xlsx-read.ts create mode 100644 src/lib/features/find-replace/find-replace.svelte.ts create mode 100644 src/lib/features/find-replace/find-replace.test.ts create mode 100644 src/lib/features/find-replace/find-replace.types.ts create mode 100644 src/lib/features/find-replace/find.test.ts create mode 100644 src/lib/features/find-replace/find.ts create mode 100644 src/lib/features/find-replace/index.ts create mode 100644 src/lib/features/find-replace/server.test.ts create mode 100644 src/lib/features/find-replace/verify.test.ts create mode 100644 src/lib/features/formula/compiled.ts create mode 100644 src/lib/features/formula/evaluate.ts create mode 100644 src/lib/features/formula/formula-grid.test.ts create mode 100644 src/lib/features/formula/formula-tree.test.ts create mode 100644 src/lib/features/formula/formula.svelte.ts create mode 100644 src/lib/features/formula/formula.test.ts create mode 100644 src/lib/features/formula/formula.types.ts create mode 100644 src/lib/features/formula/index.ts create mode 100644 src/lib/features/formula/not-editable.test.ts create mode 100644 src/lib/features/formula/order.ts create mode 100644 src/lib/features/formula/parse.ts create mode 100644 src/lib/features/formula/tokenize.ts create mode 100644 src/lib/features/grouping/aggregate.test.ts create mode 100644 src/lib/features/grouping/aggregate.ts create mode 100644 src/lib/features/grouping/group-nodes.test.ts create mode 100644 src/lib/features/grouping/group-nodes.ts create mode 100644 src/lib/features/grouping/grouping.svelte.ts create mode 100644 src/lib/features/grouping/grouping.test.ts create mode 100644 src/lib/features/grouping/grouping.types.ts create mode 100644 src/lib/features/grouping/index.ts create mode 100644 src/lib/features/grouping/seen-groups.ts create mode 100644 src/lib/features/grouping/totals-nodes.test.ts create mode 100644 src/lib/features/grouping/totals-nodes.ts create mode 100644 src/lib/features/master-detail/detail-nodes.test.ts create mode 100644 src/lib/features/master-detail/detail-nodes.ts create mode 100644 src/lib/features/master-detail/index.ts create mode 100644 src/lib/features/master-detail/master-detail.svelte.ts create mode 100644 src/lib/features/master-detail/master-detail.types.ts create mode 100644 src/lib/features/policy/index.ts create mode 100644 src/lib/features/policy/leaks.test.ts create mode 100644 src/lib/features/policy/masks.test.ts create mode 100644 src/lib/features/policy/masks.ts create mode 100644 src/lib/features/policy/policy.svelte.ts create mode 100644 src/lib/features/policy/policy.types.ts create mode 100644 src/lib/features/range-selection/fill.test.ts create mode 100644 src/lib/features/range-selection/fill.ts create mode 100644 src/lib/features/range-selection/index.ts create mode 100644 src/lib/features/range-selection/range-clipboard.test.ts create mode 100644 src/lib/features/range-selection/range-clipboard.ts create mode 100644 src/lib/features/range-selection/range-selection.svelte.ts create mode 100644 src/lib/features/range-selection/range-selection.test.ts create mode 100644 src/lib/features/range-selection/range-selection.types.ts create mode 100644 src/lib/features/range-selection/range.test.ts create mode 100644 src/lib/features/range-selection/range.ts create mode 100644 src/lib/features/range-selection/verify.test.ts create mode 100644 src/lib/features/saved-views/index.ts create mode 100644 src/lib/features/saved-views/saved-views.svelte.ts create mode 100644 src/lib/features/saved-views/saved-views.test.ts create mode 100644 src/lib/features/saved-views/saved-views.types.ts create mode 100644 src/lib/features/saved-views/share-param.ts create mode 100644 src/lib/features/saved-views/storage.ts create mode 100644 src/lib/features/server-row-model/blocks.test.ts create mode 100644 src/lib/features/server-row-model/blocks.ts create mode 100644 src/lib/features/server-row-model/index.ts create mode 100644 src/lib/features/server-row-model/loaded-blocks.ts create mode 100644 src/lib/features/server-row-model/server-row-model.svelte.ts create mode 100644 src/lib/features/server-row-model/server-row-model.types.ts create mode 100644 src/lib/features/show-values-as/index.ts create mode 100644 src/lib/features/show-values-as/shares.ts create mode 100644 src/lib/features/show-values-as/show-values-as.svelte.ts create mode 100644 src/lib/features/show-values-as/show-values-as.test.ts create mode 100644 src/lib/features/show-values-as/show-values-as.types.ts create mode 100644 src/lib/features/tree/index.ts create mode 100644 src/lib/features/tree/tree-nodes.test.ts create mode 100644 src/lib/features/tree/tree-nodes.ts create mode 100644 src/lib/features/tree/tree.svelte.ts create mode 100644 src/lib/features/tree/tree.types.ts create mode 100644 src/lib/features/worker-row-model/columnar.ts create mode 100644 src/lib/features/worker-row-model/combine.test.ts create mode 100644 src/lib/features/worker-row-model/engine.ts create mode 100644 src/lib/features/worker-row-model/grouping.ts create mode 100644 src/lib/features/worker-row-model/hardening.test.ts create mode 100644 src/lib/features/worker-row-model/index.ts create mode 100644 src/lib/features/worker-row-model/parity.test.ts create mode 100644 src/lib/features/worker-row-model/predicates.ts create mode 100644 src/lib/features/worker-row-model/protocol.ts create mode 100644 src/lib/features/worker-row-model/query.ts create mode 100644 src/lib/features/worker-row-model/worker-row-model.test.ts create mode 100644 src/lib/features/worker-row-model/worker-row-model.ts create mode 100644 src/lib/features/worker-row-model/worker-row-model.types.ts create mode 100644 src/lib/features/worker-row-model/worker.ts create mode 100644 src/lib/features/xlsx/conditional-format.test.ts create mode 100644 src/lib/features/xlsx/conditional-format.ts create mode 100644 src/lib/features/xlsx/export-rows.test.ts create mode 100644 src/lib/features/xlsx/export.ts create mode 100644 src/lib/features/xlsx/index.ts create mode 100644 src/lib/features/xlsx/sheet.ts create mode 100644 src/lib/features/xlsx/styles.test.ts create mode 100644 src/lib/features/xlsx/styles.ts create mode 100644 src/lib/features/xlsx/styles.types.ts create mode 100644 src/lib/features/xlsx/workbook.ts create mode 100644 src/lib/features/xlsx/xlsx.test.ts create mode 100644 src/lib/features/xlsx/zip.ts diff --git a/eslint.config.js b/eslint.config.js index 38d95bf..5442f88 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,6 +40,14 @@ export default defineConfig( 'no-console': 'off' } }, + { + // Escaping control characters is what this module is for: they are + // legal in a cell and illegal in the XML the sheet is written as. + files: ['src/lib/features/xlsx/**'], + rules: { + 'no-control-regex': 'off' + } + }, { files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], diff --git a/src/lib/components/datagrid.config.ts b/src/lib/components/datagrid.config.ts index e1ae9a2..4cfbad3 100644 --- a/src/lib/components/datagrid.config.ts +++ b/src/lib/components/datagrid.config.ts @@ -1,53 +1,12 @@ -import type { Density } from '../core/types/index.js' -import { datagridDefaults, type DataGridUi } from './datagrid.variants.js' - /** - * Whole-app defaults for every grid, in the same `{ defaultVariants, slots }` - * shape sv5ui components take from `defineConfig`. Set it once at startup; - * grids read it when they mount, so it is not a reactive store. - * - * It lives here rather than in sv5ui's config because sv5ui does not export - * the reader its components use internally. When it does, this store becomes - * the fallback and `defineConfig({ datagrid })` starts feeding the same - * values — which is why the shape is identical. + * The whole-app config now lives in `core/theme`, beside the slot table it + * overrides, so a feature can read it without importing from the layer that + * imports it. Re-exported here because this is the path the public API names. */ -export interface DataGridConfig { - /** Defaults for variants that apply to a whole grid. */ - defaultVariants: { density: Density } - /** Classes appended to every grid's slots, before any per-instance `ui`. */ - slots: DataGridUi -} - -let current: DataGridConfig = clone(datagridDefaults) - -function clone(config: DataGridConfig): DataGridConfig { - return { defaultVariants: { ...config.defaultVariants }, slots: { ...config.slots } } -} - -/** - * Overrides the defaults for every grid in the app. - * - * @example - * ```ts - * defineDataGridConfig({ - * defaultVariants: { density: 'compact' }, - * slots: { cell: 'font-mono', headerCell: 'uppercase tracking-wide' } - * }) - * ``` - */ -export function defineDataGridConfig(config: Partial): void { - current = { - defaultVariants: { ...current.defaultVariants, ...config.defaultVariants }, - slots: { ...current.slots, ...config.slots } - } -} - -/** The config a grid reads when it mounts. */ -export function getDataGridConfig(): DataGridConfig { - return current -} -/** Restores the built-in defaults. Mainly for tests, which must not leak state. */ -export function resetDataGridConfig(): void { - current = clone(datagridDefaults) -} +export { + defineDataGridConfig, + getDataGridConfig, + resetDataGridConfig, + type DataGridConfig +} from '../core/theme/index.js' diff --git a/src/lib/components/datagrid.variants.ts b/src/lib/components/datagrid.variants.ts index b674638..a8a4f0c 100644 --- a/src/lib/components/datagrid.variants.ts +++ b/src/lib/components/datagrid.variants.ts @@ -1,260 +1,8 @@ -import type { ClassNameValue } from 'tailwind-merge' import { tv, type VariantProps } from 'tailwind-variants' -import type { Density } from '../core/types/index.js' +import { datagridSlots, type DataGridSlots, type DataGridUi } from '../core/theme/index.js' export const datagridVariants = tv({ - slots: { - root: 'w-full space-y-3', - toolbar: 'flex flex-wrap items-center gap-2', - viewport: - 'group/grid relative w-full overflow-auto rounded-lg border border-outline-variant text-sm', - header: 'group/header sticky top-0 z-10 min-w-min border-b border-outline-variant bg-surface-container', - headerRow: 'grid [grid-template-columns:var(--dg-grid-template)]', - groupRow: - 'relative grid min-w-min [grid-template-columns:var(--dg-grid-template)] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-[16] after:h-px after:bg-outline-variant', - headerCell: - 'group/head relative flex h-(--dg-row-h) min-w-0 items-center gap-1 overflow-hidden px-3 font-medium whitespace-nowrap text-on-surface-variant outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', - groupCell: - 'relative flex h-(--dg-row-h) min-w-0 items-center justify-center gap-1 overflow-hidden px-3 text-xs font-medium whitespace-nowrap text-on-surface-variant outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', - /** What a `headerGroupCell` snippet draws into: shrinkable, and clipped. */ - groupContent: 'flex min-w-0 items-center gap-1 overflow-hidden', - /** - * The strip a railed group folds down to, running the body's height. - * A closed drawer rather than a gap: its own surface, edged on both - * sides, and warm to the pointer, since clicking anywhere down it is - * what opens the group again. - */ - rail: 'absolute inset-y-0 z-[7] cursor-pointer bg-surface-container-high', - /** - * The drawer's head, over the header rows. Above the lines the header - * draws between its levels and along its foot, because a drawer with - * the header's rules struck across it is not one thing but three. - */ - railHead: - 'absolute top-0 -bottom-px z-[17] flex cursor-pointer items-start justify-center bg-surface-container-high', - /** - * The caret stands on the group cell under the head, which the head - * covers, so the drawer shows the caret itself. Not a ring: the - * drawer is two elements meeting, and a box around each of them is - * two boxes rather than one drawer. A bar down the leading edge and - * a wash over the surface both carry through the seam. Tied to - * focus inside the grid, so it goes out with the grid's focus - * rather than sitting on a page nobody is on. - */ - railFocus: - 'group-has-[:focus-visible]/grid:bg-primary/10 group-has-[:focus-visible]/grid:shadow-[inset_2px_0_0_0_var(--color-primary)]', - /** - * Every cell over a folded group's strip, header included, so the - * drawer is one band from the top of the header to the last row - * rather than a patch that starts where the rows do. - */ - railSurface: 'bg-surface-container-high focus-visible:ring-0', - /** - * What the strip holds, held in the middle of what is on screen: the - * way back, and the name of what is folded. - */ - railInner: 'flex flex-col items-center gap-2 py-3 text-on-surface-variant', - /** The group's name, turned to read up the strip. */ - railLabel: - 'max-h-[50vh] truncate text-xs font-medium tracking-wide [writing-mode:vertical-rl] [rotate:180deg]', - /** Room kept at the trailing edge for the fold toggle to sit in. */ - groupCellFoldable: 'pe-8', - groupToggle: 'absolute inset-y-0.5 end-1 flex items-center', - sortButton: - 'inline-flex min-w-0 cursor-pointer items-center gap-1 truncate select-none transition-colors [text-align:inherit] [text-transform:inherit] hover:text-on-surface', - resizeHandle: - 'absolute inset-y-0 end-0 z-10 w-1.5 cursor-col-resize touch-none select-none hover:bg-primary/40 active:bg-primary', - headerControls: - 'absolute inset-y-0.5 end-1.5 flex items-center gap-0.5 bg-surface-container ps-2 opacity-0 transition-opacity group-hover/head:opacity-100 group-focus-within/head:opacity-100 [@media(hover:none)]:opacity-100', - headerControlsPinned: 'opacity-100', - menuButton: 'shrink-0', - dropIndicator: 'pointer-events-none absolute inset-y-0 z-20 w-0.5 bg-primary', - rowHandle: - 'flex size-full cursor-grab items-center justify-center text-on-surface-variant outline-none touch-pan-y select-none hover:text-on-surface focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset active:cursor-grabbing disabled:cursor-default disabled:opacity-30', - rowDropIndicator: 'pointer-events-none absolute inset-x-0 z-20 h-0.5 bg-primary', - rowGhost: 'overflow-hidden rounded-md bg-surface shadow-lg ring-1 ring-primary', - rowDragging: 'opacity-40', - pinnedCell: - 'sticky z-[5] bg-surface group-hover/row:bg-surface-container-low border-outline-variant', - pinnedHeaderCell: 'sticky z-[15] bg-surface-container', - chooserItem: 'flex items-center gap-2 px-1 py-1', - body: 'relative', - bodyOffset: 'will-change-transform', - row: 'group/row relative grid min-w-min [grid-template-columns:var(--dg-grid-template)] transition-colors after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-[6] after:h-px after:bg-outline-variant last:after:hidden hover:bg-surface-container-low', - cell: 'flex min-h-(--dg-row-h) min-w-0 items-center overflow-hidden px-3 py-(--dg-cell-py) text-on-surface outline-none', - cellFocus: - 'focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', - cellEditing: 'overflow-visible', - cellRowSpan: 'relative z-[7] overflow-visible p-0', - rowSpanFill: - 'absolute inset-x-0 z-[7] flex min-w-0 items-start overflow-hidden bg-surface px-3 py-(--dg-cell-py) after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-outline-variant', - rowSpanFillLast: 'after:hidden', - rowSpanEdge: 'border-e border-outline-variant', - rowSpanEdgeStart: 'border-s border-outline-variant', - pinnedCellRaised: - 'z-[8] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-outline-variant group-last/row:after:hidden', - pinnedCellSelected: - 'before:pointer-events-none before:absolute before:inset-0 before:bg-primary/8', - empty: 'w-full p-4', - toggleButton: - 'me-1 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded text-on-surface-variant transition-colors hover:bg-surface-container hover:text-on-surface', - cellEditor: 'relative flex h-full min-h-(--dg-row-h) w-full min-w-0 items-center', - // z-7, not higher: above the row's own separator, which is at 6 and - // would otherwise paint its grey line along the bottom of the ring and - // leave three edges looking one weight and the fourth another. Below - // the pinned cells at 8, which have to stay over anything scrolling - // under them, an open editor included. - cellEditorFlat: 'z-[7] bg-surface ring-2 ring-inset ring-primary', - cellEditorInRow: - 'bg-surface after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-transparent focus-within:z-[7] focus-within:bg-primary/8 focus-within:after:bg-primary', - cellEditorInRowWidget: 'bg-surface focus-within:z-[7]', - cellEditorInRowDivider: 'border-s border-outline-variant', - cellEditorPad: 'px-2', - cellEditorWide: 'min-w-max', - cellEditorField: 'w-full', - cellError: - 'absolute top-full start-0 z-30 mt-0.5 rounded bg-error px-1.5 py-0.5 text-xs whitespace-nowrap text-on-error shadow-sm', - cellEditable: 'cursor-text', - tooltipTrigger: - '-mx-3 -my-(--dg-cell-py) flex min-w-0 grow items-center overflow-hidden px-3 py-(--dg-cell-py)', - fullWidthCell: - 'min-h-(--dg-row-h) min-w-0 overflow-hidden bg-surface-container-lowest p-3 text-on-surface outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', - pinnedRow: - 'group/row relative grid min-w-min [grid-template-columns:var(--dg-grid-template)] bg-surface after:pointer-events-none after:absolute after:inset-x-0 after:z-[6] after:h-px after:bg-outline-variant', - pinnedRowsTop: 'sticky z-[9] min-w-min shadow-sm', - pinnedRowsBottom: 'sticky bottom-0 z-[9] min-w-min shadow-[0_-1px_2px_rgba(0,0,0,0.05)]', - rowSelected: - 'before:pointer-events-none before:absolute before:inset-0 before:z-[6] before:bg-primary/8', - rowEditing: - 'after:pointer-events-none after:absolute after:inset-0 after:z-[8] after:ring-2 after:ring-inset after:ring-primary', - groupBoundary: 'border-e border-outline-variant', - /** - * A drawer draws both of its own edges, so it is framed the same on - * both sides wherever it stands, including in the middle of a group - * where the grid draws no line of its own. The cells beside it give - * theirs up for the same reason. - */ - railEdge: 'border-s border-outline-variant', - headerDivider: 'border-e border-outline-variant', - filterRow: - 'grid min-w-min border-t border-outline-variant bg-surface-container-low [grid-template-columns:var(--dg-grid-template)]', - filterCell: - 'relative flex h-(--dg-row-h) min-w-0 items-center gap-1 overflow-hidden px-1.5 outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', - filterCellPinned: 'sticky z-[15] bg-surface-container-low', - filterSummary: 'min-w-0 grow truncate text-xs text-on-surface-variant', - filterPanel: - 'fixed z-40 flex w-68 flex-col gap-2 rounded-lg border border-outline-variant bg-surface p-3 shadow-lg', - filterChips: 'flex flex-wrap items-center gap-1.5', - statusBar: 'flex items-center gap-2 text-xs text-on-surface-variant', - footer: 'flex flex-wrap items-center justify-end gap-x-3 gap-y-2', - - // Grouping, range selection, the tool panel, the filter builder, the - // import wizard, conditional formatting, saved views, find and replace - // and the command palette. Registered here rather than in a second - // table so an app overrides every slot the grid has in one `ui` prop. - groupPanel: - 'flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-outline-variant px-3 py-2', - groupPanelLabel: 'text-xs font-medium text-on-surface-variant', - groupPanelChip: - 'inline-flex items-center gap-1 rounded-md bg-surface-container py-0.5 pr-0.5 pl-2', - groupPanelChipLabel: 'text-xs font-medium text-on-surface', - groupPanelSeparator: 'size-3.5 text-on-surface-variant', - groupPanelEmpty: 'text-xs text-on-surface-variant', - - rangeStatusBar: - 'flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-on-surface-variant', - rangeStatusBarValue: 'text-on-surface', - - rangeCell: - 'relative bg-primary/10 before:pointer-events-none before:absolute before:inset-0 before:z-[2] before:border-primary/45', - rangeEdgeTop: 'before:border-t', - rangeEdgeBottom: 'before:border-b', - rangeEdgeStart: 'before:border-s', - rangeEdgeEnd: 'before:border-e', - - fillHandle: - 'after:absolute after:end-0 after:bottom-0 after:z-20 after:size-[7px] after:cursor-crosshair after:bg-primary after:content-[""]', - - cutSource: - 'relative before:pointer-events-none before:absolute before:inset-0 before:z-[3] before:border before:border-dashed before:border-primary', - - moveTarget: - 'relative bg-tertiary/10 before:pointer-events-none before:absolute before:inset-0 before:z-[3] before:border before:border-tertiary/60', - - fillPreview: - 'relative bg-primary/5 before:pointer-events-none before:absolute before:inset-0 before:z-[2] before:border-dashed before:border-primary/45', - - toolPanel: - 'flex w-full flex-col gap-3 rounded-lg border border-outline-variant bg-surface p-3 lg:w-72 lg:shrink-0', - toolPanelCollapsed: - 'flex w-full flex-row items-center justify-between gap-2 rounded-lg border border-outline-variant bg-surface p-2 lg:w-auto lg:flex-col lg:justify-start', - toolPanelHeader: 'flex items-center justify-between gap-2', - toolPanelTitle: 'text-xs font-medium text-on-surface', - toolPanelTabs: 'flex items-center gap-1', - toolPanelBody: 'flex max-h-96 min-h-0 flex-col gap-1 overflow-y-auto lg:max-h-none', - toolPanelRow: - 'flex items-center gap-1 rounded px-1 py-1 hover:bg-surface-container has-[input:checked]:bg-transparent', - toolPanelRowMuted: 'opacity-60', - toolPanelValueRow: 'flex flex-col gap-1 rounded px-1 py-1', - toolPanelRowLabel: 'grow truncate text-xs text-on-surface', - toolPanelPinMark: 'text-[10px] font-medium tracking-wide text-primary uppercase', - toolPanelHint: 'text-xs text-on-surface-variant', - - filterBuilder: - 'flex flex-col gap-2 rounded-lg border border-outline-variant bg-surface p-3', - filterBuilderGroup: 'flex flex-col gap-2 rounded-md border border-outline-variant/70 p-2', - filterBuilderGroupHeader: 'flex flex-wrap items-center gap-2', - filterBuilderRow: 'flex flex-wrap items-center gap-2', - filterBuilderColumnSelect: 'w-full shrink-0 sm:w-44', - filterBuilderOperatorSelect: 'w-full shrink-0 sm:w-48', - filterBuilderValueInput: 'w-full min-w-36 sm:w-auto sm:min-w-0 sm:grow', - filterBuilderValuePlaceholder: 'text-on-surface-variant', - filterBuilderHint: 'text-xs text-on-surface-variant', - - importPanel: 'flex flex-col gap-3 rounded-lg border border-outline-variant bg-surface p-4', - importDropZone: - 'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-outline-variant px-4 py-10 text-center transition-colors', - importDropZoneActive: 'border-primary bg-primary-container/40', - importHint: 'text-xs text-on-surface-variant', - importMapRow: 'flex flex-col gap-1 rounded px-1 py-1', - importMapLabel: 'text-xs font-medium text-on-surface', - importMapSelect: 'w-full', - importIssueList: 'flex max-h-40 flex-col gap-1 overflow-y-auto', - importIssueRow: 'flex items-center justify-between gap-3 text-xs text-on-surface-variant', - importIssueCount: 'shrink-0 tabular-nums text-error', - importStagedCell: 'bg-tertiary/10', - importBadCell: 'bg-error/15 ring-1 ring-inset ring-error/60', - - commandPalette: 'p-0 max-w-lg', - - findPanel: - 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', - findCount: 'text-xs tabular-nums text-on-surface-variant', - - findMatch: 'bg-tertiary/20', - - findMatchCurrent: 'bg-tertiary/40 ring-1 ring-inset ring-tertiary', - - formatHighlight: 'bg-tertiary/20', - - formatPanel: - 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', - formatPanelLabel: 'text-xs font-medium text-on-surface-variant', - formatPanelEmpty: 'text-xs text-on-surface-variant', - formatPanelError: 'text-xs text-error', - formatRuleChip: - 'inline-flex items-center gap-1 rounded-md bg-surface-container-high py-0.5 pr-0.5 pl-2', - formatRuleChipLabel: 'text-xs font-medium text-on-surface', - formatRuleChipInvalid: 'ring-1 ring-error', - - savedViews: - 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', - savedViewsLabel: 'text-xs font-medium text-on-surface-variant', - savedViewsModified: 'text-xs text-warning', - - watermark: - 'pointer-events-none absolute right-2 bottom-2 z-30 rounded-md border border-outline-variant bg-surface/80 px-2 py-1 text-xs font-medium text-on-surface-variant backdrop-blur-sm select-none' - }, + slots: datagridSlots, variants: { align: { left: { @@ -293,11 +41,5 @@ export const datagridVariants = tv({ }) export type DataGridVariantProps = VariantProps -export type DataGridSlots = keyof ReturnType - -export type DataGridUi = Partial> -export const datagridDefaults: { defaultVariants: { density: Density }; slots: DataGridUi } = { - defaultVariants: { density: 'standard' }, - slots: {} -} +export type { DataGridSlots, DataGridUi } diff --git a/src/lib/components/internal/icons.data.ts b/src/lib/components/internal/icons.data.ts index db6ed6c..a9f7994 100644 --- a/src/lib/components/internal/icons.data.ts +++ b/src/lib/components/internal/icons.data.ts @@ -37,6 +37,9 @@ export const datagridIcons: IconifyJSON = { 'chevron-up': { body: '' }, + 'chevrons-down-up': { + body: '' + }, 'chevrons-left': { body: '' }, @@ -70,6 +73,9 @@ export const datagridIcons: IconifyJSON = { 'ellipsis-vertical': { body: '' }, + eye: { + body: '' + }, 'eye-off': { body: '' }, @@ -79,6 +85,9 @@ export const datagridIcons: IconifyJSON = { 'grip-vertical': { body: '' }, + group: { + body: '' + }, inbox: { body: '' }, @@ -94,6 +103,9 @@ export const datagridIcons: IconifyJSON = { pencil: { body: '' }, + pin: { + body: '' + }, 'pin-off': { body: '' }, @@ -115,6 +127,9 @@ export const datagridIcons: IconifyJSON = { table: { body: '' }, + ungroup: { + body: '' + }, x: { body: '' } diff --git a/src/lib/core/theme/config.ts b/src/lib/core/theme/config.ts new file mode 100644 index 0000000..4e4f685 --- /dev/null +++ b/src/lib/core/theme/config.ts @@ -0,0 +1,72 @@ +import type { Density } from '../types/index.js' +import { datagridSlots, type DataGridSlots, type DataGridUi } from './slots.js' + +const datagridDefaults: { defaultVariants: { density: Density }; slots: DataGridUi } = { + defaultVariants: { density: 'standard' }, + slots: {} +} + +/** + * Whole-app defaults for every grid, in the same `{ defaultVariants, slots }` + * shape sv5ui components take from `defineConfig`. Set it once at startup; + * grids read it when they mount, so it is not a reactive store. + * + * It lives here rather than in sv5ui's config because sv5ui does not export + * the reader its components use internally. When it does, this store becomes + * the fallback and `defineConfig({ datagrid })` starts feeding the same + * values — which is why the shape is identical. + */ +export interface DataGridConfig { + /** Defaults for variants that apply to a whole grid. */ + defaultVariants: { density: Density } + /** Classes appended to every grid's slots, before any per-instance `ui`. */ + slots: DataGridUi +} + +let current: DataGridConfig = clone(datagridDefaults) + +function clone(config: DataGridConfig): DataGridConfig { + return { defaultVariants: { ...config.defaultVariants }, slots: { ...config.slots } } +} + +/** + * Overrides the defaults for every grid in the app. + * + * @example + * ```ts + * defineDataGridConfig({ + * defaultVariants: { density: 'compact' }, + * slots: { cell: 'font-mono', headerCell: 'uppercase tracking-wide' } + * }) + * ``` + */ +export function defineDataGridConfig(config: Partial): void { + current = { + defaultVariants: { ...current.defaultVariants, ...config.defaultVariants }, + slots: { ...current.slots, ...config.slots } + } +} + +/** The config a grid reads when it mounts. */ +export function getDataGridConfig(): DataGridConfig { + return current +} + +/** Restores the built-in defaults. Mainly for tests, which must not leak state. */ +export function resetDataGridConfig(): void { + current = clone(datagridDefaults) +} + +/** + * One slot's classes for a caller that draws no markup: a feature returning a + * `cellDecoration`. The grid's own class, then whatever the app set for that + * slot in `defineDataGridConfig`. + * + * A grid's per-instance `ui` is deliberately absent. It arrives through + * context, which only a component is inside; a feature runs in the pipeline, + * where there is no component to ask. + */ +export function slotClass(slot: DataGridSlots): string { + const override = current.slots[slot] + return override ? `${datagridSlots[slot]} ${String(override)}` : datagridSlots[slot] +} diff --git a/src/lib/core/theme/index.ts b/src/lib/core/theme/index.ts new file mode 100644 index 0000000..7a190a2 --- /dev/null +++ b/src/lib/core/theme/index.ts @@ -0,0 +1,10 @@ +/** The class table both the components and the features name slots out of. */ + +export { datagridSlots, type DataGridSlots, type DataGridUi } from './slots.js' +export { + defineDataGridConfig, + getDataGridConfig, + resetDataGridConfig, + slotClass, + type DataGridConfig +} from './config.js' diff --git a/src/lib/core/theme/slots.ts b/src/lib/core/theme/slots.ts new file mode 100644 index 0000000..18ca39c --- /dev/null +++ b/src/lib/core/theme/slots.ts @@ -0,0 +1,270 @@ +import type { ClassNameValue } from 'tailwind-merge' + +/** + * Every class the grid puts on an element it draws, named one slot at a time. + * + * It lives in the kernel rather than beside `datagridVariants` because two + * layers need it and only one of them draws markup. A feature contributing a + * `cellDecoration` names a slot the same way a component does, and having it + * reach up into `components/` for the table would point an import back down + * the layer it is imported from. + * + * `datagridVariants` builds `tv()` on top of this, and is what applies the + * variants and an app's `ui` overrides. What a feature gets here is the base. + */ +export const datagridSlots = { + root: 'w-full space-y-3', + toolbar: 'flex flex-wrap items-center gap-2', + viewport: + 'group/grid relative w-full overflow-auto rounded-lg border border-outline-variant text-sm', + header: 'group/header sticky top-0 z-10 min-w-min border-b border-outline-variant bg-surface-container', + headerRow: 'grid [grid-template-columns:var(--dg-grid-template)]', + groupRow: + 'relative grid min-w-min [grid-template-columns:var(--dg-grid-template)] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-[16] after:h-px after:bg-outline-variant', + headerCell: + 'group/head relative flex h-(--dg-row-h) min-w-0 items-center gap-1 overflow-hidden px-3 font-medium whitespace-nowrap text-on-surface-variant outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', + groupCell: + 'relative flex h-(--dg-row-h) min-w-0 items-center justify-center gap-1 overflow-hidden px-3 text-xs font-medium whitespace-nowrap text-on-surface-variant outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', + /** What a `headerGroupCell` snippet draws into: shrinkable, and clipped. */ + groupContent: 'flex min-w-0 items-center gap-1 overflow-hidden', + /** + * The strip a railed group folds down to, running the body's height. + * A closed drawer rather than a gap: its own surface, edged on both + * sides, and warm to the pointer, since clicking anywhere down it is + * what opens the group again. + */ + rail: 'absolute inset-y-0 z-[7] cursor-pointer bg-surface-container-high', + /** + * The drawer's head, over the header rows. Above the lines the header + * draws between its levels and along its foot, because a drawer with + * the header's rules struck across it is not one thing but three. + */ + railHead: + 'absolute top-0 -bottom-px z-[17] flex cursor-pointer items-start justify-center bg-surface-container-high', + /** + * The caret stands on the group cell under the head, which the head + * covers, so the drawer shows the caret itself. Not a ring: the + * drawer is two elements meeting, and a box around each of them is + * two boxes rather than one drawer. A bar down the leading edge and + * a wash over the surface both carry through the seam. Tied to + * focus inside the grid, so it goes out with the grid's focus + * rather than sitting on a page nobody is on. + */ + railFocus: + 'group-has-[:focus-visible]/grid:bg-primary/10 group-has-[:focus-visible]/grid:shadow-[inset_2px_0_0_0_var(--color-primary)]', + /** + * Every cell over a folded group's strip, header included, so the + * drawer is one band from the top of the header to the last row + * rather than a patch that starts where the rows do. + */ + railSurface: 'bg-surface-container-high focus-visible:ring-0', + /** + * What the strip holds, held in the middle of what is on screen: the + * way back, and the name of what is folded. + */ + railInner: 'flex flex-col items-center gap-2 py-3 text-on-surface-variant', + /** The group's name, turned to read up the strip. */ + railLabel: + 'max-h-[50vh] truncate text-xs font-medium tracking-wide [writing-mode:vertical-rl] [rotate:180deg]', + /** Room kept at the trailing edge for the fold toggle to sit in. */ + groupCellFoldable: 'pe-8', + groupToggle: 'absolute inset-y-0.5 end-1 flex items-center', + sortButton: + 'inline-flex min-w-0 cursor-pointer items-center gap-1 truncate select-none transition-colors [text-align:inherit] [text-transform:inherit] hover:text-on-surface', + resizeHandle: + 'absolute inset-y-0 end-0 z-10 w-1.5 cursor-col-resize touch-none select-none hover:bg-primary/40 active:bg-primary', + headerControls: + 'absolute inset-y-0.5 end-1.5 flex items-center gap-0.5 bg-surface-container ps-2 opacity-0 transition-opacity group-hover/head:opacity-100 group-focus-within/head:opacity-100 [@media(hover:none)]:opacity-100', + headerControlsPinned: 'opacity-100', + menuButton: 'shrink-0', + dropIndicator: 'pointer-events-none absolute inset-y-0 z-20 w-0.5 bg-primary', + rowHandle: + 'flex size-full cursor-grab items-center justify-center text-on-surface-variant outline-none touch-pan-y select-none hover:text-on-surface focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset active:cursor-grabbing disabled:cursor-default disabled:opacity-30', + rowDropIndicator: 'pointer-events-none absolute inset-x-0 z-20 h-0.5 bg-primary', + rowGhost: 'overflow-hidden rounded-md bg-surface shadow-lg ring-1 ring-primary', + rowDragging: 'opacity-40', + pinnedCell: + 'sticky z-[5] bg-surface group-hover/row:bg-surface-container-low border-outline-variant', + pinnedHeaderCell: 'sticky z-[15] bg-surface-container', + chooserItem: 'flex items-center gap-2 px-1 py-1', + body: 'relative', + bodyOffset: 'will-change-transform', + row: 'group/row relative grid min-w-min [grid-template-columns:var(--dg-grid-template)] transition-colors after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-[6] after:h-px after:bg-outline-variant last:after:hidden hover:bg-surface-container-low', + cell: 'flex min-h-(--dg-row-h) min-w-0 items-center overflow-hidden px-3 py-(--dg-cell-py) text-on-surface outline-none', + cellFocus: + 'focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', + cellEditing: 'overflow-visible', + cellRowSpan: 'relative z-[7] overflow-visible p-0', + rowSpanFill: + 'absolute inset-x-0 z-[7] flex min-w-0 items-start overflow-hidden bg-surface px-3 py-(--dg-cell-py) after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-outline-variant', + rowSpanFillLast: 'after:hidden', + rowSpanEdge: 'border-e border-outline-variant', + rowSpanEdgeStart: 'border-s border-outline-variant', + pinnedCellRaised: + 'z-[8] after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-outline-variant group-last/row:after:hidden', + pinnedCellSelected: + 'before:pointer-events-none before:absolute before:inset-0 before:bg-primary/8', + empty: 'w-full p-4', + toggleButton: + 'me-1 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded text-on-surface-variant transition-colors hover:bg-surface-container hover:text-on-surface', + cellEditor: 'relative flex h-full min-h-(--dg-row-h) w-full min-w-0 items-center', + // z-7, not higher: above the row's own separator, which is at 6 and + // would otherwise paint its grey line along the bottom of the ring and + // leave three edges looking one weight and the fourth another. Below + // the pinned cells at 8, which have to stay over anything scrolling + // under them, an open editor included. + cellEditorFlat: 'z-[7] bg-surface ring-2 ring-inset ring-primary', + cellEditorInRow: + 'bg-surface after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-transparent focus-within:z-[7] focus-within:bg-primary/8 focus-within:after:bg-primary', + cellEditorInRowWidget: 'bg-surface focus-within:z-[7]', + cellEditorInRowDivider: 'border-s border-outline-variant', + cellEditorPad: 'px-2', + cellEditorWide: 'min-w-max', + cellEditorField: 'w-full', + cellError: + 'absolute top-full start-0 z-30 mt-0.5 rounded bg-error px-1.5 py-0.5 text-xs whitespace-nowrap text-on-error shadow-sm', + cellEditable: 'cursor-text', + tooltipTrigger: + '-mx-3 -my-(--dg-cell-py) flex min-w-0 grow items-center overflow-hidden px-3 py-(--dg-cell-py)', + fullWidthCell: + 'min-h-(--dg-row-h) min-w-0 overflow-hidden bg-surface-container-lowest p-3 text-on-surface outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', + pinnedRow: + 'group/row relative grid min-w-min [grid-template-columns:var(--dg-grid-template)] bg-surface after:pointer-events-none after:absolute after:inset-x-0 after:z-[6] after:h-px after:bg-outline-variant', + pinnedRowsTop: 'sticky z-[9] min-w-min shadow-sm', + pinnedRowsBottom: 'sticky bottom-0 z-[9] min-w-min shadow-[0_-1px_2px_rgba(0,0,0,0.05)]', + rowSelected: + 'before:pointer-events-none before:absolute before:inset-0 before:z-[6] before:bg-primary/8', + rowEditing: + 'after:pointer-events-none after:absolute after:inset-0 after:z-[8] after:ring-2 after:ring-inset after:ring-primary', + groupBoundary: 'border-e border-outline-variant', + /** + * A drawer draws both of its own edges, so it is framed the same on + * both sides wherever it stands, including in the middle of a group + * where the grid draws no line of its own. The cells beside it give + * theirs up for the same reason. + */ + railEdge: 'border-s border-outline-variant', + headerDivider: 'border-e border-outline-variant', + filterRow: + 'grid min-w-min border-t border-outline-variant bg-surface-container-low [grid-template-columns:var(--dg-grid-template)]', + filterCell: + 'relative flex h-(--dg-row-h) min-w-0 items-center gap-1 overflow-hidden px-1.5 outline-none focus-visible:z-[7] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset', + filterCellPinned: 'sticky z-[15] bg-surface-container-low', + filterSummary: 'min-w-0 grow truncate text-xs text-on-surface-variant', + filterPanel: + 'fixed z-40 flex w-68 flex-col gap-2 rounded-lg border border-outline-variant bg-surface p-3 shadow-lg', + filterChips: 'flex flex-wrap items-center gap-1.5', + statusBar: 'flex items-center gap-2 text-xs text-on-surface-variant', + footer: 'flex flex-wrap items-center justify-end gap-x-3 gap-y-2', + + // Grouping, range selection, the tool panel, the filter builder, the + // import wizard, conditional formatting, saved views, find and replace + // and the command palette. Registered here rather than in a second + // table so an app overrides every slot the grid has in one `ui` prop. + groupPanel: + 'flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-outline-variant px-3 py-2', + groupPanelLabel: 'text-xs font-medium text-on-surface-variant', + groupPanelChip: + 'inline-flex items-center gap-1 rounded-md bg-surface-container py-0.5 pr-0.5 pl-2', + groupPanelChipLabel: 'text-xs font-medium text-on-surface', + groupPanelSeparator: 'size-3.5 text-on-surface-variant', + groupPanelEmpty: 'text-xs text-on-surface-variant', + + rangeStatusBar: + 'flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-on-surface-variant', + rangeStatusBarValue: 'text-on-surface', + + rangeCell: + 'relative bg-primary/10 before:pointer-events-none before:absolute before:inset-0 before:z-[2] before:border-primary/45', + rangeEdgeTop: 'before:border-t', + rangeEdgeBottom: 'before:border-b', + rangeEdgeStart: 'before:border-s', + rangeEdgeEnd: 'before:border-e', + + fillHandle: + 'after:absolute after:end-0 after:bottom-0 after:z-20 after:size-[7px] after:cursor-crosshair after:bg-primary after:content-[""]', + + cutSource: + 'relative before:pointer-events-none before:absolute before:inset-0 before:z-[3] before:border before:border-dashed before:border-primary', + + moveTarget: + 'relative bg-tertiary/10 before:pointer-events-none before:absolute before:inset-0 before:z-[3] before:border before:border-tertiary/60', + + fillPreview: + 'relative bg-primary/5 before:pointer-events-none before:absolute before:inset-0 before:z-[2] before:border-dashed before:border-primary/45', + + toolPanel: + 'flex w-full flex-col gap-3 rounded-lg border border-outline-variant bg-surface p-3 lg:w-72 lg:shrink-0', + toolPanelCollapsed: + 'flex w-full flex-row items-center justify-between gap-2 rounded-lg border border-outline-variant bg-surface p-2 lg:w-auto lg:flex-col lg:justify-start', + toolPanelHeader: 'flex items-center justify-between gap-2', + toolPanelTitle: 'text-xs font-medium text-on-surface', + toolPanelTabs: 'flex items-center gap-1', + toolPanelBody: 'flex max-h-96 min-h-0 flex-col gap-1 overflow-y-auto lg:max-h-none', + toolPanelRow: + 'flex items-center gap-1 rounded px-1 py-1 hover:bg-surface-container has-[input:checked]:bg-transparent', + toolPanelRowMuted: 'opacity-60', + toolPanelValueRow: 'flex flex-col gap-1 rounded px-1 py-1', + toolPanelRowLabel: 'grow truncate text-xs text-on-surface', + toolPanelPinMark: 'text-[10px] font-medium tracking-wide text-primary uppercase', + toolPanelHint: 'text-xs text-on-surface-variant', + + filterBuilder: 'flex flex-col gap-2 rounded-lg border border-outline-variant bg-surface p-3', + filterBuilderGroup: 'flex flex-col gap-2 rounded-md border border-outline-variant/70 p-2', + filterBuilderGroupHeader: 'flex flex-wrap items-center gap-2', + filterBuilderRow: 'flex flex-wrap items-center gap-2', + filterBuilderColumnSelect: 'w-full shrink-0 sm:w-44', + filterBuilderOperatorSelect: 'w-full shrink-0 sm:w-48', + filterBuilderValueInput: 'w-full min-w-36 sm:w-auto sm:min-w-0 sm:grow', + filterBuilderValuePlaceholder: 'text-on-surface-variant', + filterBuilderHint: 'text-xs text-on-surface-variant', + + importPanel: 'flex flex-col gap-3 rounded-lg border border-outline-variant bg-surface p-4', + importDropZone: + 'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-outline-variant px-4 py-10 text-center transition-colors', + importDropZoneActive: 'border-primary bg-primary-container/40', + importHint: 'text-xs text-on-surface-variant', + importMapRow: 'flex flex-col gap-1 rounded px-1 py-1', + importMapLabel: 'text-xs font-medium text-on-surface', + importMapSelect: 'w-full', + importIssueList: 'flex max-h-40 flex-col gap-1 overflow-y-auto', + importIssueRow: 'flex items-center justify-between gap-3 text-xs text-on-surface-variant', + importIssueCount: 'shrink-0 tabular-nums text-error', + importStagedCell: 'bg-tertiary/10', + importBadCell: 'bg-error/15 ring-1 ring-inset ring-error/60', + + commandPalette: 'p-0 max-w-lg', + + findPanel: + 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', + findCount: 'text-xs tabular-nums text-on-surface-variant', + + findMatch: 'bg-tertiary/20', + + findMatchCurrent: 'bg-tertiary/40 ring-1 ring-inset ring-tertiary', + + formatHighlight: 'bg-tertiary/20', + + formatPanel: + 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', + formatPanelLabel: 'text-xs font-medium text-on-surface-variant', + formatPanelEmpty: 'text-xs text-on-surface-variant', + formatPanelError: 'text-xs text-error', + formatRuleChip: + 'inline-flex items-center gap-1 rounded-md bg-surface-container-high py-0.5 pr-0.5 pl-2', + formatRuleChipLabel: 'text-xs font-medium text-on-surface', + formatRuleChipInvalid: 'ring-1 ring-error', + + savedViews: + 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', + savedViewsLabel: 'text-xs font-medium text-on-surface-variant', + savedViewsModified: 'text-xs text-warning', + + watermark: + 'pointer-events-none absolute right-2 bottom-2 z-30 rounded-md border border-outline-variant bg-surface/80 px-2 py-1 text-xs font-medium text-on-surface-variant backdrop-blur-sm select-none' +} + +export type DataGridSlots = keyof typeof datagridSlots + +/** An app's classes for any slot, merged over the grid's own. */ +export type DataGridUi = Partial> diff --git a/src/lib/features/advanced-filter/advanced-filter.svelte.ts b/src/lib/features/advanced-filter/advanced-filter.svelte.ts new file mode 100644 index 0000000..29b2cf2 --- /dev/null +++ b/src/lib/features/advanced-filter/advanced-filter.svelte.ts @@ -0,0 +1,157 @@ +import { + type CellRead, + gateReader, + type GridState, + isDataRow, + PIPELINE_ORDER +} from '../../core/grid/index.js' +import { type ColumnDef, type GridFeature, type RowNode } from '../../core/types/index.js' +import { getFiltering } from '../../features/filtering/index.js' +import { + countActive, + countConditions, + isEmptyModel, + matchesNode, + resolveColumns, + sanitizeModel +} from './evaluate.js' +import type { AdvancedFilterOptions, FilterGroup, FilterNode } from './advanced-filter.types.js' + +export const ADVANCED_FILTER = 'advancedFilter' + +const emptyModel = (): FilterGroup => ({ kind: 'group', join: 'and', children: [] }) + +const SERVER_REFUSAL = + 'advancedFilter() does not filter a grid on rowModel: "server": it could only test the ' + + 'rows already loaded, and the row count would keep counting the rest. The tree is still ' + + 'held and still serialized - read getAdvancedFilter(grid).model and send it to the server, ' + + 'which is the only side that can answer it.' + +export class AdvancedFilter { + model = $state.raw(emptyModel()) + + #grid: GridState + #onChange?: (model: FilterGroup) => void + #alwaysKeep?: (node: RowNode) => boolean + #warned = false + + constructor(grid: GridState, options: AdvancedFilterOptions) { + this.#grid = grid + this.#onChange = options.onChange + this.#alwaysKeep = options.alwaysKeep + if (options.model) this.model = options.model + } + + get isActive(): boolean { + return !isEmptyModel(this.model) + } + + get isApplied(): boolean { + return this.isActive && this.#grid.rowModel !== 'server' + } + + get conditionCount(): number { + return countConditions(this.model) + } + + get activeCount(): number { + return countActive(this.model) + } + + setModel = (model: FilterGroup): void => { + this.model = sanitizeModel(model) ?? emptyModel() + this.#grid.events.emit('filterChanged', { + filter: getFiltering(this.#grid)?.model ?? { quick: '', columns: {} } + }) + this.#onChange?.(this.model) + } + + restore = (model: unknown): void => { + this.model = sanitizeModel(model) ?? emptyModel() + } + + clear = (): void => { + this.setModel(emptyModel()) + } + + #defsForModel(): Map> { + return resolveColumns(this.model, (columnId) => this.#grid.columns.get(columnId)?.def) + } + + #matchesWith( + node: RowNode, + defs: Map>, + read: CellRead + ): boolean { + if (this.#alwaysKeep?.(node) === true) return true + if (!isDataRow(node)) return true + + return matchesNode(this.model, (columnId) => { + const def = defs.get(columnId) + return def ? read(node, def) : undefined + }) + } + + matches = (node: RowNode): boolean => + this.#matchesWith(node, this.#defsForModel(), gateReader(this.#grid, 'search')) + + #refuseOnServer(): boolean { + if (this.#grid.rowModel !== 'server') return false + if (!this.#warned) { + this.#warned = true + // eslint-disable-next-line no-console + console.warn(SERVER_REFUSAL) + } + return true + } + + apply = (nodes: RowNode[]): RowNode[] => { + if (!this.isActive || this.#refuseOnServer()) return nodes + + const defs = this.#defsForModel() + const read = gateReader(this.#grid, 'search') + return nodes.filter((node) => this.#matchesWith(node, defs, read)) + } +} + +export function advancedFilter(options: AdvancedFilterOptions = {}): GridFeature { + return { + id: ADVANCED_FILTER, + createState: (grid) => new AdvancedFilter(grid, options), + pipelineStage: { + order: PIPELINE_ORDER.filter + 1, + transform: (nodes, grid) => getAdvancedFilter(grid)?.apply(nodes) ?? nodes + }, + createApi: (grid) => { + const state = getAdvancedFilter(grid)! + return { + setAdvancedFilter: (model: unknown) => state.setModel(model as FilterGroup), + getAdvancedFilter: () => state.model, + isAdvancedFilterApplied: () => state.isApplied, + clearAdvancedFilter: state.clear + } + }, + serialize: (grid) => { + const state = getAdvancedFilter(grid) + return state ? { model: state.model } : undefined + }, + hydrate: (slice, grid) => { + getAdvancedFilter(grid)?.restore((slice as { model?: unknown } | undefined)?.model) + } + } +} + +export function getAdvancedFilter(grid: GridState): AdvancedFilter | undefined { + return grid.feature>(ADVANCED_FILTER) +} + +declare module '../../core/types/api.js' { + interface GridApi { + setAdvancedFilter?: (model: unknown) => void + getAdvancedFilter?: () => unknown + isAdvancedFilterApplied?: () => boolean + clearAdvancedFilter?: () => void + } +} + +export type { FilterNode } diff --git a/src/lib/features/advanced-filter/advanced-filter.test.ts b/src/lib/features/advanced-filter/advanced-filter.test.ts new file mode 100644 index 0000000..e6fc1d8 --- /dev/null +++ b/src/lib/features/advanced-filter/advanced-filter.test.ts @@ -0,0 +1,345 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it, vi } from 'vitest' +import { advancedFilter, getAdvancedFilter } from './advanced-filter.svelte.js' +import { grouping } from '../grouping/index.js' +import type { AdvancedFilterOptions, FilterGroup } from './advanced-filter.types.js' + +interface Sale { + id: number + region: string + rep: string + total: number +} + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region' }, + { id: 'rep', header: 'Rep' }, + { id: 'total', header: 'Total' } +] + +const data: Sale[] = [ + { id: 1, region: 'North', rep: 'Alice', total: 120 }, + { id: 2, region: 'North', rep: 'Bob', total: 80 }, + { id: 3, region: 'South', rep: 'Alice', total: 300 }, + { id: 4, region: 'South', rep: 'Chi', total: 50 } +] + +function createGrid(options: AdvancedFilterOptions = {}): GridState { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), advancedFilter(options)] + }) +} + +const ids = (grid: GridState) => grid.nodes.map((node) => node.id) + +const group = (children: FilterGroup['children'], join: 'and' | 'or' = 'and'): FilterGroup => ({ + kind: 'group', + join, + children +}) + +describe('the advanced filter feature', () => { + it('ignores a condition that has no value yet, the way the free grid does', () => { + const grid = createGrid() + const state = getAdvancedFilter(grid)! + + state.setModel(group([{ kind: 'condition', columnId: 'region', op: 'equals', value: '' }])) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + expect(state.conditionCount).toBe(1) + expect(state.activeCount).toBe(0) + + state.setModel( + group([{ kind: 'condition', columnId: 'region', op: 'equals', value: 'South' }]) + ) + expect(ids(grid)).toEqual(['3', '4']) + expect(state.activeCount).toBe(1) + }) + + it('waits for both ends of a between, and for a list to hold something', () => { + const grid = createGrid() + const state = getAdvancedFilter(grid)! + + state.setModel(group([{ kind: 'condition', columnId: 'total', op: 'between', value: 100 }])) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + + state.setModel( + group([{ kind: 'condition', columnId: 'total', op: 'between', value: 100, to: 200 }]) + ) + expect(ids(grid)).toEqual(['1']) + + state.setModel(group([{ kind: 'condition', columnId: 'rep', op: 'in', values: [] }])) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + }) + + it('still tests presence with no value, because that is what blank means', () => { + const grid = createGrid() + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'region', op: 'notBlank' }]) + ) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'region', op: 'blank' }]) + ) + expect(ids(grid)).toEqual([]) + }) + + it('keeps every row until it holds a condition', () => { + const grid = createGrid() + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + expect(getAdvancedFilter(grid)!.isActive).toBe(false) + }) + + it('filters on a tree the per-column model could not express', () => { + const grid = createGrid() + getAdvancedFilter(grid)!.setModel( + group( + [ + group([ + { kind: 'condition', columnId: 'region', op: 'equals', value: 'North' }, + { kind: 'condition', columnId: 'total', op: 'gte', value: 100 } + ]), + { kind: 'condition', columnId: 'rep', op: 'equals', value: 'Chi' } + ], + 'or' + ) + ) + + expect(ids(grid)).toEqual(['1', '4']) + }) + + it('starts from the model an app hands it, and reports how many conditions it holds', () => { + const grid = createGrid({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 100 }]) + }) + + expect(ids(grid)).toEqual(['1', '3']) + expect(getAdvancedFilter(grid)!.conditionCount).toBe(1) + }) + + it('tells an app every time the tree changes', () => { + const onChange = vi.fn() + const grid = createGrid({ onChange }) + + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'region', op: 'equals', value: 'South' }]) + ) + getAdvancedFilter(grid)!.clear() + + expect(onChange).toHaveBeenCalledTimes(2) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + }) + + it('drives from the api the same way', () => { + const grid = createGrid() + const setAdvancedFilter = grid.api.setAdvancedFilter as (model: unknown) => void + const clearAdvancedFilter = grid.api.clearAdvancedFilter as () => void + + setAdvancedFilter( + group([{ kind: 'condition', columnId: 'rep', op: 'equals', value: 'Alice' }]) + ) + expect(ids(grid)).toEqual(['1', '3']) + + clearAdvancedFilter() + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + }) + + it('narrows what the quick filter already narrowed, rather than replacing it', () => { + const grid = createGrid() + const setQuickFilter = grid.api.setQuickFilter as (text: string) => void + + setQuickFilter('Alice') + expect(ids(grid)).toEqual(['1', '3']) + + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 200 }]) + ) + expect(ids(grid)).toEqual(['3']) + }) + + it('runs before grouping, so the groups count what survived', () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [ + sorting(), + advancedFilter({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gte', value: 100 }]) + }), + grouping({ by: ['region'], expandedByDefault: false }) + ] + }) + + expect(grid.nodes.map((node) => node.row as unknown as Record)).toEqual([ + expect.objectContaining({ region: 'North (1)' }), + expect.objectContaining({ region: 'South (1)' }) + ]) + }) + + it('keeps a row the app insists on, whatever the tree says', () => { + const grid = createGrid({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 1_000 }]), + alwaysKeep: (node) => node.id === '2' + }) + + expect(ids(grid)).toEqual(['2']) + }) + + it('refuses to filter a server grid, and says so once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + sorting(), + advancedFilter({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 100 }]) + }) + ] + }) + + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + expect(getAdvancedFilter(grid)!.isActive).toBe(true) + expect(getAdvancedFilter(grid)!.isApplied).toBe(false) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('does not filter a grid on rowModel') + warn.mockRestore() + }) + + it('still holds and serializes the tree on a server grid, for the app to send onward', () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [sorting(), advancedFilter()] + }) + const model = group([{ kind: 'condition', columnId: 'rep', op: 'equals', value: 'Chi' }]) + + getAdvancedFilter(grid)!.setModel(model) + expect((grid.api.getAdvancedFilter as () => unknown)()).toEqual(model) + expect(JSON.stringify(grid.getState())).toContain('"columnId":"rep"') + }) + + it('reports applied on a client grid, so a panel can say which it is', () => { + const grid = createGrid({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 100 }]) + }) + expect(getAdvancedFilter(grid)!.isApplied).toBe(true) + expect((grid.api.isAdvancedFilterApplied as () => boolean)()).toBe(true) + }) + + it('does not warn twice on a server row model', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + sorting(), + advancedFilter({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 100 }]) + }) + ] + }) + + void grid.nodes.length + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 50 }]) + ) + void grid.nodes.length + + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('never drops a row that is still loading, so scrolling keeps working', () => { + const grid = createGrid({ + model: group([{ kind: 'condition', columnId: 'total', op: 'gt', value: 1_000 }]) + }) + const loading = { __dgLoading: true, id: 9, region: '', rep: '', total: 0 } + + expect( + getAdvancedFilter(grid)!.matches({ + id: '9', + row: loading as unknown as Sale, + index: 8 + }) + ).toBe(true) + }) + + it('round-trips through the grid snapshot', () => { + const grid = createGrid() + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'region', op: 'equals', value: 'South' }]) + ) + expect(ids(grid)).toEqual(['3', '4']) + + const snapshot = grid.getState() + getAdvancedFilter(grid)!.clear() + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + + grid.setState(snapshot) + expect(ids(grid)).toEqual(['3', '4']) + }) + + it('saves the empty tree too, so restoring a view without one clears the filter', () => { + const grid = createGrid() + const empty = grid.getState() + + getAdvancedFilter(grid)!.setModel( + group([{ kind: 'condition', columnId: 'region', op: 'equals', value: 'South' }]) + ) + expect(ids(grid)).toEqual(['3', '4']) + + grid.setState(empty) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + }) + + it('restores without telling the app its own filter changed', () => { + const onChange = vi.fn() + const grid = createGrid({ onChange }) + const snapshot = grid.getState() + + grid.setState(snapshot) + expect(onChange).not.toHaveBeenCalled() + }) + + it('refuses a tree that is not one, rather than throwing inside the pipeline', () => { + const grid = createGrid() + const setAdvancedFilter = grid.api.setAdvancedFilter as (model: unknown) => void + + for (const rubbish of [42, null, { kind: 'group' }, { kind: 'group', children: {} }]) { + setAdvancedFilter(rubbish) + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + } + }) + + it('keeps the conditions it can read out of a half-broken tree', () => { + const grid = createGrid() + const setAdvancedFilter = grid.api.setAdvancedFilter as (model: unknown) => void + + setAdvancedFilter({ + kind: 'group', + join: 'and', + children: [ + { kind: 'condition', columnId: 'region', op: 'equals', value: 'South' }, + { kind: 'condition', columnId: 'total', op: 'nonsense', value: 1 }, + 'not a node' + ] + }) + + expect(ids(grid)).toEqual(['3', '4']) + expect(getAdvancedFilter(grid)!.conditionCount).toBe(1) + }) +}) diff --git a/src/lib/features/advanced-filter/advanced-filter.types.ts b/src/lib/features/advanced-filter/advanced-filter.types.ts new file mode 100644 index 0000000..7e5315f --- /dev/null +++ b/src/lib/features/advanced-filter/advanced-filter.types.ts @@ -0,0 +1,83 @@ +import { type RowNode } from '../../core/types/index.js' + +/** + * Operators a condition can use. They are Community's own operator names, so + * a `text` condition here means what a text column filter means there. + * + * `blank` / `notBlank` carry no value; `between` carries both `value` and + * `to`; `in` carries `values`. Everything else carries `value`. + */ +export type AdvancedFilterOp = + | 'contains' + | 'notContains' + | 'equals' + | 'notEqual' + | 'startsWith' + | 'endsWith' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'between' + | 'before' + | 'after' + | 'in' + | 'blank' + | 'notBlank' + +/** One test against one column. */ +export interface FilterCondition { + kind: 'condition' + /** Column id, read through the grid's own accessor so a formula column works. */ + columnId: string + op: AdvancedFilterOp + /** Left side of `between`, the value for every single-value operator. */ + value?: unknown + /** Right side of `between`, inclusive. */ + to?: unknown + /** The list an `in` matches against. */ + values?: unknown[] + /** + * Compare text exactly as written. + * @default false + */ + caseSensitive?: boolean +} + +/** Conditions and nested groups, joined by one operator and optionally negated. */ +export interface FilterGroup { + kind: 'group' + /** @default 'and' */ + join: 'and' | 'or' + /** + * Invert the whole group, which is what makes `NOT (A OR B)` expressible + * without a separate operator for every condition. + * @default false + */ + not?: boolean + children: FilterNode[] +} + +/** A node of the filter tree: a test, or a group of tests. */ +export type FilterNode = FilterCondition | FilterGroup + +export interface AdvancedFilterOptions { + /** + * The tree the grid starts with. An empty group, or no option at all, + * filters nothing. + * @default { kind: 'group', join: 'and', children: [] } + */ + model?: FilterGroup + + /** + * Called whenever the tree changes, for an app that stores it or sends it + * to a server. The tree is JSON already; nothing in it is a function. + */ + onChange?: (model: FilterGroup) => void + + /** + * Keep a row whatever the tree says. Runs before the tree, so a pinned + * summary row or a row the app must always show survives a filter. + */ + alwaysKeep?: (node: RowNode) => boolean +} diff --git a/src/lib/features/advanced-filter/community-parity.test.ts b/src/lib/features/advanced-filter/community-parity.test.ts new file mode 100644 index 0000000..1d8309d --- /dev/null +++ b/src/lib/features/advanced-filter/community-parity.test.ts @@ -0,0 +1,206 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef, type ColumnFilter } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { describe, expect, it } from 'vitest' +import { advancedFilter, getAdvancedFilter } from './advanced-filter.svelte.js' +import type { FilterCondition } from './advanced-filter.types.js' + +interface Row { + id: number + text: string | null + num: number | null + day: string | Date | null + flag: boolean | null +} + +const rows: Row[] = [ + { id: 1, text: 'Alpha', num: 5, day: '2026-03-02', flag: true }, + { id: 2, text: 'alpha beta', num: 10, day: new Date(2026, 2, 2, 10, 30), flag: false }, + { id: 3, text: 'Gamma', num: 0, day: '2026-03-31T09:00:00', flag: true }, + { id: 4, text: '', num: null, day: null, flag: null }, + { id: 5, text: null, num: -3, day: '2026-12-31', flag: false } +] + +const columns: ColumnDef[] = [ + { id: 'text', header: 'Text', filter: 'text' }, + { id: 'num', header: 'Num', filter: 'number' }, + { id: 'day', header: 'Day', type: 'date', filter: 'date' }, + { id: 'flag', header: 'Flag', type: 'boolean', filter: 'boolean' } +] + +function surviving(grid: GridState): number[] { + return grid.preWindowNodes.map((node) => node.row.id) +} + +function throughCommunity(columnId: string, filter: ColumnFilter): number[] { + const grid = createDataGrid({ + columns, + data: rows, + getRowId: (row) => String(row.id), + features: [filtering()] + }) + grid.api.setColumnFilter!(columnId, filter) + return surviving(grid) +} + +function throughPro(condition: Omit): number[] { + const grid = createDataGrid({ + columns, + data: rows, + getRowId: (row) => String(row.id), + features: [advancedFilter()] + }) + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', ...condition }] + }) + return surviving(grid) +} + +const cases: [string, string, ColumnFilter, Omit][] = [ + [ + 'text contains', + 'text', + { kind: 'text', op: 'contains', value: 'alpha' }, + { columnId: 'text', op: 'contains', value: 'alpha' } + ], + [ + 'text contains, case sensitive', + 'text', + { kind: 'text', op: 'contains', value: 'alpha', caseSensitive: true }, + { columnId: 'text', op: 'contains', value: 'alpha', caseSensitive: true } + ], + [ + 'text notContains', + 'text', + { kind: 'text', op: 'notContains', value: 'alpha' }, + { columnId: 'text', op: 'notContains', value: 'alpha' } + ], + [ + 'text equals', + 'text', + { kind: 'text', op: 'equals', value: 'Gamma' }, + { columnId: 'text', op: 'equals', value: 'Gamma' } + ], + [ + 'text notEqual', + 'text', + { kind: 'text', op: 'notEqual', value: 'Gamma' }, + { columnId: 'text', op: 'notEqual', value: 'Gamma' } + ], + [ + 'text startsWith', + 'text', + { kind: 'text', op: 'startsWith', value: 'al' }, + { columnId: 'text', op: 'startsWith', value: 'al' } + ], + [ + 'text endsWith', + 'text', + { kind: 'text', op: 'endsWith', value: 'a' }, + { columnId: 'text', op: 'endsWith', value: 'a' } + ], + [ + 'text blank', + 'text', + { kind: 'text', op: 'blank', value: '' }, + { columnId: 'text', op: 'blank' } + ], + [ + 'text notBlank', + 'text', + { kind: 'text', op: 'notBlank', value: '' }, + { columnId: 'text', op: 'notBlank' } + ], + [ + 'number gt', + 'num', + { kind: 'number', op: 'gt', value: 0 }, + { columnId: 'num', op: 'gt', value: 0 } + ], + [ + 'number gte', + 'num', + { kind: 'number', op: 'gte', value: 5 }, + { columnId: 'num', op: 'gte', value: 5 } + ], + [ + 'number lt', + 'num', + { kind: 'number', op: 'lt', value: 5 }, + { columnId: 'num', op: 'lt', value: 5 } + ], + [ + 'number eq', + 'num', + { kind: 'number', op: 'eq', value: 0 }, + { columnId: 'num', op: 'equals', value: 0 } + ], + [ + 'number between', + 'num', + { kind: 'number', op: 'between', value: 0, to: 10 }, + { columnId: 'num', op: 'between', value: 0, to: 10 } + ], + ['number blank', 'num', { kind: 'number', op: 'blank' }, { columnId: 'num', op: 'blank' }], + [ + 'date equals', + 'day', + { kind: 'date', op: 'equals', value: '2026-03-02' }, + { columnId: 'day', op: 'equals', value: '2026-03-02' } + ], + [ + 'date before', + 'day', + { kind: 'date', op: 'before', value: '2026-03-31' }, + { columnId: 'day', op: 'before', value: '2026-03-31' } + ], + [ + 'date after', + 'day', + { kind: 'date', op: 'after', value: '2026-03-02' }, + { columnId: 'day', op: 'after', value: '2026-03-02' } + ], + [ + 'date between', + 'day', + { kind: 'date', op: 'between', value: '2026-01-01', to: '2026-03-31' }, + { columnId: 'day', op: 'between', value: '2026-01-01', to: '2026-03-31' } + ], + ['date blank', 'day', { kind: 'date', op: 'blank' }, { columnId: 'day', op: 'blank' }], + [ + 'set of two', + 'text', + { kind: 'set', values: ['Alpha', 'Gamma'] }, + { columnId: 'text', op: 'in', values: ['Alpha', 'Gamma'] } + ], + [ + 'boolean true', + 'flag', + { kind: 'boolean', value: true }, + { columnId: 'flag', op: 'equals', value: true } + ] +] + +describe('Pro answers a single condition the way the free grid does', () => { + it.each(cases)('%s', (_label, columnId, community, pro) => { + expect(throughPro(pro)).toEqual(throughCommunity(columnId, community)) + }) +}) + +describe('the one answer that differs, and why it is not copied', () => { + it('keeps a blank cell out of a negative test, where the free grid drops it', () => { + const community = throughCommunity('num', { kind: 'number', op: 'neq', value: 5 }) + const pro = throughPro({ columnId: 'num', op: 'notEqual', value: 5 }) + + expect(community).toEqual([2, 3, 5]) + expect(pro).toEqual([2, 3, 4, 5]) + }) + + it('answers a blank the same way the free grid answers one on text', () => { + expect( + throughCommunity('text', { kind: 'text', op: 'notEqual', value: 'Gamma' }) + ).toContain(4) + }) +}) diff --git a/src/lib/features/advanced-filter/evaluate.test.ts b/src/lib/features/advanced-filter/evaluate.test.ts new file mode 100644 index 0000000..5d9adad --- /dev/null +++ b/src/lib/features/advanced-filter/evaluate.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import { countConditions, isBlankValue, matchesCondition, matchesNode } from './evaluate.js' +import type { FilterCondition, FilterGroup } from './advanced-filter.types.js' + +const condition = (partial: Partial): FilterCondition => ({ + kind: 'condition', + columnId: 'x', + op: 'equals', + ...partial +}) + +describe('one condition', () => { + it('tests text the way a text filter does, ignoring case unless asked', () => { + expect(matchesCondition('Hello', condition({ op: 'contains', value: 'ell' }))).toBe(true) + expect(matchesCondition('Hello', condition({ op: 'contains', value: 'ELL' }))).toBe(true) + expect( + matchesCondition( + 'Hello', + condition({ op: 'contains', value: 'ELL', caseSensitive: true }) + ) + ).toBe(false) + expect(matchesCondition('Hello', condition({ op: 'startsWith', value: 'he' }))).toBe(true) + expect(matchesCondition('Hello', condition({ op: 'endsWith', value: 'LO' }))).toBe(true) + expect(matchesCondition('Hello', condition({ op: 'notContains', value: 'z' }))).toBe(true) + }) + + it('compares numbers as numbers, including numeric text', () => { + expect(matchesCondition(10, condition({ op: 'gt', value: 5 }))).toBe(true) + expect(matchesCondition(10, condition({ op: 'lte', value: 10 }))).toBe(true) + expect(matchesCondition('10', condition({ op: 'gte', value: '10' }))).toBe(true) + expect(matchesCondition(4, condition({ op: 'between', value: 1, to: 4 }))).toBe(true) + expect(matchesCondition(5, condition({ op: 'between', value: 1, to: 4 }))).toBe(false) + }) + + it('compares dates by day, the way the free grid does', () => { + const day = '2026-03-02T00:00:00.000Z' + expect( + matchesCondition(new Date(day), condition({ op: 'before', value: '2026-04-01' })) + ).toBe(true) + expect( + matchesCondition(day, condition({ op: 'after', value: new Date('2026-01-01') })) + ).toBe(true) + expect( + matchesCondition( + day, + condition({ op: 'between', value: '2026-01-01', to: '2026-12-31' }) + ) + ).toBe(true) + }) + + it('counts a timestamp as its own day, not as an instant past midnight', () => { + expect( + matchesCondition( + new Date(2026, 2, 2, 10, 30), + condition({ op: 'equals', value: '2026-03-02' }) + ) + ).toBe(true) + expect( + matchesCondition( + new Date(2026, 2, 2, 10, 30), + condition({ op: 'after', value: '2026-03-02' }) + ) + ).toBe(false) + }) + + it('keeps the last day of a between range whole', () => { + expect( + matchesCondition( + new Date(2026, 2, 31, 9, 0), + condition({ op: 'between', value: '2026-01-01', to: '2026-03-31' }) + ) + ).toBe(true) + }) + + it('tests presence without a value', () => { + expect(matchesCondition(null, condition({ op: 'blank' }))).toBe(true) + expect(matchesCondition('', condition({ op: 'blank' }))).toBe(true) + expect(matchesCondition(0, condition({ op: 'blank' }))).toBe(false) + expect(matchesCondition('x', condition({ op: 'notBlank' }))).toBe(true) + }) + + it('matches a list, by value or by its text', () => { + expect(matchesCondition('a', condition({ op: 'in', values: ['a', 'b'] }))).toBe(true) + expect(matchesCondition(2, condition({ op: 'in', values: ['2'] }))).toBe(true) + expect(matchesCondition('c', condition({ op: 'in', values: ['a', 'b'] }))).toBe(false) + }) + + it('waits rather than excluding, while a condition is still being filled in', () => { + expect(matchesCondition('a', condition({ op: 'in' }))).toBe(true) + expect(matchesCondition('a', condition({ op: 'in', values: [] }))).toBe(true) + expect(matchesCondition('a', condition({ op: 'equals', value: '' }))).toBe(true) + expect(matchesCondition(5, condition({ op: 'between', value: 1 }))).toBe(true) + expect(matchesCondition(5, condition({ op: 'between', value: 1, to: 4 }))).toBe(false) + }) + + it('treats booleans as booleans rather than as text', () => { + expect(matchesCondition(true, condition({ op: 'equals', value: true }))).toBe(true) + expect(matchesCondition(false, condition({ op: 'equals', value: true }))).toBe(false) + expect(matchesCondition(false, condition({ op: 'notEqual', value: true }))).toBe(true) + }) + + it('falls back to text when the two sides are not comparable as numbers', () => { + expect(matchesCondition('abc', condition({ op: 'gt', value: 5 }))).toBe(false) + expect(matchesCondition('abc', condition({ op: 'equals', value: 'abc' }))).toBe(true) + }) +}) + +describe('a tree of conditions', () => { + const row: Record = { region: 'North', total: 120, paid: false } + const read = (columnId: string) => row[columnId] + + it('ands and ors its children', () => { + const and: FilterGroup = { + kind: 'group', + join: 'and', + children: [ + condition({ columnId: 'region', op: 'equals', value: 'North' }), + condition({ columnId: 'total', op: 'gt', value: 100 }) + ] + } + expect(matchesNode(and, read)).toBe(true) + + const or: FilterGroup = { + ...and, + join: 'or', + children: [ + condition({ columnId: 'region', op: 'equals', value: 'South' }), + condition({ columnId: 'total', op: 'gt', value: 100 }) + ] + } + expect(matchesNode(or, read)).toBe(true) + }) + + it('nests, which is the whole point of a tree', () => { + const model: FilterGroup = { + kind: 'group', + join: 'or', + children: [ + { + kind: 'group', + join: 'and', + children: [ + condition({ columnId: 'region', op: 'equals', value: 'South' }), + condition({ columnId: 'total', op: 'gt', value: 100 }) + ] + }, + condition({ columnId: 'paid', op: 'equals', value: false }) + ] + } + expect(matchesNode(model, read)).toBe(true) + }) + + it('negates a whole group', () => { + const model: FilterGroup = { + kind: 'group', + join: 'and', + not: true, + children: [condition({ columnId: 'region', op: 'equals', value: 'North' })] + } + expect(matchesNode(model, read)).toBe(false) + }) + + it('keeps every row when it holds nothing', () => { + expect(matchesNode({ kind: 'group', join: 'and', children: [] }, read)).toBe(true) + }) + + it('counts the conditions it holds, however deep', () => { + const model: FilterGroup = { + kind: 'group', + join: 'and', + children: [ + condition({}), + { kind: 'group', join: 'or', children: [condition({}), condition({})] } + ] + } + expect(countConditions(model)).toBe(3) + }) +}) + +describe('isBlankValue', () => { + it('counts null, undefined and empty text, and nothing else', () => { + expect([null, undefined, ''].map(isBlankValue)).toEqual([true, true, true]) + expect([0, false, ' ', []].map(isBlankValue)).toEqual([false, false, false, false]) + }) +}) diff --git a/src/lib/features/advanced-filter/evaluate.ts b/src/lib/features/advanced-filter/evaluate.ts new file mode 100644 index 0000000..c2ece20 --- /dev/null +++ b/src/lib/features/advanced-filter/evaluate.ts @@ -0,0 +1,264 @@ +import type { + AdvancedFilterOp, + FilterCondition, + FilterGroup, + FilterNode +} from './advanced-filter.types.js' +import { numericOrNull } from '../../core/utils/index.js' + +const PRESENCE = new Set(['blank', 'notBlank']) + +export function isBlankValue(value: unknown): boolean { + return value === null || value === undefined || value === '' +} + +function asText(value: unknown, caseSensitive: boolean): string { + const text = value instanceof Date ? value.toISOString() : String(value ?? '') + return caseSensitive ? text : text.toLowerCase() +} + +const MS_PER_DAY = 86_400_000 +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/ + +function localDay(date: Date): number { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / MS_PER_DAY +} + +function asDay(value: unknown): number | null { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : localDay(value) + if (typeof value === 'number') return localDay(new Date(value)) + if (typeof value !== 'string') return null + + const text = value.trim() + if (DATE_ONLY.test(text)) return Date.parse(text) / MS_PER_DAY + + const parsed = new Date(text) + return Number.isNaN(parsed.getTime()) ? null : localDay(parsed) +} + +function compareText(cell: unknown, condition: FilterCondition): boolean { + const sensitive = condition.caseSensitive ?? false + const left = asText(cell, sensitive) + const right = asText(condition.value, sensitive) + + switch (condition.op) { + case 'contains': + return left.includes(right) + case 'notContains': + return !left.includes(right) + case 'startsWith': + return left.startsWith(right) + case 'endsWith': + return left.endsWith(right) + case 'equals': + return left === right + case 'notEqual': + return left !== right + default: + return false + } +} + +const NUMERIC_TESTS: Partial boolean>> = { + equals: (left, right) => left === right, + notEqual: (left, right) => left !== right, + gt: (left, right) => left > right, + gte: (left, right) => left >= right, + lt: (left, right) => left < right, + lte: (left, right) => left <= right +} + +function compareNumbers(cell: unknown, condition: FilterCondition): boolean | null { + const left = numericOrNull(cell) + const right = numericOrNull(condition.value) + if (left === null || right === null) return null + + if (condition.op === 'between') { + const upper = numericOrNull(condition.to) + return upper === null ? null : left >= right && left <= upper + } + + const test = NUMERIC_TESTS[condition.op] + return test ? test(left, right) : null +} + +function compareDays(cell: unknown, condition: FilterCondition): boolean | null { + const left = asDay(cell) + const right = asDay(condition.value) + if (left === null || right === null) return null + + switch (condition.op) { + case 'before': + return left < right + case 'after': + return left > right + case 'equals': + return left === right + case 'notEqual': + return left !== right + case 'between': { + const upper = asDay(condition.to) + return upper === null ? null : left >= right && left <= upper + } + default: + return null + } +} + +function matchesIn(cell: unknown, condition: FilterCondition): boolean { + const sensitive = condition.caseSensitive ?? false + const values = condition.values ?? [] + + return values.some((value) => + value instanceof Date || cell instanceof Date + ? asDay(value) === asDay(cell) + : value === cell || asText(value, sensitive) === asText(cell, sensitive) + ) +} + +function matchesPresence(cell: unknown, op: AdvancedFilterOp): boolean { + const blank = isBlankValue(cell) + return op === 'blank' ? blank : !blank +} + +function matchesBoolean(cell: unknown, condition: FilterCondition): boolean | null { + const equality = condition.op === 'equals' || condition.op === 'notEqual' + if (!equality) return null + if (typeof cell !== 'boolean' && typeof condition.value !== 'boolean') return null + + const same = cell === condition.value || asText(cell, false) === asText(condition.value, false) + return condition.op === 'notEqual' ? !same : same +} + +export function isComplete(condition: FilterCondition): boolean { + if (PRESENCE.has(condition.op)) return true + if (condition.op === 'in') return (condition.values?.length ?? 0) > 0 + if (isBlankValue(condition.value)) return false + return condition.op === 'between' ? !isBlankValue(condition.to) : true +} + +export function matchesCondition(cell: unknown, condition: FilterCondition): boolean { + if (!isComplete(condition)) return true + + if (PRESENCE.has(condition.op)) return matchesPresence(cell, condition.op) + if (condition.op === 'in') return matchesIn(cell, condition) + + const asBoolean = matchesBoolean(cell, condition) + if (asBoolean !== null) return asBoolean + + const asNumbers = compareNumbers(cell, condition) + if (asNumbers !== null) return asNumbers + + const asDates = compareDays(cell, condition) + if (asDates !== null) return asDates + + return compareText(cell, condition) +} + +export function matchesNode(node: FilterNode, read: (columnId: string) => unknown): boolean { + if (node.kind === 'condition') return matchesCondition(read(node.columnId), node) + + const children = node.children + if (children.length === 0) return true + + const join = node.join ?? 'and' + const matched = + join === 'and' + ? children.every((child) => matchesNode(child, read)) + : children.some((child) => matchesNode(child, read)) + + return node.not === true ? !matched : matched +} + +export function isEmptyModel(model: FilterGroup): boolean { + return model.children.length === 0 +} + +export function countConditions(node: FilterNode): number { + return node.kind === 'condition' + ? 1 + : node.children.reduce((total, child) => total + countConditions(child), 0) +} + +export function countActive(node: FilterNode): number { + if (node.kind === 'condition') return isComplete(node) ? 1 : 0 + return node.children.reduce((total, child) => total + countActive(child), 0) +} + +export const MAX_DEPTH = 20 + +const OPS = new Set([ + 'contains', + 'notContains', + 'equals', + 'notEqual', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + 'before', + 'after', + 'in', + 'blank', + 'notBlank' +]) + +function sanitizeCondition(input: Record): FilterCondition | null { + const columnId = input.columnId + const op = input.op + if (typeof columnId !== 'string' || typeof op !== 'string') return null + if (!OPS.has(op as AdvancedFilterOp)) return null + + const condition: FilterCondition = { kind: 'condition', columnId, op: op as AdvancedFilterOp } + if ('value' in input) condition.value = input.value + if ('to' in input) condition.to = input.to + if (Array.isArray(input.values)) condition.values = input.values + if (typeof input.caseSensitive === 'boolean') condition.caseSensitive = input.caseSensitive + return condition +} + +function sanitizeNode(input: unknown, depth: number): FilterNode | null { + if (depth > MAX_DEPTH || input === null || typeof input !== 'object') return null + + const node = input as Record + if (node.kind === 'condition') return sanitizeCondition(node) + if (node.kind !== 'group' || !Array.isArray(node.children)) return null + + const children = node.children + .map((child) => sanitizeNode(child, depth + 1)) + .filter((child) => child !== null) + + const group: FilterGroup = { + kind: 'group', + join: node.join === 'or' ? 'or' : 'and', + children + } + if (node.not === true) group.not = true + return group +} + +export function sanitizeModel(input: unknown): FilterGroup | null { + const node = sanitizeNode(input, 0) + return node?.kind === 'group' ? node : null +} + +export function columnIdsOf(node: FilterNode, into: Set = new Set()): Set { + if (node.kind === 'condition') into.add(node.columnId) + else for (const child of node.children) columnIdsOf(child, into) + return into +} + +export function resolveColumns( + model: FilterNode, + lookup: (columnId: string) => TDef | undefined +): Map { + const defs = new Map() + for (const columnId of columnIdsOf(model)) { + const def = lookup(columnId) + if (def !== undefined) defs.set(columnId, def) + } + return defs +} diff --git a/src/lib/features/advanced-filter/index.ts b/src/lib/features/advanced-filter/index.ts new file mode 100644 index 0000000..babea0c --- /dev/null +++ b/src/lib/features/advanced-filter/index.ts @@ -0,0 +1,22 @@ +export { + advancedFilter, + AdvancedFilter, + ADVANCED_FILTER, + getAdvancedFilter +} from './advanced-filter.svelte.js' +export { + countActive, + countConditions, + isComplete, + isBlankValue, + isEmptyModel, + matchesCondition, + matchesNode +} from './evaluate.js' +export type { + AdvancedFilterOp, + AdvancedFilterOptions, + FilterCondition, + FilterGroup, + FilterNode +} from './advanced-filter.types.js' diff --git a/src/lib/features/advanced-filter/operators.test.ts b/src/lib/features/advanced-filter/operators.test.ts new file mode 100644 index 0000000..d38b9d6 --- /dev/null +++ b/src/lib/features/advanced-filter/operators.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { type ColumnDef } from '../../core/types/index.js' +import { + isFilterable, + kindOf, + needsList, + needsRange, + needsValue, + opForKind, + opsFor +} from './operators.js' + +interface Row { + id: number +} + +const def = (partial: Partial>): ColumnDef => ({ + id: 'x', + header: 'X', + ...partial +}) + +describe('what kind of filter a column wants', () => { + it('believes what the column declared', () => { + expect(kindOf(def({ filter: 'number' }))).toBe('number') + expect(kindOf(def({ filter: 'date' }))).toBe('date') + expect(kindOf(def({ filter: 'set' }))).toBe('set') + expect(kindOf(def({ filter: 'boolean' }))).toBe('boolean') + }) + + it('reads an advanced filter definition too', () => { + expect(kindOf(def({ filter: { type: 'number' } }))).toBe('number') + }) + + it('reads the renderer when no filter was declared', () => { + expect(kindOf(def({ type: 'currency' }))).toBe('number') + expect(kindOf(def({ type: 'percent' }))).toBe('number') + expect(kindOf(def({ type: 'datetime' }))).toBe('date') + expect(kindOf(def({ type: 'boolean' }))).toBe('boolean') + expect(kindOf(def({ type: 'badge' }))).toBe('text') + }) + + it('falls back to text, including for no column at all', () => { + expect(kindOf(def({}))).toBe('text') + expect(kindOf(undefined)).toBe('text') + }) +}) + +describe('which operators a kind offers', () => { + it('never offers startsWith on a number, or gt on text', () => { + expect(opsFor('number')).not.toContain('startsWith') + expect(opsFor('text')).not.toContain('gt') + expect(opsFor('date')).toContain('before') + expect(opsFor('boolean')).toEqual(['equals', 'notEqual', 'blank', 'notBlank']) + }) + + it('keeps an operator that survives a column change, and replaces one that does not', () => { + expect(opForKind('gt', 'number')).toBe('gt') + expect(opForKind('gt', 'text')).toBe('contains') + expect(opForKind('contains', 'date')).toBe('equals') + }) +}) + +describe('what a condition needs typed into it', () => { + it('knows which operators carry a value, a range or a list', () => { + expect(needsValue('blank')).toBe(false) + expect(needsValue('equals')).toBe(true) + expect(needsRange('between')).toBe(true) + expect(needsRange('gt')).toBe(false) + expect(needsList('in')).toBe(true) + }) +}) + +describe('a column the panel should not offer', () => { + const def = (extra: Partial>>) => + ({ id: 'c', ...extra }) as ColumnDef> + + it('takes filter: false as the refusal it is', () => { + expect(isFilterable(def({ filter: false }))).toBe(false) + }) + + it('leaves out an actions column, which holds no value to compare', () => { + expect(isFilterable(def({ type: 'actions' }))).toBe(false) + }) + + it('offers every other type, declared or not', () => { + for (const type of [ + 'text', + 'number', + 'currency', + 'percent', + 'date', + 'datetime', + 'boolean', + 'badge', + 'user', + 'progress', + 'rating', + 'link' + ] as const) { + expect(isFilterable(def({ type })), type).toBe(true) + } + }) +}) diff --git a/src/lib/features/advanced-filter/operators.ts b/src/lib/features/advanced-filter/operators.ts new file mode 100644 index 0000000..248590d --- /dev/null +++ b/src/lib/features/advanced-filter/operators.ts @@ -0,0 +1,80 @@ +import { type ColumnDef } from '../../core/types/index.js' +import type { AdvancedFilterOp } from './advanced-filter.types.js' + +export type FilterKind = 'text' | 'number' | 'date' | 'boolean' | 'set' + +const PRESENCE: AdvancedFilterOp[] = ['blank', 'notBlank'] + +export const OPS_BY_KIND: Record = { + text: ['contains', 'notContains', 'equals', 'notEqual', 'startsWith', 'endsWith', ...PRESENCE], + number: ['equals', 'notEqual', 'gt', 'gte', 'lt', 'lte', 'between', ...PRESENCE], + date: ['equals', 'notEqual', 'before', 'after', 'between', ...PRESENCE], + boolean: ['equals', 'notEqual', ...PRESENCE], + set: ['in', 'equals', 'notEqual', ...PRESENCE] +} + +const BY_COLUMN_TYPE: Record = { + number: 'number', + currency: 'number', + percent: 'number', + progress: 'number', + rating: 'number', + date: 'date', + datetime: 'date', + boolean: 'boolean' +} + +const UNREADABLE = new Set(['actions']) + +export function isFilterable(def: ColumnDef | undefined): boolean { + if (!def) return false + if (def.filter === false) return false + return !UNREADABLE.has(def.type ?? 'text') +} + +export function kindOf(def: ColumnDef | undefined): FilterKind { + if (!def) return 'text' + + const declared = def.filter + if (typeof declared === 'string' && declared in OPS_BY_KIND) return declared as FilterKind + if (declared && typeof declared === 'object' && declared.type in OPS_BY_KIND) { + return declared.type as FilterKind + } + + return BY_COLUMN_TYPE[def.type ?? 'text'] ?? 'text' +} + +export function opsFor(kind: FilterKind): AdvancedFilterOp[] { + return OPS_BY_KIND[kind] +} + +export function needsValue(op: AdvancedFilterOp): boolean { + return !PRESENCE.includes(op) +} + +export function needsRange(op: AdvancedFilterOp): boolean { + return op === 'between' +} + +export function needsList(op: AdvancedFilterOp): boolean { + return op === 'in' +} + +export function opForKind(op: AdvancedFilterOp, kind: FilterKind): AdvancedFilterOp { + const allowed = opsFor(kind) + return allowed.includes(op) ? op : allowed[0]! +} + +export function distinctValues( + read: (index: number) => unknown, + count: number, + limit: number +): string[] { + const seen = new Set() + for (let index = 0; index < count && seen.size < limit; index++) { + const value = read(index) + if (value === null || value === undefined || value === '') continue + seen.add(String(value)) + } + return [...seen].sort() +} diff --git a/src/lib/features/command-palette/command-palette-commands.ts b/src/lib/features/command-palette/command-palette-commands.ts new file mode 100644 index 0000000..c9fff08 --- /dev/null +++ b/src/lib/features/command-palette/command-palette-commands.ts @@ -0,0 +1,192 @@ +import { type GridState } from '../../core/grid/index.js' +import { SELECTION_COLUMN_ID } from '../../core/types/index.js' +import { getGrouping } from '../grouping/grouping.svelte.js' +import { getFindReplace } from '../find-replace/index.js' +import { getRangeSelection } from '../range-selection/range-selection.svelte.js' +import type { CommandPaletteOptions, GridCommand } from './command-palette.types.js' + +export function columnCommands(grid: GridState): GridCommand[] { + const t = grid.labels + const commands: GridCommand[] = [] + + for (const column of grid.columns.all) { + if (column.id === SELECTION_COLUMN_ID) continue + const { id, header } = column + + if (column.hidden) { + commands.push({ + id: `column:show:${id}`, + label: t.commandShowColumn(header), + icon: 'lucide:eye', + group: t.commandGroupColumns, + run: () => grid.columns.setHidden(id, false) + }) + continue + } + + commands.push({ + id: `column:hide:${id}`, + label: t.commandHideColumn(header), + icon: 'lucide:eye-off', + group: t.commandGroupColumns, + run: () => grid.columns.setHidden(id, true) + }) + + if (column.pinned) { + commands.push({ + id: `column:unpin:${id}`, + label: t.commandUnpinColumn(header), + icon: 'lucide:pin-off', + group: t.commandGroupColumns, + run: () => grid.columns.setPinned(id, null) + }) + } else { + commands.push( + { + id: `column:pin-left:${id}`, + label: t.commandPinColumnLeft(header), + icon: 'lucide:pin', + group: t.commandGroupColumns, + run: () => grid.columns.setPinned(id, 'left') + }, + { + id: `column:pin-right:${id}`, + label: t.commandPinColumnRight(header), + icon: 'lucide:pin', + group: t.commandGroupColumns, + run: () => grid.columns.setPinned(id, 'right') + } + ) + } + } + + return commands +} + +export function groupingCommands(grid: GridState): GridCommand[] { + const state = getGrouping(grid) + if (!state) return [] + const t = grid.labels + const commands: GridCommand[] = [] + + for (const column of state.groupableColumns()) { + commands.push({ + id: `group:by:${column.id}`, + label: t.commandGroupByColumn(column.header), + icon: 'lucide:group', + group: t.commandGroupGrouping, + run: () => state.groupBy(column.id) + }) + } + + for (const columnId of state.by) { + commands.push({ + id: `group:remove:${columnId}`, + label: t.commandUngroupColumn(grid.columns.get(columnId)?.header ?? columnId), + icon: 'lucide:ungroup', + group: t.commandGroupGrouping, + run: () => state.ungroup(columnId) + }) + } + + if (state.isGrouped) { + commands.push( + { + id: 'group:expand-all', + label: t.commandExpandAllGroups, + icon: 'lucide:chevrons-up-down', + group: t.commandGroupGrouping, + run: state.expandAllGroups + }, + { + id: 'group:collapse-all', + label: t.commandCollapseAllGroups, + icon: 'lucide:chevrons-down-up', + group: t.commandGroupGrouping, + run: state.collapseAllGroups + }, + { + id: 'group:clear', + label: t.commandClearGrouping, + icon: 'lucide:x', + group: t.commandGroupGrouping, + run: state.clearGrouping + } + ) + } + + return commands +} + +export function rangeCommands(grid: GridState): GridCommand[] { + const state = getRangeSelection(grid) + if (!state || state.ranges.length === 0) return [] + const t = grid.labels + + const commands: GridCommand[] = [ + { + id: 'range:copy', + label: t.commandCopyRange, + icon: 'lucide:copy', + keywords: ['clipboard'], + group: t.commandGroupClipboard, + run: () => void state.copyRange() + } + ] + + const range = state.primary + if (state.fill && range) { + if (range.bottom > range.top) { + commands.push({ + id: 'range:fill-down', + label: t.commandFillDown, + icon: 'lucide:arrow-down-to-line', + group: t.commandGroupClipboard, + run: () => void state.fillWithin('down') + }) + } + if (range.right > range.left) { + commands.push({ + id: 'range:fill-right', + label: t.commandFillRight, + icon: 'lucide:arrow-right-to-line', + group: t.commandGroupClipboard, + run: () => void state.fillWithin('right') + }) + } + } + + return commands +} + +export function findCommands(grid: GridState): GridCommand[] { + const state = getFindReplace(grid) + if (!state || state.open) return [] + + return [ + { + id: 'find:open', + label: grid.labels.commandFind, + icon: 'lucide:search', + keywords: ['find', 'replace', 'search'], + group: grid.labels.commandGroupClipboard, + run: state.show + } + ] +} + +export function buildCommands( + grid: GridState, + options: CommandPaletteOptions +): GridCommand[] { + const builtins = + (options.builtins ?? true) + ? [ + ...columnCommands(grid), + ...groupingCommands(grid), + ...rangeCommands(grid), + ...findCommands(grid) + ] + : [] + return [...builtins, ...(options.commands?.(grid) ?? [])] +} diff --git a/src/lib/features/command-palette/command-palette.svelte.ts b/src/lib/features/command-palette/command-palette.svelte.ts new file mode 100644 index 0000000..8cd3d4a --- /dev/null +++ b/src/lib/features/command-palette/command-palette.svelte.ts @@ -0,0 +1,66 @@ +import { type GridState } from '../../core/grid/index.js' +import { type GridFeature } from '../../core/types/index.js' +import { buildCommands } from './command-palette-commands.js' +import type { CommandPaletteOptions, GridCommand } from './command-palette.types.js' + +export const COMMAND_PALETTE = 'commandPalette' + +export class CommandPalette { + open = $state(false) + + #grid: GridState + #options: CommandPaletteOptions + + constructor(grid: GridState, options: CommandPaletteOptions) { + this.#grid = grid + this.#options = options + } + + get commands(): GridCommand[] { + return buildCommands(this.#grid, this.#options) + } + + show = (): void => { + this.open = true + } + + hide = (): void => { + this.open = false + } + + toggle = (): void => { + this.open = !this.open + } + + run = (command: GridCommand): void => { + this.hide() + command.run() + } +} + +export function commandPalette(options: CommandPaletteOptions = {}): GridFeature { + return { + id: COMMAND_PALETTE, + createState: (grid) => new CommandPalette(grid, options), + createApi: (grid) => { + const state = getCommandPalette(grid)! + return { + openCommandPalette: state.show, + closeCommandPalette: state.hide, + toggleCommandPalette: state.toggle + } + } + } +} + +export function getCommandPalette(grid: GridState): CommandPalette | undefined { + return grid.feature>(COMMAND_PALETTE) +} + +declare module '../../core/types/api.js' { + interface GridApi { + openCommandPalette?: () => void + closeCommandPalette?: () => void + toggleCommandPalette?: () => void + } +} diff --git a/src/lib/features/command-palette/command-palette.test.ts b/src/lib/features/command-palette/command-palette.test.ts new file mode 100644 index 0000000..fb55a2a --- /dev/null +++ b/src/lib/features/command-palette/command-palette.test.ts @@ -0,0 +1,137 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { grouping } from '../grouping/grouping.svelte.js' +import { getRangeSelection, rangeSelection } from '../range-selection/range-selection.svelte.js' +import { type CommandPalette, commandPalette, getCommandPalette } from './command-palette.svelte.js' +import type { CommandPaletteOptions, GridCommand } from './command-palette.types.js' + +interface Person { + id: number + name: string + dept: string + salary: number +} + +const people: Person[] = [ + { id: 1, name: 'Alice', dept: 'Core', salary: 100 }, + { id: 2, name: 'Bob', dept: 'Data', salary: 200 } +] + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + { id: 'dept', header: 'Dept' }, + { id: 'salary', header: 'Salary', hidden: true } +] + +function createGrid( + options: CommandPaletteOptions = {}, + extraFeatures: Parameters>[0]['features'] = [] +): { grid: GridState; palette: CommandPalette } { + const grid = createDataGrid({ + columns, + data: people, + getRowId: (person) => String(person.id), + features: [...(extraFeatures ?? []), commandPalette(options)] + }) + return { grid, palette: getCommandPalette(grid)! } +} + +function ids(commands: GridCommand[]): string[] { + return commands.map((command) => command.id) +} + +describe('column commands', () => { + it('offers hide + pin for visible columns and show for hidden ones', () => { + const { palette } = createGrid() + const commandIds = ids(palette.commands) + + expect(commandIds).toContain('column:hide:name') + expect(commandIds).toContain('column:pin-left:name') + expect(commandIds).toContain('column:pin-right:name') + expect(commandIds).toContain('column:show:salary') + expect(commandIds).not.toContain('column:hide:salary') + }) + + it('mutates the grid when run, and the list follows', () => { + const { grid, palette } = createGrid() + + palette.commands.find((command) => command.id === 'column:hide:name')!.run() + expect(grid.columns.get('name')?.hidden).toBe(true) + expect(ids(palette.commands)).toContain('column:show:name') + + palette.commands.find((command) => command.id === 'column:pin-left:dept')!.run() + expect(grid.columns.get('dept')?.pinned).toBe('left') + expect(ids(palette.commands)).toContain('column:unpin:dept') + }) +}) + +describe('grouping commands', () => { + it('is absent without the grouping feature', () => { + const { palette } = createGrid() + expect(ids(palette.commands).some((id) => id.startsWith('group:'))).toBe(false) + }) + + it('offers group-by, ungroup and bulk commands from live grouping state', () => { + const { palette } = createGrid({}, [grouping({ by: ['dept'] })]) + const commandIds = ids(palette.commands) + + expect(commandIds).toContain('group:by:name') + expect(commandIds).not.toContain('group:by:dept') + expect(commandIds).toContain('group:remove:dept') + expect(commandIds).toContain('group:expand-all') + expect(commandIds).toContain('group:collapse-all') + expect(commandIds).toContain('group:clear') + }) +}) + +describe('range commands', () => { + it('offers copy only while a range is selected', () => { + const { grid, palette } = createGrid({}, [rangeSelection()]) + expect(ids(palette.commands)).not.toContain('range:copy') + + getRangeSelection(grid)!.startRange(0, 0) + expect(ids(palette.commands)).toContain('range:copy') + }) +}) + +describe('custom commands and builtins toggle', () => { + it('appends custom commands after the built-ins', () => { + const { palette } = createGrid({ + commands: () => [{ id: 'custom:export', label: 'Export', run: () => {} }] + }) + const commandIds = ids(palette.commands) + expect(commandIds[commandIds.length - 1]).toBe('custom:export') + }) + + it('yields only custom commands when builtins is false', () => { + const { palette } = createGrid({ + builtins: false, + commands: () => [{ id: 'custom:only', label: 'Only', run: () => {} }] + }) + expect(ids(palette.commands)).toEqual(['custom:only']) + }) +}) + +describe('palette state and api', () => { + it('opens, toggles and closes through the grid api', () => { + const { grid, palette } = createGrid() + expect(palette.open).toBe(false) + ;(grid.api.openCommandPalette as () => void)() + expect(palette.open).toBe(true) + ;(grid.api.toggleCommandPalette as () => void)() + expect(palette.open).toBe(false) + }) + + it('run closes the palette before executing', () => { + const { palette } = createGrid() + const order: string[] = [] + palette.show() + palette.run({ + id: 'probe', + label: 'Probe', + run: () => order.push(`ran while open=${palette.open}`) + }) + expect(order).toEqual(['ran while open=false']) + }) +}) diff --git a/src/lib/features/command-palette/command-palette.types.ts b/src/lib/features/command-palette/command-palette.types.ts new file mode 100644 index 0000000..7e95153 --- /dev/null +++ b/src/lib/features/command-palette/command-palette.types.ts @@ -0,0 +1,33 @@ +import { type GridState } from '../../core/grid/index.js' + +/** One entry in the command palette. */ +export interface GridCommand { + /** Unique id; also the palette's search value, so keep it stable. */ + id: string + label: string + /** Iconify name, e.g. `lucide:eye-off`. */ + icon?: string + /** Extra search aliases beyond the label. */ + keywords?: string[] + /** Section heading the command renders under. @default 'Commands' */ + group?: string + disabled?: boolean + /** Executes the command. The palette closes first, then this runs. */ + run: () => void +} + +export interface CommandPaletteOptions { + /** + * Extra commands appended after the built-ins. Read every time the + * list is built, so it can reflect the grid's current state. + */ + commands?: (grid: GridState) => GridCommand[] + + /** + * Contribute the built-in commands: column hide/show/pin, grouping + * (when the grouping feature is registered) and range copy (when + * range selection is registered). + * @default true + */ + builtins?: boolean +} diff --git a/src/lib/features/command-palette/index.ts b/src/lib/features/command-palette/index.ts new file mode 100644 index 0000000..05e5726 --- /dev/null +++ b/src/lib/features/command-palette/index.ts @@ -0,0 +1,13 @@ +export { + buildCommands, + columnCommands, + groupingCommands, + rangeCommands +} from './command-palette-commands.js' +export { + COMMAND_PALETTE, + CommandPalette, + commandPalette, + getCommandPalette +} from './command-palette.svelte.js' +export type { CommandPaletteOptions, GridCommand } from './command-palette.types.js' diff --git a/src/lib/features/conditional-formatting/conditional-formatting.svelte.ts b/src/lib/features/conditional-formatting/conditional-formatting.svelte.ts new file mode 100644 index 0000000..c0f0afc --- /dev/null +++ b/src/lib/features/conditional-formatting/conditional-formatting.svelte.ts @@ -0,0 +1,204 @@ +import { + type CellRead, + gateReader, + type GridState, + isDataRow, + isLoadingRow +} from '../../core/grid/index.js' +import { + type CellDecoration, + type ColumnState, + type GridFeature, + type RowNode +} from '../../core/types/index.js' +import { slotClass } from '../../core/theme/index.js' +import type { ConditionalFormattingOptions, FormatRule } from './conditional-formatting.types.js' +import { isPainted, mergePaint, type Paint, type PaintContext, paintOf } from './paint.js' +import { + appliesTo, + compileRules, + copyRules, + NO_COMPILED, + normalizeRules, + ruleId, + sanitizeRules, + type CompiledRules, + type ReportParseError, + type RuleParseError +} from './rules.js' +import { columnMap, computeStats, dataRowsOf, type RuleStats } from './stats.js' + +export const CONDITIONAL_FORMATTING = 'conditionalFormatting' + +const SERVER_SKIP = + 'conditionalFormatting() skips colorScale, dataBar, topN and duplicates on ' + + 'rowModel: "server": each ranks a value against the whole column, and the client only holds ' + + 'the blocks it has loaded, so the paint would change under the user as they scroll. ' + + 'Expression rules still run - they read one row at a time. ' + + 'getConditionalFormatting(grid).skippedRules lists what was skipped.' + +const NO_READ = (): undefined => undefined + +export class ConditionalFormatting { + #grid: GridState + #gate: CellRead + #report: ReportParseError + + #rules = $state.raw([]) + #compiled = $state.raw(NO_COMPILED) + + #highlight = $derived(slotClass('formatHighlight')) + + constructor(grid: GridState, options: ConditionalFormattingOptions) { + this.#grid = grid + this.#gate = gateReader(grid, 'render') + this.#report = (id, message, at) => options.onParseError?.(id, message, at) + this.#apply(normalizeRules(options.rules ?? [])) + } + + #warned = false + + #columns: ReadonlyMap> = $derived(this.#columnsOf()) + #hasExpression = $derived(this.#rules.some((rule) => rule.kind === 'expression')) + #stats: RuleStats[] = $derived(this.#compute()) + + #columnsOf(): ReadonlyMap> { + return columnMap(this.#grid.columns.all) + } + + #compute(): RuleStats[] { + if (this.#rules.length === 0) return [] + const rows = dataRowsOf(this.#grid.preWindowNodes, isDataRow) + return computeStats(this.#rules, rows, this.#columns, this.#gate) + } + + get skippedRules(): readonly string[] { + if (this.#grid.rowModel !== 'server') return [] + return this.#rules + .filter((rule) => rule.kind !== 'expression') + .map((rule) => rule.id ?? rule.kind) + } + + #skips(rule: FormatRule): boolean { + if (this.#grid.rowModel !== 'server' || rule.kind === 'expression') return false + if (!this.#warned) { + this.#warned = true + // eslint-disable-next-line no-console + console.warn(SERVER_SKIP) + } + return true + } + + #apply(rules: FormatRule[]): void { + this.#rules = rules + this.#compiled = compileRules(rules, this.#report) + } + + errorOf = (id: string): RuleParseError | null => this.#compiled.get(id)?.error ?? null + + get rules(): readonly FormatRule[] { + return this.#rules + } + + setRules = (rules: readonly FormatRule[]): void => { + this.#apply(normalizeRules(rules)) + } + + addRule = (rule: FormatRule): void => { + this.#apply(normalizeRules([...this.#rules, rule])) + } + + removeRule = (id: string): void => { + this.#apply(this.#rules.filter((rule, index) => ruleId(rule, index) !== id)) + } + + clearRules = (): void => { + this.#apply([]) + } + + #readerFor = (node: RowNode) => (columnId: string) => { + const column = this.#columns.get(columnId) + return column ? this.#gate(node, column.def) : undefined + } + + #contextFor(node: RowNode, column: ColumnState): PaintContext { + return { + value: this.#gate(node, column.def), + rowId: node.id, + read: this.#hasExpression ? this.#readerFor(node) : NO_READ, + node: null, + highlight: this.#highlight + } + } + + #nodeFor(rule: FormatRule, index: number): PaintContext['node'] { + return this.#compiled.get(ruleId(rule, index))?.node ?? null + } + + decoration = (node: RowNode, column: ColumnState): CellDecoration | undefined => { + const rules = this.#rules + if (rules.length === 0 || isLoadingRow(node.row)) return undefined + + const stats = this.#stats + let context: PaintContext | undefined + let paint: Paint | undefined + + for (const [index, rule] of rules.entries()) { + if (!appliesTo(rule, column.id) || this.#skips(rule)) continue + context ??= this.#contextFor(node, column) + context.node = this.#nodeFor(rule, index) + + const next = paintOf(rule, stats[index] ?? null, context) + if (next) paint = mergePaint(paint, next) + } + + return isPainted(paint) ? paint : undefined + } + + serialize = (): FormatRule[] | undefined => + this.#rules.length === 0 ? undefined : copyRules(this.#rules) + + hydrate = (slice: unknown): void => { + const next = sanitizeRules(slice) + if (next) this.#apply(next) + } +} + +export function conditionalFormatting( + options: ConditionalFormattingOptions = {} +): GridFeature { + return { + id: CONDITIONAL_FORMATTING, + createState: (grid) => new ConditionalFormatting(grid, options), + createApi: (grid) => { + const state = getConditionalFormatting(grid)! + return { + setFormatRules: state.setRules, + addFormatRule: state.addRule, + removeFormatRule: state.removeRule, + clearFormatRules: state.clearRules, + getFormatRules: () => copyRules(state.rules) + } + }, + cellDecoration: ({ grid, node, column }) => + getConditionalFormatting(grid)?.decoration(node, column), + serialize: (grid) => getConditionalFormatting(grid)?.serialize(), + hydrate: (slice, grid) => getConditionalFormatting(grid)?.hydrate(slice) + } +} + +export function getConditionalFormatting( + grid: GridState +): ConditionalFormatting | undefined { + return grid.feature>(CONDITIONAL_FORMATTING) +} + +declare module '../../core/types/api.js' { + interface GridApi { + setFormatRules?: (rules: readonly FormatRule[]) => void + addFormatRule?: (rule: FormatRule) => void + removeFormatRule?: (id: string) => void + clearFormatRules?: () => void + getFormatRules?: () => FormatRule[] + } +} diff --git a/src/lib/features/conditional-formatting/conditional-formatting.test.ts b/src/lib/features/conditional-formatting/conditional-formatting.test.ts new file mode 100644 index 0000000..6d3634d --- /dev/null +++ b/src/lib/features/conditional-formatting/conditional-formatting.test.ts @@ -0,0 +1,403 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { pagination } from '../../features/pagination/index.js' +import { sorting } from '../../features/sorting/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it, vi } from 'vitest' +import { formula } from '../formula/index.js' +import { grouping } from '../grouping/index.js' +import { serverRowModel } from '../server-row-model/index.js' +import { tree } from '../tree/index.js' +import type { ConditionalFormattingOptions, FormatRule } from './conditional-formatting.types.js' +import { conditionalFormatting, getConditionalFormatting } from './conditional-formatting.svelte.js' + +interface Deal { + id: number + owner: string + status: string + amount: number + qty: number + email: string + total?: number +} + +const deals: Deal[] = [ + { id: 1, owner: 'ann', status: 'open', amount: 100, qty: 2, email: 'a@x.com' }, + { id: 2, owner: 'bob', status: 'overdue', amount: 400, qty: 1, email: 'b@x.com' }, + { id: 3, owner: 'ann', status: 'open', amount: 200, qty: 3, email: 'a@x.com' }, + { id: 4, owner: 'cid', status: 'won', amount: 0, qty: 5, email: 'c@x.com' } +] + +const columns: ColumnDef[] = [ + { id: 'owner', header: 'Owner', sortable: true, filter: 'text' }, + { id: 'status', header: 'Status', filter: 'text' }, + { id: 'amount', header: 'Amount', sortable: true, filter: 'number' }, + { id: 'qty', header: 'Qty' }, + { id: 'email', header: 'Email' }, + { id: 'total', header: 'Total' } +] + +function build(options: ConditionalFormattingOptions, extra: unknown[] = []) { + return createDataGrid({ + columns, + data: deals, + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + ...(extra as never[]), + conditionalFormatting(options) + ] + }) +} + +function decorate(grid: GridState, rowIndex: number, columnId: string) { + const node = grid.preWindowNodes[rowIndex]! + const column = grid.columns.visible.find((entry) => entry.id === columnId)! + return getConditionalFormatting(grid)!.decoration(node, column) +} + +describe('rules paint the cells they name', () => { + it('paints only the column the rule names', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + expect(decorate(grid, 0, 'amount')?.style).toBeDefined() + expect(decorate(grid, 0, 'owner')).toBeUndefined() + }) + + it('scales against the column, so the largest row is the fullest', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + expect(decorate(grid, 1, 'amount')?.style?.['background-color']).toContain('100%') + expect(decorate(grid, 3, 'amount')?.style?.['background-color']).toContain('0%') + }) + + it('places a middle value where the arithmetic says, not merely between the ends', () => { + const grid = createDataGrid({ + columns, + data: [ + { id: 1, owner: 'a', status: 'open', amount: 0, qty: 1, email: 'a@x' }, + { id: 2, owner: 'b', status: 'open', amount: 25, qty: 1, email: 'b@x' }, + { id: 3, owner: 'c', status: 'open', amount: 90, qty: 1, email: 'c@x' }, + { id: 4, owner: 'd', status: 'open', amount: 100, qty: 1, email: 'd@x' } + ], + getRowId: (row) => String(row.id), + features: [ + conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + ] + }) + + const shades = grid.preWindowNodes.map( + (_, index) => decorate(grid, index, 'amount')?.style?.['background-color'] + ) + + expect(shades).toEqual([ + 'color-mix(in oklab, var(--color-primary) 0%, transparent)', + 'color-mix(in oklab, var(--color-primary) 25%, transparent)', + 'color-mix(in oklab, var(--color-primary) 90%, transparent)', + 'color-mix(in oklab, var(--color-primary) 100%, transparent)' + ]) + }) + + it('paints every cell of the row for an expression rule with no column', () => { + const grid = build({ rules: [{ kind: 'expression', when: 'status = "overdue"' }] }) + expect(decorate(grid, 1, 'owner')?.class).toBeTruthy() + expect(decorate(grid, 1, 'amount')?.class).toBeTruthy() + expect(decorate(grid, 0, 'owner')).toBeUndefined() + }) + + it('reads a duplicate across the whole column, not within the row', () => { + const grid = build({ rules: [{ kind: 'duplicates', column: 'email' }] }) + expect(decorate(grid, 0, 'email')?.class).toBeTruthy() + expect(decorate(grid, 1, 'email')).toBeUndefined() + }) + + it('merges two rules on one cell, the later winning the property they share', () => { + const grid = build({ + rules: [ + { + kind: 'expression', + column: 'amount', + when: 'amount > 0', + style: { color: 'red' } + }, + { + kind: 'expression', + column: 'amount', + when: 'amount > 0', + style: { color: 'blue' } + } + ] + }) + expect(decorate(grid, 0, 'amount')?.style).toEqual({ color: 'blue' }) + }) +}) + +describe('the rules follow the grid', () => { + it('rescales after a filter, so the ramp spans what is on screen', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + expect(decorate(grid, 1, 'amount')?.style?.['background-color']).toContain('100%') + + grid.api.setColumnFilter?.('amount', { kind: 'number', op: 'lt', value: 300 }) + const top = grid.preWindowNodes.findIndex((node) => node.row.amount === 200) + expect(decorate(grid, top, 'amount')?.style?.['background-color']).toContain('100%') + }) + + it('ranks against the filtered set for topN', () => { + const grid = build({ rules: [{ kind: 'topN', column: 'amount', n: 1 }] }) + expect(decorate(grid, 1, 'amount')?.class).toBeTruthy() + expect(decorate(grid, 2, 'amount')).toBeUndefined() + }) + + it('sees a formula column, which is the part a spreadsheet grid usually cannot', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'total' }] }, [ + formula({ columns: { total: 'amount * qty' } }) + ]) + const totals = grid.preWindowNodes.map((node) => node.row.total) + expect(totals).toEqual([200, 400, 600, 0]) + expect(decorate(grid, 2, 'total')?.style?.['background-color']).toContain('100%') + }) + + it('leaves the group rows out of the range they would otherwise distort', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'amount' }] }, [ + grouping({ by: ['owner'], aggregations: { amount: 'sum' } }) + ]) + const rows = grid.preWindowNodes.filter((node) => node.row.amount === 400) + expect(rows.length).toBeGreaterThan(0) + const index = grid.preWindowNodes.findIndex((node) => node.id === '2') + expect(decorate(grid, index, 'amount')?.style?.['background-color']).toContain('100%') + }) +}) + +describe('the rules see the rows a structural feature nested', () => { + interface Node { + id: number + owner: string + amount: number + reports?: Node[] + } + + const nested: Node[] = [ + { id: 1, owner: 'ann', amount: 10, reports: [{ id: 2, owner: 'bob', amount: 100 }] } + ] + + it('spans a colour scale over tree children, not only over the roots', () => { + const grid = createDataGrid({ + columns: [ + { id: 'owner', header: 'Owner' }, + { id: 'amount', header: 'Amount' } + ], + data: nested, + getRowId: (row) => String(row.id), + features: [ + tree({ getChildren: (row) => row.reports, defaultExpandedDepth: 2 }), + conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + ] + }) + const state = getConditionalFormatting(grid)! + const column = grid.columns.visible.find((entry) => entry.id === 'amount')! + const [root, child] = grid.preWindowNodes + + expect(state.decoration(root!, column)?.style?.['background-color']).toContain('0%') + expect(state.decoration(child!, column)?.style?.['background-color']).toContain('100%') + }) +}) + +describe('the rules span the data, not the page', () => { + it('ranks over every filtered row while pagination shows one page', () => { + const grid = createDataGrid({ + columns, + data: deals, + getRowId: (row) => String(row.id), + features: [ + sorting(), + pagination({ pageSize: 2 }), + conditionalFormatting({ rules: [{ kind: 'topN', column: 'amount', n: 1 }] }) + ] + }) + expect(grid.nodes).toHaveLength(2) + + const state = getConditionalFormatting(grid)! + const amount = grid.columns.visible.find((entry) => entry.id === 'amount')! + const painted = grid.preWindowNodes.filter((node) => state.decoration(node, amount)) + + expect(painted.map((node) => node.row.amount)).toEqual([400]) + }) +}) + +describe('the rule list is state, not code', () => { + it('round-trips through serialize and hydrate', () => { + const grid = build({ rules: [{ kind: 'topN', column: 'amount', n: 2 }] }) + const snapshot = JSON.parse(JSON.stringify(grid.api.getState?.())) + + const restored = build({}) + restored.api.setState?.(snapshot) + expect(restored.api.getFormatRules?.()).toEqual([ + expect.objectContaining({ kind: 'topN', column: 'amount', n: 2 }) + ]) + }) + + it('hands out copies, so an app cannot edit the live rules through a snapshot', () => { + const grid = build({ rules: [{ kind: 'topN', column: 'amount', n: 2 }] }) + const taken = grid.api.getFormatRules?.() ?? [] + taken[0]!.column = 'qty' + + expect(grid.api.getFormatRules?.()[0]!.column).toBe('amount') + }) + + it('removes only the rule named, when two rules share a kind', () => { + const grid = build({}) + grid.api.addFormatRule?.({ kind: 'duplicates', column: 'email' }) + grid.api.addFormatRule?.({ kind: 'duplicates', column: 'owner' }) + const [first] = grid.api.getFormatRules?.() ?? [] + + grid.api.removeFormatRule?.(first!.id!) + expect(grid.api.getFormatRules?.()).toHaveLength(1) + expect(decorate(grid, 0, 'email')).toBeUndefined() + }) + + it('adds and removes a rule by id at runtime', () => { + const grid = build({}) + grid.api.addFormatRule?.({ kind: 'duplicates', column: 'email', id: 'dupes' }) + expect(decorate(grid, 0, 'email')?.class).toBeTruthy() + + grid.api.removeFormatRule?.('dupes') + expect(decorate(grid, 0, 'email')).toBeUndefined() + }) + + it('replaces the whole list and clears it', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + grid.api.setFormatRules?.([{ kind: 'colorScale', column: 'qty' }]) + expect(decorate(grid, 0, 'amount')).toBeUndefined() + expect(decorate(grid, 0, 'qty')?.style).toBeDefined() + + grid.api.clearFormatRules?.() + expect(decorate(grid, 0, 'qty')).toBeUndefined() + }) + + it('reports a broken expression once and paints nothing for it', () => { + const onParseError = vi.fn() + const grid = build({ rules: [{ kind: 'expression', when: 'status =' }], onParseError }) + expect(onParseError).toHaveBeenCalledOnce() + expect(decorate(grid, 0, 'status')).toBeUndefined() + }) + + it('ignores a hydrated payload that is not a rule list', () => { + const grid = build({ rules: [{ kind: 'colorScale', column: 'amount' }] }) + getConditionalFormatting(grid)!.hydrate({ evil: true }) + expect(decorate(grid, 0, 'amount')?.style).toBeDefined() + }) + + it('drops a hydrated rule naming a kind the feature does not have', () => { + const grid = build({}) + getConditionalFormatting(grid)!.hydrate([ + { kind: 'script', column: 'amount' }, + { kind: 'colorScale', column: 'amount' } + ] as unknown as FormatRule[]) + expect(grid.api.getFormatRules?.()).toHaveLength(1) + }) +}) + +describe('a rule that ranks a value against the whole column, on a server row model', () => { + interface Row { + id: number + total: number + } + + const serverColumns: ColumnDef[] = [{ id: 'total', header: 'Total' }] + const table: Row[] = Array.from({ length: 40 }, (_, i) => ({ id: i + 1, total: (i + 1) * 100 })) + + function serverGrid(rules: FormatRule[]): GridState { + return createDataGrid({ + columns: serverColumns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization(), + serverRowModel( + { + getRows: async ({ startRow, endRow }) => ({ + rows: table.slice(startRow, Math.min(endRow, 10)), + rowCount: 40 + }) + }, + { + mode: 'infinite', + blockSize: 10, + placeholder: (index) => ({ id: -index - 1, total: 0 }) + } + ), + conditionalFormatting({ rules }) + ] + }) + } + + async function loaded(grid: GridState) { + grid.feature<{ refresh: () => void }>('serverRowModel')!.refresh() + await vi.waitFor(() => expect(grid.data.length).toBe(40)) + return grid + } + + it('skips it, and says which rules it skipped', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = await loaded( + serverGrid([ + { kind: 'colorScale', id: 'scale', column: 'total' }, + { kind: 'topN', id: 'top', column: 'total', n: 3 } + ]) + ) + + const state = getConditionalFormatting(grid)! + const column = grid.columns.visible[0]! + const painted = grid.preWindowNodes + .slice(0, 10) + .filter((node) => state.decoration(node, column) !== undefined) + + expect(painted).toHaveLength(0) + expect(state.skippedRules).toEqual(['scale', 'top']) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('skips colorScale, dataBar, topN and duplicates') + warn.mockRestore() + }) + + it('keeps painting an expression rule, which only ever reads one row', async () => { + const grid = await loaded( + serverGrid([ + { + kind: 'expression', + id: 'flag', + column: 'total', + when: 'total > 500', + style: { 'background-color': 'var(--color-error)' } + } + ]) + ) + + const state = getConditionalFormatting(grid)! + const column = grid.columns.visible[0]! + const painted = grid.preWindowNodes + .slice(0, 10) + .filter((node) => state.decoration(node, column) !== undefined) + + expect(painted.map((node) => node.id)).toEqual(['6', '7', '8', '9', '10']) + expect(state.skippedRules).toEqual([]) + }) + + it('paints every rule on a client grid, where the whole column is here', () => { + const grid = createDataGrid({ + columns: serverColumns, + data: table, + getRowId: (row) => String(row.id), + features: [ + sorting(), + conditionalFormatting({ + rules: [{ kind: 'colorScale', id: 'scale', column: 'total' }] + }) + ] + }) + + const state = getConditionalFormatting(grid)! + expect(state.skippedRules).toEqual([]) + expect(state.decoration(grid.nodes[0]!, grid.columns.visible[0]!)).toBeDefined() + }) +}) diff --git a/src/lib/features/conditional-formatting/conditional-formatting.types.ts b/src/lib/features/conditional-formatting/conditional-formatting.types.ts new file mode 100644 index 0000000..ec1dd67 --- /dev/null +++ b/src/lib/features/conditional-formatting/conditional-formatting.types.ts @@ -0,0 +1,190 @@ +/** + * What a rule paints when it matches: a class, inline declarations, or both. + * + * The style record is keyed by CSS property and goes onto the cell through + * Community's `CellDecoration.style`, which the grid's own layout outranks: + * a rule can colour a cell but cannot move it out of its column. + */ +export interface FormatPaint { + /** + * Classes merged onto the cell. Any class the app's Tailwind build knows + * about, applied as written - including from a hydrated share link, which + * is the one path where the rule was not authored by the app. + */ + class?: string + + /** + * Declarations written onto the cell, keyed by CSS property. A value is + * cut at the first `;`, so one entry stays one declaration. + */ + style?: Record +} + +/** Fields every rule carries. */ +export interface FormatRuleBase { + /** + * Stable id, so a rule can be removed or replaced by name. Generated from + * the rule's position when omitted, which is enough for a static list and + * not enough for one the user edits. + */ + id?: string +} + +/** + * A continuous colour ramp across the column's range: the lowest value gets + * `from`, the highest `to`, everything between a mix of the two. + * + * Mixing happens in CSS (`color-mix`), not in JavaScript, so a theme token + * (`var(--color-primary)`) works as a stop and follows the theme when it + * changes - which a hex value interpolated at build time cannot. + */ +export interface ColorScaleRule extends FormatRuleBase { + kind: 'colorScale' + + /** Column the ramp reads and paints. */ + column: string + + /** + * Colour at the low end. Any CSS colour, including a theme token. + * + * @default 'transparent' + */ + from?: string + + /** + * Colour at the high end. + * + * @default 'var(--color-primary)' + */ + to?: string + + /** Optional middle stop, making the ramp three-colour rather than two. */ + via?: string + + /** + * Value that counts as the low end. Defaults to the smallest value in the + * column after filtering, so the ramp always spans what the grid shows. + */ + min?: number + + /** Value that counts as the high end. Defaults to the largest. */ + max?: number +} + +/** + * A bar drawn behind the value, its width proportional to the value's place + * in the column's range. Drawn as a background gradient, so it costs no + * element and never intercepts a click. + */ +export interface DataBarRule extends FormatRuleBase { + kind: 'dataBar' + + /** Column the bar reads and paints. */ + column: string + + /** + * Bar colour. Any CSS colour, including a theme token. + * + * @default 'color-mix(in oklab, var(--color-primary) 35%, transparent)' + */ + color?: string + + /** Colour for negative values, when they should read differently. */ + negativeColor?: string + + /** + * Value at which the bar is empty. Defaults to the smallest value in the + * column, or to zero when every value is positive - a bar measured from + * the smallest value would otherwise show the smallest row as empty. + */ + min?: number + + /** Value at which the bar is full. Defaults to the largest. */ + max?: number +} + +/** + * Paints the cells of rows where an expression is true. + * + * The condition is written as **text** in the same language as a formula + * column (`status = "overdue" AND total > 1000`), evaluated by the same + * hand-written parser - never `eval`. Text rather than a callback because a + * string round-trips: a rule travels into saved views, share links and + * `getState`/`setState`, which a function cannot. + */ +export interface ExpressionRule extends FormatRuleBase, FormatPaint { + kind: 'expression' + + /** + * Column to paint. Omit to paint the whole row - the expression is + * evaluated once per row either way. + */ + column?: string + + /** The condition, as text. A row is painted when it evaluates truthy. */ + when: string +} + +/** + * Paints values that appear more than once in the column - the fastest way to + * see a duplicated email or SKU without writing a query. + */ +export interface DuplicatesRule extends FormatRuleBase, FormatPaint { + kind: 'duplicates' + + /** Column whose values are counted. */ + column: string + + /** + * Paints the values that appear exactly once instead of the repeated ones. + * + * @default false + */ + unique?: boolean +} + +/** + * Paints the highest (or lowest) values in the column, by rank rather than by + * threshold - the rule survives the numbers changing. + */ +export interface TopNRule extends FormatRuleBase, FormatPaint { + kind: 'topN' + + /** Column whose values are ranked. */ + column: string + + /** + * How many rows to paint. Ties are painted too, so a column with five + * rows sharing the tenth-largest value paints more than `n` cells. + * + * @default 10 + */ + n?: number + + /** + * Ranks from the bottom instead of the top. + * + * @default false + */ + bottom?: boolean +} + +/** Any rule the feature understands. */ +export type FormatRule = ColorScaleRule | DataBarRule | ExpressionRule | DuplicatesRule | TopNRule + +export interface ConditionalFormattingOptions { + /** + * Rules in paint order: a later rule wins a property an earlier one also + * set, the way `CellDecoration.style` merges. At most 64 are kept - a + * hydrated share link is untrusted input, and the hook runs per rendered + * cell. + */ + rules?: FormatRule[] + + /** + * Called when a rule's expression fails to parse - on the initial options + * and on every later edit. Reported rather than thrown, so one bad rule + * cannot take a grid down; the rule simply paints nothing. + */ + onParseError?: (ruleId: string, message: string, at: number) => void +} diff --git a/src/lib/features/conditional-formatting/index.ts b/src/lib/features/conditional-formatting/index.ts new file mode 100644 index 0000000..1e7f7cb --- /dev/null +++ b/src/lib/features/conditional-formatting/index.ts @@ -0,0 +1,39 @@ +export { + CONDITIONAL_FORMATTING, + ConditionalFormatting, + conditionalFormatting, + getConditionalFormatting +} from './conditional-formatting.svelte.js' +export type { + ColorScaleRule, + ConditionalFormattingOptions, + DataBarRule, + DuplicatesRule, + ExpressionRule, + FormatPaint, + FormatRule, + FormatRuleBase, + TopNRule +} from './conditional-formatting.types.js' +export { isPainted, mergePaint, paintOf, type Paint, type PaintContext } from './paint.js' +export { + appliesTo, + compileRules, + copyRules, + MAX_RULES, + normalizeRules, + ruleId, + sanitizeRules, + type CompiledRule, + type CompiledRules, + type RuleParseError +} from './rules.js' +export { + columnMap, + computeStats, + dataRowsOf, + hasNumbers, + NUMERIC_KINDS, + sampleDataRows, + type RuleStats +} from './stats.js' diff --git a/src/lib/features/conditional-formatting/paint.test.ts b/src/lib/features/conditional-formatting/paint.test.ts new file mode 100644 index 0000000..f673b14 --- /dev/null +++ b/src/lib/features/conditional-formatting/paint.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { parse } from '../formula/index.js' +import { mergePaint, type PaintContext, paintOf } from './paint.js' +import type { RuleStats } from './stats.js' + +const context = (over: Partial = {}): PaintContext => ({ + value: 0, + rowId: '1', + read: () => undefined, + node: null, + highlight: 'x-highlight', + ...over +}) + +const scale: RuleStats = { kind: 'scale', min: 0, max: 100 } + +describe('colour scale', () => { + it('mixes in CSS rather than in JavaScript, so a theme token works as a stop', () => { + const paint = paintOf( + { kind: 'colorScale', column: 'a', to: 'var(--color-primary)' }, + scale, + context({ value: 25 }) + ) + expect(paint?.style).toEqual({ + 'background-color': 'color-mix(in oklab, var(--color-primary) 25%, transparent)' + }) + }) + + it('passes through the middle stop when the ramp has three colours', () => { + const rule = { + kind: 'colorScale', + column: 'a', + from: 'red', + via: 'white', + to: 'green' + } as const + expect(paintOf(rule, scale, context({ value: 25 }))?.style).toEqual({ + 'background-color': 'color-mix(in oklab, white 50%, red)' + }) + expect(paintOf(rule, scale, context({ value: 75 }))?.style).toEqual({ + 'background-color': 'color-mix(in oklab, green 50%, white)' + }) + }) + + it('clamps a value outside the range instead of overshooting the mix', () => { + expect( + paintOf({ kind: 'colorScale', column: 'a' }, scale, context({ value: 400 }))?.style + ).toEqual({ + 'background-color': 'color-mix(in oklab, var(--color-primary) 100%, transparent)' + }) + }) + + it('paints nothing where there is no number', () => { + expect( + paintOf({ kind: 'colorScale', column: 'a' }, scale, context({ value: null })) + ).toBeUndefined() + }) + + it('treats a flat column as full rather than dividing by zero', () => { + const flat: RuleStats = { kind: 'scale', min: 7, max: 7 } + expect( + paintOf({ kind: 'colorScale', column: 'a' }, flat, context({ value: 7 }))?.style + ).toEqual({ + 'background-color': 'color-mix(in oklab, var(--color-primary) 100%, transparent)' + }) + }) +}) + +describe('data bar', () => { + it('draws the bar as a gradient, which costs no element and catches no click', () => { + const paint = paintOf( + { kind: 'dataBar', column: 'a', color: 'teal' }, + scale, + context({ value: 40 }) + ) + expect(paint?.style).toEqual({ + 'background-image': 'linear-gradient(to right, teal 0 40%, transparent 40%)', + 'background-repeat': 'no-repeat' + }) + }) + + it('takes the negative colour only for a negative value', () => { + const rule = { + kind: 'dataBar', + column: 'a', + color: 'teal', + negativeColor: 'crimson' + } as const + const negative: RuleStats = { kind: 'scale', min: -100, max: 100 } + expect( + paintOf(rule, negative, context({ value: -50 }))?.style?.['background-image'] + ).toContain('crimson') + expect( + paintOf(rule, negative, context({ value: 50 }))?.style?.['background-image'] + ).toContain('teal') + }) +}) + +describe('a column whose values are all the same', () => { + const flat: RuleStats = { kind: 'scale', min: 0, max: 0 } + + it('fills the bar rather than dividing by zero, the way a flat scale fills the colour', () => { + const paint = paintOf( + { kind: 'dataBar', column: 'a', color: 'teal' }, + flat, + context({ value: 0 }) + ) + expect(paint?.style?.['background-image']).toContain('teal 0 100%') + }) +}) + +describe('duplicates and topN', () => { + const keys: RuleStats = { kind: 'keys', keys: new Set(['a@x.com']) } + + it('paints a repeated value and leaves the rest alone', () => { + const rule = { kind: 'duplicates', column: 'email' } as const + expect(paintOf(rule, keys, context({ value: 'a@x.com' }))).toEqual({ class: 'x-highlight' }) + expect(paintOf(rule, keys, context({ value: 'b@x.com' }))).toBeUndefined() + }) + + it('leaves a blank out of it, blank not being a duplicate of anything', () => { + expect( + paintOf({ kind: 'duplicates', column: 'email' }, keys, context({ value: '' })) + ).toBeUndefined() + }) + + it('uses what the rule asks for instead of the fallback class', () => { + const rule = { kind: 'duplicates', column: 'email', class: 'text-error' } as const + expect(paintOf(rule, keys, context({ value: 'a@x.com' }))).toEqual({ + class: 'text-error', + style: undefined + }) + }) + + it('paints at and above the threshold, ties included', () => { + const threshold: RuleStats = { kind: 'threshold', value: 25 } + const rule = { kind: 'topN', column: 'a' } as const + expect(paintOf(rule, threshold, context({ value: 25 }))).toEqual({ class: 'x-highlight' }) + expect(paintOf(rule, threshold, context({ value: 24 }))).toBeUndefined() + }) + + it('paints at and below it when ranking from the bottom', () => { + const threshold: RuleStats = { kind: 'threshold', value: 10 } + const rule = { kind: 'topN', column: 'a', bottom: true } as const + expect(paintOf(rule, threshold, context({ value: 10 }))).toEqual({ class: 'x-highlight' }) + expect(paintOf(rule, threshold, context({ value: 11 }))).toBeUndefined() + }) +}) + +describe('expression', () => { + const stats = (): RuleStats => ({ kind: 'expression', cache: new Map() }) + const rule = { kind: 'expression', when: 'total > 100' } as const + + it('paints the rows where the condition holds', () => { + const node = parse('total > 100') + const paint = paintOf(rule, stats(), context({ node, read: () => 400 })) + expect(paint).toEqual({ class: 'x-highlight' }) + }) + + it('evaluates a row once however many of its cells ask', () => { + const shared = stats() + let reads = 0 + const read = () => { + reads++ + return 400 + } + const node = parse('total > 100') + paintOf(rule, shared, context({ node, read })) + paintOf(rule, shared, context({ node, read })) + expect(reads).toBe(1) + }) + + it('paints nothing when the expression would not parse', () => { + expect(paintOf(rule, stats(), context({ node: null }))).toBeUndefined() + }) + + it('reads an error as false rather than as a match', () => { + const node = parse('total / 0 > 1') + expect(paintOf(rule, stats(), context({ node, read: () => 1 }))).toBeUndefined() + }) +}) + +describe('mergePaint', () => { + it('lets the later rule win a property while keeping the rest', () => { + const merged = mergePaint( + { style: { color: 'red', 'font-weight': '700' } }, + { style: { color: 'blue' } } + ) + expect(merged.style).toEqual({ color: 'blue', 'font-weight': '700' }) + }) + + it('adds classes rather than replacing them', () => { + expect(mergePaint({ class: 'a' }, { class: 'b' }).class).toBe('a b') + }) +}) diff --git a/src/lib/features/conditional-formatting/paint.ts b/src/lib/features/conditional-formatting/paint.ts new file mode 100644 index 0000000..cb8c3c7 --- /dev/null +++ b/src/lib/features/conditional-formatting/paint.ts @@ -0,0 +1,148 @@ +import { evaluate, type FormulaValue, isFormulaError, type Node } from '../formula/index.js' +import { numericOrNull } from '../../core/utils/index.js' +import type { + ColorScaleRule, + DataBarRule, + DuplicatesRule, + FormatPaint, + FormatRule, + TopNRule +} from './conditional-formatting.types.js' +import type { RuleStats } from './stats.js' + +export interface Paint { + class?: string + style?: Record +} + +export interface PaintContext { + value: unknown + rowId: string + read: (columnId: string) => unknown + node: Node | null + highlight: string +} + +const DEFAULT_FROM = 'transparent' +const DEFAULT_TO = 'var(--color-primary)' +const DEFAULT_BAR = 'color-mix(in oklab, var(--color-primary) 35%, transparent)' + +function ratio(value: number, min: number, max: number): number { + if (max <= min) return 1 + if (value <= min) return 0 + if (value >= max) return 1 + return (value - min) / (max - min) +} + +const percent = (fraction: number): string => `${Math.round(fraction * 1000) / 10}%` + +const mix = (over: string, under: string, fraction: number): string => + `color-mix(in oklab, ${over} ${percent(fraction)}, ${under})` + +function scaleColor(rule: { from?: string; to?: string; via?: string }, at: number): string { + const from = rule.from ?? DEFAULT_FROM + const to = rule.to ?? DEFAULT_TO + if (!rule.via) return mix(to, from, at) + return at < 0.5 ? mix(rule.via, from, at * 2) : mix(to, rule.via, (at - 0.5) * 2) +} + +function highlightPaint(rule: FormatPaint, fallback: string): Paint { + if (!rule.class && !rule.style) return { class: fallback } + return { class: rule.class, style: rule.style } +} + +function truthy(value: FormulaValue): boolean { + if (isFormulaError(value) || value === null) return false + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + if (typeof value === 'string') return value !== '' && value.toLowerCase() !== 'false' + return true +} + +function matchesExpression(context: PaintContext, stats: RuleStats): boolean { + if (!context.node || stats?.kind !== 'expression') return false + const cached = stats.cache.get(context.rowId) + if (cached !== undefined) return cached + + const result = truthy(evaluate(context.node, { column: context.read })) + stats.cache.set(context.rowId, result) + return result +} + +function scalePaint( + rule: ColorScaleRule, + stats: RuleStats, + context: PaintContext +): Paint | undefined { + if (stats?.kind !== 'scale') return undefined + const value = numericOrNull(context.value) + if (value === null) return undefined + return { style: { 'background-color': scaleColor(rule, ratio(value, stats.min, stats.max)) } } +} + +function barPaint(rule: DataBarRule, stats: RuleStats, context: PaintContext): Paint | undefined { + if (stats?.kind !== 'scale') return undefined + const value = numericOrNull(context.value) + if (value === null) return undefined + + const width = percent(ratio(value, stats.min, stats.max)) + const color = (value < 0 ? rule.negativeColor : undefined) ?? rule.color ?? DEFAULT_BAR + return { + style: { + 'background-image': `linear-gradient(to right, ${color} 0 ${width}, transparent ${width})`, + 'background-repeat': 'no-repeat' + } + } +} + +function duplicatesPaint( + rule: DuplicatesRule, + stats: RuleStats, + context: PaintContext +): Paint | undefined { + if (stats?.kind !== 'keys') return undefined + const { value } = context + if (value === null || value === undefined || value === '') return undefined + return stats.keys.has(String(value)) ? highlightPaint(rule, context.highlight) : undefined +} + +function topNPaint(rule: TopNRule, stats: RuleStats, context: PaintContext): Paint | undefined { + if (stats?.kind !== 'threshold' || stats.value === null) return undefined + const value = numericOrNull(context.value) + if (value === null) return undefined + + const inside = rule.bottom === true ? value <= stats.value : value >= stats.value + return inside ? highlightPaint(rule, context.highlight) : undefined +} + +export function paintOf( + rule: FormatRule, + stats: RuleStats, + context: PaintContext +): Paint | undefined { + switch (rule.kind) { + case 'colorScale': + return scalePaint(rule, stats, context) + case 'dataBar': + return barPaint(rule, stats, context) + case 'duplicates': + return duplicatesPaint(rule, stats, context) + case 'topN': + return topNPaint(rule, stats, context) + case 'expression': + return matchesExpression(context, stats) + ? highlightPaint(rule, context.highlight) + : undefined + } +} + +export const isPainted = (paint: Paint | undefined): boolean => + Boolean(paint?.class || paint?.style) + +export function mergePaint(base: Paint | undefined, next: Paint): Paint { + const style = base?.style || next.style ? { ...base?.style, ...next.style } : undefined + return { + class: [base?.class, next.class].filter(Boolean).join(' ') || undefined, + style + } +} diff --git a/src/lib/features/conditional-formatting/rules.test.ts b/src/lib/features/conditional-formatting/rules.test.ts new file mode 100644 index 0000000..400fe19 --- /dev/null +++ b/src/lib/features/conditional-formatting/rules.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FormatRule } from './conditional-formatting.types.js' +import { compileRules, MAX_RULES, normalizeRules, ruleId, sanitizeRules } from './rules.js' + +describe('normalizeRules', () => { + it('names a rule after its kind and position when it has no id', () => { + const rules = normalizeRules([ + { kind: 'dataBar', column: 'a' }, + { kind: 'topN', column: 'b' } + ]) + expect(rules.map((rule) => rule.id)).toEqual(['dataBar:0', 'topN:1']) + }) + + it('keeps an id the app chose', () => { + expect(normalizeRules([{ kind: 'dataBar', column: 'a', id: 'sales' }])[0]!.id).toBe('sales') + }) + + it('never reuses an id a rule already holds', () => { + const first = normalizeRules([ + { kind: 'dataBar', column: 'a' }, + { kind: 'dataBar', column: 'b' } + ]) + const afterRemove = first.filter((rule) => rule.id !== 'dataBar:0') + const afterAdd = normalizeRules([...afterRemove, { kind: 'dataBar', column: 'c' }]) + + expect(afterAdd.map((rule) => rule.id)).toEqual(['dataBar:1', 'dataBar:2']) + }) + + it('renames the second of two rules that arrive with the same id', () => { + const rules = normalizeRules([ + { kind: 'topN', column: 'a', id: 'same' }, + { kind: 'topN', column: 'b', id: 'same' } + ]) + expect(new Set(rules.map((rule) => rule.id)).size).toBe(2) + }) + + it('caps the list, because the hook runs per rendered cell', () => { + const many: FormatRule[] = Array.from({ length: MAX_RULES + 10 }, () => ({ + kind: 'dataBar', + column: 'a' + })) + expect(normalizeRules(many)).toHaveLength(MAX_RULES) + }) +}) + +describe('compileRules', () => { + it('parses only the expression rules', () => { + const compiled = compileRules( + normalizeRules([ + { kind: 'expression', when: 'a > 1' }, + { kind: 'dataBar', column: 'a' } + ]), + () => {} + ) + expect([...compiled.keys()]).toEqual(['expression:0']) + expect(compiled.get('expression:0')?.node).not.toBeNull() + }) + + it('reports a broken expression instead of throwing, and paints nothing', () => { + const report = vi.fn() + const compiled = compileRules(normalizeRules([{ kind: 'expression', when: 'a >' }]), report) + expect(compiled.get('expression:0')?.node).toBeNull() + expect(compiled.get('expression:0')?.error?.message).toBeTruthy() + expect(report).toHaveBeenCalledOnce() + expect(report.mock.calls[0]![0]).toBe('expression:0') + }) +}) + +describe('sanitizeRules', () => { + it('rejects what is not a list', () => { + expect(sanitizeRules({ kind: 'dataBar' })).toBeNull() + expect(sanitizeRules('dataBar')).toBeNull() + }) + + it('drops an unknown kind and a rule with no column', () => { + expect( + sanitizeRules([ + { kind: 'javascript', column: 'a' }, + { kind: 'dataBar' }, + { kind: 'dataBar', column: 'a' } + ]) + ).toHaveLength(1) + }) + + it('keeps only the fields it knows, at the types it knows', () => { + const rules = sanitizeRules([ + { + kind: 'topN', + column: 'a', + n: '10', + bottom: 'yes', + class: 'text-error', + style: { 'background-color': 'red', width: 4 }, + onclick: 'alert(1)' + } + ])! + expect(rules[0]).toEqual({ + kind: 'topN', + column: 'a', + id: 'topN:0', + n: undefined, + bottom: undefined, + class: 'text-error', + style: { 'background-color': 'red' } + }) + }) + + it('drops an expression rule with no expression', () => { + expect(sanitizeRules([{ kind: 'expression', column: 'a' }])).toHaveLength(0) + }) + + it('caps a hydrated list the way the option list is capped', () => { + const many = Array.from({ length: MAX_RULES + 5 }, () => ({ kind: 'dataBar', column: 'a' })) + expect(sanitizeRules(many)).toHaveLength(MAX_RULES) + }) +}) + +describe('ruleId', () => { + it('falls back to the position for a rule that never got one', () => { + expect(ruleId({ kind: 'duplicates', column: 'email' }, 3)).toBe('duplicates:3') + }) +}) diff --git a/src/lib/features/conditional-formatting/rules.ts b/src/lib/features/conditional-formatting/rules.ts new file mode 100644 index 0000000..8680620 --- /dev/null +++ b/src/lib/features/conditional-formatting/rules.ts @@ -0,0 +1,170 @@ +import { FormulaSyntaxError, type Node, parse } from '../formula/index.js' +import type { FormatRule } from './conditional-formatting.types.js' + +export const MAX_RULES = 64 + +const KINDS = new Set(['colorScale', 'dataBar', 'expression', 'duplicates', 'topN']) + +export interface RuleParseError { + message: string + at: number +} + +export interface CompiledRule { + node: Node | null + error: RuleParseError | null +} + +export type CompiledRules = ReadonlyMap + +export const NO_RULES: readonly FormatRule[] = [] + +export const NO_COMPILED: CompiledRules = new Map() + +export function ruleId(rule: FormatRule, index: number): string { + return rule.id ?? `${rule.kind}:${index}` +} + +export function appliesTo(rule: FormatRule, columnId: string): boolean { + return rule.column === undefined || rule.column === columnId +} + +function freeId(kind: string, taken: Set): string { + let suffix = taken.size + let id = `${kind}:${suffix}` + while (taken.has(id)) id = `${kind}:${++suffix}` + return id +} + +export function normalizeRules(rules: readonly FormatRule[]): FormatRule[] { + const kept = rules.slice(0, MAX_RULES) + const taken = new Set() + const out: FormatRule[] = [] + + for (const rule of kept) { + const id = rule.id && !taken.has(rule.id) ? rule.id : freeId(rule.kind, taken) + taken.add(id) + out.push(rule.id === id ? rule : { ...rule, id }) + } + return out +} + +export function copyRules(rules: readonly FormatRule[]): FormatRule[] { + return rules.map((rule) => + 'style' in rule && rule.style ? { ...rule, style: { ...rule.style } } : { ...rule } + ) +} + +export type ReportParseError = (ruleId: string, message: string, at: number) => void + +export function compileRules( + rules: readonly FormatRule[], + report: ReportParseError +): CompiledRules { + const out = new Map() + for (const [index, rule] of rules.entries()) { + if (rule.kind !== 'expression') continue + const id = ruleId(rule, index) + try { + out.set(id, { node: parse(rule.when), error: null }) + } catch (error) { + const failure = { + message: error instanceof Error ? error.message : String(error), + at: error instanceof FormulaSyntaxError ? error.at : 0 + } + out.set(id, { node: null, error: failure }) + report(id, failure.message, failure.at) + } + } + return out +} + +function styleOf(value: unknown): Record | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined + const out: Record = {} + for (const [property, declaration] of Object.entries(value as Record)) { + if (typeof declaration === 'string') out[property] = declaration + } + return Object.keys(out).length > 0 ? out : undefined +} + +const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined) + +const finite = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined + +const flag = (value: unknown): boolean | undefined => + typeof value === 'boolean' ? value : undefined + +function paintFields(source: Record): { + class?: string + style?: Record +} { + return { class: text(source.class), style: styleOf(source.style) } +} + +function ruleOf(source: Record): FormatRule | null { + const kind = source.kind + if (typeof kind !== 'string' || !KINDS.has(kind)) return null + const column = text(source.column) + if (kind !== 'expression' && !column) return null + + const base = { id: text(source.id), column } + + switch (kind) { + case 'colorScale': + return { + ...base, + column: column!, + kind, + from: text(source.from), + to: text(source.to), + via: text(source.via), + min: finite(source.min), + max: finite(source.max) + } + case 'dataBar': + return { + ...base, + column: column!, + kind, + color: text(source.color), + negativeColor: text(source.negativeColor), + min: finite(source.min), + max: finite(source.max) + } + case 'expression': { + const when = text(source.when) + return when ? { ...base, kind, when, ...paintFields(source) } : null + } + case 'duplicates': + return { + ...base, + column: column!, + kind, + unique: flag(source.unique), + ...paintFields(source) + } + default: + return { + ...base, + column: column!, + kind: 'topN', + n: finite(source.n), + bottom: flag(source.bottom), + ...paintFields(source) + } + } +} + +export function sanitizeRules(slice: unknown): FormatRule[] | null { + if (!Array.isArray(slice)) return null + const out: FormatRule[] = [] + for (const entry of slice) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue + const rule = ruleOf(entry as Record) + if (rule) out.push(rule) + if (out.length === MAX_RULES) break + } + return normalizeRules(out) +} diff --git a/src/lib/features/conditional-formatting/stats.test.ts b/src/lib/features/conditional-formatting/stats.test.ts new file mode 100644 index 0000000..cdbdd0c --- /dev/null +++ b/src/lib/features/conditional-formatting/stats.test.ts @@ -0,0 +1,171 @@ +import { type ColumnState } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { markSyntheticRow } from '../../core/grid/index.js' +import { + columnMap, + computeStats, + dataRowsOf, + hasNumbers, + sampleDataRows, + type KeyStats, + type ScaleStats, + type ThresholdStats +} from './stats.js' + +interface Row { + id: number + value: number + email: string +} + +const rows: Row[] = [ + { id: 1, value: 10, email: 'a@x.com' }, + { id: 2, value: 40, email: 'b@x.com' }, + { id: 3, value: 25, email: 'a@x.com' }, + { id: 4, value: 5, email: '' } +] + +/** The statistics walk nodes now, so that a masked column reads as masked. */ +const nodesOf = (list: readonly T[]) => + list.map((row, index) => ({ id: String(index), row, index })) + +const nodes = nodesOf(rows) + +const columns = columnMap([ + { id: 'value', def: { id: 'value' } }, + { id: 'email', def: { id: 'email' } } +] as unknown as ColumnState[]) + +describe('computeStats', () => { + it('spans a colour scale over the values the grid holds', () => { + const [stats] = computeStats([{ kind: 'colorScale', column: 'value' }], nodes, columns) + expect(stats as ScaleStats).toMatchObject({ min: 5, max: 40 }) + }) + + it('lets the rule override either end', () => { + const [stats] = computeStats( + [{ kind: 'colorScale', column: 'value', min: 0, max: 100 }], + nodes, + columns + ) + expect(stats as ScaleStats).toMatchObject({ min: 0, max: 100 }) + }) + + it('bases a data bar at zero when every value is positive', () => { + const [stats] = computeStats([{ kind: 'dataBar', column: 'value' }], nodes, columns) + expect(stats as ScaleStats).toMatchObject({ min: 0, max: 40 }) + }) + + it('keeps a negative floor for a data bar that has one', () => { + const [stats] = computeStats( + [{ kind: 'dataBar', column: 'value' }], + nodesOf([...rows, { id: 5, value: -20, email: 'c@x.com' }]), + columns + ) + expect(stats as ScaleStats).toMatchObject({ min: -20, max: 40 }) + }) + + it('collects the repeated values, ignoring blanks', () => { + const [stats] = computeStats([{ kind: 'duplicates', column: 'email' }], nodes, columns) + expect([...(stats as KeyStats).keys]).toEqual(['a@x.com']) + }) + + it('collects the values seen once when asked for the unique ones', () => { + const [stats] = computeStats( + [{ kind: 'duplicates', column: 'email', unique: true }], + nodes, + columns + ) + expect([...(stats as KeyStats).keys]).toEqual(['b@x.com']) + }) + + it('reads the threshold off the nth value from the top', () => { + const [stats] = computeStats([{ kind: 'topN', column: 'value', n: 2 }], nodes, columns) + expect((stats as ThresholdStats).value).toBe(25) + }) + + it('reads it from the bottom when asked', () => { + const [stats] = computeStats( + [{ kind: 'topN', column: 'value', n: 2, bottom: true }], + nodes, + columns + ) + expect((stats as ThresholdStats).value).toBe(10) + }) + + it('asks for more rows than exist without inventing a threshold', () => { + const [stats] = computeStats([{ kind: 'topN', column: 'value', n: 99 }], nodes, columns) + expect((stats as ThresholdStats).value).toBe(5) + }) + + it('survives a column the rule names but the grid does not have', () => { + const [stats] = computeStats([{ kind: 'colorScale', column: 'ghost' }], nodes, columns) + expect(stats as ScaleStats).toMatchObject({ min: 0, max: 0 }) + }) + + it('gives an expression rule a cache rather than a pass over the rows', () => { + const [stats] = computeStats([{ kind: 'expression', when: 'value > 1' }], nodes, columns) + expect(stats).toEqual({ kind: 'expression', cache: new Map() }) + }) +}) + +describe('hasNumbers', () => { + it('says yes for a column holding numbers', () => { + expect(hasNumbers(nodes, columns.get('value'))).toBe(true) + }) + + it('says no for a column of text, which a scale or a bar cannot read', () => { + expect(hasNumbers(nodes, columns.get('email'))).toBe(false) + }) + + it('says no for a column the grid does not have', () => { + expect(hasNumbers(nodes, undefined)).toBe(false) + }) + + it('looks past a leading blank rather than giving up on the first row', () => { + const withGap = [{ id: 0, value: null as unknown as number, email: '' }, ...rows] + expect(hasNumbers(nodesOf(withGap), columns.get('value'))).toBe(true) + }) + + it('reads only the sample it was given', () => { + expect(hasNumbers(nodes, columns.get('value'), undefined, 0)).toBe(false) + }) +}) + +describe('sampleDataRows', () => { + const manyNodes = (count: number) => + Array.from({ length: count }, (_, i) => ({ + id: String(i), + row: rows[i % rows.length]!, + index: i + })) + + it('stops as soon as it has the sample, whatever the grid holds', () => { + let read = 0 + const many = new Proxy(manyNodes(100_000), { + get(target, key) { + if (typeof key === 'string' && /^\d+$/.test(key)) read++ + return Reflect.get(target, key) + } + }) + + expect(sampleDataRows(many, 3)).toHaveLength(3) + expect(read).toBeLessThan(10) + }) + + it('skips the rows a feature synthesized, so a group total is not the sample', () => { + const nodes = [ + { id: 'group:a', row: markSyntheticRow(rows[0]!), index: 0 }, + { id: '2', row: rows[1]!, index: 1 } + ] + expect(sampleDataRows(nodes, 5).map((node) => node.row)).toEqual([rows[1]]) + }) +}) + +describe('dataRowsOf', () => { + it('keeps the rows the predicate calls data', () => { + const listed = rows.map((row) => ({ id: String(row.id), row, index: row.id })) + const kept = dataRowsOf(listed, (node) => node.id !== '2') + expect(kept.map((node) => node.row.id)).toEqual([1, 3, 4]) + }) +}) diff --git a/src/lib/features/conditional-formatting/stats.ts b/src/lib/features/conditional-formatting/stats.ts new file mode 100644 index 0000000..e85201d --- /dev/null +++ b/src/lib/features/conditional-formatting/stats.ts @@ -0,0 +1,195 @@ +import { isDataRow, rawRead, type CellRead } from '../../core/grid/index.js' +import { type ColumnState, type RowNode } from '../../core/types/index.js' +import { numericOrNull } from '../../core/utils/index.js' +import type { FormatRule } from './conditional-formatting.types.js' + +export interface ScaleStats { + kind: 'scale' + min: number + max: number +} + +export interface KeyStats { + kind: 'keys' + keys: Set +} + +export interface ThresholdStats { + kind: 'threshold' + value: number | null +} + +export interface ExpressionStats { + kind: 'expression' + cache: Map +} + +export type RuleStats = ScaleStats | KeyStats | ThresholdStats | ExpressionStats | null + +export function columnMap( + columns: readonly ColumnState[] +): ReadonlyMap> { + return new Map(columns.map((column) => [column.id, column])) +} + +/** + * Every statistic here reads through `read` rather than off the row, so a + * colour scale or a data bar over a column the grid is masking is drawn from + * what the user is being shown. Read raw, a bar would hand back the exact + * number the mask exists to hide, one pixel width at a time. + */ +function eachNumber( + nodes: readonly RowNode[], + column: ColumnState | undefined, + read: CellRead, + visit: (value: number) => void +): void { + if (!column) return + const { def } = column + for (const node of nodes) { + const value = numericOrNull(read(node, def)) + if (value !== null) visit(value) + } +} + +function scaleOf( + nodes: readonly RowNode[], + column: ColumnState | undefined, + read: CellRead, + rule: { min?: number; max?: number } +): ScaleStats { + const { min, max } = rule + if (min !== undefined && max !== undefined) return { kind: 'scale', min, max } + + let low = Infinity + let high = -Infinity + eachNumber(nodes, column, read, (value) => { + if (value < low) low = value + if (value > high) high = value + }) + + return { + kind: 'scale', + min: min ?? (Number.isFinite(low) ? low : 0), + max: max ?? (Number.isFinite(high) ? high : 0) + } +} + +function barScaleOf( + nodes: readonly RowNode[], + column: ColumnState | undefined, + read: CellRead, + rule: { min?: number; max?: number } +): ScaleStats { + const scale = scaleOf(nodes, column, read, rule) + return rule.min === undefined && scale.min > 0 ? { ...scale, min: 0 } : scale +} + +function keysOf( + nodes: readonly RowNode[], + column: ColumnState | undefined, + read: CellRead, + unique: boolean +): KeyStats { + const seen = new Map() + if (column) { + for (const node of nodes) { + const value = read(node, column.def) + if (value === null || value === undefined || value === '') continue + const key = String(value) + seen.set(key, (seen.get(key) ?? 0) + 1) + } + } + const keys = new Set() + for (const [key, count] of seen) { + if (unique ? count === 1 : count > 1) keys.add(key) + } + return { kind: 'keys', keys } +} + +function thresholdOf( + nodes: readonly RowNode[], + column: ColumnState | undefined, + read: CellRead, + rule: { n?: number; bottom?: boolean } +): ThresholdStats { + const n = rule.n ?? 10 + const bottom = rule.bottom === true + if (n <= 0) return { kind: 'threshold', value: null } + + const best: number[] = [] + const outranks = (candidate: number, held: number) => + bottom ? candidate < held : candidate > held + + eachNumber(nodes, column, read, (value) => { + if (best.length === n && !outranks(value, best[n - 1]!)) return + + let at = best.length + while (at > 0 && outranks(value, best[at - 1]!)) at-- + best.splice(at, 0, value) + if (best.length > n) best.pop() + }) + + return { kind: 'threshold', value: best.at(-1) ?? null } +} + +export function computeStats( + rules: readonly FormatRule[], + nodes: readonly RowNode[], + columns: ReadonlyMap>, + read: CellRead = rawRead +): RuleStats[] { + return rules.map((rule): RuleStats => { + switch (rule.kind) { + case 'colorScale': + return scaleOf(nodes, columns.get(rule.column), read, rule) + case 'dataBar': + return barScaleOf(nodes, columns.get(rule.column), read, rule) + case 'duplicates': + return keysOf(nodes, columns.get(rule.column), read, rule.unique === true) + case 'topN': + return thresholdOf(nodes, columns.get(rule.column), read, rule) + case 'expression': + return { kind: 'expression', cache: new Map() } + } + }) +} + +export const NUMERIC_KINDS: readonly FormatRule['kind'][] = ['colorScale', 'dataBar', 'topN'] + +export function hasNumbers( + nodes: readonly RowNode[], + column: ColumnState | undefined, + read: CellRead = rawRead, + sample = 50 +): boolean { + if (!column) return false + + const limit = Math.min(sample, nodes.length) + for (let index = 0; index < limit; index++) { + if (numericOrNull(read(nodes[index]!, column.def)) !== null) return true + } + return false +} + +export function sampleDataRows( + nodes: readonly RowNode[], + limit: number +): RowNode[] { + const sampled: RowNode[] = [] + for (const node of nodes) { + if (!isDataRow(node)) continue + sampled.push(node) + if (sampled.length === limit) break + } + return sampled +} + +export function dataRowsOf( + nodes: readonly RowNode[], + isData: (node: RowNode) => boolean +): RowNode[] { + const rows: RowNode[] = [] + for (const node of nodes) if (isData(node)) rows.push(node) + return rows +} diff --git a/src/lib/features/data-import/coerce.test.ts b/src/lib/features/data-import/coerce.test.ts new file mode 100644 index 0000000..715f9ba --- /dev/null +++ b/src/lib/features/data-import/coerce.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { coerce, parseBoolean, parseDate, parseNumber } from './coerce.js' + +describe('reading a number a spreadsheet wrote', () => { + it.each([ + ['1234', 1234], + ['1,234', 1234], + ['1.234,50', 1234.5], + ['1,234.50', 1234.5], + ['1 234,5', 1234.5], + ['-12.5', -12.5], + ['0,5', 0.5], + ['12%', 0.12], + ['$1,299.00', 1299], + ['1.234.567', 1234567] + ])('reads %s as %s', (text, expected) => { + expect(parseNumber(text)).toBe(expected) + }) + + it.each(['', ' ', 'abc', '--', '+', '.', 'Infinity', '1e999'])('refuses %s', (text) => { + expect(parseNumber(text)).toBeNull() + }) + + it('reads the parentheses a finance export writes a negative with', () => { + expect(parseNumber('(1,234.50)')).toBe(-1234.5) + expect(parseNumber('(12%)')).toBe(-0.12) + expect(parseNumber('(0)')).toBe(-0) + }) + + it('takes the mark an app names rather than guessing', () => { + expect(parseNumber('1.250', ',')).toBe(1250) + expect(parseNumber('1.250', '.')).toBe(1.25) + expect(parseNumber('1.250,50', ',')).toBe(1250.5) + }) +}) + +describe('reading a date a spreadsheet wrote', () => { + it('reads an ISO date as the day it spells', () => { + const parsed = parseDate('2026-03-02')! + expect([parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()]).toEqual([ + 2026, 3, 2 + ]) + }) + + it('keeps the time an ISO datetime carries', () => { + const parsed = parseDate('2026-03-02T10:30')! + expect([parsed.getHours(), parsed.getMinutes()]).toEqual([10, 30]) + }) + + it('reads a day-first date, which is what most of the world writes', () => { + const parsed = parseDate('25/12/2026')! + expect([parsed.getMonth() + 1, parsed.getDate()]).toEqual([12, 25]) + }) + + it('reads a month-first date when the first number cannot be a day', () => { + const parsed = parseDate('12/25/2026')! + expect([parsed.getMonth() + 1, parsed.getDate()]).toEqual([12, 25]) + }) + + it('refuses a date that does not exist', () => { + expect(parseDate('31/02/2026')).toBeNull() + }) + + it('refuses an ISO date that does not exist rather than rolling it over', () => { + expect(parseDate('2026-13-45')).toBeNull() + expect(parseDate('2026-00-10')).toBeNull() + expect(parseDate('2026-02-30')).toBeNull() + expect(parseDate('2026-02-28')).not.toBeNull() + }) + + it('honours a timezone the text carries rather than reading it as local', () => { + const zoned = parseDate('2026-03-02T10:00:00Z')! + expect(zoned.toISOString()).toBe('2026-03-02T10:00:00.000Z') + expect(parseDate('2026-03-02T10:00:00+02:00')!.toISOString()).toBe( + '2026-03-02T08:00:00.000Z' + ) + }) + + it('reads a two-digit year the same way round as a four-digit one', () => { + const short = parseDate('03/04/26')! + const long = parseDate('03/04/2026')! + expect([short.getFullYear(), short.getMonth(), short.getDate()]).toEqual([2026, 3, 3]) + expect([short.getFullYear(), short.getMonth(), short.getDate()]).toEqual([ + long.getFullYear(), + long.getMonth(), + long.getDate() + ]) + }) + + it('puts a two-digit year in the century a spreadsheet means', () => { + expect(parseDate('01/01/69')!.getFullYear()).toBe(2069) + expect(parseDate('01/01/70')!.getFullYear()).toBe(1970) + }) + + it('refuses a year that cannot be written as one', () => { + expect(parseDate('0000-01-01')).toBeNull() + }) + + it.each(['', 'soon', '99'])('refuses %s', (text) => { + expect(parseDate(text)).toBeNull() + }) +}) + +describe('reading a yes or a no', () => { + it.each(['true', 'YES', 'y', '1', 'x', 'Có'])('reads %s as true', (text) => { + expect(parseBoolean(text)).toBe(true) + }) + + it.each(['false', 'no', 'n', '0', 'Không'])('reads %s as false', (text) => { + expect(parseBoolean(text)).toBe(false) + }) + + it('refuses a word it does not know rather than guessing', () => { + expect(parseBoolean('maybe')).toBeNull() + }) +}) + +describe('coercing a cell into what a column holds', () => { + it('leaves a blank blank without calling it a problem', () => { + expect(coerce('', 'number')).toEqual({ value: null, problem: null }) + expect(coerce(null, 'date')).toEqual({ value: null, problem: null }) + }) + + it('names the kind of value it could not read', () => { + expect(coerce('abc', 'number').problem).toBe('number') + expect(coerce('soon', 'date').problem).toBe('date') + expect(coerce('maybe', 'boolean').problem).toBe('boolean') + }) + + it('takes a number that arrived as a number, from xlsx', () => { + expect(coerce(1234.5, 'number')).toEqual({ value: 1234.5, problem: null }) + }) + + it('takes a date that arrived as a Date, from xlsx', () => { + const date = new Date(2026, 2, 2) + expect(coerce(date, 'date')).toEqual({ value: date, problem: null }) + }) + + it('writes a date into a text column as the day, not as an instant', () => { + expect(coerce(new Date(Date.UTC(2026, 2, 2)), 'text').value).toBe('2026-03-02') + }) + + it('leaves a formula-looking cell as the text it is', () => { + expect(coerce('=SUM(A1:A9)', 'text')).toEqual({ value: '=SUM(A1:A9)', problem: null }) + }) +}) diff --git a/src/lib/features/data-import/coerce.ts b/src/lib/features/data-import/coerce.ts new file mode 100644 index 0000000..79cb9be --- /dev/null +++ b/src/lib/features/data-import/coerce.ts @@ -0,0 +1,200 @@ +import { type ColumnDef } from '../../core/types/index.js' +import type { SourceValue } from './mapping.js' + +export type CoerceKind = 'text' | 'number' | 'date' | 'boolean' + +export interface Coerced { + value: unknown + problem: 'number' | 'date' | 'boolean' | null +} + +const BY_COLUMN_TYPE: Record = { + number: 'number', + currency: 'number', + percent: 'number', + progress: 'number', + rating: 'number', + date: 'date', + datetime: 'date', + boolean: 'boolean' +} + +const TRUE_WORDS = new Set(['true', 'yes', 'y', '1', 'x', 'có', 'co', 'đúng', 'dung']) +const FALSE_WORDS = new Set(['false', 'no', 'n', '0', '', 'không', 'khong', 'sai']) + +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/ +const ISO_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i +const DMY = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2}|\d{4})$/ + +export function kindOfColumn(column: ColumnDef): CoerceKind { + return BY_COLUMN_TYPE[column.type ?? 'text'] ?? 'text' +} + +export type DecimalMark = '.' | ',' + +function withMark(body: string, mark: DecimalMark): string { + const other = mark === '.' ? ',' : '.' + const stripped = body.split(other).join('') + return mark === ',' ? stripped.replace(',', '.') : stripped +} + +function separatorsOf(body: string): { normalized: string } { + const lastComma = body.lastIndexOf(',') + const lastDot = body.lastIndexOf('.') + + if (lastComma >= 0 && lastDot >= 0) { + return { + normalized: + lastComma > lastDot + ? body.replace(/\./g, '').replace(',', '.') + : body.replace(/,/g, '') + } + } + + if (lastComma >= 0) { + const decimals = body.length - lastComma - 1 + const grouped = (body.match(/,/g) ?? []).length > 1 + return { + normalized: grouped || decimals === 3 ? body.replace(/,/g, '') : body.replace(',', '.') + } + } + + if ((body.match(/\./g) ?? []).length > 1) return { normalized: body.replace(/\./g, '') } + return { normalized: body } +} + +export function parseNumber(text: string, mark?: DecimalMark): number | null { + const trimmed = text.trim().replace(/[\s\u00a0\u202f]/g, '') + if (trimmed === '') return null + + const bracketed = /^\((.*)\)$/.exec(trimmed) + const unwrapped = bracketed ? (bracketed[1] ?? '') : trimmed + + const percent = unwrapped.endsWith('%') + const body = (percent ? unwrapped.slice(0, -1) : unwrapped).replace(/[^0-9,.\-+eE]/g, '') + if (body === '') return null + + const parsed = Number(mark ? withMark(body, mark) : separatorsOf(body).normalized) + if (!Number.isFinite(parsed)) return null + + const scaled = percent ? parsed / 100 : parsed + return bracketed ? -Math.abs(scaled) : scaled +} + +function exists(made: Date, year: number, month: number, day: number): boolean { + return ( + !Number.isNaN(made.getTime()) && + made.getFullYear() === year && + made.getMonth() === month - 1 && + made.getDate() === day + ) +} + +function fromIso(trimmed: string): Date | null { + const iso = ISO_DATE.exec(trimmed) + if (!iso) return null + + if (ISO_ZONE.test(trimmed)) { + const zoned = new Date(trimmed) + return Number.isNaN(zoned.getTime()) ? null : zoned + } + + const [year, month, day] = [Number(iso[1]), Number(iso[2]), Number(iso[3])] + const made = new Date( + year, + month - 1, + day, + Number(iso[4] ?? 0), + Number(iso[5] ?? 0), + Number(iso[6] ?? 0) + ) + return exists(made, year, month, day) ? made : null +} + +const CENTURY_BREAK = 70 + +function centuryOf(twoDigits: number): number { + return twoDigits < CENTURY_BREAK ? 2000 + twoDigits : 1900 + twoDigits +} + +function fromDayMonthYear(trimmed: string): Date | null { + const dmy = DMY.exec(trimmed) + if (!dmy) return null + + const first = Number(dmy[1]) + const second = Number(dmy[2]) + const day = first > 12 ? first : second > 12 ? second : first + const month = first > 12 ? second : second > 12 ? first : second + + const written = dmy[3] ?? '' + const year = written.length === 2 ? centuryOf(Number(written)) : Number(written) + + const made = new Date(year, month - 1, day) + return exists(made, year, month, day) ? made : null +} + +export function parseDate(text: string): Date | null { + const trimmed = text.trim() + if (trimmed === '') return null + + if (ISO_DATE.test(trimmed)) return fromIso(trimmed) + if (DMY.test(trimmed)) return fromDayMonthYear(trimmed) + if (/^[+-]?\d+$/.test(trimmed)) return null + + const parsed = new Date(trimmed) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +export function parseBoolean(text: string): boolean | null { + const word = text.trim().toLowerCase() + if (TRUE_WORDS.has(word)) return true + if (FALSE_WORDS.has(word)) return false + return null +} + +function coerceNumber(raw: SourceValue, mark?: DecimalMark): Coerced { + if (typeof raw === 'number') return { value: raw, problem: null } + if (typeof raw === 'boolean') return { value: raw ? 1 : 0, problem: null } + if (raw instanceof Date) return { value: raw.getTime(), problem: null } + + const parsed = parseNumber(String(raw), mark) + return parsed === null ? { value: null, problem: 'number' } : { value: parsed, problem: null } +} + +function coerceDate(raw: SourceValue): Coerced { + if (raw instanceof Date) return { value: raw, problem: null } + if (typeof raw === 'number') return { value: new Date(raw), problem: null } + if (typeof raw === 'boolean') return { value: null, problem: 'date' } + + const parsed = parseDate(String(raw)) + return parsed === null ? { value: null, problem: 'date' } : { value: parsed, problem: null } +} + +function coerceBoolean(raw: SourceValue): Coerced { + if (typeof raw === 'boolean') return { value: raw, problem: null } + if (typeof raw === 'number') return { value: raw !== 0, problem: null } + if (raw instanceof Date) return { value: null, problem: 'boolean' } + + const parsed = parseBoolean(String(raw)) + return parsed === null ? { value: null, problem: 'boolean' } : { value: parsed, problem: null } +} + +function coerceText(raw: SourceValue): Coerced { + if (raw instanceof Date) return { value: raw.toISOString().slice(0, 10), problem: null } + return { value: String(raw), problem: null } +} + +export function coerce(raw: SourceValue, kind: CoerceKind, mark?: DecimalMark): Coerced { + if (raw === null || raw === undefined || raw === '') return { value: null, problem: null } + + switch (kind) { + case 'number': + return coerceNumber(raw, mark) + case 'date': + return coerceDate(raw) + case 'boolean': + return coerceBoolean(raw) + default: + return coerceText(raw) + } +} diff --git a/src/lib/features/data-import/combine.test.ts b/src/lib/features/data-import/combine.test.ts new file mode 100644 index 0000000..7540124 --- /dev/null +++ b/src/lib/features/data-import/combine.test.ts @@ -0,0 +1,260 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it, vi } from 'vitest' +import { advancedFilter, getAdvancedFilter } from '../advanced-filter/index.js' +import { conditionalFormatting } from '../conditional-formatting/index.js' +import { formula } from '../formula/index.js' +import { getGrouping, grouping } from '../grouping/index.js' +import { serverRowModel } from '../server-row-model/index.js' +import { dataImport, getDataImport } from './data-import.svelte.js' + +interface Line { + id: number + name: string + dept: string + unit: number + qty: number + total?: number +} + +const existing: Line[] = [ + { id: 1, name: 'Chi', dept: 'Core', unit: 100, qty: 2 }, + { id: 2, name: 'An', dept: 'Data', unit: 50, qty: 1 } +] + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', filter: 'text' }, + { id: 'dept', header: 'Dept', filter: 'text' }, + { id: 'unit', header: 'Unit', type: 'currency', filter: 'number' }, + { id: 'qty', header: 'Qty', type: 'number', filter: 'number' }, + { id: 'total', header: 'Total', type: 'currency', filter: 'number' } +] + +const FILE = 'Name,Dept,Unit,Qty\nBình,Core,200,3\nDũng,Growth,abc,1\n' + +function makeGrid(extra: ReturnType>[] = []): GridState { + return createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + ...extra, + dataImport({ + onCommit: () => {}, + newRow: (index) => ({ id: 100 + index }) + }) + ] + }) +} + +async function staged(grid: GridState) { + const importing = getDataImport(grid)! + await importing.takeText(FILE) + await importing.review() + return importing +} + +describe('staged rows are ordinary rows', () => { + it('shows them in the grid next to what was already there', async () => { + const grid = makeGrid() + await staged(grid) + + expect(grid.preWindowNodes).toHaveLength(existing.length + 2) + expect(grid.preWindowNodes.at(-1)!.row.name).toBe('Dũng') + }) + + it('leaves the grid alone once the import is cancelled', async () => { + const grid = makeGrid() + const importing = await staged(grid) + importing.cancel() + + expect(grid.preWindowNodes).toHaveLength(existing.length) + }) + + it('paints the cell it could not read, and only that one', async () => { + const grid = makeGrid() + const importing = await staged(grid) + + const bad = grid.preWindowNodes.at(-1)! + expect(importing.decoration(bad, 'unit')?.class).toContain('error') + expect(importing.decoration(bad, 'name')?.class).not.toContain('error') + expect(importing.decoration(grid.preWindowNodes[0]!, 'unit')).toBeUndefined() + }) +}) + +describe('crossing the import with the features already here', () => { + it('computes a formula column for the rows being reviewed', async () => { + const grid = makeGrid([formula({ columns: { total: 'unit * qty' } })]) + await staged(grid) + + const bình = grid.preWindowNodes.find((node) => node.row.name === 'Bình')! + expect(bình.row.total).toBe(600) + }) + + it('lets a filter narrow the review', async () => { + const grid = makeGrid() + await staged(grid) + + grid.api.setColumnFilter!('dept', { kind: 'text', op: 'equals', value: 'Core' }) + + const names = grid.preWindowNodes.map((node) => node.row.name) + expect(names).toContain('Chi') + expect(names).toContain('Bình') + expect(names).not.toContain('Dũng') + }) + + it('lets an advanced filter tree narrow the review too', async () => { + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + filtering(), + advancedFilter(), + dataImport({ onCommit: () => {}, newRow: (index) => ({ id: 100 + index }) }) + ] + }) + await staged(grid) + + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', columnId: 'dept', op: 'equals', value: 'Growth' }] + }) + + expect(grid.preWindowNodes.map((node) => node.row.name)).toEqual(['Dũng']) + }) + + it('groups the staged rows with the rest', async () => { + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + grouping({ by: ['dept'] }), + dataImport({ onCommit: () => {}, newRow: (index) => ({ id: 100 + index }) }) + ] + }) + await staged(grid) + + const groups = grid.preWindowNodes.filter((node) => node.meta?.expandable) + expect(groups.length).toBeGreaterThan(0) + expect(getGrouping(grid)!.by).toEqual(['dept']) + + const leaves = grid.preWindowNodes.filter((node) => !node.meta?.expandable) + expect(leaves.map((node) => node.row.name)).toContain('Bình') + }) + + it('does not fight conditional formatting over the same cell', async () => { + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + conditionalFormatting({ rules: [{ kind: 'expression', when: 'qty > 2' }] }), + dataImport({ onCommit: () => {}, newRow: (index) => ({ id: 100 + index }) }) + ] + }) + const importing = await staged(grid) + + const bình = grid.preWindowNodes.find((node) => node.row.name === 'Bình')! + expect(importing.decoration(bình, 'qty')?.class).toBeTruthy() + }) + + it('refuses to stage into a grid whose rows a server owns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + virtualization({ rowHeight: 40 }), + serverRowModel( + { + getRows: async () => ({ rows: [], rowCount: 0 }) + }, + { mode: 'infinite', blockSize: 10, placeholder: (i) => ({ id: -i }) as Line } + ), + dataImport({ onCommit: () => {} }) + ] + }) + + const importing = getDataImport(grid)! + await importing.takeText(FILE) + + expect(importing.error).toBeTruthy() + expect(importing.step).toBe('idle') + warn.mockRestore() + }) +}) + +describe('committing', () => { + it('hands over only the rows that are ready when asked', async () => { + const handed: Line[][] = [] + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + dataImport({ + onCommit: (rows) => { + handed.push(rows) + }, + newRow: (index) => ({ id: 100 + index }) + }) + ] + }) + const importing = await staged(grid) + await importing.commit({ validOnly: true }) + + expect(handed[0]!.map((row) => row.name)).toEqual(['Bình']) + expect(importing.step).toBe('idle') + }) + + it('hands over a row without the mark it wore while staged', async () => { + const handed: Line[][] = [] + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + dataImport({ + onCommit: (rows) => { + handed.push(rows) + }, + newRow: (index) => ({ id: 100 + index }) + }) + ] + }) + const importing = await staged(grid) + await importing.commit() + + expect(Object.keys(handed[0]![0]!)).not.toContain('__dgSynthetic') + }) + + it('keeps the staged rows when a commit throws', async () => { + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + dataImport({ + onCommit: () => { + throw new Error('the server said no') + } + }) + ] + }) + const importing = await staged(grid) + await importing.commit() + + expect(importing.error).toBe('the server said no') + expect(importing.staged).toHaveLength(2) + }) +}) diff --git a/src/lib/features/data-import/data-import.svelte.ts b/src/lib/features/data-import/data-import.svelte.ts new file mode 100644 index 0000000..bb58389 --- /dev/null +++ b/src/lib/features/data-import/data-import.svelte.ts @@ -0,0 +1,453 @@ +import { type GridState } from '../../core/grid/index.js' +import { + type CellDecoration, + type ColumnDef, + type GridFeature, + type RowNode +} from '../../core/types/index.js' +import { slotClass } from '../../core/theme/index.js' +import { looksLikeHeader, parseDelimited } from './delimited.js' +import { type DuplicateOptions, duplicateProblems, existingKeys, noKeys } from './duplicates.js' +import { emptyIds, idsOf } from './ids.js' +import { type ColumnMapping, guessMapping, rememberMapping, type SourceValue } from './mapping.js' +import { + problemRows, + problemsByMessage, + stageRows, + type ImportProblem, + type ProblemKind +} from './stage.js' +import { readXlsx } from './xlsx-read.js' +import type { DataImportOptions, ImportFormat, ImportStep } from './data-import.types.js' + +export const DATA_IMPORT = 'dataImport' + +export const DEFAULT_MAX_BYTES = 32 * 1024 * 1024 + +const BY_EXTENSION: Record = { + csv: 'csv', + tsv: 'tsv', + txt: 'csv', + xlsx: 'xlsx' +} + +export class DataImport { + step = $state('idle') + busy = $state(false) + error = $state(null) + fileName = $state(null) + + hasHeader = $state(true) + headers = $state.raw([]) + sheets = $state.raw([]) + sheet = $state(null) + + mappings = $state.raw([]) + staged = $state.raw([]) + problems = $state.raw([]) + + #grid: GridState + #options: DataImportOptions + #source: SourceValue[][] = [] + #bytes: Uint8Array | null = null + #staging = false + #stagedIds: ReadonlySet = emptyIds() + + constructor(grid: GridState, options: DataImportOptions) { + this.#grid = grid + this.#options = options + + grid.events.on('cellEdited', ({ rowId, columnId }) => { + if (this.#stagedIds.has(rowId)) void this.#recheck(rowId, columnId) + }) + } + + #indexOf(rowId: string): number { + return this.staged.findIndex((row) => this.#grid.getRowId(row) === rowId) + } + + #currentRow(rowId: string): TRow | undefined { + return this.#grid.data.find((row) => this.#grid.getRowId(row) === rowId) + } + + async #recheck(rowId: string, columnId: string): Promise { + const rowIndex = this.#indexOf(rowId) + const row = this.#currentRow(rowId) + const column = this.#columns().find((entry) => entry.id === columnId) + if (rowIndex < 0 || !row || !column) return + + const kept = this.problems.filter( + (problem) => problem.rowIndex !== rowIndex || problem.columnId !== columnId + ) + + const { problems } = await stageRows([[]], [], [column], { + messageFor: (kind, entry) => this.#defaultMessage(kind, entry) + }) + void problems + + const failed = await this.#checkCell(row, column) + this.staged = this.staged.map((entry, index) => (index === rowIndex ? row : entry)) + const revalidated = failed + ? [...kept, { rowIndex, columnId, kind: 'invalid' as const, message: failed }] + : kept + this.problems = [ + ...revalidated.filter( + (problem) => problem.kind !== 'duplicate' && problem.kind !== 'existing' + ), + ...this.#duplicates(this.staged) + ] + } + + async #checkCell(row: TRow, column: ColumnDef): Promise { + const value = (row as Record)[column.id] + if (column.validate) return column.validate(value, row) + if (!column.schema) return null + + const result = await column.schema['~standard'].validate(value) + const issues = result.issues + return issues && issues.length > 0 ? (issues[0]?.message ?? null) : null + } + + get accepts(): ImportFormat[] { + return this.#options.accept ?? ['csv', 'tsv', 'xlsx', 'clipboard'] + } + + get badRows(): Set { + return problemRows(this.problems) + } + + get issueCounts(): { message: string; count: number }[] { + return problemsByMessage(this.problems) + } + + get validCount(): number { + return this.staged.length - this.badRows.size + } + + get sourceCount(): number { + return Math.max(0, this.#source.length - (this.hasHeader ? 1 : 0)) + } + + get preview(): string[][] { + const body = this.hasHeader ? this.#source.slice(1) : this.#source + return body + .slice(0, 3) + .map((line) => line.map((cell) => (cell === null ? '' : String(cell)))) + } + + get matchedCount(): number { + return this.mappings.filter((entry) => entry.sourceIndex !== null).length + } + + get canReview(): boolean { + return this.step === 'mapping' && this.mappings.some((entry) => entry.sourceIndex !== null) + } + + get isReviewing(): boolean { + return this.step === 'review' && this.staged.length > 0 + } + + #memoryKey(): string | null { + const asked = this.#options.rememberAs + if (asked === null) return null + return ( + asked ?? `dg-pro-import:${this.#grid.columns.all.map((column) => column.id).join(',')}` + ) + } + + #remembered(): Record { + const key = this.#memoryKey() + if (!key || typeof localStorage === 'undefined') return {} + try { + const stored = localStorage.getItem(key) + const parsed = stored ? (JSON.parse(stored) as Record) : {} + + const out: Record = {} + for (const [columnId, header] of Object.entries(parsed)) { + if (typeof header === 'string') out[columnId] = header + } + return out + } catch { + return {} + } + } + + #remember(): void { + const key = this.#memoryKey() + if (!key || typeof localStorage === 'undefined') return + try { + localStorage.setItem(key, JSON.stringify(rememberMapping(this.mappings, this.headers))) + } catch { + this.error = null + } + } + + #columns(): ColumnDef[] { + return this.#grid.columns.all + .map((column) => column.def) + .filter((def) => def.type !== 'actions') + } + + #afterRead(rows: SourceValue[][]): void { + this.staged = [] + this.problems = [] + const asText = rows.map((line) => line.map((cell) => (cell === null ? '' : String(cell)))) + this.hasHeader = looksLikeHeader(asText) + this.#source = rows + this.#applyHeader() + this.step = 'mapping' + } + + #applyHeader(): void { + const first = this.#source[0] ?? [] + this.headers = this.hasHeader + ? first.map((cell, index) => + cell === null || cell === '' ? `#${index + 1}` : String(cell) + ) + : first.map((_, index) => `#${index + 1}`) + + this.mappings = guessMapping(this.#columns(), this.headers, this.#remembered()) + } + + setHasHeader = (value: boolean): void => { + this.back() + this.hasHeader = value + this.#applyHeader() + } + + setMapping = (columnId: string, sourceIndex: number | null): void => { + this.back() + this.mappings = this.mappings.map((mapping) => + mapping.columnId === columnId + ? { ...mapping, sourceIndex } + : mapping.sourceIndex === sourceIndex && sourceIndex !== null + ? { ...mapping, sourceIndex: null } + : mapping + ) + } + + #refuseServer(): boolean { + if (this.#grid.rowModel !== 'server') return false + this.error = this.#grid.labels.importServerRefusal + this.step = 'idle' + return true + } + + takeText = async (text: string, format: ImportFormat = 'csv'): Promise => { + if (this.#refuseServer()) return + this.#restore() + this.error = null + this.busy = true + try { + const rows = parseDelimited(text, { + delimiter: format === 'tsv' ? '\t' : undefined, + maxRows: this.#options.maxRows + }) + if (rows.length === 0) throw new Error(this.#grid.labels.importEmpty) + this.#bytes = null + this.sheets = [] + this.#afterRead(rows) + } catch (cause) { + this.error = cause instanceof Error ? cause.message : String(cause) + this.step = 'idle' + } finally { + this.busy = false + } + } + + takeBytes = async (bytes: Uint8Array, sheet?: string): Promise => { + if (this.#refuseServer()) return + this.#restore() + this.error = null + this.busy = true + try { + const { rows, sheets } = await readXlsx(bytes, { + sheet: sheet ?? this.#options.sheet, + maxRows: this.#options.maxRows + }) + if (rows.length === 0) throw new Error(this.#grid.labels.importEmpty) + this.#bytes = bytes + this.sheets = sheets + this.sheet = sheet ?? this.#options.sheet ?? sheets[0] ?? null + this.#afterRead(rows) + } catch (cause) { + this.error = cause instanceof Error ? cause.message : String(cause) + this.step = 'idle' + } finally { + this.busy = false + } + } + + setSheet = async (name: string): Promise => { + if (!this.#bytes) return + await this.takeBytes(this.#bytes, name) + } + + take = async (file: File): Promise => { + const extension = file.name.split('.').pop()?.toLowerCase() ?? '' + const format = BY_EXTENSION[extension] + + if (!format || !this.accepts.includes(format)) { + this.error = this.#grid.labels.importUnknownFormat(file.name) + return + } + + const maxBytes = this.#options.maxBytes ?? DEFAULT_MAX_BYTES + if (file.size > maxBytes) { + this.error = this.#grid.labels.importTooBig(file.name, Math.round(maxBytes / 1_048_576)) + return + } + + this.fileName = file.name + + if (format === 'xlsx') { + await this.takeBytes(new Uint8Array(await file.arrayBuffer())) + return + } + await this.takeText(await file.text(), format) + } + + review = async (): Promise => { + if (!this.canReview) return + this.busy = true + this.error = null + try { + const body = this.hasHeader ? this.#source.slice(1) : this.#source + const { rows, problems } = await stageRows(body, this.mappings, this.#columns(), { + newRow: this.#options.newRow, + decimal: this.#options.decimal, + messageFor: (kind, column) => + this.#options.messageFor?.(kind, column) ?? this.#defaultMessage(kind, column) + }) + + this.staged = rows + this.problems = [...problems, ...this.#duplicates(rows)] + this.#stagedIds = idsOf(rows, (row) => this.#grid.getRowId(row)) + this.#grid.data = [...this.#grid.data, ...rows] + this.#staging = true + this.step = 'review' + this.#remember() + } catch (cause) { + this.error = cause instanceof Error ? cause.message : String(cause) + } finally { + this.busy = false + } + } + + #dedupe(): DuplicateOptions | null { + const dedupe = this.#options.dedupe + if (!dedupe || dedupe.columns.length === 0) return null + return { columns: dedupe.columns, against: dedupe.against ?? 'both' } + } + + #duplicates(rows: readonly TRow[]): ImportProblem[] { + const dedupe = this.#dedupe() + if (!dedupe) return [] + + const t = this.#grid.labels + const known = + dedupe.against === 'file' + ? noKeys() + : existingKeys( + this.#grid.data.filter( + (row) => !this.#stagedIds.has(this.#grid.getRowId(row)) + ), + dedupe.columns + ) + + return duplicateProblems(rows, dedupe, known, { + inFile: (key) => t.importDuplicate(key), + inGrid: (key) => t.importExisting(key) + }) + } + + #defaultMessage(kind: ProblemKind, column: ColumnDef): string { + const t = this.#grid.labels + const header = String(column.header ?? column.id) + if (kind === 'number') return t.importNotNumber(header) + if (kind === 'date') return t.importNotDate(header) + if (kind === 'boolean') return t.importNotBoolean(header) + if (kind === 'duplicate') return t.importDuplicate(header) + if (kind === 'existing') return t.importExisting(header) + return t.importInvalid(header) + } + + commit = async (options: { validOnly?: boolean } = {}): Promise => { + const bad = this.badRows + const live = this.staged.map((row) => this.#currentRow(this.#grid.getRowId(row)) ?? row) + const chosen = options.validOnly ? live.filter((_, index) => !bad.has(index)) : live + + if (chosen.length === 0) return + + this.busy = true + this.error = null + try { + this.#restore() + await this.#options.onCommit(chosen) + this.cancel() + } catch (cause) { + this.error = cause instanceof Error ? cause.message : String(cause) + } finally { + this.busy = false + } + } + + #restore(): void { + if (!this.#staging) return + + const staged = this.#stagedIds + this.#grid.data = this.#grid.data.filter((row) => !staged.has(this.#grid.getRowId(row))) + this.#staging = false + this.#stagedIds = emptyIds() + } + + back = (): void => { + if (this.step !== 'review') return + this.#restore() + this.staged = [] + this.problems = [] + this.step = 'mapping' + } + + cancel = (): void => { + this.#restore() + this.step = 'idle' + this.staged = [] + this.problems = [] + this.headers = [] + this.mappings = [] + this.sheets = [] + this.sheet = null + this.fileName = null + this.error = null + this.#source = [] + this.#bytes = null + } + + isStaged = (rowId: string): boolean => this.#stagedIds.has(rowId) + + decoration = (node: RowNode, columnId: string): CellDecoration | undefined => { + if (!this.#stagedIds.has(node.id)) return undefined + + const rowIndex = this.#indexOf(node.id) + const problem = this.problems.find( + (entry) => entry.rowIndex === rowIndex && entry.columnId === columnId + ) + + return { + class: slotClass(problem ? 'importBadCell' : 'importStagedCell') + } + } +} + +export function getDataImport(grid: GridState): DataImport | undefined { + return grid.feature>(DATA_IMPORT) +} + +export function dataImport(options: DataImportOptions): GridFeature { + return { + id: DATA_IMPORT, + createState: (grid) => new DataImport(grid, options), + cellDecoration: ({ grid, node, column }) => getDataImport(grid)?.decoration(node, column.id) + } +} diff --git a/src/lib/features/data-import/data-import.types.ts b/src/lib/features/data-import/data-import.types.ts new file mode 100644 index 0000000..7892021 --- /dev/null +++ b/src/lib/features/data-import/data-import.types.ts @@ -0,0 +1,128 @@ +import { type ColumnDef } from '../../core/types/index.js' +import type { DecimalMark } from './coerce.js' +import type { DuplicateScope } from './duplicates.js' +import type { ProblemKind } from './stage.js' + +/** The formats the wizard offers to read. */ +export type ImportFormat = 'csv' | 'tsv' | 'xlsx' | 'clipboard' + +/** Which screen the wizard is on. `idle` is "nothing taken yet". */ +export type ImportStep = 'idle' | 'mapping' | 'review' + +export interface DataImportOptions { + /** + * Formats offered. A file whose extension is not listed is refused by name + * rather than guessed at. + * + * @default ['csv', 'tsv', 'xlsx', 'clipboard'] + */ + accept?: ImportFormat[] + + /** + * Called with the rows the user chose to commit. Anything it throws is + * shown in the wizard rather than swallowed, and the staged rows stay put + * so nobody loses ten minutes of fixing. + */ + onCommit: (rows: TRow[]) => void | Promise + + /** + * Seeds each staged row before the file's values are written over it - an + * id, a tenant, a default status. The index is the row's position in the + * file, counting from zero after the header. + * + * @default undefined + */ + newRow?: (index: number) => Partial + + /** + * The message shown on a cell whose text could not be read as the column's + * type. Return `undefined` to keep the built-in wording. + * + * @default undefined + */ + messageFor?: (kind: ProblemKind, column: ColumnDef) => string | undefined + + /** + * Where the guessed column mapping is remembered, so importing the same + * report twice is one click the second time. A key of `null` remembers + * nothing. + * + * @default `dg-pro-import:${grid.id}` + */ + rememberAs?: string | null + + /** + * Rows read from one file, before it is refused. Guards a paste of a + * million cells more than it guards a deliberate import. + * + * @default 200000 + */ + maxRows?: number + + /** + * The largest file this will open, in bytes. Checked against `file.size` + * before a byte is read, which is the cheapest guard there is: a file too + * big to hold is refused by name instead of being loaded and then regretted. + * + * @default 33554432 + */ + maxBytes?: number + + /** + * Which mark the file's numbers use for the decimal point. Unset guesses, + * and a guess on `1.250` is genuinely undecidable: it is one and a quarter + * in one country and twelve hundred and fifty in another. The guess errs + * towards the smaller reading, because turning money into a thousand times + * itself is the worse mistake. Say `','` when the files come from a place + * that writes `1.250,50`. + * + * @default undefined + */ + decimal?: DecimalMark + + /** + * Sheet to read from an `xlsx` workbook. Unset reads the first. + * + * @default undefined + */ + sheet?: string + + /** + * Marks a row whose key has been seen already, so importing the same + * export twice does not double the data. A duplicate is flagged like any + * other problem: the cell turns red, the review panel counts it, and + * "add the valid rows" leaves it behind. Nothing is dropped silently. + * + * @default undefined + */ + dedupe?: ImportDedupe +} + +/** + * How an import decides that two rows are the same row. The key is the values + * of `columns` read together, trimmed and compared without case; a row missing + * any part of the key has no key and is never called a duplicate. + */ +export interface ImportDedupe { + /** + * Grid column ids whose values together identify a row. One id is the + * common case (`['email']`); several make a composite key. + */ + columns: string[] + + /** + * What a key is measured against: rows earlier in the same file, rows the + * grid already holds, or both. + * + * @default 'both' + */ + against?: DuplicateScope +} + +/** One cell the import could not read or could not accept. */ +export interface ImportIssue { + rowIndex: number + columnId: string + kind: ProblemKind + message: string +} diff --git a/src/lib/features/data-import/delimited.test.ts b/src/lib/features/data-import/delimited.test.ts new file mode 100644 index 0000000..80f4a89 --- /dev/null +++ b/src/lib/features/data-import/delimited.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { looksLikeHeader, parseDelimited, sniffDelimiter } from './delimited.js' + +describe('reading a delimited file', () => { + it('reads the plain case', () => { + expect(parseDelimited('a,b\n1,2')).toEqual([ + ['a', 'b'], + ['1', '2'] + ]) + }) + + it('keeps a delimiter that is inside quotes', () => { + expect(parseDelimited('name,note\n"Chi, An",hi')).toEqual([ + ['name', 'note'], + ['Chi, An', 'hi'] + ]) + }) + + it('keeps a newline that is inside quotes', () => { + expect(parseDelimited('a\n"one\ntwo"')).toEqual([['a'], ['one\ntwo']]) + }) + + it('reads a doubled quote as one quote', () => { + expect(parseDelimited('a\n"say ""hi"""')).toEqual([['a'], ['say "hi"']]) + }) + + it('takes CRLF the same way as LF', () => { + expect(parseDelimited('a,b\r\n1,2\r\n')).toEqual([ + ['a', 'b'], + ['1', '2'] + ]) + }) + + it('drops the byte order mark a spreadsheet writes', () => { + expect(parseDelimited('id,name\n1,Chi')[0]).toEqual(['id', 'name']) + }) + + it('keeps an empty trailing field rather than losing the column', () => { + expect(parseDelimited('a,b,c\n1,,3')).toEqual([ + ['a', 'b', 'c'], + ['1', '', '3'] + ]) + }) + + it('drops the blank line a file ends with', () => { + expect(parseDelimited('a,b\n1,2\n\n')).toHaveLength(2) + }) + + it('reads a tab file without being told', () => { + expect(parseDelimited('a\tb\n1\t2')).toEqual([ + ['a', 'b'], + ['1', '2'] + ]) + }) + + it('refuses a file past the row cap instead of freezing', () => { + const many = Array.from({ length: 40 }, (_, i) => `${i}`).join('\n') + expect(() => parseDelimited(many, { maxRows: 10 })).toThrow(/more than 10 rows/) + }) + + it('refuses a file past the cell cap', () => { + expect(() => parseDelimited('a,b,c\n1,2,3', { maxCells: 4 })).toThrow(/cells/) + }) +}) + +describe('which delimiter a file uses', () => { + it.each([ + ['a,b,c', ','], + ['a\tb\tc', '\t'], + ['a;b;c', ';'], + ['a|b|c', '|'], + ['"a,b";c;d', ';'] + ])('reads %s as %s', (line, expected) => { + expect(sniffDelimiter(line)).toBe(expected) + }) +}) + +describe('whether the first row is a header', () => { + it('says yes to distinct words over data', () => { + expect( + looksLikeHeader([ + ['id', 'name'], + ['1', 'Chi'] + ]) + ).toBe(true) + }) + + it('says no when the first row is numbers', () => { + expect( + looksLikeHeader([ + ['1', '2'], + ['3', '4'] + ]) + ).toBe(false) + }) + + it('says no when a name repeats, which a header does not', () => { + expect( + looksLikeHeader([ + ['id', 'id'], + ['1', '2'] + ]) + ).toBe(false) + }) + + it('says no to a single row, which is data or nothing', () => { + expect(looksLikeHeader([['id', 'name']])).toBe(false) + }) +}) diff --git a/src/lib/features/data-import/delimited.ts b/src/lib/features/data-import/delimited.ts new file mode 100644 index 0000000..31a4010 --- /dev/null +++ b/src/lib/features/data-import/delimited.ts @@ -0,0 +1,156 @@ +export interface DelimitedOptions { + delimiter?: string + maxRows?: number + maxCells?: number +} + +export const DEFAULT_MAX_ROWS = 200_000 +export const DEFAULT_MAX_CELLS = 4_000_000 + +const BOM = '' + +export function sniffDelimiter(text: string): string { + const line = text.slice(0, 64_000).split(/\r?\n/)[0] ?? '' + const candidates = [',', '\t', ';', '|'] + + let best = ',' + let bestCount = 0 + for (const candidate of candidates) { + let count = 0 + let quoted = false + for (let index = 0; index < line.length; index++) { + const char = line[index] + if (char === '"') quoted = !quoted + else if (!quoted && char === candidate) count += 1 + } + if (count > bestCount) { + best = candidate + bestCount = count + } + } + return best +} + +interface Scanner { + field: string + quoted: boolean + skip: boolean +} + +type Step = 'none' | 'field' | 'row' + +function insideQuotes(text: string, index: number, scanner: Scanner): void { + const char = text[index]! + if (char !== '"') { + scanner.field += char + return + } + if (text[index + 1] === '"') { + scanner.field += '"' + scanner.skip = true + return + } + scanner.quoted = false +} + +function outsideQuotes(text: string, index: number, delimiter: string, scanner: Scanner): Step { + const char = text[index]! + + if (char === '"' && scanner.field === '') { + scanner.quoted = true + return 'none' + } + if (char === delimiter) return 'field' + if (char === '\n') return 'row' + if (char === '\r') { + if (text[index + 1] === '\n') scanner.skip = true + return 'row' + } + + scanner.field += char + return 'none' +} + +function advance(text: string, index: number, delimiter: string, scanner: Scanner): Step { + if (scanner.skip) { + scanner.skip = false + return 'none' + } + if (scanner.quoted) { + insideQuotes(text, index, scanner) + return 'none' + } + return outsideQuotes(text, index, delimiter, scanner) +} + +function dropTrailingBlank(rows: string[][]): string[][] { + while (rows.length > 0 && rows[rows.length - 1]!.every((cell) => cell === '')) rows.pop() + return rows +} + +function makeSink(options: DelimitedOptions) { + const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS + const maxCells = options.maxCells ?? DEFAULT_MAX_CELLS + const rows: string[][] = [] + let row: string[] = [] + let cells = 0 + + return { + rows, + pending: () => row.length, + field(text: string) { + row.push(text) + cells += 1 + if (cells > maxCells) { + throw new RangeError( + `The file holds more than ${maxCells.toLocaleString('en-US')} cells, which ` + + 'is more than this reads in one go. Split it, or raise maxCells.' + ) + } + }, + endRow(text: string) { + this.field(text) + rows.push(row) + row = [] + if (rows.length > maxRows) { + throw new RangeError( + `The file holds more than ${maxRows.toLocaleString('en-US')} rows, which is ` + + 'more than this reads in one go. Split it, or raise maxRows.' + ) + } + } + } +} + +export function parseDelimited(input: string, options: DelimitedOptions = {}): string[][] { + const text = input.startsWith(BOM) ? input.slice(1) : input + const delimiter = options.delimiter ?? sniffDelimiter(text) + const sink = makeSink(options) + const scanner: Scanner = { field: '', quoted: false, skip: false } + + for (let index = 0; index < text.length; index++) { + const step = advance(text, index, delimiter, scanner) + if (step === 'none') continue + + if (step === 'field') sink.field(scanner.field) + else sink.endRow(scanner.field) + scanner.field = '' + } + + if (scanner.field !== '' || sink.pending() > 0) sink.endRow(scanner.field) + return dropTrailingBlank(sink.rows) +} + +export function looksLikeHeader(rows: string[][]): boolean { + const first = rows[0] + if (!first || rows.length < 2) return false + + const filled = first.filter((cell) => cell.trim() !== '') + if (filled.length === 0) return false + + const numericHeaders = filled.filter((cell) => Number.isFinite(Number(cell.trim()))).length + if (numericHeaders > filled.length / 2) return false + + const unique = new Set(filled.map((cell) => cell.trim().toLowerCase())) + return unique.size === filled.length +} diff --git a/src/lib/features/data-import/duplicates.test.ts b/src/lib/features/data-import/duplicates.test.ts new file mode 100644 index 0000000..b4eea6b --- /dev/null +++ b/src/lib/features/data-import/duplicates.test.ts @@ -0,0 +1,215 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { describe, expect, it } from 'vitest' +import { dataImport, getDataImport } from './data-import.svelte.js' +import { duplicateKey, duplicateProblems, existingKeys } from './duplicates.js' + +const messages = { + inFile: (key: string) => `${key} twice`, + inGrid: (key: string) => `${key} already here` +} + +describe('the key two rows are the same row by', () => { + it('reads the columns together, trimmed and without case', () => { + expect(duplicateKey({ email: ' Chi@Example.COM ' }, ['email'])).toBe('chi@example.com') + expect(duplicateKey({ a: 'x', b: 2 }, ['a', 'b'])).toBe('x\u00002') + }) + + it('has no key for a row missing part of one, so a blank is never a duplicate', () => { + expect(duplicateKey({ a: 'x', b: null }, ['a', 'b'])).toBeNull() + expect(duplicateKey({ a: 'x', b: ' ' }, ['a', 'b'])).toBeNull() + expect(duplicateKey({ a: 'x' }, [])).toBeNull() + }) + + it('reads a date by the instant it holds, not by how it was typed', () => { + const one = duplicateKey({ at: new Date(2026, 0, 1) }, ['at']) + const two = duplicateKey({ at: new Date(2026, 0, 1) }, ['at']) + + expect(one).toBe(two) + expect(duplicateKey({ at: new Date('nope') }, ['at'])).toBeNull() + }) +}) + +describe('duplicates inside one file', () => { + const rows = [{ email: 'a@x.com' }, { email: 'b@x.com' }, { email: 'A@X.com' }] + + it('flags the second sighting and leaves the first alone', () => { + const found = duplicateProblems( + rows, + { columns: ['email'], against: 'file' }, + new Set(), + messages + ) + + expect(found).toHaveLength(1) + expect(found[0]!.rowIndex).toBe(2) + expect(found[0]!.kind).toBe('duplicate') + expect(found[0]!.columnId).toBe('email') + }) + + it('says nothing about rows the grid already holds when only the file was asked about', () => { + const found = duplicateProblems( + [{ email: 'a@x.com' }], + { columns: ['email'], against: 'file' }, + existingKeys([{ email: 'a@x.com' }], ['email']), + messages + ) + + expect(found).toEqual([]) + }) +}) + +describe('duplicates against what the grid already holds', () => { + it('names the row that is already there, and says which', () => { + const found = duplicateProblems( + [{ email: 'a@x.com' }], + { columns: ['email'], against: 'grid' }, + existingKeys([{ email: ' A@x.com ' }], ['email']), + messages + ) + + expect(found).toHaveLength(1) + expect(found[0]!.kind).toBe('existing') + expect(found[0]!.message).toBe('a@x.com already here') + }) + + it('leaves a file that repeats itself alone when only the grid was asked about', () => { + const found = duplicateProblems( + [{ email: 'a@x.com' }, { email: 'a@x.com' }], + { columns: ['email'], against: 'grid' }, + new Set(), + messages + ) + + expect(found).toEqual([]) + }) + + it('answers both questions at once, and calls the older collision the older one', () => { + const found = duplicateProblems( + [{ email: 'a@x.com' }, { email: 'b@x.com' }, { email: 'b@x.com' }], + { columns: ['email'], against: 'both' }, + existingKeys([{ email: 'a@x.com' }], ['email']), + messages + ) + + expect(found.map((problem) => [problem.rowIndex, problem.kind])).toEqual([ + [0, 'existing'], + [2, 'duplicate'] + ]) + }) +}) + +interface Member { + id: number + email: string + name: string +} + +const columns: ColumnDef[] = [ + { id: 'email', header: 'Email', editable: true }, + { id: 'name', header: 'Name', editable: true } +] + +const FILE = 'Email,Name\nchi@x.com,Chi\nan@x.com,An\nCHI@x.com,Chi again\n' + +function importedInto(existing: Member[], against?: 'file' | 'grid' | 'both') { + const grid = createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + editing(), + dataImport({ + onCommit: () => {}, + newRow: (index) => ({ id: 100 + index }), + dedupe: { columns: ['email'], against } + }) + ] + }) + return { grid, importing: getDataImport(grid)! } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)) + +describe('a wizard told which column identifies a row', () => { + it('marks the row the file repeats, and only that one', async () => { + const { importing } = importedInto([]) + await importing.takeText(FILE) + await importing.review() + + expect([...importing.badRows]).toEqual([2]) + expect(importing.validCount).toBe(2) + }) + + it('marks a row the grid already has, without touching the rest', async () => { + const { importing } = importedInto([{ id: 1, email: 'an@x.com', name: 'An' }]) + await importing.takeText(FILE) + await importing.review() + + expect([...importing.badRows].sort()).toEqual([1, 2]) + }) + + it('does not call a staged row a duplicate of itself', async () => { + const { importing } = importedInto([], 'grid') + await importing.takeText(FILE) + await importing.review() + + expect([...importing.badRows]).toEqual([]) + }) + + it('clears the mark when the key is edited to something new', async () => { + const { grid, importing } = importedInto([]) + await importing.takeText(FILE) + await importing.review() + + grid.api.applyEdits?.([{ rowId: '102', changes: { email: 'moi@x.com' } }]) + await settle() + + expect([...importing.badRows]).toEqual([]) + }) + + it('commits the valid rows only when asked, leaving the duplicate behind', async () => { + const committed: Member[][] = [] + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + features: [ + editing(), + dataImport({ + onCommit: (rows) => { + committed.push(rows) + }, + newRow: (index) => ({ id: 100 + index }), + dedupe: { columns: ['email'] } + }) + ] + }) + const importing = getDataImport(grid)! + + await importing.takeText(FILE) + await importing.review() + await importing.commit({ validOnly: true }) + + expect(committed[0]!.map((row) => row.email)).toEqual(['chi@x.com', 'an@x.com']) + }) + + it('says nothing about duplicates when no key was declared', async () => { + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + features: [ + editing(), + dataImport({ onCommit: () => {}, newRow: (index) => ({ id: 100 + index }) }) + ] + }) + const importing = getDataImport(grid)! + + await importing.takeText(FILE) + await importing.review() + + expect([...importing.badRows]).toEqual([]) + }) +}) diff --git a/src/lib/features/data-import/duplicates.ts b/src/lib/features/data-import/duplicates.ts new file mode 100644 index 0000000..88bebc3 --- /dev/null +++ b/src/lib/features/data-import/duplicates.ts @@ -0,0 +1,103 @@ +import type { ImportProblem, ProblemKind } from './stage.js' + +const KEY_SEPARATOR = '\u0000' + +const NO_KEYS: ReadonlySet = new Set() + +export type DuplicateScope = 'file' | 'grid' | 'both' + +export interface DuplicateOptions { + columns: string[] + against: DuplicateScope +} + +export interface DuplicateMessages { + inFile: (key: string) => string + inGrid: (key: string) => string +} + +function part(value: unknown): string | null { + if (value === null || value === undefined) return null + if (value instanceof Date) { + const time = value.getTime() + return Number.isNaN(time) ? null : String(time) + } + const text = String(value).trim() + return text === '' ? null : text.toLowerCase() +} + +export function duplicateKey(row: unknown, columns: string[]): string | null { + if (columns.length === 0) return null + + const parts: string[] = [] + for (const columnId of columns) { + const piece = part((row as Record)[columnId]) + if (piece === null) return null + parts.push(piece) + } + return parts.join(KEY_SEPARATOR) +} + +export function keyLabel(row: unknown, columns: string[]): string { + return columns + .map((columnId) => String((row as Record)[columnId] ?? '')) + .filter((piece) => piece !== '') + .join(' / ') +} + +export function noKeys(): ReadonlySet { + return NO_KEYS +} + +export function existingKeys(rows: readonly unknown[], columns: string[]): Set { + const keys = new Set() + for (const row of rows) { + const key = duplicateKey(row, columns) + if (key !== null) keys.add(key) + } + return keys +} + +function kindOf( + key: string, + against: DuplicateScope, + known: ReadonlySet, + seen: ReadonlySet +): ProblemKind | null { + if (against !== 'file' && known.has(key)) return 'existing' + if (against !== 'grid' && seen.has(key)) return 'duplicate' + return null +} + +export function duplicateProblems( + rows: readonly unknown[], + options: DuplicateOptions, + known: ReadonlySet, + messages: DuplicateMessages +): ImportProblem[] { + const { columns, against } = options + const columnId = columns[0] + if (!columnId) return [] + + const seen = new Set() + const problems: ImportProblem[] = [] + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const key = duplicateKey(rows[rowIndex], columns) + if (key === null) continue + + const kind = kindOf(key, against, known, seen) + seen.add(key) + if (kind === null) continue + + const label = keyLabel(rows[rowIndex], columns) + problems.push({ + rowIndex, + columnId, + kind, + message: kind === 'existing' ? messages.inGrid(label) : messages.inFile(label) + }) + } + + return problems +} diff --git a/src/lib/features/data-import/ids.ts b/src/lib/features/data-import/ids.ts new file mode 100644 index 0000000..4294ca4 --- /dev/null +++ b/src/lib/features/data-import/ids.ts @@ -0,0 +1,7 @@ +export function emptyIds(): ReadonlySet { + return new Set() +} + +export function idsOf(rows: TRow[], idOf: (row: TRow) => string): ReadonlySet { + return new Set(rows.map(idOf)) +} diff --git a/src/lib/features/data-import/index.ts b/src/lib/features/data-import/index.ts new file mode 100644 index 0000000..61b200b --- /dev/null +++ b/src/lib/features/data-import/index.ts @@ -0,0 +1,49 @@ +export { + coerce, + kindOfColumn, + parseBoolean, + parseDate, + parseNumber, + type CoerceKind +} from './coerce.js' +export { DATA_IMPORT, DataImport, dataImport, getDataImport } from './data-import.svelte.js' +export type { + DataImportOptions, + ImportDedupe, + ImportFormat, + ImportIssue, + ImportStep +} from './data-import.types.js' +export { + duplicateKey, + duplicateProblems, + existingKeys, + keyLabel, + type DuplicateOptions, + type DuplicateScope +} from './duplicates.js' +export { + DEFAULT_MAX_CELLS, + DEFAULT_MAX_ROWS, + looksLikeHeader, + parseDelimited, + sniffDelimiter +} from './delimited.js' +export { emptyIds, idsOf } from './ids.js' +export { + guessMapping, + mappedColumns, + rememberMapping, + type ColumnMapping, + type SourceValue +} from './mapping.js' +export { + problemRows, + problemsByMessage, + stageRows, + type ImportProblem, + type ProblemKind, + type StagedRows +} from './stage.js' +export { readArchive, readDirectory, readEntry, type ArchiveEntry } from './unzip.js' +export { readSheet, readXlsx, serialToDate, type SheetCell } from './xlsx-read.js' diff --git a/src/lib/features/data-import/mapping.ts b/src/lib/features/data-import/mapping.ts new file mode 100644 index 0000000..093737d --- /dev/null +++ b/src/lib/features/data-import/mapping.ts @@ -0,0 +1,95 @@ +import { type ColumnDef } from '../../core/types/index.js' + +export type SourceValue = string | number | boolean | Date | null + +export interface ColumnMapping { + columnId: string + sourceIndex: number | null +} + +function fold(text: string): string { + return text + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/đ/g, 'd') + .replace(/Đ/g, 'D') + .toLowerCase() + .replace(/[^a-z0-9]/g, '') +} + +export const MIN_LOOSE_LENGTH = 4 + +function nearly(candidate: string, wanted: string): boolean { + if (wanted.length < MIN_LOOSE_LENGTH || candidate.length < MIN_LOOSE_LENGTH) return false + return candidate.startsWith(wanted) || wanted.startsWith(candidate) +} + +function findFor(folded: string[], taken: Set, byId: string, byHeader: string): number { + const search = (wanted: string, accept: (candidate: string) => boolean): number => { + if (wanted === '') return -1 + return folded.findIndex( + (candidate, index) => !taken.has(index) && candidate !== '' && accept(candidate) + ) + } + + const exact = search(byId, (candidate) => candidate === byId) + if (exact >= 0) return exact + + const header = search(byHeader, (candidate) => candidate === byHeader) + if (header >= 0) return header + + const loose = search(byHeader, (candidate) => nearly(candidate, byHeader)) + if (loose >= 0) return loose + + return search(byId, (candidate) => nearly(candidate, byId)) +} + +export function guessMapping( + columns: ColumnDef[], + headers: string[], + remembered: Record = {} +): ColumnMapping[] { + const taken = new Set() + const folded = headers.map(fold) + + const mappings = columns.map((column) => { + const known = remembered[column.id] + const at = typeof known === 'string' ? folded.indexOf(fold(known)) : -1 + + if (at >= 0 && !taken.has(at)) { + taken.add(at) + return { columnId: column.id, sourceIndex: at } + } + return { columnId: column.id, sourceIndex: null } + }) + + for (const mapping of mappings) { + if (mapping.sourceIndex !== null) continue + + const column = columns.find((entry) => entry.id === mapping.columnId)! + const at = findFor(folded, taken, fold(column.id), fold(String(column.header ?? ''))) + + if (at >= 0) { + taken.add(at) + mapping.sourceIndex = at + } + } + + return mappings +} + +export function rememberMapping( + mappings: ColumnMapping[], + headers: string[] +): Record { + const out: Record = {} + for (const mapping of mappings) { + const header = mapping.sourceIndex === null ? undefined : headers[mapping.sourceIndex] + if (header !== undefined && header !== '') out[mapping.columnId] = header + } + return out +} + +export function mappedColumns(mappings: ColumnMapping[]): string[] { + return mappings.filter((entry) => entry.sourceIndex !== null).map((entry) => entry.columnId) +} diff --git a/src/lib/features/data-import/review.test.ts b/src/lib/features/data-import/review.test.ts new file mode 100644 index 0000000..fa0a4bf --- /dev/null +++ b/src/lib/features/data-import/review.test.ts @@ -0,0 +1,212 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { describe, expect, it } from 'vitest' +import { dataImport, getDataImport } from './data-import.svelte.js' + +interface Row { + id: number + name: string + qty: number +} + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', editable: true }, + { + id: 'qty', + header: 'Qty', + type: 'number', + editable: true, + validate: (value) => (Number(value) > 0 ? null : 'Qty must be positive') + } +] + +const FILE = 'Name,Qty\nChi,3\nAn,abc\n' + +function makeGrid(existing: Row[] = [{ id: 1, name: 'Existing', qty: 1 }]) { + return createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + editing(), + dataImport({ onCommit: () => {}, newRow: (index) => ({ id: 100 + index }) }) + ] + }) +} + +async function reviewed(grid: ReturnType) { + const importing = getDataImport(grid)! + await importing.takeText(FILE) + await importing.review() + return importing +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)) + +describe('a staged row is a row the grid owns', () => { + it('carries the id the app would give it, not one the import made up', async () => { + const grid = makeGrid() + await reviewed(grid) + + expect(grid.preWindowNodes.map((node) => node.id)).toEqual(['1', '100', '101']) + }) + + it('can be edited, which is the whole point of reviewing in the grid', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + grid.api.applyEdits?.([{ rowId: '101', changes: { qty: 9 } }]) + await settle() + + expect(grid.preWindowNodes.at(-1)!.row.qty).toBe(9) + expect(importing.staged.at(-1)!.qty).toBe(9) + }) + + it('clears the mark once the cell is fixed', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + expect(importing.badRows.size).toBe(1) + + grid.api.applyEdits?.([{ rowId: '101', changes: { qty: 9 } }]) + await settle() + + expect(importing.problems).toEqual([]) + expect(importing.badRows.size).toBe(0) + }) + + it('cannot be edited into something the column refuses', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + grid.api.applyEdits?.([{ rowId: '100', changes: { qty: -5 } }]) + await settle() + + expect(grid.data.find((row) => row.id === 100)!.qty).toBe(3) + expect(importing.problems.map((problem) => problem.columnId)).toEqual(['qty']) + expect(importing.problems[0]!.rowIndex).toBe(1) + }) + + it('commits the fixed value, not the one that was read', async () => { + const handed: Row[][] = [] + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + features: [ + editing(), + dataImport({ + onCommit: (rows) => { + handed.push(rows) + }, + newRow: (index) => ({ id: 100 + index }) + }) + ] + }) + const importing = getDataImport(grid)! + await importing.takeText(FILE) + await importing.review() + + grid.api.applyEdits?.([{ rowId: '101', changes: { qty: 9 } }]) + await settle() + await importing.commit() + + expect(handed[0]!.map((row) => row.qty)).toEqual([3, 9]) + }) +}) + +describe('the grid goes back to what it was', () => { + it('after cancelling', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + expect(grid.data).toHaveLength(3) + + importing.cancel() + expect(grid.data.map((row) => row.id)).toEqual([1]) + }) + + it('after stepping back to the mapping', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + importing.back() + expect(grid.data.map((row) => row.id)).toEqual([1]) + expect(importing.step).toBe('mapping') + }) + + it('after committing, so the app decides what lands', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + await importing.commit() + expect(grid.data.map((row) => row.id)).toEqual([1]) + }) + + it('even when a second file is reviewed without cancelling the first', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + await importing.takeText('Name,Qty\nMai,5\n') + await importing.review() + + expect(grid.data.map((row) => row.id)).toEqual([1, 100]) + }) +}) + +describe('the wizard refuses rather than getting stuck', () => { + it('keeps a review intact when an unreadable file is dropped onto it', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + await importing.take(new File(['x'], 'report.pdf')) + + expect(importing.step).toBe('review') + expect(importing.error).toContain('report.pdf') + expect(grid.data).toHaveLength(3) + }) + + it('will not review a file with no column matched to anything', async () => { + const grid = makeGrid() + const importing = getDataImport(grid)! + await importing.takeText(FILE) + + for (const mapping of importing.mappings) importing.setMapping(mapping.columnId, null) + + expect(importing.canReview).toBe(false) + await importing.review() + expect(importing.step).toBe('mapping') + expect(grid.data).toHaveLength(1) + }) +}) + +describe('state that moves while a review is open', () => { + it('keeps a row the app added, and removes only what it staged', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + grid.data = [...grid.data, { id: 7, name: 'added by the app', qty: 1 }] + importing.cancel() + + expect(grid.data.map((row) => row.id)).toEqual([1, 7]) + }) + + it('leaves the review when the header line is reinterpreted', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + importing.setHasHeader(false) + + expect(importing.step).toBe('mapping') + expect(grid.data.map((row) => row.id)).toEqual([1]) + }) + + it('leaves the review when a column is mapped somewhere else', async () => { + const grid = makeGrid() + const importing = await reviewed(grid) + + importing.setMapping('qty', null) + + expect(importing.step).toBe('mapping') + expect(grid.data.map((row) => row.id)).toEqual([1]) + }) +}) diff --git a/src/lib/features/data-import/security.test.ts b/src/lib/features/data-import/security.test.ts new file mode 100644 index 0000000..08f27b5 --- /dev/null +++ b/src/lib/features/data-import/security.test.ts @@ -0,0 +1,120 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { createZip, deflateEntries, zipEntry } from '../xlsx/index.js' +import { dataImport, getDataImport } from './data-import.svelte.js' +import { parseDelimited } from './delimited.js' +import { readArchive, readDirectory, readEntry } from './unzip.js' +import { readSharedStrings, readSheet, unescapeXml } from './xlsx-read.js' + +async function lyingZip(realBytes: number, declared: number): Promise { + const [entry] = await deflateEntries([ + zipEntry('xl/worksheets/sheet1.xml', 'A'.repeat(realBytes)) + ]) + const zip = createZip([entry!]) + + const view = new DataView(zip.buffer as ArrayBuffer) + for (let at = 0; at < zip.length - 4; at++) { + if (view.getUint32(at, true) === 0x02014b50) view.setUint32(at + 24, declared, true) + } + return zip +} + +describe('a zip that lies about what it unpacks to', () => { + it('stops unpacking at the cap rather than after it', async () => { + const zip = await lyingZip(4_000_000, 64) + const [entry] = readDirectory(zip) + + expect(entry!.size).toBe(64) + await expect(readEntry(zip, entry!, { maxEntrySize: 4096 })).rejects.toThrow(/zip bomb/) + }) + + it('holds the whole archive to a total, whatever the directory claims', async () => { + const zip = await lyingZip(4_000_000, 64) + + await expect( + readArchive(zip, () => true, { maxTotalSize: 4096, maxEntrySize: 4096 }) + ).rejects.toThrow(/RangeError|zip bomb|unpacks past/) + }) +}) + +describe('an XML document that attacks the reader', () => { + it('refuses a declared entity instead of expanding it', () => { + const billionLaughs = + ']>' + expect(() => readSheet(billionLaughs, [])).toThrow(/DOCTYPE/) + expect(() => readSharedStrings(billionLaughs)).toThrow(/DOCTYPE/) + }) + + it('leaves a character reference past the end of Unicode as text', () => { + expect(unescapeXml('�')).toBe('�') + }) + + it('refuses a worksheet with more rows than it reads', () => { + const many = 'x'.repeat(200) + expect(() => readSheet(many, [], { maxRows: 50 })).toThrow(/more than 50 rows/) + }) + + it('does not fall into a hole on tags that never close', () => { + const nasty = `${''.repeat(4000)}` + const started = performance.now() + readSheet(nasty, []) + expect(performance.now() - started).toBeLessThan(2000) + }) +}) + +describe('a delimited file that attacks the reader', () => { + it('refuses more rows than it reads', () => { + expect(() => parseDelimited('a\n'.repeat(500), { maxRows: 50 })).toThrow(/more than 50/) + }) + + it('refuses more cells than it reads', () => { + expect(() => parseDelimited('a,b,c,d\n'.repeat(50), { maxCells: 20 })).toThrow(/cells/) + }) +}) + +interface Row { + id: number + note: string +} + +describe('what a file is allowed to become', () => { + const columns: ColumnDef[] = [{ id: 'note', header: 'Note' }] + + function makeGrid() { + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + features: [ + dataImport({ onCommit: () => {}, newRow: (index) => ({ id: index + 1 }) }) + ] + }) + } + + it('refuses a file too big to open, by name, before reading a byte', async () => { + const importing = getDataImport(makeGrid())! + const huge = new File(['x'], 'huge.csv') + Object.defineProperty(huge, 'size', { value: 40 * 1024 * 1024 }) + + await importing.take(huge) + + expect(importing.error).toContain('huge.csv') + expect(importing.step).toBe('idle') + }) + + it('keeps a formula as the text it is, and never runs it', async () => { + const grid = makeGrid() + const importing = getDataImport(grid)! + + await importing.takeText('Note\n"=cmd|\'/c calc\'!A1"\n"@SUM(1)"\n"+1+1"') + await importing.review() + + expect(importing.staged.map((row) => row.note)).toEqual([ + "=cmd|'/c calc'!A1", + '@SUM(1)', + '+1+1' + ]) + expect(importing.problems).toEqual([]) + }) +}) diff --git a/src/lib/features/data-import/stage.test.ts b/src/lib/features/data-import/stage.test.ts new file mode 100644 index 0000000..996ebd9 --- /dev/null +++ b/src/lib/features/data-import/stage.test.ts @@ -0,0 +1,236 @@ +import { type ColumnDef } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { guessMapping, rememberMapping } from './mapping.js' +import { problemRows, problemsByMessage, stageRows } from './stage.js' + +interface Member { + id: number + name: string + email: string + joined: Date | null + active: boolean + salary: number +} + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Full name' }, + { + id: 'email', + header: 'Email', + validate: (value) => (String(value ?? '').includes('@') ? null : 'Email needs an @') + }, + { id: 'joined', header: 'Joined', type: 'date' }, + { id: 'active', header: 'Active', type: 'boolean' }, + { id: 'salary', header: 'Salary', type: 'currency' } +] + +describe('guessing which source column is which', () => { + it('matches on the header a person wrote', () => { + const mapping = guessMapping(columns, ['Full name', 'Email', 'Joined', 'Active', 'Salary']) + expect(mapping.map((entry) => entry.sourceIndex)).toEqual([0, 1, 2, 3, 4]) + }) + + it('matches through case, spaces and Vietnamese diacritics', () => { + const local: ColumnDef[] = [{ id: 'name', header: 'Họ tên' }] + expect(guessMapping(local, ['ho ten'])[0]!.sourceIndex).toBe(0) + }) + + it('matches on the column id when the header does not help', () => { + const mapping = guessMapping(columns, ['name', 'email', 'joined', 'active', 'salary']) + expect(mapping.every((entry) => entry.sourceIndex !== null)).toBe(true) + }) + + it('leaves a column unmapped rather than guessing twice into one source', () => { + const mapping = guessMapping(columns, ['Email']) + const used = mapping.filter((entry) => entry.sourceIndex !== null) + expect(used).toHaveLength(1) + expect(used[0]!.columnId).toBe('email') + }) + + it('prefers what it was told to remember, by name rather than by position', () => { + const remembered = { salary: 'Salary' } + const mapping = guessMapping(columns, ['Salary', 'Full name'], remembered) + expect(mapping.find((entry) => entry.columnId === 'salary')!.sourceIndex).toBe(0) + expect(mapping.find((entry) => entry.columnId === 'name')!.sourceIndex).toBe(1) + }) + + it('follows a remembered column that moved rather than the position it held', () => { + const remembered = { salary: 'Salary' } + const mapping = guessMapping(columns, ['Full name', 'Salary'], remembered) + expect(mapping.find((entry) => entry.columnId === 'salary')!.sourceIndex).toBe(1) + }) + + it('forgets a remembered column the new file does not have', () => { + const remembered = { salary: 'Salary', name: 'Full name' } + const mapping = guessMapping(columns, ['Alpha', 'Beta'], remembered) + expect(mapping.every((entry) => entry.sourceIndex === null)).toBe(true) + }) + + it('refuses a guess that is coincidence rather than evidence', () => { + const mapped = guessMapping(columns, ['Paid', 'Company Name', 'Valid', 'Nickname']) + expect(mapped.every((entry) => entry.sourceIndex === null)).toBe(true) + }) + + it('still takes a prefix long enough to mean something', () => { + expect(guessMapping(columns, ['Email address'])[1]!.columnId).toBe('email') + expect( + guessMapping(columns, ['Email address']).find((entry) => entry.columnId === 'email')! + .sourceIndex + ).toBe(0) + }) + + it('maps nothing when a file shares nothing with the grid', () => { + const mapped = guessMapping(columns, ['Alpha', 'Beta', 'Gamma']) + expect(mapped.every((entry) => entry.sourceIndex === null)).toBe(true) + }) + + it('gives back a mapping worth remembering', () => { + const headers = ['Full name', 'Email'] + const mapping = guessMapping(columns, headers) + expect(rememberMapping(mapping, headers)).toEqual({ + name: 'Full name', + email: 'Email' + }) + }) +}) + +describe('staging a file into rows', () => { + const headers = ['Full name', 'Email', 'Joined', 'Active', 'Salary'] + const mapping = guessMapping(columns, headers) + + it('builds the rows a good file describes', async () => { + const { rows, problems } = await stageRows( + [['Chi', 'chi@x.vn', '2026-03-02', 'Có', '1.234,50']], + mapping, + columns + ) + + expect(problems).toEqual([]) + expect(rows[0]!.name).toBe('Chi') + expect(rows[0]!.active).toBe(true) + expect(rows[0]!.salary).toBe(1234.5) + expect(rows[0]!.joined).toBeInstanceOf(Date) + }) + + it('names the cell it could not read and keeps the text it was given', async () => { + const { rows, problems } = await stageRows( + [['An', 'an@x.vn', 'soon', 'maybe', 'abc']], + mapping, + columns + ) + + expect(problems.map((problem) => [problem.columnId, problem.kind])).toEqual([ + ['joined', 'date'], + ['active', 'boolean'], + ['salary', 'number'] + ]) + expect(rows[0]!.joined).toBe('soon') + }) + + it('runs the column validation an app already wrote for editing', async () => { + const { problems } = await stageRows( + [['Chi', 'not-an-email', '', '', '']], + mapping, + columns + ) + + expect(problems).toHaveLength(1) + expect(problems[0]!.message).toBe('Email needs an @') + expect(problems[0]!.kind).toBe('invalid') + }) + + it('does not validate a cell it already could not read', async () => { + const withBoth: ColumnDef[] = [ + { id: 'salary', header: 'Salary', type: 'currency', validate: () => 'never runs' } + ] + const { problems } = await stageRows( + [['abc']], + guessMapping(withBoth, ['Salary']), + withBoth + ) + + expect(problems.map((problem) => problem.kind)).toEqual(['number']) + }) + + it('leaves a seeded value alone when the row simply has no such cell', async () => { + const { rows } = await stageRows([['Chi']], mapping, columns, { + newRow: () => ({ salary: 42 }) + }) + + expect(rows[0]!.salary).toBe(42) + expect(rows[0]!.name).toBe('Chi') + }) + + it('writes null when the cell is there and empty, which is what the file said', async () => { + const { rows } = await stageRows([['Chi', '', '', '', '']], mapping, columns, { + newRow: () => ({ salary: 42 }) + }) + + expect(rows[0]!.salary).toBeNull() + }) + + it('seeds each row with what the app supplies', async () => { + const { rows } = await stageRows([['Chi', 'chi@x.vn', '', '', '']], mapping, columns, { + newRow: (index) => ({ id: index + 1 }) + }) + + expect(rows[0]!.id).toBe(1) + }) + + it('leaves a formula-looking cell as text rather than running anything', async () => { + const { rows, problems } = await stageRows( + [['=cmd|/c calc', 'x@y.z', '', '', '']], + mapping, + columns + ) + + expect(problems).toEqual([]) + expect(rows[0]!.name).toBe('=cmd|/c calc') + }) + + it('waits for a schema that answers asynchronously', async () => { + const slow: ColumnDef[] = [ + { + id: 'name', + header: 'Full name', + schema: { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value: unknown) => + String(value) === 'Chi' ? { value } : { issues: [{ message: 'nope' }] } + } + } as never + } + ] + const mapped = guessMapping(slow, ['Full name']) + + expect((await stageRows([['Chi']], mapped, slow)).problems).toEqual([]) + expect((await stageRows([['An']], mapped, slow)).problems[0]?.message).toBe('nope') + }) +}) + +describe('counting what went wrong', () => { + it('lists the rows that hold a problem', () => { + expect([ + ...problemRows([ + { rowIndex: 2, columnId: 'a', kind: 'invalid', message: 'x' }, + { rowIndex: 2, columnId: 'b', kind: 'invalid', message: 'x' }, + { rowIndex: 5, columnId: 'a', kind: 'number', message: 'y' } + ]) + ]).toEqual([2, 5]) + }) + + it('groups them by what they say, worst first', () => { + expect( + problemsByMessage([ + { rowIndex: 1, columnId: 'a', kind: 'invalid', message: 'rare' }, + { rowIndex: 2, columnId: 'a', kind: 'invalid', message: 'common' }, + { rowIndex: 3, columnId: 'a', kind: 'invalid', message: 'common' } + ]) + ).toEqual([ + { message: 'common', count: 2 }, + { message: 'rare', count: 1 } + ]) + }) +}) diff --git a/src/lib/features/data-import/stage.ts b/src/lib/features/data-import/stage.ts new file mode 100644 index 0000000..9e22bb7 --- /dev/null +++ b/src/lib/features/data-import/stage.ts @@ -0,0 +1,171 @@ +import { type ColumnDef } from '../../core/types/index.js' +import { coerce, type DecimalMark, kindOfColumn } from './coerce.js' +import type { ColumnMapping, SourceValue } from './mapping.js' + +export type ProblemKind = 'number' | 'date' | 'boolean' | 'invalid' | 'duplicate' | 'existing' + +export interface ImportProblem { + rowIndex: number + columnId: string + kind: ProblemKind + message: string +} + +export interface StagedRows { + rows: TRow[] + problems: ImportProblem[] +} + +export interface StageOptions { + newRow?: (index: number) => Partial + messageFor?: (kind: ProblemKind, column: ColumnDef) => string | undefined + decimal?: DecimalMark +} + +interface Checked { + error: string | null + value: unknown +} + +async function check(value: unknown, row: TRow, column: ColumnDef): Promise { + if (column.validate) return { error: column.validate(value, row), value } + if (!column.schema) return { error: null, value } + + const result = await column.schema['~standard'].validate(value) + const issues = result.issues + if (issues && issues.length > 0) return { error: issues[0]!.message, value } + return { error: null, value: (result as { value: unknown }).value } +} + +function defaultMessage(kind: ProblemKind, header: string): string { + if (kind === 'number') return `${header} is not a number` + if (kind === 'date') return `${header} is not a date` + if (kind === 'boolean') return `${header} is not a yes or a no` + if (kind === 'duplicate') return `${header} appears more than once in this file` + if (kind === 'existing') return `${header} is already in this grid` + return `${header} is not valid` +} + +interface RowContext { + columns: Map> + used: ColumnMapping[] + options: StageOptions +} + +function unreadable( + rowIndex: number, + column: ColumnDef, + kind: ProblemKind, + options: StageOptions +): ImportProblem { + return { + rowIndex, + columnId: column.id, + kind, + message: + options.messageFor?.(kind, column) ?? + defaultMessage(kind, String(column.header ?? column.id)) + } +} + +function readCells( + line: SourceValue[], + rowIndex: number, + context: RowContext +): { row: Record; problems: ImportProblem[] } { + const row = { ...(context.options.newRow?.(rowIndex) ?? {}) } as Record + const problems: ImportProblem[] = [] + + for (const mapping of context.used) { + const column = context.columns.get(mapping.columnId) + if (!column) continue + + const at = mapping.sourceIndex! + if (at >= line.length) continue + + const raw = line[at] ?? null + const { value, problem } = coerce(raw, kindOfColumn(column), context.options.decimal) + + if (!problem) { + row[column.id] = value + continue + } + + row[column.id] = raw === null ? null : String(raw) + problems.push(unreadable(rowIndex, column, problem, context.options)) + } + + return { row, problems } +} + +async function validateCells( + row: Record, + rowIndex: number, + unreadable: Set, + context: RowContext +): Promise { + const problems: ImportProblem[] = [] + const parsed = new Map() + + for (const mapping of context.used) { + const column = context.columns.get(mapping.columnId) + if (!column || (!column.schema && !column.validate)) continue + if (unreadable.has(column.id)) continue + + const checked = await check(row[column.id], row as TRow, column) + if (checked.error !== null) { + problems.push({ + rowIndex, + columnId: column.id, + kind: 'invalid', + message: checked.error + }) + continue + } + parsed.set(column.id, checked.value) + } + + for (const [columnId, value] of parsed) row[columnId] = value + return problems +} + +export async function stageRows( + source: SourceValue[][], + mappings: ColumnMapping[], + columns: ColumnDef[], + options: StageOptions = {} +): Promise> { + const context: RowContext = { + columns: new Map(columns.map((column) => [column.id, column])), + used: mappings.filter((mapping) => mapping.sourceIndex !== null), + options + } + + const rows: TRow[] = [] + const problems: ImportProblem[] = [] + + for (let rowIndex = 0; rowIndex < source.length; rowIndex++) { + const read = readCells(source[rowIndex]!, rowIndex, context) + const unreadable = new Set(read.problems.map((problem) => problem.columnId)) + + problems.push(...read.problems) + problems.push(...(await validateCells(read.row, rowIndex, unreadable, context))) + rows.push(read.row as TRow) + } + + return { rows, problems } +} + +export function problemRows(problems: ImportProblem[]): Set { + return new Set(problems.map((problem) => problem.rowIndex)) +} + +export function problemsByMessage(problems: ImportProblem[]): { message: string; count: number }[] { + const counts = new Map() + for (const problem of problems) { + counts.set(problem.message, (counts.get(problem.message) ?? 0) + 1) + } + return [...counts.entries()] + .map(([message, count]) => ({ message, count })) + .sort((a, b) => b.count - a.count || a.message.localeCompare(b.message)) +} diff --git a/src/lib/features/data-import/unzip.ts b/src/lib/features/data-import/unzip.ts new file mode 100644 index 0000000..6a8fbe2 --- /dev/null +++ b/src/lib/features/data-import/unzip.ts @@ -0,0 +1,183 @@ +export interface ArchiveEntry { + name: string + method: number + offset: number + compressedSize: number + size: number +} + +export interface UnzipLimits { + maxEntrySize?: number + maxTotalSize?: number +} + +export const DEFAULT_MAX_ENTRY = 64 * 1024 * 1024 +export const DEFAULT_MAX_TOTAL = 256 * 1024 * 1024 + +const EOCD = 0x06054b50 +const CENTRAL = 0x02014b50 +const LOCAL = 0x04034b50 +const STORED = 0 +const DEFLATED = 8 + +function u16(bytes: Uint8Array, at: number): number { + return bytes[at]! | (bytes[at + 1]! << 8) +} + +function u32(bytes: Uint8Array, at: number): number { + return ( + (bytes[at]! | (bytes[at + 1]! << 8) | (bytes[at + 2]! << 16) | (bytes[at + 3]! << 24)) >>> 0 + ) +} + +function findEndOfDirectory(bytes: Uint8Array): number { + const earliest = Math.max(0, bytes.length - 0xffff - 22) + for (let at = bytes.length - 22; at >= earliest; at--) { + if (u32(bytes, at) === EOCD) return at + } + throw new Error('This is not a zip archive: it has no end-of-directory record.') +} + +export function readDirectory(bytes: Uint8Array): ArchiveEntry[] { + const end = findEndOfDirectory(bytes) + const count = u16(bytes, end + 10) + let at = u32(bytes, end + 16) + + const entries: ArchiveEntry[] = [] + const decoder = new TextDecoder() + + for (let index = 0; index < count; index++) { + if (u32(bytes, at) !== CENTRAL) break + + const nameLength = u16(bytes, at + 28) + const extraLength = u16(bytes, at + 30) + const commentLength = u16(bytes, at + 32) + + entries.push({ + name: decoder.decode(bytes.subarray(at + 46, at + 46 + nameLength)), + method: u16(bytes, at + 10), + compressedSize: u32(bytes, at + 20), + size: u32(bytes, at + 24), + offset: u32(bytes, at + 42) + }) + + at += 46 + nameLength + extraLength + commentLength + } + + return entries +} + +async function inflate(bytes: Uint8Array, name: string, limit: number): Promise { + const stream = new Blob([bytes as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream('deflate-raw')) + + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + + total += value.length + if (total > limit) { + await reader.cancel() + throw new RangeError( + `"${name}" keeps unpacking past the ${limit.toLocaleString('en-US')} bytes ` + + 'this reads. An archive that unpacks to far more than it claims is the ' + + 'shape of a zip bomb, and reading the rest of it is the attack.' + ) + } + chunks.push(value) + } + } finally { + reader.releaseLock() + } + + const out = new Uint8Array(total) + let at = 0 + for (const chunk of chunks) { + out.set(chunk, at) + at += chunk.length + } + return out +} + +export async function readEntry( + bytes: Uint8Array, + entry: ArchiveEntry, + limits: UnzipLimits = {} +): Promise { + const maxEntry = limits.maxEntrySize ?? DEFAULT_MAX_ENTRY + if (entry.size > maxEntry) { + throw new RangeError( + `"${entry.name}" unpacks to ${entry.size.toLocaleString('en-US')} bytes, past the ` + + `${maxEntry.toLocaleString('en-US')} this reads. A small archive claiming a huge ` + + 'entry is the shape of a zip bomb.' + ) + } + + if (entry.size === 0xffffffff || entry.compressedSize === 0xffffffff) { + throw new Error( + `"${entry.name}" is stored in the zip64 form, which this reader does not read. ` + + 'Save the workbook again from a spreadsheet that writes the plain form.' + ) + } + + if (u32(bytes, entry.offset) !== LOCAL) { + throw new Error(`"${entry.name}" does not start where the directory says it does.`) + } + + const nameLength = u16(bytes, entry.offset + 26) + const extraLength = u16(bytes, entry.offset + 28) + const start = entry.offset + 30 + nameLength + extraLength + const raw = bytes.subarray(start, start + entry.compressedSize) + + if (entry.method === STORED) return raw + if (entry.method !== DEFLATED) { + throw new Error( + `"${entry.name}" uses compression method ${entry.method}, which is not read.` + ) + } + + return inflate(raw, entry.name, maxEntry) +} + +export async function readArchive( + bytes: Uint8Array, + wanted: (name: string) => boolean, + limits: UnzipLimits = {} +): Promise> { + const maxTotal = limits.maxTotalSize ?? DEFAULT_MAX_TOTAL + const entries = readDirectory(bytes).filter((entry) => wanted(entry.name)) + + const declared = entries.reduce((sum, entry) => sum + entry.size, 0) + if (declared > maxTotal) { + throw new RangeError( + `The archive declares ${declared.toLocaleString('en-US')} bytes of content, past the ` + + `${maxTotal.toLocaleString('en-US')} this reads at once.` + ) + } + + const decoder = new TextDecoder() + const out = new Map() + let unpacked = 0 + + for (const entry of entries) { + const room = Math.min(limits.maxEntrySize ?? DEFAULT_MAX_ENTRY, maxTotal - unpacked) + if (room <= 0) { + throw new RangeError( + `The archive unpacks past the ${maxTotal.toLocaleString('en-US')} bytes this ` + + 'reads at once, whatever its directory claims.' + ) + } + + const data = await readEntry(bytes, entry, { ...limits, maxEntrySize: room }) + unpacked += data.length + out.set(entry.name, decoder.decode(data)) + } + return out +} diff --git a/src/lib/features/data-import/xlsx-read.test.ts b/src/lib/features/data-import/xlsx-read.test.ts new file mode 100644 index 0000000..fa5f811 --- /dev/null +++ b/src/lib/features/data-import/xlsx-read.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { BUILT_IN_STYLES, createWorkbook, StyleTable } from '../xlsx/index.js' +import { readDirectory } from './unzip.js' +import { + columnIndexOf, + readDateStyles, + readSharedStrings, + readSheet, + readXlsx, + serialToDate, + sheetNames, + unescapeXml +} from './xlsx-read.js' + +describe('reading back what the writer wrote', () => { + it('reads a date the writer marked with no style as the number it is', async () => { + const workbook = await createWorkbook({ + sheetName: 'Data', + columns: [{ header: 'When' }], + rows: [[new Date(2026, 2, 2)]] + }) + + const { rows } = await readXlsx(workbook) + expect(typeof rows[1]?.[0]).toBe('number') + }) + + it('returns the same grid of values', async () => { + const workbook = await createWorkbook({ + sheetName: 'Data', + columns: [{ header: 'Name' }, { header: 'Qty' }, { header: 'When' }], + rows: [ + ['Chi', 3, new Date(2026, 2, 2)], + ['An "quoted"', 0, new Date(2026, 11, 31)], + ['Bình, có dấu', -12.5, null] + ] + }) + + const { rows, sheets } = await readXlsx(workbook) + + expect(sheets).toEqual(['Data']) + expect(rows[0]).toEqual(['Name', 'Qty', 'When']) + expect(rows[1]?.[0]).toBe('Chi') + expect(rows[1]?.[1]).toBe(3) + expect(rows[2]?.[0]).toBe('An "quoted"') + expect(rows[2]?.[1]).toBe(0) + expect(rows[3]?.[0]).toBe('Bình, có dấu') + expect(rows[3]?.[1]).toBe(-12.5) + }) + + it('brings a date back as a date, not as a number', async () => { + const styles = new StyleTable() + const workbook = await createWorkbook({ + sheetName: 'Data', + columns: [{ header: 'When' }], + rows: [[new Date(2026, 2, 2)]], + datetimeStyle: styles.add(BUILT_IN_STYLES.datetime), + styles + }) + + const { rows } = await readXlsx(workbook) + const value = rows[1]?.[0] + + expect(value).toBeInstanceOf(Date) + expect(`${(value as Date).getFullYear()}-${(value as Date).getMonth() + 1}`).toBe('2026-3') + }) + + it('names the sheet it could not find', async () => { + const workbook = await createWorkbook({ + sheetName: 'Only', + columns: [{ header: 'A' }], + rows: [['x']] + }) + + await expect(readXlsx(workbook, { sheet: 'Missing' })).rejects.toThrow(/no sheet called/) + }) + + it('reads a named sheet rather than the first', async () => { + const workbook = await createWorkbook([ + { sheetName: 'First', columns: [{ header: 'A' }], rows: [['one']] }, + { sheetName: 'Second', columns: [{ header: 'A' }], rows: [['two']] } + ]) + + const { rows } = await readXlsx(workbook, { sheet: 'Second' }) + expect(rows[1]?.[0]).toBe('two') + }) + + it('leaves a zip it wrote readable by the directory it wrote', async () => { + const workbook = await createWorkbook({ + sheetName: 'A', + columns: [{ header: 'A' }], + rows: [['x']] + }) + + const names = readDirectory(workbook).map((entry) => entry.name) + expect(names).toContain('xl/workbook.xml') + expect(names).toContain('xl/worksheets/sheet1.xml') + }) +}) + +describe('the pieces of a worksheet', () => { + it('turns a column reference into an index', () => { + expect(columnIndexOf('A1')).toBe(0) + expect(columnIndexOf('Z9')).toBe(25) + expect(columnIndexOf('AA1')).toBe(26) + expect(columnIndexOf('AB12')).toBe(27) + }) + + it('places a cell by its reference, not by its position', () => { + const xml = '01' + expect(readSheet(xml, ['left', 'right'])).toEqual([['left', null, 'right']]) + }) + + it('keeps a column when the cell before it is empty and closes itself', () => { + const xml = '2' + expect(readSheet(xml, [])).toEqual([[null, 2]]) + }) + + it('keeps two empty cells apart rather than swallowing what follows', () => { + const xml = '0' + expect(readSheet(xml, ['third'])).toEqual([[null, null, 'third']]) + }) + + it('reads a shared string, an inline one and a boolean', () => { + const xml = + '0' + + 'inline' + + '1' + expect(readSheet(xml, ['shared'])).toEqual([['shared', 'inline', true]]) + }) + + it('reads a rich string as the text a person sees', () => { + const xml = 'Bìnhplain' + expect(readSharedStrings(xml)).toEqual(['Bình', 'plain']) + }) + + it('unescapes the five entities and numeric references', () => { + expect(unescapeXml('a&b<c>d"e'fAB')).toBe('a&bd"e\'fAB') + }) + + it('turns a serial into the date a spreadsheet shows', () => { + expect(serialToDate(1).toISOString().slice(0, 10)).toBe('1899-12-31') + expect(serialToDate(46083).toISOString().slice(0, 10)).toBe('2026-03-02') + }) + + it('reads which styles mean a date', () => { + const styles = + '' + + '' + expect([...readDateStyles(styles)]).toEqual([1, 2]) + }) + + it('does not read a money format as a date because it holds an m', () => { + const styles = + '' + + '' + expect([...readDateStyles(styles)]).toEqual([]) + }) + + it('lists the sheets a workbook declares', () => { + expect(sheetNames('')).toEqual([ + 'A', + 'B & C' + ]) + }) +}) + +describe('what the reader refuses', () => { + it('refuses a document that declares entities', () => { + expect(() => readSheet(']>', [])).toThrow(/DOCTYPE/) + expect(() => readSharedStrings('')).toThrow(/DOCTYPE/) + }) + + it('leaves an entity outside Unicode as the text it is, rather than throwing', () => { + expect(unescapeXml('�')).toBe('�') + expect(unescapeXml('�')).toBe('�') + expect(unescapeXml('😀')).toBe('😀') + }) + + it('refuses something that is not a zip at all', () => { + expect(() => readDirectory(new Uint8Array([1, 2, 3, 4]))).toThrow(/not a zip/) + }) +}) diff --git a/src/lib/features/data-import/xlsx-read.ts b/src/lib/features/data-import/xlsx-read.ts new file mode 100644 index 0000000..eb7da2c --- /dev/null +++ b/src/lib/features/data-import/xlsx-read.ts @@ -0,0 +1,255 @@ +import { readArchive } from './unzip.js' + +export interface SheetCell { + value: string | number | boolean | Date | null +} + +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'" +} + +const EXCEL_EPOCH = Date.UTC(1899, 11, 30) +const MS_PER_DAY = 86_400_000 + +const MAX_CODE_POINT = 0x10ffff + +function fromCode(code: number, whole: string): string { + if (!Number.isFinite(code) || code < 0 || code > MAX_CODE_POINT) return whole + return String.fromCodePoint(code) +} + +export function unescapeXml(text: string): string { + return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, entity: string) => { + if (entity.startsWith('#x') || entity.startsWith('#X')) { + return fromCode(Number.parseInt(entity.slice(2), 16), whole) + } + if (entity.startsWith('#')) return fromCode(Number.parseInt(entity.slice(1), 10), whole) + return ENTITIES[entity] ?? whole + }) +} + +export function refuseDoctype(xml: string, what: string): void { + if (/]*)?>([\s\S]*?)<\/t>|]*)?\/>/g + let match = pattern.exec(node) + while (match) { + out += unescapeXml(match[1] ?? '') + match = pattern.exec(node) + } + return out +} + +export function readSharedStrings(xml: string | undefined): string[] { + if (!xml) return [] + refuseDoctype(xml, 'sharedStrings.xml') + + const strings: string[] = [] + const pattern = /]*)?>([\s\S]*?)<\/si>|]*)?\/>/g + let match = pattern.exec(xml) + while (match) { + strings.push(match[1] === undefined ? '' : textOf(match[1])) + match = pattern.exec(xml) + } + return strings +} + +export function columnIndexOf(reference: string): number { + let index = 0 + for (const char of reference) { + const code = char.charCodeAt(0) + if (code < 65 || code > 90) break + index = index * 26 + (code - 64) + } + return index - 1 +} + +export function serialToDate(serial: number): Date { + return new Date(EXCEL_EPOCH + Math.round(serial * MS_PER_DAY)) +} + +interface CellContext { + type: string | undefined + style: number | undefined + strings: string[] + dateStyles: Set +} + +function typedValue(text: string, context: CellContext): SheetCell['value'] | undefined { + if (context.type === 's') return context.strings[Number(text)] ?? '' + if (context.type === 'str') return text + if (context.type === 'b') return text === '1' + if (context.type === 'e') return null + return undefined +} + +function cellValue(cell: string, context: CellContext): SheetCell['value'] { + if (context.type === 'inlineStr') return textOf(cell) + + const raw = /]*)?>([\s\S]*?)<\/v>/.exec(cell)?.[1] + if (raw === undefined) return null + + const text = unescapeXml(raw) + const typed = typedValue(text, context) + if (typed !== undefined) return typed + + const numeric = Number(text) + if (!Number.isFinite(numeric)) return text + if (context.style !== undefined && context.dateStyles.has(context.style)) { + return serialToDate(numeric) + } + return numeric +} + +function customDateFormats(stylesXml: string): Set { + const ids = new Set([14, 15, 16, 17, 22, 45, 46, 47]) + const pattern = /]*numFmtId="(\d+)"[^>]*formatCode="([^"]*)"/g + + let match = pattern.exec(stylesXml) + while (match) { + const code = unescapeXml(match[2] ?? '') + if (/[dmyhs]/i.test(code) && !/[#0]/.test(code.replace(/\[[^\]]*\]/g, ''))) { + ids.add(Number(match[1])) + } + match = pattern.exec(stylesXml) + } + return ids +} + +export function readDateStyles(stylesXml: string | undefined): Set { + const styles = new Set() + if (!stylesXml) return styles + refuseDoctype(stylesXml, 'styles.xml') + + const dateFormats = customDateFormats(stylesXml) + const cellXfs = /]*>([\s\S]*?)<\/cellXfs>/.exec(stylesXml)?.[1] ?? '' + const pattern = /]*>/g + + let index = 0 + let entry = pattern.exec(cellXfs) + while (entry) { + const id = Number(/numFmtId="(\d+)"/.exec(entry[0])?.[1] ?? '0') + if (dateFormats.has(id)) styles.add(index) + index += 1 + entry = pattern.exec(cellXfs) + } + return styles +} + +function readRow(body: string, strings: string[], dateStyles: Set): SheetCell['value'][] { + const cells: SheetCell['value'][] = [] + const pattern = /]*?)(?:\/>|>([\s\S]*?)<\/c>)/g + + let match = pattern.exec(body) + while (match) { + const attributes = match[1] ?? '' + const reference = /r="([A-Z]+)\d+"/.exec(attributes)?.[1] + const at = reference ? columnIndexOf(reference) : cells.length + const styleText = /s="(\d+)"/.exec(attributes)?.[1] + + while (cells.length < at) cells.push(null) + cells[at] = cellValue(match[2] ?? '', { + type: /t="([^"]+)"/.exec(attributes)?.[1], + style: styleText === undefined ? undefined : Number(styleText), + strings, + dateStyles + }) + + match = pattern.exec(body) + } + + return cells +} + +export interface SheetLimits { + dateStyles?: Set + maxRows?: number +} + +export const DEFAULT_SHEET_MAX_ROWS = 200_000 + +export function readSheet( + xml: string, + strings: string[], + limits: SheetLimits = {} +): SheetCell['value'][][] { + refuseDoctype(xml, 'The worksheet') + + const dateStyles = limits.dateStyles ?? new Set() + const maxRows = limits.maxRows ?? DEFAULT_SHEET_MAX_ROWS + + const rows: SheetCell['value'][][] = [] + const pattern = /]*>([\s\S]*?)<\/row>|]*\/>/g + + let match = pattern.exec(xml) + while (match) { + rows.push(readRow(match[1] ?? '', strings, dateStyles)) + if (rows.length > maxRows) { + throw new RangeError( + `The worksheet holds more than ${maxRows.toLocaleString('en-US')} rows, which is ` + + 'more than this reads in one go. Split it, or raise maxRows.' + ) + } + match = pattern.exec(xml) + } + + while (rows.length > 0 && rows[rows.length - 1]!.every((cell) => cell === null)) rows.pop() + return rows +} + +export function sheetNames(workbookXml: string | undefined): string[] { + if (!workbookXml) return [] + refuseDoctype(workbookXml, 'workbook.xml') + + const names: string[] = [] + const pattern = /]*name="([^"]*)"/g + let match = pattern.exec(workbookXml) + while (match) { + names.push(unescapeXml(match[1] ?? '')) + match = pattern.exec(workbookXml) + } + return names +} + +export async function readXlsx( + bytes: Uint8Array, + options: { sheet?: string; maxRows?: number } = {} +): Promise<{ rows: SheetCell['value'][][]; sheets: string[] }> { + const wanted = (name: string) => + name === 'xl/workbook.xml' || + name === 'xl/sharedStrings.xml' || + name === 'xl/styles.xml' || + /^xl\/worksheets\/sheet\d+\.xml$/.test(name) + + const files = await readArchive(bytes, wanted) + const sheets = sheetNames(files.get('xl/workbook.xml')) + const strings = readSharedStrings(files.get('xl/sharedStrings.xml')) + const dateStyles = readDateStyles(files.get('xl/styles.xml')) + + const index = options.sheet ? sheets.indexOf(options.sheet) : 0 + if (index < 0) { + throw new Error( + `This workbook has no sheet called "${options.sheet}". It has: ${sheets.join(', ')}.` + ) + } + + const xml = files.get(`xl/worksheets/sheet${index + 1}.xml`) + if (!xml) throw new Error('This workbook has no readable worksheet.') + + return { + rows: readSheet(xml, strings, { dateStyles, maxRows: options.maxRows }), + sheets + } +} diff --git a/src/lib/features/find-replace/find-replace.svelte.ts b/src/lib/features/find-replace/find-replace.svelte.ts new file mode 100644 index 0000000..84dfc62 --- /dev/null +++ b/src/lib/features/find-replace/find-replace.svelte.ts @@ -0,0 +1,220 @@ +import { type CellRead, gateReader, type GridState, isDataRow } from '../../core/grid/index.js' +import { + type CellDecoration, + type EditTransaction, + type GridFeature +} from '../../core/types/index.js' +import { getPagination } from '../../features/pagination/index.js' +import { slotClass } from '../../core/theme/index.js' +import { findMatches, type Match, replaceIn } from './find.js' +import type { FindReplaceOptions } from './find-replace.types.js' + +export const FIND_REPLACE = 'findReplace' + +export class FindReplace { + open = $state(false) + query = $state('') + replacement = $state('') + caseSensitive = $state(false) + wholeCell = $state(false) + + current = $state(-1) + + replaced = $state(null) + + readonly replace: boolean + readonly hotkeys: boolean + readonly serverReplace: boolean + + #grid: GridState + #gate: CellRead + #matchClass = $derived(slotClass('findMatch')) + #currentClass = $derived(slotClass('findMatchCurrent')) + + constructor(grid: GridState, options: FindReplaceOptions) { + this.#grid = grid + this.#gate = gateReader(grid, 'search') + this.replace = options.replace ?? true + this.hotkeys = options.hotkeys ?? true + this.serverReplace = options.serverReplace ?? false + } + + matches: Match[] = $derived(this.#scan()) + + #scan(): Match[] { + if (!this.open) return [] + return findMatches(this.#grid.preWindowNodes, this.#grid.columns.visible, this.query, { + read: this.#gate, + caseSensitive: this.caseSensitive, + wholeCell: this.wholeCell, + isDataNode: isDataRow + }) + } + + get total(): number { + return this.matches.length + } + + get partial(): boolean { + return this.#grid.rowModel === 'server' + } + + get replaceAvailable(): boolean { + if (!this.replace) return false + return !this.partial || this.serverReplace + } + + #writable(match: Match): boolean { + const editable = this.#grid.columns.visible[match.col]?.def.editable + return editable !== undefined && editable !== false + } + + writableTotal: number = $derived(this.#countWritable()) + + #countWritable(): number { + let count = 0 + for (const match of this.matches) if (this.#writable(match)) count++ + return count + } + + get canReplaceCurrent(): boolean { + const match = this.active + return this.replaceAvailable && match !== null && this.#writable(match) + } + + get canReplaceAll(): boolean { + return this.replaceAvailable && this.writableTotal > 0 + } + + get active(): Match | null { + return this.matches[this.current] ?? null + } + + show = (): void => { + this.open = true + this.replaced = null + } + + hide = (): void => { + this.open = false + this.current = -1 + this.replaced = null + } + + step = (delta: number): void => { + const total = this.total + if (total === 0) { + this.current = -1 + return + } + + const from = this.current === -1 ? (delta > 0 ? -1 : 0) : this.current + this.current = (((from + delta) % total) + total) % total + this.replaced = null + this.#reveal(this.matches[this.current]!) + } + + #reveal(match: Match): void { + const pagination = getPagination(this.#grid) + if (pagination?.pageSize) { + const page = Math.floor(match.row / pagination.pageSize) + 1 + if (page !== pagination.page) pagination.setPage(page) + } + this.#grid.api.ensureVisible?.(match.row) + this.#grid.focus.focusCell({ row: match.row, col: match.col }) + } + + #editsFor(matches: Match[]): EditTransaction[] { + const byRow: Record> = {} + for (const match of matches) { + const node = this.#grid.preWindowNodes[match.row] + const column = this.#grid.columns.visible[match.col] + if (!node || !column) continue + + const next = replaceIn(this.#gate(node, column.def), this.query, this.replacement, { + caseSensitive: this.caseSensitive, + wholeCell: this.wholeCell + }) + if (next === null) continue + ;(byRow[node.id] ??= {})[column.id] = next + } + return Object.entries(byRow).map(([rowId, changes]) => ({ rowId, changes })) + } + + replaceCurrent = (): boolean | Promise => { + const match = this.active + if (!this.replaceAvailable || !match) return false + return this.#apply(this.#editsFor([match])) + } + + replaceAll = (): boolean | Promise => { + if (!this.replaceAvailable) return false + return this.#apply(this.#editsFor(this.matches)) + } + + #apply(edits: EditTransaction[]): boolean | Promise { + const applyEdits = this.#grid.api.applyEdits + this.replaced = null + if (!applyEdits || edits.length === 0) return false + + const cells = edits.reduce((total, edit) => total + Object.keys(edit.changes).length, 0) + const result = applyEdits(edits) + if (result instanceof Promise) { + return result.then((ok) => { + if (ok) this.replaced = cells + return ok + }) + } + if (result) this.replaced = cells + return result + } + + #matchKeys: Record = $derived( + Object.fromEntries(this.matches.map((match) => [`${match.row}:${match.col}`, true])) + ) + + cellDecoration = (row: number, col: number): CellDecoration | undefined => { + if (!this.open) return undefined + + const active = this.active + if (active?.row === row && active.col === col) return { class: this.#currentClass } + return this.#matchKeys[`${row}:${col}`] ? { class: this.#matchClass } : undefined + } +} + +function get(grid: GridState): FindReplace | undefined { + return grid.feature>(FIND_REPLACE) +} + +export function findReplace(options: FindReplaceOptions = {}): GridFeature { + return { + id: FIND_REPLACE, + createState: (grid) => new FindReplace(grid, options), + createApi: (grid) => { + const state = get(grid)! + return { + openFind: state.show, + closeFind: state.hide, + findNext: () => state.step(1), + findPrevious: () => state.step(-1), + replaceAllMatches: state.replaceAll + } + }, + cellDecoration: ({ grid, rowIndex, colIndex }) => + get(grid)?.cellDecoration(rowIndex, colIndex) + } +} + +export function getFindReplace(grid: GridState): FindReplace | undefined { + return get(grid) +} + +declare module '../../core/types/api.js' { + interface GridApi { + openFind?: () => void + closeFind?: () => void + findNext?: () => void + findPrevious?: () => void + replaceAllMatches?: () => boolean | Promise + } +} diff --git a/src/lib/features/find-replace/find-replace.test.ts b/src/lib/features/find-replace/find-replace.test.ts new file mode 100644 index 0000000..784d4bb --- /dev/null +++ b/src/lib/features/find-replace/find-replace.test.ts @@ -0,0 +1,272 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it } from 'vitest' +import { grouping } from '../grouping/index.js' +import { tree } from '../tree/index.js' +import { findReplace, getFindReplace } from './find-replace.svelte.js' +import type { FindReplaceOptions } from './find-replace.types.js' + +interface Row { + id: number + dept: string + city: string + note: string +} + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept' }, + { id: 'city', header: 'City', editable: true, filter: 'text' }, + { id: 'note', header: 'Note', editable: true } +] + +function makeData(): Row[] { + return [ + { id: 1, dept: 'Core', city: 'Hà Nội', note: 'ship 2026' }, + { id: 2, dept: 'Core', city: 'Đà Nẵng', note: 'hold 2026' }, + { id: 3, dept: 'Data', city: 'Hà Nội', note: 'ship 2025' } + ] +} + +function makeGrid(options: FindReplaceOptions = {}, extra: unknown[] = []): GridState { + return createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), editing(), ...(extra as never[]), findReplace(options)] + }) +} + +function open(grid: GridState, query: string) { + const state = getFindReplace(grid)! + state.show() + state.query = query + return state +} + +describe('finding', () => { + it('finds nothing until it is open, so a closed panel costs nothing', () => { + const grid = makeGrid() + const state = getFindReplace(grid)! + state.query = 'Hà Nội' + expect(state.total).toBe(0) + + state.show() + expect(state.total).toBe(2) + }) + + it('steps forward and wraps at the end', () => { + const state = open(makeGrid(), 'Hà Nội') + expect(state.current).toBe(-1) + + state.step(1) + expect(state.active).toMatchObject({ rowId: '1', columnId: 'city' }) + state.step(1) + expect(state.active).toMatchObject({ rowId: '3', columnId: 'city' }) + state.step(1) + expect(state.active).toMatchObject({ rowId: '1', columnId: 'city' }) + }) + + it('steps backward from nowhere to the last match, not the first', () => { + const state = open(makeGrid(), 'Hà Nội') + state.step(-1) + expect(state.active).toMatchObject({ rowId: '3' }) + + state.step(-1) + expect(state.active).toMatchObject({ rowId: '1' }) + state.step(-1) + expect(state.active).toMatchObject({ rowId: '3' }) + }) + + it('carries the grid focus to the match', () => { + const grid = makeGrid() + const state = open(grid, 'ship 2025') + state.step(1) + expect(grid.focus.active).toMatchObject({ row: 2, col: 2 }) + }) + + it('searches the rows the filter left, not the source array', () => { + const grid = makeGrid() + const state = open(grid, 'Hà Nội') + expect(state.total).toBe(2) + ;(grid.api.setQuickFilter as (query: string) => void)('Đà Nẵng') + expect(state.total).toBe(0) + }) + + it('follows the sort, so Next walks the order on screen', () => { + const grid = makeGrid() + const state = open(grid, '2026') + expect(state.matches.map((match) => match.rowId)).toEqual(['1', '2']) + ;(grid.api.setSort as (sort: unknown[]) => void)([{ columnId: 'city', direction: 'asc' }]) + expect(state.matches.map((match) => match.rowId)).toEqual(['2', '1']) + }) + + it('finds a row a nested tree holds as a child, which the scan used to skip', () => { + interface Node { + id: number + dept: string + city: string + note: string + reports?: Node[] + } + const nested: Node[] = [ + { + id: 1, + dept: 'Core', + city: 'Hà Nội', + note: 'ship 2026', + reports: [{ id: 2, dept: 'Core', city: 'Cần Thơ', note: 'ship 2026' }] + } + ] + const grid = createDataGrid({ + columns: columns as unknown as ColumnDef[], + data: nested, + getRowId: (row) => String(row.id), + features: [ + sorting(), + tree({ getChildren: (row) => row.reports, defaultExpandedDepth: 2 }), + findReplace() + ] + }) + const state = getFindReplace(grid)! + state.show() + state.query = 'Cần Thơ' + + expect(state.matches.map((match) => match.rowId)).toEqual(['2']) + }) + + it('never lands on a group header, which is not a row anything can write', () => { + const grid = makeGrid({}, [grouping({ by: ['dept'] })]) + const state = open(grid, 'Core') + + expect(state.matches.every((match) => grid.nodeById(match.rowId) !== undefined)).toBe(true) + }) +}) + +describe('replacing', () => { + it('rewrites only the match it is standing on', () => { + const grid = makeGrid() + const state = open(grid, 'Hà Nội') + state.replacement = 'Huế' + state.step(1) + + expect(state.replaceCurrent()).toBe(true) + expect(grid.data.map((row) => row.city)).toEqual(['Huế', 'Đà Nẵng', 'Hà Nội']) + }) + + it('rewrites every match in one transaction, so one undo takes it back', () => { + const grid = makeGrid() + const state = open(grid, '2026') + state.replacement = '2027' + + expect(state.replaceAll()).toBe(true) + expect(grid.data.map((row) => row.note)).toEqual(['ship 2027', 'hold 2027', 'ship 2025']) + ;(grid.api.undo as () => void)() + expect(grid.data.map((row) => row.note)).toEqual(['ship 2026', 'hold 2026', 'ship 2025']) + }) + + it('leaves a column it cannot write alone, and says so rather than going quiet', () => { + const grid = makeGrid() + const state = open(grid, 'Core') + state.replacement = 'Platform' + state.step(1) + + expect(state.total).toBe(2) + expect(state.writableTotal).toBe(0) + expect(state.canReplaceCurrent).toBe(false) + expect(state.canReplaceAll).toBe(false) + + expect(state.replaceAll()).toBe(false) + expect(grid.data.map((row) => row.dept)).toEqual(['Core', 'Core', 'Data']) + expect(state.replaced).toBeNull() + }) + + it('counts only the matches a write could reach', () => { + const grid = makeGrid() + + const state = open(grid, 'Hà Nội') + expect(state.total).toBe(2) + expect(state.writableTotal).toBe(2) + expect(state.canReplaceAll).toBe(true) + }) + + it('reports how many cells a replace wrote', () => { + const grid = makeGrid() + const state = open(grid, '2026') + state.replacement = '2027' + expect(state.replaced).toBeNull() + + state.replaceAll() + expect(state.replaced).toBe(2) + }) + + it('forgets the tally as soon as the cursor moves on', () => { + const grid = makeGrid() + const state = open(grid, '2026') + state.replacement = '2027' + state.replaceAll() + expect(state.replaced).toBe(2) + + state.query = '2027' + state.step(1) + expect(state.replaced).toBeNull() + }) + + it('does nothing at all when replace is switched off', () => { + const grid = makeGrid({ replace: false }) + const state = open(grid, 'Hà Nội') + state.replacement = 'Huế' + state.step(1) + + expect(state.replaceCurrent()).toBe(false) + expect(state.replaceAll()).toBe(false) + expect(grid.data[0]!.city).toBe('Hà Nội') + }) +}) + +describe('the panel', () => { + it('forgets where it was standing when it closes', () => { + const state = open(makeGrid(), 'Hà Nội') + state.step(1) + expect(state.current).toBe(0) + + state.hide() + expect(state.open).toBe(false) + expect(state.current).toBe(-1) + }) + + it('highlights every match and marks the one in hand differently', () => { + const grid = makeGrid() + const state = open(grid, 'Hà Nội') + state.step(1) + + const first = state.cellDecoration(0, 1) + const second = state.cellDecoration(2, 1) + expect(first?.class).toBeTruthy() + expect(second?.class).toBeTruthy() + expect(first?.class).not.toBe(second?.class) + expect(state.cellDecoration(1, 1)).toBeUndefined() + }) + + it('decorates nothing while closed', () => { + const grid = makeGrid() + const state = getFindReplace(grid)! + state.query = 'Hà Nội' + expect(state.cellDecoration(0, 1)).toBeUndefined() + }) +}) + +describe('the grid api', () => { + it('drives find without the panel', () => { + const grid = makeGrid() + ;(grid.api.openFind as () => void)() + getFindReplace(grid)!.query = 'Hà Nội' + ;(grid.api.findNext as () => void)() + + expect(getFindReplace(grid)!.active).toMatchObject({ rowId: '1' }) + ;(grid.api.closeFind as () => void)() + expect(getFindReplace(grid)!.open).toBe(false) + }) +}) diff --git a/src/lib/features/find-replace/find-replace.types.ts b/src/lib/features/find-replace/find-replace.types.ts new file mode 100644 index 0000000..04944db --- /dev/null +++ b/src/lib/features/find-replace/find-replace.types.ts @@ -0,0 +1,36 @@ +export interface FindReplaceOptions { + /** + * Offer Replace alongside Find. Turning it off leaves a read-only find + * box - the panel drops its replace row and `Ctrl/⌘+H` does nothing. + * @default true + */ + replace?: boolean + + /** + * Bind `Ctrl/⌘+F` and `Ctrl/⌘+H` while `` is mounted. + * + * The listener is on `window`, not on the grid, and it calls + * `preventDefault` - which is what a grid-wide find has to do to be + * reachable at all: a shortcut scoped to the grid does nothing until the + * user has clicked into it, and the browser's own find opens instead. + * The cost is that these two keys belong to the grid for as long as the + * panel is mounted, so an app that wants them back sets this false. + * @default true + */ + hotkeys?: boolean + + /** + * Allow Replace on a grid built with `rowModel: 'server'`. + * + * Off by default, and the default is the safe one. Under a server model + * the grid holds one page, or the blocks scrolled through; an edit writes + * into that copy, and the next fetch overwrites it. Measured: replacing + * ten cells on page one, paging away and back, and every one of them is + * the value the server still has - while the panel had reported success. + * + * Turn it on only when edits reach whatever `getRows` reads from, which + * is something only the app knows. Find itself is unaffected; it searches + * what has been loaded either way, and says so. + */ + serverReplace?: boolean +} diff --git a/src/lib/features/find-replace/find.test.ts b/src/lib/features/find-replace/find.test.ts new file mode 100644 index 0000000..6eb4771 --- /dev/null +++ b/src/lib/features/find-replace/find.test.ts @@ -0,0 +1,119 @@ +import { createDataGrid, rawRead, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { findMatches, replaceIn } from './find.js' + +interface Row { + id: number + name: string + city: string + n: number +} + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + { id: 'city', header: 'City' }, + { id: 'n', header: 'N' } +] + +const data: Row[] = [ + { id: 1, name: 'Anna', city: 'Hà Nội', n: 2026 }, + { id: 2, name: 'anna', city: 'Đà Nẵng', n: 12 }, + { id: 3, name: 'Bình', city: 'Hà Nội', n: 2026 } +] + +function grid(): GridState { + return createDataGrid({ columns, data, getRowId: (row) => String(row.id) }) +} + +function scan(query: string, options = {}) { + const g = grid() + return findMatches(g.preWindowNodes, g.columns.visible, query, { + read: rawRead, + ...options + }).map((match) => `${match.row}:${match.col}`) +} + +describe('findMatches', () => { + it('finds nothing for an empty query', () => { + expect(scan('')).toEqual([]) + }) + + it('walks row-major, which is the order Next steps through', () => { + expect(scan('Hà Nội')).toEqual(['0:1', '2:1']) + }) + + it('ignores case by default and respects it on request', () => { + expect(scan('anna')).toEqual(['0:0', '1:0']) + expect(scan('anna', { caseSensitive: true })).toEqual(['1:0']) + }) + + it('matches inside a cell unless asked for the whole one', () => { + expect(scan('Hà')).toEqual(['0:1', '2:1']) + expect(scan('Hà', { wholeCell: true })).toEqual([]) + expect(scan('Hà Nội', { wholeCell: true })).toEqual(['0:1', '2:1']) + }) + + it('searches numbers as the text they read as', () => { + expect(scan('2026')).toEqual(['0:2', '2:2']) + + expect(scan('12')).toEqual(['1:2']) + }) + + it('skips nodes that are not data', () => { + const g = grid() + const found = findMatches(g.preWindowNodes, g.columns.visible, 'Hà Nội', { + read: rawRead, + isDataNode: (node) => node.id !== '1' + }) + expect(found.map((match) => match.row)).toEqual([2]) + }) + + it('reports the ids an edit needs, not only the coordinates', () => { + const g = grid() + expect( + findMatches(g.preWindowNodes, g.columns.visible, 'Bình', { read: rawRead })[0] + ).toMatchObject({ + rowId: '3', + columnId: 'name' + }) + }) +}) + +describe('replaceIn', () => { + it('returns null when there is nothing to change', () => { + expect(replaceIn('Anna', 'zz', 'x')).toBeNull() + expect(replaceIn('Anna', '', 'x')).toBeNull() + }) + + it('rewrites every occurrence in the cell', () => { + expect(replaceIn('a-a-a', 'a', 'b')).toBe('b-b-b') + }) + + it('rewrites case-insensitively while keeping the rest of the text', () => { + expect(replaceIn('Anna and ANNA', 'anna', 'Bình')).toBe('Bình and Bình') + }) + + it('honours case sensitivity', () => { + expect(replaceIn('Anna and anna', 'anna', 'x', { caseSensitive: true })).toBe('Anna and x') + }) + + it('replaces the whole cell or nothing at all', () => { + expect(replaceIn('Hà Nội', 'Hà', 'X', { wholeCell: true })).toBeNull() + expect(replaceIn('Hà Nội', 'hà nội', 'X', { wholeCell: true })).toBe('X') + }) + + it('treats the query as text, not as a pattern', () => { + expect(replaceIn('a.b.c', '.', '-')).toBe('a-b-c') + expect(replaceIn('cost (net)', '(net)', '(gross)')).toBe('cost (gross)') + }) + + it('rewrites inside a number, which is what Replace is opened for', () => { + expect(replaceIn(2026, '2026', '2027')).toBe('2027') + }) + + it('handles a blank cell without inventing text', () => { + expect(replaceIn(null, 'a', 'b')).toBeNull() + expect(replaceIn(undefined, 'a', 'b')).toBeNull() + }) +}) diff --git a/src/lib/features/find-replace/find.ts b/src/lib/features/find-replace/find.ts new file mode 100644 index 0000000..44bef2f --- /dev/null +++ b/src/lib/features/find-replace/find.ts @@ -0,0 +1,99 @@ +import type { CellRead } from '../../core/grid/index.js' +import { type ColumnState, type RowNode } from '../../core/types/index.js' + +export interface FindOptions { + caseSensitive?: boolean + + wholeCell?: boolean +} + +export interface Match { + row: number + col: number + rowId: string + columnId: string +} + +function cellText(value: unknown): string | null { + if (value === null || value === undefined) return null + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? String(value) : value.toISOString() + } + return String(value) +} + +function matcher(query: string, options: FindOptions): (text: string) => boolean { + const caseSensitive = options.caseSensitive === true + const needle = caseSensitive ? query : query.toLowerCase() + if (options.wholeCell === true) { + return (text) => (caseSensitive ? text : text.toLowerCase()) === needle + } + return (text) => (caseSensitive ? text : text.toLowerCase()).includes(needle) +} + +export function findMatches( + nodes: RowNode[], + columns: ColumnState[], + query: string, + options: FindOptions & { + /** + * How a cell is read, and not optional. A find goes through the same + * gate the cell itself does: a column the grid is masking is one whose + * values the user is not being shown, and a search that matched one + * would point at a cell they cannot read and confirm what it says. + * `rawRead` is the way to say the raw value is what you meant. + */ + read: CellRead + isDataNode?: (node: RowNode) => boolean + } +): Match[] { + if (query === '') return [] + + const { read, isDataNode = () => true } = options + const hit = matcher(query, options) + const targets = columns.map((column, col) => ({ col, id: column.id, def: column.def })) + const matches: Match[] = [] + + for (let row = 0; row < nodes.length; row++) { + const node = nodes[row] + if (!node || !isDataNode(node)) continue + + for (const target of targets) { + const text = cellText(read(node, target.def)) + if (text !== null && hit(text)) { + matches.push({ row, col: target.col, rowId: node.id, columnId: target.id }) + } + } + } + return matches +} + +export function replaceIn( + value: unknown, + query: string, + replacement: string, + options: FindOptions = {} +): string | null { + if (query === '') return null + const text = cellText(value) + if (text === null || !matcher(query, options)(text)) return null + + if (options.wholeCell === true) return replacement + if (options.caseSensitive === true) return text.split(query).join(replacement) + return replaceInsensitive(text, query, replacement) +} + +function replaceInsensitive(text: string, query: string, replacement: string): string { + const lower = text.toLowerCase() + const needle = query.toLowerCase() + + let out = '' + let at = 0 + for (;;) { + const found = lower.indexOf(needle, at) + if (found === -1) break + out += text.slice(at, found) + replacement + at = found + needle.length + } + return out + text.slice(at) +} diff --git a/src/lib/features/find-replace/index.ts b/src/lib/features/find-replace/index.ts new file mode 100644 index 0000000..2a804c1 --- /dev/null +++ b/src/lib/features/find-replace/index.ts @@ -0,0 +1,3 @@ +export { findMatches, replaceIn, type FindOptions, type Match } from './find.js' +export { FIND_REPLACE, FindReplace, findReplace, getFindReplace } from './find-replace.svelte.js' +export type { FindReplaceOptions } from './find-replace.types.js' diff --git a/src/lib/features/find-replace/server.test.ts b/src/lib/features/find-replace/server.test.ts new file mode 100644 index 0000000..825c0f9 --- /dev/null +++ b/src/lib/features/find-replace/server.test.ts @@ -0,0 +1,131 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { pagination } from '../../features/pagination/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it } from 'vitest' +import { serverRowModel } from '../server-row-model/index.js' +import { findReplace, getFindReplace } from './find-replace.svelte.js' +import type { FindReplaceOptions } from './find-replace.types.js' + +interface Row { + id: number + name: string +} + +const columns: ColumnDef[] = [{ id: 'name', header: 'Name', editable: true }] +const TOTAL = 100 + +const source = { + getRows: async ({ startRow, endRow }: { startRow: number; endRow: number }) => ({ + rows: Array.from({ length: Math.min(endRow, TOTAL) - startRow }, (_, k) => ({ + id: startRow + k + 1, + name: `Anna ${startRow + k + 1}` + })), + rowCount: TOTAL + }) +} + +function pagedGrid(options: FindReplaceOptions = {}): GridState { + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + editing(), + pagination({ pageSize: 10 }), + serverRowModel(source, { mode: 'paged' }), + findReplace(options) + ] + }) +} + +async function loaded(grid: GridState, rows: number) { + grid.feature<{ refresh: () => void }>('serverRowModel')!.refresh() + await expect.poll(() => grid.data.length).toBe(rows) + return getFindReplace(grid)! +} + +describe('find over rows the server owns', () => { + it('sees the page it has, not the hundred the server has', async () => { + const grid = pagedGrid() + const state = await loaded(grid, 10) + state.show() + state.query = 'Anna' + + expect(state.total).toBe(10) + + expect(state.partial).toBe(true) + }) + + it('sees only the blocks an infinite scroll has pulled', async () => { + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + editing(), + virtualization(), + serverRowModel(source, { + mode: 'infinite', + blockSize: 10, + placeholder: (index) => ({ id: -index - 1, name: '' }) + }), + findReplace() + ] + }) + const state = await loaded(grid, TOTAL) + state.show() + state.query = 'Anna' + + expect(grid.data).toHaveLength(TOTAL) + expect(state.total).toBe(10) + }) + + it('refuses to replace, because the next fetch would undo it', async () => { + const grid = pagedGrid() + const state = await loaded(grid, 10) + state.show() + state.query = 'Anna' + state.replacement = 'Bình' + state.step(1) + + expect(state.replaceAvailable).toBe(false) + expect(state.canReplaceAll).toBe(false) + expect(state.replaceAll()).toBe(false) + expect(state.replaceCurrent()).toBe(false) + expect(grid.data[0]!.name).toBe('Anna 1') + }) + + it('allows it when the app says its edits survive a refetch', async () => { + const grid = pagedGrid({ serverReplace: true }) + const state = await loaded(grid, 10) + state.show() + state.query = 'Anna' + state.replacement = 'Bình' + + expect(state.replaceAvailable).toBe(true) + expect(state.replaceAll()).toBe(true) + expect(grid.data[0]!.name).toBe('Bình 1') + }) + + it('is exactly the write the default is protecting against', async () => { + const grid = pagedGrid({ serverReplace: true }) + const state = await loaded(grid, 10) + state.show() + state.query = 'Anna' + state.replacement = 'Bình' + state.replaceAll() + expect(state.replaced).toBe(10) + + const setPage = grid.api.setPage as (page: number) => void + setPage(2) + await expect.poll(() => grid.data[0]!.id).toBe(11) + setPage(1) + await expect.poll(() => grid.data[0]!.id).toBe(1) + + expect(grid.data[0]!.name).toBe('Anna 1') + }) +}) diff --git a/src/lib/features/find-replace/verify.test.ts b/src/lib/features/find-replace/verify.test.ts new file mode 100644 index 0000000..2099dfa --- /dev/null +++ b/src/lib/features/find-replace/verify.test.ts @@ -0,0 +1,109 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { filtering } from '../../features/filtering/index.js' +import { pagination } from '../../features/pagination/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it } from 'vitest' +import { findReplace, getFindReplace } from './find-replace.svelte.js' + +interface Row { + id: number + name: string + city: string + note: string + ref: string +} +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', editable: true, filter: 'text' }, + { id: 'city', header: 'City', editable: true, filter: 'text' }, + { id: 'note', header: 'Note', editable: true }, + { id: 'ref', header: 'Ref' } +] +const CITIES = ['Hà Nội', 'Hà Nam', 'Đà Nẵng', 'Huế'] +const NOTES = ['ship 2026', 'hold 2026', 'ship 2025', 'review 2026'] +const seed = (): Row[] => + Array.from({ length: 24 }, (_, i) => ({ + id: i + 1, + name: ['Anna', 'anna', 'ANNA', 'Bình', 'Chi', 'Dũng'][i % 6]!, + city: CITIES[i % 4]!, + note: NOTES[i % 4]!, + ref: `REF-2026-${String(i + 1).padStart(3, '0')}` + })) + +function makeGrid(paged = false) { + return createDataGrid({ + columns, + data: seed(), + getRowId: (r) => String(r.id), + features: [ + sorting(), + filtering(), + editing(), + ...(paged ? [pagination({ pageSize: 8 })] : []), + findReplace() + ] + }) +} +function open(grid: ReturnType, q: string) { + const s = getFindReplace(grid)! + s.show() + s.query = q + return s +} + +describe('the /find checklist', () => { + it('1. matching case narrows the result', () => { + const s = open(makeGrid(), 'anna') + const loose = s.total + s.caseSensitive = true + expect(s.total).toBeLessThan(loose) + }) + it('2. whole-cell rejects a prefix that a partial match accepts', () => { + const s = open(makeGrid(), 'Hà') + const partial = s.total + s.wholeCell = true + expect(partial).toBeGreaterThan(0) + expect(s.total).toBe(0) + }) + it('5. replace-all rewrites what it can and leaves read-only columns alone', () => { + const grid = makeGrid() + const s = open(grid, '2026') + s.replacement = '2027' + s.replaceAll() + const notes = grid.data.filter((r) => r.note.includes('2027')).length + const refs = grid.data.filter((r) => r.ref.includes('2027')).length + expect(refs).toBe(0) + expect(notes).toBeGreaterThan(0) + }) + it('7. a filter that hides the matches leaves nothing to find', () => { + const grid = makeGrid() + const s = open(grid, 'Hà') + ;(grid.api.setColumnFilter as (c: string, f: unknown) => void)('city', { + kind: 'text', + op: 'equals', + value: 'Huế' + }) + expect(s.total).toBe(0) + }) + it('10. stepping to a match on another page turns to it', () => { + const grid = makeGrid(true) + const s = open(grid, 'REF-2026-020') + const page = grid.feature<{ page: number }>('pagination')! + s.step(1) + expect(page.page).toBe(3) + }) + it('11. the query is text, not a pattern', () => { + const grid = makeGrid() + grid.data = [{ id: 99, name: 'a.b', city: 'x', note: 'cost (net)', ref: 'r' }] + const s = open(grid, '(net)') + expect(s.total).toBe(1) + }) + it('12. closing clears every highlight', () => { + const grid = makeGrid() + const s = open(grid, 'Hà') + expect(s.cellDecoration(0, 1)).toBeTruthy() + s.hide() + expect(s.cellDecoration(0, 1)).toBeUndefined() + }) +}) diff --git a/src/lib/features/formula/compiled.ts b/src/lib/features/formula/compiled.ts new file mode 100644 index 0000000..c2e2369 --- /dev/null +++ b/src/lib/features/formula/compiled.ts @@ -0,0 +1,187 @@ +import { gateReader, type GridState, isLoadingRow } from '../../core/grid/index.js' +import { type ColumnState, type RowNode } from '../../core/types/index.js' +import { evaluate, FormulaError, type FormulaValue, isFormulaError } from './evaluate.js' +import type { FormulaColumn } from './formula.types.js' +import { resolveOrder } from './order.js' +import { columnsUsed, type Node, parse } from './parse.js' +import { FormulaSyntaxError } from './tokenize.js' + +export interface ParseFailure { + message: string + at: number +} + +export interface Compiled { + expression: string + onError: 'code' | 'blank' + node: Node | null + error: ParseFailure | null + dependsOn: string[] +} + +export type CompiledMap = ReadonlyMap + +export const NO_FORMULAS: CompiledMap = new Map() + +const normalize = (entry: string | FormulaColumn): FormulaColumn => + typeof entry === 'string' ? { expression: entry } : entry + +function failureOf(error: unknown): ParseFailure { + if (error instanceof FormulaSyntaxError) return { message: error.message, at: error.at } + return { message: String(error), at: 0 } +} + +export function compile(entry: string | FormulaColumn): Compiled { + const column = normalize(entry) + const onError = column.onError ?? 'code' + + try { + const node = parse(column.expression) + return { + expression: column.expression, + onError, + node, + error: null, + dependsOn: columnsUsed(node) + } + } catch (error) { + return { + expression: column.expression, + onError, + node: null, + error: failureOf(error), + dependsOn: [] + } + } +} + +export type ReportParseError = (columnId: string, failure: ParseFailure) => void + +function compiledAndReported( + id: string, + entry: string | FormulaColumn, + report: ReportParseError +): Compiled { + const result = compile(entry) + if (result.error) report(id, result.error) + return result +} + +export function compileAll( + columns: Record, + report: ReportParseError +): CompiledMap { + const out = new Map() + for (const [id, entry] of Object.entries(columns)) { + out.set(id, compiledAndReported(id, entry, report)) + } + return out +} + +export function compileExpressions(slice: unknown, report: ReportParseError): CompiledMap | null { + if (slice === null || typeof slice !== 'object' || Array.isArray(slice)) return null + + const out = new Map() + for (const [id, expression] of Object.entries(slice as Record)) { + if (typeof expression !== 'string') continue + out.set(id, compiledAndReported(id, expression, report)) + } + return out +} + +export function withEntry( + current: CompiledMap, + id: string, + entry: string | FormulaColumn, + report: ReportParseError +): CompiledMap { + const next = new Map(current) + next.set(id, compiledAndReported(id, entry, report)) + return next +} + +export function withoutEntry(current: CompiledMap, id: string): CompiledMap { + if (!current.has(id)) return current + const next = new Map(current) + next.delete(id) + return next +} + +export function expressionsOf(current: CompiledMap): Record { + const out: Record = {} + for (const [id, compiled] of current) out[id] = compiled.expression + return out +} + +function shown(value: FormulaValue, onError: 'code' | 'blank'): unknown { + if (!isFormulaError(value)) return value + return onError === 'blank' ? null : value.code +} + +interface Plan { + compiled: CompiledMap + columns: ReadonlyMap> + order: string[] + cyclic: Set + read: (node: RowNode, column: ColumnState) => unknown +} + +function computedFor(node: RowNode, plan: Plan): Record { + const { compiled, columns, order, cyclic } = plan + const computed = new Map() + + const read = (id: string): unknown => { + if (computed.has(id)) return computed.get(id) + const column = columns.get(id) + return column ? plan.read(node, column) : undefined + } + + for (const id of cyclic) { + const onError = compiled.get(id)?.onError ?? 'code' + computed.set( + id, + shown(new FormulaError('#CYCLE', `"${id}" refers back to itself`), onError) + ) + } + + for (const id of order) { + const entry = compiled.get(id)! + const value = entry.node + ? evaluate(entry.node, { column: read }) + : new FormulaError('#NAME', entry.error?.message ?? 'will not parse') + computed.set(id, shown(value, entry.onError)) + } + + return Object.fromEntries(computed) +} + +export function applyFormulas( + nodes: RowNode[], + compiled: CompiledMap, + allColumns: readonly ColumnState[], + grid?: GridState +): RowNode[] { + if (compiled.size === 0) return nodes + + const dependencies = new Map() + for (const [id, entry] of compiled) dependencies.set(id, entry.dependsOn) + const { order, cyclic } = resolveOrder(dependencies) + + const columns = new Map(allColumns.map((column) => [column.id, column])) + + const gated = grid ? gateReader(grid, 'render') : undefined + const plan: Plan = { + compiled, + columns, + order, + cyclic, + read: (node, column) => + gated ? gated(node, column.def) : (node.row as Record)[column.id] + } + + return nodes.map((node) => { + if (isLoadingRow(node.row)) return node + const computed = computedFor(node, plan) + return { ...node, row: { ...node.row, ...computed } } + }) +} diff --git a/src/lib/features/formula/evaluate.ts b/src/lib/features/formula/evaluate.ts new file mode 100644 index 0000000..a1711df --- /dev/null +++ b/src/lib/features/formula/evaluate.ts @@ -0,0 +1,430 @@ +import type { BinaryOperator, Node } from './parse.js' + +export type FormulaValue = number | string | boolean | Date | null | FormulaError + +export type FormulaErrorCode = '#VALUE' | '#DIV/0' | '#NAME' | '#NUM' | '#LIMIT' | '#CYCLE' + +export class FormulaError { + readonly code: FormulaErrorCode + readonly detail: string + + constructor(code: FormulaErrorCode, detail: string) { + this.code = code + this.detail = detail + } + + toString(): string { + return this.code + } +} + +export const MAX_TEXT_LENGTH = 32_767 + +export const isFormulaError = (value: unknown): value is FormulaError => + value instanceof FormulaError + +export interface EvaluateContext { + column: (id: string) => unknown +} + +const VALUE = (detail: string) => new FormulaError('#VALUE', detail) + +function firstError(values: FormulaValue[]): FormulaError | undefined { + for (const value of values) if (isFormulaError(value)) return value + return undefined +} + +function toNumber(value: FormulaValue): number | FormulaError { + if (isFormulaError(value)) return value + if (value === null || value === '') return 0 + if (typeof value === 'number') return Number.isFinite(value) ? value : VALUE('not a number') + if (typeof value === 'boolean') return value ? 1 : 0 + if (value instanceof Date) return VALUE('a date has no arithmetic in this version') + + const parsed = Number(value) + return Number.isNaN(parsed) ? VALUE(`"${value}" is not a number`) : parsed +} + +const isInvalidDate = (value: unknown): boolean => + value instanceof Date && Number.isNaN(value.getTime()) + +function toText(value: FormulaValue): string | FormulaError { + if (isFormulaError(value)) return value + if (value === null) return '' + if (value instanceof Date) { + return isInvalidDate(value) ? VALUE('not a date') : value.toISOString() + } + return String(value) +} + +function toBoolean(value: FormulaValue): boolean | FormulaError { + if (isFormulaError(value)) return value + if (value === null) return false + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + if (typeof value === 'string') return value !== '' + return true +} + +function capped(text: string): string | FormulaError { + return text.length > MAX_TEXT_LENGTH + ? new FormulaError('#LIMIT', `text longer than ${MAX_TEXT_LENGTH} characters`) + : text +} + +function compareNumeric(left: FormulaValue, right: FormulaValue): number | FormulaError { + const a = toNumber(left) + if (isFormulaError(a)) return a + const b = toNumber(right) + if (isFormulaError(b)) return b + return a - b +} + +function compareText(left: string, right: string): number { + if (left === right) return 0 + return left < right ? -1 : 1 +} + +function compare(left: FormulaValue, right: FormulaValue): number | FormulaError { + if (isInvalidDate(left) || isInvalidDate(right)) return VALUE('not a date') + if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime() + if (typeof left === 'string' && typeof right === 'string') return compareText(left, right) + if (left === null && right === null) return 0 + return compareNumeric(left, right) +} + +function equals(left: FormulaValue, right: FormulaValue): boolean | FormulaError { + if (left instanceof Date || right instanceof Date) { + const order = compare(left, right) + return isFormulaError(order) ? order : order === 0 + } + if (typeof left === 'string' || typeof right === 'string') { + const a = toText(left) + if (isFormulaError(a)) return a + const b = toText(right) + if (isFormulaError(b)) return b + return a === b + } + const order = compare(left, right) + return isFormulaError(order) ? order : order === 0 +} + +function arithmetic( + operator: BinaryOperator, + left: FormulaValue, + right: FormulaValue +): FormulaValue { + const a = toNumber(left) + if (isFormulaError(a)) return a + const b = toNumber(right) + if (isFormulaError(b)) return b + + switch (operator) { + case '+': + return a + b + case '-': + return a - b + case '*': + return a * b + case '/': + return b === 0 ? new FormulaError('#DIV/0', 'divided by zero') : a / b + case '%': + return b === 0 ? new FormulaError('#DIV/0', 'divided by zero') : a % b + default: + return finite(a ** b) + } +} + +function finite(value: number): FormulaValue { + return Number.isFinite(value) ? value : new FormulaError('#NUM', 'result is out of range') +} + +const ORDERING = new Map boolean>([ + ['<', (order) => order < 0], + ['<=', (order) => order <= 0], + ['>', (order) => order > 0], + ['>=', (order) => order >= 0] +]) + +function binary(operator: BinaryOperator, left: FormulaValue, right: FormulaValue): FormulaValue { + if (operator === '&') { + const a = toText(left) + if (isFormulaError(a)) return a + const b = toText(right) + if (isFormulaError(b)) return b + return capped(a + b) + } + + if (operator === '=') return equals(left, right) + if (operator === '<>') { + const same = equals(left, right) + return isFormulaError(same) ? same : !same + } + + const ordering = ORDERING.get(operator) + if (ordering) { + const order = compare(left, right) + return isFormulaError(order) ? order : ordering(order) + } + + return arithmetic(operator, left, right) +} + +type Fn = (args: FormulaValue[]) => FormulaValue + +function numeric(args: FormulaValue[]): number[] | FormulaError { + const numbers: number[] = [] + for (const argument of args) { + if (argument === null) continue + const value = toNumber(argument) + if (isFormulaError(value)) return value + numbers.push(value) + } + return numbers +} + +function needText(args: FormulaValue[], index: number): string | FormulaError { + return toText(args[index] ?? null) +} + +function needNumber(args: FormulaValue[], index: number): number | FormulaError { + return toNumber(args[index] ?? null) +} + +function datePart(value: FormulaValue, part: (date: Date) => number): FormulaValue { + if (isFormulaError(value)) return value + if (value instanceof Date) return part(value) + if (value === null) return null + + const date = new Date(String(value)) + return Number.isNaN(date.getTime()) ? VALUE('not a date') : part(date) +} + +function slice( + args: FormulaValue[], + from: (length: number, count: number) => [number, number] +): FormulaValue { + const text = needText(args, 0) + if (isFormulaError(text)) return text + const count = needNumber(args, 1) + if (isFormulaError(count)) return count + if (count < 0) return new FormulaError('#NUM', 'a negative length') + + const [start, end] = from(text.length, Math.floor(count)) + return text.slice(start, end) +} + +function reduceNumbers(args: FormulaValue[], run: (numbers: number[]) => number): FormulaValue { + const numbers = numeric(args) + if (isFormulaError(numbers)) return numbers + return numbers.length === 0 ? null : finite(run(numbers)) +} + +const LAZY_FUNCTIONS = ['if'] + +const EAGER: Record = { + and: (args) => { + for (const argument of args) { + const value = toBoolean(argument) + if (isFormulaError(value)) return value + if (!value) return false + } + return true + }, + or: (args) => { + for (const argument of args) { + const value = toBoolean(argument) + if (isFormulaError(value)) return value + if (value) return true + } + return false + }, + not: (args) => { + const value = toBoolean(args[0] ?? null) + return isFormulaError(value) ? value : !value + }, + coalesce: (args) => { + for (const argument of args) { + if (isFormulaError(argument)) return argument + if (argument !== null && argument !== '') return argument + } + return null + }, + isblank: (args) => { + const value = args[0] ?? null + if (isFormulaError(value)) return value + return value === null || value === '' + }, + iserror: (args) => isFormulaError(args[0] ?? null), + + abs: (args) => { + const value = needNumber(args, 0) + return isFormulaError(value) ? value : Math.abs(value) + }, + sign: (args) => { + const value = needNumber(args, 0) + return isFormulaError(value) ? value : Math.sign(value) + }, + sqrt: (args) => { + const value = needNumber(args, 0) + if (isFormulaError(value)) return value + return value < 0 ? new FormulaError('#NUM', 'square root of a negative') : Math.sqrt(value) + }, + round: (args) => { + const value = needNumber(args, 0) + if (isFormulaError(value)) return value + const digits = args.length > 1 ? needNumber(args, 1) : 0 + if (isFormulaError(digits)) return digits + const factor = 10 ** Math.max(0, Math.min(15, Math.floor(digits))) + return Math.round(value * factor) / factor + }, + floor: (args) => { + const value = needNumber(args, 0) + return isFormulaError(value) ? value : Math.floor(value) + }, + ceiling: (args) => { + const value = needNumber(args, 0) + return isFormulaError(value) ? value : Math.ceil(value) + }, + sum: (args) => reduceNumbers(args, (n) => n.reduce((total, value) => total + value, 0)), + min: (args) => reduceNumbers(args, (n) => Math.min(...n)), + max: (args) => reduceNumbers(args, (n) => Math.max(...n)), + average: (args) => + reduceNumbers(args, (n) => n.reduce((total, value) => total + value, 0) / n.length), + + len: (args) => { + const text = needText(args, 0) + return isFormulaError(text) ? text : text.length + }, + upper: (args) => { + const text = needText(args, 0) + return isFormulaError(text) ? text : text.toUpperCase() + }, + lower: (args) => { + const text = needText(args, 0) + return isFormulaError(text) ? text : text.toLowerCase() + }, + trim: (args) => { + const text = needText(args, 0) + return isFormulaError(text) ? text : text.trim() + }, + left: (args) => slice(args, (_, count) => [0, count]), + right: (args) => slice(args, (length, count) => [Math.max(0, length - count), length]), + concat: (args) => { + let out = '' + for (const argument of args) { + const text = toText(argument) + if (isFormulaError(text)) return text + out += text + if (out.length > MAX_TEXT_LENGTH) return capped(out) + } + return out + }, + contains: (args) => { + const haystack = needText(args, 0) + if (isFormulaError(haystack)) return haystack + const needle = needText(args, 1) + if (isFormulaError(needle)) return needle + return haystack.toLowerCase().includes(needle.toLowerCase()) + }, + startswith: (args) => { + const haystack = needText(args, 0) + if (isFormulaError(haystack)) return haystack + const needle = needText(args, 1) + if (isFormulaError(needle)) return needle + return haystack.toLowerCase().startsWith(needle.toLowerCase()) + }, + text: (args) => toText(args[0] ?? null), + number: (args) => toNumber(args[0] ?? null), + + year: (args) => datePart(args[0] ?? null, (date) => date.getFullYear()), + month: (args) => datePart(args[0] ?? null, (date) => date.getMonth() + 1), + day: (args) => datePart(args[0] ?? null, (date) => date.getDate()) +} + +const FUNCTIONS = new Map(Object.entries(EAGER)) + +export const FUNCTION_NAMES = [...LAZY_FUNCTIONS, ...FUNCTIONS.keys()] + .map((name) => name.toUpperCase()) + .sort() + +function toValue(raw: unknown): FormulaValue { + if (raw === null || raw === undefined) return null + if (typeof raw === 'number' || typeof raw === 'string' || typeof raw === 'boolean') return raw + if (raw instanceof Date) return raw + if (typeof raw === 'function' || typeof raw === 'symbol') return null + return String(raw) +} + +function evaluateUnary( + node: Extract, + context: EvaluateContext +): FormulaValue { + const operand = evaluate(node.operand, context) + if (isFormulaError(operand)) return operand + + if (node.operator === 'not') { + const value = toBoolean(operand) + return isFormulaError(value) ? value : !value + } + + const value = toNumber(operand) + if (isFormulaError(value)) return value + return node.operator === '-' ? -value : value +} + +function evaluateBinary( + node: Extract, + context: EvaluateContext +): FormulaValue { + const left = evaluate(node.left, context) + if (node.operator !== 'and' && node.operator !== 'or') { + return binary(node.operator, left, evaluate(node.right, context)) + } + + const decided = toBoolean(left) + if (isFormulaError(decided)) return decided + if (decided !== (node.operator === 'and')) return decided + return toBoolean(evaluate(node.right, context)) +} + +function evaluateIf(args: Node[], context: EvaluateContext): FormulaValue { + const test = args[0] + const condition = toBoolean(test ? evaluate(test, context) : null) + if (isFormulaError(condition)) return condition + + const chosen = condition ? args[1] : args[2] + return chosen ? evaluate(chosen, context) : null +} + +function evaluateCall( + node: Extract, + context: EvaluateContext +): FormulaValue { + if (node.name === 'if') return evaluateIf(node.args, context) + + const fn = FUNCTIONS.get(node.name) + if (!fn) return new FormulaError('#NAME', `there is no function called "${node.name}"`) + + const args = node.args.map((argument) => evaluate(argument, context)) + if (node.name !== 'iserror') { + const failed = firstError(args) + if (failed) return failed + } + return fn(args) +} + +export function evaluate(node: Node, context: EvaluateContext): FormulaValue { + switch (node.kind) { + case 'literal': + return node.value + case 'column': + return toValue(context.column(node.id)) + case 'unary': + return evaluateUnary(node, context) + case 'binary': + return evaluateBinary(node, context) + case 'call': + return evaluateCall(node, context) + } +} diff --git a/src/lib/features/formula/formula-grid.test.ts b/src/lib/features/formula/formula-grid.test.ts new file mode 100644 index 0000000..748321a --- /dev/null +++ b/src/lib/features/formula/formula-grid.test.ts @@ -0,0 +1,265 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it } from 'vitest' +import { grouping } from '../grouping/index.js' +import { buildGridXlsx } from '../xlsx/index.js' +import { formula, getFormula } from './formula.svelte.js' +import type { FormulaOptions } from './formula.types.js' + +interface Line { + id: number + item: string + price: number + qty: number + total?: number + withTax?: number + tag?: string +} + +const lines: Line[] = [ + { id: 1, item: 'bolt', price: 3, qty: 4 }, + { id: 2, item: 'nut', price: 10, qty: 2 }, + { id: 3, item: 'washer', price: 1, qty: 100 } +] + +const columns: ColumnDef[] = [ + { id: 'item', header: 'Item', sortable: true, filter: 'text' }, + { id: 'price', header: 'Price', sortable: true }, + { id: 'qty', header: 'Qty', sortable: true }, + { id: 'total', header: 'Total', sortable: true, filter: 'number' }, + { id: 'withTax', header: 'With tax', sortable: true }, + { id: 'tag', header: 'Tag', sortable: true, filter: 'text' } +] + +function grid(options: FormulaOptions, data: Line[] = lines) { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), formula(options)] + }) +} + +const valuesOf = (built: ReturnType, id: keyof Line) => + built.preWindowNodes.map((node) => node.row[id]) + +describe('a formula column is an ordinary column to everything downstream', () => { + it('writes the computed value onto the row', () => { + const built = grid({ columns: { total: 'price * qty' } }) + expect(valuesOf(built, 'total')).toEqual([12, 20, 100]) + }) + + it('leaves the source rows untouched', () => { + const built = grid({ columns: { total: 'price * qty' } }) + void built.preWindowNodes + expect(lines.every((row) => row.total === undefined)).toBe(true) + }) + + it('sorts by what the formula computed, not by the raw fields', () => { + const built = grid({ columns: { total: 'price * qty' } }) + built.api.setSort?.([{ columnId: 'total', direction: 'desc' }]) + expect(valuesOf(built, 'total')).toEqual([100, 20, 12]) + }) + + it('filters on the computed value', () => { + const built = grid({ columns: { total: 'price * qty' } }) + built.api.setColumnFilter?.('total', { kind: 'number', op: 'gt', value: 15 }) + expect(valuesOf(built, 'id')).toEqual([2, 3]) + }) + + it('lets the quick filter reach a computed text column', () => { + const built = grid({ columns: { tag: 'IF(price * qty > 50, "big", "small")' } }) + built.api.setQuickFilter?.('big') + expect(valuesOf(built, 'id')).toEqual([3]) + }) +}) + +describe('formulas that build on other formulas', () => { + it('evaluates in dependency order however the options are written', () => { + const built = grid({ + columns: { withTax: 'total * 1.1', total: 'price * qty' } + }) + expect(valuesOf(built, 'withTax')).toEqual([12 * 1.1, 20 * 1.1, 100 * 1.1]) + }) + + it('reports a cycle rather than hanging', () => { + const built = grid({ + columns: { total: 'withTax + 1', withTax: 'total + 1' } + }) + expect(valuesOf(built, 'total')).toEqual(['#CYCLE', '#CYCLE', '#CYCLE']) + expect(valuesOf(built, 'withTax')).toEqual(['#CYCLE', '#CYCLE', '#CYCLE']) + }) + + it('keeps the columns outside a cycle working', () => { + const built = grid({ + columns: { total: 'price * qty', withTax: 'tag', tag: 'withTax' } + }) + expect(valuesOf(built, 'total')).toEqual([12, 20, 100]) + expect(valuesOf(built, 'tag')).toEqual(['#CYCLE', '#CYCLE', '#CYCLE']) + }) +}) + +describe('a formula that goes wrong', () => { + it('shows the error code in the cell, the way a spreadsheet does', () => { + const built = grid({ columns: { total: 'price / (qty - qty)' } }) + expect(valuesOf(built, 'total')).toEqual(['#DIV/0', '#DIV/0', '#DIV/0']) + }) + + it('writes blank instead when the column asked for that', () => { + const built = grid({ + columns: { total: { expression: 'price / 0', onError: 'blank' } } + }) + expect(valuesOf(built, 'total')).toEqual([null, null, null]) + }) + + it('reports a parse failure once, at construction, rather than throwing', () => { + const seen: string[] = [] + const built = grid({ + columns: { total: 'price * * qty' }, + onParseError: (columnId, message) => seen.push(`${columnId}: ${message}`) + }) + + expect(seen).toHaveLength(1) + expect(seen[0]).toContain('total:') + expect(getFormula(built)!.errorOf('total')).not.toBeNull() + expect(valuesOf(built, 'total')).toEqual(['#NAME', '#NAME', '#NAME']) + }) + + it('leaves the rest of the grid working when one formula will not parse', () => { + const built = grid({ columns: { total: '((', withTax: 'price + 1' } }) + expect(valuesOf(built, 'withTax')).toEqual([4, 11, 2]) + }) +}) + +describe('editing a formula at runtime', () => { + it('recomputes when the expression is replaced', () => { + const built = grid({ columns: { total: 'price * qty' } }) + expect(valuesOf(built, 'total')).toEqual([12, 20, 100]) + + built.api.setFormula?.('total', 'price + qty') + expect(valuesOf(built, 'total')).toEqual([7, 12, 101]) + }) + + it('stops computing once the formula is removed', () => { + const built = grid({ columns: { total: 'price * qty' } }) + void built.preWindowNodes + + built.api.removeFormula?.('total') + expect(valuesOf(built, 'total')).toEqual([undefined, undefined, undefined]) + }) + + it('hands the expression back for an editor to show', () => { + const built = grid({ columns: { total: 'price * qty' } }) + expect(built.api.formulaFor?.('total')).toBe('price * qty') + expect(built.api.formulaFor?.('nope')).toBeUndefined() + }) +}) + +describe('formulas round-trip through grid state, which a callback could not', () => { + it('survives getState and setState', () => { + const source = grid({ columns: { total: 'price * qty' } }) + const snapshot = source.getState() + + const target = grid({}) + expect(valuesOf(target, 'total')).toEqual([undefined, undefined, undefined]) + + target.setState(snapshot) + expect(valuesOf(target, 'total')).toEqual([12, 20, 100]) + expect(getFormula(target)!.expressionOf('total')).toBe('price * qty') + }) + + it('ignores a saved shape that is not expressions', () => { + const built = grid({ columns: { total: 'price * qty' } }) + getFormula(built)!.hydrate({ total: 42, withTax: ['nope'] }) + expect(getFormula(built)!.columnIds).toEqual([]) + }) + + it('leaves nothing in the snapshot when there are no formulas', () => { + expect(getFormula(grid({}))!.serialize()).toBeUndefined() + }) +}) + +describe('the computed value reaches the file, and stays inert there', () => { + it('exports what the formula produced', () => { + const built = grid({ columns: { total: 'price * qty' } }) + const text = new TextDecoder().decode(buildGridXlsx(built)) + + expect(text).toContain('12') + expect(text).toContain('100') + }) + + it('never turns a computed string into a spreadsheet formula', () => { + const built = grid({ columns: { tag: '"=1+1" & item' } }) + const text = new TextDecoder().decode(buildGridXlsx(built)) + + expect(text).not.toContain('') + expect(text).toContain('=1+1bolt') + expect(text).toContain('t="inlineStr"') + }) +}) + +describe('the formula runs before grouping, so a group can aggregate it', () => { + interface Sale { + id: number + region: string + price: number + qty: number + total?: number + } + + const sales: Sale[] = [ + { id: 1, region: 'north', price: 10, qty: 2 }, + { id: 2, region: 'north', price: 5, qty: 4 }, + { id: 3, region: 'south', price: 7, qty: 3 } + ] + + function groupedGrid(by: string[]) { + return createDataGrid({ + columns: [ + { id: 'region', header: 'Region' }, + { id: 'price', header: 'Price' }, + { id: 'qty', header: 'Qty' }, + { id: 'total', header: 'Total' } + ], + data: sales, + getRowId: (row) => String(row.id), + features: [ + formula({ columns: { total: 'price * qty' } }), + grouping({ by, aggregations: { total: 'sum' } }) + ] + }) + } + + it('sums a computed column on the group row', () => { + const built = groupedGrid(['region']) + const groups = built.nodes.filter((node) => node.id.startsWith('group:')) + + expect(groups).toHaveLength(2) + expect(groups.map((node) => node.row.total)).toEqual([40, 21]) + }) + + it('groups by a computed column itself', () => { + const built = createDataGrid({ + columns: [ + { id: 'region', header: 'Region' }, + { id: 'price', header: 'Price' }, + { id: 'qty', header: 'Qty' }, + { id: 'total', header: 'Total' } + ], + data: sales, + getRowId: (row) => String(row.id), + features: [ + formula({ columns: { total: 'IF(price * qty > 20, "big", "small")' } }), + grouping({ by: ['total'] }) + ] + }) + + const keys = built.nodes + .filter((node) => node.id.startsWith('group:')) + .map((node) => node.id) + + expect(keys.sort()).toEqual(['group:total=big', 'group:total=small']) + }) +}) diff --git a/src/lib/features/formula/formula-tree.test.ts b/src/lib/features/formula/formula-tree.test.ts new file mode 100644 index 0000000..dd117fb --- /dev/null +++ b/src/lib/features/formula/formula-tree.test.ts @@ -0,0 +1,166 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it } from 'vitest' +import { getTree, tree } from '../tree/index.js' +import { formula } from './formula.svelte.js' + +interface Task { + id: number + parentId: number | null + name: string + rate: number + hours: number + cost?: number + children?: Task[] +} + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', filter: 'text' }, + { id: 'rate', header: 'Rate' }, + { id: 'hours', header: 'Hours' }, + { id: 'cost', header: 'Cost', sortable: true, filter: 'number' } +] + +const flat: Task[] = [ + { id: 1, parentId: null, name: 'design', rate: 100, hours: 2 }, + { id: 2, parentId: 1, name: 'sketch', rate: 50, hours: 3 }, + { id: 3, parentId: 2, name: 'revise', rate: 20, hours: 4 }, + { id: 4, parentId: null, name: 'build', rate: 10, hours: 10 } +] + +const nested: Task[] = [ + { + id: 1, + parentId: null, + name: 'design', + rate: 100, + hours: 2, + children: [ + { + id: 2, + parentId: 1, + name: 'sketch', + rate: 50, + hours: 3, + children: [{ id: 3, parentId: 2, name: 'revise', rate: 20, hours: 4 }] + } + ] + }, + { id: 4, parentId: null, name: 'build', rate: 10, hours: 10 } +] + +function flatGrid(expression = 'rate * hours') { + return createDataGrid({ + columns, + data: flat, + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + formula({ columns: { cost: expression } }), + tree({ + getParentId: (row) => (row.parentId === null ? null : String(row.parentId)), + defaultExpandedDepth: 3 + }) + ] + }) +} + +function nestedGrid(expression = 'rate * hours') { + return createDataGrid({ + columns, + data: nested, + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + formula({ columns: { cost: expression } }), + tree({ getChildren: (row) => row.children, defaultExpandedDepth: 3 }) + ] + }) +} + +const costsOf = (built: ReturnType) => + built.preWindowNodes.map((node) => [node.id, node.row.cost] as const) + +describe('a formula column on a tree, parent rows and children alike', () => { + it('computes every level of the flat shape', () => { + expect(costsOf(flatGrid())).toEqual([ + ['1', 200], + ['2', 150], + ['3', 80], + ['4', 100] + ]) + }) + + it('computes every level of the nested shape', () => { + expect(costsOf(nestedGrid())).toEqual([ + ['1', 200], + ['2', 150], + ['3', 80], + ['4', 100] + ]) + }) + + it('leaves the source rows untouched, children included', () => { + void nestedGrid().preWindowNodes + expect(nested[0]!.cost).toBeUndefined() + expect(nested[0]!.children![0]!.cost).toBeUndefined() + }) + + it('sorts a level by what the formula computed', () => { + const built = flatGrid() + built.api.setSort?.([{ columnId: 'cost', direction: 'desc' }]) + + expect(built.preWindowNodes.map((node) => node.id)).toEqual(['1', '2', '3', '4']) + expect(built.preWindowNodes.map((node) => node.row.cost)).toEqual([200, 150, 80, 100]) + }) + + it('filters on the computed value and keeps the survivors reachable', () => { + const built = flatGrid() + built.api.setColumnFilter?.('cost', { kind: 'number', op: 'gt', value: 90 }) + + expect(built.preWindowNodes.map((node) => node.id).sort()).toEqual(['1', '2', '4']) + }) + + it('holds the level a row sits at, so the tree is still a tree', () => { + const built = nestedGrid() + expect(built.preWindowNodes.map((node) => node.meta?.level ?? 0)).toEqual([0, 1, 2, 0]) + }) + + it('recomputes the whole tree when the expression changes', () => { + const built = nestedGrid() + void built.preWindowNodes + + built.api.setFormula?.('cost', 'rate + hours') + expect(costsOf(built)).toEqual([ + ['1', 102], + ['2', 53], + ['3', 24], + ['4', 20] + ]) + }) + + it('computes a child that is expanded only later', () => { + const built = createDataGrid({ + columns, + data: nested, + getRowId: (row) => String(row.id), + features: [ + formula({ columns: { cost: 'rate * hours' } }), + tree({ getChildren: (row) => row.children }) + ] + }) + void built.preWindowNodes + + getTree(built)!.expandAll() + expect(costsOf(built)).toEqual([ + ['1', 200], + ['2', 150], + ['3', 80], + ['4', 100] + ]) + }) +}) diff --git a/src/lib/features/formula/formula.svelte.ts b/src/lib/features/formula/formula.svelte.ts new file mode 100644 index 0000000..3cbc3b5 --- /dev/null +++ b/src/lib/features/formula/formula.svelte.ts @@ -0,0 +1,109 @@ +import { type GridState } from '../../core/grid/index.js' +import { type GridFeature, type RowNode } from '../../core/types/index.js' +import { + applyFormulas, + compileAll, + compileExpressions, + expressionsOf, + NO_FORMULAS, + withEntry, + withoutEntry, + type CompiledMap, + type ParseFailure, + type ReportParseError +} from './compiled.js' +import type { FormulaColumn, FormulaOptions } from './formula.types.js' + +export const FORMULA = 'formula' + +export const FORMULA_ORDER = 50 + +const COMPUTED = Object.freeze({}) + +const refuseEditing = (): unknown => COMPUTED + +export class Formula { + #grid: GridState + #report: ReportParseError + + #compiled = $state.raw(NO_FORMULAS) + + constructor(grid: GridState, options: FormulaOptions) { + this.#grid = grid + this.#report = (columnId, failure) => + options.onParseError?.(columnId, failure.message, failure.at) + + this.#compiled = compileAll(options.columns ?? {}, this.#report) + } + + get columnIds(): string[] { + return [...this.#compiled.keys()] + } + + expressionOf = (columnId: string): string | undefined => + this.#compiled.get(columnId)?.expression + + errorOf = (columnId: string): ParseFailure | null => this.#compiled.get(columnId)?.error ?? null + + isFormulaColumn = (columnId: string): boolean => this.#compiled.has(columnId) + + set = (columnId: string, entry: string | FormulaColumn): void => { + this.#compiled = withEntry(this.#compiled, columnId, entry, this.#report) + } + + remove = (columnId: string): void => { + this.#compiled = withoutEntry(this.#compiled, columnId) + } + + clear = (): void => { + this.#compiled = NO_FORMULAS + } + + apply = (nodes: RowNode[]): RowNode[] => + applyFormulas(nodes, this.#compiled, this.#grid.columns.all, this.#grid) + + serialize = (): Record | undefined => + this.#compiled.size === 0 ? undefined : expressionsOf(this.#compiled) + + hydrate = (slice: unknown): void => { + const next = compileExpressions(slice, this.#report) + if (next) this.#compiled = next + } +} + +export function formula(options: FormulaOptions = {}): GridFeature { + return { + id: FORMULA, + createState: (grid) => new Formula(grid, options), + pipelineStage: { + order: FORMULA_ORDER, + transform: (nodes, grid) => getFormula(grid)?.apply(nodes) ?? nodes + }, + createApi: (grid) => { + const state = getFormula(grid)! + return { + setFormula: state.set, + removeFormula: state.remove, + formulaFor: state.expressionOf + } + }, + cellValue: ({ grid, column, purpose }) => + purpose === 'edit' && getFormula(grid)?.isFormulaColumn(column.id) + ? refuseEditing + : undefined, + serialize: (grid) => getFormula(grid)?.serialize(), + hydrate: (slice, grid) => getFormula(grid)?.hydrate(slice) + } +} + +export function getFormula(grid: GridState): Formula | undefined { + return grid.feature>(FORMULA) +} + +declare module '../../core/types/api.js' { + interface GridApi { + setFormula?: (columnId: string, entry: string) => void + removeFormula?: (columnId: string) => void + formulaFor?: (columnId: string) => string | undefined + } +} diff --git a/src/lib/features/formula/formula.test.ts b/src/lib/features/formula/formula.test.ts new file mode 100644 index 0000000..3b29c0e --- /dev/null +++ b/src/lib/features/formula/formula.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'vitest' +import { evaluate, type FormulaError, type FormulaValue, isFormulaError } from './evaluate.js' +import { columnsUsed, MAX_DEPTH, MAX_NODES, parse } from './parse.js' +import { FormulaSyntaxError, MAX_SOURCE_LENGTH, tokenize } from './tokenize.js' + +function run(source: string, row: Record = {}): FormulaValue { + return evaluate(parse(source), { column: (id) => row[id] }) +} + +function runOwn(source: string, row: Record = {}): FormulaValue { + return evaluate(parse(source), { + column: (id) => (Object.hasOwn(row, id) ? row[id] : null) + }) +} + +const code = (value: FormulaValue): string => + isFormulaError(value) ? value.code : `not an error: ${String(value)}` + +describe('tokenizer', () => { + it('reads the number shapes a person actually types', () => { + expect(tokenize('1 2.5 .5 1e3 1.5e-2').filter((t) => t.kind === 'number')).toHaveLength(5) + expect(run('1e3')).toBe(1000) + expect(run('.5')).toBe(0.5) + expect(run('1.5e-2')).toBe(0.015) + }) + + it('does not mistake a trailing e for an exponent', () => { + expect(run('2 & "e"')).toBe('2e') + }) + + it('takes a doubled quote as one quote inside text', () => { + expect(run('"she said ""hi"""')).toBe('she said "hi"') + expect(run("'it''s'")).toBe("it's") + }) + + it('refuses text whose quote is never closed', () => { + expect(() => tokenize('"open')).toThrow(FormulaSyntaxError) + }) + + it('refuses a column name whose bracket is never closed', () => { + expect(() => tokenize('[open')).toThrow(FormulaSyntaxError) + }) + + it('points at where the trouble is', () => { + try { + tokenize('1 + #') + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(FormulaSyntaxError) + expect((error as FormulaSyntaxError).at).toBe(4) + } + }) + + it('caps the source so a pasted novel cannot be parsed', () => { + expect(() => tokenize('1+'.repeat(MAX_SOURCE_LENGTH))).toThrow(/over the .* limit/) + }) +}) + +describe('precedence and associativity', () => { + it('multiplies before it adds', () => { + expect(run('2 + 3 * 4')).toBe(14) + expect(run('(2 + 3) * 4')).toBe(20) + }) + + it('raises to a power right to left, the way maths does', () => { + expect(run('2 ^ 3 ^ 2')).toBe(512) + }) + + it('subtracts left to right', () => { + expect(run('10 - 3 - 2')).toBe(5) + }) + + it('compares after it does arithmetic', () => { + expect(run('1 + 1 = 2')).toBe(true) + expect(run('2 * 3 > 5')).toBe(true) + }) + + it('joins text after arithmetic but before comparison', () => { + expect(run('"a" & 1 + 1')).toBe('a2') + expect(run('"a" & "b" = "ab"')).toBe(true) + }) + + it('puts AND above OR', () => { + expect(run('true OR false AND false')).toBe(true) + expect(run('(true OR false) AND false')).toBe(false) + }) + + it('reads -x^2 as -(x^2), the way maths does rather than the way Excel does', () => { + expect(run('-2 * 3')).toBe(-6) + expect(run('-2 ^ 2')).toBe(-4) + expect(run('(-2) ^ 2')).toBe(4) + }) + + it('accepts both spellings of equality and inequality', () => { + expect(run('1 = 1')).toBe(true) + expect(run('1 == 1')).toBe(true) + expect(run('1 <> 2')).toBe(true) + expect(run('1 != 2')).toBe(true) + }) +}) + +describe('column references', () => { + it('reads a bare name as a column', () => { + expect(run('price * qty', { price: 3, qty: 4 })).toBe(12) + }) + + it('reads a bracketed name, so an id may hold spaces', () => { + expect(run('[unit price] * 2', { 'unit price': 5 })).toBe(10) + }) + + it('reports every column a formula depends on', () => { + expect(columnsUsed(parse('IF(a > b, a & [c d], SUM(a, e))')).sort()).toEqual([ + 'a', + 'b', + 'c d', + 'e' + ]) + }) + + it('treats a missing column as blank rather than failing', () => { + expect(run('missing + 1')).toBe(1) + expect(run('ISBLANK(missing)')).toBe(true) + }) +}) + +describe('Excel-shaped coercion', () => { + it('counts blank as zero in arithmetic and empty in text', () => { + expect(run('x + 5', { x: null })).toBe(5) + expect(run('"[" & x & "]"', { x: null })).toBe('[]') + }) + + it('reads a numeric string as a number', () => { + expect(run('x * 2', { x: '21' })).toBe(42) + }) + + it('refuses a string that is not a number', () => { + expect(code(run('x * 2', { x: 'abc' }))).toBe('#VALUE') + }) + + it('compares two strings as text, not as numbers', () => { + expect(run('"10" < "9"')).toBe(true) + expect(run('10 < 9')).toBe(false) + }) + + it('compares dates by their instant', () => { + const early = new Date(2026, 0, 1) + const late = new Date(2026, 6, 1) + expect(run('a < b', { a: early, b: late })).toBe(true) + expect(run('a = b', { a: early, b: new Date(2026, 0, 1) })).toBe(true) + }) + + it('pulls the parts out of a date', () => { + expect(run('YEAR(d)', { d: new Date(2026, 7, 17) })).toBe(2026) + expect(run('MONTH(d)', { d: new Date(2026, 7, 17) })).toBe(8) + expect(run('DAY(d)', { d: new Date(2026, 7, 17) })).toBe(17) + }) + + it('says plainly that date arithmetic is not in this version', () => { + expect(code(run('d + 1', { d: new Date(2026, 0, 1) }))).toBe('#VALUE') + }) +}) + +describe('errors are values, not exceptions', () => { + it('returns a division error rather than throwing', () => { + expect(code(run('1 / 0'))).toBe('#DIV/0') + expect(code(run('1 % 0'))).toBe('#DIV/0') + }) + + it('names a function it does not have', () => { + const result = run('VLOOKUP(1)') + expect(code(result)).toBe('#NAME') + expect((result as FormulaError).detail).toContain('VLOOKUP'.toLowerCase()) + }) + + it('carries an error up through the expression around it', () => { + expect(code(run('1 + (2 / 0)'))).toBe('#DIV/0') + expect(code(run('ROUND(1 / 0, 2)'))).toBe('#DIV/0') + }) + + it('lets ISERROR see the error instead of being swallowed by it', () => { + expect(run('ISERROR(1 / 0)')).toBe(true) + expect(run('ISERROR(1)')).toBe(false) + }) + + it('reports a number that ran off the end of what a double holds', () => { + expect(code(run('10 ^ 400'))).toBe('#NUM') + }) + + it('prints as its code, so a cell shows #DIV/0', () => { + expect(String(run('1 / 0'))).toBe('#DIV/0') + }) +}) + +describe('IF chooses a branch instead of running both', () => { + it('does not divide by zero on the branch it did not take', () => { + expect(run('IF(x = 0, 0, 100 / x)', { x: 0 })).toBe(0) + expect(run('IF(x = 0, 0, 100 / x)', { x: 4 })).toBe(25) + }) + + it('returns blank when the branch is missing', () => { + expect(run('IF(false, 1)')).toBe(null) + }) +}) + +describe('functions', () => { + it('does the numeric ones', () => { + expect(run('ABS(-3)')).toBe(3) + expect(run('ROUND(3.14159, 2)')).toBe(3.14) + expect(run('ROUND(3.7)')).toBe(4) + expect(run('FLOOR(3.7)')).toBe(3) + expect(run('CEILING(3.2)')).toBe(4) + expect(run('SQRT(9)')).toBe(3) + expect(run('SIGN(-2)')).toBe(-1) + }) + + it('does the aggregate-shaped ones over their arguments', () => { + expect(run('SUM(1, 2, 3)')).toBe(6) + expect(run('MIN(4, 2, 9)')).toBe(2) + expect(run('MAX(4, 2, 9)')).toBe(9) + expect(run('AVERAGE(2, 4)')).toBe(3) + }) + + it('skips blanks rather than counting them as zero in an average', () => { + expect(run('AVERAGE(a, b, c)', { a: 2, b: null, c: 4 })).toBe(3) + expect(run('SUM(a, b)', { a: null, b: null })).toBe(null) + }) + + it('does the text ones', () => { + expect(run('LEN("hello")')).toBe(5) + expect(run('UPPER("ab")')).toBe('AB') + expect(run('LOWER("AB")')).toBe('ab') + expect(run('TRIM(" x ")')).toBe('x') + expect(run('LEFT("hello", 2)')).toBe('he') + expect(run('RIGHT("hello", 2)')).toBe('lo') + expect(run('CONCAT("a", 1, true)')).toBe('a1true') + }) + + it('matches text without caring about case, which is what a filter wants', () => { + expect(run('CONTAINS("Hello World", "world")')).toBe(true) + expect(run('STARTSWITH("Hello", "he")')).toBe(true) + expect(run('CONTAINS("Hello", "xyz")')).toBe(false) + }) + + it('takes the first thing that is neither blank nor empty', () => { + expect(run('COALESCE(a, b, "fallback")', { a: null, b: '' })).toBe('fallback') + expect(run('COALESCE(a, b)', { a: null, b: 7 })).toBe(7) + }) + + it('refuses a negative length rather than returning nonsense', () => { + expect(code(run('LEFT("abc", -1)'))).toBe('#NUM') + }) +}) + +describe('limits that keep one bad formula from taking the tab down', () => { + it('refuses to nest past the depth it can walk', () => { + const deep = '('.repeat(MAX_DEPTH + 5) + '1' + ')'.repeat(MAX_DEPTH + 5) + expect(() => parse(deep)).toThrow(/nests deeper/) + }) + + it('survives what it does accept, rather than overflowing the stack', () => { + const allowed = '('.repeat(MAX_DEPTH - 2) + '1' + ')'.repeat(MAX_DEPTH - 2) + expect(run(allowed)).toBe(1) + }) + + it('refuses a formula with more parts than it will evaluate', () => { + const dense = Array(MAX_NODES + 10) + .fill('1') + .join('+') + expect(dense.length).toBeLessThan(MAX_SOURCE_LENGTH) + expect(() => parse(dense)).toThrow(/more than/) + }) + + it('caps a string a formula builds, rather than letting it eat the heap', () => { + const long = 'x'.repeat(20_000) + expect(code(run('a & a', { a: long }))).toBe('#LIMIT') + }) +}) + +describe('the evaluator cannot be walked out of into JavaScript', () => { + it('does not find a function on Object.prototype', () => { + for (const name of ['constructor', 'toString', 'valueOf', 'hasOwnProperty']) { + expect(code(run(`${name}(1)`))).toBe('#NAME') + } + }) + + it('does not resolve a keyword off the prototype chain either', () => { + const node = parse('constructor') + expect(node).toEqual({ kind: 'column', id: 'constructor', at: 0 }) + }) + + it('treats such a name as an ordinary column the app may or may not have', () => { + expect(runOwn('constructor & ""', {})).toBe('') + expect(runOwn('constructor & ""', { constructor: 'mine' })).toBe('mine') + }) + + it('never lets a function reach a cell, even from a careless value lookup', () => { + expect(run('constructor & ""', {})).toBe('') + expect(run('ISBLANK(toString)', {})).toBe(true) + expect(evaluate(parse('x'), { column: () => () => 'boom' })).toBe(null) + }) + + it('never hands a formula anything but the values the context returns', () => { + const seen: string[] = [] + evaluate(parse('a + b'), { + column: (id) => { + seen.push(id) + return 1 + } + }) + expect(seen).toEqual(['a', 'b']) + }) +}) + +describe('syntax errors say what is wrong and where', () => { + it.each([ + ['1 +', 'ends too early'], + ['1 + + ', 'ends too early'], + ['(1', 'missing'], + ['SUM(1,', 'ends too early'], + ['1 2', 'unexpected'], + ['* 1', 'needs a value before it'], + ['[]', 'empty column name'] + ])('rejects %j', (source, message) => { + expect(() => parse(source)).toThrow(new RegExp(message)) + }) + + it('accepts a function called with nothing', () => { + expect(run('SUM()')).toBe(null) + }) +}) diff --git a/src/lib/features/formula/formula.types.ts b/src/lib/features/formula/formula.types.ts new file mode 100644 index 0000000..1b38093 --- /dev/null +++ b/src/lib/features/formula/formula.types.ts @@ -0,0 +1,46 @@ +/** + * One computed column: the expression, plus what to do when it goes wrong. + * + * The column itself is declared the ordinary way in `createDataGrid({ columns })`; + * this only supplies the value behind it. That split is deliberate - adding a + * column at runtime is a separate Community extension point, and a formula + * column is otherwise an ordinary column in every respect. + */ +export interface FormulaColumn { + /** + * The expression, as text. Text rather than a callback because a string + * round-trips: it serializes into saved views, share links and state + * persistence, which a function cannot. + */ + expression: string + + /** + * What a cell shows when the expression fails for that row. + * + * `'code'` writes the error code (`#DIV/0`), the way a spreadsheet does. + * `'blank'` writes nothing, which reads better in a report but hides the + * mistake. + * + * @default 'code' + */ + onError?: 'code' | 'blank' +} + +export interface FormulaOptions { + /** + * Expression per column id. A bare string is shorthand for + * `{ expression }`. + * + * Formula columns may refer to one another; they are evaluated in + * dependency order. A reference that runs in a circle yields `#CYCLE` + * rather than hanging. + */ + columns?: Record + + /** + * Called when an expression fails to parse - on the initial options and on + * every later edit. Parse failures are reported here rather than thrown, so + * one bad formula cannot take a grid down. + */ + onParseError?: (columnId: string, message: string, at: number) => void +} diff --git a/src/lib/features/formula/index.ts b/src/lib/features/formula/index.ts new file mode 100644 index 0000000..bc7ab07 --- /dev/null +++ b/src/lib/features/formula/index.ts @@ -0,0 +1,14 @@ +export { + evaluate, + FormulaError, + FUNCTION_NAMES, + isFormulaError, + MAX_TEXT_LENGTH, + type EvaluateContext, + type FormulaErrorCode, + type FormulaValue +} from './evaluate.js' +export { formula, Formula, getFormula } from './formula.svelte.js' +export type { FormulaColumn, FormulaOptions } from './formula.types.js' +export { columnsUsed, MAX_DEPTH, MAX_NODES, parse, type Node } from './parse.js' +export { FormulaSyntaxError, MAX_SOURCE_LENGTH, tokenize } from './tokenize.js' diff --git a/src/lib/features/formula/not-editable.test.ts b/src/lib/features/formula/not-editable.test.ts new file mode 100644 index 0000000..73bed27 --- /dev/null +++ b/src/lib/features/formula/not-editable.test.ts @@ -0,0 +1,90 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { describe, expect, it } from 'vitest' +import { formula } from './formula.svelte.js' + +interface Row { + id: number + a: number + b: number + total?: number + note?: string +} + +function grid(editable: boolean | undefined, data?: Row[]) { + const columns: ColumnDef[] = [ + { id: 'a', header: 'A', editable: true }, + { id: 'b', header: 'B' }, + { id: 'total', header: 'Total', editable }, + { id: 'note', header: 'Note', editable: true } + ] + + return createDataGrid({ + columns, + data: data ?? [{ id: 1, a: 2, b: 3 }], + getRowId: (row) => String(row.id), + features: [editing(), formula({ columns: { total: 'a * b' } })] + }) +} + +describe('a formula column the app left open for editing', () => { + it('takes no write, and leaves nothing behind in the row', () => { + const built = grid(true) + void built.preWindowNodes + + built.api.applyEdits?.([{ rowId: '1', changes: { total: 999 } }]) + + expect(built.data[0]!.total).toBeUndefined() + expect(built.preWindowNodes[0]?.row.total).toBe(6) + }) + + it('opens no editor on it', () => { + const built = grid(true) + void built.preWindowNodes + + built.api.startEditing?.('1', 'total') + + expect(built.api.getEditingCell?.()).toBeNull() + }) + + it('still writes the plain columns in the same batch', () => { + const built = grid(true) + void built.preWindowNodes + + built.api.applyEdits?.([{ rowId: '1', changes: { total: 999, note: 'kept' } }]) + + expect(built.data[0]!.note).toBe('kept') + expect(built.data[0]!.total).toBeUndefined() + }) + + it('refuses the write whatever the formula computed, blank included', () => { + const built = createDataGrid({ + columns: [ + { id: 'a', header: 'A' }, + { id: 'b', header: 'B' }, + { id: 'total', header: 'Total', editable: true } + ], + data: [{ id: 1, a: 2, b: 3 }], + getRowId: (row) => String(row.id), + features: [ + editing(), + formula({ columns: { total: { expression: 'nope(', onError: 'blank' } } }) + ] + }) + void built.preWindowNodes + + built.api.applyEdits?.([{ rowId: '1', changes: { total: 999 } }]) + + expect(built.data[0]!.total).toBeUndefined() + }) + + it('leaves a column with no formula on it editable', () => { + const built = grid(undefined) + void built.preWindowNodes + + built.api.applyEdits?.([{ rowId: '1', changes: { a: 7 } }]) + + expect(built.data[0]!.a).toBe(7) + }) +}) diff --git a/src/lib/features/formula/order.ts b/src/lib/features/formula/order.ts new file mode 100644 index 0000000..aba5adb --- /dev/null +++ b/src/lib/features/formula/order.ts @@ -0,0 +1,37 @@ +export interface OrderResult { + order: string[] + cyclic: Set +} + +export function resolveOrder(dependencies: Map): OrderResult { + const order: string[] = [] + const cyclic = new Set() + + const state = new Map() + + const visit = (id: string, stack: string[]): boolean => { + const seen = state.get(id) + if (seen === 'done') return true + if (seen === 'visiting') { + const from = stack.indexOf(id) + for (const member of stack.slice(from === -1 ? 0 : from)) cyclic.add(member) + cyclic.add(id) + return false + } + + state.set(id, 'visiting') + let clean = true + for (const next of dependencies.get(id) ?? []) { + if (!dependencies.has(next)) continue + if (!visit(next, [...stack, id])) clean = false + } + state.set(id, 'done') + + if (clean) order.push(id) + return clean + } + + for (const id of dependencies.keys()) visit(id, []) + + return { order: order.filter((id) => !cyclic.has(id)), cyclic } +} diff --git a/src/lib/features/formula/parse.ts b/src/lib/features/formula/parse.ts new file mode 100644 index 0000000..cefd170 --- /dev/null +++ b/src/lib/features/formula/parse.ts @@ -0,0 +1,259 @@ +import { FormulaSyntaxError, type Token, tokenize, unbracket, unquote } from './tokenize.js' + +export type Node = + | { kind: 'literal'; value: number | string | boolean | null } + | { kind: 'column'; id: string; at: number } + | { kind: 'unary'; operator: '-' | '+' | 'not'; operand: Node } + | { kind: 'binary'; operator: BinaryOperator; left: Node; right: Node } + | { kind: 'call'; name: string; args: Node[]; at: number } + +export type BinaryOperator = + '+' | '-' | '*' | '/' | '%' | '^' | '&' | '=' | '<>' | '<' | '<=' | '>' | '>=' | 'and' | 'or' + +export const MAX_DEPTH = 32 +export const MAX_NODES = 512 + +const BINDING = new Map([ + ['or', 1], + ['and', 2], + ['=', 3], + ['<>', 3], + ['<', 3], + ['<=', 3], + ['>', 3], + ['>=', 3], + ['&', 4], + ['+', 5], + ['-', 5], + ['*', 6], + ['/', 6], + ['%', 6], + ['^', 7] +]) + +const RIGHT_ASSOCIATIVE = new Set(['^']) + +const KEYWORDS = new Map([ + ['true', { kind: 'literal', value: true }], + ['false', { kind: 'literal', value: false }], + ['null', { kind: 'literal', value: null }], + ['blank', { kind: 'literal', value: null }] +]) + +const NORMALIZED = new Map([ + ['==', '='], + ['!=', '<>'] +]) + +class Parser { + #tokens: Token[] + #index = 0 + #nodes = 0 + + constructor(source: string) { + this.#tokens = tokenize(source) + } + + #peek(): Token { + return this.#tokens[this.#index]! + } + + #next(): Token { + return this.#tokens[this.#index++]! + } + + #count(token: Token): void { + this.#nodes++ + if (this.#nodes > MAX_NODES) { + throw new FormulaSyntaxError( + `formula has more than ${MAX_NODES} parts, which is past what this evaluates`, + token.at + ) + } + } + + #operatorOf(token: Token): BinaryOperator | undefined { + if (token.kind === 'operator') { + const text = NORMALIZED.get(token.text) ?? (token.text as BinaryOperator) + return BINDING.has(text) ? text : undefined + } + if (token.kind === 'name') { + const word = token.text.toLowerCase() + if (word === 'and' || word === 'or') return word + } + return undefined + } + + parse(): Node { + const node = this.#expression(0, 0) + const token = this.#peek() + if (token.kind !== 'end') { + throw new FormulaSyntaxError(`unexpected "${token.text}"`, token.at) + } + return node + } + + #expression(minBinding: number, depth: number): Node { + if (depth > MAX_DEPTH) { + throw new FormulaSyntaxError( + `formula nests deeper than ${MAX_DEPTH} levels`, + this.#peek().at + ) + } + + let left = this.#prefix(depth) + + for (;;) { + const token = this.#peek() + const operator = this.#operatorOf(token) + if (!operator) break + + const binding = BINDING.get(operator)! + if (binding < minBinding) break + + this.#next() + this.#count(token) + + const nextBinding = RIGHT_ASSOCIATIVE.has(operator) ? binding : binding + 1 + const right = this.#expression(nextBinding, depth + 1) + left = { kind: 'binary', operator, left, right } + } + + return left + } + + #numberNode(token: Token): Node { + const value = Number(token.text) + if (Number.isNaN(value)) { + throw new FormulaSyntaxError(`"${token.text}" is not a number`, token.at) + } + return { kind: 'literal', value } + } + + #signNode(token: Token, depth: number): Node { + if (token.text !== '-' && token.text !== '+') { + throw new FormulaSyntaxError(`"${token.text}" needs a value before it`, token.at) + } + const operand = this.#expression(BINDING.get('^')!, depth + 1) + return { kind: 'unary', operator: token.text, operand } + } + + #groupNode(token: Token, depth: number): Node { + if (token.text !== '(') { + throw new FormulaSyntaxError(`unexpected "${token.text}"`, token.at) + } + const inner = this.#expression(0, depth + 1) + this.#expect(')') + return inner + } + + #prefix(depth: number): Node { + const token = this.#next() + this.#count(token) + + switch (token.kind) { + case 'number': + return this.#numberNode(token) + case 'string': + return { kind: 'literal', value: unquote(token.text) } + case 'operator': + return this.#signNode(token, depth) + case 'punct': + return this.#groupNode(token, depth) + case 'name': + return this.#name(token, depth) + default: + throw new FormulaSyntaxError('the formula ends too early', token.at) + } + } + + #name(token: Token, depth: number): Node { + if (token.text.startsWith('[')) { + const id = unbracket(token.text) + if (id === '') throw new FormulaSyntaxError('empty column name', token.at) + return { kind: 'column', id, at: token.at } + } + + const word = token.text.toLowerCase() + + if (word === 'not') { + const operand = this.#expression(BINDING.get('=')!, depth + 1) + return { kind: 'unary', operator: 'not', operand } + } + + const next = this.#peek() + if (next.kind === 'punct' && next.text === '(') { + this.#next() + return { kind: 'call', name: word, args: this.#arguments(depth), at: token.at } + } + + const keyword = KEYWORDS.get(word) + if (keyword) return keyword + + return { kind: 'column', id: token.text, at: token.at } + } + + #arguments(depth: number): Node[] { + const args: Node[] = [] + + const first = this.#peek() + if (first.kind === 'punct' && first.text === ')') { + this.#next() + return args + } + + for (;;) { + args.push(this.#expression(0, depth + 1)) + const token = this.#next() + + if (token.kind === 'punct' && token.text === ')') return args + if (token.kind === 'punct' && token.text === ',') continue + + throw new FormulaSyntaxError( + token.kind === 'end' ? 'a ")" is missing' : 'expected "," or ")"', + token.at + ) + } + } + + #expect(text: string): void { + const token = this.#next() + if (token.text !== text) { + throw new FormulaSyntaxError( + token.kind === 'end' ? `a "${text}" is missing` : `expected "${text}"`, + token.at + ) + } + } +} + +export function parse(source: string): Node { + return new Parser(source).parse() +} + +export function columnsUsed(node: Node): string[] { + const found = new Set() + + const walk = (current: Node): void => { + switch (current.kind) { + case 'column': + found.add(current.id) + return + case 'unary': + walk(current.operand) + return + case 'binary': + walk(current.left) + walk(current.right) + return + case 'call': + for (const argument of current.args) walk(argument) + return + default: + return + } + } + + walk(node) + return [...found] +} diff --git a/src/lib/features/formula/tokenize.ts b/src/lib/features/formula/tokenize.ts new file mode 100644 index 0000000..560af16 --- /dev/null +++ b/src/lib/features/formula/tokenize.ts @@ -0,0 +1,166 @@ +export type TokenKind = 'number' | 'string' | 'name' | 'operator' | 'punct' | 'end' + +export interface Token { + kind: TokenKind + text: string + at: number +} + +export class FormulaSyntaxError extends Error { + readonly at: number + + constructor(message: string, at: number) { + super(message) + this.name = 'FormulaSyntaxError' + this.at = at + } +} + +export const MAX_SOURCE_LENGTH = 2_000 + +const TWO_CHAR = ['<=', '>=', '<>', '!=', '=='] +const ONE_CHAR = '+-*/%^&=<>' +const PUNCT = '(),' + +const isDigit = (char: string): boolean => char >= '0' && char <= '9' + +const isNameStart = (char: string): boolean => + (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char === '_' + +const isNamePart = (char: string): boolean => isNameStart(char) || isDigit(char) || char === '.' + +function skipDigits(source: string, start: number): number { + let index = start + while (index < source.length && isDigit(source[index]!)) index++ + return index +} + +function readExponent(source: string, start: number): number { + if (source[start] !== 'e' && source[start] !== 'E') return start + + let index = start + 1 + if (source[index] === '+' || source[index] === '-') index++ + + const digits = skipDigits(source, index) + return digits === index ? start : digits +} + +function readNumber(source: string, start: number): number { + const whole = skipDigits(source, start) + const withFraction = source[whole] === '.' ? skipDigits(source, whole + 1) : whole + return readExponent(source, withFraction) +} + +function readQuoted(source: string, start: number): number { + const quote = source[start]! + let index = start + 1 + + while (index < source.length) { + if (source[index] === quote) { + if (source[index + 1] === quote) { + index += 2 + continue + } + return index + 1 + } + index++ + } + + throw new FormulaSyntaxError('unterminated text: the quote is never closed', start) +} + +function readBracketed(source: string, start: number): number { + const end = source.indexOf(']', start + 1) + if (end === -1) { + throw new FormulaSyntaxError('unterminated column name: the "]" is missing', start) + } + return end + 1 +} + +export function unquote(text: string): string { + const quote = text[0]! + return text.slice(1, -1).split(`${quote}${quote}`).join(quote) +} + +export function unbracket(text: string): string { + return text.slice(1, -1).trim() +} + +function operatorAt(source: string, index: number): string | undefined { + const two = source.slice(index, index + 2) + if (TWO_CHAR.includes(two)) return two + + const one = source[index]! + return ONE_CHAR.includes(one) ? one : undefined +} + +const isSpace = (char: string): boolean => + char === ' ' || char === '\t' || char === '\n' || char === '\r' + +function readName(source: string, start: number): number { + let index = start + 1 + while (index < source.length && isNamePart(source[index]!)) index++ + return index +} + +type Span = { kind: TokenKind; end: number } | undefined + +const startsNumber = (source: string, index: number): boolean => + isDigit(source[index]!) || (source[index] === '.' && isDigit(source[index + 1] ?? '')) + +const SCANNERS: ((source: string, index: number) => Span)[] = [ + (source, index) => + startsNumber(source, index) + ? { kind: 'number', end: readNumber(source, index) } + : undefined, + (source, index) => + source[index] === '"' || source[index] === "'" + ? { kind: 'string', end: readQuoted(source, index) } + : undefined, + (source, index) => + source[index] === '[' ? { kind: 'name', end: readBracketed(source, index) } : undefined, + (source, index) => + isNameStart(source[index]!) ? { kind: 'name', end: readName(source, index) } : undefined, + (source, index) => + PUNCT.includes(source[index]!) ? { kind: 'punct', end: index + 1 } : undefined, + (source, index) => { + const operator = operatorAt(source, index) + return operator ? { kind: 'operator', end: index + operator.length } : undefined + } +] + +function spanAt(source: string, index: number): Span { + for (const scan of SCANNERS) { + const span = scan(source, index) + if (span) return span + } + return undefined +} + +export function tokenize(source: string): Token[] { + if (source.length > MAX_SOURCE_LENGTH) { + throw new FormulaSyntaxError( + `formula is ${source.length} characters, over the ${MAX_SOURCE_LENGTH} limit`, + 0 + ) + } + + const tokens: Token[] = [] + let index = 0 + + while (index < source.length) { + if (isSpace(source[index]!)) { + index++ + continue + } + + const span = spanAt(source, index) + if (!span) throw new FormulaSyntaxError(`unexpected character "${source[index]}"`, index) + + tokens.push({ kind: span.kind, text: source.slice(index, span.end), at: index }) + index = span.end + } + + tokens.push({ kind: 'end', text: '', at: source.length }) + return tokens +} diff --git a/src/lib/features/grouping/aggregate.test.ts b/src/lib/features/grouping/aggregate.test.ts new file mode 100644 index 0000000..64c81f2 --- /dev/null +++ b/src/lib/features/grouping/aggregate.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { aggregate } from './aggregate.js' + +interface Row { + n: number +} + +const rows: Row[] = [{ n: 1 }, { n: 2 }, { n: 3 }] + +describe('aggregate', () => { + it('sums, mins, maxes and averages numeric values', () => { + const values = [10, 20, 30] + expect(aggregate('sum', values, rows)).toBe(60) + expect(aggregate('min', values, rows)).toBe(10) + expect(aggregate('max', values, rows)).toBe(30) + expect(aggregate('avg', values, rows)).toBe(20) + }) + + it('counts rows rather than values', () => { + expect(aggregate('count', [1], rows)).toBe(3) + expect(aggregate('count', [], [])).toBe(0) + }) + + it('skips nullish, empty and non-numeric entries', () => { + const values = [10, null, undefined, '', 'abc', '20'] + expect(aggregate('sum', values, rows)).toBe(30) + expect(aggregate('avg', values, rows)).toBe(15) + }) + + it('returns null when nothing numeric is left', () => { + expect(aggregate('sum', [null, 'abc'], rows)).toBeNull() + expect(aggregate('max', [], rows)).toBeNull() + }) + + it('takes the median, interpolating between the middle two', () => { + expect(aggregate('median', [3, 1, 2], rows)).toBe(2) + expect(aggregate('median', [1, 2, 3, 4], rows)).toBe(2.5) + expect(aggregate('median', ['10', null, '30'], rows)).toBe(20) + expect(aggregate('median', ['abc'], rows)).toBeNull() + }) + + it('interpolates a percentile the way a spreadsheet does', () => { + const values = [1, 2, 3, 4] + expect(aggregate({ kind: 'percentile', p: 0 }, values, rows)).toBe(1) + expect(aggregate({ kind: 'percentile', p: 1 }, values, rows)).toBe(4) + expect(aggregate({ kind: 'percentile', p: 0.5 }, values, rows)).toBe(2.5) + expect(aggregate({ kind: 'percentile', p: 0.9 }, values, rows)).toBeCloseTo(3.7) + }) + + it('clamps a percentile asked for outside 0 to 1, rather than returning undefined', () => { + expect(aggregate({ kind: 'percentile', p: 90 }, [1, 2, 3], rows)).toBe(3) + expect(aggregate({ kind: 'percentile', p: -1 }, [1, 2, 3], rows)).toBe(1) + expect(aggregate({ kind: 'percentile', p: 0.5 }, [], rows)).toBeNull() + }) + + it('counts distinct non-blank values, matching dates by their instant', () => { + expect(aggregate('distinctCount', ['a', 'b', 'a'], rows)).toBe(2) + expect(aggregate('distinctCount', [null, undefined, ''], rows)).toBe(0) + const day = '2026-08-26T00:00:00.000Z' + expect(aggregate('distinctCount', [new Date(day), new Date(day)], rows)).toBe(1) + }) + + it('returns the first and last value that is actually there', () => { + const values = [null, 'b', '', 'c', undefined] + expect(aggregate('first', values, rows)).toBe('b') + expect(aggregate('last', values, rows)).toBe('c') + expect(aggregate('first', [null, ''], rows)).toBeNull() + expect(aggregate('last', [], rows)).toBeNull() + }) + + it('weights an average by a value read off each row', () => { + interface Score { + score: number + credits: number + } + const scores: Score[] = [ + { score: 8, credits: 3 }, + { score: 6, credits: 1 } + ] + const weight = { kind: 'weightedAvg' as const, weight: (row: Score) => row.credits } + expect(aggregate(weight, [8, 6], scores)).toBe(7.5) + expect(aggregate(weight, [8, 'abc'], scores)).toBe(8) + expect(aggregate({ ...weight, weight: () => 0 }, [8, 6], scores)).toBeNull() + expect(aggregate(weight, [], [])).toBeNull() + }) + + it('supports a custom reducer receiving values and rows', () => { + const joined = aggregate( + (values, groupRows) => `${values.length}/${groupRows.length}`, + [1, 2], + rows + ) + expect(joined).toBe('2/3') + }) +}) diff --git a/src/lib/features/grouping/aggregate.ts b/src/lib/features/grouping/aggregate.ts new file mode 100644 index 0000000..cb01c57 --- /dev/null +++ b/src/lib/features/grouping/aggregate.ts @@ -0,0 +1,103 @@ +import { isBlank, numericOrNull } from '../../core/utils/index.js' +import type { Aggregation } from './grouping.types.js' + +function numbersOf(values: unknown[]): number[] { + const numbers: number[] = [] + for (const value of values) { + const parsed = numericOrNull(value) + if (parsed !== null) numbers.push(parsed) + } + return numbers +} + +function distinctKey(value: unknown): unknown { + return value instanceof Date ? `date:${value.getTime()}` : value +} + +function distinctCount(values: unknown[]): number { + const seen = new Set() + for (const value of values) { + if (!isBlank(value)) seen.add(distinctKey(value)) + } + return seen.size +} + +function percentileOf(numbers: number[], fraction: number): number { + const sorted = [...numbers].sort((left, right) => left - right) + const position = (sorted.length - 1) * Math.min(1, Math.max(0, fraction)) + const lower = Math.floor(position) + const upper = Math.ceil(position) + if (lower === upper) return sorted[lower]! + return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (position - lower) +} + +function weightedAvg( + values: unknown[], + rows: TRow[], + weightOf: (row: TRow) => unknown +): number | null { + let total = 0 + let weights = 0 + const pairs = Math.min(values.length, rows.length) + for (let index = 0; index < pairs; index += 1) { + const value = numericOrNull(values[index]) + const weight = numericOrNull(weightOf(rows[index]!)) + if (value === null || weight === null) continue + total += value * weight + weights += weight + } + return weights === 0 ? null : total / weights +} + +function endValue(values: unknown[], end: 'first' | 'last'): unknown { + const present = values.filter((value) => !isBlank(value)) + if (present.length === 0) return null + return end === 'first' ? present[0] : present[present.length - 1] +} + +function aggregateNumbers( + kind: 'sum' | 'min' | 'max' | 'avg' | 'median', + values: unknown[] +): number | null { + const numbers = numbersOf(values) + if (numbers.length === 0) return null + + switch (kind) { + case 'sum': + return numbers.reduce((total, value) => total + value, 0) + case 'min': + return Math.min(...numbers) + case 'max': + return Math.max(...numbers) + case 'avg': + return numbers.reduce((total, value) => total + value, 0) / numbers.length + case 'median': + return percentileOf(numbers, 0.5) + } +} + +export function aggregate( + aggregation: Aggregation, + values: unknown[], + rows: TRow[] +): unknown { + if (typeof aggregation === 'function') return aggregation(values, rows) + + if (typeof aggregation === 'object') { + if (aggregation.kind === 'weightedAvg') return weightedAvg(values, rows, aggregation.weight) + const numbers = numbersOf(values) + return numbers.length === 0 ? null : percentileOf(numbers, aggregation.p) + } + + switch (aggregation) { + case 'count': + return rows.length + case 'distinctCount': + return distinctCount(values) + case 'first': + case 'last': + return endValue(values, aggregation) + default: + return aggregateNumbers(aggregation, values) + } +} diff --git a/src/lib/features/grouping/group-nodes.test.ts b/src/lib/features/grouping/group-nodes.test.ts new file mode 100644 index 0000000..224ec62 --- /dev/null +++ b/src/lib/features/grouping/group-nodes.test.ts @@ -0,0 +1,148 @@ +import { type ColumnDef, type RowNode } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { buildGroupNodes, groupNodeId } from './group-nodes.js' +import type { Aggregation } from './grouping.types.js' + +function buildRowNodes(data: TRow[], getRowId: (row: TRow) => string): RowNode[] { + return data.map((row, index) => ({ id: getRowId(row), row, index })) +} + +interface Person { + id: number + name: string + dept: string + country: string + salary: number +} + +const people: Person[] = [ + { id: 1, name: 'Alice', dept: 'Core', country: 'VN', salary: 100 }, + { id: 2, name: 'Bob', dept: 'Core', country: 'VN', salary: 200 }, + { id: 3, name: 'Carol', dept: 'Core', country: 'US', salary: 300 }, + { id: 4, name: 'Dave', dept: 'Data', country: 'US', salary: 400 } +] + +const columns: ColumnDef[] = [ + { id: 'name' }, + { id: 'dept' }, + { id: 'country' }, + { id: 'salary' } +] + +const nodes = buildRowNodes(people, (person) => String(person.id)) + +function build( + by: string[], + { + expanded = () => true, + aggregations = {} + }: { + expanded?: (id: string) => boolean + aggregations?: Record> + } = {} +) { + return buildGroupNodes(nodes, { + by, + columns, + aggregations, + isExpanded: expanded, + groupLabel: (key, count) => `${key} (${count})` + }) +} + +describe('buildGroupNodes', () => { + it('passes nodes through when nothing is grouped', () => { + expect(build([])).toBe(nodes) + }) + + it('emits a group row before its members, in first-appearance order', () => { + const result = build(['dept']) + expect(result.map((node) => node.id)).toEqual([ + 'group:dept=Core', + '1', + '2', + '3', + 'group:dept=Data', + '4' + ]) + }) + + it('marks group rows expandable and indents members one level deeper', () => { + const [group, member] = build(['dept']) + expect(group!.meta).toMatchObject({ level: 0, expandable: true, setSize: 2, posInSet: 1 }) + expect(member!.meta).toMatchObject({ level: 1 }) + expect(member!.meta?.expandable).toBeUndefined() + }) + + it('nests several grouped columns', () => { + const result = build(['dept', 'country']) + expect(result.map((node) => node.id)).toEqual([ + 'group:dept=Core', + 'group:dept=Core|country=VN', + '1', + '2', + 'group:dept=Core|country=US', + '3', + 'group:dept=Data', + 'group:dept=Data|country=US', + '4' + ]) + expect(result[1]!.meta).toMatchObject({ level: 1, expandable: true }) + expect(result[2]!.meta).toMatchObject({ level: 2 }) + }) + + it('omits members of collapsed groups', () => { + const result = build(['dept'], { expanded: (id) => id !== 'group:dept=Core' }) + expect(result.map((node) => node.id)).toEqual(['group:dept=Core', 'group:dept=Data', '4']) + }) + + it('labels the grouped column and computes aggregates on the group row', () => { + const result = build(['dept'], { + aggregations: { salary: 'sum', id: 'count' } + }) + const core = result[0]!.row as unknown as Record + expect(core.dept).toBe('Core (3)') + expect(core.salary).toBe(600) + expect(core.id).toBe(3) + }) + + it('aggregates over every descendant when nested', () => { + const result = build(['dept', 'country'], { aggregations: { salary: 'sum' } }) + expect((result[0]!.row as unknown as Record).salary).toBe(600) + expect((result[1]!.row as unknown as Record).salary).toBe(300) + }) + + it('buckets nullish values under (blank)', () => { + const withBlank = buildRowNodes( + [{ id: 9, name: 'X', dept: '', country: 'VN', salary: 1 }], + (person) => String(person.id) + ) + const result = buildGroupNodes(withBlank, { + by: ['dept'], + columns, + aggregations: {}, + isExpanded: () => true, + groupLabel: (key) => key + }) + expect(result[0]!.id).toBe('group:dept=(blank)') + }) + + it('falls back to plain rows for an unknown grouped column', () => { + const result = build(['nope']) + expect(result.map((node) => node.id)).toEqual(['1', '2', '3', '4']) + }) + + it('builds stable ids from the group path', () => { + expect(groupNodeId([{ columnId: 'dept', key: 'Core' }])).toBe('group:dept=Core') + }) + + it('escapes the separator so keys containing it cannot collide', () => { + const nested = groupNodeId([ + { columnId: 'a', key: 'x' }, + { columnId: 'b', key: 'y' } + ]) + const single = groupNodeId([{ columnId: 'a', key: 'x|b=y' }]) + expect(single).not.toBe(nested) + expect(groupNodeId([{ columnId: 'a', key: 'x\\|y' }])).toBe('group:a=x\\\\\\|y') + }) +}) diff --git a/src/lib/features/grouping/group-nodes.ts b/src/lib/features/grouping/group-nodes.ts new file mode 100644 index 0000000..d9458ca --- /dev/null +++ b/src/lib/features/grouping/group-nodes.ts @@ -0,0 +1,107 @@ +import { type ColumnDef, type RowNode } from '../../core/types/index.js' +import { type CellRead, markSyntheticRow, rawRead } from '../../core/grid/index.js' +import { aggregateRowValues, buildFooterNode, groupPathId } from './totals-nodes.js' +import type { Aggregation } from './grouping.types.js' + +export interface BuildGroupNodesOptions { + read?: CellRead + + by: string[] + + columns: ColumnDef[] + + aggregations: Record> + + isExpanded: (groupId: string) => boolean + + groupLabel: (key: string, count: number, columnId: string) => string + + footerLabel?: (key: string, count: number, columnId: string) => string +} + +export function groupNodeId(path: { columnId: string; key: string }[]): string { + return groupPathId('group', path) +} + +function columnById(columns: ColumnDef[], id: string): ColumnDef | undefined { + return columns.find((column) => column.id === id) +} + +function keyOf(node: RowNode, column: ColumnDef, read: CellRead): string { + const value = read(node, column) + return value === null || value === undefined || value === '' ? '(blank)' : String(value) +} + +function partition( + nodes: RowNode[], + column: ColumnDef, + read: CellRead = rawRead +): Map[]> { + const groups = new Map[]>() + for (const node of nodes) { + const key = keyOf(node, column, read) + const bucket = groups.get(key) + if (bucket) bucket.push(node) + else groups.set(key, [node]) + } + return groups +} + +function buildLevel( + nodes: RowNode[], + level: number, + path: { columnId: string; key: string }[], + options: BuildGroupNodesOptions +): RowNode[] { + const columnId = options.by[level] + const column = columnId ? columnById(options.columns, columnId) : undefined + + if (!columnId || !column) { + return nodes.map((node) => ({ ...node, meta: { ...node.meta, level } })) + } + + const groups = partition(nodes, column, options.read) + const setSize = groups.size + const output: RowNode[] = [] + let position = 0 + + for (const [key, members] of groups) { + position += 1 + const groupPath = [...path, { columnId, key }] + const id = groupNodeId(groupPath) + const row = markSyntheticRow({ + ...aggregateRowValues(members, options.columns, options.aggregations, options.read), + [columnId]: options.groupLabel(key, members.length, columnId) + } as unknown as TRow) + + output.push({ + id, + row, + index: members[0]!.index, + meta: { level, expandable: true, setSize, posInSet: position } + }) + + if (options.isExpanded(id)) { + output.push(...buildLevel(members, level + 1, groupPath, options)) + if (options.footerLabel) { + output.push( + buildFooterNode(groupPath, members, level, { + columns: options.columns, + aggregations: options.aggregations, + footerLabel: options.footerLabel + }) + ) + } + } + } + + return output +} + +export function buildGroupNodes( + nodes: RowNode[], + options: BuildGroupNodesOptions +): RowNode[] { + if (options.by.length === 0) return nodes + return buildLevel(nodes, 0, [], options) +} diff --git a/src/lib/features/grouping/grouping.svelte.ts b/src/lib/features/grouping/grouping.svelte.ts new file mode 100644 index 0000000..0957617 --- /dev/null +++ b/src/lib/features/grouping/grouping.svelte.ts @@ -0,0 +1,299 @@ +import { gateReader, type GridState, PIPELINE_ORDER } from '../../core/grid/index.js' +import { + type GridFeature, + type MenuContext, + type MenuItem, + type RowNode, + SELECTION_COLUMN_ID +} from '../../core/types/index.js' +import { buildGroupNodes } from './group-nodes.js' +import { buildGrandTotalNode, totalsKindOf } from './totals-nodes.js' +import { createSeenGroups } from './seen-groups.js' +import type { Aggregation, GroupingOptions } from './grouping.types.js' + +export const GROUPING = 'grouping' + +const defaultLabel = (key: string, count: number): string => `${key} (${count})` + +const SERVER_SCOPE = + 'grouping() on rowModel: "server" groups the rows the client has loaded, not the dataset. ' + + 'Every count and aggregate it shows is scoped to those rows, and the labels say so. ' + + 'Group on the server with serverRowModel({ groupBy }) when the numbers have to be complete.' + +export class Grouping { + by = $state.raw([]) + + aggregations = $state.raw>>({}) + readonly expandedByDefault: boolean + #groupLabel?: (key: string, count: number, columnId: string) => string + readonly groupFooters: boolean + readonly grandTotal: boolean + + #footerLabel?: (key: string, count: number, columnId: string) => string + #grandTotalLabel?: (count: number) => string + + #grid: GridState + #seen = createSeenGroups() + + constructor(grid: GridState, options: GroupingOptions) { + this.#grid = grid + this.by = options.by ?? [] + this.aggregations = options.aggregations ?? {} + this.expandedByDefault = options.expandedByDefault ?? true + this.#groupLabel = options.groupLabel + this.groupFooters = options.groupFooters ?? false + this.#footerLabel = options.footerLabel + this.grandTotal = options.grandTotal ?? false + this.#grandTotalLabel = options.grandTotalLabel + } + + get isPartial(): boolean { + return this.#grid.rowModel === 'server' + } + + #scopeWarned = false + + #scoped(): boolean { + if (!this.isPartial) return false + if (!this.#scopeWarned) { + this.#scopeWarned = true + // eslint-disable-next-line no-console + console.warn(SERVER_SCOPE) + } + return true + } + + groupLabel = (key: string, count: number, columnId: string): string => { + if (this.#groupLabel) return this.#groupLabel(key, count, columnId) + const labels = this.#grid.labels + return this.#scoped() ? labels.groupLoaded(key, count) : defaultLabel(key, count) + } + + footerLabel = (key: string, count: number, columnId: string): string => { + if (this.#footerLabel) return this.#footerLabel(key, count, columnId) + const labels = this.#grid.labels + return this.#scoped() + ? labels.groupFooterLoaded(key, count) + : labels.groupFooter(key, count) + } + + grandTotalLabel = (count: number): string => { + if (this.#grandTotalLabel) return this.#grandTotalLabel(count) + const labels = this.#grid.labels + return this.#scoped() ? labels.grandTotalLoaded(count) : labels.grandTotal(count) + } + + get isGrouped(): boolean { + return this.by.length > 0 + } + + isGroupedBy(columnId: string): boolean { + return this.by.includes(columnId) + } + + groupableColumns(): { id: string; header: string }[] { + return this.#grid.columns.visible + .filter((column) => !this.by.includes(column.id)) + .map((column) => ({ id: column.id, header: column.header })) + } + + setGroupBy = (columnIds: string[]): void => { + this.by = [...columnIds] + } + + groupBy = (columnId: string): void => { + if (this.by.includes(columnId)) return + this.by = [...this.by, columnId] + } + + ungroup = (columnId: string): void => { + this.by = this.by.filter((id) => id !== columnId) + } + + moveGroup = (columnId: string, offset: number): void => { + const from = this.by.indexOf(columnId) + if (from < 0) return + const to = from + offset + if (to < 0 || to >= this.by.length) return + const next = [...this.by] + next.splice(from, 1) + next.splice(to, 0, columnId) + this.by = next + } + + clearGrouping = (): void => { + this.by = [] + } + + expandAllGroups = (): void => { + const expansion = this.#grid.expansion + for (let level = 0; level < Math.max(1, this.by.length); level++) { + const ids = this.#grid.preWindowNodes + .filter((node) => node.meta?.expandable) + .map((node) => node.id) + expansion.expandAll([...expansion.expandedIds, ...ids]) + } + } + + collapseAllGroups = (): void => { + this.#grid.expansion.collapseAll() + } + + setAggregation = (columnId: string, aggregation: Aggregation | null): void => { + const next = { ...this.aggregations } + if (aggregation === null) delete next[columnId] + else next[columnId] = aggregation + this.aggregations = next + } + + buildNodes = (nodes: RowNode[]): RowNode[] => { + const pending: string[] = [] + + const result = buildGroupNodes(nodes, { + read: gateReader(this.#grid, 'render'), + by: this.by, + columns: this.#grid.columns.all.map((column) => column.def), + aggregations: this.aggregations, + isExpanded: (groupId) => { + if (this.#grid.expansion.isExpanded(groupId)) return true + if (!this.expandedByDefault || this.#seen.has(groupId)) return false + pending.push(groupId) + return true + }, + groupLabel: this.groupLabel, + footerLabel: this.groupFooters ? this.footerLabel : undefined + }) + + if (pending.length > 0) { + const expansion = this.#grid.expansion + queueMicrotask(() => { + for (const id of pending) this.#seen.add(id) + expansion.expandAll([...expansion.expandedIds, ...pending]) + }) + } + return result + } + + grandTotalNode = (leaves: RowNode[]): RowNode => { + const labelColumnId = + this.by[0] ?? + this.#grid.columns.visible.find( + (column) => column.id !== SELECTION_COLUMN_ID && !(column.id in this.aggregations) + )?.id + + return buildGrandTotalNode(leaves, { + read: gateReader(this.#grid, 'render'), + columns: this.#grid.columns.all.map((column) => column.def), + aggregations: this.aggregations, + labelColumnId, + grandTotalLabel: this.grandTotalLabel + }) + } +} + +function createMenuItems(ctx: MenuContext): MenuItem[] { + const state = getGrouping(ctx.grid) + const columnId = ctx.columnId + if (!state || !columnId) return [] + + if (state.isGroupedBy(columnId)) { + return [ + { + id: 'ungroup-column', + label: ctx.grid.labels.ungroupColumn, + icon: 'lucide:ungroup', + onSelect: () => state.ungroup(columnId) + } + ] + } + return [ + { + id: 'group-by-column', + label: ctx.grid.labels.groupByColumn, + icon: 'lucide:group', + onSelect: () => state.groupBy(columnId) + } + ] +} + +export function grouping(options: GroupingOptions = {}): GridFeature { + return { + id: GROUPING, + createState: (grid) => { + grid.expansion.enabled = true + return new Grouping(grid, options) + }, + createApi: (grid) => { + const state = getGrouping(grid)! + return { + groupBy: state.groupBy, + ungroup: state.ungroup, + setGroupBy: state.setGroupBy, + clearGrouping: state.clearGrouping, + expandAllGroups: state.expandAllGroups, + collapseAllGroups: state.collapseAllGroups, + isGroupingPartial: () => state.isPartial, + setAggregation: (columnId: string, aggregation: unknown) => + state.setAggregation(columnId, aggregation as Aggregation | null), + getAggregations: () => state.aggregations + } + }, + menuItems: createMenuItems, + serialize: (grid) => { + const by = getGrouping(grid)?.by ?? [] + return by.length > 0 ? { by } : undefined + }, + hydrate: (slice, grid) => { + const state = getGrouping(grid) + if (!state || typeof slice !== 'object' || slice === null) return + const by = (slice as { by?: unknown }).by + if (!Array.isArray(by)) return + state.setGroupBy( + by.filter( + (columnId): columnId is string => + typeof columnId === 'string' && grid.columns.get(columnId) !== undefined + ) + ) + }, + pipelineStage: { + order: PIPELINE_ORDER.group, + transform: (nodes, grid) => { + const state = getGrouping(grid) + if (!state) return nodes + const grouped = state.isGrouped ? state.buildNodes(nodes) : nodes + if (!state.grandTotal || nodes.length === 0) return grouped + return [...grouped, state.grandTotalNode(nodes)] + } + }, + cellDecoration: (ctx) => { + const kind = totalsKindOf(ctx.node.id) + if (kind === 'footer') { + return { class: 'bg-surface-container/40 font-medium' } + } + if (kind === 'grandTotal') { + return { + class: 'border-t border-outline-variant bg-surface-container/60 font-semibold' + } + } + return undefined + } + } +} + +export function getGrouping(grid: GridState): Grouping | undefined { + return grid.feature>(GROUPING) +} + +declare module '../../core/types/api.js' { + interface GridApi { + groupBy?: (columnId: string) => void + ungroup?: (columnId: string) => void + setGroupBy?: (columnIds: string[]) => void + clearGrouping?: () => void + expandAllGroups?: () => void + collapseAllGroups?: () => void + isGroupingPartial?: () => boolean + setAggregation?: (columnId: string, aggregation: unknown) => void + getAggregations?: () => Record + } +} diff --git a/src/lib/features/grouping/grouping.test.ts b/src/lib/features/grouping/grouping.test.ts new file mode 100644 index 0000000..f31f5f0 --- /dev/null +++ b/src/lib/features/grouping/grouping.test.ts @@ -0,0 +1,308 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { sorting } from '../../features/sorting/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it, vi } from 'vitest' +import { getGrouping, grouping } from './grouping.svelte.js' +import type { GroupingOptions } from './grouping.types.js' + +interface Person { + id: number + name: string + dept: string + country: string + salary: number +} + +const people: Person[] = [ + { id: 1, name: 'Alice', dept: 'Core', country: 'VN', salary: 100 }, + { id: 2, name: 'Bob', dept: 'Core', country: 'VN', salary: 200 }, + { id: 3, name: 'Carol', dept: 'Core', country: 'US', salary: 300 }, + { id: 4, name: 'Dave', dept: 'Data', country: 'US', salary: 400 } +] + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + { id: 'dept', header: 'Dept' }, + { id: 'country', header: 'Country' }, + { id: 'salary', header: 'Salary' } +] + +function createGrid(options: GroupingOptions = {}): GridState { + return createDataGrid({ + columns, + data: people, + getRowId: (person) => String(person.id), + features: [sorting(), grouping({ expandedByDefault: false, ...options })] + }) +} + +const ids = (grid: GridState) => grid.nodes.map((node) => node.id) + +describe('grouping feature', () => { + it('leaves the pipeline untouched when nothing is grouped', () => { + const grid = createGrid() + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + expect(getGrouping(grid)!.isGrouped).toBe(false) + }) + + it('turns on treegrid mode through the kernel expansion model', () => { + expect(createGrid().expansion.enabled).toBe(true) + }) + + it('emits collapsed group rows, then children once expanded', () => { + const grid = createGrid({ by: ['dept'] }) + expect(ids(grid)).toEqual(['group:dept=Core', 'group:dept=Data']) + + grid.expansion.expand('group:dept=Core') + expect(ids(grid)).toEqual(['group:dept=Core', '1', '2', '3', 'group:dept=Data']) + }) + + it('groups and ungroups through the api', () => { + const grid = createGrid() + const state = getGrouping(grid)! + + state.groupBy('dept') + expect(state.by).toEqual(['dept']) + state.groupBy('dept') + expect(state.by).toEqual(['dept']) + + state.groupBy('country') + expect(ids(grid)).toEqual(['group:dept=Core', 'group:dept=Data']) + + state.ungroup('dept') + expect(state.by).toEqual(['country']) + expect(ids(grid)).toEqual(['group:country=VN', 'group:country=US']) + + state.clearGrouping() + expect(ids(grid)).toEqual(['1', '2', '3', '4']) + }) + + it('reorders grouping levels', () => { + const state = getGrouping(createGrid({ by: ['dept', 'country'] }))! + state.moveGroup('country', -1) + expect(state.by).toEqual(['country', 'dept']) + state.moveGroup('country', -1) + expect(state.by).toEqual(['country', 'dept']) + state.moveGroup('country', 1) + expect(state.by).toEqual(['dept', 'country']) + }) + + it('lists only columns that are not grouped yet', () => { + const state = getGrouping(createGrid({ by: ['dept'] }))! + expect(state.groupableColumns().map((column) => column.id)).toEqual([ + 'name', + 'country', + 'salary' + ]) + }) + + it('exposes aggregates on the group row', () => { + const grid = createGrid({ by: ['dept'], aggregations: { salary: 'sum', id: 'count' } }) + const core = grid.nodes[0]!.row as unknown as Record + expect(core).toMatchObject({ dept: 'Core (3)', salary: 600, id: 3 }) + }) + + it('puts the advanced aggregators on the group row too', () => { + const grid = createGrid({ + by: ['dept'], + aggregations: { salary: 'median', country: 'distinctCount', name: 'first' } + }) + const core = grid.nodes[0]!.row as unknown as Record + expect(core).toMatchObject({ salary: 200, country: 2, name: 'Alice' }) + }) + + it('reads the weight off the row, not off the aggregated column', () => { + const grid = createGrid({ + by: ['dept'], + aggregations: { salary: { kind: 'weightedAvg', weight: (person) => person.id } } + }) + const core = grid.nodes[0]!.row as unknown as Record + expect(core.salary).toBeCloseTo((100 * 1 + 200 * 2 + 300 * 3) / 6) + }) + + it('groups by a hidden column, which is what grouping usually does to it', () => { + const grid = createDataGrid({ + columns: columns.map((column) => + column.id === 'dept' ? { ...column, hidden: true } : column + ), + data: people, + getRowId: (person) => String(person.id), + features: [ + sorting(), + grouping({ + expandedByDefault: false, + by: ['dept'], + aggregations: { salary: 'sum' } + }) + ] + }) + expect(ids(grid)).toEqual(['group:dept=Core', 'group:dept=Data']) + expect((grid.nodes[0]!.row as unknown as Record).salary).toBe(600) + }) + + it('aggregates a hidden column instead of reading it as empty', () => { + const grid = createDataGrid({ + columns: columns.map((column) => + column.id === 'salary' ? { ...column, hidden: true } : column + ), + data: people, + getRowId: (person) => String(person.id), + features: [ + sorting(), + grouping({ + expandedByDefault: false, + by: ['dept'], + aggregations: { salary: 'sum' } + }) + ] + }) + expect((grid.nodes[0]!.row as unknown as Record).salary).toBe(600) + }) + + it('takes a new aggregation at runtime, and drops one on null', () => { + const grid = createGrid({ by: ['dept'], aggregations: { salary: 'sum' } }) + const state = getGrouping(grid)! + const groupRow = () => grid.nodes[0]!.row as unknown as Record + + expect(groupRow().salary).toBe(600) + + state.setAggregation('salary', 'median') + expect(groupRow().salary).toBe(200) + + const setAggregation = grid.api.setAggregation as (id: string, next: unknown) => void + setAggregation('salary', 'max') + expect(groupRow().salary).toBe(300) + + setAggregation('salary', null) + expect(groupRow().salary).toBeUndefined() + }) + + it('recomputes aggregates when the grouping columns change', () => { + const grid = createGrid({ by: ['dept'], aggregations: { salary: 'sum' } }) + const setGroupBy = grid.api.setGroupBy as (columnIds: string[]) => void + + setGroupBy(['country']) + const first = grid.nodes[0]!.row as unknown as Record + expect(first.country).toBe('VN (2)') + expect(first.salary).toBe(300) + }) + + it('contributes group/ungroup context-menu items per column', () => { + const grid = createGrid() + const feature = grid.features.find((entry) => entry.id === 'grouping')! + + expect(feature.menuItems!({ grid, columnId: 'dept' }).map((item) => item.id)).toEqual([ + 'group-by-column' + ]) + feature.menuItems!({ grid, columnId: 'dept' })[0]!.onSelect() + expect(feature.menuItems!({ grid, columnId: 'dept' }).map((item) => item.id)).toEqual([ + 'ungroup-column' + ]) + expect(feature.menuItems!({ grid })).toEqual([]) + }) + + it('expands every group and collapses them again', () => { + const grid = createGrid({ by: ['dept'] }) + const state = getGrouping(grid)! + + state.expandAllGroups() + expect(ids(grid)).toEqual(['group:dept=Core', '1', '2', '3', 'group:dept=Data', '4']) + + state.collapseAllGroups() + expect(ids(grid)).toEqual(['group:dept=Core', 'group:dept=Data']) + }) +}) + +describe('state round-trip', () => { + function twoGrids() { + return [createGrid({ by: ['dept'] }), createGrid()] as const + } + + it('carries the grouping into a snapshot and back out', () => { + const [saved, fresh] = twoGrids() + const snapshot = saved.api.getState() + + expect(snapshot.features?.grouping).toEqual({ by: ['dept'] }) + fresh.api.setState(snapshot) + expect(getGrouping(fresh)!.by).toEqual(['dept']) + }) + + it('leaves an ungrouped grid out of the snapshot entirely', () => { + expect(createGrid().api.getState().features?.grouping).toBeUndefined() + }) + + it('drops a column the grid no longer has', () => { + const fresh = createGrid() + fresh.api.setState({ + version: 1, + features: { grouping: { by: ['dept', 'gone'] } } + }) + expect(getGrouping(fresh)!.by).toEqual(['dept']) + }) + + it('ignores a slice of the wrong shape, leaving the grouping alone', () => { + for (const slice of [null, 'dept', 42, { by: 'dept' }, {}]) { + const fresh = createGrid({ by: ['dept'] }) + fresh.api.setState({ version: 1, features: { grouping: slice } }) + expect(getGrouping(fresh)!.by, JSON.stringify(slice)).toEqual(['dept']) + } + }) + + it('reads a list of ids the grid does not have as no grouping', () => { + const fresh = createGrid({ by: ['dept'] }) + fresh.api.setState({ version: 1, features: { grouping: { by: [1, 2] } } }) + expect(getGrouping(fresh)!.by).toEqual([]) + }) +}) + +describe('grouping a grid whose rows come from a server', () => { + function serverGrid(options: GroupingOptions = {}): GridState { + return createDataGrid({ + columns, + data: people, + getRowId: (person) => String(person.id), + rowModel: 'server', + features: [ + sorting(), + virtualization(), + grouping({ by: ['dept'], expandedByDefault: false, ...options }) + ] + }) + } + + it('says every count is scoped to the rows it has, and warns once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = serverGrid({ groupFooters: true, grandTotal: true }) + + const label = (grid.nodes[0]!.row as unknown as Record).dept + expect(label).toBe('Core (3 loaded)') + + const total = grid.nodes.at(-1)!.row as unknown as Record + expect(String(total.dept)).toContain('loaded') + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('groups the rows the client has loaded') + expect(getGrouping(grid)!.isPartial).toBe(true) + expect((grid.api.isGroupingPartial as () => boolean)()).toBe(true) + warn.mockRestore() + }) + + it('leaves a label the app wrote alone, scoped or not', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = serverGrid({ groupLabel: (key, count) => `${key}: ${count}` }) + + expect((grid.nodes[0]!.row as unknown as Record).dept).toBe('Core: 3') + warn.mockRestore() + }) + + it('says nothing of the sort on a client grid', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = createGrid({ by: ['dept'] }) + + expect((grid.nodes[0]!.row as unknown as Record).dept).toBe('Core (3)') + expect(getGrouping(grid)!.isPartial).toBe(false) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/lib/features/grouping/grouping.types.ts b/src/lib/features/grouping/grouping.types.ts new file mode 100644 index 0000000..55549e8 --- /dev/null +++ b/src/lib/features/grouping/grouping.types.ts @@ -0,0 +1,88 @@ +/** + * Built-in aggregators, or a custom reducer over the group's values and rows. + * + * `sum`, `min`, `max`, `avg` and `median` read the numeric values and ignore + * anything that is not a number; they return `null` when none is left. + * `count` counts the group's rows, `distinctCount` counts distinct non-blank + * values, and `first` / `last` return the first and last non-blank value in + * the group's order. + * + * `{ kind: 'percentile', p }` takes `p` as a fraction between 0 and 1 - + * `0.9` is the 90th percentile - and interpolates between the two + * neighbouring values the way a spreadsheet does. `{ kind: 'weightedAvg', + * weight }` reads each row's weight and returns `sum(value * weight) / + * sum(weight)`, skipping any pair where either side is not numeric and + * returning `null` when the weights total zero. + */ +export type Aggregation = + | 'sum' + | 'min' + | 'max' + | 'avg' + | 'count' + | 'median' + | 'distinctCount' + | 'first' + | 'last' + | { kind: 'percentile'; p: number } + | { kind: 'weightedAvg'; weight: (row: TRow) => unknown } + | ((values: unknown[], rows: TRow[]) => unknown) + +export interface GroupingOptions { + /** + * Column ids to group by, outermost first. + * @default [] + */ + by?: string[] + + /** + * Aggregation per column id, shown on the group row. + * Keyed by column id because Pro cannot extend the Community `ColumnDef`. + */ + aggregations?: Record> + + /** + * Expand every group the first time it appears. + * @default true + */ + expandedByDefault?: boolean + + /** + * Label shown on a group row, before the aggregates. + * @default (key, count) => `${key} (${count})` + */ + groupLabel?: (key: string, count: number, columnId: string) => string + + /** + * Close every expanded group with a footer row carrying the group's + * aggregates, Excel-style. + * @default false + */ + groupFooters?: boolean + + /** + * Label in the grouped column's cell of a footer row. + * @default (key) => `Total - ${key}` + */ + footerLabel?: (key: string, count: number, columnId: string) => string + + /** + * Append a grand-total row aggregating every leaf row after the last + * row. Works with or without active grouping, and reflects the current + * filter. The row is in-flow - it scrolls with the data. + * @default false + */ + grandTotal?: boolean + + /** + * Label on the grand-total row. Rendered in the first grouped column, + * or in the first visible non-aggregated column when not grouped. + * @default (count) => `Total (${count})` + */ + grandTotalLabel?: (count: number) => string +} + +/** Row object synthesised for a group row. */ +export interface GroupRowValues { + [columnId: string]: unknown +} diff --git a/src/lib/features/grouping/index.ts b/src/lib/features/grouping/index.ts new file mode 100644 index 0000000..9c7dcc7 --- /dev/null +++ b/src/lib/features/grouping/index.ts @@ -0,0 +1,15 @@ +export { aggregate } from './aggregate.js' +export { buildGroupNodes, groupNodeId, type BuildGroupNodesOptions } from './group-nodes.js' +export { getGrouping, GROUPING, grouping, Grouping } from './grouping.svelte.js' +export type { Aggregation, GroupingOptions, GroupRowValues } from './grouping.types.js' +export { createSeenGroups, type SeenGroups } from './seen-groups.js' +export { + aggregateRowValues, + buildFooterNode, + buildGrandTotalNode, + footerNodeId, + GRAND_TOTAL_ID, + totalsKindOf, + type BuildFooterNodeOptions, + type BuildGrandTotalNodeOptions +} from './totals-nodes.js' diff --git a/src/lib/features/grouping/seen-groups.ts b/src/lib/features/grouping/seen-groups.ts new file mode 100644 index 0000000..9f83cee --- /dev/null +++ b/src/lib/features/grouping/seen-groups.ts @@ -0,0 +1,14 @@ +export interface SeenGroups { + has: (id: string) => boolean + add: (id: string) => void +} + +export function createSeenGroups(): SeenGroups { + const seen = new Set() + return { + has: (id) => seen.has(id), + add: (id) => { + seen.add(id) + } + } +} diff --git a/src/lib/features/grouping/totals-nodes.test.ts b/src/lib/features/grouping/totals-nodes.test.ts new file mode 100644 index 0000000..16bfea8 --- /dev/null +++ b/src/lib/features/grouping/totals-nodes.test.ts @@ -0,0 +1,235 @@ +import { createDataGrid, isSyntheticRow } from '../../core/grid/index.js' +import { type ColumnDef, type RowNode } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { describe, expect, it } from 'vitest' +import { buildGroupNodes } from './group-nodes.js' +import { grouping } from './grouping.svelte.js' +import { + aggregateRowValues, + buildFooterNode, + buildGrandTotalNode, + footerNodeId, + GRAND_TOTAL_ID, + totalsKindOf +} from './totals-nodes.js' +import type { Aggregation } from './grouping.types.js' + +function buildRowNodes(data: TRow[], getRowId: (row: TRow) => string): RowNode[] { + return data.map((row, index) => ({ id: getRowId(row), row, index })) +} + +interface Person { + id: number + name: string + dept: string + country: string + salary: number +} + +const people: Person[] = [ + { id: 1, name: 'Alice', dept: 'Core', country: 'VN', salary: 100 }, + { id: 2, name: 'Bob', dept: 'Core', country: 'VN', salary: 200 }, + { id: 3, name: 'Carol', dept: 'Core', country: 'US', salary: 300 }, + { id: 4, name: 'Dave', dept: 'Data', country: 'US', salary: 400 } +] + +const columns: ColumnDef[] = [ + { id: 'name' }, + { id: 'dept' }, + { id: 'country' }, + { id: 'salary' } +] + +const nodes = buildRowNodes(people, (person) => String(person.id)) + +const salarySum: Record> = { salary: 'sum' } + +describe('totalsKindOf', () => { + it('classifies footer, grand-total and ordinary ids', () => { + expect(totalsKindOf(footerNodeId([{ columnId: 'dept', key: 'Core' }]))).toBe('footer') + expect(totalsKindOf(GRAND_TOTAL_ID)).toBe('grandTotal') + expect(totalsKindOf('group:dept=Core')).toBeNull() + expect(totalsKindOf('42')).toBeNull() + }) +}) + +describe('aggregateRowValues', () => { + it('runs every aggregation keyed by column id', () => { + const values = aggregateRowValues(nodes, columns, { + salary: 'sum', + name: 'count' + }) + expect(values).toEqual({ salary: 1000, name: 4 }) + }) + + it('hands values and rows to a custom reducer', () => { + const values = aggregateRowValues(nodes, columns, { + salary: (cells, rows) => `${cells.length}/${rows.length}` + }) + expect(values.salary).toBe('4/4') + }) + + it('aggregates over an empty cell list for an unknown column', () => { + const values = aggregateRowValues(nodes, columns, { missing: 'sum' }) + expect(values.missing).toBeNull() + }) +}) + +describe('buildFooterNode', () => { + it('carries the aggregates and the footer label one level below the group', () => { + const path = [{ columnId: 'dept', key: 'Core' }] + const members = nodes.slice(0, 3) + const footer = buildFooterNode(path, members, 0, { + columns, + aggregations: salarySum, + footerLabel: (key, count) => `Total ${key} (${count})` + }) + + expect(footer.id).toBe('footer:dept=Core') + expect(footer.meta).toEqual({ level: 1 }) + expect(footer.row).toMatchObject({ salary: 600, dept: 'Total Core (3)' }) + expect(isSyntheticRow(footer.row)).toBe(true) + }) +}) + +describe('buildGroupNodes with footers', () => { + function build(by: string[], expanded: (id: string) => boolean = () => true) { + return buildGroupNodes(nodes, { + by, + columns, + aggregations: salarySum, + isExpanded: expanded, + groupLabel: (key, count) => `${key} (${count})`, + footerLabel: (key) => `Total - ${key}` + }) + } + + it('closes each expanded group with its footer', () => { + expect(build(['dept']).map((node) => node.id)).toEqual([ + 'group:dept=Core', + '1', + '2', + '3', + 'footer:dept=Core', + 'group:dept=Data', + '4', + 'footer:dept=Data' + ]) + }) + + it('omits the footer of a collapsed group', () => { + const result = build(['dept'], (id) => id !== 'group:dept=Core') + expect(result.map((node) => node.id)).toEqual([ + 'group:dept=Core', + 'group:dept=Data', + '4', + 'footer:dept=Data' + ]) + }) + + it('nests footers per level, inner before outer', () => { + const ids = build(['dept', 'country']).map((node) => node.id) + expect(ids).toEqual([ + 'group:dept=Core', + 'group:dept=Core|country=VN', + '1', + '2', + 'footer:dept=Core|country=VN', + 'group:dept=Core|country=US', + '3', + 'footer:dept=Core|country=US', + 'footer:dept=Core', + 'group:dept=Data', + 'group:dept=Data|country=US', + '4', + 'footer:dept=Data|country=US', + 'footer:dept=Data' + ]) + }) +}) + +describe('buildGrandTotalNode', () => { + it('aggregates every leaf and writes the label into the label column', () => { + const total = buildGrandTotalNode(nodes, { + columns, + aggregations: salarySum, + labelColumnId: 'name', + grandTotalLabel: (count) => `Total (${count})` + }) + + expect(total.id).toBe(GRAND_TOTAL_ID) + expect(total.row).toMatchObject({ salary: 1000, name: 'Total (4)' }) + }) + + it('shows aggregates only when no label column is free', () => { + const total = buildGrandTotalNode(nodes, { + columns, + aggregations: salarySum, + grandTotalLabel: (count) => `Total (${count})` + }) + expect(total.row).toMatchObject({ salary: 1000 }) + }) +}) + +describe('grouping feature with totals', () => { + function createGrid(options: Parameters>[0] = {}) { + return createDataGrid({ + columns, + data: people, + getRowId: (person) => String(person.id), + features: [filtering(), grouping(options)] + }) + } + + it('appends the grand-total row after the leaf rows when not grouped', () => { + const grid = createGrid({ grandTotal: true, aggregations: salarySum }) + const last = grid.nodes[grid.nodes.length - 1] + + expect(last!.id).toBe(GRAND_TOTAL_ID) + expect((last!.row as unknown as Record).salary).toBe(1000) + + expect((last!.row as unknown as Record).name).toBe('Total (4)') + }) + + it('labels the grand total in the first grouped column when grouped', () => { + const grid = createGrid({ by: ['dept'], grandTotal: true, aggregations: salarySum }) + const last = grid.nodes[grid.nodes.length - 1] + + expect(last!.id).toBe(GRAND_TOTAL_ID) + expect((last!.row as unknown as Record).dept).toBe('Total (4)') + }) + + it('reflects the active filter in footers and the grand total', () => { + const grid = createGrid({ + by: ['dept'], + groupFooters: true, + grandTotal: true, + aggregations: salarySum + }) + ;(grid.api.setQuickFilter as (query: string) => void)('Core') + + const withFilter = () => { + const ids = grid.nodes.map((node) => node.id) + const total = grid.nodes[grid.nodes.length - 1]!.row as unknown as Record< + string, + unknown + > + return { ids, total } + } + + const { ids, total } = withFilter() + expect(ids).toContain('footer:dept=Core') + expect(ids).not.toContain('group:dept=Data') + expect(total.salary).toBe(600) + }) + + it('emits no grand total for an empty grid', () => { + const grid = createDataGrid({ + columns, + data: [], + getRowId: (person) => String(person.id), + features: [grouping({ grandTotal: true, aggregations: salarySum })] + }) + expect(grid.nodes).toHaveLength(0) + }) +}) diff --git a/src/lib/features/grouping/totals-nodes.ts b/src/lib/features/grouping/totals-nodes.ts new file mode 100644 index 0000000..f3b96a9 --- /dev/null +++ b/src/lib/features/grouping/totals-nodes.ts @@ -0,0 +1,96 @@ +import { type ColumnDef, type RowNode } from '../../core/types/index.js' +import { type CellRead, markSyntheticRow, rawRead } from '../../core/grid/index.js' +import { aggregate } from './aggregate.js' +import type { Aggregation, GroupRowValues } from './grouping.types.js' + +export function groupPathId(prefix: string, path: { columnId: string; key: string }[]): string { + const parts = path.map( + (part) => `${part.columnId}=${part.key.replaceAll('\\', '\\\\').replaceAll('|', '\\|')}` + ) + return `${prefix}:${parts.join('|')}` +} + +export function footerNodeId(path: { columnId: string; key: string }[]): string { + return groupPathId('footer', path) +} + +export const GRAND_TOTAL_ID = 'total:grand' + +export function totalsKindOf(id: string): 'footer' | 'grandTotal' | null { + if (id === GRAND_TOTAL_ID) return 'grandTotal' + return id.startsWith('footer:') ? 'footer' : null +} + +export function aggregateRowValues( + members: RowNode[], + columns: ColumnDef[], + aggregations: Record>, + read: CellRead = rawRead +): GroupRowValues { + const values: GroupRowValues = {} + const rows = members.map((node) => node.row) + for (const [columnId, aggregation] of Object.entries(aggregations)) { + const column = columns.find((candidate) => candidate.id === columnId) + const cells = column ? members.map((node) => read(node, column)) : [] + values[columnId] = aggregate(aggregation, cells, rows) + } + return values +} + +export interface BuildFooterNodeOptions { + read?: CellRead + columns: ColumnDef[] + + aggregations: Record> + + footerLabel: (key: string, count: number, columnId: string) => string +} + +export function buildFooterNode( + path: { columnId: string; key: string }[], + members: RowNode[], + level: number, + options: BuildFooterNodeOptions +): RowNode { + const { columnId, key } = path[path.length - 1]! + const row = markSyntheticRow({ + ...aggregateRowValues(members, options.columns, options.aggregations, options.read), + [columnId]: options.footerLabel(key, members.length, columnId) + } as unknown as TRow) + + return { + id: footerNodeId(path), + row, + index: members[0]!.index, + meta: { level: level + 1 } + } +} + +export interface BuildGrandTotalNodeOptions { + read?: CellRead + + columns: ColumnDef[] + + aggregations: Record> + + labelColumnId?: string + + grandTotalLabel: (count: number) => string +} + +export function buildGrandTotalNode( + leaves: RowNode[], + options: BuildGrandTotalNodeOptions +): RowNode { + const values = aggregateRowValues(leaves, options.columns, options.aggregations, options.read) + if (options.labelColumnId !== undefined) { + values[options.labelColumnId] = options.grandTotalLabel(leaves.length) + } + + return { + id: GRAND_TOTAL_ID, + row: markSyntheticRow(values as unknown as TRow), + index: leaves.length > 0 ? leaves[0]!.index : 0, + meta: { level: 0 } + } +} diff --git a/src/lib/features/master-detail/detail-nodes.test.ts b/src/lib/features/master-detail/detail-nodes.test.ts new file mode 100644 index 0000000..68ad050 --- /dev/null +++ b/src/lib/features/master-detail/detail-nodes.test.ts @@ -0,0 +1,55 @@ +import { type RowNode } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { buildDetailNodes, detailNodeId, isDetailNode, masterIdOf } from './detail-nodes.js' + +interface Row { + id: string + lines: number +} + +const nodes: RowNode[] = [ + { id: '1', index: 0, row: { id: '1', lines: 2 } }, + { id: '2', index: 1, row: { id: '2', lines: 0 } }, + { id: '3', index: 2, row: { id: '3', lines: 5 } } +] + +const ids = (list: RowNode[]) => list.map((node) => node.id) + +describe('detail ids', () => { + it('round-trips a master id', () => { + const id = detailNodeId('order-7') + expect(isDetailNode(id)).toBe(true) + expect(masterIdOf(id)).toBe('order-7') + expect(isDetailNode('order-7')).toBe(false) + }) +}) + +describe('buildDetailNodes', () => { + const hasDetail = (row: Row) => row.lines > 0 + + it('inserts a detail row under each expanded master', () => { + const result = buildDetailNodes(nodes, { hasDetail, isExpanded: (id) => id === '1' }) + expect(ids(result)).toEqual(['1', 'detail:1', '2', '3']) + }) + + it('marks the detail row full-width so the kernel renders the snippet', () => { + const result = buildDetailNodes(nodes, { hasDetail, isExpanded: (id) => id === '1' }) + expect(result[1]!.meta).toMatchObject({ fullWidth: true }) + expect(result[1]!.row).toBe(nodes[0]!.row) + }) + + it('gives a chevron only to rows that have something to show', () => { + const result = buildDetailNodes(nodes, { hasDetail, isExpanded: () => false }) + expect(result.map((node) => node.meta?.expandable)).toEqual([true, undefined, true]) + }) + + it('never opens a panel for a row without detail', () => { + const result = buildDetailNodes(nodes, { hasDetail, isExpanded: () => true }) + expect(ids(result)).toEqual(['1', 'detail:1', '2', '3', 'detail:3']) + }) + + it('leaves the list alone when nothing is expanded', () => { + const result = buildDetailNodes(nodes, { hasDetail, isExpanded: () => false }) + expect(ids(result)).toEqual(['1', '2', '3']) + }) +}) diff --git a/src/lib/features/master-detail/detail-nodes.ts b/src/lib/features/master-detail/detail-nodes.ts new file mode 100644 index 0000000..d321235 --- /dev/null +++ b/src/lib/features/master-detail/detail-nodes.ts @@ -0,0 +1,44 @@ +import { type RowNode } from '../../core/types/index.js' + +export const DETAIL_ID_PREFIX = 'detail:' + +export function detailNodeId(masterId: string): string { + return `${DETAIL_ID_PREFIX}${masterId}` +} + +export function isDetailNode(id: string): boolean { + return id.startsWith(DETAIL_ID_PREFIX) +} + +export function masterIdOf(detailId: string): string { + return detailId.slice(DETAIL_ID_PREFIX.length) +} + +export interface BuildDetailOptions { + hasDetail: (row: TRow) => boolean + + isExpanded: (id: string) => boolean +} + +export function buildDetailNodes( + nodes: RowNode[], + options: BuildDetailOptions +): RowNode[] { + const result: RowNode[] = [] + + for (const node of nodes) { + const expandable = options.hasDetail(node.row) + result.push(expandable ? { ...node, meta: { ...node.meta, expandable, level: 0 } } : node) + + if (expandable && options.isExpanded(node.id)) { + result.push({ + id: detailNodeId(node.id), + row: node.row, + index: node.index, + meta: { ...node.meta, fullWidth: true, level: 1 } + }) + } + } + + return result +} diff --git a/src/lib/features/master-detail/index.ts b/src/lib/features/master-detail/index.ts new file mode 100644 index 0000000..1c547f5 --- /dev/null +++ b/src/lib/features/master-detail/index.ts @@ -0,0 +1,15 @@ +export { + buildDetailNodes, + detailNodeId, + DETAIL_ID_PREFIX, + isDetailNode, + masterIdOf, + type BuildDetailOptions +} from './detail-nodes.js' +export { + getMasterDetail, + MASTER_DETAIL, + masterDetail, + MasterDetail +} from './master-detail.svelte.js' +export type { MasterDetailOptions } from './master-detail.types.js' diff --git a/src/lib/features/master-detail/master-detail.svelte.ts b/src/lib/features/master-detail/master-detail.svelte.ts new file mode 100644 index 0000000..e2b87d1 --- /dev/null +++ b/src/lib/features/master-detail/master-detail.svelte.ts @@ -0,0 +1,79 @@ +import { type GridState, PIPELINE_ORDER } from '../../core/grid/index.js' +import { type GridFeature, type RowNode } from '../../core/types/index.js' +import { buildDetailNodes, isDetailNode, masterIdOf } from './detail-nodes.js' +import type { MasterDetailOptions } from './master-detail.types.js' + +export const MASTER_DETAIL = 'masterDetail' + +export class MasterDetail { + readonly single: boolean + + #grid: GridState + #hasDetail: (row: TRow) => boolean + + constructor(grid: GridState, options: MasterDetailOptions) { + this.#grid = grid + this.#hasDetail = options.hasDetail ?? (() => true) + this.single = options.single ?? false + + if (this.single) { + grid.events.on('rowExpanded', ({ id, expanded }) => { + if (!expanded) return + + const others = [...grid.expansion.expandedIds].filter( + (open) => open !== id && !isDetailNode(open) + ) + if (others.length > 0) grid.expansion.expandAll([id]) + }) + } + } + + hasDetail = (row: TRow): boolean => this.#hasDetail(row) + + isDetailRow = (node: RowNode): boolean => isDetailNode(node.id) + + masterOf = (node: RowNode): string => masterIdOf(node.id) + + build = (nodes: RowNode[]): RowNode[] => + buildDetailNodes(nodes, { + hasDetail: this.#hasDetail, + isExpanded: (id) => this.#grid.expansion.isExpanded(id) + }) + + toggle = (id: string): void => { + this.#grid.expansion.toggle(id) + } + + closeAll = (): void => { + this.#grid.expansion.collapseAll() + } +} + +export function masterDetail(options: MasterDetailOptions = {}): GridFeature { + return { + id: MASTER_DETAIL, + createState: (grid) => { + grid.expansion.enabled = true + return new MasterDetail(grid, options) + }, + createApi: (grid) => { + const state = getMasterDetail(grid)! + return { toggleDetail: state.toggle, closeAllDetails: state.closeAll } + }, + pipelineStage: { + order: PIPELINE_ORDER.flatten, + transform: (nodes, grid) => getMasterDetail(grid)?.build(nodes) ?? nodes + } + } +} + +export function getMasterDetail(grid: GridState): MasterDetail | undefined { + return grid.feature>(MASTER_DETAIL) +} + +declare module '../../core/types/api.js' { + interface GridApi { + toggleDetail?: (id: string) => void + closeAllDetails?: () => void + } +} diff --git a/src/lib/features/master-detail/master-detail.types.ts b/src/lib/features/master-detail/master-detail.types.ts new file mode 100644 index 0000000..c0cb8bd --- /dev/null +++ b/src/lib/features/master-detail/master-detail.types.ts @@ -0,0 +1,17 @@ +export interface MasterDetailOptions { + /** + * Which rows can open a panel. Rows without one keep no chevron, so the + * grid never offers to expand something empty. + * + * @default every row + */ + hasDetail?: (row: TRow) => boolean + + /** + * Only one panel open at a time. Useful when a panel is tall or fetches + * its own data. + * + * @default false + */ + single?: boolean +} diff --git a/src/lib/features/policy/index.ts b/src/lib/features/policy/index.ts new file mode 100644 index 0000000..045e701 --- /dev/null +++ b/src/lib/features/policy/index.ts @@ -0,0 +1,3 @@ +export { applyMask, maskEmail, maskInitials, maskLast4, REDACTED, type MaskKind } from './masks.js' +export { getPolicy, POLICY, Policy, policy } from './policy.svelte.js' +export type { PolicyContext, PolicyOptions, PolicyRule } from './policy.types.js' diff --git a/src/lib/features/policy/leaks.test.ts b/src/lib/features/policy/leaks.test.ts new file mode 100644 index 0000000..ede396a --- /dev/null +++ b/src/lib/features/policy/leaks.test.ts @@ -0,0 +1,197 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it, vi } from 'vitest' +import { editing, getEditing } from '../../features/editing/index.js' +import { advancedFilter, getAdvancedFilter } from '../advanced-filter/index.js' +import { conditionalFormatting, getConditionalFormatting } from '../conditional-formatting/index.js' +import { findReplace, getFindReplace } from '../find-replace/index.js' +import { formula } from '../formula/index.js' +import { getGrouping, grouping } from '../grouping/index.js' +import { getRangeSelection, rangeSelection } from '../range-selection/index.js' +import { showValuesAs } from '../show-values-as/index.js' +import { buildGridXlsx } from '../xlsx/index.js' +import { readXlsx } from '../data-import/index.js' +import { policy } from './policy.svelte.js' + +interface Person { + id: number + name: string + salary: number + dept: string + doubled?: number +} + +const people: Person[] = [ + { id: 1, name: 'Chi', salary: 120_000, dept: 'Core' }, + { id: 2, name: 'An', salary: 60_000, dept: 'Data' }, + { id: 3, name: 'Binh', salary: 90_000, dept: 'Core' } +] + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + { id: 'salary', header: 'Salary', type: 'currency' }, + { id: 'dept', header: 'Dept' }, + { id: 'doubled', header: 'Doubled', type: 'currency' } +] + +const guard = () => policy({ rules: [{ columns: ['salary'], mask: 'hide' }] }) + +function makeGrid(extra: unknown[] = []): GridState { + return createDataGrid({ + columns, + data: people, + getRowId: (row) => String(row.id), + features: [guard(), ...(extra as never[])] + }) +} + +describe('a masked value must not come back out through', () => { + it('the clipboard', () => { + const grid = makeGrid([sorting(), rangeSelection()]) + const range = getRangeSelection(grid)! + range.selectAll() + + const text = range.getRangeTsv() + expect(text).not.toContain('120000') + expect(text).not.toContain('120,000') + }) + + it('the matrix a copy is built from', () => { + const grid = makeGrid([sorting(), rangeSelection()]) + const range = getRangeSelection(grid)! + range.selectAll() + + expect(range.getRangeMatrix().flat()).not.toContain('120000') + }) + + it('an XLSX export', async () => { + const grid = makeGrid([sorting()]) + const workbook = await buildGridXlsx(grid, {}) + const { rows } = await readXlsx(workbook) + + const flat = rows.flat().map((cell) => String(cell)) + expect(flat).not.toContain('120000') + }) + + it('find and replace', () => { + const grid = makeGrid([findReplace()]) + const find = getFindReplace(grid)! + find.query = '120000' + + expect(find.matches).toHaveLength(0) + }) + + it("Pro's own advanced filter", () => { + const grid = makeGrid([filtering(), advancedFilter()]) + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', columnId: 'salary', op: 'gt', value: 100_000 }] + }) + + expect(grid.preWindowNodes).toHaveLength(0) + }) + + it('a group aggregate', () => { + const grid = makeGrid([grouping({ by: ['dept'], aggregations: { salary: 'sum' } })]) + void getGrouping(grid) + + const totals = grid.preWindowNodes + .map((node) => (node.row as Person).salary) + .filter((value) => typeof value === 'number') + expect(totals).not.toContain(210_000) + }) + + it('a formula column built over it', () => { + const grid = makeGrid([formula({ columns: { doubled: 'salary * 2' } })]) + + const doubled = grid.preWindowNodes.map((node) => node.row.doubled) + expect(doubled).not.toContain(240_000) + }) + + it('a share of the total, which would divide one real number by another', () => { + const grid = makeGrid([ + grouping({ by: ['dept'], aggregations: { salary: 'sum' } }), + showValuesAs({ columns: { salary: 'percentOfGrandTotal' } }) + ]) + + const shares = grid.preWindowNodes.map((node) => (node.row as Person).salary) + expect(shares.filter((value) => typeof value === 'number')).toEqual([]) + }) +}) + +describe('the values a panel offers', () => { + it('does not list the real ones in a set filter', () => { + const grid = makeGrid([filtering()]) + const salary = grid.columns.get('salary')! + const offered = grid.preWindowNodes.map((node) => grid.getValue(node, salary, 'facet')) + + expect(offered).not.toContain(120_000) + }) + + it('does not paint a colour scale by the real ranking', () => { + const grid = makeGrid([ + conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'salary' }] }) + ]) + const format = getConditionalFormatting(grid)! + const salary = grid.columns.get('salary')! + + const painted = grid.preWindowNodes.map( + (node) => format.decoration(node, salary)?.style?.['background-color'] + ) + expect(new Set(painted).size).toBe(1) + }) +}) + +describe('a masked cell is not a way in either', () => { + it('cannot be edited, because the grid refuses a substituted cell', () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', header: 'Name', editable: true }, + { id: 'salary', header: 'Salary', editable: true } + ], + data: people, + getRowId: (row) => String(row.id), + features: [editing(), guard()] + }) + + const salary = grid.columns.get('salary')! + const name = grid.columns.get('name')! + const node = grid.preWindowNodes[0]! + const editor = getEditing(grid)! + + expect(editor.editableAt(node, name.def)).toBe(true) + expect(editor.editableAt(node, salary.def)).toBe(false) + }) + + it('leaves an unguarded column untouched, by identity', () => { + const grid = makeGrid() + const node = grid.preWindowNodes[0]! + const name = grid.columns.get('name')! + + expect(grid.getValue(node, name, 'render')).toBe('Chi') + }) +}) + +describe('what the policy says out loud', () => { + it('warns when a masked column is left sortable or filterable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const grid = createDataGrid({ + columns: [ + { id: 'name', header: 'Name' }, + { id: 'salary', header: 'Salary', sortable: true, filter: 'number' } + ], + data: people, + getRowId: (row) => String(row.id), + features: [sorting(), guard()] + }) + void grid.getValue(grid.preWindowNodes[0]!, grid.columns.visible[0]!, 'render') + + expect(warn).toHaveBeenCalled() + expect(String(warn.mock.calls[0]?.[0])).toContain('salary') + warn.mockRestore() + }) +}) diff --git a/src/lib/features/policy/masks.test.ts b/src/lib/features/policy/masks.test.ts new file mode 100644 index 0000000..5655a2e --- /dev/null +++ b/src/lib/features/policy/masks.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { applyMask, maskEmail, maskInitials, maskLast4, REDACTED } from './masks.js' + +describe('the built-in masks', () => { + it('keeps the last four, which is what a receipt shows', () => { + expect(maskLast4('0123456789')).toBe(`${REDACTED}6789`) + expect(maskLast4(1234567)).toBe(`${REDACTED}4567`) + }) + + it('refuses to keep four of a value that is barely four', () => { + expect(maskLast4('123')).toBe(REDACTED) + expect(maskLast4('1234')).toBe(REDACTED) + }) + + it('leaves an email addressable without giving the person away', () => { + expect(maskEmail('chi.nguyen@example.vn')).toBe(`c${REDACTED}@example.vn`) + expect(maskEmail('not-an-email')).toBe(REDACTED) + expect(maskEmail('@nolocal.vn')).toBe(REDACTED) + }) + + it('reduces a name to initials, diacritics kept whole', () => { + expect(maskInitials('Nguyễn Thị Chi')).toBe('N. T. C.') + expect(maskInitials(' ')).toBe(REDACTED) + }) + + it('hides by returning null, so a number column draws nothing rather than zero', () => { + expect(applyMask('hide', 120_000)).toBeNull() + expect(applyMask('hide', null)).toBeNull() + }) + + it('leaves a blank blank rather than redacting emptiness', () => { + expect(applyMask('redact', '')).toBe('') + expect(applyMask('last4', null)).toBeNull() + expect(applyMask('email', undefined)).toBeUndefined() + }) + + it('redacts everything else to one fixed shape, so length says nothing', () => { + expect(applyMask('redact', 'short')).toBe(REDACTED) + expect(applyMask('redact', 'a much longer secret')).toBe(REDACTED) + }) +}) diff --git a/src/lib/features/policy/masks.ts b/src/lib/features/policy/masks.ts new file mode 100644 index 0000000..064f379 --- /dev/null +++ b/src/lib/features/policy/masks.ts @@ -0,0 +1,56 @@ +export type MaskKind = 'hide' | 'redact' | 'last4' | 'email' | 'initials' + +export const REDACTED = '••••' + +export function idSet(values: string[]): string[] { + return [...new Set(values)] +} + +const KEEP = 4 + +function textOf(value: unknown): string { + return value instanceof Date ? value.toISOString() : String(value) +} + +export function isEmpty(value: unknown): boolean { + return value === null || value === undefined || value === '' +} + +export function maskLast4(value: unknown): string { + const text = textOf(value) + if (text.length <= KEEP) return REDACTED + return REDACTED + text.slice(-KEEP) +} + +export function maskEmail(value: unknown): string { + const text = textOf(value) + const at = text.lastIndexOf('@') + if (at <= 0) return REDACTED + + const local = text.slice(0, at) + return `${local[0]}${REDACTED}${text.slice(at)}` +} + +export function maskInitials(value: unknown): string { + const words = textOf(value) + .split(/\s+/) + .filter((word) => word !== '') + if (words.length === 0) return REDACTED + return words.map((word) => `${[...word][0]}.`).join(' ') +} + +export function applyMask(kind: MaskKind, value: unknown): unknown { + if (kind === 'hide') return null + if (isEmpty(value)) return value + + switch (kind) { + case 'redact': + return REDACTED + case 'last4': + return maskLast4(value) + case 'email': + return maskEmail(value) + default: + return maskInitials(value) + } +} diff --git a/src/lib/features/policy/policy.svelte.ts b/src/lib/features/policy/policy.svelte.ts new file mode 100644 index 0000000..b080db2 --- /dev/null +++ b/src/lib/features/policy/policy.svelte.ts @@ -0,0 +1,126 @@ +import { type GridState } from '../../core/grid/index.js' +import { + type CellValuePurpose, + type CellValueReader, + type ColumnDef, + type GridFeature, + type RowNode +} from '../../core/types/index.js' +import { applyMask, idSet } from './masks.js' +import type { PolicyContext, PolicyOptions, PolicyRule } from './policy.types.js' + +export const POLICY = 'policy' + +const EVERY_PURPOSE: CellValuePurpose[] = [ + 'render', + 'export', + 'clipboard', + 'search', + 'facet', + 'edit' +] + +export class Policy { + #grid: GridState + #options: PolicyOptions + #warned = false + + constructor(grid: GridState, options: PolicyOptions) { + this.#grid = grid + this.#options = options + } + + get rules(): PolicyRule[] { + return this.#options.rules + } + + get guarded(): string[] { + const ids = idSet(this.#options.rules.flatMap((rule) => rule.columns)) + return ids.filter((columnId) => { + const def = this.#grid.columns.get(columnId)?.def + return def ? !this.#options.except?.(def) : true + }) + } + + isGuarded = (columnId: string): boolean => this.guarded.includes(columnId) + + #rulesFor(columnId: string, purpose: CellValuePurpose): PolicyRule[] { + const def = this.#grid.columns.get(columnId)?.def + if (def && this.#options.except?.(def)) return [] + + return this.#options.rules.filter( + (rule) => + rule.columns.includes(columnId) && + (rule.purposes ?? EVERY_PURPOSE).includes(purpose) + ) + } + + valueFor = ( + value: unknown, + row: TRow | null, + columnId: string, + purpose: CellValuePurpose + ): unknown => { + const context: PolicyContext = { row, columnId, purpose } + + let answer = value + for (const rule of this.#rulesFor(columnId, purpose)) { + if (rule.when && !rule.when(context)) continue + answer = + typeof rule.mask === 'function' + ? rule.mask(answer, context) + : applyMask(rule.mask, answer) + } + return answer + } + + reader = (columnId: string, purpose: CellValuePurpose): CellValueReader | undefined => { + if (this.#rulesFor(columnId, purpose).length === 0) return undefined + + return (value: unknown, node: RowNode) => + this.valueFor(value, node?.row ?? null, columnId, purpose) + } + + readValue = (row: TRow, column: ColumnDef): unknown => { + const raw = column.accessor + ? column.accessor(row) + : (row as Record)[column.id] + return this.valueFor(raw, row, column.id, 'search') + } + + warnOnOpenDoors = (): void => { + if (this.#warned || this.#options.warnOnOpenDoors === false) return + + const open = this.guarded.filter((columnId) => { + const def = this.#grid.columns.get(columnId)?.def + return def ? def.sortable === true || def.filter !== undefined : false + }) + if (open.length === 0) return + + this.#warned = true + // eslint-disable-next-line no-console + console.warn( + `policy(): ${open.join(', ')} are masked but still sortable or filterable by the ` + + "grid's own sort and column filters, which read the real value. A hidden number " + + 'is still rankable, and a filter narrowed enough is a search for it. Set ' + + 'sortable: false and filter: false on those columns until Community can gate ' + + 'both, or pass warnOnOpenDoors: false if the ranking is not a secret.' + ) + } +} + +export function getPolicy(grid: GridState): Policy | undefined { + return grid.feature>(POLICY) +} + +export function policy(options: PolicyOptions): GridFeature { + return { + id: POLICY, + createState: (grid) => new Policy(grid, options), + cellValue: ({ grid, column, purpose }) => { + const state = getPolicy(grid) + state?.warnOnOpenDoors() + return state?.reader(column.id, purpose) + } + } +} diff --git a/src/lib/features/policy/policy.types.ts b/src/lib/features/policy/policy.types.ts new file mode 100644 index 0000000..0e8043e --- /dev/null +++ b/src/lib/features/policy/policy.types.ts @@ -0,0 +1,59 @@ +import { type CellValuePurpose, type ColumnDef } from '../../core/types/index.js' +import type { MaskKind } from './masks.js' + +/** What a rule is asked about before it decides. */ +export interface PolicyContext { + /** + * The row the value came from, or `null` where the purpose has no single + * row - the list a set filter offers, for one. + */ + row: TRow | null + columnId: string + purpose: CellValuePurpose +} + +export interface PolicyRule { + /** Column ids this rule covers. */ + columns: string[] + + /** + * How the value is replaced. A string picks a built-in; a function gets the + * value and its context and may return anything the column can draw. + * + * `hide` returns `null` rather than an empty string, so a number column + * draws nothing instead of a zero. + */ + mask: MaskKind | ((value: unknown, context: PolicyContext) => unknown) + + /** + * When the rule applies, asked per cell. Omit for always. + * + * @default undefined + */ + when?: (context: PolicyContext) => boolean + + /** + * The ways out of the grid this rule covers. Every one, unless you say + * otherwise - and saying otherwise is how a value ends up masked on screen + * and plain in the CSV, so it should read like the decision it is. + * + * @default every purpose + */ + purposes?: CellValuePurpose[] +} + +export interface PolicyOptions { + rules: PolicyRule[] + + /** + * Warn when a masked column is left sortable or filterable by the grid's + * own sort and column filters, which read the real value - a hidden number + * is still rankable, and still findable by narrowing a filter. + * + * @default true + */ + warnOnOpenDoors?: boolean + + /** Columns the policy never touches, whatever the rules say. */ + except?: (column: ColumnDef) => boolean +} diff --git a/src/lib/features/range-selection/fill.test.ts b/src/lib/features/range-selection/fill.test.ts new file mode 100644 index 0000000..d4397e0 --- /dev/null +++ b/src/lib/features/range-selection/fill.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { continueSeries, fillCells, fillTargetOf } from './fill.js' +import type { CellRange } from './range.js' + +describe('continueSeries', () => { + it('returns nothing to fill for an empty source or count', () => { + expect(continueSeries([], 3)).toEqual([]) + expect(continueSeries([1, 2], 0)).toEqual([]) + expect(continueSeries([1, 2], -1)).toEqual([]) + }) + + describe('numbers', () => { + it('continues a run', () => { + expect(continueSeries([1, 2, 3], 3)).toEqual([4, 5, 6]) + expect(continueSeries([5, 10], 3)).toEqual([15, 20, 25]) + }) + + it('runs backwards and through zero', () => { + expect(continueSeries([10, 8], 3)).toEqual([6, 4, 2]) + expect(continueSeries([2, 1], 3)).toEqual([0, -1, -2]) + }) + + it('repeats a single number instead of inventing a run', () => { + expect(continueSeries([7], 3)).toEqual([7, 7, 7]) + }) + + it('repeats when the steps are uneven', () => { + expect(continueSeries([1, 2, 4], 3)).toEqual([1, 2, 4]) + }) + + it('keeps a fractional step', () => { + expect(continueSeries([1, 1.5], 2)).toEqual([2, 2.5]) + }) + }) + + describe('dates', () => { + const iso = (value: string) => value + + it('continues a daily run', () => { + expect(continueSeries([iso('2026-01-01'), iso('2026-01-02')], 2)).toEqual([ + '2026-01-03', + '2026-01-04' + ]) + }) + + it('continues a weekly run', () => { + expect(continueSeries([iso('2026-01-01'), iso('2026-01-08')], 2)).toEqual([ + '2026-01-15', + '2026-01-22' + ]) + }) + + it('steps by month rather than by the days between months', () => { + expect( + continueSeries([iso('2026-01-01'), iso('2026-02-01'), iso('2026-03-01')], 2) + ).toEqual(['2026-04-01', '2026-05-01']) + }) + + it('crosses a year boundary on a monthly run', () => { + expect(continueSeries([iso('2026-11-01'), iso('2026-12-01')], 2)).toEqual([ + '2027-01-01', + '2027-02-01' + ]) + }) + + it('hands back Date objects when it was given Date objects', () => { + const result = continueSeries([new Date(2026, 0, 1), new Date(2026, 0, 2)], 1) + expect(result[0]).toBeInstanceOf(Date) + expect((result[0] as Date).getDate()).toBe(3) + }) + + it('repeats a single date', () => { + expect(continueSeries([iso('2026-01-01')], 2)).toEqual(['2026-01-01', '2026-01-01']) + }) + }) + + describe('text with a trailing number', () => { + it('continues the number and keeps the prefix', () => { + expect(continueSeries(['Item 1', 'Item 2'], 2)).toEqual(['Item 3', 'Item 4']) + expect(continueSeries(['Q1', 'Q2'], 2)).toEqual(['Q3', 'Q4']) + }) + + it('keeps zero padding, because that is what the column looks like', () => { + expect(continueSeries(['row-07', 'row-08'], 2)).toEqual(['row-09', 'row-10']) + }) + + it('repeats when the prefixes differ', () => { + expect(continueSeries(['Item 1', 'Thing 2'], 2)).toEqual(['Item 1', 'Thing 2']) + }) + + it('repeats plain text with no number to continue', () => { + expect(continueSeries(['Core', 'Data'], 4)).toEqual(['Core', 'Data', 'Core', 'Data']) + }) + + it('does not run a number negative', () => { + expect(continueSeries(['x3', 'x2'], 4)).toEqual(['x1', 'x0', 'x0', 'x0']) + }) + }) + + describe('anything else', () => { + it('repeats a mixed block cyclically', () => { + expect(continueSeries([1, 'a'], 4)).toEqual([1, 'a', 1, 'a']) + }) + + it('repeats booleans and nulls rather than guessing', () => { + expect(continueSeries([true, false], 3)).toEqual([true, false, true]) + expect(continueSeries([null], 2)).toEqual([null, null]) + }) + }) +}) + +const source: CellRange = { top: 1, left: 1, bottom: 2, right: 2 } + +describe('fillTargetOf', () => { + it('reads nothing while the pointer is still inside the source', () => { + expect(fillTargetOf(source, 2, 2)).toBeNull() + expect(fillTargetOf(source, 1, 1)).toBeNull() + }) + + it('extends on whichever side the drag has been pulled furthest', () => { + expect(fillTargetOf(source, 5, 2)).toEqual({ + axis: 'down', + range: { top: 3, left: 1, bottom: 5, right: 2 } + }) + expect(fillTargetOf(source, 2, 6)).toEqual({ + axis: 'right', + range: { top: 1, left: 3, bottom: 2, right: 6 } + }) + expect(fillTargetOf(source, 0, 2)?.axis).toBe('up') + expect(fillTargetOf(source, 2, 0)?.axis).toBe('left') + }) + + it('picks one axis for a diagonal drag rather than an L', () => { + expect(fillTargetOf(source, 6, 3)).toEqual({ + axis: 'down', + range: { top: 3, left: 1, bottom: 6, right: 2 } + }) + }) +}) + +describe('fillCells', () => { + const read = (row: number, col: number): unknown => (col === 1 ? row * 10 : `Item ${row}`) + + it('carries each column its own series when filling down', () => { + const target = fillTargetOf(source, 4, 2)! + expect(fillCells(source, target, { read })).toEqual([ + { row: 3, col: 1, value: 30 }, + { row: 4, col: 1, value: 40 }, + { row: 3, col: 2, value: 'Item 3' }, + { row: 4, col: 2, value: 'Item 4' } + ]) + }) + + it('runs the series upward when the drag goes up', () => { + const target = fillTargetOf(source, 0, 2)! + + expect(fillCells(source, target, { read })).toEqual([ + { row: 0, col: 1, value: 0 }, + { row: 0, col: 2, value: 'Item 0' } + ]) + }) + + it('carries each row its own series when filling right', () => { + const byColumn = (row: number, col: number): unknown => row * 100 + col + const target = fillTargetOf(source, 2, 4)! + expect(fillCells(source, target, { read: byColumn })).toEqual([ + { row: 1, col: 3, value: 103 }, + { row: 1, col: 4, value: 104 }, + { row: 2, col: 3, value: 203 }, + { row: 2, col: 4, value: 204 } + ]) + }) + + it('repeats a block that carries no run', () => { + const words = (row: number): unknown => (row === 1 ? 'Core' : 'Data') + const target = fillTargetOf(source, 6, 2)! + expect(fillCells(source, target, { read: words }).map((cell) => cell.value)).toEqual([ + 'Core', + 'Data', + 'Core', + 'Data', + 'Core', + 'Data', + 'Core', + 'Data' + ]) + }) +}) + +describe('rows that are not data', () => { + const isDataRow = (row: number) => row !== 2 + const numbers = (row: number): unknown => (row === 2 ? null : row) + + it('keeps the run continuous across one, instead of letting it eat a step', () => { + const from: CellRange = { top: 0, left: 0, bottom: 1, right: 0 } + const target = fillTargetOf(from, 4, 0)! + + expect(fillCells(from, target, { read: numbers, isDataRow })).toEqual([ + { row: 3, col: 0, value: 2 }, + { row: 4, col: 0, value: 3 } + ]) + }) + + it('keeps one out of the source, so its blank does not break the run', () => { + const from: CellRange = { top: 1, left: 0, bottom: 2, right: 0 } + const target = fillTargetOf(from, 4, 0)! + + expect(fillCells(from, target, { read: numbers, isDataRow })).toEqual([ + { row: 3, col: 0, value: 1 }, + { row: 4, col: 0, value: 1 } + ]) + }) + + it('drops one whole when the fill runs sideways', () => { + const from: CellRange = { top: 1, left: 0, bottom: 2, right: 0 } + const target = fillTargetOf(from, 2, 2)! + + expect(fillCells(from, target, { read: numbers, isDataRow })).toEqual([ + { row: 1, col: 1, value: 1 }, + { row: 1, col: 2, value: 1 } + ]) + }) +}) diff --git a/src/lib/features/range-selection/fill.ts b/src/lib/features/range-selection/fill.ts new file mode 100644 index 0000000..8ecc8f8 --- /dev/null +++ b/src/lib/features/range-selection/fill.ts @@ -0,0 +1,205 @@ +import type { CellRange } from './range.js' + +type Series = (index: number) => unknown + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ +const TRAILING_NUMBER = /^(.*?)(\d+)$/ + +function constantStep(values: number[]): number | null { + if (values.length < 2) return null + const step = values[1]! - values[0]! + for (let i = 2; i < values.length; i++) { + if (values[i]! - values[i - 1]! !== step) return null + } + return step +} + +function asNumbers(source: unknown[]): number[] | null { + const numbers: number[] = [] + for (const value of source) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + numbers.push(value) + } + return numbers +} + +function numericSeries(source: unknown[]): Series | null { + const numbers = asNumbers(source) + if (!numbers) return null + + const step = constantStep(numbers) + + if (step === null) return null + + const last = numbers.at(-1)! + return (index) => last + step * (index + 1) +} + +function asDates(source: unknown[]): Date[] | null { + const dates: Date[] = [] + for (const value of source) { + const date = + value instanceof Date + ? value + : typeof value === 'string' && ISO_DATE.test(value) + ? new Date(`${value}T00:00:00`) + : null + if (!date || Number.isNaN(date.getTime())) return null + dates.push(date) + } + return dates +} + +const DAY_MS = 86_400_000 + +function toIsoDate(date: Date): string { + const pad = (value: number, width = 2) => String(value).padStart(width, '0') + return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +function dateSeries(source: unknown[]): Series | null { + const dates = asDates(source) + if (!dates) return null + + const last = dates.at(-1)! + const wasIso = typeof source.at(-1) === 'string' + const shape = (date: Date) => (wasIso ? toIsoDate(date) : date) + + const sameDayOfMonth = dates.every((date) => date.getDate() === dates[0]!.getDate()) + const monthStep = constantStep(dates.map((date) => date.getFullYear() * 12 + date.getMonth())) + if (sameDayOfMonth && monthStep !== null && monthStep !== 0) { + return (index) => { + const next = new Date(last) + next.setMonth(next.getMonth() + monthStep * (index + 1)) + return shape(next) + } + } + + const dayStep = constantStep(dates.map((date) => date.getTime())) + if (dayStep === null || dayStep % DAY_MS !== 0) return null + return (index) => shape(new Date(last.getTime() + dayStep * (index + 1))) +} + +function textNumberSeries(source: unknown[]): Series | null { + const parts: { prefix: string; digits: string; value: number }[] = [] + for (const value of source) { + if (typeof value !== 'string') return null + const match = TRAILING_NUMBER.exec(value) + if (!match) return null + parts.push({ prefix: match[1]!, digits: match[2]!, value: Number(match[2]) }) + } + + const prefix = parts[0]!.prefix + if (!parts.every((part) => part.prefix === prefix)) return null + + const step = constantStep(parts.map((part) => part.value)) + if (step === null) return null + + const last = parts.at(-1)! + + const width = parts.every((part) => part.digits.length === last.digits.length) + ? last.digits.length + : 0 + return (index) => { + const next = last.value + step * (index + 1) + return `${prefix}${String(Math.max(0, next)).padStart(width, '0')}` + } +} + +function repeatSeries(source: unknown[]): Series { + return (index) => source[index % source.length] +} + +export function continueSeries(source: unknown[], count: number): unknown[] { + if (count <= 0) return [] + if (source.length === 0) return [] + + const series = + numericSeries(source) ?? + dateSeries(source) ?? + textNumberSeries(source) ?? + repeatSeries(source) + + return Array.from({ length: count }, (_, index) => series(index)) +} + +export type FillAxis = 'down' | 'up' | 'right' | 'left' + +export interface FillTarget { + axis: FillAxis + + range: CellRange +} + +export function fillTargetOf(source: CellRange, row: number, col: number): FillTarget | null { + const below = row - source.bottom + const above = source.top - row + const right = col - source.right + const left = source.left - col + + const vertical = Math.max(below, above) + const horizontal = Math.max(right, left) + if (vertical <= 0 && horizontal <= 0) return null + + if (vertical >= horizontal) { + return below >= above + ? { axis: 'down', range: { ...source, top: source.bottom + 1, bottom: row } } + : { axis: 'up', range: { ...source, top: row, bottom: source.top - 1 } } + } + return right >= left + ? { axis: 'right', range: { ...source, left: source.right + 1, right: col } } + : { axis: 'left', range: { ...source, left: col, right: source.left - 1 } } +} + +function span(from: number, to: number, reversed: boolean): number[] { + const values: number[] = [] + for (let value = from; value <= to; value++) values.push(value) + return reversed ? values.reverse() : values +} + +export interface FillCellsOptions { + read: (row: number, col: number) => unknown + + isDataRow?: (row: number) => boolean +} + +export function fillCells( + source: CellRange, + target: FillTarget, + options: FillCellsOptions +): { row: number; col: number; value: unknown }[] { + const { read, isDataRow = () => true } = options + + const reversed = target.axis === 'up' || target.axis === 'left' + const cells: { row: number; col: number; value: unknown }[] = [] + + if (target.axis === 'down' || target.axis === 'up') { + const sourceRows = span(source.top, source.bottom, reversed).filter(isDataRow) + const targetRows = span(target.range.top, target.range.bottom, reversed).filter(isDataRow) + + for (const col of span(source.left, source.right, false)) { + const continued = continueSeries( + sourceRows.map((row) => read(row, col)), + targetRows.length + ) + targetRows.forEach((row, index) => { + cells.push({ row, col, value: continued[index] }) + }) + } + return cells + } + + const sourceCols = span(source.left, source.right, reversed) + const targetCols = span(target.range.left, target.range.right, reversed) + + for (const row of span(source.top, source.bottom, false).filter(isDataRow)) { + const continued = continueSeries( + sourceCols.map((col) => read(row, col)), + targetCols.length + ) + targetCols.forEach((col, index) => { + cells.push({ row, col, value: continued[index] }) + }) + } + return cells +} diff --git a/src/lib/features/range-selection/index.ts b/src/lib/features/range-selection/index.ts new file mode 100644 index 0000000..a6dabc5 --- /dev/null +++ b/src/lib/features/range-selection/index.ts @@ -0,0 +1,29 @@ +export { + buildMoveEdits, + buildPasteEdits, + buildRangeEdits, + movePairs, + toHtmlTable, + type CellRef, + type MoveCells, + type MovePair +} from './range-clipboard.js' +export { + getRangeSelection, + RANGE_SELECTION, + rangeSelection, + RangeSelection +} from './range-selection.svelte.js' +export type { CopyRangeOptions, RangeSelectionOptions } from './range-selection.types.js' +export { + boundsOf, + cellsOf, + containsCell, + isInAnyRange, + rangeBetween, + rangeCols, + rangeRows, + rangeSize, + type CellPosition, + type CellRange +} from './range.js' diff --git a/src/lib/features/range-selection/range-clipboard.test.ts b/src/lib/features/range-selection/range-clipboard.test.ts new file mode 100644 index 0000000..765ec09 --- /dev/null +++ b/src/lib/features/range-selection/range-clipboard.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { buildMoveEdits, movePairs, toHtmlTable } from './range-clipboard.js' +import type { CellRange } from './range.js' + +const rowIds = ['r0', 'r1', 'r2', 'r3'] +const columnIds = ['a', 'b', 'c'] + +function resolve(row: number, col: number) { + const rowId = rowIds[row] + const columnId = columnIds[col] + return rowId && columnId ? { rowId, columnId } : null +} + +describe('toHtmlTable', () => { + it('writes a table a word processor can read', () => { + expect( + toHtmlTable([ + ['a', 'b'], + ['c', 'd'] + ]) + ).toBe('
ab
cd
') + }) + + it('escapes what would otherwise become markup', () => { + expect(toHtmlTable([['&"']])).toBe( + '
<b>&"
' + ) + }) + + it('forces text on a cell a spreadsheet would read as a formula', () => { + for (const risky of ['=SUM(A1)', '+1', '@name', '-SUM(A1)']) { + expect(toHtmlTable([[risky]])).toContain('mso-number-format') + } + }) + + it('leaves a negative number alone, because it is a number', () => { + expect(toHtmlTable([['-42.5']])).toBe('
-42.5
') + }) + + it('writes nothing for an empty matrix', () => { + expect(toHtmlTable([])).toBe('') + }) +}) + +describe('movePairs', () => { + it('keeps the block shape while shifting its origin', () => { + const source: CellRange = { top: 1, left: 0, bottom: 2, right: 1 } + expect(movePairs(source, 5, 3)).toEqual([ + { from: { row: 1, col: 0 }, to: { row: 5, col: 3 } }, + { from: { row: 1, col: 1 }, to: { row: 5, col: 4 } }, + { from: { row: 2, col: 0 }, to: { row: 6, col: 3 } }, + { from: { row: 2, col: 1 }, to: { row: 6, col: 4 } } + ]) + }) +}) + +describe('buildMoveEdits', () => { + const read = (row: number, col: number) => `${row}${col}` + const canRead = (row: number, col: number) => resolve(row, col) !== null + + it('writes the values across and clears where they came from', () => { + const source: CellRange = { top: 0, left: 0, bottom: 0, right: 1 } + expect(buildMoveEdits(source, 1, 0, { canRead, read, resolve })).toEqual([ + { rowId: 'r1', changes: { a: '00', b: '01' } }, + { rowId: 'r0', changes: { a: null, b: null } } + ]) + }) + + it('clears only the part of the source the block moved off', () => { + const source: CellRange = { top: 0, left: 0, bottom: 1, right: 0 } + const edits = buildMoveEdits(source, 1, 0, { canRead, read, resolve }) + expect(edits).toEqual([ + { rowId: 'r1', changes: { a: '00' } }, + { rowId: 'r2', changes: { a: '10' } }, + { rowId: 'r0', changes: { a: null } } + ]) + }) + + it('does not clear a cell whose value had nowhere to land', () => { + const source: CellRange = { top: 0, left: 0, bottom: 0, right: 2 } + const edits = buildMoveEdits(source, 0, 1, { canRead, read, resolve }) + expect(edits).toEqual([{ rowId: 'r0', changes: { b: '00', c: '01', a: null } }]) + }) + + it('writes nothing when the whole block lands off the grid', () => { + const source: CellRange = { top: 0, left: 0, bottom: 0, right: 0 } + expect(buildMoveEdits(source, 99, 0, { canRead, read, resolve })).toEqual([]) + }) +}) diff --git a/src/lib/features/range-selection/range-clipboard.ts b/src/lib/features/range-selection/range-clipboard.ts new file mode 100644 index 0000000..9bb9329 --- /dev/null +++ b/src/lib/features/range-selection/range-clipboard.ts @@ -0,0 +1,156 @@ +import { type EditTransaction } from '../../core/types/index.js' +import { type CellRange, containsCell, rangeRows } from './range.js' + +export type CellRef = { rowId: string; columnId: string } | null + +export function buildPasteEdits( + range: CellRange, + columnIndices: number[], + matrix: string[][], + resolve: (row: number, col: number) => CellRef +): EditTransaction[] { + if (matrix.length === 0 || columnIndices.length === 0) return [] + + const blockRows = matrix.length + const blockCols = Math.max(...matrix.map((line) => line.length)) + const rows = Math.max(rangeRows(range), blockRows) + const byRow = new Map>() + + for (let rowOffset = 0; rowOffset < rows; rowOffset++) { + for (const [colOffset, col] of columnIndices.entries()) { + const value = matrix[rowOffset % blockRows]?.[colOffset % blockCols] + if (value === undefined) continue + + const ref = resolve(range.top + rowOffset, col) + if (!ref) continue + + const changes = byRow.get(ref.rowId) + if (changes) changes[ref.columnId] = value + else byRow.set(ref.rowId, { [ref.columnId]: value }) + } + } + + return [...byRow].map(([rowId, changes]) => ({ rowId, changes })) +} + +export function buildRangeEdits( + ranges: CellRange[], + value: unknown, + resolve: (row: number, col: number) => CellRef +): EditTransaction[] { + const byRow = new Map>() + + for (const range of ranges) { + for (let row = range.top; row <= range.bottom; row++) { + for (let col = range.left; col <= range.right; col++) { + const ref = resolve(row, col) + if (!ref) continue + + const changes = byRow.get(ref.rowId) + if (changes) changes[ref.columnId] = value + else byRow.set(ref.rowId, { [ref.columnId]: value }) + } + } + } + + return [...byRow].map(([rowId, changes]) => ({ rowId, changes })) +} + +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"' +} + +function escapeHtml(text: string): string { + return text.replace(/[&<>"]/g, (character) => HTML_ESCAPES[character]!) +} + +const TEXT_FORMAT = "mso-number-format:'\\@'" + +function readsAsFormula(text: string): boolean { + return /^[=+@]/.test(text) || /^-(?![\d.])/.test(text) +} + +export function toHtmlTable(matrix: string[][]): string { + if (matrix.length === 0) return '' + + const rows = matrix.map((line) => { + const cells = line.map((cell) => { + const text = escapeHtml(cell) + return readsAsFormula(cell) + ? `${text}` + : `${text}` + }) + return `${cells.join('')}` + }) + + return `${rows.join('')}
` +} + +export interface MovePair { + from: { row: number; col: number } + to: { row: number; col: number } +} + +export function movePairs(source: CellRange, toRow: number, toCol: number): MovePair[] { + const pairs: MovePair[] = [] + for (let row = source.top; row <= source.bottom; row++) { + for (let col = source.left; col <= source.right; col++) { + pairs.push({ + from: { row, col }, + to: { row: toRow + (row - source.top), col: toCol + (col - source.left) } + }) + } + } + return pairs +} + +export interface MoveCells { + canRead: (row: number, col: number) => boolean + read: (row: number, col: number) => unknown + resolve: (row: number, col: number) => CellRef +} + +export function buildMoveEdits( + source: CellRange, + toRow: number, + toCol: number, + cells: MoveCells +): EditTransaction[] { + const { canRead, read, resolve } = cells + const target: CellRange = { + top: toRow, + left: toCol, + bottom: toRow + (source.bottom - source.top), + right: toCol + (source.right - source.left) + } + const byRow = new Map>() + const landed: MovePair[] = [] + + const write = (ref: { rowId: string; columnId: string }, value: unknown) => { + const changes = byRow.get(ref.rowId) + if (changes) changes[ref.columnId] = value + else byRow.set(ref.rowId, { [ref.columnId]: value }) + } + + for (const pair of movePairs(source, toRow, toCol)) { + if (!canRead(pair.from.row, pair.from.col)) continue + + const to = resolve(pair.to.row, pair.to.col) + if (!to) continue + + write(to, read(pair.from.row, pair.from.col)) + landed.push(pair) + } + + for (const pair of landed) { + if (containsCell(target, pair.from.row, pair.from.col)) continue + + const origin = resolve(pair.from.row, pair.from.col) + if (origin) write(origin, null) + } + + return [...byRow].map(([rowId, changes]) => ({ rowId, changes })) +} diff --git a/src/lib/features/range-selection/range-selection.svelte.ts b/src/lib/features/range-selection/range-selection.svelte.ts new file mode 100644 index 0000000..8661727 --- /dev/null +++ b/src/lib/features/range-selection/range-selection.svelte.ts @@ -0,0 +1,734 @@ +import { type GridState, isDataRow, isLoadingRow } from '../../core/grid/index.js' +import { + type CellDecoration, + type EditTransaction, + type GridFeature, + type Keybinding, + SELECTION_COLUMN_ID +} from '../../core/types/index.js' +import { getEditing } from '../../features/editing/index.js' +import { rowsToMatrix, toTsv, withHeaderRow } from '../../features/selection/index.js' +import { slotClass } from '../../core/theme/index.js' +import { numericOrNull } from '../../core/utils/index.js' +import { fillCells, type FillTarget, fillTargetOf } from './fill.js' +import { + buildMoveEdits, + buildPasteEdits as buildEdits, + buildRangeEdits, + toHtmlTable +} from './range-clipboard.js' +import { + boundsOf, + containsCell, + clampRange, + isInAnyRange, + parseTsv, + rangeBetween, + rangeCols, + rangeRows, + rangeSize, + type CellPosition, + type CellRange +} from './range.js' +import type { CopyRangeOptions, RangeSelectionOptions } from './range-selection.types.js' + +export interface RangeEdges { + top: boolean + bottom: boolean + start: boolean + end: boolean +} + +export const RANGE_SELECTION = 'rangeSelection' + +export class RangeSelection { + ranges = $state.raw([]) + dragging = $state(false) + + fillTarget = $state.raw(null) + + #cellClass = $derived(slotClass('rangeCell')) + #handleClass = $derived(slotClass('fillHandle')) + #previewClass = $derived(slotClass('fillPreview')) + #cutClass = $derived(slotClass('cutSource')) + #moveClass = $derived(slotClass('moveTarget')) + #edgeClasses = $derived([ + slotClass('rangeEdgeTop'), + slotClass('rangeEdgeBottom'), + slotClass('rangeEdgeStart'), + slotClass('rangeEdgeEnd') + ]) + + readonly multiple: boolean + readonly paste: boolean + readonly fill: boolean + readonly rangeEdit: boolean + readonly cut: boolean + + #grid: GridState + #anchor: CellPosition | null = null + + #fillSource = $state.raw(null) + cutSource = $state.raw(null) + #cutRowIds: string[] = [] + moveTarget = $state.raw(null) + #grab = $state.raw(null) + + constructor(grid: GridState, options: RangeSelectionOptions) { + this.#grid = grid + this.multiple = options.multiple ?? true + this.paste = options.paste ?? true + this.fill = options.fill ?? true + this.rangeEdit = options.rangeEdit ?? true + this.cut = options.cut ?? true + } + + get maxRow(): number { + return this.#grid.totalRows - 1 + } + + get maxCol(): number { + return this.#grid.columns.visible.length - 1 + } + + get primary(): CellRange | null { + return this.ranges.at(-1) ?? null + } + + get clampedRanges(): CellRange[] { + return this.ranges + .map((range) => clampRange(range, this.maxRow, this.maxCol)) + .filter((range) => range !== null) + } + + get cellCount(): number { + return this.clampedRanges.reduce((total, range) => total + rangeSize(range), 0) + } + + isSelected(row: number, col: number): boolean { + return isInAnyRange(this.ranges, row, col) + } + + startRange = (row: number, col: number, options: { additive?: boolean } = {}): void => { + const cell = { row, col } + this.#anchor = cell + const range = rangeBetween(cell, cell) + this.ranges = options.additive && this.multiple ? [...this.ranges, range] : [range] + this.dragging = true + } + + extendTo = (row: number, col: number): void => { + if (!this.#anchor) return + const range = rangeBetween(this.#anchor, { row, col }) + this.ranges = [...this.ranges.slice(0, -1), range] + } + + endRange = (): void => { + this.dragging = false + } + + extendBy = (rowDelta: number, colDelta: number): void => { + const focus = this.#grid.focus.active + this.#anchor ??= { row: focus.row, col: focus.col } + + const next = { + row: Math.min(Math.max(focus.row + rowDelta, 0), Math.max(0, this.maxRow)), + col: Math.min(Math.max(focus.col + colDelta, 0), Math.max(0, this.maxCol)) + } + this.#grid.focus.focusCell(next) + const range = rangeBetween(this.#anchor, next) + this.ranges = this.ranges.length === 0 ? [range] : [...this.ranges.slice(0, -1), range] + } + + selectAll = (): void => { + if (this.maxRow < 0 || this.maxCol < 0) return + this.#anchor = { row: 0, col: 0 } + this.ranges = [{ top: 0, left: 0, bottom: this.maxRow, right: this.maxCol }] + } + + clear = (): void => { + this.#anchor = null + this.ranges = [] + this.dragging = false + this.cancelCut() + } + + getRangeMatrix = (options: CopyRangeOptions = {}): string[][] => { + const range = this.primary + const clamped = range ? clampRange(range, this.maxRow, this.maxCol) : null + if (!clamped) return [] + + const nodes = this.#grid.preWindowNodes.slice(clamped.top, clamped.bottom + 1) + const columns = this.#grid.columns.visible.slice(clamped.left, clamped.right + 1) + const matrix = rowsToMatrix(nodes, columns, undefined, { + formatted: options.formatted, + locale: this.#grid.locale, + read: (column) => this.#grid.readerFor(column.id, 'clipboard') + }).map((line, index) => (isLoadingRow(nodes[index]?.row) ? line.map(() => '') : line)) + return options.headers ? withHeaderRow(matrix, columns) : matrix + } + + getRangeTsv = (options: CopyRangeOptions = {}): string => toTsv(this.getRangeMatrix(options)) + + copyRange = async (options: CopyRangeOptions = {}): Promise => { + const matrix = this.getRangeMatrix(options) + const text = toTsv(matrix) + if (text === '' || typeof navigator === 'undefined' || !navigator.clipboard) return false + + const html = options.html ?? true + if (html && typeof ClipboardItem === 'function' && navigator.clipboard.write) { + const item = new ClipboardItem({ + 'text/plain': new Blob([text], { type: 'text/plain' }), + 'text/html': new Blob([toHtmlTable(matrix)], { type: 'text/html' }) + }) + await navigator.clipboard.write([item]) + return true + } + + await navigator.clipboard.writeText(text) + return true + } + + cutRange = (): boolean | Promise => { + if (!this.cut) return false + + const range = this.primary + const clamped = range ? clampRange(range, this.maxRow, this.maxCol) : null + if (!clamped) return false + + this.cutSource = clamped + this.#cutRowIds = this.#rowIdsOf(clamped) + return this.copyRange() + } + + cancelCut = (): void => { + this.cutSource = null + this.#cutRowIds = [] + } + + #rowIdsOf(range: CellRange): string[] { + const nodes = this.#grid.preWindowNodes + const ids: string[] = [] + for (let row = range.top; row <= range.bottom; row++) ids.push(nodes[row]?.id ?? '') + return ids + } + + #cutStillPointsAtItsRows(source: CellRange): boolean { + if (this.#cutRowIds.length === 0) return true + + const now = this.#rowIdsOf(source) + return ( + now.length === this.#cutRowIds.length && + now.every((id, at) => id === this.#cutRowIds[at]) + ) + } + + get moving(): boolean { + return this.#grab !== null + } + + moveBorderAt(row: number, col: number): RangeEdges | null { + if (!this.cut) return null + const range = this.primary + const clamped = range ? clampRange(range, this.maxRow, this.maxCol) : null + if (!clamped || !containsCell(clamped, row, col)) return null + + const edges = { + top: row === clamped.top, + bottom: row === clamped.bottom, + start: col === clamped.left, + end: col === clamped.right + } + return edges.top || edges.bottom || edges.start || edges.end ? edges : null + } + + startMove = (row: number, col: number): void => { + const range = this.primary + const clamped = range ? clampRange(range, this.maxRow, this.maxCol) : null + if (!this.cut || !clamped || !containsCell(clamped, row, col)) return + + this.cutSource = clamped + this.#cutRowIds = this.#rowIdsOf(clamped) + this.moveTarget = clamped + this.#grab = { row: row - clamped.top, col: col - clamped.left } + } + + extendMove = (row: number, col: number): void => { + const source = this.cutSource + const grab = this.#grab + if (!source || !grab) return + + const height = source.bottom - source.top + const width = source.right - source.left + const top = Math.min(Math.max(row - grab.row, 0), Math.max(0, this.maxRow - height)) + const left = Math.min(Math.max(col - grab.col, 0), Math.max(0, this.maxCol - width)) + this.moveTarget = { top, left, bottom: top + height, right: left + width } + } + + endMove = (): boolean | Promise => { + const target = this.moveTarget + const source = this.cutSource + const grabbed = this.#grab !== null + + this.moveTarget = null + this.#grab = null + if (!grabbed || !target || !source) return false + + if (target.top === source.top && target.left === source.left) { + this.cancelCut() + return false + } + + const moved = this.moveRange(target.top, target.left) + if (moved === false) this.cancelCut() + return moved + } + + moveRange = (row: number, col: number): boolean | Promise => { + const source = this.cutSource + if (!source) return false + + if (!this.#cutStillPointsAtItsRows(source)) { + this.cancelCut() + return false + } + + const applyEdits = this.#grid.api.applyEdits as + ((edits: EditTransaction[]) => boolean | Promise) | undefined + if (!applyEdits) return false + + const nodes = this.#grid.preWindowNodes + const columns = this.#grid.columns.visible + const isData = isDataRow + const editing = getEditing(this.#grid) + + const isRealCell = (at: number, on: number) => { + const node = nodes[at] + const column = columns[on] + return Boolean(node && column && isData(node) && column.id !== SELECTION_COLUMN_ID) + } + + const edits = buildMoveEdits(source, row, col, { + canRead: isRealCell, + read: (from, at) => { + const node = nodes[from] + const column = columns[at] + return node && column ? this.#grid.getValue(node, column, 'clipboard') : null + }, + resolve: (at, on) => { + if (!isRealCell(at, on)) return null + + const node = nodes[at]! + const column = columns[on]! + if (editing && !editing.editableAt(node, column.def)) return null + return { rowId: node.id, columnId: column.id } + } + }) + if (edits.length === 0) return false + + this.cancelCut() + this.ranges = [ + { + top: row, + left: col, + bottom: row + (source.bottom - source.top), + right: col + (source.right - source.left) + } + ] + this.#anchor = { row, col } + return applyEdits(edits) + } + + #pasteColumns(range: CellRange, blockCols: number): number[] { + const columns = this.#grid.columns.visible + const inRange: number[] = [] + const beyond: number[] = [] + + for (let col = range.left; col < columns.length; col++) { + if (columns[col]!.id === SELECTION_COLUMN_ID) continue + if (col <= range.right) inRange.push(col) + else beyond.push(col) + } + + if (inRange.length === 0) return [] + + const needed = Math.max(inRange.length, blockCols) + return [...inRange, ...beyond].slice(0, needed) + } + + buildPasteEdits = (matrix: string[][]): EditTransaction[] => { + const range = this.primary + if (!range || matrix.length === 0) return [] + + const nodes = this.#grid.preWindowNodes + const columns = this.#grid.columns.visible + const isData = isDataRow + const blockCols = Math.max(...matrix.map((line) => line.length)) + + return buildEdits(range, this.#pasteColumns(range, blockCols), matrix, (row, col) => { + const node = nodes[row] + const column = columns[col] + if (!node || !column || !isData(node)) return null + return { rowId: node.id, columnId: column.id } + }) + } + + pasteText = (text: string): boolean | Promise => { + if (!this.paste) return false + const applyEdits = this.#grid.api.applyEdits as + ((edits: EditTransaction[]) => boolean | Promise) | undefined + if (!applyEdits) return false + + const edits = this.buildPasteEdits(parseTsv(text)) + if (edits.length === 0) return false + return applyEdits(edits) + } + + pasteFromClipboard = async (): Promise => { + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return false + return this.pasteText(await navigator.clipboard.readText()) + } + + applyToRange = (value: unknown): boolean | Promise => { + const applyEdits = this.#grid.api.applyEdits as + ((edits: EditTransaction[]) => boolean | Promise) | undefined + if (!applyEdits) return false + + const nodes = this.#grid.preWindowNodes + const columns = this.#grid.columns.visible + const isData = isDataRow + + const edits = buildRangeEdits(this.clampedRanges, value, (row, col) => { + const node = nodes[row] + const column = columns[col] + if (!node || !column || !isData(node)) return null + return { rowId: node.id, columnId: column.id } + }) + if (edits.length === 0) return false + return applyEdits(edits) + } + + #indexOfCell(rowId: string, columnId: string): CellPosition | null { + const row = this.#grid.preWindowNodes.findIndex((node) => node.id === rowId) + const col = this.#grid.columns.visible.findIndex((column) => column.id === columnId) + return row < 0 || col < 0 ? null : { row, col } + } + + commitDraftToRange = (): boolean => { + if (!this.rangeEdit || this.cellCount < 2) return false + + const editing = getEditing(this.#grid) + const active = editing?.active + if (!editing || !active || editing.rowEditId !== null) return false + + const cell = this.#indexOfCell(active.rowId, active.columnId) + if (!cell || !this.isSelected(cell.row, cell.col)) return false + + const value = editing.draft + editing.cancel() + void this.applyToRange(value) + return true + } + + #anyWritable(range: CellRange): boolean { + const columns = this.#grid.columns.visible + for (let col = range.left; col <= range.right; col++) { + const editable = columns[col]?.def.editable + if (editable !== undefined && editable !== false) return true + } + return false + } + + get fillHandleAt(): CellPosition | null { + if (!this.fill || this.dragging) return null + const range = this.primary + if (!range || !this.#anyWritable(range)) return null + return { row: range.bottom, col: range.right } + } + + get filling(): boolean { + return this.#fillSource !== null + } + + startFill = (): void => { + if (!this.fill) return + this.#fillSource = this.primary + } + + extendFill = (row: number, col: number): void => { + const source = this.#fillSource + if (!source) return + + const clamped = { + row: Math.min(Math.max(row, 0), Math.max(0, this.maxRow)), + col: Math.min(Math.max(col, 0), Math.max(0, this.maxCol)) + } + const target = fillTargetOf(source, clamped.row, clamped.col) + + this.fillTarget = target && this.#anyWritable(target.range) ? target : null + } + + endFill = (): boolean | Promise => { + const source = this.#fillSource + const target = this.fillTarget + this.#fillSource = null + this.fillTarget = null + if (!source || !target) return false + + const applyEdits = this.#grid.api.applyEdits + if (!applyEdits) return false + + const nodes = this.#grid.preWindowNodes + const columns = this.#grid.columns.visible + const isData = isDataRow + const cells = fillCells(source, target, { + read: (row, col) => { + const node = nodes[row] + const column = columns[col] + return node && column ? this.#grid.getValue(node, column, 'clipboard') : null + }, + isDataRow: (row) => { + const node = nodes[row] + return node !== undefined && isData(node) + } + }) + + const byRow: Record> = {} + for (const cell of cells) { + const node = nodes[cell.row] + const column = columns[cell.col] + if (!node || !column) continue + ;(byRow[node.id] ??= {})[column.id] = cell.value + } + + const edits = Object.entries(byRow).map(([rowId, changes]) => ({ rowId, changes })) + if (edits.length === 0) return false + + this.ranges = [ + ...this.ranges.slice(0, -1), + { + top: Math.min(source.top, target.range.top), + left: Math.min(source.left, target.range.left), + bottom: Math.max(source.bottom, target.range.bottom), + right: Math.max(source.right, target.range.right) + } + ] + return applyEdits(edits) + } + + fillWithin = (axis: 'down' | 'right'): boolean | Promise => { + const range = this.primary + const clamped = range ? clampRange(range, this.maxRow, this.maxCol) : null + if (!clamped) return false + if (axis === 'down' && clamped.bottom === clamped.top) return false + if (axis === 'right' && clamped.right === clamped.left) return false + + this.#fillSource = + axis === 'down' + ? { ...clamped, bottom: clamped.top } + : { ...clamped, right: clamped.left } + this.fillTarget = { + axis, + range: + axis === 'down' + ? { ...clamped, top: clamped.top + 1 } + : { ...clamped, left: clamped.left + 1 } + } + return this.endFill() + } + + #edges(row: number, col: number, inside: (row: number, col: number) => boolean): string { + const [top, bottom, start, end] = this.#edgeClasses + let classes = '' + if (!inside(row - 1, col)) classes += ` ${top}` + if (!inside(row + 1, col)) classes += ` ${bottom}` + if (!inside(row, col - 1)) classes += ` ${start}` + if (!inside(row, col + 1)) classes += ` ${end}` + return classes + } + + #boxed(className: string, rect: CellRange, row: number, col: number): CellDecoration { + const inside = (r: number, c: number) => containsCell(rect, r, c) + return { class: className + this.#edges(row, col, inside), selected: true } + } + + #overlay(row: number, col: number): CellDecoration | undefined { + const fill = this.fillTarget + if (fill && containsCell(fill.range, row, col)) { + return this.#boxed(this.#previewClass, fill.range, row, col) + } + + const move = this.moveTarget + if (move && containsCell(move, row, col)) + return this.#boxed(this.#moveClass, move, row, col) + + const cut = this.cutSource + if (cut && containsCell(cut, row, col)) { + const boxed = this.#boxed(this.#cellClass, cut, row, col) + return { ...boxed, class: `${boxed.class} ${this.#cutClass}` } + } + return undefined + } + + cellDecoration = (row: number, col: number): CellDecoration | undefined => { + const overlay = this.#overlay(row, col) + if (overlay) return overlay + if (!this.isSelected(row, col)) return undefined + + const inside = (r: number, c: number) => this.isSelected(r, c) + const base = this.#cellClass + this.#edges(row, col, inside) + const handle = this.fillHandleAt + return handle?.row === row && handle.col === col + ? { class: `${base} ${this.#handleClass}`, selected: true } + : { class: base, selected: true } + } + + get selectedValues(): number[] { + const nodes = this.#grid.preWindowNodes + const columns = this.#grid.columns.visible + const values: number[] = [] + + for (const range of this.ranges) { + const clamped = clampRange(range, nodes.length - 1, columns.length - 1) + if (!clamped) continue + + for (let row = clamped.top; row <= clamped.bottom; row++) { + const node = nodes[row] + if (!node) continue + + for (let col = clamped.left; col <= clamped.right; col++) { + const column = columns[col] + if (!column) continue + + const value = numericOrNull(this.#grid.getValue(node, column, 'render')) + if (value !== null) values.push(value) + } + } + } + return values + } + + get bounds(): CellRange | null { + return boundsOf(this.ranges) + } + + get shape(): { rows: number; cols: number } | null { + const range = this.primary + const clamped = range ? clampRange(range, this.maxRow, this.maxCol) : null + return clamped ? { rows: rangeRows(clamped), cols: rangeCols(clamped) } : null + } +} + +function hasRange(grid: GridState): boolean { + return (getRangeSelection(grid)?.ranges.length ?? 0) > 0 +} + +function canFill(grid: GridState): boolean { + return hasRange(grid) && (getRangeSelection(grid)?.fill ?? false) +} + +function createKeybindings(): Keybinding[] { + const extend = + (rowDelta: number, colDelta: number) => + (grid: GridState): void => + getRangeSelection(grid)!.extendBy(rowDelta, colDelta) + + const onBody = (grid: GridState) => + getRangeSelection(grid) !== undefined && grid.focus.active.row >= 0 + + return [ + { key: 'Shift+ArrowDown', when: onBody, handler: extend(1, 0) }, + { key: 'Shift+ArrowUp', when: onBody, handler: extend(-1, 0) }, + { key: 'Shift+ArrowRight', when: onBody, handler: extend(0, 1) }, + { key: 'Shift+ArrowLeft', when: onBody, handler: extend(0, -1) }, + { + key: 'Escape', + when: hasRange, + handler: (grid) => getRangeSelection(grid)!.clear() + }, + { + key: 'Ctrl+c', + when: hasRange, + handler: (grid) => void getRangeSelection(grid)!.copyRange() + }, + { + key: 'Ctrl+x', + when: (grid) => hasRange(grid) && (getRangeSelection(grid)?.cut ?? false), + handler: (grid) => void getRangeSelection(grid)!.cutRange() + }, + { + key: 'Ctrl+v', + when: (grid) => hasRange(grid) && getRangeSelection(grid)?.cutSource !== null, + handler: (grid) => { + const range = getRangeSelection(grid)! + const to = range.primary! + void range.moveRange(to.top, to.left) + } + }, + { + key: 'Ctrl+v', + when: (grid) => hasRange(grid) && (getRangeSelection(grid)?.paste ?? false), + handler: (grid) => void getRangeSelection(grid)!.pasteFromClipboard() + }, + { + key: 'Ctrl+d', + when: canFill, + handler: (grid) => void getRangeSelection(grid)!.fillWithin('down') + }, + { + key: 'Ctrl+r', + when: canFill, + handler: (grid) => void getRangeSelection(grid)!.fillWithin('right') + } + ] +} + +export function rangeSelection(options: RangeSelectionOptions = {}): GridFeature { + return { + id: RANGE_SELECTION, + createState: (grid) => new RangeSelection(grid, options), + createApi: (grid) => { + const state = getRangeSelection(grid)! + return { + getRanges: () => state.ranges, + selectCellRange: state.startRange, + extendCellRange: state.extendTo, + clearCellRange: state.clear, + selectAllCells: state.selectAll, + copyRange: state.copyRange, + pasteRange: state.pasteFromClipboard, + getRangeTsv: state.getRangeTsv, + fillRange: state.fillWithin, + applyToRange: state.applyToRange, + cutRange: state.cutRange, + moveRange: state.moveRange, + cancelCut: state.cancelCut + } + }, + cellDecoration: ({ grid, rowIndex, colIndex }) => + getRangeSelection(grid)?.cellDecoration(rowIndex, colIndex), + keybindings: createKeybindings() + } +} + +export function getRangeSelection(grid: GridState): RangeSelection | undefined { + return grid.feature>(RANGE_SELECTION) +} + +declare module '../../core/types/api.js' { + interface GridApi { + getRanges?: () => CellRange[] + selectCellRange?: (row: number, col: number, options?: { additive?: boolean }) => void + extendCellRange?: (row: number, col: number) => void + clearCellRange?: () => void + selectAllCells?: () => void + copyRange?: (options?: CopyRangeOptions) => Promise + pasteRange?: () => Promise + getRangeTsv?: (options?: CopyRangeOptions) => string + fillRange?: (axis: 'down' | 'right') => boolean | Promise + applyToRange?: (value: unknown) => boolean | Promise + cutRange?: () => boolean | Promise + moveRange?: (row: number, col: number) => boolean | Promise + cancelCut?: () => void + } +} diff --git a/src/lib/features/range-selection/range-selection.test.ts b/src/lib/features/range-selection/range-selection.test.ts new file mode 100644 index 0000000..c44db30 --- /dev/null +++ b/src/lib/features/range-selection/range-selection.test.ts @@ -0,0 +1,907 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef, SELECTION_COLUMN_ID } from '../../core/types/index.js' +import { editing, getEditing } from '../../features/editing/index.js' +import { filtering } from '../../features/filtering/index.js' +import { selection } from '../../features/selection/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it, vi } from 'vitest' +import { grouping } from '../grouping/index.js' +import { getRangeSelection, rangeSelection } from './range-selection.svelte.js' +import type { RangeSelectionOptions } from './range-selection.types.js' + +interface Cell { + id: number + a: string + b: string + n: number +} + +const columns: ColumnDef[] = [ + { id: 'a', header: 'A', editable: true }, + { id: 'b', header: 'B', editable: true }, + { id: 'n', header: 'N', editable: true, editor: 'number', parse: (v) => Number(v) } +] + +function makeData(): Cell[] { + return [ + { id: 1, a: 'a1', b: 'b1', n: 10 }, + { id: 2, a: 'a2', b: 'b2', n: 20 }, + { id: 3, a: 'a3', b: 'b3', n: 30 } + ] +} + +function createGrid(options: RangeSelectionOptions = {}): GridState { + return createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), editing(), rangeSelection(options)] + }) +} + +describe('range state', () => { + it('starts, extends and clears a range', () => { + const state = getRangeSelection(createGrid())! + + state.startRange(0, 0) + expect(state.ranges).toEqual([{ top: 0, left: 0, bottom: 0, right: 0 }]) + + state.extendTo(2, 1) + expect(state.ranges).toEqual([{ top: 0, left: 0, bottom: 2, right: 1 }]) + expect(state.cellCount).toBe(6) + + state.clear() + expect(state.ranges).toEqual([]) + }) + + it('replaces the range unless the gesture is additive', () => { + const state = getRangeSelection(createGrid())! + + state.startRange(0, 0) + state.startRange(2, 2) + expect(state.ranges).toHaveLength(1) + + state.startRange(0, 0, { additive: true }) + expect(state.ranges).toHaveLength(2) + expect(state.cellCount).toBe(2) + }) + + it('ignores additive gestures when multiple is off', () => { + const state = getRangeSelection(createGrid({ multiple: false }))! + state.startRange(0, 0) + state.startRange(2, 2, { additive: true }) + expect(state.ranges).toHaveLength(1) + }) + + it('reports which cells are selected', () => { + const state = getRangeSelection(createGrid())! + state.startRange(1, 0) + state.extendTo(2, 1) + + expect(state.isSelected(1, 0)).toBe(true) + expect(state.isSelected(2, 1)).toBe(true) + expect(state.isSelected(0, 0)).toBe(false) + }) + + it('selects every cell', () => { + const state = getRangeSelection(createGrid())! + state.selectAll() + expect(state.ranges).toEqual([{ top: 0, left: 0, bottom: 2, right: 2 }]) + expect(state.cellCount).toBe(9) + }) +}) + +describe('keyboard extension', () => { + function keydown(key: string, modifiers: Partial = {}): KeyboardEvent { + return { + key, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + preventDefault: vi.fn(), + ...modifiers + } as unknown as KeyboardEvent + } + + it('grows the range with Shift+Arrow and carries the focus', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + grid.focus.focusCell({ row: 0, col: 0 }) + + grid.focus.handleKeydown(keydown('ArrowDown', { shiftKey: true })) + expect(grid.focus.active).toEqual({ row: 1, col: 0 }) + expect(state.ranges).toEqual([{ top: 0, left: 0, bottom: 1, right: 0 }]) + + grid.focus.handleKeydown(keydown('ArrowRight', { shiftKey: true })) + expect(state.ranges).toEqual([{ top: 0, left: 0, bottom: 1, right: 1 }]) + }) + + it('stops at the grid edges', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + grid.focus.focusCell({ row: 0, col: 0 }) + + grid.focus.handleKeydown(keydown('ArrowUp', { shiftKey: true })) + expect(state.ranges).toEqual([{ top: 0, left: 0, bottom: 0, right: 0 }]) + }) + + it('clears with Escape', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + + grid.focus.handleKeydown(keydown('Escape')) + expect(state.ranges).toEqual([]) + }) +}) + +describe('copy', () => { + it('builds TSV for the range', () => { + const state = getRangeSelection(createGrid())! + state.startRange(0, 0) + state.extendTo(1, 1) + + expect(state.getRangeTsv()).toBe('a1\tb1\na2\tb2') + }) + + it('can prefix the column headers', () => { + const state = getRangeSelection(createGrid())! + state.startRange(0, 1) + state.extendTo(1, 2) + + expect(state.getRangeTsv({ headers: true })).toBe('B\tN\nb1\t10\nb2\t20') + }) + + it('returns nothing without a range', () => { + expect(getRangeSelection(createGrid())!.getRangeTsv()).toBe('') + }) + + it('clamps a range that outgrew the data', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.selectAll() + grid.data = grid.data.slice(0, 1) + + expect(state.getRangeTsv()).toBe('a1\tb1\t10') + }) + + it('copies the raw value by default and the rendered text on request', () => { + const grid = createDataGrid({ + columns: [ + { id: 'a', header: 'A' }, + { id: 'n', header: 'N', type: 'currency', typeOptions: { currency: 'USD' } } + ], + data: [{ id: 1, a: 'a1', b: 'b1', n: 204_000 }], + getRowId: (row) => String(row.id), + locale: 'en-US', + features: [rangeSelection()] + }) + const state = getRangeSelection(grid)! + state.selectAll() + + expect(state.getRangeTsv()).toBe('a1\t204000') + expect(state.getRangeTsv({ formatted: true })).toBe('a1\t$204,000.00') + }) + + it('skips the selection column a range happens to span', () => { + const grid = createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [selection(), rangeSelection()] + }) + const state = getRangeSelection(grid)! + state.selectAll() + + expect(grid.columns.visible[0]?.id).toBe(SELECTION_COLUMN_ID) + expect(state.getRangeTsv({ headers: true }).split('\n')[0]).toBe('A\tB\tN') + }) +}) + +describe('paste', () => { + it('writes a block starting at the range anchor', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + + expect(state.pasteText('x1\ty1\nx2\ty2')).toBe(true) + expect(grid.data[0]).toMatchObject({ a: 'x1', b: 'y1' }) + expect(grid.data[1]).toMatchObject({ a: 'x2', b: 'y2' }) + }) + + it('repeats the block across a larger range', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(2, 0) + + expect(state.pasteText('z')).toBe(true) + expect(grid.data.map((row) => row.a)).toEqual(['z', 'z', 'z']) + }) + + it('runs the column parse so numbers land as numbers', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 2) + + state.pasteText('99') + expect(grid.data[0]!.n).toBe(99) + }) + + it('undoes the whole paste in one step', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.pasteText('x1\ty1\nx2\ty2') + + const undo = grid.api.undo as () => void + undo() + expect(grid.data[0]).toMatchObject({ a: 'a1', b: 'b1' }) + expect(grid.data[1]).toMatchObject({ a: 'a2', b: 'b2' }) + }) + + it('does nothing without a range or with empty text', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + expect(state.pasteText('x')).toBe(false) + + state.startRange(0, 0) + expect(state.pasteText('')).toBe(false) + }) + + it('stays out of the way when paste is disabled', () => { + const grid = createGrid({ paste: false }) + const state = getRangeSelection(grid)! + state.startRange(0, 0) + + expect(state.pasteText('x')).toBe(false) + expect(grid.data[0]!.a).toBe('a1') + }) +}) + +describe('status bar values', () => { + it('collects the numeric cells of every range', () => { + const state = getRangeSelection(createGrid())! + state.startRange(0, 2) + state.extendTo(2, 2) + + expect(state.selectedValues).toEqual([10, 20, 30]) + }) + + it('skips non-numeric cells', () => { + const state = getRangeSelection(createGrid())! + state.selectAll() + expect(state.selectedValues).toEqual([10, 20, 30]) + }) + + it('reports the shape of the primary range', () => { + const state = getRangeSelection(createGrid())! + state.startRange(0, 0) + state.extendTo(1, 2) + expect(state.shape).toEqual({ rows: 2, cols: 3 }) + }) +}) + +describe('cell decoration', () => { + it('decorates only cells inside a range', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(1, 1) + + const feature = grid.features.find((entry) => entry.id === 'rangeSelection')! + const decorate = (rowIndex: number, colIndex: number) => + feature.cellDecoration!({ + grid, + node: grid.preWindowNodes[rowIndex]!, + column: grid.columns.visible[colIndex]!, + rowIndex, + colIndex + }) + + expect(decorate(0, 0)).toMatchObject({ selected: true }) + expect(decorate(2, 2)).toBeUndefined() + }) +}) + +describe('fill handle', () => { + it('offers the handle on the range corner, and hides it mid-drag', () => { + const state = getRangeSelection(createGrid())! + expect(state.fillHandleAt).toBeNull() + + state.startRange(0, 0) + state.extendTo(1, 1) + + expect(state.fillHandleAt).toBeNull() + + state.endRange() + expect(state.fillHandleAt).toEqual({ row: 1, col: 1 }) + }) + + it('previews without writing, and writes on release', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + + state.startRange(0, 2) + state.endRange() + + state.startFill() + state.extendFill(2, 2) + expect(state.fillTarget).toEqual({ + axis: 'down', + range: { top: 1, left: 2, bottom: 2, right: 2 } + }) + expect(grid.data.map((row) => row.n)).toEqual([10, 20, 30]) + + expect(state.endFill()).toBe(true) + expect(grid.data.map((row) => row.n)).toEqual([10, 10, 10]) + expect(state.fillTarget).toBeNull() + }) + + it('continues a numeric run down the column', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 2) + state.extendTo(1, 2) + state.endRange() + + state.startFill() + state.extendFill(2, 2) + expect(state.endFill()).toBe(true) + expect(grid.data.map((row) => row.n)).toEqual([10, 20, 30]) + }) + + it('selects what it filled, the way releasing the handle does', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 2) + state.extendTo(1, 2) + state.endRange() + + state.startFill() + state.extendFill(2, 2) + state.endFill() + expect(state.ranges).toEqual([{ top: 0, left: 2, bottom: 2, right: 2 }]) + }) + + it('writes nothing when the pointer never left the source', () => { + const grid = createGrid() + const before = grid.data.map((row) => row.a) + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(1, 0) + state.endRange() + + state.startFill() + state.extendFill(1, 0) + expect(state.endFill()).toBe(false) + expect(grid.data.map((row) => row.a)).toEqual(before) + }) + + it('undoes a whole fill in one step', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 2) + state.endRange() + + state.startFill() + state.extendFill(2, 2) + state.endFill() + expect(grid.data.map((row) => row.n)).toEqual([10, 10, 10]) + ;(grid.api.undo as () => void)() + expect(grid.data.map((row) => row.n)).toEqual([10, 20, 30]) + }) + + it('does nothing at all when fill is switched off', () => { + const grid = createGrid({ fill: false }) + const state = getRangeSelection(grid)! + state.startRange(0, 2) + state.extendTo(1, 2) + state.endRange() + + expect(state.fillHandleAt).toBeNull() + state.startFill() + state.extendFill(2, 2) + expect(state.endFill()).toBe(false) + expect(grid.data.map((row) => row.n)).toEqual([10, 20, 30]) + }) +}) + +describe('Ctrl+D and Ctrl+R', () => { + it('fills the leading row down the range', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(2, 0) + + expect(state.fillWithin('down')).toBe(true) + expect(grid.data.map((row) => row.a)).toEqual(['a1', 'a1', 'a1']) + }) + + it('fills the leading column across the range', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(0, 1) + + expect(state.fillWithin('right')).toBe(true) + expect(grid.data[0]).toMatchObject({ a: 'a1', b: 'a1' }) + }) + + it('refuses a range with nothing to fill into', () => { + const state = getRangeSelection(createGrid())! + state.startRange(0, 0) + expect(state.fillWithin('down')).toBe(false) + expect(state.fillWithin('right')).toBe(false) + }) +}) + +describe('fill across rows that are not data', () => { + interface Grouped { + id: number + dept: string + n: number + } + + function groupedGrid() { + return createDataGrid({ + columns: [ + { id: 'dept', header: 'Dept' }, + { id: 'n', header: 'N', editable: true, parse: Number } + ], + data: [ + { id: 1, dept: 'Core', n: 1 }, + { id: 2, dept: 'Core', n: 2 }, + { id: 3, dept: 'Data', n: 100 }, + { id: 4, dept: 'Data', n: 200 } + ], + getRowId: (row) => String(row.id), + features: [sorting(), editing(), grouping({ by: ['dept'] }), rangeSelection()] + }) + } + + it('a group row inside the target does not eat a step', () => { + const grid = groupedGrid() + const state = getRangeSelection(grid)! + + state.startRange(1, 1) + state.extendTo(2, 1) + state.endRange() + + state.startFill() + state.extendFill(5, 1) + state.endFill() + + expect(grid.data.map((row) => row.n)).toEqual([1, 2, 3, 4]) + }) + + it('skips a group row on the paste path too, keeping the block aligned', () => { + const grid = groupedGrid() + const state = getRangeSelection(grid)! + + state.startRange(1, 1) + state.extendTo(5, 1) + state.pasteText('7\n8\n9\n10\n11') + + expect(grid.data.map((row) => row.n)).toEqual([7, 8, 10, 11]) + }) + + it('a group row inside the source does not poison the run', () => { + const grid = groupedGrid() + const state = getRangeSelection(grid)! + state.startRange(2, 1) + state.extendTo(3, 1) + state.endRange() + + state.startFill() + state.extendFill(5, 1) + state.endFill() + + expect(grid.data.every((row) => Number.isFinite(row.n))).toBe(true) + }) +}) + +describe('a range with nothing writable in it', () => { + function readOnlyGrid() { + return createDataGrid({ + columns: [ + { id: 'a', header: 'A' }, + { id: 'b', header: 'B' }, + { id: 'n', header: 'N', editable: true, parse: Number } + ], + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), rangeSelection()] + }) + } + + it('offers no handle, because the gesture would do nothing', () => { + const state = getRangeSelection(readOnlyGrid())! + state.startRange(0, 0) + state.extendTo(1, 0) + state.endRange() + + expect(state.fillHandleAt).toBeNull() + }) + + it('still offers one when part of the range can take a write', () => { + const state = getRangeSelection(readOnlyGrid())! + state.startRange(0, 1) + state.extendTo(1, 2) + state.endRange() + + expect(state.fillHandleAt).toEqual({ row: 1, col: 2 }) + }) + + it('previews nothing over a target it cannot write', () => { + const state = getRangeSelection(readOnlyGrid())! + state.startRange(0, 0) + state.endRange() + + state.startFill() + state.extendFill(2, 0) + expect(state.fillTarget).toBeNull() + }) +}) + +describe('a range that spans the selection checkbox column', () => { + function checkboxGrid() { + return createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), selection(), rangeSelection()] + }) + } + + it('round-trips copy then paste, instead of shifting every column left', () => { + const grid = checkboxGrid() + const state = getRangeSelection(grid)! + state.selectAll() + + expect(grid.columns.visible[0]?.id).toBe(SELECTION_COLUMN_ID) + expect(state.pasteText(state.getRangeTsv())).toBe(true) + expect(grid.data).toEqual(makeData()) + }) + + it('spills a block wider than the range onto the next data columns', () => { + const grid = checkboxGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 1) + state.endRange() + + expect(state.pasteText('p\tq')).toBe(true) + expect(grid.data[0]).toMatchObject({ a: 'p', b: 'q' }) + }) + + it('writes nothing when the checkbox column is all the range holds', () => { + const grid = checkboxGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.endRange() + + expect(state.buildPasteEdits([['zzz']])).toEqual([]) + expect(grid.data[0]).toMatchObject({ a: 'a1' }) + }) +}) + +describe('a range left behind by the data', () => { + it('counts the cells that are still there, not the ones it was drawn over', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.selectAll() + expect(state.cellCount).toBe(9) + expect(state.shape).toEqual({ rows: 3, cols: 3 }) + + const setQuickFilter = grid.api.setQuickFilter as (text: string) => void + setQuickFilter('a1') + + expect(grid.totalRows).toBe(1) + expect(state.cellCount).toBe(3) + expect(state.shape).toEqual({ rows: 1, cols: 3 }) + }) +}) + +describe('range editing with Ctrl+Enter', () => { + function editingGrid(options: RangeSelectionOptions = {}) { + const grid = createGrid(options) + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(2, 1) + state.endRange() + return { grid, state, edit: getEditing(grid)! } + } + + it('writes the open draft into every cell of the selection', () => { + const { grid, state, edit } = editingGrid() + edit.startEdit('1', 'a') + edit.setDraft('same') + + expect(state.commitDraftToRange()).toBe(true) + expect(grid.data.map((row) => [row.a, row.b])).toEqual([ + ['same', 'same'], + ['same', 'same'], + ['same', 'same'] + ]) + }) + + it('closes the editor rather than leaving a draft behind it', () => { + const { state, edit } = editingGrid() + edit.startEdit('1', 'a') + edit.setDraft('same') + state.commitDraftToRange() + + expect(edit.active).toBeNull() + }) + + it('is one undo step, like a paste', () => { + const { grid, state, edit } = editingGrid() + edit.startEdit('1', 'a') + edit.setDraft('same') + state.commitDraftToRange() + + edit.undo() + expect(grid.data.map((row) => row.a)).toEqual(['a1', 'a2', 'a3']) + }) + + it('runs the column parse, so a number column takes a number', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 2) + state.extendTo(1, 2) + state.endRange() + + const edit = getEditing(grid)! + edit.startEdit('1', 'n') + edit.setDraft('77') + expect(state.commitDraftToRange()).toBe(true) + expect(grid.data.map((row) => row.n)).toEqual([77, 77, 30]) + }) + + it("leaves a single selected cell to Community's plain commit", () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.endRange() + + const edit = getEditing(grid)! + edit.startEdit('1', 'a') + edit.setDraft('only') + expect(state.commitDraftToRange()).toBe(false) + expect(edit.active).not.toBeNull() + }) + + it('stands aside when the edited cell is outside the selection', () => { + const { state, edit } = editingGrid() + edit.startEdit('1', 'n') + edit.setDraft('99') + + expect(state.commitDraftToRange()).toBe(false) + }) + + it('does nothing when the option is off', () => { + const { state, edit } = editingGrid({ rangeEdit: false }) + edit.startEdit('1', 'a') + edit.setDraft('same') + + expect(state.commitDraftToRange()).toBe(false) + }) + + it('applies a value straight through the api, with no editor open', () => { + const { grid, state } = editingGrid() + expect(state.applyToRange('api')).toBe(true) + expect(grid.data[1]).toMatchObject({ a: 'api', b: 'api' }) + + const applyToRange = grid.api.applyToRange as (value: unknown) => boolean + expect(applyToRange('again')).toBe(true) + expect(grid.data[2]).toMatchObject({ a: 'again', b: 'again' }) + }) + + it('skips rows that are not data', () => { + const grid = createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), grouping({ by: ['b'] }), rangeSelection()] + }) + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(grid.preWindowNodes.length - 1, 1) + state.endRange() + + expect(state.applyToRange('flat')).toBe(true) + expect(grid.data.every((row) => row.a === 'flat')).toBe(true) + }) +}) + +describe('cut and move', () => { + function cutGrid() { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(0, 1) + state.endRange() + return { grid, state } + } + + it('moves the block and clears where it came from, in one undo step', () => { + const { grid, state } = cutGrid() + state.cutSource = { top: 0, left: 0, bottom: 0, right: 1 } + + expect(state.moveRange(2, 0)).toBe(true) + expect(grid.data[2]).toMatchObject({ a: 'a1', b: 'b1' }) + expect(grid.data[0]).toMatchObject({ a: null, b: null }) + + const undo = grid.api.undo as () => void + undo() + expect(grid.data.map((row) => [row.a, row.b])).toEqual([ + ['a1', 'b1'], + ['a2', 'b2'], + ['a3', 'b3'] + ]) + }) + + it('selects what it moved, so the next gesture works on it', () => { + const { state } = cutGrid() + state.cutSource = { top: 0, left: 0, bottom: 0, right: 1 } + state.moveRange(1, 0) + + expect(state.ranges).toEqual([{ top: 1, left: 0, bottom: 1, right: 1 }]) + expect(state.cutSource).toBeNull() + }) + + it('keeps the overlap rather than clearing what it just wrote', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.cutSource = { top: 0, left: 0, bottom: 1, right: 0 } + + state.moveRange(1, 0) + expect(grid.data.map((row) => row.a)).toEqual([null, 'a1', 'a2']) + }) + + it('clears through the column parse, so an unguarded parse decides what empty is', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.cutSource = { top: 0, left: 2, bottom: 0, right: 2 } + + state.moveRange(1, 2) + expect(grid.data[1]!.n).toBe(10) + expect(grid.data[0]!.n).toBe(0) + }) + + it('does nothing without a cut, and nothing when the block lands off the grid', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + expect(state.moveRange(1, 0)).toBe(false) + + state.cutSource = { top: 0, left: 0, bottom: 0, right: 0 } + expect(state.moveRange(9, 0)).toBe(false) + expect(grid.data[0]).toMatchObject({ a: 'a1' }) + }) + + it('stays out of the way when cut is switched off', () => { + const grid = createGrid({ cut: false }) + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.endRange() + + expect(state.cutRange()).toBe(false) + expect(state.cutSource).toBeNull() + expect(state.moveBorderAt(0, 0)).toBeNull() + }) + + it('drags the border: preview follows, drop moves, and a drop in place writes nothing', () => { + const { grid, state } = cutGrid() + + expect(state.moveBorderAt(0, 0)).toEqual({ + top: true, + bottom: true, + start: true, + end: false + }) + expect(state.moveBorderAt(2, 2)).toBeNull() + + state.startMove(0, 0) + expect(state.moving).toBe(true) + state.extendMove(1, 0) + expect(state.moveTarget).toEqual({ top: 1, left: 0, bottom: 1, right: 1 }) + + expect(state.endMove()).toBe(true) + expect(grid.data[1]).toMatchObject({ a: 'a1', b: 'b1' }) + expect(state.moving).toBe(false) + + state.startRange(0, 0) + state.endRange() + state.startMove(0, 0) + state.extendMove(0, 0) + expect(state.endMove()).toBe(false) + }) + + it('names only the edges that sit on the range boundary', () => { + const grid = createGrid() + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.extendTo(2, 2) + state.endRange() + + expect(state.moveBorderAt(1, 0)).toEqual({ + top: false, + bottom: false, + start: true, + end: false + }) + expect(state.moveBorderAt(1, 1)).toBeNull() + expect(state.moveBorderAt(2, 2)).toEqual({ + top: false, + bottom: true, + start: false, + end: true + }) + }) + + it('forgets the cut marks when a drop cannot be written', () => { + const grid = createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), rangeSelection()] + }) + const state = getRangeSelection(grid)! + state.startRange(0, 0) + state.endRange() + + state.startMove(0, 0) + state.extendMove(1, 0) + expect(state.endMove()).toBe(false) + expect(state.cutSource).toBeNull() + }) + + it('moves past a group row without writing to it or losing the value', () => { + const grid = createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), grouping({ by: ['b'] }), rangeSelection()] + }) + const state = getRangeSelection(grid)! + const groupRows = grid.preWindowNodes.filter((node) => node.meta?.expandable).length + expect(groupRows).toBeGreaterThan(0) + + state.cutSource = { top: 1, left: 0, bottom: 1, right: 0 } + state.moveRange(0, 0) + + expect(grid.data.map((row) => row.a)).toEqual(['a1', 'a2', 'a3']) + }) + + it('keeps the source when the target column refuses the write', () => { + const grid = createDataGrid({ + columns: columns.map((column) => + column.id === 'b' ? { ...column, editable: false } : column + ), + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), rangeSelection()] + }) + const state = getRangeSelection(grid)! + + state.cutSource = { top: 0, left: 0, bottom: 0, right: 0 } + expect(state.moveRange(0, 1)).toBe(false) + expect(grid.data[0]).toMatchObject({ a: 'a1', b: 'b1' }) + }) + + it('keeps the dragged block inside the grid', () => { + const { state } = cutGrid() + state.startMove(0, 1) + state.extendMove(9, 9) + + expect(state.moveTarget).toEqual({ top: 2, left: 1, bottom: 2, right: 2 }) + }) + + it('clearing the selection forgets the cut', () => { + const { state } = cutGrid() + state.cutRange() + state.clear() + expect(state.cutSource).toBeNull() + }) +}) diff --git a/src/lib/features/range-selection/range-selection.types.ts b/src/lib/features/range-selection/range-selection.types.ts new file mode 100644 index 0000000..8ca0ec5 --- /dev/null +++ b/src/lib/features/range-selection/range-selection.types.ts @@ -0,0 +1,69 @@ +export interface RangeSelectionOptions { + /** + * Allow several ranges at once (Ctrl/Cmd while starting a new one). + * @default true + */ + multiple?: boolean + + /** + * Let Ctrl/Cmd+V write the clipboard into the range. Requires the + * `editing` feature; only editable cells are written. + * @default true + */ + paste?: boolean + + /** + * Let `Ctrl/Cmd+Enter` from an open editor write the value into every + * editable cell of the selection instead of only the cell being edited, + * the way a spreadsheet does. Requires the `editing` feature, and takes + * effect only while the edited cell is inside a selection of more than + * one cell - a lone cell keeps Community's plain commit. + * @default true + */ + rangeEdit?: boolean + + /** + * Let `Ctrl/Cmd+X` mark the range and the next `Ctrl/Cmd+V` **move** it - + * the values land at the new place and the cells they came from are + * cleared, in one undo step. Dragging the range's border moves it the + * same way. Requires the `editing` feature, since a move is a write. + * + * A cleared cell is written as `null`, which still goes through the + * column's `parse`: a `parse` that cannot represent empty decides what + * empty becomes there. + * @default true + */ + cut?: boolean + + /** + * Show the fill handle on the range's bottom-right cell, and enable + * `Ctrl/Cmd+D` / `Ctrl/Cmd+R`. Like `paste`, this writes through the + * `editing` feature, so without it the handle does nothing. + * @default true + */ + fill?: boolean +} + +export interface CopyRangeOptions { + /** Prepend a row of column headers. */ + headers?: boolean + + /** + * Copy what the grid is showing - the column's `type` applied - rather + * than the value behind it. Off by default, matching the Community + * `copySelection` default, so a paste into a spreadsheet keeps a number a + * number. A column whose `type` draws a widget has no text of its own and + * falls back to the raw value. + * @default false + */ + formatted?: boolean + + /** + * Also write a `text/html` table, so a paste into Word, Google Docs or + * Excel keeps the grid shape instead of arriving as one line of text. + * The plain-text flavour is written either way, and is the one that + * pastes back into this grid. + * @default true + */ + html?: boolean +} diff --git a/src/lib/features/range-selection/range.test.ts b/src/lib/features/range-selection/range.test.ts new file mode 100644 index 0000000..e8fb574 --- /dev/null +++ b/src/lib/features/range-selection/range.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + boundsOf, + cellsOf, + clampRange, + containsCell, + isInAnyRange, + parseTsv, + pasteTargets, + rangeBetween, + rangeCols, + rangeRows, + rangeSize +} from './range.js' + +describe('rangeBetween', () => { + it('normalises whichever corner came first', () => { + const forward = rangeBetween({ row: 1, col: 2 }, { row: 3, col: 5 }) + const backward = rangeBetween({ row: 3, col: 5 }, { row: 1, col: 2 }) + expect(forward).toEqual({ top: 1, left: 2, bottom: 3, right: 5 }) + expect(backward).toEqual(forward) + }) + + it('makes a single cell when both corners match', () => { + expect(rangeBetween({ row: 2, col: 2 }, { row: 2, col: 2 })).toEqual({ + top: 2, + left: 2, + bottom: 2, + right: 2 + }) + }) +}) + +describe('membership', () => { + const range = { top: 1, left: 1, bottom: 2, right: 3 } + + it('includes the edges and excludes the outside', () => { + expect(containsCell(range, 1, 1)).toBe(true) + expect(containsCell(range, 2, 3)).toBe(true) + expect(containsCell(range, 0, 1)).toBe(false) + expect(containsCell(range, 2, 4)).toBe(false) + }) + + it('checks several ranges at once', () => { + const other = { top: 5, left: 0, bottom: 5, right: 0 } + expect(isInAnyRange([range, other], 5, 0)).toBe(true) + expect(isInAnyRange([range, other], 4, 0)).toBe(false) + expect(isInAnyRange([], 1, 1)).toBe(false) + }) +}) + +describe('measurements', () => { + it('counts rows, columns and cells', () => { + const range = { top: 1, left: 1, bottom: 2, right: 3 } + expect(rangeRows(range)).toBe(2) + expect(rangeCols(range)).toBe(3) + expect(rangeSize(range)).toBe(6) + }) + + it('walks cells row-major', () => { + expect(cellsOf({ top: 0, left: 0, bottom: 1, right: 1 })).toEqual([ + { row: 0, col: 0 }, + { row: 0, col: 1 }, + { row: 1, col: 0 }, + { row: 1, col: 1 } + ]) + }) + + it('bounds several ranges', () => { + expect( + boundsOf([ + { top: 3, left: 0, bottom: 4, right: 1 }, + { top: 1, left: 2, bottom: 2, right: 5 } + ]) + ).toEqual({ top: 1, left: 0, bottom: 4, right: 5 }) + expect(boundsOf([])).toBeNull() + }) +}) + +describe('clampRange', () => { + it('trims to the grid', () => { + expect(clampRange({ top: -2, left: 0, bottom: 99, right: 99 }, 4, 2)).toEqual({ + top: 0, + left: 0, + bottom: 4, + right: 2 + }) + }) + + it('returns null when the range starts past the grid', () => { + expect(clampRange({ top: 9, left: 0, bottom: 12, right: 1 }, 4, 2)).toBeNull() + }) +}) + +describe('pasteTargets', () => { + it('drops a block at a single-cell target', () => { + const targets = pasteTargets({ top: 2, left: 1, bottom: 2, right: 1 }, 2, 2) + expect(targets).toHaveLength(4) + expect(targets[0]).toEqual({ row: 2, col: 1, blockRow: 0, blockCol: 0 }) + expect(targets[3]).toEqual({ row: 3, col: 2, blockRow: 1, blockCol: 1 }) + }) + + it('repeats the block to fill a larger target, like a spreadsheet', () => { + const targets = pasteTargets({ top: 0, left: 0, bottom: 3, right: 1 }, 2, 1) + expect(targets).toHaveLength(8) + expect(targets.map((t) => t.blockRow)).toEqual([0, 0, 1, 1, 0, 0, 1, 1]) + expect(targets.every((t) => t.blockCol === 0)).toBe(true) + }) + + it('returns nothing for an empty block', () => { + expect(pasteTargets({ top: 0, left: 0, bottom: 2, right: 2 }, 0, 0)).toEqual([]) + }) +}) + +describe('parseTsv', () => { + it('splits rows and columns', () => { + expect(parseTsv('a\tb\nc\td')).toEqual([ + ['a', 'b'], + ['c', 'd'] + ]) + }) + + it('accepts CRLF and ignores a trailing newline', () => { + expect(parseTsv('a\tb\r\nc\td\r\n')).toEqual([ + ['a', 'b'], + ['c', 'd'] + ]) + }) + + it('keeps empty cells', () => { + expect(parseTsv('a\t\tc')).toEqual([['a', '', 'c']]) + }) + + it('returns nothing for empty text', () => { + expect(parseTsv('')).toEqual([]) + expect(parseTsv('\n')).toEqual([]) + }) +}) diff --git a/src/lib/features/range-selection/range.ts b/src/lib/features/range-selection/range.ts new file mode 100644 index 0000000..0b9011b --- /dev/null +++ b/src/lib/features/range-selection/range.ts @@ -0,0 +1,101 @@ +export interface CellPosition { + row: number + col: number +} + +export interface CellRange { + top: number + left: number + bottom: number + right: number +} + +export function rangeBetween(anchor: CellPosition, focus: CellPosition): CellRange { + return { + top: Math.min(anchor.row, focus.row), + bottom: Math.max(anchor.row, focus.row), + left: Math.min(anchor.col, focus.col), + right: Math.max(anchor.col, focus.col) + } +} + +export function containsCell(range: CellRange, row: number, col: number): boolean { + return row >= range.top && row <= range.bottom && col >= range.left && col <= range.right +} + +export function isInAnyRange(ranges: CellRange[], row: number, col: number): boolean { + for (const range of ranges) { + if (containsCell(range, row, col)) return true + } + return false +} + +export function rangeRows(range: CellRange): number { + return range.bottom - range.top + 1 +} + +export function rangeCols(range: CellRange): number { + return range.right - range.left + 1 +} + +export function rangeSize(range: CellRange): number { + return rangeRows(range) * rangeCols(range) +} + +export function cellsOf(range: CellRange): CellPosition[] { + const cells: CellPosition[] = [] + for (let row = range.top; row <= range.bottom; row++) { + for (let col = range.left; col <= range.right; col++) cells.push({ row, col }) + } + return cells +} + +export function boundsOf(ranges: CellRange[]): CellRange | null { + if (ranges.length === 0) return null + return ranges.reduce((bounds, range) => ({ + top: Math.min(bounds.top, range.top), + left: Math.min(bounds.left, range.left), + bottom: Math.max(bounds.bottom, range.bottom), + right: Math.max(bounds.right, range.right) + })) +} + +export function clampRange(range: CellRange, maxRow: number, maxCol: number): CellRange | null { + if (range.top > maxRow || range.left > maxCol) return null + return { + top: Math.max(0, range.top), + left: Math.max(0, range.left), + bottom: Math.min(maxRow, range.bottom), + right: Math.min(maxCol, range.right) + } +} + +export function pasteTargets( + target: CellRange, + blockRows: number, + blockCols: number +): { row: number; col: number; blockRow: number; blockCol: number }[] { + if (blockRows === 0 || blockCols === 0) return [] + + const rows = Math.max(rangeRows(target), blockRows) + const cols = Math.max(rangeCols(target), blockCols) + const targets: { row: number; col: number; blockRow: number; blockCol: number }[] = [] + + for (let rowOffset = 0; rowOffset < rows; rowOffset++) { + for (let colOffset = 0; colOffset < cols; colOffset++) { + targets.push({ + row: target.top + rowOffset, + col: target.left + colOffset, + blockRow: rowOffset % blockRows, + blockCol: colOffset % blockCols + }) + } + } + return targets +} + +export function parseTsv(text: string): string[][] { + const normalised = text.replace(/\r\n/g, '\n').replace(/\n+$/, '') + if (normalised === '') return [] + return normalised.split('\n').map((line) => line.split('\t')) +} diff --git a/src/lib/features/range-selection/verify.test.ts b/src/lib/features/range-selection/verify.test.ts new file mode 100644 index 0000000..ad3c1d0 --- /dev/null +++ b/src/lib/features/range-selection/verify.test.ts @@ -0,0 +1,245 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { editing } from '../../features/editing/index.js' +import { sorting } from '../../features/sorting/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it } from 'vitest' +import { grouping } from '../grouping/index.js' +import { masterDetail } from '../master-detail/index.js' +import { serverRowModel } from '../server-row-model/index.js' +import { tree } from '../tree/index.js' +import { getRangeSelection, rangeSelection } from './range-selection.svelte.js' + +interface Row { + id: number + parentId: number | null + dept: string + n: number +} + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept' }, + { id: 'n', header: 'N', editable: true, parse: Number } +] + +function rows(): Row[] { + return [ + { id: 1, parentId: null, dept: 'Core', n: 1 }, + { id: 2, parentId: 1, dept: 'Core', n: 2 }, + { id: 3, parentId: null, dept: 'Data', n: 100 }, + { id: 4, parentId: 3, dept: 'Data', n: 200 } + ] +} + +function fillDown(grid: GridState, from: number, to: number, at: number) { + const state = getRangeSelection(grid)! + state.startRange(from, 1) + state.extendTo(to, 1) + state.endRange() + state.startFill() + state.extendFill(at, 1) + state.endFill() + return grid.data.map((row) => row.n) +} + +describe('I1 verified across every synthetic row shape', () => { + it('group headers', () => { + const grid = createDataGrid({ + columns, + data: rows(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), grouping({ by: ['dept'] }), rangeSelection()] + }) + + expect(fillDown(grid, 1, 2, 5)).toEqual([1, 2, 3, 4]) + }) + + it('group footers and the grand total', () => { + const grid = createDataGrid({ + columns, + data: rows(), + getRowId: (row) => String(row.id), + features: [ + sorting(), + editing(), + grouping({ + by: ['dept'], + aggregations: { n: 'sum' }, + groupFooters: true, + grandTotal: true + }), + rangeSelection() + ] + }) + const ids = grid.preWindowNodes.map((node) => node.id) + + expect(ids.filter((id) => grid.nodeById(id) === undefined)).toHaveLength(5) + expect(fillDown(grid, 1, 2, ids.length - 1)).toEqual([1, 2, 3, 4]) + }) + + it('master-detail panels', () => { + const grid = createDataGrid({ + columns, + data: rows(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), masterDetail(), rangeSelection()] + }) + ;(grid.api.toggleDetail as (id: string) => void)('2') + const detail = grid.preWindowNodes.filter((node) => node.meta?.fullWidth) + expect(detail).toHaveLength(1) + + expect(fillDown(grid, 0, 1, 4)).toEqual([1, 2, 3, 4]) + }) + + it('tree rows stay fillable, because they are data', () => { + const grid = createDataGrid({ + columns, + data: rows(), + getRowId: (row) => String(row.id), + features: [ + sorting(), + editing(), + tree({ + getParentId: (row) => (row.parentId ? String(row.parentId) : null), + defaultExpandedDepth: 1 + }), + rangeSelection() + ] + }) + expect(grid.preWindowNodes).toHaveLength(4) + expect(fillDown(grid, 0, 1, 3)).toEqual([1, 2, 3, 4]) + }) + + it('server placeholder rows', async () => { + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization(), + editing(), + serverRowModel( + { + getRows: async ({ startRow, endRow }) => ({ + rows: rows().slice(startRow, Math.min(endRow, 4)), + rowCount: 8 + }) + }, + { + mode: 'infinite', + blockSize: 4, + placeholder: (index) => ({ + id: -index - 1, + parentId: null, + dept: '', + n: 0 + }) + } + ), + rangeSelection() + ] + }) + const model = grid.feature<{ refresh: () => void }>('serverRowModel')! + model.refresh() + await expect.poll(() => grid.data.length).toBe(8) + + const state = getRangeSelection(grid)! + state.startRange(0, 1) + state.extendTo(1, 1) + state.endRange() + state.startFill() + state.extendFill(7, 1) + state.endFill() + + expect(grid.data.slice(0, 4).map((row) => row.n)).toEqual([1, 2, 3, 4]) + + expect(grid.data.slice(4).map((row) => row.n)).toEqual([0, 0, 0, 0]) + }) +}) + +describe('cut and move on a server row model', () => { + async function serverGrid() { + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization(), + editing(), + serverRowModel( + { + getRows: async ({ startRow, endRow }) => ({ + rows: rows().slice(startRow, Math.min(endRow, 4)), + rowCount: 8 + }) + }, + { + mode: 'infinite', + blockSize: 4, + placeholder: (index) => ({ id: -index - 1, parentId: null, dept: '', n: 0 }) + } + ), + rangeSelection() + ] + }) + const model = grid.feature<{ refresh: () => void }>('serverRowModel')! + model.refresh() + await expect.poll(() => grid.data.length).toBe(8) + return grid + } + + it('copies a blank for a row the server has not sent, not the placeholder value', async () => { + const grid = await serverGrid() + const state = getRangeSelection(grid)! + state.startRange(2, 1) + state.extendTo(5, 1) + state.endRange() + + expect(state.getRangeTsv()).toBe('100\n200\n\n') + }) + + it('refuses to move onto rows that are still loading', async () => { + const grid = await serverGrid() + const state = getRangeSelection(grid)! + state.cutSource = { top: 0, left: 1, bottom: 1, right: 1 } + + expect(state.moveRange(4, 1)).toBe(false) + expect(grid.data.slice(0, 2).map((row) => row.n)).toEqual([1, 2]) + }) + + it('refuses to move a placeholder onto a real row, rather than writing its zeros there', async () => { + const grid = await serverGrid() + const state = getRangeSelection(grid)! + state.cutSource = { top: 4, left: 1, bottom: 5, right: 1 } + + expect(state.moveRange(0, 1)).toBe(false) + expect(grid.data.slice(0, 2).map((row) => row.n)).toEqual([1, 2]) + }) +}) + +describe('a cut that outlives the rows it was taken from', () => { + it('refuses the move once the rows under it are different rows', () => { + const grid = createDataGrid({ + columns, + data: rows(), + getRowId: (row) => String(row.id), + features: [sorting(), editing(), rangeSelection()] + }) + const state = getRangeSelection(grid)! + state.startRange(0, 1) + state.endRange() + state.cutRange() + expect(state.cutSource).not.toBeNull() + + const setSort = grid.api.setSort as ( + state: { columnId: string; direction: 'desc' }[] + ) => void + setSort([{ columnId: 'n', direction: 'desc' }]) + + expect(state.moveRange(3, 1)).toBe(false) + expect(state.cutSource).toBeNull() + expect(grid.data.map((row) => row.n)).toEqual([1, 2, 100, 200]) + }) +}) diff --git a/src/lib/features/saved-views/index.ts b/src/lib/features/saved-views/index.ts new file mode 100644 index 0000000..067990c --- /dev/null +++ b/src/lib/features/saved-views/index.ts @@ -0,0 +1,4 @@ +export { getSavedViews, SAVED_VIEWS, SavedViews, savedViews } from './saved-views.svelte.js' +export { readShareParam, SHARE_PARAM, withShareParam } from './share-param.js' +export type { SavedView, SavedViewStorage, SavedViewsOptions } from './saved-views.types.js' +export { DEFAULT_VIEWS_KEY, localStorageViews, mergeViews, parseViews } from './storage.js' diff --git a/src/lib/features/saved-views/saved-views.svelte.ts b/src/lib/features/saved-views/saved-views.svelte.ts new file mode 100644 index 0000000..887d958 --- /dev/null +++ b/src/lib/features/saved-views/saved-views.svelte.ts @@ -0,0 +1,166 @@ +import { + decodeSnapshot, + encodeSnapshot, + type GridState, + sameSnapshot +} from '../../core/grid/index.js' +import { type GridFeature } from '../../core/types/index.js' +import type { SavedView, SavedViewsOptions, SavedViewStorage } from './saved-views.types.js' +import { readShareParam, withShareParam } from './share-param.js' +import { DEFAULT_VIEWS_KEY, localStorageViews, mergeViews } from './storage.js' + +export const SAVED_VIEWS = 'savedViews' + +function newId(): string { + return `view-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +export class SavedViews { + views = $state.raw([]) + activeId = $state(null) + + readonly key: string + + #grid: GridState + #storage: SavedViewStorage | null + #onChange?: (views: SavedView[]) => void + + constructor(grid: GridState, options: SavedViewsOptions) { + this.#grid = grid + this.key = options.key ?? DEFAULT_VIEWS_KEY + this.#storage = options.storage === undefined ? localStorageViews : options.storage + this.#onChange = options.onChange + this.views = mergeViews(options.views ?? [], this.#storage?.read(this.key) ?? null) + } + + get active(): SavedView | null { + return this.views.find((view) => view.id === this.activeId) ?? null + } + + get modified(): boolean | null { + const active = this.active + if (!active) return null + return !sameSnapshot(this.#grid.api.getState(), active.snapshot) + } + + #commit(views: SavedView[]): void { + this.views = views + this.#storage?.write(this.key, views) + this.#onChange?.(views) + } + + save = (name: string): SavedView | null => { + const trimmed = name.trim() + if (trimmed === '') return null + + const view: SavedView = { id: newId(), name: trimmed, snapshot: this.#grid.api.getState() } + this.#commit([...this.views, view]) + this.activeId = view.id + return view + } + + update = (id: string): boolean => { + const snapshot = this.#grid.api.getState() + let found = false + const next = this.views.map((view) => { + if (view.id !== id) return view + found = true + return { ...view, snapshot } + }) + if (found) this.#commit(next) + return found + } + + rename = (id: string, name: string): boolean => { + const trimmed = name.trim() + if (trimmed === '') return false + + let found = false + const next = this.views.map((view) => { + if (view.id !== id) return view + found = true + return { ...view, name: trimmed } + }) + if (found) this.#commit(next) + return found + } + + remove = (id: string): boolean => { + const next = this.views.filter((view) => view.id !== id) + if (next.length === this.views.length) return false + this.#commit(next) + if (this.activeId === id) this.activeId = null + return true + } + + apply = (id: string): boolean => { + const view = this.views.find((candidate) => candidate.id === id) + if (!view) return false + this.#grid.api.setState(view.snapshot) + this.activeId = id + return true + } + + revert = (): boolean => { + const active = this.active + if (!active) return false + this.#grid.api.setState(active.snapshot) + return true + } + + shareToken = (): Promise => encodeSnapshot(this.#grid.api.getState()) + + shareLink = async (base?: string): Promise => { + const href = base ?? (typeof location === 'undefined' ? null : location.href) + if (href === null) return null + + return withShareParam(href, await this.shareToken()) + } + + applyToken = async (token: string): Promise => { + const snapshot = await decodeSnapshot(token) + if (!snapshot) return false + this.#grid.api.setState(snapshot) + this.activeId = null + return true + } + + applyLink = async (href?: string): Promise => { + const source = href ?? (typeof location === 'undefined' ? null : location.href) + if (source === null) return false + + const token = readShareParam(source) + return token === null ? false : this.applyToken(token) + } +} + +export function savedViews(options: SavedViewsOptions = {}): GridFeature { + return { + id: SAVED_VIEWS, + createState: (grid) => new SavedViews(grid, options), + createApi: (grid) => { + const state = getSavedViews(grid)! + return { + saveView: state.save, + applyView: state.apply, + removeView: state.remove, + shareGridLink: state.shareLink, + applyGridLink: state.applyLink + } + } + } +} + +export function getSavedViews(grid: GridState): SavedViews | undefined { + return grid.feature>(SAVED_VIEWS) +} + +declare module '../../core/types/api.js' { + interface GridApi { + saveView?: (name: string) => unknown + applyView?: (id: string) => boolean + removeView?: (id: string) => boolean + shareGridLink?: (base?: string) => Promise + applyGridLink?: (href?: string) => Promise + } +} diff --git a/src/lib/features/saved-views/saved-views.test.ts b/src/lib/features/saved-views/saved-views.test.ts new file mode 100644 index 0000000..99e4b6f --- /dev/null +++ b/src/lib/features/saved-views/saved-views.test.ts @@ -0,0 +1,295 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getGrouping, grouping } from '../grouping/index.js' +import { getSavedViews, savedViews } from './saved-views.svelte.js' +import { SHARE_PARAM } from './share-param.js' +import type { SavedView, SavedViewsOptions, SavedViewStorage } from './saved-views.types.js' +import { mergeViews, parseViews } from './storage.js' + +interface Row { + id: number + dept: string + country: string + salary: number +} + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept', sortable: true, filter: 'text' }, + { id: 'country', header: 'Country', sortable: true }, + { id: 'salary', header: 'Salary', sortable: true } +] + +const data: Row[] = [ + { id: 1, dept: 'Core', country: 'VN', salary: 100 }, + { id: 2, dept: 'Core', country: 'US', salary: 200 }, + { id: 3, dept: 'Data', country: 'VN', salary: 300 } +] + +function memoryStorage(): SavedViewStorage & { saved: SavedView[] | null } { + return { + saved: null, + read() { + return this.saved + }, + write(_key, views) { + this.saved = views + } + } +} + +function makeGrid(options: SavedViewsOptions = { storage: null }): GridState { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), grouping(), savedViews(options)] + }) +} + +describe('saving and applying', () => { + it('captures the grouping, which is the whole reason to save a view', () => { + const grid = makeGrid() + const views = getSavedViews(grid)! + getGrouping(grid)!.setGroupBy(['dept']) + + const view = views.save('By dept')! + expect(view.snapshot.features?.grouping).toEqual({ by: ['dept'] }) + + getGrouping(grid)!.clearGrouping() + expect(getGrouping(grid)!.by).toEqual([]) + + views.apply(view.id) + expect(getGrouping(grid)!.by).toEqual(['dept']) + }) + + it('captures the sort and the column layout with it', () => { + const grid = makeGrid() + const views = getSavedViews(grid)! + ;(grid.api.setSort as (sort: unknown[]) => void)([ + { columnId: 'salary', direction: 'desc' } + ]) + grid.columns.setHidden('country', true) + + const view = views.save('Salary first')! + grid.columns.setHidden('country', false) + ;(grid.api.setSort as (sort: unknown[]) => void)([]) + + views.apply(view.id) + expect(grid.columns.get('country')?.hidden).toBe(true) + expect(view.snapshot.features?.sorting).toBeDefined() + }) + + it('refuses a blank name rather than making a view nobody can pick out', () => { + const views = getSavedViews(makeGrid())! + expect(views.save(' ')).toBeNull() + expect(views.views).toEqual([]) + }) + + it('reports a miss instead of throwing on an id it does not have', () => { + const views = getSavedViews(makeGrid())! + expect(views.apply('nope')).toBe(false) + expect(views.remove('nope')).toBe(false) + expect(views.update('nope')).toBe(false) + expect(views.rename('nope', 'x')).toBe(false) + }) +}) + +describe('the modified flag', () => { + it('is null with no view active, which is not the same as unchanged', () => { + expect(getSavedViews(makeGrid())!.modified).toBeNull() + }) + + it('turns on when the grid moves off the view, and off again on revert', () => { + const grid = makeGrid() + const views = getSavedViews(grid)! + getGrouping(grid)!.setGroupBy(['dept']) + views.save('By dept') + + expect(views.modified).toBe(false) + getGrouping(grid)!.setGroupBy(['country']) + expect(views.modified).toBe(true) + + views.revert() + expect(views.modified).toBe(false) + expect(getGrouping(grid)!.by).toEqual(['dept']) + }) + + it('does not flicker when the same state is rebuilt in another order', () => { + const grid = makeGrid() + const views = getSavedViews(grid)! + getGrouping(grid)!.setGroupBy(['dept', 'country']) + views.save('Two levels') + + getGrouping(grid)!.clearGrouping() + getGrouping(grid)!.setGroupBy(['dept', 'country']) + expect(views.modified).toBe(false) + }) + + it('clears once the view is updated to match', () => { + const grid = makeGrid() + const views = getSavedViews(grid)! + const view = views.save('Start')! + getGrouping(grid)!.setGroupBy(['dept']) + expect(views.modified).toBe(true) + + views.update(view.id) + expect(views.modified).toBe(false) + }) +}) + +describe('renaming and removing', () => { + it('keeps the id across a rename, so a share link still resolves', () => { + const views = getSavedViews(makeGrid())! + const view = views.save('Old')! + views.rename(view.id, 'New') + + expect(views.views[0]).toMatchObject({ id: view.id, name: 'New' }) + }) + + it('refuses a blank rename', () => { + const views = getSavedViews(makeGrid())! + const view = views.save('Keep')! + expect(views.rename(view.id, ' ')).toBe(false) + expect(views.views[0]!.name).toBe('Keep') + }) + + it('stands on nothing after removing the active view', () => { + const views = getSavedViews(makeGrid())! + const view = views.save('Gone')! + views.remove(view.id) + + expect(views.activeId).toBeNull() + expect(views.active).toBeNull() + expect(views.modified).toBeNull() + }) +}) + +describe('persistence', () => { + it('writes on every change and reads back on the next grid', () => { + const storage = memoryStorage() + const first = getSavedViews(makeGrid({ storage }))! + first.save('Kept') + + expect(storage.saved).toHaveLength(1) + expect(getSavedViews(makeGrid({ storage }))!.views[0]?.name).toBe('Kept') + }) + + it('keeps seeded views a user has never touched', () => { + const seeded: SavedView[] = [{ id: 'built-in', name: 'Default', snapshot: { version: 1 } }] + const storage = memoryStorage() + storage.saved = [{ id: 'mine', name: 'Mine', snapshot: { version: 1 } }] + + const views = getSavedViews(makeGrid({ views: seeded, storage }))! + expect(views.views.map((view) => view.id)).toEqual(['built-in', 'mine']) + }) + + it('lets a stored view win over a seeded one with the same id', () => { + const seeded: SavedView[] = [{ id: 'shared', name: 'Shipped', snapshot: { version: 1 } }] + const storage = memoryStorage() + storage.saved = [{ id: 'shared', name: 'Edited', snapshot: { version: 1 } }] + + expect(getSavedViews(makeGrid({ views: seeded, storage }))!.views[0]!.name).toBe('Edited') + }) + + it('keeps everything in memory when storage is off', () => { + const views = getSavedViews(makeGrid({ storage: null }))! + views.save('Session only') + expect(views.views).toHaveLength(1) + }) + + it('tells an app about every change, for one persisting the list itself', () => { + const onChange = vi.fn() + const views = getSavedViews(makeGrid({ storage: null, onChange }))! + const view = views.save('One')! + views.rename(view.id, 'Two') + views.remove(view.id) + + expect(onChange).toHaveBeenCalledTimes(3) + expect(onChange).toHaveBeenLastCalledWith([]) + }) +}) + +describe('reading a stored list', () => { + it('reads nothing out of nonsense rather than throwing', () => { + for (const raw of [null, '', 'not json', '{}', '[]', '[1,2]', '[{"id":1}]']) { + expect(parseViews(raw)).toBeNull() + } + }) + + it('drops the entries that are not views and keeps the rest', () => { + const raw = JSON.stringify([ + { id: 'a', name: 'A', snapshot: { version: 1 } }, + { id: 'b' }, + null + ]) + expect(parseViews(raw)).toEqual([{ id: 'a', name: 'A', snapshot: { version: 1 } }]) + }) + + it('merges an empty stored list as no list at all', () => { + const seeded: SavedView[] = [{ id: 'a', name: 'A', snapshot: { version: 1 } }] + expect(mergeViews(seeded, null)).toEqual(seeded) + }) +}) + +describe('share links', () => { + let grid: GridState + + beforeEach(() => { + grid = makeGrid() + getGrouping(grid)!.setGroupBy(['dept']) + }) + + it('round-trips the state through a URL', async () => { + const link = await getSavedViews(grid)!.shareLink('https://app.test/orders?tab=all') + expect(link).toContain(`${SHARE_PARAM}=`) + + const fresh = makeGrid() + expect(getGrouping(fresh)!.by).toEqual([]) + await expect(getSavedViews(fresh)!.applyLink(link!)).resolves.toBe(true) + expect(getGrouping(fresh)!.by).toEqual(['dept']) + }) + + it('keeps the parameters the link already had', async () => { + const link = await getSavedViews(grid)!.shareLink('https://app.test/orders?tab=all') + expect(new URL(link!).searchParams.get('tab')).toBe('all') + }) + + it('leaves the grid alone for a link carrying nothing', async () => { + const fresh = makeGrid() + await expect(getSavedViews(fresh)!.applyLink('https://app.test/orders')).resolves.toBe( + false + ) + }) + + it('leaves the grid alone for a token it cannot read', async () => { + const fresh = makeGrid() + getGrouping(fresh)!.setGroupBy(['country']) + await expect(getSavedViews(fresh)!.applyToken('9nonsense')).resolves.toBe(false) + expect(getGrouping(fresh)!.by).toEqual(['country']) + }) + + it('stands on no view after a link, because a link is not a saved view', async () => { + const views = getSavedViews(grid)! + views.save('Mine') + expect(views.activeId).not.toBeNull() + + await views.applyToken(await views.shareToken()) + expect(views.activeId).toBeNull() + }) + + it('drops a grouping the receiving grid has no column for', async () => { + const token = await getSavedViews(grid)!.shareToken() + + const narrow = createDataGrid({ + columns: [{ id: 'country', header: 'Country' }], + data, + getRowId: (row) => String(row.id), + features: [sorting(), grouping(), savedViews({ storage: null })] + }) + await expect(getSavedViews(narrow)!.applyToken(token)).resolves.toBe(true) + expect(getGrouping(narrow)!.by).toEqual([]) + }) +}) diff --git a/src/lib/features/saved-views/saved-views.types.ts b/src/lib/features/saved-views/saved-views.types.ts new file mode 100644 index 0000000..0459c41 --- /dev/null +++ b/src/lib/features/saved-views/saved-views.types.ts @@ -0,0 +1,46 @@ +import { type GridSnapshot } from '../../core/types/index.js' + +/** One named arrangement of the grid: its columns, density, sort, filter and grouping. */ +export interface SavedView { + /** Stable across renames; what `apply` and share links address. */ + id: string + name: string + snapshot: GridSnapshot +} + +/** + * Where the list of views lives between sessions. + * + * Deliberately synchronous. A backend-backed list is asynchronous, and making + * this async would put a loading state into every read; an app with a server + * instead loads its own list and hands it to `views`, then writes on + * `onChange`. The built-in adapter is `localStorage`. + */ +export interface SavedViewStorage { + read(key: string): SavedView[] | null + write(key: string, views: SavedView[]): void +} + +export interface SavedViewsOptions { + /** + * Views to start with. Anything read from `storage` is merged over these, + * so seeded defaults survive a user who has saved views of their own. + */ + views?: SavedView[] + + /** + * Persistence. `null` keeps the list in memory for the session only. + * @default localStorage + */ + storage?: SavedViewStorage | null + + /** + * Key the storage adapter writes under. Two grids in one app need two + * keys, or they overwrite each other's views. + * @default 'sv5ui-datagrid:views' + */ + key?: string + + /** Called after any change, for an app persisting the list itself. */ + onChange?: (views: SavedView[]) => void +} diff --git a/src/lib/features/saved-views/share-param.ts b/src/lib/features/saved-views/share-param.ts new file mode 100644 index 0000000..3a887a7 --- /dev/null +++ b/src/lib/features/saved-views/share-param.ts @@ -0,0 +1,17 @@ +export const SHARE_PARAM = 'view' + +const RELATIVE_BASE = 'http://sv5ui.invalid' + +export function withShareParam(href: string, token: string): string { + const url = new URL(href, RELATIVE_BASE) + url.searchParams.set(SHARE_PARAM, token) + return url.origin === RELATIVE_BASE ? `${url.pathname}${url.search}${url.hash}` : url.toString() +} + +export function readShareParam(href: string): string | null { + try { + return new URL(href, RELATIVE_BASE).searchParams.get(SHARE_PARAM) + } catch { + return null + } +} diff --git a/src/lib/features/saved-views/storage.ts b/src/lib/features/saved-views/storage.ts new file mode 100644 index 0000000..802ceae --- /dev/null +++ b/src/lib/features/saved-views/storage.ts @@ -0,0 +1,52 @@ +import type { SavedView, SavedViewStorage } from './saved-views.types.js' + +export const DEFAULT_VIEWS_KEY = 'sv5ui-datagrid:views' + +function isView(value: unknown): value is SavedView { + if (typeof value !== 'object' || value === null) return false + const view = value as Partial + return ( + typeof view.id === 'string' && + typeof view.name === 'string' && + typeof view.snapshot === 'object' && + view.snapshot !== null + ) +} + +export function parseViews(raw: string | null): SavedView[] | null { + if (!raw) return null + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return null + const views = parsed.filter(isView) + return views.length > 0 ? views : null + } catch { + return null + } +} + +export const localStorageViews: SavedViewStorage = { + read(key) { + if (typeof localStorage === 'undefined') return null + try { + return parseViews(localStorage.getItem(key)) + } catch { + return null + } + }, + write(key, views) { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(key, JSON.stringify(views)) + } catch { + return + } + } +} + +export function mergeViews(seeded: SavedView[], stored: SavedView[] | null): SavedView[] { + if (!stored) return seeded + const byId = new Map(seeded.map((view) => [view.id, view])) + for (const view of stored) byId.set(view.id, view) + return [...byId.values()] +} diff --git a/src/lib/features/server-row-model/blocks.test.ts b/src/lib/features/server-row-model/blocks.test.ts new file mode 100644 index 0000000..63de568 --- /dev/null +++ b/src/lib/features/server-row-model/blocks.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + blockBounds, + blockOf, + blocksFor, + blocksIn, + blocksToEvict, + totalFromShortBlock +} from './blocks.js' + +describe('block arithmetic', () => { + it('maps rows to blocks and back', () => { + expect(blockOf(0, 100)).toBe(0) + expect(blockOf(99, 100)).toBe(0) + expect(blockOf(100, 100)).toBe(1) + expect(blockBounds(2, 100)).toEqual({ start: 200, end: 300 }) + }) + + it('covers a row range inclusively at both ends', () => { + expect(blocksFor(0, 100, 100)).toEqual({ start: 0, end: 0 }) + expect(blocksFor(0, 101, 100)).toEqual({ start: 0, end: 1 }) + expect(blocksFor(250, 460, 100)).toEqual({ start: 2, end: 4 }) + expect(blocksIn({ start: 2, end: 4 })).toEqual([2, 3, 4]) + }) + + it('never asks for a negative block on an empty range', () => { + expect(blocksFor(0, 0, 100)).toEqual({ start: 0, end: 0 }) + }) +}) + +describe('eviction', () => { + const keep = { start: 5, end: 6 } + + it('keeps everything while under budget', () => { + expect(blocksToEvict([1, 5, 6], keep, 20)).toEqual([]) + }) + + it('drops the blocks furthest from the viewport first', () => { + const evicted = blocksToEvict([0, 4, 5, 6, 7, 20], keep, 4) + expect(evicted).toEqual([20, 0]) + }) + + it('never evicts a block the viewport is sitting on', () => { + const evicted = blocksToEvict([5, 6], keep, 1) + expect(evicted).toEqual([]) + }) +}) + +describe('discovering the end', () => { + it('reads the total from a short block', () => { + expect(totalFromShortBlock(3, 40, 100)).toBe(340) + }) + + it('stays unknown while blocks come back full', () => { + expect(totalFromShortBlock(3, 100, 100)).toBeNull() + }) + + it('treats an empty block as the end', () => { + expect(totalFromShortBlock(4, 0, 100)).toBe(400) + }) +}) diff --git a/src/lib/features/server-row-model/blocks.ts b/src/lib/features/server-row-model/blocks.ts new file mode 100644 index 0000000..6643a7a --- /dev/null +++ b/src/lib/features/server-row-model/blocks.ts @@ -0,0 +1,46 @@ +export interface BlockRange { + start: number + + end: number +} + +export function blockOf(rowIndex: number, blockSize: number): number { + return Math.floor(rowIndex / blockSize) +} + +export function blockBounds(block: number, blockSize: number): { start: number; end: number } { + return { start: block * blockSize, end: (block + 1) * blockSize } +} + +export function blocksFor(startRow: number, endRow: number, blockSize: number): BlockRange { + return { + start: Math.max(0, blockOf(startRow, blockSize)), + end: Math.max(0, blockOf(Math.max(startRow, endRow - 1), blockSize)) + } +} + +export function blocksIn(range: BlockRange): number[] { + const blocks: number[] = [] + for (let block = range.start; block <= range.end; block++) blocks.push(block) + return blocks +} + +export function blocksToEvict(loaded: number[], keep: BlockRange, maxBlocks: number): number[] { + if (loaded.length <= maxBlocks) return [] + + const distance = (block: number) => + block < keep.start ? keep.start - block : block > keep.end ? block - keep.end : 0 + + return loaded + .filter((block) => distance(block) > 0) + .sort((a, b) => distance(b) - distance(a)) + .slice(0, loaded.length - maxBlocks) +} + +export function totalFromShortBlock( + block: number, + rowsReturned: number, + blockSize: number +): number | null { + return rowsReturned < blockSize ? block * blockSize + rowsReturned : null +} diff --git a/src/lib/features/server-row-model/index.ts b/src/lib/features/server-row-model/index.ts new file mode 100644 index 0000000..0650080 --- /dev/null +++ b/src/lib/features/server-row-model/index.ts @@ -0,0 +1,24 @@ +export { + blockBounds, + blockOf, + blocksFor, + blocksIn, + blocksToEvict, + totalFromShortBlock, + type BlockRange +} from './blocks.js' +export { createLoadedBlocks, type LoadedBlocks } from './loaded-blocks.js' +export { + getServerRowModel, + SERVER_ROW_MODEL, + ServerRowModel, + serverRowModel +} from './server-row-model.svelte.js' +export type { + DataSource, + GetRowsRequest, + GetRowsResult, + ServerMode, + ServerRowModelOptions +} from './server-row-model.types.js' +export { isLoadingRow } from '../../core/grid/index.js' diff --git a/src/lib/features/server-row-model/loaded-blocks.ts b/src/lib/features/server-row-model/loaded-blocks.ts new file mode 100644 index 0000000..7b62576 --- /dev/null +++ b/src/lib/features/server-row-model/loaded-blocks.ts @@ -0,0 +1,18 @@ +export interface LoadedBlocks { + has(block: number): boolean + add(block: number): void + remove(block: number): void + list(): number[] + clear(): void +} + +export function createLoadedBlocks(): LoadedBlocks { + const blocks = new Set() + return { + has: (block) => blocks.has(block), + add: (block) => void blocks.add(block), + remove: (block) => void blocks.delete(block), + list: () => [...blocks], + clear: () => blocks.clear() + } +} diff --git a/src/lib/features/server-row-model/server-row-model.svelte.ts b/src/lib/features/server-row-model/server-row-model.svelte.ts new file mode 100644 index 0000000..9f2714c --- /dev/null +++ b/src/lib/features/server-row-model/server-row-model.svelte.ts @@ -0,0 +1,310 @@ +import { type GridState, LOADING_KEY, PIPELINE_ORDER } from '../../core/grid/index.js' +import { type GridFeature, type RowNode } from '../../core/types/index.js' +import { getFiltering, toFilterRequest } from '../../features/filtering/index.js' +import { getPagination } from '../../features/pagination/index.js' +import { getSorting } from '../../features/sorting/index.js' +import { getAdvancedFilter } from '../advanced-filter/index.js' +import { blockBounds, blocksFor, blocksIn, blocksToEvict, totalFromShortBlock } from './blocks.js' +import { createLoadedBlocks } from './loaded-blocks.js' +import type { + DataSource, + GetRowsRequest, + FetchAllOptions, + ServerMode, + ServerRowModelOptions +} from './server-row-model.types.js' + +export const SERVER_ROW_MODEL = 'serverRowModel' + +const DEFAULT_BLOCK_SIZE = 100 +const DEFAULT_MAX_BLOCKS = 20 +const DEFAULT_FETCH_ALL_BATCH = 5_000 + +export class ServerRowModel { + loading = $state(false) + answered = $state(false) + error = $state(null) + + rowCount = $state(null) + + readonly mode: ServerMode + readonly blockSize: number + readonly maxBlocks: number + + #grid: GridState + #source: DataSource + #options: ServerRowModelOptions + #blocks = createLoadedBlocks() + + #generation = 0 + #inFlight = 0 + + constructor( + grid: GridState, + source: DataSource, + options: ServerRowModelOptions + ) { + this.#grid = grid + this.#source = source + this.#options = options + this.mode = options.mode ?? 'paged' + this.blockSize = options.blockSize ?? DEFAULT_BLOCK_SIZE + this.maxBlocks = options.maxBlocks ?? DEFAULT_MAX_BLOCKS + + if (grid.rowModel !== 'server') { + throw new Error( + "serverRowModel needs createDataGrid({ rowModel: 'server' }): without it " + + 'the grid filters and sorts the page again on the client, over rows ' + + 'the server already filtered and sorted.' + ) + } + + if (this.mode === 'infinite' && !options.placeholder) { + throw new Error( + 'serverRowModel({ mode: "infinite" }) needs a placeholder option: ' + + 'it builds the rows shown while a block loads, and getRowId must ' + + 'return a unique id for them.' + ) + } + + for (const event of ['sortChanged', 'filterChanged'] as const) { + grid.events.on(event, () => this.refresh()) + } + if (this.mode === 'paged') { + grid.events.on('pageChanged', () => void this.#loadPage()) + } + } + + #request(startRow: number, endRow: number, groupKeys: unknown[] = []): GetRowsRequest { + const filtering = getFiltering(this.#grid) + const tree = getAdvancedFilter(this.#grid)?.model + + return { + ...(tree && tree.children.length > 0 ? { advancedFilter: tree } : {}), + startRow, + endRow, + sortModel: getSorting(this.#grid)?.sort ?? [], + filterModel: toFilterRequest( + filtering?.model ?? { quick: '', columns: {} }, + this.#grid.columns.visible.map((column) => column.id) + ), + groupKeys, + groupBy: this.#options.groupBy ?? [] + } + } + + async #fetch(request: GetRowsRequest, generation: number) { + this.#inFlight += 1 + this.loading = true + try { + const result = await this.#source.getRows(request) + + return generation === this.#generation ? result : null + } catch (error) { + if (generation === this.#generation) { + this.error = error instanceof Error ? error.message : String(error) + this.#options.onError?.(error, request) + } + return null + } finally { + this.#inFlight -= 1 + if (this.#inFlight === 0) { + this.loading = false + this.answered = true + } + } + } + + refresh = (): void => { + this.#generation += 1 + this.#blocks.clear() + this.error = null + this.answered = false + this.rowCount = null + this.#grid.data = [] + + const pagination = getPagination(this.#grid) + if (this.mode === 'paged') { + if (pagination) pagination.setRowCount(null) + void this.#loadPage() + return + } + void this.#loadBlock(0) + } + + async #loadPage(): Promise { + const pagination = getPagination(this.#grid) + const pageSize = pagination?.pageSize ?? this.blockSize + const page = pagination?.page ?? 1 + const startRow = (page - 1) * pageSize + + const generation = ++this.#generation + const result = await this.#fetch(this.#request(startRow, startRow + pageSize), generation) + if (!result) return + + this.#grid.data = result.rows + if (result.rowCount !== undefined) { + this.rowCount = result.rowCount + pagination?.setRowCount(result.rowCount) + } + } + + async #loadBlock(block: number): Promise { + if (this.#blocks.has(block)) return + this.#blocks.add(block) + + const { start, end } = blockBounds(block, this.blockSize) + const generation = this.#generation + const result = await this.#fetch(this.#request(start, end), generation) + if (!result) { + this.#blocks.remove(block) + return + } + + const discovered = totalFromShortBlock(block, result.rows.length, this.blockSize) + const before = this.rowCount + if (result.rowCount !== undefined) this.rowCount = result.rowCount + else if (discovered !== null) this.rowCount = discovered + + if (this.rowCount !== null && this.rowCount !== before) { + this.#grid.events.emit('rowCountChanged', { total: this.rowCount }) + } + + this.#writeBlock(start, result.rows) + } + + #writeBlock(start: number, rows: TRow[]): void { + const total = this.rowCount ?? start + rows.length + const next = this.#grid.data.slice() + next.length = total + + for (let i = 0; i < rows.length && start + i < total; i++) next[start + i] = rows[i]! + for (let i = 0; i < total; i++) { + if (next[i] === undefined) next[i] = this.#placeholder(i) + } + this.#grid.data = next + } + + #placeholder(index: number): TRow { + return { ...this.#options.placeholder!(index), [LOADING_KEY]: true } + } + + #lastPage(received: number, pageSize: number, loaded: number, total: number | null): boolean { + if (received < pageSize) return true + return total !== null && loaded >= total + } + + fetchAll = async (options: FetchAllOptions = {}): Promise => { + const { onProgress } = options + const pageSize = Math.max(1, options.batchSize ?? DEFAULT_FETCH_ALL_BATCH) + const generation = this.#generation + const all: TRow[] = [] + + for (;;) { + const result = await this.#fetch( + this.#request(all.length, all.length + pageSize), + generation + ) + if (!result) break + + for (const row of result.rows) all.push(row) + const total = result.rowCount ?? this.rowCount + onProgress?.(all.length, total) + + if (this.#lastPage(result.rows.length, pageSize, all.length, total)) break + } + + return all + } + + ensureRange = (startRow: number, endRow: number): void => { + if (this.mode !== 'infinite') return + + const range = blocksFor(startRow, endRow, this.blockSize) + for (const block of blocksIn(range)) void this.#loadBlock(block) + + const evicted = blocksToEvict(this.#blocks.list(), range, this.maxBlocks) + if (evicted.length === 0) return + + const next = this.#grid.data.slice() + for (const block of evicted) { + this.#blocks.remove(block) + const { start, end } = blockBounds(block, this.blockSize) + for (let i = start; i < Math.min(end, next.length); i++) { + next[i] = this.#placeholder(i) + } + } + this.#grid.data = next + } + + start = (): void => { + if (this.#grid.data.length > 0 || this.#blocks.list().length > 0) return + this.refresh() + } + + expandGroup = async (node: RowNode, groupKeys: unknown[]): Promise => { + const generation = this.#generation + const result = await this.#fetch(this.#request(0, this.blockSize, groupKeys), generation) + if (!result) return + + const index = this.#grid.data.findIndex((row) => this.#grid.getRowId(row) === node.id) + if (index < 0) return + + const next = this.#grid.data.slice() + next.splice(index + 1, 0, ...result.rows) + this.#grid.data = next + } + + collapseGroup = (node: RowNode, isChild: (row: TRow) => boolean): void => { + const index = this.#grid.data.findIndex((row) => this.#grid.getRowId(row) === node.id) + if (index < 0) return + + let end = index + 1 + while (end < this.#grid.data.length && isChild(this.#grid.data[end]!)) end++ + if (end === index + 1) return + + const next = this.#grid.data.slice() + next.splice(index + 1, end - index - 1) + this.#grid.data = next + } +} + +export function serverRowModel( + source: DataSource, + options: ServerRowModelOptions = {} +): GridFeature { + return { + id: SERVER_ROW_MODEL, + createState: (grid) => new ServerRowModel(grid, source, options), + createApi: (grid) => { + const state = getServerRowModel(grid)! + return { + refreshServerRows: state.refresh, + ensureServerRange: state.ensureRange, + fetchAllServerRows: state.fetchAll + } + }, + pipelineStage: options.getRowMeta + ? { + order: PIPELINE_ORDER.group, + transform: (nodes) => + nodes.map((node) => { + const meta = options.getRowMeta!(node.row) + return meta ? { ...node, meta } : node + }) + } + : undefined + } +} + +export function getServerRowModel(grid: GridState): ServerRowModel | undefined { + return grid.feature>(SERVER_ROW_MODEL) +} + +declare module '../../core/types/api.js' { + interface GridApi { + refreshServerRows?: () => void + ensureServerRange?: (startRow: number, endRow: number) => void + fetchAllServerRows?: (options?: FetchAllOptions) => Promise + } +} diff --git a/src/lib/features/server-row-model/server-row-model.types.ts b/src/lib/features/server-row-model/server-row-model.types.ts new file mode 100644 index 0000000..2ca3d82 --- /dev/null +++ b/src/lib/features/server-row-model/server-row-model.types.ts @@ -0,0 +1,117 @@ +import type { FilterGroup } from '../advanced-filter/index.js' +import { type FilterRequest, type RowMeta, type SortState } from '../../core/types/index.js' + +/** + * What the grid asks the server for. Matches PLAN §3.3: one descriptor covers + * paging, infinite scroll and group expansion, so a data source has a single + * entry point to implement. + */ +export interface GetRowsRequest { + /** First row index wanted, inclusive. */ + startRow: number + /** Last row index wanted, exclusive. */ + endRow: number + sortModel: SortState[] + /** + * The filter in its normalized wire shape: every column is a list of + * conditions and a join, whichever shorthand the grid held internally. A + * server implementation therefore handles exactly one shape, and keeps + * doing so as the client-side model grows. + */ + filterModel: FilterRequest + /** + * Values identifying the group being expanded, outermost first. Empty at + * the top level. + */ + groupKeys: unknown[] + /** Columns being grouped by, outermost first. Empty when not grouping. */ + groupBy: string[] + /** + * The advanced filter's tree, when the grid has one and it is not empty. + * + * Separate from `filterModel` because it is a different shape: nested + * groups with a join per level and a `not` flag, which the per-column wire + * format cannot express. A backend that does not read it simply ignores + * it, and the grid says the filter is unapplied rather than pretending. + */ + advancedFilter?: FilterGroup +} + +export interface GetRowsResult { + rows: TRow[] + /** + * Total rows matching the request, ignoring the row window. Omit while the + * total is unknown - infinite scroll keeps loading until a short block + * tells it where the end is. + */ + rowCount?: number +} + +export interface DataSource { + getRows(request: GetRowsRequest): Promise> +} + +export type ServerMode = 'paged' | 'infinite' + +export interface ServerRowModelOptions { + /** + * `'paged'` fetches one page per request and pairs with `pagination()`. + * `'infinite'` fetches fixed-size blocks as the viewport moves and pairs + * with `virtualization()`. + * + * @default 'paged' + */ + mode?: ServerMode + + /** + * Rows per request in infinite mode. Larger blocks mean fewer requests and + * more rows discarded on a jump. + * + * @default 100 + */ + blockSize?: number + + /** + * Blocks kept in memory in infinite mode. The oldest are evicted first, so + * scrolling through a million rows does not accumulate them all. + * + * @default 20 + */ + maxBlocks?: number + + /** + * Builds the placeholder shown while a row is still loading. Receives the + * absolute row index. + */ + placeholder?: (index: number) => TRow + + /** Columns the server is grouping by, outermost first. */ + groupBy?: string[] + + /** + * Marks which rows are group rows and at what depth, so the kernel renders + * the chevron, the indent and treegrid ARIA. Rows arrive already grouped + * from the server, so nothing is grouped locally. + */ + getRowMeta?: (row: TRow) => RowMeta | undefined + + /** Called when a request rejects, for logging or a toast. */ + onError?: (error: unknown, request: GetRowsRequest) => void +} + +export interface FetchAllOptions { + /** + * Rows per request while walking the whole set. Deliberately independent of + * the page size the user is browsing at: a 25-row page over 50 000 rows + * would otherwise mean two thousand round trips. + * + * @default 5000 + */ + batchSize?: number + + /** + * Called after each batch. `total` is `null` until the source reports a + * `rowCount`, which infinite sources may never do. + */ + onProgress?: (loaded: number, total: number | null) => void +} diff --git a/src/lib/features/show-values-as/index.ts b/src/lib/features/show-values-as/index.ts new file mode 100644 index 0000000..eaf3deb --- /dev/null +++ b/src/lib/features/show-values-as/index.ts @@ -0,0 +1,9 @@ +export { applyShares, type ShareOptions } from './shares.js' +export { + getShowValuesAs, + SHOW_VALUES_AS, + SHOW_VALUES_AS_ORDER, + ShowValuesAs, + showValuesAs +} from './show-values-as.svelte.js' +export type { ShowAs, ShowValuesAsOptions } from './show-values-as.types.js' diff --git a/src/lib/features/show-values-as/shares.ts b/src/lib/features/show-values-as/shares.ts new file mode 100644 index 0000000..7ad905c --- /dev/null +++ b/src/lib/features/show-values-as/shares.ts @@ -0,0 +1,141 @@ +import { type ColumnDef, type RowNode } from '../../core/types/index.js' +import { numericOrNull } from '../../core/utils/index.js' +import { type CellRead, isDataRow, isLoadingRow, rawRead } from '../../core/grid/index.js' +import { totalsKindOf } from '../grouping/index.js' +import type { ShowAs } from './show-values-as.types.js' + +export interface ShareOptions { + shown: Record + columns: ColumnDef[] + read?: CellRead +} + +interface Column { + id: string + showAs: ShowAs + def: ColumnDef +} + +const levelOf = (node: RowNode): number => node.meta?.level ?? 0 + +function parentIndexes(nodes: RowNode[]): number[] { + const parents: number[] = new Array(nodes.length).fill(-1) + const openAt: number[] = [] + + for (let index = 0; index < nodes.length; index++) { + const level = levelOf(nodes[index]!) + parents[index] = level > 0 ? (openAt[level - 1] ?? -1) : -1 + openAt[level] = index + openAt.length = level + 1 + } + return parents +} + +function readColumn( + nodes: RowNode[], + column: ColumnDef, + read: CellRead +): (number | null)[] { + return nodes.map((node) => (isLoadingRow(node.row) ? null : numericOrNull(read(node, column)))) +} + +function grandTotalOf(nodes: RowNode[], values: (number | null)[]): number | null { + let total: number | null = null + for (let index = 0; index < nodes.length; index++) { + const value = values[index] ?? null + if (value === null || !isDataRow(nodes[index]!)) continue + total = (total ?? 0) + value + } + return total +} + +function rootTotal(nodes: RowNode[], values: (number | null)[]): number | null { + let total: number | null = null + for (let index = 0; index < nodes.length; index++) { + const value = values[index] ?? null + const node = nodes[index]! + if (value === null || levelOf(node) !== 0 || totalsKindOf(node.id) !== null) continue + total = (total ?? 0) + value + } + return total +} + +function rowTotal( + node: RowNode, + of: string[], + columns: ColumnDef[], + read: CellRead +): number | null { + let total: number | null = null + for (const columnId of of) { + const def = columns.find((column) => column.id === columnId) + if (!def) continue + const value = numericOrNull(read(node, def)) + if (value !== null) total = (total ?? 0) + value + } + return total +} + +const share = (value: number | null, total: number | null): number | null => + value === null || total === null || total === 0 ? null : value / total + +interface Pass { + parents: number[] + read: CellRead + columns: ColumnDef[] +} + +function sharesFor( + nodes: RowNode[], + column: Column, + pass: Pass +): (number | null)[] { + const { parents, read, columns } = pass + const values = readColumn(nodes, column.def, read) + const { showAs } = column + + if (typeof showAs === 'object') { + return nodes.map((node, index) => + share(values[index]!, rowTotal(node, showAs.of, columns, read)) + ) + } + + if (showAs === 'percentOfGrandTotal') { + const grand = grandTotalOf(nodes, values) + return values.map((value) => share(value, grand)) + } + + const roots = rootTotal(nodes, values) + return values.map((value, index) => { + const parent = parents[index]! + return share(value, parent < 0 ? roots : values[parent]!) + }) +} + +export function applyShares( + nodes: RowNode[], + options: ShareOptions +): RowNode[] { + const read = options.read ?? rawRead + const columns: Column[] = [] + + for (const [id, showAs] of Object.entries(options.shown)) { + const def = options.columns.find((column) => column.id === id) + if (def) columns.push({ id, showAs, def }) + } + if (columns.length === 0) return nodes + + const parents = parentIndexes(nodes) + const pass: Pass = { parents, read, columns: options.columns } + const shares = columns.map((column) => sharesFor(nodes, column, pass)) + + return nodes.map((node, index) => { + if (isLoadingRow(node.row)) return node + + const written: Record = {} + for (let entry = 0; entry < columns.length; entry++) { + written[columns[entry]!.id] = shares[entry]![index]! + } + return { ...node, row: { ...node.row, ...written } } + }) +} diff --git a/src/lib/features/show-values-as/show-values-as.svelte.ts b/src/lib/features/show-values-as/show-values-as.svelte.ts new file mode 100644 index 0000000..3df2399 --- /dev/null +++ b/src/lib/features/show-values-as/show-values-as.svelte.ts @@ -0,0 +1,97 @@ +import { gateReader, type GridState, PIPELINE_ORDER } from '../../core/grid/index.js' +import { type GridFeature, type RowNode } from '../../core/types/index.js' +import { applyShares } from './shares.js' +import type { ShowAs, ShowValuesAsOptions } from './show-values-as.types.js' + +export const SHOW_VALUES_AS = 'showValuesAs' + +export const SHOW_VALUES_AS_ORDER = PIPELINE_ORDER.group + 1 + +function readShown(slice: unknown): Record | null { + if (slice === null || typeof slice !== 'object' || Array.isArray(slice)) return null + + const out: Record = {} + for (const [id, entry] of Object.entries(slice as Record)) { + if (entry === 'percentOfGrandTotal' || entry === 'percentOfParent') { + out[id] = entry + continue + } + const shape = entry as { kind?: unknown; of?: unknown } + if (shape?.kind !== 'percentOfRow' || !Array.isArray(shape.of)) continue + out[id] = { kind: 'percentOfRow', of: shape.of.filter((of) => typeof of === 'string') } + } + return out +} + +export class ShowValuesAs { + shown = $state.raw>({}) + + #grid: GridState + + constructor(grid: GridState, options: ShowValuesAsOptions) { + this.#grid = grid + this.shown = options.columns ?? {} + } + + showAsFor = (columnId: string): ShowAs | undefined => this.shown[columnId] + + set = (columnId: string, showAs: ShowAs | null): void => { + const next = { ...this.shown } + if (showAs === null) delete next[columnId] + else next[columnId] = showAs + this.shown = next + } + + clear = (): void => { + this.shown = {} + } + + apply = (nodes: RowNode[]): RowNode[] => + applyShares(nodes, { + shown: this.shown, + columns: this.#grid.columns.all.map((column) => column.def), + read: gateReader(this.#grid, 'render') + }) + + serialize = (): Record | undefined => + Object.keys(this.shown).length === 0 ? undefined : this.shown + + hydrate = (slice: unknown): void => { + const next = readShown(slice) + if (next) this.shown = next + } +} + +export function getShowValuesAs(grid: GridState): ShowValuesAs | undefined { + return grid.feature>(SHOW_VALUES_AS) +} + +export function showValuesAs(options: ShowValuesAsOptions = {}): GridFeature { + return { + id: SHOW_VALUES_AS, + createState: (grid) => new ShowValuesAs(grid, options), + createApi: (grid) => { + const state = getShowValuesAs(grid)! + return { + setShowValuesAs: (columnId: string, showAs: unknown) => + state.set(columnId, showAs as ShowAs | null), + showValuesAsFor: state.showAsFor, + clearShowValuesAs: state.clear + } + }, + pipelineStage: { + order: SHOW_VALUES_AS_ORDER, + transform: (nodes, grid) => getShowValuesAs(grid)?.apply(nodes) ?? nodes + }, + serialize: (grid) => getShowValuesAs(grid)?.serialize(), + hydrate: (slice, grid) => getShowValuesAs(grid)?.hydrate(slice) + } +} + +declare module '../../core/types/api.js' { + interface GridApi { + setShowValuesAs?: (columnId: string, showAs: unknown) => void + showValuesAsFor?: (columnId: string) => unknown + clearShowValuesAs?: () => void + } +} diff --git a/src/lib/features/show-values-as/show-values-as.test.ts b/src/lib/features/show-values-as/show-values-as.test.ts new file mode 100644 index 0000000..a990724 --- /dev/null +++ b/src/lib/features/show-values-as/show-values-as.test.ts @@ -0,0 +1,231 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { describe, expect, it } from 'vitest' +import { grouping } from '../grouping/index.js' +import { tree } from '../tree/index.js' +import { getShowValuesAs, showValuesAs } from './show-values-as.svelte.js' +import type { ShowValuesAsOptions } from './show-values-as.types.js' + +interface Sale { + id: number + region: string + rep: string + q1: number + q2: number + revenue: number +} + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region', filter: 'text' }, + { id: 'rep', header: 'Rep' }, + { id: 'q1', header: 'Q1', type: 'percent' }, + { id: 'q2', header: 'Q2', type: 'percent' }, + { id: 'revenue', header: 'Revenue', type: 'percent' } +] + +const sales: Sale[] = [ + { id: 1, region: 'north', rep: 'Chi', q1: 30, q2: 70, revenue: 100 }, + { id: 2, region: 'north', rep: 'An', q1: 50, q2: 50, revenue: 300 }, + { id: 3, region: 'south', rep: 'Bao', q1: 20, q2: 80, revenue: 600 } +] + +function grid(options: ShowValuesAsOptions, by: string[] = []) { + return createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: [ + filtering(), + grouping({ by, aggregations: { revenue: 'sum' }, grandTotal: true }), + showValuesAs(options) + ] + }) +} + +const shown = (built: ReturnType, columnId: keyof Sale) => + built.preWindowNodes.map((node) => [node.id, node.row[columnId]] as const) + +describe('a column shown as a share of the whole', () => { + it('divides every row by the column total', () => { + const built = grid({ columns: { revenue: 'percentOfGrandTotal' } }) + + expect(shown(built, 'revenue')).toEqual([ + ['1', 0.1], + ['2', 0.3], + ['3', 0.6], + ['total:grand', 1] + ]) + }) + + it('counts only the rows that survived the filter', () => { + const built = grid({ columns: { revenue: 'percentOfGrandTotal' } }) + built.api.setColumnFilter?.('region', { kind: 'text', op: 'equals', value: 'north' }) + + expect(shown(built, 'revenue')).toEqual([ + ['1', 0.25], + ['2', 0.75], + ['total:grand', 1] + ]) + }) + + it('leaves a column nobody asked about alone', () => { + const built = grid({ columns: { revenue: 'percentOfGrandTotal' } }) + expect(built.preWindowNodes[0]!.row.q1).toBe(30) + }) + + it('writes null rather than dividing by nothing', () => { + const built = createDataGrid({ + columns, + data: [{ id: 1, region: 'north', rep: 'Chi', q1: 0, q2: 0, revenue: 0 }], + getRowId: (row) => String(row.id), + features: [showValuesAs({ columns: { revenue: 'percentOfGrandTotal' } })] + }) + + expect(built.preWindowNodes[0]!.row.revenue).toBeNull() + }) +}) + +describe('a column shown as a share of the group it sits in', () => { + it('divides a row by its own group, and the group by the whole', () => { + const built = grid({ columns: { revenue: 'percentOfParent' } }, ['region']) + + expect(shown(built, 'revenue')).toEqual([ + ['group:region=north', 0.4], + ['1', 0.25], + ['2', 0.75], + ['group:region=south', 0.6], + ['3', 1], + ['total:grand', 1] + ]) + }) + + it('falls back to the whole when there is no group above the row', () => { + const built = grid({ columns: { revenue: 'percentOfParent' } }) + + expect(shown(built, 'revenue')).toEqual([ + ['1', 0.1], + ['2', 0.3], + ['3', 0.6], + ['total:grand', 1] + ]) + }) + + it('reads the parent row of a tree the same way it reads a group', () => { + interface Task { + id: number + parentId: number | null + cost: number + } + + const rows: Task[] = [ + { id: 1, parentId: null, cost: 100 }, + { id: 2, parentId: 1, cost: 40 }, + { id: 3, parentId: 1, cost: 10 } + ] + + const built = createDataGrid({ + columns: [ + { id: 'id', header: 'Id' }, + { id: 'cost', header: 'Cost', type: 'percent' } + ], + data: rows, + getRowId: (row) => String(row.id), + features: [ + tree({ + getParentId: (row) => (row.parentId === null ? null : String(row.parentId)), + defaultExpandedDepth: 2 + }), + showValuesAs({ columns: { cost: 'percentOfParent' } }) + ] + }) + + expect(built.preWindowNodes.map((node) => [node.id, node.row.cost])).toEqual([ + ['1', 1], + ['2', 0.4], + ['3', 0.1] + ]) + }) +}) + +describe('a column shown as a share of its own row', () => { + it('divides by the columns named beside it', () => { + const built = grid({ + columns: { + q1: { kind: 'percentOfRow', of: ['q1', 'q2'] }, + q2: { kind: 'percentOfRow', of: ['q1', 'q2'] } + } + }) + + expect(shown(built, 'q1')).toEqual([ + ['1', 0.3], + ['2', 0.5], + ['3', 0.2], + ['total:grand', null] + ]) + expect(built.preWindowNodes[0]!.row.q2).toBe(0.7) + }) + + it('ignores a column id that is not in the grid', () => { + const built = grid({ columns: { q1: { kind: 'percentOfRow', of: ['q1', 'nope'] } } }) + expect(built.preWindowNodes[0]!.row.q1).toBe(1) + }) +}) + +describe('what an app can change while the grid is running', () => { + it('turns a share on and off again', () => { + const built = grid({}) + expect(built.preWindowNodes[0]!.row.revenue).toBe(100) + + built.api.setShowValuesAs?.('revenue', 'percentOfGrandTotal') + expect(built.preWindowNodes[0]!.row.revenue).toBe(0.1) + + built.api.setShowValuesAs?.('revenue', null) + expect(built.preWindowNodes[0]!.row.revenue).toBe(100) + }) + + it('hands back what a column is showing, for a panel to tick', () => { + const built = grid({ columns: { revenue: 'percentOfGrandTotal' } }) + + expect(built.api.showValuesAsFor?.('revenue')).toBe('percentOfGrandTotal') + expect(built.api.showValuesAsFor?.('q1')).toBeUndefined() + }) + + it('clears every column at once', () => { + const built = grid({ columns: { revenue: 'percentOfGrandTotal' } }) + built.api.clearShowValuesAs?.() + + expect(built.preWindowNodes[0]!.row.revenue).toBe(100) + }) +}) + +describe('a share travels with the view it was saved in', () => { + it('survives getState and setState', () => { + const source = grid({ + columns: { + revenue: 'percentOfParent', + q1: { kind: 'percentOfRow', of: ['q1', 'q2'] } + } + }) + const snapshot = source.getState() + + const target = grid({}) + target.setState(snapshot) + + expect(getShowValuesAs(target)!.shown).toEqual({ + revenue: 'percentOfParent', + q1: { kind: 'percentOfRow', of: ['q1', 'q2'] } + }) + }) + + it('leaves nothing in the snapshot when no column is shown as a share', () => { + expect(getShowValuesAs(grid({}))!.serialize()).toBeUndefined() + }) + + it('ignores a saved shape it does not recognise', () => { + const built = grid({ columns: { revenue: 'percentOfGrandTotal' } }) + getShowValuesAs(built)!.hydrate({ revenue: 'nonsense', q1: { kind: 'percentOfRow' } }) + + expect(getShowValuesAs(built)!.shown).toEqual({}) + }) +}) diff --git a/src/lib/features/show-values-as/show-values-as.types.ts b/src/lib/features/show-values-as/show-values-as.types.ts new file mode 100644 index 0000000..566c867 --- /dev/null +++ b/src/lib/features/show-values-as/show-values-as.types.ts @@ -0,0 +1,26 @@ +/** + * What a cell shows instead of the number underneath it. + * + * `percentOfGrandTotal` divides by the column's total over every row that + * survived the filter. `percentOfParent` divides by the same column on the row + * above it in the hierarchy - the group a row sits in, the parent row on a + * tree - and falls back to the grand total at the top level. + * `{ kind: 'percentOfRow', of }` divides by the sum of the named columns in + * the same row, which is how a month-per-column table reads across. + * + * The share is a fraction: `0.34`, not `34`. Declare the column + * `type: 'percent'` and Community formats it as `34%` on screen, in the + * clipboard and in every export. + */ +export type ShowAs = + 'percentOfGrandTotal' | 'percentOfParent' | { kind: 'percentOfRow'; of: string[] } + +export interface ShowValuesAsOptions { + /** + * How each column is shown, keyed by column id. A column not named here + * keeps its own value. + * + * @default {} + */ + columns?: Record +} diff --git a/src/lib/features/tree/index.ts b/src/lib/features/tree/index.ts new file mode 100644 index 0000000..0ec2d4b --- /dev/null +++ b/src/lib/features/tree/index.ts @@ -0,0 +1,3 @@ +export { buildTreeNodes } from './tree-nodes.js' +export { getTree, TREE, tree, Tree } from './tree.svelte.js' +export type { TreeOptions } from './tree.types.js' diff --git a/src/lib/features/tree/tree-nodes.test.ts b/src/lib/features/tree/tree-nodes.test.ts new file mode 100644 index 0000000..f30503b --- /dev/null +++ b/src/lib/features/tree/tree-nodes.test.ts @@ -0,0 +1,121 @@ +import { type RowNode } from '../../core/types/index.js' +import { describe, expect, it } from 'vitest' +import { buildTreeNodes, indexByParent } from './tree-nodes.js' + +interface Row { + id: string + parent: string | null +} + +function nodesOf(...ids: [string, string | null][]): RowNode[] { + return ids.map(([id, parent], index) => ({ id, row: { id, parent }, index })) +} + +const flat = nodesOf(['a', null], ['a1', 'a'], ['a2', 'a'], ['a1x', 'a1'], ['b', null]) + +const index = indexByParent(flat, (row) => row.parent) +const childrenOf = (node: RowNode) => index.children.get(node.id) ?? [] +const ids = (nodes: RowNode[]) => nodes.map((node) => node.id) + +describe('indexByParent', () => { + it('splits roots from children', () => { + expect(ids(index.roots)).toEqual(['a', 'b']) + expect(ids(index.children.get('a')!)).toEqual(['a1', 'a2']) + }) + + it('promotes an orphan to a root rather than dropping it', () => { + const orphaned = indexByParent(nodesOf(['a1', 'a'], ['b', null]), (row) => row.parent) + expect(ids(orphaned.roots)).toEqual(['a1', 'b']) + }) +}) + +describe('buildTreeNodes', () => { + it('shows only roots while nothing is expanded', () => { + const result = buildTreeNodes(index.roots, { childrenOf, isExpanded: () => false }) + expect(ids(result.nodes)).toEqual(['a', 'b']) + }) + + it('reveals a level at a time as rows expand', () => { + const open = new Set(['a']) + const first = buildTreeNodes(index.roots, { + childrenOf, + isExpanded: (id) => open.has(id) + }) + expect(ids(first.nodes)).toEqual(['a', 'a1', 'a2', 'b']) + + open.add('a1') + const second = buildTreeNodes(index.roots, { + childrenOf, + isExpanded: (id) => open.has(id) + }) + expect(ids(second.nodes)).toEqual(['a', 'a1', 'a1x', 'a2', 'b']) + }) + + it('attaches the meta the kernel needs for treegrid ARIA', () => { + const result = buildTreeNodes(index.roots, { + childrenOf, + isExpanded: () => true + }) + const byId = new Map(result.nodes.map((node) => [node.id, node.meta])) + + expect(byId.get('a')).toMatchObject({ level: 0, expandable: true, setSize: 2, posInSet: 1 }) + expect(byId.get('a1')).toMatchObject({ + level: 1, + expandable: true, + setSize: 2, + posInSet: 1 + }) + expect(byId.get('a1x')).toMatchObject({ level: 2, expandable: false, posInSet: 1 }) + expect(byId.get('b')).toMatchObject({ level: 0, expandable: false, posInSet: 2 }) + }) + + it('opens to the default depth and reports what it opened', () => { + const result = buildTreeNodes(index.roots, { + childrenOf, + isExpanded: () => false, + defaultExpandedDepth: 1 + }) + + expect(ids(result.nodes)).toEqual(['a', 'a1', 'a2', 'b']) + expect(result.autoExpanded).toEqual(['a']) + }) + + it('does not report ids the user already opened', () => { + const result = buildTreeNodes(index.roots, { + childrenOf, + isExpanded: (id) => id === 'a', + defaultExpandedDepth: 1 + }) + expect(result.autoExpanded).toEqual([]) + }) + + it('leaves a seeded row closed once the user collapses it', () => { + const result = buildTreeNodes(index.roots, { + childrenOf, + isExpanded: () => false, + defaultExpandedDepth: 1, + isSeeded: (id) => id === 'a' + }) + + expect(ids(result.nodes)).toEqual(['a', 'b']) + expect(result.autoExpanded).toEqual([]) + }) + + it('walks nested children just as well as flat ones', () => { + interface Nested { + id: string + kids?: Nested[] + } + const root: RowNode = { + id: 'r', + index: 0, + row: { id: 'r', kids: [{ id: 'k1' }, { id: 'k2' }] } + } + const result = buildTreeNodes([root], { + childrenOf: (node) => + (node.row.kids ?? []).map((kid, i) => ({ id: kid.id, row: kid, index: i })), + isExpanded: () => true + }) + expect(result.nodes.map((node) => node.id)).toEqual(['r', 'k1', 'k2']) + }) +}) diff --git a/src/lib/features/tree/tree-nodes.ts b/src/lib/features/tree/tree-nodes.ts new file mode 100644 index 0000000..521854d --- /dev/null +++ b/src/lib/features/tree/tree-nodes.ts @@ -0,0 +1,75 @@ +import { type RowNode } from '../../core/types/index.js' + +export interface BuildTreeOptions { + childrenOf: (node: RowNode) => RowNode[] + + isExpanded: (id: string) => boolean + + defaultExpandedDepth?: number + + isSeeded?: (id: string) => boolean +} + +export interface TreeResult { + nodes: RowNode[] + + autoExpanded: string[] +} + +export function buildTreeNodes( + roots: RowNode[], + options: BuildTreeOptions +): TreeResult { + const flat: RowNode[] = [] + const autoExpanded: string[] = [] + const depth = options.defaultExpandedDepth ?? 0 + + const visit = (siblings: RowNode[], level: number) => { + siblings.forEach((node, index) => { + const children = options.childrenOf(node) + const expandable = children.length > 0 + + flat.push({ + ...node, + meta: { + ...node.meta, + level, + expandable, + setSize: siblings.length, + posInSet: index + 1 + } + }) + + if (!expandable) return + const expanded = options.isExpanded(node.id) + const auto = level < depth && !expanded && !options.isSeeded?.(node.id) + if (auto) autoExpanded.push(node.id) + if (auto || expanded) visit(children, level + 1) + }) + } + + visit(roots, 0) + return { nodes: flat, autoExpanded } +} + +export function indexByParent( + nodes: RowNode[], + parentIdOf: (row: TRow) => string | null | undefined +): { roots: RowNode[]; children: Map[]> } { + const present = new Set(nodes.map((node) => node.id)) + const children = new Map[]>() + const roots: RowNode[] = [] + + for (const node of nodes) { + const parent = parentIdOf(node.row) + if (parent === null || parent === undefined || !present.has(parent)) { + roots.push(node) + continue + } + const bucket = children.get(parent) + if (bucket) bucket.push(node) + else children.set(parent, [node]) + } + + return { roots, children } +} diff --git a/src/lib/features/tree/tree.svelte.ts b/src/lib/features/tree/tree.svelte.ts new file mode 100644 index 0000000..49296da --- /dev/null +++ b/src/lib/features/tree/tree.svelte.ts @@ -0,0 +1,118 @@ +import { type GridState, PIPELINE_ORDER } from '../../core/grid/index.js' +import { type GridFeature, type RowNode } from '../../core/types/index.js' +import { getFormula } from '../formula/index.js' +import { createSeenGroups } from '../grouping/seen-groups.js' +import { buildTreeNodes, indexByParent } from './tree-nodes.js' +import type { TreeOptions } from './tree.types.js' + +export const TREE = 'tree' + +export class Tree { + readonly defaultExpandedDepth: number + + #grid: GridState + #options: TreeOptions + #seen = createSeenGroups() + + constructor(grid: GridState, options: TreeOptions) { + this.#grid = grid + this.#options = options + this.defaultExpandedDepth = options.defaultExpandedDepth ?? 0 + + if (!options.getChildren && !options.getParentId) { + throw new Error('tree() needs either getChildren or getParentId to find the hierarchy') + } + } + + #wrap(rows: TRow[], parentIndex: number): RowNode[] { + const nodes = rows.map((row, index) => ({ + id: this.#grid.getRowId(row), + row, + index: parentIndex + index + 1 + })) + return getFormula(this.#grid)?.apply(nodes) ?? nodes + } + + build = (nodes: RowNode[]): RowNode[] => { + const { getChildren, getParentId } = this.#options + + const flatIndex = getParentId ? indexByParent(nodes, getParentId) : null + const roots = flatIndex ? flatIndex.roots : nodes + + const childrenOf = (node: RowNode): RowNode[] => { + if (flatIndex) return flatIndex.children.get(node.id) ?? [] + return this.#wrap(getChildren!(node.row) ?? [], node.index) + } + + const result = buildTreeNodes(roots, { + childrenOf, + defaultExpandedDepth: this.defaultExpandedDepth, + isExpanded: (id) => this.#grid.expansion.isExpanded(id), + isSeeded: (id) => this.#seen.has(id) + }) + + const fresh = result.autoExpanded + if (fresh.length > 0) { + const expansion = this.#grid.expansion + queueMicrotask(() => { + for (const id of fresh) this.#seen.add(id) + expansion.expandAll([...expansion.expandedIds, ...fresh]) + }) + } + + return result.nodes + } + + expandableIds = (): string[] => { + const ids: string[] = [] + for (const node of this.#grid.preWindowNodes) { + if (node.meta?.expandable) ids.push(node.id) + } + return ids + } + + expandAll = (): void => { + for (let level = 0; level < MAX_EXPAND_PASSES; level++) { + const ids = this.expandableIds() + const expansion = this.#grid.expansion + const before = expansion.expandedIds.size + expansion.expandAll([...expansion.expandedIds, ...ids]) + if (expansion.expandedIds.size === before) return + } + } + + collapseAll = (): void => { + this.#grid.expansion.collapseAll() + } +} + +const MAX_EXPAND_PASSES = 50 + +export function tree(options: TreeOptions): GridFeature { + return { + id: TREE, + createState: (grid) => { + grid.expansion.enabled = true + return new Tree(grid, options) + }, + createApi: (grid) => { + const state = getTree(grid)! + return { expandAllRows: state.expandAll, collapseAllRows: state.collapseAll } + }, + pipelineStage: { + order: PIPELINE_ORDER.group, + transform: (nodes, grid) => getTree(grid)?.build(nodes) ?? nodes + } + } +} + +export function getTree(grid: GridState): Tree | undefined { + return grid.feature>(TREE) +} + +declare module '../../core/types/api.js' { + interface GridApi { + expandAllRows?: () => void + collapseAllRows?: () => void + } +} diff --git a/src/lib/features/tree/tree.types.ts b/src/lib/features/tree/tree.types.ts new file mode 100644 index 0000000..4eb2955 --- /dev/null +++ b/src/lib/features/tree/tree.types.ts @@ -0,0 +1,20 @@ +export interface TreeOptions { + /** + * Nested shape: the children held on a row. Use this or `getParentId`, + * not both. + */ + getChildren?: (row: TRow) => TRow[] | undefined + + /** + * Flat shape: the id of a row's parent, or null for a root. Rows whose + * parent is not in the current set are promoted to roots, so a filter that + * removes a parent still leaves matching descendants reachable. + */ + getParentId?: (row: TRow) => string | null | undefined + + /** + * Levels open before the user touches anything. 0 shows roots only. + * @default 0 + */ + defaultExpandedDepth?: number +} diff --git a/src/lib/features/worker-row-model/columnar.ts b/src/lib/features/worker-row-model/columnar.ts new file mode 100644 index 0000000..fbfbbec --- /dev/null +++ b/src/lib/features/worker-row-model/columnar.ts @@ -0,0 +1,231 @@ +export type StoreKind = 'number' | 'date' | 'boolean' | 'string' + +export interface ColumnStore { + kind: StoreKind + numbers?: Float64Array + times?: Float64Array + days?: Float64Array + flags?: Uint8Array + codes?: Uint32Array + dictionary?: string[] + dictionaryDays?: Float64Array + dictionaryTimes?: Float64Array + lowered?: string[] + rank?: Uint32Array +} + +export interface RowStore { + rowCount: number + columns: Record + texts: Record +} + +export const BOOLEAN_BLANK = 2 + +export const MS_PER_DAY = 86_400_000 + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/ + +export function isBlank(value: unknown): boolean { + return value === null || value === undefined || value === '' +} + +function localDay(date: Date): number { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / MS_PER_DAY +} + +export function toEpochDay(value: unknown): number { + if (isBlank(value)) return Number.NaN + if (value instanceof Date) return Number.isNaN(value.getTime()) ? Number.NaN : localDay(value) + if (typeof value === 'number') return localDay(new Date(value)) + + const text = String(value).trim() + if (DATE_ONLY.test(text)) return Date.parse(text) / MS_PER_DAY + + const parsed = new Date(text) + return Number.isNaN(parsed.getTime()) ? Number.NaN : localDay(parsed) +} + +export function setKeyOf(value: unknown): string | number | boolean | null { + if (isBlank(value)) return null + if (typeof value === 'number' || typeof value === 'boolean') return value + if (value instanceof Date) return value.toISOString() + return String(value) +} + +export function kindOfValues(read: (index: number) => unknown, rowCount: number): StoreKind { + for (let index = 0; index < rowCount; index++) { + const value = read(index) + if (isBlank(value)) continue + if (typeof value === 'number') return 'number' + if (typeof value === 'boolean') return 'boolean' + if (value instanceof Date) return 'date' + return 'string' + } + return 'string' +} + +function numberColumn(read: (index: number) => unknown, rowCount: number): ColumnStore { + const numbers = new Float64Array(rowCount) + for (let index = 0; index < rowCount; index++) { + const value = read(index) + numbers[index] = isBlank(value) ? Number.NaN : Number(value) + } + return { kind: 'number', numbers } +} + +function dateColumn(read: (index: number) => unknown, rowCount: number): ColumnStore { + const times = new Float64Array(rowCount) + const days = new Float64Array(rowCount) + for (let index = 0; index < rowCount; index++) { + const value = read(index) + const time = value instanceof Date ? value.getTime() : Number.NaN + times[index] = time + days[index] = toEpochDay(value) + } + return { kind: 'date', times, days } +} + +function booleanColumn(read: (index: number) => unknown, rowCount: number): ColumnStore { + const flags = new Uint8Array(rowCount) + for (let index = 0; index < rowCount; index++) { + const value = read(index) + flags[index] = isBlank(value) ? BOOLEAN_BLANK : value ? 1 : 0 + } + return { kind: 'boolean', flags } +} + +function stringColumn(read: (index: number) => unknown, rowCount: number): ColumnStore { + const dictionary: string[] = [''] + const seen = new Map() + const codes = new Uint32Array(rowCount) + + for (let index = 0; index < rowCount; index++) { + const value = read(index) + if (isBlank(value)) continue + + const text = String(value) + let code = seen.get(text) + if (code === undefined) { + code = dictionary.length + dictionary.push(text) + seen.set(text, code) + } + codes[index] = code + } + + return { kind: 'string', codes, dictionary } +} + +export function buildColumn(read: (index: number) => unknown, rowCount: number): ColumnStore { + switch (kindOfValues(read, rowCount)) { + case 'number': + return numberColumn(read, rowCount) + case 'date': + return dateColumn(read, rowCount) + case 'boolean': + return booleanColumn(read, rowCount) + default: + return stringColumn(read, rowCount) + } +} + +export type ColumnValues = Record + +function storeOf(values: ColumnValues, rowCount: number): Record { + const columns: Record = Object.create(null) + for (const [field, column] of Object.entries(values)) { + columns[field] = buildColumn((index) => column[index], rowCount) + } + return columns +} + +export function buildStore( + values: ColumnValues, + rowCount: number, + texts: ColumnValues = {} +): RowStore { + return { + rowCount, + columns: storeOf(values, rowCount), + texts: storeOf(texts, rowCount) + } +} + +export function columnOf(store: RowStore, columnId: string): ColumnStore | undefined { + return Object.hasOwn(store.columns, columnId) ? store.columns[columnId] : undefined +} + +export function textOf(store: RowStore, columnId: string): ColumnStore | undefined { + return Object.hasOwn(store.texts, columnId) ? store.texts[columnId] : undefined +} + +export function readStore(store: ColumnStore, index: number): unknown { + if (store.kind === 'string') { + const code = store.codes![index]! + return code === 0 ? null : store.dictionary![code]! + } + if (store.kind === 'boolean') { + const flag = store.flags![index]! + return flag === BOOLEAN_BLANK ? null : flag === 1 + } + if (store.kind === 'date') { + const time = store.times![index]! + return Number.isNaN(time) ? null : new Date(time) + } + const value = store.numbers![index]! + return Number.isNaN(value) ? null : value +} + +export function dictionaryDaysOf(store: ColumnStore): Float64Array { + if (!store.dictionaryDays) { + const dictionary = store.dictionary! + const days = new Float64Array(dictionary.length) + days[0] = Number.NaN + for (let code = 1; code < dictionary.length; code++) + days[code] = toEpochDay(dictionary[code]) + store.dictionaryDays = days + } + return store.dictionaryDays +} + +export function loweredOf(store: ColumnStore): string[] { + if (!store.lowered) { + const dictionary = store.dictionary! + const lowered = new Array(dictionary.length) + for (let code = 0; code < dictionary.length; code++) { + lowered[code] = dictionary[code]!.toLowerCase() + } + store.lowered = lowered + } + return store.lowered +} + +export function dictionaryTimesOf(store: ColumnStore): Float64Array { + if (!store.dictionaryTimes) { + const dictionary = store.dictionary! + const times = new Float64Array(dictionary.length) + times[0] = Number.NaN + for (let code = 1; code < dictionary.length; code++) { + times[code] = new Date(dictionary[code]!).getTime() + } + store.dictionaryTimes = times + } + return store.dictionaryTimes +} + +export function rankOf(store: ColumnStore, collator: Intl.Collator): Uint32Array { + if (!store.rank) { + const dictionary = store.dictionary! + const order = new Array(dictionary.length - 1) + for (let index = 0; index < order.length; index++) order[index] = index + 1 + + order.sort((a, b) => collator.compare(dictionary[a]!, dictionary[b]!)) + + const rank = new Uint32Array(dictionary.length) + for (let position = 0; position < order.length; position++) + rank[order[position]!] = position + 1 + store.rank = rank + } + return store.rank +} diff --git a/src/lib/features/worker-row-model/combine.test.ts b/src/lib/features/worker-row-model/combine.test.ts new file mode 100644 index 0000000..1108678 --- /dev/null +++ b/src/lib/features/worker-row-model/combine.test.ts @@ -0,0 +1,130 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it, vi } from 'vitest' +import { formula } from '../formula/index.js' +import { getServerRowModel, serverRowModel } from '../server-row-model/index.js' +import { workerDataSource } from './worker-row-model.js' + +interface Line { + id: number + unit: number + qty: number + total?: number +} + +const lines: Line[] = Array.from({ length: 40 }, (_, i) => ({ + id: i + 1, + unit: (i % 8) * 100, + qty: (i % 5) + 1 +})) + +const columns: ColumnDef[] = [ + { id: 'unit', header: 'Unit', filter: 'number' }, + { id: 'qty', header: 'Qty', filter: 'number' }, + { id: 'total', header: 'Total', filter: 'number' } +] + +function grid(supplyComputed = true): GridState { + const source = workerDataSource(lines, { + columns, + inline: true, + readValue: supplyComputed + ? (row, column) => (column.id === 'total' ? row.unit * row.qty : undefined) + : undefined + }) + const built = createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + formula({ columns: { total: 'unit * qty' } }), + virtualization({ rowHeight: 40 }), + serverRowModel(source, { + mode: 'infinite', + blockSize: 20, + placeholder: (index) => ({ id: -(index + 1) }) as Line + }) + ] + }) + getServerRowModel(built)!.start() + return built +} + +describe('a formula column on a worker row model', () => { + it('filters by the computed value rather than ignoring the condition', async () => { + const built = grid() + await vi.waitFor(() => expect(built.data.length).toBeGreaterThan(0)) + + built.api.setColumnFilter!('total', { kind: 'number', op: 'gt', value: 1000 }) + + const model = getServerRowModel(built)! + const expected = lines.filter((line) => line.unit * line.qty > 1000).length + await vi.waitFor(() => expect(model.rowCount).toBe(expected)) + }) + + it('says so when nobody gave it the computed value', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const built = grid(false) + await vi.waitFor(() => expect(built.data.length).toBeGreaterThan(0)) + + built.api.setColumnFilter!('total', { kind: 'number', op: 'gt', value: 1000 }) + await vi.waitFor(() => expect(warn).toHaveBeenCalled()) + expect(String(warn.mock.calls[0]?.[0])).toContain('readValue') + warn.mockRestore() + }) + + it('sorts by the computed value', async () => { + const built = grid() + await vi.waitFor(() => expect(built.data.length).toBeGreaterThan(0)) + + built.api.setSort!([{ columnId: 'total', direction: 'desc' }]) + const best = Math.max(...lines.map((line) => line.unit * line.qty)) + + await vi.waitFor(() => + expect(built.data[0] && built.data[0].unit * built.data[0].qty).toBe(best) + ) + }) +}) + +describe('a saved view restored onto a worker row model', () => { + it('refetches so the grid shows what the view described', async () => { + const source = workerDataSource(lines, { columns, inline: true }) + const built = createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + virtualization({ rowHeight: 40 }), + serverRowModel(source, { + mode: 'infinite', + blockSize: 20, + placeholder: (index) => ({ id: -(index + 1) }) as Line + }) + ] + }) + const model = getServerRowModel(built)! + model.start() + await vi.waitFor(() => expect(model.rowCount).toBe(lines.length)) + + const snapshot = built.getState() + built.setState({ + ...snapshot, + features: { + ...snapshot.features, + filtering: { quick: '', columns: { qty: { kind: 'number', op: 'eq', value: 3 } } } + } + }) + + const expected = lines.filter((line) => line.qty === 3).length + await vi.waitFor(() => expect(model.rowCount).toBe(expected)) + }) +}) diff --git a/src/lib/features/worker-row-model/engine.ts b/src/lib/features/worker-row-model/engine.ts new file mode 100644 index 0000000..eb0672f --- /dev/null +++ b/src/lib/features/worker-row-model/engine.ts @@ -0,0 +1,118 @@ +import { buildStore, type ColumnValues, type RowStore } from './columnar.js' +import { groupLevel } from './grouping.js' +import { type QueryOverrides, type QueryResult, runQuery, type WorkerQuery } from './query.js' +import type { FromWorker, QueryMessage, ToWorker } from './protocol.js' + +const EMPTY: RowStore = { rowCount: 0, columns: Object.create(null), texts: Object.create(null) } + +function fill(into: ColumnValues, slices: Record, from: number): void { + for (const [field, slice] of Object.entries(slices)) { + if (!Object.hasOwn(into, field)) continue + const target = into[field]! + for (let offset = 0; offset < slice.length; offset++) { + target[from + offset] = slice[offset] + } + } +} + +function passKey(query: WorkerQuery): string { + return JSON.stringify([ + query.filter, + query.sort, + query.advancedFilter ?? null, + query.dateColumns ?? [], + query.nulls ?? 'first' + ]) +} + +export function createEngine( + overridesFor?: (query: WorkerQuery) => QueryOverrides +): (message: ToWorker) => FromWorker | null { + let store: RowStore = EMPTY + let incoming: ColumnValues = Object.create(null) + let incomingTexts: ColumnValues = Object.create(null) + let rowCount = 0 + + let cachedKey: string | null = null + let cached: QueryResult | null = null + + function pass(query: WorkerQuery): QueryResult { + const key = passKey(query) + if (cachedKey === key && cached) return cached + + cached = runQuery(store, query, overridesFor?.(query)) + cachedKey = key + return cached + } + + function answer(message: QueryMessage): FromWorker { + const { indices, rowCount: matched } = pass(message.query) + + const groupBy = message.query.groupBy ?? [] + if (groupBy.length > 0) { + const level = groupLevel(store, indices, { + groupBy, + groupKeys: message.query.groupKeys ?? [], + startRow: message.startRow, + endRow: message.endRow + }) + return { + type: 'result', + id: message.id, + indices: level.indices, + rowCount: level.rowCount, + groups: level.groups + } + } + + const window = indices.subarray( + Math.min(message.startRow, matched), + Math.min(message.endRow, matched) + ) + return { type: 'result', id: message.id, indices: [...window], rowCount: matched } + } + + function forget(): void { + cachedKey = null + cached = null + } + + return (message) => { + if (message.type === 'load') { + incoming = Object.create(null) + incomingTexts = Object.create(null) + rowCount = message.rowCount + for (const field of message.fields) incoming[field] = new Array(rowCount) + for (const field of message.textFields) { + incomingTexts[field] = new Array(rowCount) + } + store = EMPTY + forget() + return null + } + + if (message.type === 'chunk') { + fill(incoming, message.values, message.from) + fill(incomingTexts, message.texts, message.from) + return null + } + + if (message.type === 'loaded') { + store = buildStore(incoming, rowCount, incomingTexts) + incoming = Object.create(null) + incomingTexts = Object.create(null) + forget() + return null + } + + try { + return answer(message) + } catch (error) { + return { + type: 'failure', + id: message.id, + message: error instanceof Error ? error.message : String(error) + } + } + } +} diff --git a/src/lib/features/worker-row-model/grouping.ts b/src/lib/features/worker-row-model/grouping.ts new file mode 100644 index 0000000..a6cfcd3 --- /dev/null +++ b/src/lib/features/worker-row-model/grouping.ts @@ -0,0 +1,78 @@ +import { columnOf, readStore, type RowStore } from './columnar.js' +import type { GroupDescriptor } from './protocol.js' + +export interface GroupLevel { + indices: number[] + groups?: GroupDescriptor[] + rowCount: number +} + +const collator = new Intl.Collator(undefined, { numeric: true }) + +function keyOf(value: unknown): string { + if (value === null || value === undefined) return '' + if (value instanceof Date) return value.toISOString() + return String(value) +} + +function narrow( + store: RowStore, + indices: Uint32Array, + groupBy: string[], + groupKeys: unknown[] +): number[] { + const kept: number[] = [] + + for (let position = 0; position < indices.length; position++) { + const index = indices[position]! + let matches = true + for (let depth = 0; depth < groupKeys.length; depth++) { + const column = columnOf(store, groupBy[depth]!) + if (!column || keyOf(readStore(column, index)) !== keyOf(groupKeys[depth])) { + matches = false + break + } + } + if (matches) kept.push(index) + } + + return kept +} + +export interface GroupWindow { + groupBy: string[] + groupKeys: unknown[] + startRow: number + endRow: number +} + +export function groupLevel(store: RowStore, indices: Uint32Array, window: GroupWindow): GroupLevel { + const { groupBy, groupKeys, startRow, endRow } = window + const inside = narrow(store, indices, groupBy, groupKeys) + + const column = + groupKeys.length < groupBy.length ? columnOf(store, groupBy[groupKeys.length]!) : undefined + + if (!column) { + return { indices: inside.slice(startRow, endRow), rowCount: inside.length } + } + + const counts = new Map() + for (const index of inside) { + const value = readStore(column, index) + const key = keyOf(value) + const entry = counts.get(key) + if (entry) entry.count += 1 + else counts.set(key, { value, count: 1 }) + } + + const groups = [...counts.values()] + .sort((a, b) => collator.compare(keyOf(a.value), keyOf(b.value))) + .map((entry) => ({ keys: [...groupKeys, entry.value], count: entry.count })) + + return { + indices: [], + groups: groups.slice(startRow, endRow), + rowCount: groups.length + } +} diff --git a/src/lib/features/worker-row-model/hardening.test.ts b/src/lib/features/worker-row-model/hardening.test.ts new file mode 100644 index 0000000..b82d5b9 --- /dev/null +++ b/src/lib/features/worker-row-model/hardening.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { type ColumnDef } from '../../core/types/index.js' +import { buildStore } from './columnar.js' +import { runQuery } from './query.js' +import { workerDataSource } from './worker-row-model.js' + +interface Row { + id: number + name: string +} + +const rows: Row[] = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' } +] + +const columns: ColumnDef[] = [{ id: 'name', filter: 'text' }] + +const anyRequest = { + startRow: 0, + endRow: 10, + sortModel: [], + filterModel: { quick: '', quickFields: [], columns: {} }, + groupKeys: [], + groupBy: [] +} + +describe('a filter model that names something off the prototype', () => { + const store = buildStore({ name: ['a', 'b'] }, 2) + + it('answers a column that is not a column rather than crashing', () => { + const filter = { + quick: '', + quickFields: [], + columns: { + constructor: { + join: 'and', + conditions: [{ kind: 'text', op: 'equals', value: 'a' }] + }, + toString: { join: 'and', conditions: [{ kind: 'text', op: 'blank', value: '' }] } + } + } + + expect(runQuery(store, { filter: filter as never, sort: [] }).rowCount).toBe(2) + }) + + it('ignores a quick field that is not a column', () => { + const filter = { quick: 'a', quickFields: ['constructor', 'name'], columns: {} } + expect(runQuery(store, { filter: filter as never, sort: [] }).rowCount).toBe(1) + }) + + it('sorts by a column that is not a column without inventing an order', () => { + const filter = { quick: '', quickFields: [], columns: {} } + const sorted = runQuery(store, { + filter: filter as never, + sort: [{ columnId: '__proto__', direction: 'asc' }] + }) + expect([...sorted.indices]).toEqual([0, 1]) + }) +}) + +describe('rows changing underneath a query in flight', () => { + it('refuses the answer rather than mapping it onto the new rows', async () => { + const source = workerDataSource(rows, { columns, inline: true }) + const inFlight = source.getRows(anyRequest) + source.setRows([{ id: 9, name: 'z' }]) + + await expect(inFlight).rejects.toThrow(/in flight/) + + const after = await source.getRows(anyRequest) + expect(after.rows.map((row) => row.id)).toEqual([9]) + }) + + it('leaves no answer hanging when the source is disposed', async () => { + const source = workerDataSource(rows, { columns, inline: true }) + const inFlight = source.getRows(anyRequest) + source.dispose() + + await expect(inFlight).rejects.toThrow(/disposed/) + await expect(source.getRows(anyRequest)).rejects.toThrow(/disposed/) + }) +}) + +describe('scrolling a filtered table', () => { + it('runs the pass once and windows it, rather than once per block', async () => { + const many: Row[] = Array.from({ length: 120_000 }, (_, index) => ({ + id: index + 1, + name: `Person ${(index * 7919) % 120_000}` + })) + const source = workerDataSource(many, { columns, inline: true }) + + const block = (start: number) => ({ + startRow: start, + endRow: start + 200, + sortModel: [{ columnId: 'name', direction: 'asc' as const }], + filterModel: { quick: 'person 1', quickFields: ['name'], columns: {} }, + groupKeys: [], + groupBy: [] + }) + + const firstStart = performance.now() + await source.getRows(block(0)) + const first = performance.now() - firstStart + + const restStart = performance.now() + for (let index = 1; index <= 10; index++) await source.getRows(block(index * 200)) + const rest = performance.now() - restStart + + expect(rest).toBeLessThan(first) + }) +}) + +describe('a column whose search text is not its value', () => { + interface Money { + id: number + total: number + } + const priced: Money[] = [ + { id: 1, total: 900 }, + { id: 2, total: 1200 }, + { id: 3, total: 80 } + ] + const moneyColumns: ColumnDef[] = [{ id: 'total', type: 'currency', filter: 'number' }] + + function source() { + return workerDataSource(priced, { + columns: moneyColumns, + inline: true, + searchText: (value) => `$${Number(value).toLocaleString('en-US')}` + }) + } + + it('still compares the number when filtering', async () => { + const result = await source().getRows({ + ...anyRequest, + endRow: 50, + filterModel: { + quick: '', + quickFields: [], + columns: { + total: { join: 'and', conditions: [{ kind: 'number', op: 'gt', value: 100 }] } + } + } + }) + expect(result.rows.map((row) => row.id).sort()).toEqual([1, 2]) + }) + + it('still sorts by the number, not by the text', async () => { + const result = await source().getRows({ + ...anyRequest, + endRow: 50, + sortModel: [{ columnId: 'total', direction: 'asc' }] + }) + expect(result.rows.map((row) => row.total)).toEqual([80, 900, 1200]) + }) + + it('searches the text a quick filter was given', async () => { + const result = await source().getRows({ + ...anyRequest, + endRow: 50, + filterModel: { quick: '$1,200', quickFields: ['total'], columns: {} } + }) + expect(result.rows.map((row) => row.id)).toEqual([2]) + }) +}) + +describe('options an app did not pass', () => { + it('does not mistake a method every object carries for one of them', async () => { + const source = workerDataSource(rows, { columns, inline: true }) + + const result = await source.getRows({ + ...anyRequest, + filterModel: { + quick: '', + quickFields: [], + columns: { + name: { join: 'and', conditions: [{ kind: 'text', op: 'equals', value: 'a' }] } + } + } + }) + + expect(result.rows.map((row) => row.id)).toEqual([1]) + }) +}) diff --git a/src/lib/features/worker-row-model/index.ts b/src/lib/features/worker-row-model/index.ts new file mode 100644 index 0000000..1ea6217 --- /dev/null +++ b/src/lib/features/worker-row-model/index.ts @@ -0,0 +1,6 @@ +export { buildStore, type ColumnStore, type RowStore, type StoreKind } from './columnar.js' +export { createEngine } from './engine.js' +export { predicateFor } from './predicates.js' +export { runQuery, type QueryResult, type WorkerQuery } from './query.js' +export { workerDataSource } from './worker-row-model.js' +export type { WorkerDataSource, WorkerDataSourceOptions } from './worker-row-model.types.js' diff --git a/src/lib/features/worker-row-model/parity.test.ts b/src/lib/features/worker-row-model/parity.test.ts new file mode 100644 index 0000000..e4a5bab --- /dev/null +++ b/src/lib/features/worker-row-model/parity.test.ts @@ -0,0 +1,182 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef, type ColumnFilter, type SortState } from '../../core/types/index.js' +import { filtering, toFilterRequest } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { describe, expect, it } from 'vitest' +import { buildStore } from './columnar.js' +import { runQuery } from './query.js' + +interface Row { + id: number + city: string | null + qty: number | null + when: string | null + born: Date | null + paid: boolean | null +} + +const cities = ['Hanoi', 'Da Nang', 'hanoi', 'Ho Chi Minh', ''] +const rows: Row[] = Array.from({ length: 60 }, (_, i) => ({ + id: i + 1, + city: i % 11 === 0 ? null : cities[i % 5]!, + qty: i % 7 === 0 ? null : ((i * 13) % 50) - 10, + when: + i % 9 === 0 + ? null + : `2026-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 27) + 1).padStart(2, '0')}`, + born: i % 5 === 0 ? null : new Date(2020, i % 12, (i % 27) + 1, i % 24), + paid: i % 4 === 0 ? null : i % 3 !== 0 +})) + +const columns: ColumnDef[] = [ + { id: 'city', header: 'City', filter: 'text' }, + { id: 'qty', header: 'Qty', filter: 'number' }, + { id: 'when', header: 'When', type: 'date', filter: 'date' }, + { id: 'born', header: 'Born', type: 'datetime', filter: 'date' }, + { id: 'paid', header: 'Paid', type: 'boolean', filter: 'boolean' } +] + +const FIELDS = ['city', 'qty', 'when', 'born', 'paid'] +const DATE_COLUMNS = ['when', 'born'] + +function valuesOf(source: T[], fields: string[]): Record { + const values: Record = {} + for (const field of fields) { + values[field] = source.map((row) => (row as Record)[field]) + } + return values +} + +const store = buildStore(valuesOf(rows, FIELDS), rows.length) + +function throughCommunity( + filter: Record, + quick: string, + sort: SortState[] +): number[] { + const grid: GridState = createDataGrid({ + columns, + data: rows, + getRowId: (row) => String(row.id), + features: [filtering(), sorting()] + }) + for (const [columnId, entry] of Object.entries(filter)) { + grid.api.setColumnFilter!(columnId, entry) + } + if (quick) grid.api.setQuickFilter!(quick) + for (const entry of sort) grid.api.setSort!([entry]) + + return grid.preWindowNodes.map((node) => node.row.id) +} + +function throughWorker( + filter: Record, + quick: string, + sort: SortState[] +): number[] { + const model = { quick: '', columns: filter } + const request = toFilterRequest(model) + request.quick = quick + request.quickFields = FIELDS + + const { indices } = runQuery(store, { + filter: request, + sort, + dateColumns: DATE_COLUMNS + }) + return [...indices].map((index) => rows[index]!.id) +} + +const filters: [string, Record][] = [ + ['nothing', {}], + ['text contains', { city: { kind: 'text', op: 'contains', value: 'han' } }], + [ + 'text contains, cased', + { city: { kind: 'text', op: 'contains', value: 'Han', caseSensitive: true } } + ], + ['text notContains', { city: { kind: 'text', op: 'notContains', value: 'han' } }], + ['text equals', { city: { kind: 'text', op: 'equals', value: 'Da Nang' } }], + ['text notEqual', { city: { kind: 'text', op: 'notEqual', value: 'Da Nang' } }], + ['text startsWith', { city: { kind: 'text', op: 'startsWith', value: 'ho' } }], + ['text blank', { city: { kind: 'text', op: 'blank', value: '' } }], + ['text notBlank', { city: { kind: 'text', op: 'notBlank', value: '' } }], + ['number gt', { qty: { kind: 'number', op: 'gt', value: 0 } }], + ['number lte', { qty: { kind: 'number', op: 'lte', value: 10 } }], + ['number neq', { qty: { kind: 'number', op: 'neq', value: 3 } }], + ['number between', { qty: { kind: 'number', op: 'between', value: -5, to: 20 } }], + ['number blank', { qty: { kind: 'number', op: 'blank' } }], + ['date equals', { when: { kind: 'date', op: 'equals', value: '2026-03-03' } }], + ['date before', { when: { kind: 'date', op: 'before', value: '2026-06-01' } }], + [ + 'date between', + { when: { kind: 'date', op: 'between', value: '2026-01-01', to: '2026-03-31' } } + ], + ['datetime after', { born: { kind: 'date', op: 'after', value: '2020-06-15' } }], + ['datetime blank', { born: { kind: 'date', op: 'blank' } }], + ['boolean true', { paid: { kind: 'boolean', value: true } }], + ['boolean false', { paid: { kind: 'boolean', value: false } }], + ['set of two', { city: { kind: 'set', values: ['Hanoi', 'Da Nang'] } }], + ['set with blank', { city: { kind: 'set', values: ['Hanoi', null] } }], + [ + 'two columns at once', + { + city: { kind: 'text', op: 'contains', value: 'a' }, + qty: { kind: 'number', op: 'gte', value: 0 } + } + ] +] + +describe('the worker query answers what the free grid answers', () => { + it.each(filters)('%s', (_label, filter) => { + expect(throughWorker(filter, '', [])).toEqual(throughCommunity(filter, '', [])) + }) + + const sorts: [string, SortState[]][] = [ + ['city asc', [{ columnId: 'city', direction: 'asc' }]], + ['city desc', [{ columnId: 'city', direction: 'desc' }]], + ['qty asc', [{ columnId: 'qty', direction: 'asc' }]], + ['qty desc', [{ columnId: 'qty', direction: 'desc' }]], + ['date asc', [{ columnId: 'when', direction: 'asc' }]], + ['datetime desc', [{ columnId: 'born', direction: 'desc' }]], + ['boolean asc', [{ columnId: 'paid', direction: 'asc' }]] + ] + + it.each(sorts)('sorts by %s the same way', (_label, sort) => { + expect(throughWorker({}, '', sort)).toEqual(throughCommunity({}, '', sort)) + }) + + it('filters and sorts in one pass the way the pipeline does', () => { + const filter: Record = { + qty: { kind: 'number', op: 'gte', value: 0 } + } + const sort: SortState[] = [{ columnId: 'city', direction: 'asc' }] + expect(throughWorker(filter, '', sort)).toEqual(throughCommunity(filter, '', sort)) + }) + + it('matches the quick filter over text columns', () => { + expect(throughWorker({}, 'nang', [])).toEqual(throughCommunity({}, 'nang', [])) + }) +}) + +describe('where the worker deliberately answers less', () => { + it('does not see the text a column is drawn with, only the value behind it', () => { + const priced: { id: number; price: number }[] = [ + { id: 1, price: 1234.5 }, + { id: 2, price: 99 } + ] + const grid = createDataGrid<{ id: number; price: number }>({ + columns: [{ id: 'price', type: 'currency', filter: 'number' }], + data: priced, + getRowId: (row) => String(row.id), + features: [filtering()] + }) + grid.api.setQuickFilter!('1,234') + + const priceStore = buildStore(valuesOf(priced, ['price']), priced.length) + const request = toFilterRequest({ quick: '', columns: {} }, ['price']) + request.quick = '1,234' + + expect(grid.preWindowNodes.map((node) => node.row.id)).toEqual([1]) + expect(runQuery(priceStore, { filter: request, sort: [] }).rowCount).toBe(0) + }) +}) diff --git a/src/lib/features/worker-row-model/predicates.ts b/src/lib/features/worker-row-model/predicates.ts new file mode 100644 index 0000000..74f13e7 --- /dev/null +++ b/src/lib/features/worker-row-model/predicates.ts @@ -0,0 +1,117 @@ +import { type ColumnFilter } from '../../core/types/index.js' +import { isBlank, setKeyOf, toEpochDay } from './columnar.js' + +const PASSES = (): boolean => true + +function folded(filter: Extract) { + const fold = filter.caseSensitive + ? (text: string) => text + : (text: string) => text.toLowerCase() + return { query: fold(String(filter.value ?? '').trim()), fold } +} + +function textPredicate( + filter: Extract +): (value: unknown) => boolean { + if (filter.op === 'blank') return (value) => isBlank(value) + if (filter.op === 'notBlank') return (value) => !isBlank(value) + + const { query, fold } = folded(filter) + const read = (value: unknown) => fold(String(value)) + + switch (filter.op) { + case 'equals': + return (value) => !isBlank(value) && read(value) === query + case 'notEqual': + return (value) => isBlank(value) || read(value) !== query + case 'startsWith': + return (value) => !isBlank(value) && read(value).startsWith(query) + case 'endsWith': + return (value) => !isBlank(value) && read(value).endsWith(query) + case 'contains': + return (value) => !isBlank(value) && read(value).includes(query) + case 'notContains': + return (value) => isBlank(value) || !read(value).includes(query) + default: + return PASSES + } +} + +const NUMBER_TESTS: Record boolean> = { + eq: (value, target) => value === target, + neq: (value, target) => value !== target, + gt: (value, target) => value > target, + gte: (value, target) => value >= target, + lt: (value, target) => value < target, + lte: (value, target) => value <= target +} + +function numberPredicate( + filter: Extract +): (value: unknown) => boolean { + if (filter.op === 'blank') return (value) => isBlank(value) + if (filter.op === 'notBlank') return (value) => !isBlank(value) + + const target = filter.value ?? Number.NaN + if (filter.op === 'between') { + const to = filter.to ?? Number.NaN + return (value) => { + if (isBlank(value)) return false + const numeric = Number(value) + return numeric >= target && numeric <= to + } + } + + const compare = NUMBER_TESTS[filter.op] + if (!compare) return PASSES + return (value) => !isBlank(value) && compare(Number(value), target) +} + +function datePredicate( + filter: Extract +): (value: unknown) => boolean { + if (filter.op === 'blank') return (value) => isBlank(value) + if (filter.op === 'notBlank') return (value) => !isBlank(value) + + const target = toEpochDay(filter.value) + const to = toEpochDay(filter.to) + + switch (filter.op) { + case 'equals': + return (value) => toEpochDay(value) === target + case 'before': + return (value) => toEpochDay(value) < target + case 'after': + return (value) => toEpochDay(value) > target + case 'between': + return (value) => { + const day = toEpochDay(value) + return day >= target && day <= to + } + default: + return PASSES + } +} + +function setPredicate(filter: Extract): (value: unknown) => boolean { + if (!Array.isArray(filter.values)) return PASSES + const allowed = new Set(filter.values.map((entry) => setKeyOf(entry))) + return (value) => allowed.has(setKeyOf(value)) +} + +export function predicateFor(filter: ColumnFilter): (value: unknown) => boolean { + switch (filter.kind) { + case 'text': + return textPredicate(filter) + case 'number': + return numberPredicate(filter) + case 'date': + return datePredicate(filter) + case 'set': + return setPredicate(filter) + case 'boolean': + return (value) => Boolean(value) === filter.value + default: + return PASSES + } +} diff --git a/src/lib/features/worker-row-model/protocol.ts b/src/lib/features/worker-row-model/protocol.ts new file mode 100644 index 0000000..59eee8c --- /dev/null +++ b/src/lib/features/worker-row-model/protocol.ts @@ -0,0 +1,50 @@ +import type { WorkerQuery } from './query.js' + +export interface LoadBeginMessage { + type: 'load' + fields: string[] + textFields: string[] + rowCount: number +} + +export interface LoadChunkMessage { + type: 'chunk' + from: number + values: Record + texts: Record +} + +export interface LoadEndMessage { + type: 'loaded' +} + +export interface QueryMessage { + type: 'query' + id: number + startRow: number + endRow: number + query: WorkerQuery +} + +export type ToWorker = LoadBeginMessage | LoadChunkMessage | LoadEndMessage | QueryMessage + +export interface GroupDescriptor { + keys: unknown[] + count: number +} + +export interface ResultMessage { + type: 'result' + id: number + indices: number[] + rowCount: number + groups?: GroupDescriptor[] +} + +export interface FailureMessage { + type: 'failure' + id: number + message: string +} + +export type FromWorker = ResultMessage | FailureMessage diff --git a/src/lib/features/worker-row-model/query.ts b/src/lib/features/worker-row-model/query.ts new file mode 100644 index 0000000..d913e24 --- /dev/null +++ b/src/lib/features/worker-row-model/query.ts @@ -0,0 +1,289 @@ +import { type FilterRequest, type SortState } from '../../core/types/index.js' +import type { FilterNode } from '../advanced-filter/advanced-filter.types.js' +import { matchesNode } from '../advanced-filter/evaluate.js' +import { + BOOLEAN_BLANK, + columnOf, + dictionaryTimesOf, + textOf, + MS_PER_DAY, + dictionaryDaysOf, + isBlank, + loweredOf, + rankOf, + readStore, + type ColumnStore, + type RowStore +} from './columnar.js' +import { predicateFor } from './predicates.js' + +export interface WorkerQuery { + filter: FilterRequest + sort: SortState[] + advancedFilter?: FilterNode | null + dateColumns?: string[] + nulls?: 'first' | 'last' + groupBy?: string[] + groupKeys?: unknown[] +} + +export interface QueryResult { + indices: Uint32Array + rowCount: number +} + +export interface QueryOverrides { + comparators?: Record number> + predicates?: Record boolean> +} + +type RowTest = (index: number) => boolean + +const collator = new Intl.Collator(undefined, { numeric: true }) + +function stringTest( + store: ColumnStore, + filter: Parameters[0], + accept: (value: unknown) => boolean +): RowTest { + const dictionary = store.dictionary! + const size = filter.kind === 'date' ? dictionaryDaysOf(store).length : dictionary.length + const mask = new Uint8Array(size) + for (let code = 0; code < size; code++) { + mask[code] = accept(code === 0 ? null : dictionary[code]) ? 1 : 0 + } + + const codes = store.codes! + return (index) => mask[codes[index]!] === 1 +} + +function dateTest(store: ColumnStore, accept: (value: unknown) => boolean): RowTest { + const days = store.days! + const cache = new Map() + + return (index) => { + const day = days[index]! + const key = Number.isNaN(day) ? Number.MIN_SAFE_INTEGER : day + let answer = cache.get(key) + if (answer === undefined) { + answer = accept(Number.isNaN(day) ? null : day * MS_PER_DAY) + cache.set(key, answer) + } + return answer + } +} + +function testFor(store: ColumnStore, filter: Parameters[0]): RowTest { + const accept = predicateFor(filter) + + if (store.kind === 'string') return stringTest(store, filter, accept) + + if (store.kind === 'boolean') { + const flags = store.flags! + const answers = [accept(false), accept(true), accept(null)] + return (index) => answers[flags[index]!]! + } + + if (store.kind === 'date' && filter.kind === 'date') return dateTest(store, accept) + + if (store.kind === 'number' && filter.kind === 'number') { + const numbers = store.numbers! + return (index) => { + const value = numbers[index]! + return accept(Number.isNaN(value) ? null : value) + } + } + + return (index) => accept(readStore(store, index)) +} + +function containsTest(column: ColumnStore, query: string): RowTest { + if (column.kind === 'string') { + const lowered = loweredOf(column) + const mask = new Uint8Array(lowered.length) + for (let code = 1; code < lowered.length; code++) { + mask[code] = lowered[code]!.includes(query) ? 1 : 0 + } + const codes = column.codes! + return (index: number) => mask[codes[index]!] === 1 + } + + return (index: number) => { + const value = readStore(column, index) + return !isBlank(value) && String(value).toLowerCase().includes(query) + } +} + +function quickTest(store: RowStore, request: FilterRequest): RowTest | null { + const query = request.quick.trim().toLowerCase() + if (!query) return null + + const tests: RowTest[] = [] + for (const field of request.quickFields) { + const column = columnOf(store, field) + if (column) tests.push(containsTest(column, query)) + + const text = textOf(store, field) + if (text) tests.push(containsTest(text, query)) + } + + if (tests.length === 0) return () => false + return (index) => tests.some((test) => test(index)) +} + +function columnTests( + store: RowStore, + request: FilterRequest, + overrides: QueryOverrides | undefined +): RowTest[] { + const tests: RowTest[] = [] + + for (const [columnId, entry] of Object.entries(request.columns)) { + const own = overrides?.predicates?.[columnId] + if (own) { + tests.push(own) + continue + } + + const column = columnOf(store, columnId) + if (!column || entry.conditions.length === 0) continue + + const conditions = entry.conditions.map((condition) => testFor(column, condition)) + if (conditions.length === 1) { + tests.push(conditions[0]!) + continue + } + + tests.push( + entry.join === 'or' + ? (index) => conditions.some((test) => test(index)) + : (index) => conditions.every((test) => test(index)) + ) + } + + return tests +} + +function treeTest(store: RowStore, tree: FilterNode | null | undefined): RowTest | null { + if (!tree || (tree.kind === 'group' && tree.children.length === 0)) return null + + return (index) => + matchesNode(tree, (columnId) => { + const column = columnOf(store, columnId) + return column ? readStore(column, index) : undefined + }) +} + +function stringKeys(column: ColumnStore, rowCount: number, asDate: boolean): Float64Array { + const codes = column.codes! + const keys = new Float64Array(rowCount) + + if (asDate) { + const times = dictionaryTimesOf(column) + for (let index = 0; index < rowCount; index++) keys[index] = times[codes[index]!]! + return keys + } + + const rank = rankOf(column, collator) + for (let index = 0; index < rowCount; index++) { + const code = codes[index]! + keys[index] = code === 0 ? Number.NaN : rank[code]! + } + return keys +} + +function booleanKeys(column: ColumnStore, rowCount: number): Float64Array { + const flags = column.flags! + const keys = new Float64Array(rowCount) + for (let index = 0; index < rowCount; index++) { + const flag = flags[index]! + keys[index] = flag === BOOLEAN_BLANK ? Number.NaN : flag + } + return keys +} + +function keysFor(store: RowStore, columnId: string, asDate: boolean): Float64Array | null { + const column = columnOf(store, columnId) + if (!column) return null + + switch (column.kind) { + case 'string': + return stringKeys(column, store.rowCount, asDate) + case 'boolean': + return booleanKeys(column, store.rowCount) + case 'date': + return column.times! + default: + return column.numbers! + } +} + +function comparatorsFor(store: RowStore, query: WorkerQuery, overrides?: QueryOverrides) { + const dates = new Set(query.dateColumns ?? []) + const nullSign = (query.nulls ?? 'first') === 'last' ? 1 : -1 + + return query.sort.flatMap((entry) => { + const own = overrides?.comparators?.[entry.columnId] + if (own) { + const factor = entry.direction === 'asc' ? 1 : -1 + return [(a: number, b: number) => own(a, b) * factor] + } + + const keys = keysFor(store, entry.columnId, dates.has(entry.columnId)) + if (!keys) return [] + + const factor = entry.direction === 'asc' ? 1 : -1 + return [ + (a: number, b: number) => { + const left = keys[a]! + const right = keys[b]! + const leftBlank = Number.isNaN(left) + const rightBlank = Number.isNaN(right) + if (leftBlank || rightBlank) { + if (leftBlank && rightBlank) return 0 + return (leftBlank ? nullSign : -nullSign) * factor + } + return (left - right) * factor + } + ] + }) +} + +export function runQuery( + store: RowStore, + query: WorkerQuery, + overrides?: QueryOverrides +): QueryResult { + const tests: RowTest[] = columnTests(store, query.filter, overrides) + + const quick = quickTest(store, query.filter) + if (quick) tests.push(quick) + + const tree = treeTest(store, query.advancedFilter) + if (tree) tests.push(tree) + + const kept: number[] = [] + for (let index = 0; index < store.rowCount; index++) { + let passes = true + for (const test of tests) { + if (!test(index)) { + passes = false + break + } + } + if (passes) kept.push(index) + } + + const comparators = comparatorsFor(store, query, overrides) + if (comparators.length > 0) { + kept.sort((a, b) => { + for (const compare of comparators) { + const result = compare(a, b) + if (result !== 0) return result + } + return a - b + }) + } + + return { indices: Uint32Array.from(kept), rowCount: kept.length } +} diff --git a/src/lib/features/worker-row-model/worker-row-model.test.ts b/src/lib/features/worker-row-model/worker-row-model.test.ts new file mode 100644 index 0000000..f8d9898 --- /dev/null +++ b/src/lib/features/worker-row-model/worker-row-model.test.ts @@ -0,0 +1,266 @@ +import { createDataGrid, type GridState } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { filtering } from '../../features/filtering/index.js' +import { sorting } from '../../features/sorting/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { describe, expect, it, vi } from 'vitest' +import { advancedFilter, getAdvancedFilter } from '../advanced-filter/index.js' +import { getServerRowModel, serverRowModel } from '../server-row-model/index.js' +import { workerDataSource } from './worker-row-model.js' + +interface Sale { + id: number + rep: string + total: number + closedAt: string +} + +const rows: Sale[] = Array.from({ length: 250 }, (_, i) => ({ + id: i + 1, + rep: ['An', 'Binh', 'Chi', 'Dung'][i % 4]!, + total: (i * 37) % 1000, + closedAt: `2026-${String((i % 12) + 1).padStart(2, '0')}-15` +})) + +const columns: ColumnDef[] = [ + { id: 'rep', header: 'Rep', filter: 'text' }, + { id: 'total', header: 'Total', type: 'currency', filter: 'number' }, + { id: 'closedAt', header: 'Closed', type: 'date', filter: 'date' } +] + +function makeGrid(source: ReturnType>): GridState { + return createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + advancedFilter(), + virtualization({ rowHeight: 40 }), + serverRowModel(source, { + mode: 'infinite', + blockSize: 50, + placeholder: (index) => ({ id: -(index + 1) }) as Sale + }) + ] + }) +} + +function started(source: ReturnType>): GridState { + const grid = makeGrid(source) + getServerRowModel(grid)!.start() + return grid +} + +function inlineSource() { + return workerDataSource(rows, { columns, inline: true }) +} + +describe('a worker data source standing in for a server', () => { + it('answers the first block and the total', async () => { + const grid = started(inlineSource()) + + await vi.waitFor(() => expect(grid.data.length).toBeGreaterThan(0)) + const model = grid.state.serverRowModel as { rowCount: number | null } + expect(model.rowCount).toBe(rows.length) + expect(grid.data[0]!.id).toBe(1) + }) + + it('refetches when the advanced filter tree changes, and applies it', async () => { + const grid = started(inlineSource()) + await vi.waitFor(() => expect(grid.data.length).toBeGreaterThan(0)) + + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', columnId: 'rep', op: 'equals', value: 'Chi' }] + }) + + const model = grid.state.serverRowModel as { rowCount: number | null } + await vi.waitFor(() => + expect(model.rowCount).toBe(rows.filter((row) => row.rep === 'Chi').length) + ) + const loaded = grid.data.filter((row) => row.id > 0) + expect(loaded.length).toBeGreaterThan(0) + expect(loaded.every((row) => row.rep === 'Chi')).toBe(true) + }) + + it('applies a quick filter typed into the grid', async () => { + const grid = started(inlineSource()) + await vi.waitFor(() => expect(grid.data.length).toBeGreaterThan(0)) + + grid.api.setQuickFilter!('Chi') + + const model = grid.state.serverRowModel as { rowCount: number | null } + await vi.waitFor(() => + expect(model.rowCount).toBe(rows.filter((row) => row.rep === 'Chi').length) + ) + }) + + it('sorts through the source rather than on the client', async () => { + const grid = started(inlineSource()) + await vi.waitFor(() => expect(grid.data.length).toBeGreaterThan(0)) + + grid.api.setSort!([{ columnId: 'total', direction: 'desc' }]) + await vi.waitFor(() => expect(grid.data[0]?.total).toBe(999)) + }) + + it('refuses to group until the app says what a group row looks like', async () => { + const source = inlineSource() + await expect( + source.getRows({ + startRow: 0, + endRow: 10, + sortModel: [], + filterModel: { quick: '', quickFields: [], columns: {} }, + groupKeys: [], + groupBy: ['rep'] + }) + ).rejects.toThrow(/groupRow/) + }) + + it('counts the groups, and hands back the rows under one when it is expanded', async () => { + const source = workerDataSource(rows, { + columns, + inline: true, + groupRow: (keys, count) => + ({ id: -1, rep: String(keys.at(-1)), total: count, closedAt: '' }) as Sale + }) + + const top = await source.getRows({ + startRow: 0, + endRow: 10, + sortModel: [], + filterModel: { quick: '', quickFields: [], columns: {} }, + groupKeys: [], + groupBy: ['rep'] + }) + + expect(top.rowCount).toBe(4) + expect(top.rows.map((row) => row.rep)).toEqual(['An', 'Binh', 'Chi', 'Dung']) + expect(top.rows.reduce((sum, row) => sum + row.total, 0)).toBe(rows.length) + + const children = await source.getRows({ + startRow: 0, + endRow: 5, + sortModel: [], + filterModel: { quick: '', quickFields: [], columns: {} }, + groupKeys: ['Chi'], + groupBy: ['rep'] + }) + + expect(children.rowCount).toBe(rows.filter((row) => row.rep === 'Chi').length) + expect(children.rows.every((row) => row.rep === 'Chi')).toBe(true) + }) + + it('reads a column through its accessor rather than refusing it', async () => { + const withAccessor: ColumnDef[] = [ + ...columns, + { id: 'initial', header: 'Initial', accessor: (row) => row.rep.slice(0, 1) } + ] + const source = workerDataSource(rows, { columns: withAccessor, inline: true }) + + const result = await source.getRows({ + startRow: 0, + endRow: 500, + sortModel: [], + filterModel: { + quick: '', + quickFields: [], + columns: { + initial: { + join: 'and', + conditions: [{ kind: 'text', op: 'equals', value: 'C' }] + } + } + }, + groupKeys: [], + groupBy: [] + }) + + expect(result.rowCount).toBe(rows.filter((row) => row.rep === 'Chi').length) + }) + + it('keeps a column sorting by its own comparator', async () => { + const order = ['Dung', 'Chi', 'Binh', 'An'] + const withSortFn: ColumnDef[] = columns.map((column) => + column.id === 'rep' + ? { + ...column, + sortFn: (a: Sale, b: Sale) => order.indexOf(a.rep) - order.indexOf(b.rep) + } + : column + ) + const source = workerDataSource(rows, { columns: withSortFn, inline: true }) + + const result = await source.getRows({ + startRow: 0, + endRow: 1, + sortModel: [{ columnId: 'rep', direction: 'asc' }], + filterModel: { quick: '', quickFields: [], columns: {} }, + groupKeys: [], + groupBy: [] + }) + + expect(result.rows[0]!.rep).toBe('Dung') + }) + + it('runs a column filter predicate the app wrote', async () => { + const withPredicate: ColumnDef[] = columns.map((column) => + column.id === 'total' + ? { + ...column, + filter: { + type: 'number' as const, + predicate: (value: unknown) => Number(value) % 100 === 0 + } + } + : column + ) + const source = workerDataSource(rows, { columns: withPredicate, inline: true }) + + const result = await source.getRows({ + startRow: 0, + endRow: 500, + sortModel: [], + filterModel: { + quick: '', + quickFields: [], + columns: { + total: { join: 'and', conditions: [{ kind: 'number', op: 'gt', value: 0 }] } + } + }, + groupKeys: [], + groupBy: [] + }) + + expect(result.rowCount).toBe(rows.filter((row) => row.total % 100 === 0).length) + }) + + it('searches the text an app says a column is drawn with', async () => { + const source = workerDataSource(rows, { + columns, + inline: true, + searchText: (value, column) => + column.id === 'total' ? `$${Number(value).toLocaleString('en-US')}` : undefined + }) + + const result = await source.getRows({ + startRow: 0, + endRow: 500, + sortModel: [], + filterModel: { quick: '$999', quickFields: ['total'], columns: {} }, + groupKeys: [], + groupBy: [] + }) + + expect(result.rowCount).toBe(rows.filter((row) => row.total === 999).length) + expect(result.rowCount).toBeGreaterThan(0) + }) + + it('says plainly when it is not on a worker', () => { + expect(inlineSource().usingWorker).toBe(false) + }) +}) diff --git a/src/lib/features/worker-row-model/worker-row-model.ts b/src/lib/features/worker-row-model/worker-row-model.ts new file mode 100644 index 0000000..5b19709 --- /dev/null +++ b/src/lib/features/worker-row-model/worker-row-model.ts @@ -0,0 +1,331 @@ +import { type ColumnDef } from '../../core/types/index.js' +import { getCellValue } from '../../core/utils/index.js' +import type { DataSource, GetRowsRequest, GetRowsResult } from '../server-row-model/index.js' +import { type ColumnValues, isBlank } from './columnar.js' +import { createEngine } from './engine.js' +import type { FromWorker, QueryMessage, ResultMessage, ToWorker } from './protocol.js' +import type { QueryOverrides, WorkerQuery } from './query.js' +import type { WorkerDataSource, WorkerDataSourceOptions } from './worker-row-model.types.js' + +const CHUNK = 8_000 + +function closureColumns(columns: ColumnDef[]): Set { + const named = new Set() + for (const column of columns) { + const def = column as unknown as Record + const filter = column.filter + const custom = + typeof def.sortFn === 'function' || + (typeof filter === 'object' && typeof filter.predicate === 'function') + if (custom) named.add(column.id) + } + return named +} + +function dateColumnsOf(columns: ColumnDef[]): string[] { + return columns + .filter((column) => column.type === 'date' || column.type === 'datetime') + .map((column) => column.id) +} + +function queryOf( + request: GetRowsRequest, + dateColumns: string[], + nulls: 'first' | 'last' +): WorkerQuery { + return { + filter: request.filterModel, + sort: request.sortModel, + advancedFilter: request.advancedFilter ?? null, + dateColumns, + nulls, + groupBy: request.groupBy, + groupKeys: request.groupKeys + } +} + +function spawn(): Worker | null { + if (typeof Worker === 'undefined') return null + try { + return new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }) + } catch { + return null + } +} + +const MAX_PLAIN_DEPTH = 32 + +function plain(value: T, depth = 0): T { + if (value === null || typeof value !== 'object') return value + if (depth >= MAX_PLAIN_DEPTH) return null as T + if (value instanceof Date) return new Date(value.getTime()) as T + if (Array.isArray(value)) return value.map((entry) => plain(entry, depth + 1)) as T + + const copy: Record = {} + for (const [key, entry] of Object.entries(value)) copy[key] = plain(entry, depth + 1) + return copy as T +} + +function postable(message: ToWorker): ToWorker { + return message.type === 'query' ? { ...message, query: plain(message.query) } : plain(message) +} + +const yieldToPaint = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) + +export function workerDataSource( + rows: TRow[], + options: WorkerDataSourceOptions +): DataSource & WorkerDataSource { + const readable = options.columns.filter((column) => column.type !== 'actions') + const withClosures = closureColumns(options.columns) + const dateColumns = dateColumnsOf(options.columns) + const nulls = options.nulls ?? 'first' + const readValue = Object.hasOwn(options, 'readValue') ? options.readValue : undefined + + let loaded = rows + const empty = new Set() + const warned = new Set() + + function valueAt(index: number, column: ColumnDef): unknown { + const row = loaded[index]! + const supplied = readValue?.(row, column) + return supplied === undefined ? getCellValue(row, column) : supplied + } + + function chunkOf(from: number, to: number): { values: ColumnValues; texts: ColumnValues } { + const values: ColumnValues = {} + const texts: ColumnValues = {} + + for (const column of readable) { + const slice = new Array(to - from) + const drawn = options.searchText ? new Array(to - from) : null + + for (let index = from; index < to; index++) { + const value = valueAt(index, column) + slice[index - from] = value + if (!isBlank(value)) empty.delete(column.id) + if (drawn) drawn[index - from] = options.searchText!(value, column) ?? '' + } + + values[column.id] = slice + if (drawn) texts[column.id] = drawn + } + + return { values, texts } + } + + function warnEmpty(request: GetRowsRequest): void { + const asked = [ + ...request.sortModel.map((entry) => entry.columnId), + ...Object.keys(request.filterModel.columns) + ] + + for (const columnId of asked) { + if (!empty.has(columnId) || warned.has(columnId)) continue + warned.add(columnId) + // eslint-disable-next-line no-console + console.warn( + `workerDataSource loaded no value for "${columnId}", so filtering or sorting ` + + 'by it answers from nothing. A computed column - a formula column above all - ' + + 'is worked out on the client after the worker has answered: pass readValue to ' + + 'give the worker the same value, or leave the column out of the query.' + ) + } + } + + function overridesFor(query: WorkerQuery): QueryOverrides { + const comparators: Record number> = {} + const predicates: Record boolean> = {} + + for (const column of options.columns) { + if (!withClosures.has(column.id)) continue + + const def = column as unknown as Record + const sortFn = def.sortFn as ((a: TRow, b: TRow) => number) | undefined + if (sortFn) comparators[column.id] = (a, b) => sortFn(loaded[a]!, loaded[b]!) + + const filter = column.filter + const entry = query.filter.columns[column.id] + if (typeof filter === 'object' && typeof filter.predicate === 'function' && entry) { + const predicate = filter.predicate + predicates[column.id] = (index) => { + const row = loaded[index]! + const value = getCellValue(row, column) + const answers = entry.conditions.map((condition) => + predicate(value, row, condition) + ) + return entry.join === 'or' ? answers.some(Boolean) : answers.every(Boolean) + } + } + } + + return { comparators, predicates } + } + + let worker = options.inline ? null : spawn() + let disposed = false + let generation = 0 + const local = createEngine(overridesFor) + const pending = new Map void>() + let nextId = 0 + + function abandon(reason: string): void { + for (const [id, settle] of pending) settle({ type: 'failure', id, message: reason }) + pending.clear() + } + + if (worker) { + worker.onmessage = (event: MessageEvent) => { + const settle = pending.get(event.data.id) + if (!settle) return + pending.delete(event.data.id) + settle(event.data) + } + + worker.onerror = (event) => { + const reason = event.message || 'workerDataSource: the worker stopped.' + worker?.terminate() + worker = null + abandon(reason) + ready = ready ? start() : null + options.onFallback?.(reason) + } + } + + function send(message: ToWorker): void { + if (worker) worker.postMessage(postable(message)) + if (!worker || withClosures.size > 0) local(message) + } + + async function load(): Promise { + const fields = readable.map((column) => column.id) + for (const field of fields) empty.add(field) + send({ + type: 'load', + fields, + textFields: options.searchText ? fields : [], + rowCount: loaded.length + }) + + for (let from = 0; from < loaded.length; from += CHUNK) { + const to = Math.min(from + CHUNK, loaded.length) + send({ type: 'chunk', from, ...chunkOf(from, to) }) + if (to < loaded.length) await yieldToPaint() + } + + send({ type: 'loaded' }) + } + + function start(): Promise { + const attempt = load() + attempt.catch(() => {}) + return attempt + } + + let ready: Promise | null = null + + function needsLocal(request: GetRowsRequest): boolean { + if (!worker) return true + if (withClosures.size === 0) return false + if (request.sortModel.some((entry) => withClosures.has(entry.columnId))) return true + return Object.keys(request.filterModel.columns).some((id) => withClosures.has(id)) + } + + async function ask(message: QueryMessage): Promise { + const reply = await new Promise((resolve) => { + pending.set(message.id, resolve) + worker!.postMessage(postable(message)) + }) + if (reply.type === 'failure') throw new Error(reply.message) + return reply + } + + function refuse(request: GetRowsRequest): string | null { + if (disposed) return 'workerDataSource: the source was disposed.' + if (request.groupBy.length > 0 && !options.groupRow) { + return ( + 'workerDataSource was asked for grouped rows without a groupRow option: it ' + + 'counts the groups, but only the app knows what one of its own rows looks ' + + 'like. Pass groupRow: (keys, count) => row.' + ) + } + return null + } + + function resultOf(reply: ResultMessage, from: TRow[]): GetRowsResult { + if (reply.groups) { + return { + rows: reply.groups.map((group) => options.groupRow!(group.keys, group.count)), + rowCount: reply.rowCount + } + } + return { rows: rowsAt(reply.indices, from), rowCount: reply.rowCount } + } + + function rowsAt(indices: number[], from: TRow[]): TRow[] { + const answer: TRow[] = [] + for (const index of indices) { + const row = from[index] + if (row !== undefined) answer.push(row) + } + return answer + } + + return { + get usingWorker() { + return worker !== null + }, + + setRows(next: TRow[]) { + if (disposed) return + generation += 1 + loaded = next + abandon('workerDataSource: the rows were replaced while this query was in flight.') + ready = ready ? start() : null + }, + + dispose() { + disposed = true + ready = null + generation += 1 + worker?.terminate() + worker = null + abandon('workerDataSource: the source was disposed.') + local({ type: 'load', fields: [], textFields: [], rowCount: 0 }) + local({ type: 'loaded' }) + }, + + async getRows(request: GetRowsRequest): Promise> { + const refused = refuse(request) + if (refused) throw new Error(refused) + + const asked = generation + const rowsWhenAsked = loaded + await (ready ??= start()) + warnEmpty(request) + + const message: QueryMessage = { + type: 'query', + id: nextId++, + startRow: request.startRow, + endRow: request.endRow, + query: queryOf(request, dateColumns, nulls) + } + + const answered = needsLocal(request) ? local(message) : await ask(message) + if (!answered) throw new Error('workerDataSource: the query was never answered.') + if (answered.type === 'failure') throw new Error(answered.message) + if (disposed) throw new Error('workerDataSource: the source was disposed.') + if (asked !== generation) { + throw new Error( + 'workerDataSource: the rows changed while this query was in flight.' + ) + } + + return resultOf(answered, rowsWhenAsked) + } + } +} diff --git a/src/lib/features/worker-row-model/worker-row-model.types.ts b/src/lib/features/worker-row-model/worker-row-model.types.ts new file mode 100644 index 0000000..5f1b3e4 --- /dev/null +++ b/src/lib/features/worker-row-model/worker-row-model.types.ts @@ -0,0 +1,99 @@ +import { type ColumnDef } from '../../core/types/index.js' + +/** + * How a `workerDataSource` reads the rows it was handed. + * + * Every option here has to survive `postMessage`, which is the whole + * constraint the feature lives under: a function cannot cross to a worker, so + * a column carrying one is rejected when the store is built rather than + * answered from the raw field behind its back. + */ +export interface WorkerDataSourceOptions { + /** + * The grid's columns. Their ids name the fields loaded into the worker, + * and `type: 'date' | 'datetime'` is what makes a column sort as an + * instant rather than as text, matching how the grid sorts it. + */ + columns: ColumnDef[] + + /** + * Where blank cells go when a sort has to place them. + * + * @default 'first' + */ + nulls?: 'first' | 'last' + + /** + * The value to load for a cell, when reading the row cannot produce it. + * + * A computed column - a formula column is the case that matters - holds no + * value on the row: the grid works it out while the pipeline runs, on the + * client, after the worker has already answered. Filtering or sorting by + * such a column would otherwise be answered from nothing. Return + * `undefined` to read the row the ordinary way. + * + * @default undefined + */ + readValue?: (row: TRow, column: ColumnDef) => unknown + + /** + * The text a quick filter should search for a cell, when the value itself + * is not what the user sees. The grid searches both the value and the text + * it draws - a currency cell answers to `1234.5` and to `1,234.50` - and a + * worker has no way to reach the formatter that produced the second, so + * this is where an app supplies it. Return `undefined` to search the value. + * + * @default undefined + */ + searchText?: (value: unknown, column: ColumnDef) => string | undefined + + /** + * Builds one group row from the keys identifying it and the number of rows + * beneath it. Required before the grid may group: the worker counts the + * groups, but only the app knows what one of its own rows looks like, and + * `getRowMeta` is what turns the result into a group row with a chevron. + * + * @default undefined + */ + groupRow?: (keys: unknown[], count: number) => TRow + + /** + * Called when the source gives up on its worker and answers on the calling + * thread instead - the worker threw, or never started. `usingWorker` is a + * plain getter, so this is what a component needs to know the badge it drew + * is now wrong. + * + * @default undefined + */ + onFallback?: (reason: string) => void + + /** + * Run the query on the calling thread instead of in a worker. The answers + * are identical - it is the same module either way - so this is for + * measuring what the worker is worth, and for an environment that has no + * `Worker` at all. + * + * @default false + */ + inline?: boolean +} + +/** What a `workerDataSource` adds to the `DataSource` contract it implements. */ +export interface WorkerDataSource { + /** + * Whether the query really is running off the main thread. False when the + * environment has no `Worker`, when constructing one threw, or when + * `inline` asked for it - the grid still works, without the frame budget. + */ + readonly usingWorker: boolean + + /** + * Replaces the loaded rows and rebuilds the columnar store, in chunks, so + * no single frame is spent on it. Call it after the app's array changes - + * an edit committed on the client does not reach the store on its own. + */ + setRows(rows: TRow[]): void + + /** Terminates the worker. The source answers nothing afterwards. */ + dispose(): void +} diff --git a/src/lib/features/worker-row-model/worker.ts b/src/lib/features/worker-row-model/worker.ts new file mode 100644 index 0000000..dba5adf --- /dev/null +++ b/src/lib/features/worker-row-model/worker.ts @@ -0,0 +1,9 @@ +import { createEngine } from './engine.js' +import type { ToWorker } from './protocol.js' + +const engine = createEngine() + +self.onmessage = (event: MessageEvent) => { + const answer = engine(event.data) + if (answer) self.postMessage(answer) +} diff --git a/src/lib/features/xlsx/conditional-format.test.ts b/src/lib/features/xlsx/conditional-format.test.ts new file mode 100644 index 0000000..650b8d3 --- /dev/null +++ b/src/lib/features/xlsx/conditional-format.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import type { FormatRule } from '../conditional-formatting/index.js' +import { + conditionalFormatXml, + DEFAULT_FORMAT_COLORS, + toArgb, + xlsxFormatRules, + type XlsxFormatContext +} from './conditional-format.js' + +function context(over: Partial = {}): XlsxFormatContext { + let next = 0 + return { + letterOf: (columnId) => ({ amount: 'C', email: 'D' })[columnId], + rowCount: 3, + colors: DEFAULT_FORMAT_COLORS, + addDxf: () => next++, + ...over + } +} + +const xmlOf = (rules: FormatRule[], over: Partial = {}) => + conditionalFormatXml(xlsxFormatRules(rules, context(over))) + +describe('toArgb', () => { + it('reads the hex shapes a rule may carry', () => { + expect(toArgb('#3B82F6', '#000000')).toBe('FF3B82F6') + expect(toArgb('#abc', '#000000')).toBe('FFAABBCC') + expect(toArgb('80FF0000', '#000000')).toBe('80FF0000') + expect(toArgb('rgb(59, 130, 246)', '#000000')).toBe('FF3B82F6') + }) + + it('falls back for a colour only a browser can resolve', () => { + expect(toArgb('var(--color-primary)', '#3B82F6')).toBe('FF3B82F6') + expect(toArgb('color-mix(in oklab, red 40%, transparent)', '#3B82F6')).toBe('FF3B82F6') + expect(toArgb(undefined, '#3B82F6')).toBe('FF3B82F6') + }) +}) + +describe('rules Excel has an equivalent for', () => { + it('writes a colour scale over the column, letting Excel compute the ramp', () => { + const xml = xmlOf([{ kind: 'colorScale', column: 'amount', from: '#fff', to: '#0a7' }]) + + expect(xml).toContain('sqref="C2:C4"') + expect(xml).toContain('type="colorScale"') + expect(xml).toContain('') + expect(xml).toContain('') + }) + + it('adds the middle stop only when the rule has one', () => { + const xml = xmlOf([ + { kind: 'colorScale', column: 'amount', from: '#f00', via: '#fff', to: '#0f0' } + ]) + expect(xml).toContain('') + }) + + it('pins the ends when the rule pinned them', () => { + const xml = xmlOf([{ kind: 'colorScale', column: 'amount', min: 0, max: 100 }]) + expect(xml).toContain('') + }) + + it('writes a data bar Excel draws itself', () => { + const xml = xmlOf([{ kind: 'dataBar', column: 'amount', color: '#638EC6' }]) + expect(xml).toContain('type="dataBar"') + expect(xml).toContain('') + }) + + it('maps duplicates and uniques onto the two rule types Excel has', () => { + expect(xmlOf([{ kind: 'duplicates', column: 'email' }])).toContain('type="duplicateValues"') + expect(xmlOf([{ kind: 'duplicates', column: 'email', unique: true }])).toContain( + 'type="uniqueValues"' + ) + }) + + it('carries the rank and the direction of a topN rule', () => { + expect(xmlOf([{ kind: 'topN', column: 'amount', n: 5 }])).toContain('rank="5"') + expect(xmlOf([{ kind: 'topN', column: 'amount', bottom: true }])).toContain('bottom="1"') + }) +}) + +describe('what the file cannot carry', () => { + it('leaves an expression rule out, Excel having no equivalent to translate to', () => { + expect(xmlOf([{ kind: 'expression', column: 'amount', when: 'amount > 1' }])).toBe('') + }) + + it('leaves out a rule naming a column the export does not include', () => { + expect(xmlOf([{ kind: 'dataBar', column: 'hidden' }])).toBe('') + }) + + it('writes nothing for an empty sheet', () => { + expect(xmlOf([{ kind: 'dataBar', column: 'amount' }], { rowCount: 0 })).toBe('') + }) +}) + +describe('priority: on screen the later rule wins, in Excel the lower number does', () => { + it('gives the last rule the number Excel resolves first', () => { + const xml = xmlOf([ + { kind: 'dataBar', column: 'amount' }, + { kind: 'colorScale', column: 'amount' } + ]) + const priorities = [...xml.matchAll(/priority="(\d+)"/g)].map((match) => match[1]) + + expect(priorities).toEqual(['2', '1']) + }) +}) diff --git a/src/lib/features/xlsx/conditional-format.ts b/src/lib/features/xlsx/conditional-format.ts new file mode 100644 index 0000000..76de3ea --- /dev/null +++ b/src/lib/features/xlsx/conditional-format.ts @@ -0,0 +1,180 @@ +import type { FormatRule } from '../conditional-formatting/index.js' +import type { XlsxColor, XlsxStyle } from './styles.types.js' + +export interface XlsxFormatColors { + scaleFrom?: XlsxColor + scaleTo?: XlsxColor + bar?: XlsxColor + highlightFill?: XlsxColor + highlightText?: XlsxColor +} + +export const DEFAULT_FORMAT_COLORS = { + scaleFrom: '#FFFFFF', + scaleTo: '#3B82F6', + bar: '#638EC6', + highlightFill: '#FFEB9C', + highlightText: '#9C6500' +} as const satisfies Required + +export type XlsxCfRule = + | { kind: 'colorScale'; sqref: string; stops: string[]; bounds: string } + | { kind: 'dataBar'; sqref: string; color: string; bounds: string } + | { kind: 'values'; sqref: string; dxfId: number; unique: boolean } + | { kind: 'top10'; sqref: string; dxfId: number; rank: number; bottom: boolean } + +const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i +const RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i + +function expand(hex: string): string { + if (hex.length === 3) return [...hex].map((part) => part + part).join('') + return hex +} + +export function toArgb(color: string | undefined, fallback: XlsxColor): string { + const resolved = color === undefined ? null : argbOf(color) + return resolved ?? argbOf(fallback) ?? 'FF000000' +} + +function argbOf(color: string): string | null { + const hex = HEX.exec(color.trim()) + if (hex) { + const body = expand(hex[1]!).toUpperCase() + return body.length === 8 ? body : `FF${body}` + } + + const rgb = RGB.exec(color.trim()) + if (!rgb) return null + + const channels = [rgb[1], rgb[2], rgb[3]].map((part) => + Math.max(0, Math.min(255, Number(part))) + .toString(16) + .toUpperCase() + .padStart(2, '0') + ) + return `FF${channels.join('')}` +} + +const cfvo = (bound: number | undefined, fallback: 'min' | 'max'): string => + bound === undefined ? `` : `` + +function boundsOf(rule: { min?: number; max?: number }, middle: boolean): string { + const low = cfvo(rule.min, 'min') + const high = cfvo(rule.max, 'max') + return middle ? `${low}${high}` : `${low}${high}` +} + +export const HIGHLIGHT_STYLE = (colors: Required): XlsxStyle => ({ + font: { color: colors.highlightText }, + fill: colors.highlightFill +}) + +export interface XlsxFormatContext { + letterOf: (columnId: string) => string | undefined + rowCount: number + colors: Required + addDxf: (style: XlsxStyle) => number +} + +function ruleXml(rule: XlsxCfRule, priority: number): string { + const head = `${rule.bounds}${rule.stops + .map((stop) => ``) + .join('')}` + case 'dataBar': + return `${head}>${rule.bounds}` + case 'values': + return `${head} dxfId="${rule.dxfId}"/>` + case 'top10': + return `${head} dxfId="${rule.dxfId}" rank="${rule.rank}"${ + rule.bottom ? ' bottom="1"' : '' + }/>` + } +} + +function cfType(rule: XlsxCfRule): string { + switch (rule.kind) { + case 'colorScale': + return 'colorScale' + case 'dataBar': + return 'dataBar' + case 'values': + return rule.unique ? 'uniqueValues' : 'duplicateValues' + case 'top10': + return 'top10' + } +} + +export function conditionalFormatXml(rules: readonly XlsxCfRule[]): string { + return rules + .map( + (rule, index) => + `` + + ruleXml(rule, rules.length - index) + + '' + ) + .join('') +} + +export function xlsxFormatRules( + rules: readonly FormatRule[], + context: XlsxFormatContext +): XlsxCfRule[] { + if (context.rowCount === 0) return [] + + const out: XlsxCfRule[] = [] + for (const rule of rules) { + if (rule.kind === 'expression' || rule.column === undefined) continue + + const letter = context.letterOf(rule.column) + if (!letter) continue + + const sqref = `${letter}2:${letter}${context.rowCount + 1}` + out.push(...cfRuleOf(rule, sqref, context)) + } + return out +} + +function cfRuleOf(rule: FormatRule, sqref: string, context: XlsxFormatContext): XlsxCfRule[] { + const { colors } = context + switch (rule.kind) { + case 'colorScale': { + const stops = [toArgb(rule.from, colors.scaleFrom)] + if (rule.via) stops.push(toArgb(rule.via, colors.scaleFrom)) + stops.push(toArgb(rule.to, colors.scaleTo)) + return [{ kind: 'colorScale', sqref, stops, bounds: boundsOf(rule, Boolean(rule.via)) }] + } + case 'dataBar': + return [ + { + kind: 'dataBar', + sqref, + color: toArgb(rule.color, colors.bar), + bounds: boundsOf(rule, false) + } + ] + case 'duplicates': + return [ + { + kind: 'values', + sqref, + dxfId: context.addDxf(HIGHLIGHT_STYLE(colors)), + unique: rule.unique === true + } + ] + case 'topN': + return [ + { + kind: 'top10', + sqref, + dxfId: context.addDxf(HIGHLIGHT_STYLE(colors)), + rank: Math.max(1, Math.round(rule.n ?? 10)), + bottom: rule.bottom === true + } + ] + default: + return [] + } +} diff --git a/src/lib/features/xlsx/export-rows.test.ts b/src/lib/features/xlsx/export-rows.test.ts new file mode 100644 index 0000000..1223678 --- /dev/null +++ b/src/lib/features/xlsx/export-rows.test.ts @@ -0,0 +1,322 @@ +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { pagination } from '../../features/pagination/index.js' +import { describe, expect, it } from 'vitest' +import { conditionalFormatting, type FormatRule } from '../conditional-formatting/index.js' +import { formula } from '../formula/index.js' +import { grouping } from '../grouping/index.js' +import { masterDetail } from '../master-detail/index.js' +import { serverRowModel } from '../server-row-model/index.js' +import { tree } from '../tree/index.js' +import { buildGridXlsx, buildGridXlsxAsync } from './index.js' + +interface Person { + id: number + dept: string + salary: number +} + +const people: Person[] = [ + { id: 1, dept: 'Core', salary: 100 }, + { id: 2, dept: 'Core', salary: 200 }, + { id: 3, dept: 'Data', salary: 300 } +] + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept' }, + { id: 'salary', header: 'Salary' } +] + +const sheetOf = (bytes: Uint8Array) => new TextDecoder().decode(bytes) + +const rowCount = (bytes: Uint8Array) => (sheetOf(bytes).match(/(-?[\d.]+)<\/v>/g)].map((match) => Number(match[1])) +} + +describe('an export carries data rows and nothing else', () => { + function groupedGrid(extra: { groupFooters?: boolean; grandTotal?: boolean } = {}) { + return createDataGrid({ + columns, + data: people, + getRowId: (row) => String(row.id), + features: [ + grouping({ + by: ['dept'], + aggregations: { salary: 'sum' }, + ...extra + }) + ] + }) + } + + it('leaves group header rows out of the file', () => { + const grid = groupedGrid() + expect(grid.nodes.length).toBeGreaterThan(people.length) + + expect(rowCount(buildGridXlsx(grid))).toBe(people.length + 1) + }) + + it('leaves footers and the grand total out too', () => { + const grid = groupedGrid({ groupFooters: true, grandTotal: true }) + expect(grid.nodes.length).toBe(8) + + const bytes = buildGridXlsx(grid) + expect(rowCount(bytes)).toBe(people.length + 1) + expect(numbersIn(bytes)).toEqual([100, 200, 300]) + }) + + it('adds up in a spreadsheet to what the data adds up to', () => { + const grid = groupedGrid({ groupFooters: true, grandTotal: true }) + const total = numbersIn(buildGridXlsx(grid)).reduce((sum, value) => sum + value, 0) + + expect(total).toBe(600) + }) + + it('writes the grouped column as its value, not as a group label', () => { + const bytes = buildGridXlsx(groupedGrid()) + const text = sheetOf(bytes) + + expect(text).not.toContain('Core (2)') + expect(text).not.toContain('Total') + expect(text).toContain('Core') + }) + + it('carries the children of a nested tree, which the file used to lose', () => { + interface Node { + id: number + dept: string + salary: number + reports?: Node[] + } + const nested: Node[] = [ + { id: 1, dept: 'Core', salary: 100, reports: [{ id: 2, dept: 'Core', salary: 250 }] } + ] + const grid = createDataGrid({ + columns: columns as unknown as ColumnDef[], + data: nested, + getRowId: (row) => String(row.id), + features: [tree({ getChildren: (row) => row.reports, defaultExpandedDepth: 2 })] + }) + expect(grid.nodes.length).toBe(2) + + const bytes = buildGridXlsx(grid) + expect(rowCount(bytes)).toBe(3) + expect(numbersIn(bytes)).toEqual([100, 250]) + }) + + it('still leaves a detail panel out, as it always did', () => { + const grid = createDataGrid({ + columns, + data: people, + getRowId: (row) => String(row.id), + features: [masterDetail()] + }) + grid.expansion.expand('1') + expect(grid.nodes.length).toBe(people.length + 1) + + expect(rowCount(buildGridXlsx(grid))).toBe(people.length + 1) + }) +}) + +describe('an export carries the conditional formatting as Excel rules', () => { + function formattedGrid(rules: FormatRule[]) { + const grid = createDataGrid({ + columns, + data: people, + getRowId: (row) => String(row.id), + features: [conditionalFormatting({ rules })] + }) + void grid.preWindowNodes + return grid + } + + it('writes the rule against the column it names, over the rows it wrote', () => { + const bytes = buildGridXlsx( + formattedGrid([{ kind: 'colorScale', column: 'salary', from: '#fff', to: '#0a7' }]) + ) + const sheet = sheetOf(bytes) + + expect(sheet).toContain('') + expect(sheet).toContain('') + }) + + it('puts the rules after the autofilter, where the schema wants them', () => { + const sheet = sheetOf(buildGridXlsx(formattedGrid([{ kind: 'dataBar', column: 'salary' }]))) + + expect(sheet.indexOf('')) + }) + + it('carries the differential format a highlight rule points at', () => { + const bytes = buildGridXlsx(formattedGrid([{ kind: 'duplicates', column: 'dept' }])) + + expect(sheetOf(bytes)).toContain('dxfId="0"') + expect(sheetOf(bytes)).toContain('') + }) + + it('covers the rows a server export fetched, not the page on screen', async () => { + const TOTAL = 25 + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + pagination({ pageSize: 10 }), + serverRowModel({ + getRows: async ({ startRow, endRow }) => ({ + rows: Array.from( + { length: Math.max(0, Math.min(endRow, TOTAL) - startRow) }, + (_, i) => ({ id: startRow + i, dept: 'Core', salary: startRow + i }) + ), + rowCount: TOTAL + }) + }), + conditionalFormatting({ + rules: [{ kind: 'colorScale', column: 'salary' }] + }) + ] + }) + grid.feature<{ refresh: () => void }>('serverRowModel')!.refresh() + await expect.poll(() => grid.data.length).toBe(10) + + const sheet = await inflateSheet(await buildGridXlsxAsync(grid)) + + expect(sheet).toContain(`sqref="B2:B${TOTAL + 1}"`) + }) + + it('follows the export order when the caller reorders the columns', () => { + const bytes = buildGridXlsx(formattedGrid([{ kind: 'dataBar', column: 'salary' }]), { + columnIds: ['salary', 'dept'] + }) + + expect(sheetOf(bytes)).toContain('sqref="A2:A4"') + }) + + it('leaves the file alone when the grid has no rules or the caller says no', () => { + expect(sheetOf(buildGridXlsx(formattedGrid([])))).not.toContain('conditionalFormatting') + + const off = buildGridXlsx(formattedGrid([{ kind: 'dataBar', column: 'salary' }]), { + conditionalFormatting: false + }) + expect(sheetOf(off)).not.toContain('conditionalFormatting') + expect(sheetOf(off)).not.toContain(' { + const bytes = buildGridXlsx(formattedGrid([{ kind: 'dataBar', column: 'salary' }]), { + conditionalFormatting: { bar: '#112233' } + }) + expect(sheetOf(bytes)).toContain('') + }) +}) + +describe('a computed column survives the trip through a data source', () => { + interface Line { + id: number + a: number + b: number + total?: number + } + + const TOTAL = 40 + + function serverGrid() { + const grid = createDataGrid({ + columns: [ + { id: 'a', header: 'A' }, + { id: 'b', header: 'B' }, + { id: 'total', header: 'Total' } + ], + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + pagination({ pageSize: 10 }), + serverRowModel({ + getRows: async ({ startRow, endRow }) => ({ + rows: Array.from( + { length: Math.max(0, Math.min(endRow, TOTAL) - startRow) }, + (_, i) => ({ id: startRow + i, a: startRow + i + 1, b: 2 }) + ), + rowCount: TOTAL + }) + }), + formula({ columns: { total: 'a * b' } }) + ] + }) + grid.feature<{ refresh: () => void }>('serverRowModel')!.refresh() + return grid + } + + it('computes the formula over rows the export fetched itself', async () => { + const grid = serverGrid() + await expect.poll(() => grid.data.length).toBe(10) + + const rows = await pipeRows(grid) + expect(rows).toHaveLength(TOTAL) + expect(rows.every((row) => row.total === row.a * row.b)).toBe(true) + }) + + it('writes those values into the file rather than leaving the column blank', async () => { + const grid = serverGrid() + await expect.poll(() => grid.data.length).toBe(10) + + const bytes = await buildGridXlsxAsync(grid) + const sheet = await inflateSheet(bytes) + + expect((sheet.match(//) + expect(sheet).toContain('2') + expect(sheet).toContain(`${TOTAL * 2}`) + }) + + async function pipeRows(grid: ReturnType) { + const server = grid.feature<{ fetchAll: (o: object) => Promise }>('serverRowModel')! + const computed = grid.feature<{ + apply: (nodes: { id: string; row: Line; index: number }[]) => { + row: Line + }[] + }>('formula')! + + const fetched = await server.fetchAll({}) + return computed + .apply(fetched.map((row, index) => ({ id: String(row.id), row, index }))) + .map((node) => node.row) + } +}) + +async function inflateSheet(bytes: Uint8Array): Promise { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let end = bytes.length - 22 + while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end-- + + const count = view.getUint16(end + 10, true) + let offset = view.getUint32(end + 16, true) + const decoder = new TextDecoder() + + for (let i = 0; i < count; i++) { + const compressed = view.getUint32(offset + 20, true) + const nameLength = view.getUint16(offset + 28, true) + const localOffset = view.getUint32(offset + 42, true) + const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength)) + + if (name.includes('worksheets/sheet1')) { + const localNameLength = view.getUint16(localOffset + 26, true) + const extra = view.getUint16(localOffset + 28, true) + const start = localOffset + 30 + localNameLength + extra + const body = bytes.subarray(start, start + compressed) + + const stream = new Blob([body as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream('deflate-raw')) + return decoder.decode(await new Response(stream).arrayBuffer()) + } + offset += 46 + nameLength + } + throw new Error('no worksheet in the archive') +} diff --git a/src/lib/features/xlsx/export.ts b/src/lib/features/xlsx/export.ts new file mode 100644 index 0000000..3575743 --- /dev/null +++ b/src/lib/features/xlsx/export.ts @@ -0,0 +1,286 @@ +import { gateReader, type GridState, isDataRow } from '../../core/grid/index.js' +import { type ColumnState, type RowNode, SELECTION_COLUMN_ID } from '../../core/types/index.js' +import type { FormatRule } from '../conditional-formatting/index.js' +import { + DEFAULT_FORMAT_COLORS, + xlsxFormatRules, + type XlsxCfRule, + type XlsxFormatColors +} from './conditional-format.js' +import { type CellValue, columnLetter, type SheetColumn } from './sheet.js' +import { BUILT_IN_STYLES, StyleTable } from './styles.js' +import type { XlsxStyle } from './styles.types.js' +import { + deflatedWorkbookEntries, + workbookEntries, + XLSX_MIME, + type WorkbookOptions +} from './workbook.js' +import { createZip } from './zip.js' + +export interface ExportXlsxOptions { + filename?: string + + sheetName?: string + + columnIds?: string[] + + rows?: RowNode[] + + selectedOnly?: boolean + + freezeHeader?: boolean + + autoFilter?: boolean + + value?: (row: TRow, column: ColumnState) => CellValue | undefined + + headerStyle?: XlsxStyle + + columnStyle?: (column: ColumnState) => XlsxStyle | undefined + + cellStyle?: (value: CellValue, row: TRow, column: ColumnState) => XlsxStyle | undefined + + conditionalFormatting?: boolean | XlsxFormatColors + + loadedOnly?: boolean + + onProgress?: (loaded: number, total: number | null) => void + + batchSize?: number +} + +const SERVER_EXPORT_HINT = + 'xlsx: this grid uses rowModel: "server", so only the rows currently held in ' + + 'memory are available synchronously. Use await downloadGridXlsx(grid), which ' + + 'pages the data source for the whole set, or pass { loadedOnly: true } to ' + + 'export just what is on screen.' + +function isServerGrid(grid: GridState): boolean { + return grid.rowModel === 'server' +} + +function exportsWholeSet(grid: GridState, options: ExportXlsxOptions): boolean { + return isServerGrid(grid) && !options.rows && !options.loadedOnly && !options.selectedOnly +} + +function typeStyle(column: ColumnState): XlsxStyle { + switch (column.def.type) { + case 'currency': + return BUILT_IN_STYLES.currency + case 'percent': + return BUILT_IN_STYLES.percent + case 'date': + return BUILT_IN_STYLES.date + case 'datetime': + return BUILT_IN_STYLES.datetime + default: + return {} + } +} + +function merge(base: XlsxStyle, over: XlsxStyle | undefined): XlsxStyle { + if (!over) return base + return { + ...base, + ...over, + font: over.font || base.font ? { ...base.font, ...over.font } : undefined, + border: over.border || base.border ? { ...base.border, ...over.border } : undefined, + alignment: + over.alignment || base.alignment ? { ...base.alignment, ...over.alignment } : undefined + } +} + +function asDate(value: unknown, dateOnly: boolean): CellValue { + const date = value instanceof Date ? value : new Date(String(value)) + if (Number.isNaN(date.getTime())) return String(value) + return dateOnly ? new Date(date.getFullYear(), date.getMonth(), date.getDate()) : date +} + +function cellValueOf(value: unknown, column: ColumnState): CellValue { + if (value === null || value === undefined) return null + if (column.def.type === 'date') return asDate(value, true) + if (column.def.type === 'datetime') return asDate(value, false) + if (value instanceof Date) return value + + const primitive = + typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string' + return primitive ? (value as CellValue) : String(value) +} + +function exportColumns(grid: GridState, columnIds?: string[]): ColumnState[] { + const visible = grid.columns.visible.filter((column) => column.id !== SELECTION_COLUMN_ID) + if (!columnIds) return visible + + return columnIds.flatMap((id) => visible.filter((column) => column.id === id)) +} + +function loadedRows(grid: GridState, options: ExportXlsxOptions): TRow[] { + if (options.rows) return options.rows.map((node) => node.row) + + const isData = isDataRow + const nodes = grid.preWindowNodes.filter(isData) + if (!options.selectedOnly) return nodes.map((node) => node.row) + + const selection = grid.feature<{ isSelected: (id: string) => boolean }>('selection') + const picked = selection ? nodes.filter((node) => selection.isSelected(node.id)) : nodes + return picked.map((node) => node.row) +} + +function withComputedColumns(grid: GridState, rows: TRow[]): TRow[] { + const computed = grid.feature<{ + apply: (nodes: RowNode[]) => RowNode[] + }>('formula') + if (!computed) return rows + + const nodes = rows.map((row, index) => ({ id: grid.getRowId(row), row, index })) + return computed.apply(nodes).map((node) => node.row) +} + +async function exportRows( + grid: GridState, + options: ExportXlsxOptions +): Promise { + if (!exportsWholeSet(grid, options)) return loadedRows(grid, options) + + const server = grid.feature<{ + fetchAll: (options: { + batchSize?: number + onProgress?: ExportXlsxOptions['onProgress'] + }) => Promise + }>('serverRowModel') + if (!server) throw new Error(SERVER_EXPORT_HINT) + + const fetched = await server.fetchAll({ + batchSize: options.batchSize, + onProgress: options.onProgress + }) + return withComputedColumns(grid, fetched) +} + +function workbookOptions( + grid: GridState, + options: ExportXlsxOptions, + source: TRow[] +): WorkbookOptions { + const columns = exportColumns(grid, options.columnIds) + + const styles = new StyleTable() + const columnStyles = columns.map((column) => + merge(typeStyle(column), options.columnStyle?.(column)) + ) + + const sheetColumns: SheetColumn[] = columns.map((column, index) => ({ + header: column.header, + width: column.width ? Math.max(8, Math.round(column.width / 7)) : 16, + style: styles.add(columnStyles[index]!) + })) + + const read = gateReader(grid, 'export') + const rows = source.map((row, index) => + columns.map((column) => { + const override = options.value?.(row, column) + if (override !== undefined) return override + + const node = { id: grid.getRowId(row), row, index } + return cellValueOf(read(node, column.def), column) + }) + ) + + const perCell = options.cellStyle + const cellStyle = perCell + ? (row: number, index: number) => { + const column = columns[index] + const data = source[row] + if (!column || !data) return undefined + const extra = perCell(rows[row]?.[index] ?? null, data, column) + return extra ? styles.add(merge(columnStyles[index]!, extra)) : undefined + } + : undefined + + return { + columns: sheetColumns, + rows, + conditionalFormats: formatRulesOf({ + grid, + options, + columns, + rowCount: rows.length, + styles + }), + sheetName: options.sheetName ?? stripExtension(options.filename ?? 'export'), + freezeHeader: options.freezeHeader ?? true, + autoFilter: options.autoFilter ?? true, + headerStyle: styles.add(merge(BUILT_IN_STYLES.header, options.headerStyle)), + datetimeStyle: styles.add(BUILT_IN_STYLES.datetime), + cellStyle, + styles + } +} + +interface FormatRulesInput { + grid: GridState + options: ExportXlsxOptions + columns: ColumnState[] + rowCount: number + styles: StyleTable +} + +function formatRulesOf(input: FormatRulesInput): XlsxCfRule[] { + const { grid, options, columns, rowCount, styles } = input + if (options.conditionalFormatting === false) return [] + + const state = grid.feature<{ rules: readonly FormatRule[] }>('conditionalFormatting') + if (!state) return [] + + const letters = new Map(columns.map((column, index) => [column.id, columnLetter(index)])) + const overrides = + typeof options.conditionalFormatting === 'object' ? options.conditionalFormatting : {} + + return xlsxFormatRules(state.rules, { + letterOf: (columnId) => letters.get(columnId), + rowCount, + colors: { ...DEFAULT_FORMAT_COLORS, ...overrides }, + addDxf: (style) => styles.addDxf(style) + }) +} + +export function buildGridXlsx( + grid: GridState, + options: ExportXlsxOptions = {} +): Uint8Array { + if (exportsWholeSet(grid, options)) throw new Error(SERVER_EXPORT_HINT) + return createZip(workbookEntries(workbookOptions(grid, options, loadedRows(grid, options)))) +} + +function stripExtension(name: string): string { + return name.replace(/\.xlsx$/i, '') +} + +function withExtension(name: string): string { + return /\.xlsx$/i.test(name) ? name : `${name}.xlsx` +} + +export async function buildGridXlsxAsync( + grid: GridState, + options: ExportXlsxOptions = {} +): Promise { + const source = await exportRows(grid, options) + return createZip(await deflatedWorkbookEntries(workbookOptions(grid, options, source))) +} + +export async function downloadGridXlsx( + grid: GridState, + options: ExportXlsxOptions = {} +): Promise { + const bytes = await buildGridXlsxAsync(grid, options) + const blob = new Blob([bytes as BlobPart], { type: XLSX_MIME }) + const url = URL.createObjectURL(blob) + + const link = document.createElement('a') + link.href = url + link.download = withExtension(options.filename ?? 'export.xlsx') + link.click() + + setTimeout(() => URL.revokeObjectURL(url), 0) +} diff --git a/src/lib/features/xlsx/index.ts b/src/lib/features/xlsx/index.ts new file mode 100644 index 0000000..4a18ca4 --- /dev/null +++ b/src/lib/features/xlsx/index.ts @@ -0,0 +1,44 @@ +export { + buildGridXlsx, + buildGridXlsxAsync, + downloadGridXlsx, + type ExportXlsxOptions +} from './export.js' +export { + DEFAULT_FORMAT_COLORS, + type XlsxCfRule, + type XlsxFormatColors +} from './conditional-format.js' +export { + cellRef, + columnLetter, + DEFAULT_STYLE, + escapeCellText, + escapeXml, + isFormulaCell, + sheetXml, + toSerialDate, + type CellValue, + type SheetColumn, + type SheetOptions, + type StyleId, + type XlsxFormula +} from './sheet.js' +export { BUILT_IN_STYLES, StyleTable } from './styles.js' +export type { + XlsxAlignment, + XlsxBorder, + XlsxBorderSide, + XlsxColor, + XlsxFont, + XlsxStyle +} from './styles.types.js' +export { + createWorkbook, + sanitizeSheetName, + workbookEntries, + XLSX_MIME, + type WorkbookOptions, + type WorkbookSheet +} from './workbook.js' +export { createZip, crc32, deflateEntries, zipEntry, type ZipEntry } from './zip.js' diff --git a/src/lib/features/xlsx/sheet.ts b/src/lib/features/xlsx/sheet.ts new file mode 100644 index 0000000..006e893 --- /dev/null +++ b/src/lib/features/xlsx/sheet.ts @@ -0,0 +1,294 @@ +import { conditionalFormatXml, type XlsxCfRule } from './conditional-format.js' + +export interface XlsxFormula { + formula: string + value?: string | number | boolean | Date | null +} + +export type CellValue = string | number | boolean | Date | null | undefined | XlsxFormula + +export const DEFAULT_STYLE = 0 + +export type StyleId = number + +export interface SheetColumn { + header: string + + width?: number + style?: StyleId +} + +const MAX_ROWS = 1_048_576 +const MAX_COLUMNS = 16_384 + +const CELL_TEXT_LIMIT = 32_767 + +const FORMULA_TEXT_LIMIT = 8_192 + +export function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +const INVALID_XML = + /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? + `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_` + +export function escapeCellText(value: string): string { + return escapeXml( + value + .slice(0, CELL_TEXT_LIMIT) + .replace(X_ESCAPE_LITERAL, '_x005F_$1_') + .replace(INVALID_XML, xEscape) + ) +} + +export function columnLetter(index: number): string { + let letters = '' + let remaining = index + do { + letters = String.fromCharCode(65 + (remaining % 26)) + letters + remaining = Math.floor(remaining / 26) - 1 + } while (remaining >= 0) + return letters +} + +export function cellRef(row: number, column: number): string { + return `${columnLetter(column)}${row + 1}` +} + +export function toSerialDate(date: Date): number { + const utc = Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() + ) + return utc / 86_400_000 + 25569 +} + +const pad = (value: number, width = 2): string => String(value).padStart(width, '0') + +function isoLocal(date: Date): string { + const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + const hasTime = date.getHours() + date.getMinutes() + date.getSeconds() > 0 + return hasTime + ? `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + : day +} + +export function isFormulaCell(value: CellValue): value is XlsxFormula { + return ( + typeof value === 'object' && + value !== null && + !(value instanceof Date) && + typeof (value as XlsxFormula).formula === 'string' + ) +} + +function formulaText(cell: XlsxFormula, reference: string): string { + const expression = cell.formula.replace(INVALID_XML, '').trim().replace(/^=+/, '') + if (expression.length > FORMULA_TEXT_LIMIT) { + throw new RangeError( + `xlsx: the formula for ${reference} is ${expression.length} characters; Excel stops at ${FORMULA_TEXT_LIMIT}` + ) + } + return escapeXml(expression) +} + +function cachedXml(value: XlsxFormula['value']): string { + if (value === null || value === undefined) return '' + if (typeof value === 'number') return Number.isFinite(value) ? `${value}` : '' + if (typeof value === 'boolean') return `${value ? 1 : 0}` + if (value instanceof Date) { + const serial = toSerialDate(value) + return Number.isNaN(serial) ? '' : `${serial}` + } + return `${escapeCellText(value)}` +} + +function cachedType(value: XlsxFormula['value']): string { + if (typeof value === 'boolean') return ' t="b"' + if (typeof value === 'string') return ' t="str"' + return '' +} + +function formulaXml(attributes: string, cell: XlsxFormula, reference: string): string { + const expression = formulaText(cell, reference) + if (expression === '') return `` + + const cached = cachedXml(cell.value) + const typed = cached === '' ? '' : cachedType(cell.value) + return `${expression}${cached}` +} + +function inlineString(attributes: string, text: string): string { + return `${escapeCellText(text)}` +} + +const dated = (value: CellValue): boolean => + value instanceof Date || (isFormulaCell(value) && value.value instanceof Date) + +const appliedStyle = (value: CellValue, style: StyleId, datetime: StyleId): StyleId => + dated(value) && style === DEFAULT_STYLE ? datetime : style + +function isBlank(value: CellValue): boolean { + return value === null || value === undefined || value === '' +} + +function bodyXml(attributes: string, value: CellValue, reference: string): string { + if (isFormulaCell(value)) return formulaXml(attributes, value, reference) + if (typeof value === 'number') { + return Number.isFinite(value) + ? `${value}` + : `` + } + if (typeof value === 'boolean') return `${value ? 1 : 0}` + if (value instanceof Date) return dateCellXml(attributes, value) + return inlineString(attributes, String(value)) +} + +function cellXml( + reference: string, + value: CellValue, + style: StyleId, + datetime: StyleId = DEFAULT_STYLE +): string { + const applied = appliedStyle(value, style, datetime) + const attributes = `r="${reference}"${applied ? ` s="${applied}"` : ''}` + return isBlank(value) ? `` : bodyXml(attributes, value, reference) +} + +function dateCellXml(attributes: string, value: Date): string { + const serial = toSerialDate(value) + if (Number.isNaN(serial)) return `` + + if (serial < 61) return inlineString(attributes, isoLocal(value)) + return `${serial}` +} + +export interface SheetOptions { + columns: SheetColumn[] + rows: CellValue[][] + + freezeHeader?: boolean + + autoFilter?: boolean + + headerStyle?: StyleId + + datetimeStyle?: StyleId + + cellStyle?: (row: number, column: number) => StyleId | undefined + + conditionalFormats?: XlsxCfRule[] +} + +const ROWS_PER_CHUNK = 500 + +function checkBounds(columns: SheetColumn[], rows: CellValue[][]): void { + if (columns.length > MAX_COLUMNS) { + throw new RangeError( + `xlsx: ${columns.length} columns will not open in Excel (limit ${MAX_COLUMNS})` + ) + } + if (rows.length >= MAX_ROWS) { + throw new RangeError( + `xlsx: ${rows.length} rows plus the header will not open in Excel (limit ${MAX_ROWS})` + ) + } +} + +function colsXml(columns: SheetColumn[]): string { + if (columns.length === 0) return '' + const each = columns + .map( + (column, index) => + `` + ) + .join('') + return `${each}` +} + +function headerXml(columns: SheetColumn[], style: StyleId): string { + const cells = columns + .map((column, index) => cellXml(cellRef(0, index), column.header, style)) + .join('') + return `${cells}` +} + +function rowXml(options: SheetOptions, cells: CellValue[], rowIndex: number): string { + const content = cells + .map((value, index) => + cellXml( + cellRef(rowIndex + 1, index), + value, + options.cellStyle?.(rowIndex, index) ?? + options.columns[index]?.style ?? + DEFAULT_STYLE, + options.datetimeStyle ?? DEFAULT_STYLE + ) + ) + .join('') + return `${content}` +} + +function preamble(options: SheetOptions): string { + const freeze = options.freezeHeader + ? '' + : '' + return ( + '' + + '' + + freeze + + colsXml(options.columns) + + '' + + headerXml(options.columns, options.headerStyle ?? DEFAULT_STYLE) + ) +} + +function trailer(options: SheetOptions): string { + const { columns, rows } = options + const lastColumn = columnLetter(Math.max(0, columns.length - 1)) + const filter = + options.autoFilter && columns.length + ? `` + : '' + const formats = options.conditionalFormats?.length + ? conditionalFormatXml(options.conditionalFormats) + : '' + return `${filter}${formats}` +} + +export function* sheetChunks(options: SheetOptions): Generator { + const { columns, rows } = options + checkBounds(columns, rows) + + yield preamble(options) + + for (let start = 0; start < rows.length; start += ROWS_PER_CHUNK) { + const end = Math.min(start + ROWS_PER_CHUNK, rows.length) + let batch = '' + for (let index = start; index < end; index++) batch += rowXml(options, rows[index]!, index) + yield batch + } + + yield trailer(options) +} + +export function sheetXml(options: SheetOptions): string { + let out = '' + for (const chunk of sheetChunks(options)) out += chunk + return out +} diff --git a/src/lib/features/xlsx/styles.test.ts b/src/lib/features/xlsx/styles.test.ts new file mode 100644 index 0000000..8124261 --- /dev/null +++ b/src/lib/features/xlsx/styles.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { BUILT_IN_STYLES, StyleTable } from './styles.js' + +function counts(xml: string) { + const read = (tag: string) => Number(new RegExp(`<${tag} count="(\\d+)"`).exec(xml)?.[1] ?? 0) + return { + fonts: read('fonts'), + fills: read('fills'), + borders: read('borders'), + cells: read('cellXfs'), + formats: read('numFmts') + } +} + +describe('StyleTable', () => { + it('starts with the one style every unstyled cell points at', () => { + const table = new StyleTable() + expect(table.size).toBe(1) + expect(table.add({})).toBe(0) + }) + + it('hands the same id back for the same look', () => { + const table = new StyleTable() + const first = table.add({ font: { bold: true }, fill: 'DDEEFF' }) + const second = table.add({ fill: 'DDEEFF', font: { bold: true } }) + + expect(second).toBe(first) + expect(table.size).toBe(2) + }) + + it('shares one font across the cells that use it', () => { + const table = new StyleTable() + table.add({ font: { bold: true }, fill: 'FF0000' }) + table.add({ font: { bold: true }, fill: '00FF00' }) + + const found = counts(table.toXml()) + expect(found.cells).toBe(3) + expect(found.fonts).toBe(2) + expect(found.fills).toBe(4) + }) + + it('pads a six-digit colour to the ARGB Excel wants, and takes a leading hash', () => { + const table = new StyleTable() + table.add({ font: { color: '#1A2B3C' } }) + expect(table.toXml()).toContain('') + }) + + it('keeps an eight-digit colour as given, alpha and all', () => { + const table = new StyleTable() + table.add({ fill: '80FF0000' }) + expect(table.toXml()).toContain('rgb="80FF0000"') + }) + + it('uses the id Excel already has for a format', () => { + const table = new StyleTable() + table.add({ numberFormat: '#,##0.00' }) + + const xml = table.toXml() + expect(xml).toContain('numFmtId="4"') + expect(counts(xml).formats).toBe(0) + }) + + it('declares a custom format once, from 164 up', () => { + const table = new StyleTable() + table.add({ numberFormat: '[$$-409]#,##0' }) + table.add({ numberFormat: '0.000' }) + table.add({ numberFormat: '[$$-409]#,##0' }) + + const xml = table.toXml() + expect(counts(xml).formats).toBe(2) + expect(xml).toContain('numFmtId="164" formatCode="[$$-409]#,##0"') + expect(xml).toContain('numFmtId="165" formatCode="0.000"') + }) + + it('escapes a format code that would break the XML', () => { + const table = new StyleTable() + table.add({ numberFormat: '""#,##0' }) + expect(table.toXml()).toContain('<a & b>') + }) + + it('writes alignment inside the record rather than as an attribute', () => { + const table = new StyleTable() + table.add({ alignment: { horizontal: 'center', vertical: 'middle', wrap: true } }) + + const xml = table.toXml() + expect(xml).toContain('applyAlignment="1"') + expect(xml).toContain('') + }) + + it('writes middle as center, which is what the format calls it', () => { + const table = new StyleTable() + table.add({ alignment: { vertical: 'middle' } }) + expect(table.toXml()).toContain('vertical="center"') + }) + + it('leaves a side Excel should not draw empty rather than styling it', () => { + const table = new StyleTable() + table.add({ border: { bottom: { style: 'medium', color: '000000' } } }) + + const xml = table.toXml() + expect(xml).toContain('') + expect(xml).toContain('') + }) + + it('keeps the table small when a whole column shares a look', () => { + const table = new StyleTable() + for (let row = 0; row < 10_000; row++) table.add(BUILT_IN_STYLES.currency) + expect(table.size).toBe(2) + }) + + it('writes a document whose counts match what it holds', () => { + const table = new StyleTable() + table.add(BUILT_IN_STYLES.header) + table.add({ fill: 'FFFF00', border: { top: {} } }) + + const xml = table.toXml() + const found = counts(xml) + expect(found.cells).toBe(table.size) + expect(xml.split('').length - 1).toBe(found.fonts) + expect(xml.split('').length - 1).toBe(found.borders) + expect(xml).toMatch( + /^<\?xml version="1\.0" encoding="UTF-8" standalone="yes"\?>')).toBe(true) + }) +}) diff --git a/src/lib/features/xlsx/styles.ts b/src/lib/features/xlsx/styles.ts new file mode 100644 index 0000000..8623fda --- /dev/null +++ b/src/lib/features/xlsx/styles.ts @@ -0,0 +1,230 @@ +import { escapeXml } from './sheet.js' +import type { + XlsxAlignment, + XlsxBorder, + XlsxBorderSide, + XlsxColor, + XlsxFont, + XlsxStyle +} from './styles.types.js' + +const BUILT_IN_FORMATS = new Map([ + ['general', 0], + ['0', 1], + ['0.00', 2], + ['#,##0', 3], + ['#,##0.00', 4], + ['0%', 9], + ['0.00%', 10], + ['mm-dd-yy', 14], + ['h:mm', 20] +]) + +const FIRST_CUSTOM_FORMAT = 164 + +function color(value: XlsxColor): string { + const hex = value.replace('#', '').toUpperCase() + return `` +} + +function fontXml(font: XlsxFont): string { + return [ + '', + font.bold ? '' : '', + font.italic ? '' : '', + font.underline ? '' : '', + ``, + font.color ? color(font.color) : '', + ``, + '' + ].join('') +} + +function fillXml(value: XlsxColor): string { + const hex = value.replace('#', '').toUpperCase() + const rgb = hex.length === 6 ? `FF${hex}` : hex + return `` +} + +function dxfFontXml(font: XlsxFont): string { + const parts = [ + font.bold ? '' : '', + font.italic ? '' : '', + font.underline ? '' : '', + font.color ? color(font.color) : '' + ].join('') + return parts === '' ? '' : `${parts}` +} + +function dxfFillXml(value: XlsxColor): string { + const hex = value.replace('#', '').toUpperCase() + const rgb = hex.length === 6 ? `FF${hex}` : hex + return `` +} + +function dxfXml(style: XlsxStyle): string { + return `${style.font ? dxfFontXml(style.font) : ''}${ + style.fill ? dxfFillXml(style.fill) : '' + }` +} + +function sideXml(name: string, side: XlsxBorderSide | undefined): string { + if (!side) return `<${name}/>` + return `<${name} style="${side.style ?? 'thin'}">${side.color ? color(side.color) : ''}` +} + +function borderXml(border: XlsxBorder): string { + return [ + '', + sideXml('left', border.left), + sideXml('right', border.right), + sideXml('top', border.top), + sideXml('bottom', border.bottom), + '', + '' + ].join('') +} + +const VERTICAL: Record = { top: 'top', middle: 'center', bottom: 'bottom' } + +function alignmentXml(alignment: XlsxAlignment): string { + const parts = [ + alignment.horizontal ? ` horizontal="${alignment.horizontal}"` : '', + alignment.vertical ? ` vertical="${VERTICAL[alignment.vertical]}"` : '', + alignment.wrap ? ' wrapText="1"' : '', + alignment.rotation === undefined ? '' : ` textRotation="${alignment.rotation}"`, + alignment.indent === undefined ? '' : ` indent="${alignment.indent}"` + ].join('') + return parts === '' ? '' : `` +} + +interface StyleIds { + numFmt: number + font: number + fill: number + border: number +} + +function applied(name: string, id: number): string { + return id ? ` apply${name}="1"` : '' +} + +function cellRecord(ids: StyleIds, alignment: string): string { + const head = + `${alignment}` : `${head}/>` +} + +class Pool { + readonly entries: string[] = [] + readonly #index = new Map() + + constructor(...initial: string[]) { + for (const entry of initial) this.intern(entry) + } + + intern(xml: string): number { + const found = this.#index.get(xml) + if (found !== undefined) return found + + const id = this.entries.push(xml) - 1 + this.#index.set(xml, id) + return id + } + + get size(): number { + return this.entries.length + } + + join(): string { + return this.entries.join('') + } +} + +export class StyleTable { + #fonts = new Pool(fontXml({})) + #fills = new Pool( + '', + '' + ) + #borders = new Pool(borderXml({})) + #formats = new Pool() + #cells = new Pool() + #dxfs = new Pool() + + constructor() { + this.add({}) + } + + #formatId(code: string | undefined): number { + if (code === undefined) return 0 + const builtIn = BUILT_IN_FORMATS.get(code.toLowerCase()) + if (builtIn !== undefined) return builtIn + + return FIRST_CUSTOM_FORMAT + this.#formats.intern(code) + } + + add(style: XlsxStyle): number { + const ids = { + numFmt: this.#formatId(style.numberFormat), + font: style.font ? this.#fonts.intern(fontXml(style.font)) : 0, + fill: style.fill ? this.#fills.intern(fillXml(style.fill)) : 0, + border: style.border ? this.#borders.intern(borderXml(style.border)) : 0 + } + const alignment = style.alignment ? alignmentXml(style.alignment) : '' + return this.#cells.intern(cellRecord(ids, alignment)) + } + + addDxf(style: XlsxStyle): number { + return this.#dxfs.intern(dxfXml(style)) + } + + get size(): number { + return this.#cells.size + } + + get dxfCount(): number { + return this.#dxfs.size + } + + toXml(): string { + const formats = this.#formats.size + ? `${this.#formats.entries + .map( + (code, index) => + `` + ) + .join('')}` + : '' + + return ( + '' + + '' + + formats + + `${this.#fonts.join()}` + + `${this.#fills.join()}` + + `${this.#borders.join()}` + + '' + + `${this.#cells.join()}` + + (this.#dxfs.size + ? `${this.#dxfs.join()}` + : '') + + '' + ) + } +} + +export const BUILT_IN_STYLES = { + header: { font: { bold: true } }, + date: { numberFormat: 'yyyy\\-mm\\-dd' }, + datetime: { numberFormat: 'yyyy\\-mm\\-dd\\ hh:mm' }, + currency: { numberFormat: '#,##0.00' }, + percent: { numberFormat: '0.00%' }, + integer: { numberFormat: '#,##0' } +} as const satisfies Record diff --git a/src/lib/features/xlsx/styles.types.ts b/src/lib/features/xlsx/styles.types.ts new file mode 100644 index 0000000..e66320a --- /dev/null +++ b/src/lib/features/xlsx/styles.types.ts @@ -0,0 +1,56 @@ +/** A colour as Excel writes it: `RRGGBB`, or `AARRGGBB` with alpha. */ +export type XlsxColor = string + +export interface XlsxFont { + bold?: boolean + italic?: boolean + underline?: boolean + /** Points. @default 11 */ + size?: number + color?: XlsxColor + /** @default 'Calibri' */ + name?: string +} + +export interface XlsxBorderSide { + /** @default 'thin' */ + style?: 'thin' | 'medium' | 'thick' | 'dashed' | 'dotted' | 'double' + color?: XlsxColor +} + +export interface XlsxBorder { + top?: XlsxBorderSide + bottom?: XlsxBorderSide + left?: XlsxBorderSide + right?: XlsxBorderSide +} + +export interface XlsxAlignment { + horizontal?: 'left' | 'center' | 'right' + vertical?: 'top' | 'middle' | 'bottom' + /** Wraps long text instead of spilling it into the next cell. */ + wrap?: boolean + /** Degrees, -90 to 90. */ + rotation?: number + /** Indent steps from the cell edge. */ + indent?: number +} + +/** + * Everything one cell can look like. Every field is optional and the writer + * de-duplicates: a hundred thousand cells sharing a look cost one entry in + * `styles.xml`, because Excel refuses a file whose style table repeats itself + * past a few thousand entries. + */ +export interface XlsxStyle { + font?: XlsxFont + /** Solid background fill. */ + fill?: XlsxColor + border?: XlsxBorder + alignment?: XlsxAlignment + /** + * An Excel number format code - `#,##0.00`, `0.0%`, `yyyy-mm-dd`, + * `[$$-409]#,##0.00`. Overrides whatever the column's `type` would pick. + */ + numberFormat?: string +} diff --git a/src/lib/features/xlsx/workbook.ts b/src/lib/features/xlsx/workbook.ts new file mode 100644 index 0000000..f082e2a --- /dev/null +++ b/src/lib/features/xlsx/workbook.ts @@ -0,0 +1,124 @@ +import { escapeXml, sheetChunks, type SheetOptions, sheetXml } from './sheet.js' +import { StyleTable } from './styles.js' +import { createZip, deflateChunks, deflateEntries, type ZipEntry, zipEntry } from './zip.js' + +const ROOT_RELS = + '' + +function workbookXml(names: string[]): string { + const sheets = names + .map( + (name, index) => + `` + ) + .join('') + return `${sheets}` +} + +function contentTypes(count: number): string { + const sheets = Array.from( + { length: count }, + (_, index) => + `` + ).join('') + return `${sheets}` +} + +function workbookRels(count: number): string { + const sheets = Array.from( + { length: count }, + (_, index) => + `` + ).join('') + return `${sheets}` +} + +function uniqueNames(sheets: WorkbookSheet[]): string[] { + const taken = new Set() + return sheets.map((sheet, index) => { + const base = sanitizeSheetName(sheet.sheetName ?? `Sheet${index + 1}`) + let name = base + let suffix = 2 + while (taken.has(name.toLowerCase())) name = `${base.slice(0, 28)} (${suffix++})` + taken.add(name.toLowerCase()) + return name + }) +} + +export interface WorkbookSheet extends SheetOptions { + sheetName?: string +} + +export interface WorkbookOptions extends WorkbookSheet { + styles?: StyleTable +} + +export function sanitizeSheetName(name: string): string { + const cleaned = name + .replace(/[[\]:*?/\\\u0000-\u001F]/g, ' ') + .trim() + .replace(/^'+|'+$/g, '') + .slice(0, 31) + .trim() + return cleaned === '' || /^history$/i.test(cleaned) ? 'Sheet1' : cleaned +} + +export function workbookEntries(options: WorkbookOptions | WorkbookOptions[]): ZipEntry[] { + const sheets = Array.isArray(options) ? options : [options] + if (sheets.length === 0) throw new RangeError('xlsx: a workbook needs at least one sheet') + + const styles = sheets.find((sheet) => sheet.styles)?.styles ?? new StyleTable() + const names = uniqueNames(sheets) + + const worksheets = sheets.map((sheet, index) => + zipEntry(`xl/worksheets/sheet${index + 1}.xml`, sheetXml(sheet)) + ) + + return [ + zipEntry('[Content_Types].xml', contentTypes(sheets.length)), + zipEntry('_rels/.rels', ROOT_RELS), + zipEntry('xl/workbook.xml', workbookXml(names)), + zipEntry('xl/_rels/workbook.xml.rels', workbookRels(sheets.length)), + zipEntry('xl/styles.xml', styles.toXml()), + ...worksheets + ] +} + +export async function deflatedWorkbookEntries( + options: WorkbookOptions | WorkbookOptions[] +): Promise { + const sheets = Array.isArray(options) ? options : [options] + if (sheets.length === 0) throw new RangeError('xlsx: a workbook needs at least one sheet') + + const styles = sheets.find((sheet) => sheet.styles)?.styles ?? new StyleTable() + const names = uniqueNames(sheets) + + const worksheets: ZipEntry[] = [] + for (const [index, sheet] of sheets.entries()) { + worksheets.push( + await deflateChunks(`xl/worksheets/sheet${index + 1}.xml`, sheetChunks(sheet)) + ) + } + + const rest = await deflateEntries([ + zipEntry('[Content_Types].xml', contentTypes(sheets.length)), + zipEntry('_rels/.rels', ROOT_RELS), + zipEntry('xl/workbook.xml', workbookXml(names)), + zipEntry('xl/_rels/workbook.xml.rels', workbookRels(sheets.length)), + zipEntry('xl/styles.xml', styles.toXml()) + ]) + + return [...rest, ...worksheets] +} + +export function createWorkbook(options: WorkbookOptions | WorkbookOptions[]): Uint8Array { + return createZip(workbookEntries(options)) +} + +export async function createWorkbookAsync( + options: WorkbookOptions | WorkbookOptions[] +): Promise { + return createZip(await deflatedWorkbookEntries(options)) +} + +export const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' diff --git a/src/lib/features/xlsx/xlsx.test.ts b/src/lib/features/xlsx/xlsx.test.ts new file mode 100644 index 0000000..217de99 --- /dev/null +++ b/src/lib/features/xlsx/xlsx.test.ts @@ -0,0 +1,749 @@ +import { describe, expect, it } from 'vitest' +import { createDataGrid } from '../../core/grid/index.js' +import { type ColumnDef } from '../../core/types/index.js' +import { pagination } from '../../features/pagination/index.js' +import { virtualization } from '../../features/virtualization/index.js' +import { serverRowModel } from '../server-row-model/index.js' +import { buildGridXlsx, buildGridXlsxAsync } from './export.js' +import { cellRef, columnLetter, escapeXml, sheetChunks, sheetXml, toSerialDate } from './sheet.js' +import { BUILT_IN_STYLES, StyleTable } from './styles.js' +import { createWorkbook, sanitizeSheetName } from './workbook.js' +import { crc32, createZip, deflateChunks, deflateEntries, zipEntry } from './zip.js' + +function readZip(bytes: Uint8Array): Map { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const decoder = new TextDecoder() + + let end = bytes.length - 22 + while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end-- + expect(end).toBeGreaterThanOrEqual(0) + + const count = view.getUint16(end + 10, true) + let offset = view.getUint32(end + 16, true) + const entries = new Map() + + for (let i = 0; i < count; i++) { + expect(view.getUint32(offset, true)).toBe(0x02014b50) + const crc = view.getUint32(offset + 16, true) + const size = view.getUint32(offset + 24, true) + const nameLength = view.getUint16(offset + 28, true) + const localOffset = view.getUint32(offset + 42, true) + const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength)) + + expect(view.getUint32(localOffset, true)).toBe(0x04034b50) + const localNameLength = view.getUint16(localOffset + 26, true) + const extraLength = view.getUint16(localOffset + 28, true) + const start = localOffset + 30 + localNameLength + extraLength + const data = bytes.subarray(start, start + size) + + expect(crc32(data)).toBe(crc) + entries.set(name, decoder.decode(data)) + offset += 46 + nameLength + } + + return entries +} + +describe('cell addressing', () => { + it('numbers columns the way a spreadsheet does', () => { + expect(columnLetter(0)).toBe('A') + expect(columnLetter(25)).toBe('Z') + expect(columnLetter(26)).toBe('AA') + expect(columnLetter(701)).toBe('ZZ') + expect(columnLetter(702)).toBe('AAA') + expect(cellRef(0, 0)).toBe('A1') + expect(cellRef(9, 2)).toBe('C10') + }) +}) + +describe('serial dates', () => { + it('anchors on the 1899-12-30 epoch Excel uses', () => { + expect(toSerialDate(new Date(1900, 0, 1))).toBe(2) + expect(toSerialDate(new Date(2026, 0, 1))).toBe(46023) + }) + + it('carries the time of day in the fraction', () => { + expect(toSerialDate(new Date(2026, 0, 1, 12, 0, 0))).toBeCloseTo(46023.5, 5) + }) +}) + +describe('escaping', () => { + it('escapes what would otherwise break the XML', () => { + expect(escapeXml('a & b < c > "d" \'e\'')).toBe( + 'a & b < c > "d" 'e'' + ) + }) + + it('survives a value that looks like markup', () => { + const xml = sheetXml({ + columns: [{ header: ' + +{#if palette} + { + if (open) search = '' + palette.open = open + }} + title={title ?? t.commandPaletteTitle} + close={false} + ui={{ content: slots.commandPalette({ class: theme('commandPalette') }) }} + > + {#snippet content()} + + {/snippet} + +{/if} diff --git a/src/lib/components/chrome/ConditionalFormattingPanel.svelte b/src/lib/components/chrome/ConditionalFormattingPanel.svelte new file mode 100644 index 0000000..0f113c6 --- /dev/null +++ b/src/lib/components/chrome/ConditionalFormattingPanel.svelte @@ -0,0 +1,218 @@ + + +{#if formatting} +
+ {t.formatLabel} + + {#each chips as chip (chip.id)} + + {chip.name} +
+{/if} diff --git a/src/lib/components/chrome/FilterBuilder.svelte b/src/lib/components/chrome/FilterBuilder.svelte new file mode 100644 index 0000000..3fd328a --- /dev/null +++ b/src/lib/components/chrome/FilterBuilder.svelte @@ -0,0 +1,158 @@ + + +{#if filter} +
+
+ {t.filterTitle} + {#if grid.rowModel === 'server'} + {t.filterServerHandled} + {/if} +
+ + {#if filter.model.children.length === 0} +

+ {t.filterEmpty} +

+ {/if} + + +
+{/if} diff --git a/src/lib/components/chrome/FilterBuilderGroup.svelte b/src/lib/components/chrome/FilterBuilderGroup.svelte new file mode 100644 index 0000000..d584c0f --- /dev/null +++ b/src/lib/components/chrome/FilterBuilderGroup.svelte @@ -0,0 +1,295 @@ + + +
+
+
+ + {#each group.children as child, index (index)} + {#if child.kind === 'group'} + + {:else} + {@const kind = kindFor(child.columnId)} +
+ child.op, + (next) => patch(index, child, { op: next as FilterCondition['op'] }) + } + /> + {#if needsList(child.op)} + String(child.value ?? ''), + (next) => patch(index, child, { value: next }) + } + /> + {:else if kind === 'boolean' && needsValue(child.op)} + (find.replaced = null)} + onkeydown={onKeydown} + /> + + + {#if find.replaced !== null} + {t.findReplaced(find.replaced)} + {:else if find.query === ''} +   + {:else if find.total === 0} + {find.partial ? t.findNoMatchLoaded : t.findNoMatch} + {:else if find.replace && !find.replaceAvailable} + {t.findServerReadOnly} + {:else if find.replace && find.writableTotal === 0} + {t.findNoWritable(find.total)} + {:else if find.partial} + {t.findCountLoaded(find.current + 1, find.total)} + {:else} + {t.findCount(find.current + 1, find.total)} + {/if} + + +
+{/if} diff --git a/src/lib/components/chrome/GroupPanel.svelte b/src/lib/components/chrome/GroupPanel.svelte new file mode 100644 index 0000000..42af8ce --- /dev/null +++ b/src/lib/components/chrome/GroupPanel.svelte @@ -0,0 +1,112 @@ + + +{#if grouping} +
+ {t.groupBy} + + {#each chips as chip, index (chip.id)} + + {#if index > 0} + + {/if} + + {chip.header} +
+{/if} diff --git a/src/lib/components/chrome/ImportWizard.svelte b/src/lib/components/chrome/ImportWizard.svelte new file mode 100644 index 0000000..5fee7a9 --- /dev/null +++ b/src/lib/components/chrome/ImportWizard.svelte @@ -0,0 +1,256 @@ + + +{#if importing} +
+ {#if importing.step === 'idle'} + { + if (files[0]) void importing.take(files[0]) + }} + /> + + {#if importing.accepts.includes('clipboard')} +
+
+ {/if} + {:else if importing.step === 'mapping'} +
+

{t.importMatchTitle}

+ + {#if importing.fileName}{importing.fileName} | + {/if}{t.importRowCount(importing.sourceCount)} + +
+ +
+ importing.setHasHeader(next)} + /> + {#if importing.sheets.length > 1} + + + mapping.sourceIndex === null ? '' : String(mapping.sourceIndex), + (next) => + importing.setMapping( + mapping.columnId, + next === '' ? null : Number(next) + ) + } + /> +
+ {/each} +
+ +
+
+ {:else} +
+

{t.importReviewTitle}

+ + {importing.problems.length === 0 + ? t.importReady + : t.importIssues(importing.badRows.size)} + +
+ + {#if importing.issueCounts.length > 0} +
+ {#each importing.issueCounts as issue (issue.message)} +
+ {issue.message} + {issue.count} +
+ {/each} +
+ {/if} + +
+
+ {/if} + + {#if importing.error} + + {/if} + +{/if} diff --git a/src/lib/components/chrome/RangeStatusBar.svelte b/src/lib/components/chrome/RangeStatusBar.svelte new file mode 100644 index 0000000..cc52d6e --- /dev/null +++ b/src/lib/components/chrome/RangeStatusBar.svelte @@ -0,0 +1,59 @@ + + +{#if range && range.ranges.length > 0} +
+ + {t.rangeCells(range.cellCount.toLocaleString())}{#if shape} +  ({t.rangeShape(shape.rows, shape.cols)}){/if} + + {#if stats} + {#each stats as stat (stat.key)} + + {stat.label} + + {format(stat.value as number)} + + + {/each} + {/if} +
+{/if} diff --git a/src/lib/components/chrome/SavedViews.svelte b/src/lib/components/chrome/SavedViews.svelte new file mode 100644 index 0000000..321f9a2 --- /dev/null +++ b/src/lib/components/chrome/SavedViews.svelte @@ -0,0 +1,133 @@ + + +{#if views} +
+ {t.viewsLabel} + + {#if items.length > 0} + + {#snippet children({ props })} +
+{/if} diff --git a/src/lib/components/chrome/ToolPanel.svelte b/src/lib/components/chrome/ToolPanel.svelte new file mode 100644 index 0000000..a0e4b33 --- /dev/null +++ b/src/lib/components/chrome/ToolPanel.svelte @@ -0,0 +1,302 @@ + + +{#if collapsed} +
+ {t.toolPanelTitle} +
+{:else} +
+
+ {t.toolPanelTitle} +
+ +
+ ({ value: name, label: tabLabel(name) }))} + value={tab} + onValueChange={(next: string) => (chosen = next as Tab)} + /> +
+ + + +
+ {#if matching.length === 0} +

+ {t.toolPanelEmpty} +

+ {:else if tab === 'columns'} +
+
+ {#each matching as column (column.id)} +
+ setHidden(column, !next)} + /> + {#if column.pinned} + {column.pinned} + {/if} + + {#snippet children({ props }: { props: Record })} +
+ {/each} + {:else if tab === 'group'} + {#if grouped.length > 0} + {#each grouped as column (column!.id)} +
+ {column!.header ?? column!.id} +
+ {/each} + {:else} +

+ {t.groupPanelEmpty} +

+ {/if} + {#each groupable as column (column.id)} +
+ {column.header ?? column.id} +
+ {/each} + {:else} + {#each matching as column (column.id)} +
+ {column.header ?? column.id} + diff --git a/src/lib/components/internal/context.ts b/src/lib/components/internal/context.ts index feb3cea..e2d30d0 100644 --- a/src/lib/components/internal/context.ts +++ b/src/lib/components/internal/context.ts @@ -17,3 +17,18 @@ export function getGridContext(): GridState { export function getGridOrNull(): GridState | null { return getContext | undefined>(GRID_CONTEXT_KEY) ?? null } + +const GRID_ELEMENT_KEY = Symbol('sv5ui-datagrid-element') + +/** + * The grid's root element, for a feature component that listens on the DOM + * rather than drawing any of it. A getter, because the element arrives on + * mount and the reader is set up before that. + */ +export function setGridElement(element: () => HTMLElement | null): void { + setContext(GRID_ELEMENT_KEY, element) +} + +export function getGridElement(): () => HTMLElement | null { + return getContext<(() => HTMLElement | null) | undefined>(GRID_ELEMENT_KEY) ?? (() => null) +} diff --git a/src/lib/components/internal/icons.data.ts b/src/lib/components/internal/icons.data.ts index a9f7994..7136a44 100644 --- a/src/lib/components/internal/icons.data.ts +++ b/src/lib/components/internal/icons.data.ts @@ -7,6 +7,9 @@ export const datagridIcons: IconifyJSON = { width: 24, height: 24, icons: { + 'arrow-down': { + body: '' + }, 'arrow-down-to-line': { body: '' }, @@ -19,18 +22,27 @@ export const datagridIcons: IconifyJSON = { 'arrow-right-to-line': { body: '' }, + 'arrow-up': { + body: '' + }, 'arrow-up-narrow-wide': { body: '' }, 'arrow-up-to-line': { body: '' }, + 'bookmark-plus': { + body: '' + }, check: { body: '' }, 'chevron-down': { body: '' }, + 'chevron-left': { + body: '' + }, 'chevron-right': { body: '' }, @@ -55,6 +67,9 @@ export const datagridIcons: IconifyJSON = { 'circle-x': { body: '' }, + clipboard: { + body: '' + }, 'columns-3': { body: '' }, @@ -91,6 +106,12 @@ export const datagridIcons: IconifyJSON = { inbox: { body: '' }, + 'layout-list': { + body: '' + }, + link: { + body: '' + }, 'list-checks': { body: '' }, @@ -100,6 +121,12 @@ export const datagridIcons: IconifyJSON = { 'move-horizontal': { body: '' }, + 'panel-left-close': { + body: '' + }, + 'panel-left-open': { + body: '' + }, pencil: { body: '' }, @@ -112,6 +139,9 @@ export const datagridIcons: IconifyJSON = { plus: { body: '' }, + replace: { + body: '' + }, 'rows-2': { body: '' }, @@ -127,6 +157,9 @@ export const datagridIcons: IconifyJSON = { table: { body: '' }, + 'trash-2': { + body: '' + }, ungroup: { body: '' }, diff --git a/src/lib/components/internal/index.ts b/src/lib/components/internal/index.ts index 5260a57..cdf3333 100644 --- a/src/lib/components/internal/index.ts +++ b/src/lib/components/internal/index.ts @@ -8,7 +8,13 @@ * do it by being named again in `components/index.ts`. */ -export { getGridContext, getGridOrNull, setGridContext } from './context.js' +export { + getGridContext, + getGridElement, + getGridOrNull, + setGridContext, + setGridElement +} from './context.js' export { fromDateValue, fromTimeValue, toDateValue, toTimeValue } from './editor-values.js' export { notTabbable } from './focus.js' export { registerDataGridIcons } from './icons.js' diff --git a/src/lib/core/theme/slots.ts b/src/lib/core/theme/slots.ts index 18ca39c..2d50d2a 100644 --- a/src/lib/core/theme/slots.ts +++ b/src/lib/core/theme/slots.ts @@ -258,10 +258,7 @@ export const datagridSlots = { savedViews: 'flex flex-wrap items-center gap-2 rounded-lg border border-outline-variant bg-surface-container px-3 py-2', savedViewsLabel: 'text-xs font-medium text-on-surface-variant', - savedViewsModified: 'text-xs text-warning', - - watermark: - 'pointer-events-none absolute right-2 bottom-2 z-30 rounded-md border border-outline-variant bg-surface/80 px-2 py-1 text-xs font-medium text-on-surface-variant backdrop-blur-sm select-none' + savedViewsModified: 'text-xs text-warning' } export type DataGridSlots = keyof typeof datagridSlots diff --git a/src/lib/core/types/feature.ts b/src/lib/core/types/feature.ts index d3411b2..456f338 100644 --- a/src/lib/core/types/feature.ts +++ b/src/lib/core/types/feature.ts @@ -1,3 +1,4 @@ +import type { Component } from 'svelte' import type { GridState } from '../grid/grid.svelte.js' import type { GridApi } from './api.js' import type { ColumnState } from './columns.js' @@ -147,6 +148,22 @@ export interface GridFeature { * commit the substitute over the real data. */ cellValue?: (scope: CellValueScope) => CellValueReader | undefined + /** + * A component the grid mounts inside its root, for the work a feature can + * only do from inside the render tree: an effect, a listener on the grid's + * own element, a layer drawn over the rows. + * + * A feature is built by `createDataGrid`, where there is no effect context + * and no DOM, so anything of that kind had no home before this. Naming the + * component here rather than having the grid import it is what keeps the + * promise on the box: `DataGrid` never mentions these, so a feature nobody + * registered is a component nobody bundles. + * + * It is handed no props. The grid is in context, and so is the root + * element, which `getGridElement` answers for. + */ + component?: Component + /** The feature's JSON-safe slice of a snapshot; undefined stays out. */ serialize?: (grid: GridState) => unknown /** Restores what `serialize` produced; a feature added later starts fresh. */ diff --git a/src/lib/features/range-selection/range-selection.svelte.ts b/src/lib/features/range-selection/range-selection.svelte.ts index db03cca..ca3d79c 100644 --- a/src/lib/features/range-selection/range-selection.svelte.ts +++ b/src/lib/features/range-selection/range-selection.svelte.ts @@ -1,3 +1,4 @@ +import RangeLayer from '../../components/grid/RangeLayer.svelte' import type { CellPosition } from '../../core/interaction/index.js' import { type GridState, isDataRow, isLoadingRow } from '../../core/grid/index.js' import { @@ -707,6 +708,7 @@ export function rangeSelection(options: RangeSelectionOptions = {}): GridF }, cellDecoration: ({ grid, rowIndex, colIndex }) => getRangeSelection(grid)?.cellDecoration(rowIndex, colIndex), + component: RangeLayer, keybindings: createKeybindings() } } diff --git a/src/lib/features/server-row-model/server-row-model.svelte.ts b/src/lib/features/server-row-model/server-row-model.svelte.ts index 9f2714c..1fe2f48 100644 --- a/src/lib/features/server-row-model/server-row-model.svelte.ts +++ b/src/lib/features/server-row-model/server-row-model.svelte.ts @@ -1,3 +1,4 @@ +import ServerRows from '../../components/grid/ServerRows.svelte' import { type GridState, LOADING_KEY, PIPELINE_ORDER } from '../../core/grid/index.js' import { type GridFeature, type RowNode } from '../../core/types/index.js' import { getFiltering, toFilterRequest } from '../../features/filtering/index.js' @@ -34,6 +35,10 @@ export class ServerRowModel { #grid: GridState #source: DataSource #options: ServerRowModelOptions + + /** Read by the layer the feature contributes, which owns the effects. */ + readonly groupKeysOf: ServerRowModelOptions['groupKeysOf'] + readonly isChildOf: ServerRowModelOptions['isChildOf'] #blocks = createLoadedBlocks() #generation = 0 @@ -47,6 +52,8 @@ export class ServerRowModel { this.#grid = grid this.#source = source this.#options = options + this.groupKeysOf = options.groupKeysOf + this.isChildOf = options.isChildOf this.mode = options.mode ?? 'paged' this.blockSize = options.blockSize ?? DEFAULT_BLOCK_SIZE this.maxBlocks = options.maxBlocks ?? DEFAULT_MAX_BLOCKS @@ -275,6 +282,7 @@ export function serverRowModel( ): GridFeature { return { id: SERVER_ROW_MODEL, + component: ServerRows, createState: (grid) => new ServerRowModel(grid, source, options), createApi: (grid) => { const state = getServerRowModel(grid)! diff --git a/src/lib/features/server-row-model/server-row-model.types.ts b/src/lib/features/server-row-model/server-row-model.types.ts index 2ca3d82..dc2385e 100644 --- a/src/lib/features/server-row-model/server-row-model.types.ts +++ b/src/lib/features/server-row-model/server-row-model.types.ts @@ -1,5 +1,10 @@ import type { FilterGroup } from '../advanced-filter/index.js' -import { type FilterRequest, type RowMeta, type SortState } from '../../core/types/index.js' +import { + type FilterRequest, + type RowMeta, + type RowNode, + type SortState +} from '../../core/types/index.js' /** * What the grid asks the server for. Matches PLAN §3.3: one descriptor covers @@ -95,6 +100,19 @@ export interface ServerRowModelOptions { */ getRowMeta?: (row: TRow) => RowMeta | undefined + /** + * The group keys identifying a group row, outermost first, for a server + * that fetches a group's children on demand. Given, expanding a group row + * asks the source for its children instead of assuming they are loaded. + */ + groupKeysOf?: (node: RowNode) => unknown[] + + /** + * Which loaded rows belong to a group row, so collapsing it can drop them. + * Read only when `groupKeysOf` is given. + */ + isChildOf?: (node: RowNode) => (row: TRow) => boolean + /** Called when a request rejects, for logging or a toast. */ onError?: (error: unknown, request: GetRowsRequest) => void } diff --git a/src/tests/FeatureLayers.svelte b/src/tests/FeatureLayers.svelte new file mode 100644 index 0000000..7b523f4 --- /dev/null +++ b/src/tests/FeatureLayers.svelte @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/src/tests/feature-layers.svelte.test.ts b/src/tests/feature-layers.svelte.test.ts new file mode 100644 index 0000000..3ef7151 --- /dev/null +++ b/src/tests/feature-layers.svelte.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page } from 'vitest/browser' +import FeatureLayers from './FeatureLayers.svelte' + +/** + * What `GridFeature.component` is for. Neither of the two things checked here + * is reachable by importing anything from `DataGrid`: the layer is named by + * the feature the app registered, and the panel takes the grid out of context + * rather than off a prop. + */ +describe('a feature that contributes a component', () => { + it('mounts the layer into the grid root, once per grid', async () => { + render(FeatureLayers as never) + await expect.element(page.getByRole('treegrid')).toBeVisible() + + // `rangeSelection()` contributes `RangeLayer`, which draws nothing and + // marks the root it listens on. + await expect.poll(() => document.querySelectorAll('[data-dg-range-layer]').length).toBe(1) + }) + + it('draws a chrome part that was never handed a grid', async () => { + render(FeatureLayers as never) + // `GroupPanel` takes no `grid` prop and no `ui`: the grid comes from + // the context `Grid.Root` set, and the wording from that grid's own + // labels rather than from a table of its own. + const panel = () => document.querySelector('[data-dg-group-panel]')?.textContent ?? '' + await expect.poll(panel).toContain('Group by') + await expect.poll(panel).toContain('No grouping') + }) +}) From 1c64a21d39bd42bdbecb331b5358103f92d0a73b Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 19:41:50 +0700 Subject: [PATCH 06/46] refactor(components): sort the parts by what they are, not by when they arrived Three things were wrong with where the new parts landed. `chrome/` had become seventeen files covering two different jobs. A toolbar control is fifteen to sixty lines, belongs in a row the grid lays out, and has one place it goes; a panel is a hundred to three hundred lines and is a piece of the page an app puts where it likes. The eight of the second kind move to `panels/`, and `chrome/` goes back to meaning the grid's own frame. `RangeStatusBar` stays: it is a status bar, and it sits in the footer beside the other one. `FilterBuilderGroup` was in `chrome/` while the other half of the same builder was in `internal/`, and neither is exported. Both are internal now, in one place, so `chrome/` and `panels/` hold only what leaves the package. `RangeLayer` and `ServerRows` were in `components/grid/` and imported by the features that name them, which pointed `features/` at `components/` and closed a cycle between the two. Each moves into the feature that ships it, where the import that was crossing layers is now one folder talking to itself. What is left is those two components reading the grid out of context, which is a leaf module and no cycle; the reason that one is allowed is written where it happens. Neither is exported. An application never mounts them - the feature does - so exporting them would be publishing wiring. `SavedViews` stops writing its own clipboard call and its own copied flag. sv5ui's `useClipboard` has both, and its `copied` clears itself after a couple of seconds, which fixes a real thing: the "link copied" message used to sit there until the user happened to save a view. A refusal keeps its own state, because that one has to stay up. The import preview keeps its plain table, now with the reason written down. sv5ui's `Table` sorts, filters, selects, pins and resizes; asking it to show five rows of a file would put a second table engine in the bundle of a data grid. --- src/lib/components/chrome/index.ts | 16 +++++--------- .../FilterBuilderGroup.svelte | 8 +++---- src/lib/components/internal/context.ts | 10 +++++++++ .../{chrome => panels}/CommandPalette.svelte | 0 .../ConditionalFormattingPanel.svelte | 0 .../{chrome => panels}/FilterBuilder.svelte | 2 +- .../{chrome => panels}/FindReplace.svelte | 0 .../{chrome => panels}/GroupPanel.svelte | 0 .../{chrome => panels}/ImportWizard.svelte | 7 +++++++ .../{chrome => panels}/SavedViews.svelte | 21 ++++++++++++------- .../{chrome => panels}/ToolPanel.svelte | 0 src/lib/components/panels/index.ts | 15 +++++++++++++ .../range-selection}/RangeLayer.svelte | 4 ++-- .../range-selection/range-selection.svelte.ts | 2 +- .../server-row-model}/ServerRows.svelte | 6 +++--- .../server-row-model.svelte.ts | 2 +- src/tests/FeatureLayers.svelte | 2 +- 17 files changed, 63 insertions(+), 32 deletions(-) rename src/lib/components/{chrome => internal}/FilterBuilderGroup.svelte (97%) rename src/lib/components/{chrome => panels}/CommandPalette.svelte (100%) rename src/lib/components/{chrome => panels}/ConditionalFormattingPanel.svelte (100%) rename src/lib/components/{chrome => panels}/FilterBuilder.svelte (98%) rename src/lib/components/{chrome => panels}/FindReplace.svelte (100%) rename src/lib/components/{chrome => panels}/GroupPanel.svelte (100%) rename src/lib/components/{chrome => panels}/ImportWizard.svelte (96%) rename src/lib/components/{chrome => panels}/SavedViews.svelte (86%) rename src/lib/components/{chrome => panels}/ToolPanel.svelte (100%) create mode 100644 src/lib/components/panels/index.ts rename src/lib/{components/grid => features/range-selection}/RangeLayer.svelte (96%) rename src/lib/{components/grid => features/server-row-model}/ServerRows.svelte (82%) diff --git a/src/lib/components/chrome/index.ts b/src/lib/components/chrome/index.ts index d69d874..1a94bda 100644 --- a/src/lib/components/chrome/index.ts +++ b/src/lib/components/chrome/index.ts @@ -1,7 +1,9 @@ /** - * Everything around the grid rather than in it: toolbar, footer, status bar - * and the controls they hold. All of these are public through `Grid.*`, so an - * app can lay the chrome out itself instead of taking `toolbar`. + * The controls that sit in the grid's own frame: the toolbar, the footer, the + * status bars and the handful of buttons they hold. Each is small, each has a + * place it belongs, and `DataGrid` arranges them for an app that does not want + * to. A surface an app puts wherever it likes is a panel, not chrome, and + * lives next door. */ export { default as GridColumnChooser } from './GridColumnChooser.svelte' @@ -12,12 +14,4 @@ export { default as GridPagination } from './GridPagination.svelte' export { default as GridQuickFilter } from './GridQuickFilter.svelte' export { default as GridStatusBar } from './GridStatusBar.svelte' export { default as GridToolbar } from './GridToolbar.svelte' -export { default as CommandPalette } from './CommandPalette.svelte' -export { default as ConditionalFormattingPanel } from './ConditionalFormattingPanel.svelte' -export { default as FilterBuilder } from './FilterBuilder.svelte' -export { default as FindReplace } from './FindReplace.svelte' -export { default as GroupPanel } from './GroupPanel.svelte' -export { default as ImportWizard } from './ImportWizard.svelte' export { default as RangeStatusBar } from './RangeStatusBar.svelte' -export { default as SavedViews } from './SavedViews.svelte' -export { default as ToolPanel } from './ToolPanel.svelte' diff --git a/src/lib/components/chrome/FilterBuilderGroup.svelte b/src/lib/components/internal/FilterBuilderGroup.svelte similarity index 97% rename from src/lib/components/chrome/FilterBuilderGroup.svelte rename to src/lib/components/internal/FilterBuilderGroup.svelte index d584c0f..d552f0d 100644 --- a/src/lib/components/chrome/FilterBuilderGroup.svelte +++ b/src/lib/components/internal/FilterBuilderGroup.svelte @@ -13,13 +13,13 @@ opsFor, type FilterKind } from '../../features/advanced-filter/operators.js' - import { fromDateValue, toDateValue } from '../internal/editor-values.js' + import { fromDateValue, toDateValue } from './editor-values.js' - import FilterValueInput from '../internal/FilterValueInput.svelte' + import FilterValueInput from './FilterValueInput.svelte' import FilterBuilderGroup from './FilterBuilderGroup.svelte' import { datagridVariants } from '../datagrid.variants.js' - import { getGridContext } from '../internal/context.js' - import { getGridTheme } from '../internal/theme.js' + import { getGridContext } from './context.js' + import { getGridTheme } from './theme.js' const grid = getGridContext() const theme = getGridTheme() diff --git a/src/lib/components/internal/context.ts b/src/lib/components/internal/context.ts index e2d30d0..757c63f 100644 --- a/src/lib/components/internal/context.ts +++ b/src/lib/components/internal/context.ts @@ -1,6 +1,16 @@ import { getContext, setContext } from 'svelte' import type { GridState } from '../../core/grid/index.js' +/** + * How anything drawn inside a grid finds it. + * + * A component in `features/` reaches in here, which is the one place the + * layers run the other way. It is deliberate and it is safe: this module + * imports nothing but Svelte and a type, so there is no cycle, and a feature + * that ships a component is a feature that has stepped into the render tree + * on purpose. Nothing else in `features/` may import from `components/`. + */ + const GRID_CONTEXT_KEY = Symbol('sv5ui-datagrid') export function setGridContext(grid: GridState): void { diff --git a/src/lib/components/chrome/CommandPalette.svelte b/src/lib/components/panels/CommandPalette.svelte similarity index 100% rename from src/lib/components/chrome/CommandPalette.svelte rename to src/lib/components/panels/CommandPalette.svelte diff --git a/src/lib/components/chrome/ConditionalFormattingPanel.svelte b/src/lib/components/panels/ConditionalFormattingPanel.svelte similarity index 100% rename from src/lib/components/chrome/ConditionalFormattingPanel.svelte rename to src/lib/components/panels/ConditionalFormattingPanel.svelte diff --git a/src/lib/components/chrome/FilterBuilder.svelte b/src/lib/components/panels/FilterBuilder.svelte similarity index 98% rename from src/lib/components/chrome/FilterBuilder.svelte rename to src/lib/components/panels/FilterBuilder.svelte index 3fd328a..91fe437 100644 --- a/src/lib/components/chrome/FilterBuilder.svelte +++ b/src/lib/components/panels/FilterBuilder.svelte @@ -14,7 +14,7 @@ FilterNode } from '../../features/advanced-filter/advanced-filter.types.js' - import FilterBuilderGroup from './FilterBuilderGroup.svelte' + import FilterBuilderGroup from '../internal/FilterBuilderGroup.svelte' import { datagridVariants } from '../datagrid.variants.js' import { getGridContext } from '../internal/context.js' import { getGridTheme } from '../internal/theme.js' diff --git a/src/lib/components/chrome/FindReplace.svelte b/src/lib/components/panels/FindReplace.svelte similarity index 100% rename from src/lib/components/chrome/FindReplace.svelte rename to src/lib/components/panels/FindReplace.svelte diff --git a/src/lib/components/chrome/GroupPanel.svelte b/src/lib/components/panels/GroupPanel.svelte similarity index 100% rename from src/lib/components/chrome/GroupPanel.svelte rename to src/lib/components/panels/GroupPanel.svelte diff --git a/src/lib/components/chrome/ImportWizard.svelte b/src/lib/components/panels/ImportWizard.svelte similarity index 96% rename from src/lib/components/chrome/ImportWizard.svelte rename to src/lib/components/panels/ImportWizard.svelte index 5fee7a9..24255a3 100644 --- a/src/lib/components/chrome/ImportWizard.svelte +++ b/src/lib/components/panels/ImportWizard.svelte @@ -109,6 +109,13 @@ {/if}
+
diff --git a/src/lib/components/chrome/SavedViews.svelte b/src/lib/components/panels/SavedViews.svelte similarity index 86% rename from src/lib/components/chrome/SavedViews.svelte rename to src/lib/components/panels/SavedViews.svelte index 321f9a2..61516e4 100644 --- a/src/lib/components/chrome/SavedViews.svelte +++ b/src/lib/components/panels/SavedViews.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/src/tests/feature-interplay.test.ts b/src/tests/feature-interplay.test.ts new file mode 100644 index 0000000..95206b4 --- /dev/null +++ b/src/tests/feature-interplay.test.ts @@ -0,0 +1,120 @@ +import { createDataGrid, editing, sorting, type ColumnDef, type GridState } from '$lib/index.js' +import { describe, expect, it } from 'vitest' +import { formula } from '../lib/features/formula/index.js' +import { grouping } from '../lib/features/grouping/index.js' +import { getRangeSelection, rangeSelection } from '../lib/features/range-selection/index.js' +import { conditionalFormatting } from '../lib/features/conditional-formatting/index.js' + +interface Line { + id: number + region: string + unit: number + qty: number + total?: number +} + +const makeData = (): Line[] => [ + { id: 1, region: 'North', unit: 50, qty: 2 }, + { id: 2, region: 'North', unit: 100, qty: 3 }, + { id: 3, region: 'South', unit: 200, qty: 4 } +] + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region' }, + { + id: 'unit', + header: 'Unit', + editable: true, + parse: (input) => (input === null ? null : Number(input)) + }, + { + id: 'qty', + header: 'Qty', + editable: true, + parse: (input) => (input === null ? null : Number(input)) + }, + { id: 'total', header: 'Total' } +] + +function makeGrid(): GridState { + return createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [ + sorting(), + editing(), + formula({ columns: { total: 'unit * qty' } }), + grouping({ + by: ['region'], + expandedByDefault: true, + aggregations: { total: 'sum', unit: 'median' } + }), + conditionalFormatting({ + rules: [{ kind: 'colorScale', id: 'scale', column: 'total' }] + }), + rangeSelection() + ] + }) +} + +const dataRowIndex = (grid: GridState, rowId: string) => + grid.preWindowNodes.findIndex((node) => node.id === rowId) + +describe('range writes meet formula columns', () => { + it('leaves a formula column out of a range edit and recomputes it instead', () => { + const grid = makeGrid() + const state = getRangeSelection(grid)! + const first = dataRowIndex(grid, '1') + + state.startRange(first, 1) + state.extendTo(first, 3) + state.endRange() + state.applyToRange(7) + + expect(grid.data[0]).toMatchObject({ unit: 7, qty: 7 }) + expect(grid.data[0]!.total).toBeUndefined() + expect(grid.nodes[first]!.row.total).toBe(49) + }) + + it('refuses to move onto a formula column, and keeps the source', () => { + const grid = makeGrid() + const state = getRangeSelection(grid)! + const first = dataRowIndex(grid, '1') + + state.cutSource = { top: first, left: 1, bottom: first, right: 1 } + expect(state.moveRange(first, 3)).toBe(false) + expect(grid.data[0]!.unit).toBe(50) + }) + + it('moves an editable cell and carries the formula and the group total with it', () => { + const grid = makeGrid() + const state = getRangeSelection(grid)! + const from = dataRowIndex(grid, '1') + const to = dataRowIndex(grid, '2') + const groupRow = grid.nodes.findIndex((node) => node.meta?.expandable) + + const totalBefore = grid.nodes[groupRow]!.row.total + expect(totalBefore).toBe(50 * 2 + 100 * 3) + + state.cutSource = { top: from, left: 1, bottom: from, right: 1 } + expect(state.moveRange(to, 1)).toBe(true) + + expect(grid.data[0]!.unit).toBeNull() + expect(grid.data[1]!.unit).toBe(50) + expect(grid.nodes[groupRow]!.row.total).toBe(0 * 2 + 50 * 3) + }) +}) + +describe('a move under a group', () => { + it('writes nothing to a group row and clears nothing on its account', () => { + const grid = makeGrid() + const state = getRangeSelection(grid)! + const groupRow = grid.nodes.findIndex((node) => node.meta?.expandable) + const first = dataRowIndex(grid, '1') + + state.cutSource = { top: first, left: 1, bottom: first, right: 1 } + expect(state.moveRange(groupRow, 1)).toBe(false) + expect(grid.data[0]!.unit).toBe(50) + }) +}) diff --git a/src/tests/filter-builder.svelte.test.ts b/src/tests/filter-builder.svelte.test.ts new file mode 100644 index 0000000..046b062 --- /dev/null +++ b/src/tests/filter-builder.svelte.test.ts @@ -0,0 +1,326 @@ +import { createDataGrid, sorting, type ColumnDef, type GridState } from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page } from 'vitest/browser' +import FilterBuilder from '../lib/components/panels/FilterBuilder.svelte' +import InRoot from './InRoot.svelte' +import { + advancedFilter, + getAdvancedFilter +} from '../lib/features/advanced-filter/advanced-filter.svelte.js' +import { isBlank } from '../lib/core/utils/index.js' + +interface Sale { + id: number + note: string + region: string + rep: string + total: number + closedAt: string + won: boolean +} + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region', filter: 'set' }, + { id: 'won', header: 'Won', type: 'boolean' }, + { id: 'note', header: 'Note', filter: false }, + { id: 'act', header: 'Act', type: 'actions' }, + { id: 'rep', header: 'Rep', filter: 'text' }, + { id: 'total', header: 'Total', type: 'currency', filter: 'number' }, + { id: 'closedAt', header: 'Closed', type: 'date' } +] + +const data: Sale[] = [ + { + id: 1, + region: 'North', + rep: 'Alice', + total: 120, + closedAt: '2026-01-05', + won: true, + note: '' + }, + { + id: 2, + region: 'North', + rep: 'Bob', + total: 80, + closedAt: '2026-02-11', + won: false, + note: 'x' + }, + { + id: 3, + region: 'South', + rep: 'Alice', + total: 300, + closedAt: '2026-03-02', + won: true, + note: '' + } +] + +// The panel takes its grid from context, so it is mounted inside one. +const TypedBuilder = InRoot as unknown as Component> + +const inRoot = (grid: GridState, partProps?: { debounce?: number }) => ({ + props: { grid, component: FilterBuilder, partProps } +}) + +function makeGrid(): GridState { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [sorting(), advancedFilter()] + }) +} + +describe('which columns the panel offers', () => { + it('leaves out a column that refused, and one with nothing to compare', async () => { + const grid = makeGrid() + await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'Add condition' }).first().click() + await vi.waitFor(() => expect(getAdvancedFilter(grid)!.model.children).toHaveLength(1)) + + await page.getByRole('button', { name: 'Column' }).first().click() + await vi.waitFor(() => + expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0) + ) + + const offered = [...document.querySelectorAll('[role="option"]')].map( + (option) => option.textContent?.trim() ?? '' + ) + expect(offered).toContain('Rep') + expect(offered).toContain('Won') + expect(offered).not.toContain('Note') + expect(offered).not.toContain('Act') + }) +}) + +describe('typing a value waits for a pause', () => { + async function typeInto(grid: GridState, text: string) { + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', columnId: 'rep', op: 'contains' }] + }) + const field = page.getByRole('textbox', { name: 'Value' }) + await vi.waitFor(() => expect(field.query()).not.toBeNull()) + await field.fill(text) + } + + it('leaves the rows alone while the keys are still coming', async () => { + const grid = makeGrid() + await render(TypedBuilder, inRoot(grid, { debounce: 5_000 })) + + await typeInto(grid, 'Ali') + + const condition = getAdvancedFilter(grid)!.model.children[0]! + expect(condition.kind === 'condition' && isBlank(condition.value)).toBe(true) + expect(grid.preWindowNodes).toHaveLength(data.length) + }) + + it('filters once the typing stops', async () => { + const grid = makeGrid() + await render(TypedBuilder, inRoot(grid, { debounce: 20 })) + + await typeInto(grid, 'Ali') + + await vi.waitFor(() => expect(grid.preWindowNodes).toHaveLength(2)) + const condition = getAdvancedFilter(grid)!.model.children[0]! + expect(condition.kind === 'condition' && condition.value).toBe('Ali') + }) +}) + +describe('a new condition holds no value until one is picked', () => { + it('leaves the value empty and the rows alone', async () => { + const grid = makeGrid() + await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'Add condition' }).first().click() + await vi.waitFor(() => expect(getAdvancedFilter(grid)!.model.children).toHaveLength(1)) + + const condition = getAdvancedFilter(grid)!.model.children[0]! + expect(condition.kind === 'condition' && isBlank(condition.value)).toBe(true) + expect(getAdvancedFilter(grid)!.activeCount).toBe(0) + expect(grid.preWindowNodes).toHaveLength(data.length) + }) + + it('offers a boolean its own words rather than the operator names', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', columnId: 'won', op: 'equals' }] + }) + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-filter-row]')).toHaveLength(1) + ) + + const value = screen.container.querySelectorAll('[data-dg-filter-row] button')[2]! + expect(value.textContent).toContain('Value') + expect(value.textContent).not.toContain('Equals') + }) +}) + +describe('the filter builder writes the tree', () => { + it('adds a condition when asked', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'Add condition' }).first().click() + await vi.waitFor(() => expect(getAdvancedFilter(grid)!.model.children).toHaveLength(1)) + expect(screen.container.querySelectorAll('[data-dg-filter-row]')).toHaveLength(1) + }) + + it('adds a nested group', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'Add group' }).first().click() + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-filter-group]')).toHaveLength(2) + ) + }) + + it('switches the join and negates the group', async () => { + const grid = makeGrid() + await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'OR', exact: true }).first().click() + await vi.waitFor(() => expect(getAdvancedFilter(grid)!.model.join).toBe('or')) + + await page.getByRole('button', { name: 'NOT', exact: true }).first().click() + await vi.waitFor(() => expect(getAdvancedFilter(grid)!.model.not).toBe(true)) + }) + + it('removes a condition', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'Add condition' }).first().click() + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-filter-row]')).toHaveLength(1) + ) + + await page.getByRole('button', { name: 'Remove' }).first().click() + await vi.waitFor(() => expect(getAdvancedFilter(grid)!.model.children).toHaveLength(0)) + }) + + it('writes a tree the grid actually filters on', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + expect(grid.nodes).toHaveLength(3) + + await page.getByRole('button', { name: 'Add condition' }).first().click() + await vi.waitFor(() => + expect(screen.container.querySelector('[data-dg-filter-row]')).toBeTruthy() + ) + + const state = getAdvancedFilter(grid)! + state.setModel({ + ...state.model, + children: [{ kind: 'condition', columnId: 'rep', op: 'equals', value: 'Alice' }] + }) + + await vi.waitFor(() => expect(grid.nodes.map((node) => node.id)).toEqual(['1', '3'])) + }) + + it('says the server owns the answer when the grid is server-backed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [sorting(), advancedFilter()] + }) + const screen = await render(TypedBuilder, inRoot(grid)) + + expect(screen.container.textContent).toContain('Answered by your backend') + warn.mockRestore() + }) + + it('is axe-clean with a nested group on screen', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + + await page.getByRole('button', { name: 'Add condition' }).first().click() + await page.getByRole('button', { name: 'Add group' }).first().click() + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-filter-group]')).toHaveLength(2) + ) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) + + it('offers the operators the column type has, and no others', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + await page.getByRole('button', { name: 'Add condition' }).first().click() + + const row = () => screen.container.querySelector('[data-dg-filter-row]')! + await vi.waitFor(() => expect(row()).toBeTruthy()) + + const state = getAdvancedFilter(grid)! + state.setModel({ + ...state.model, + children: [{ kind: 'condition', columnId: 'total', op: 'gt', value: 100 }] + }) + + await vi.waitFor(() => expect(row().getAttribute('data-dg-filter-kind')).toBe('number')) + expect(row().querySelector('input')?.type).toBe('number') + }) + + it('gives a date column the same segmented picker the free grid uses', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + const state = getAdvancedFilter(grid)! + + state.setModel({ + ...state.model, + children: [ + { kind: 'condition', columnId: 'closedAt', op: 'before', value: '2026-02-01' } + ] + }) + + await vi.waitFor(() => { + const row = screen.container.querySelector('[data-dg-filter-row]')! + expect(row.getAttribute('data-dg-filter-kind')).toBe('date') + expect(row.querySelectorAll('[role="spinbutton"]').length).toBeGreaterThan(2) + expect(row.querySelector('input[type="text"]')).toBeNull() + }) + }) + + it('replaces an operator the new column cannot answer', async () => { + const grid = makeGrid() + const screen = await render(TypedBuilder, inRoot(grid)) + const state = getAdvancedFilter(grid)! + + state.setModel({ + ...state.model, + children: [{ kind: 'condition', columnId: 'rep', op: 'startsWith', value: 'A' }] + }) + await vi.waitFor(() => + expect(screen.container.querySelector('[data-dg-filter-row]')).toBeTruthy() + ) + + await page.getByRole('button', { name: 'Column' }).first().click() + await page.getByRole('option', { name: 'Total', exact: true }).click() + + await vi.waitFor(() => { + const condition = state.model.children[0] + expect(condition?.kind === 'condition' && condition.op).not.toBe('startsWith') + }) + expect(state.model.children[0]).toMatchObject({ columnId: 'total', value: undefined }) + }) +}) diff --git a/src/tests/hostile-values.test.ts b/src/tests/hostile-values.test.ts new file mode 100644 index 0000000..5558096 --- /dev/null +++ b/src/tests/hostile-values.test.ts @@ -0,0 +1,127 @@ +import { createDataGrid, type ColumnDef } from '$lib/index.js' +import { describe, expect, it } from 'vitest' +import { findReplace, getFindReplace } from '$lib/index.js' +import { formula } from '$lib/index.js' +import { grouping } from '$lib/index.js' +import { getRangeSelection, rangeSelection } from '$lib/index.js' +import { buildGridXlsx } from '$lib/xlsx.js' + +interface Row { + id: number + a: unknown + b: unknown +} + +const columns: ColumnDef[] = [ + { id: 'a', header: 'A' }, + { id: 'b', header: 'B' } +] + +const LONE_SURROGATE = String.fromCharCode(0xd800) + +const hostile: Row[] = [ + { id: 1, a: undefined, b: null }, + { id: 2, a: NaN, b: Infinity }, + { id: 3, a: '', b: ' ' }, + { id: 4, a: new Date('nope'), b: new Date(0) }, + { id: 5, a: { nested: 1 }, b: [1, 2] }, + { id: 6, a: LONE_SURROGATE, b: 'x'.repeat(50_000) }, + { id: 7, a: -0, b: 1e308 * 10 }, + { id: 8, a: Symbol('s'), b: () => 1 }, + { id: 9, a: 9007199254740993n, b: new Map() } +] + +function build(features: unknown[] = [], data: Row[] = hostile) { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: features as never + }) +} + +describe('values an app can put in a row, and the grid must survive', () => { + it('exports them', () => { + expect(() => buildGridXlsx(build())).not.toThrow() + }) + + it('groups on a column of them', () => { + const grid = build([grouping({ by: ['a'], aggregations: { b: 'sum' } })]) + expect(() => grid.nodes.length).not.toThrow() + expect(() => buildGridXlsx(grid)).not.toThrow() + }) + + it('computes a formula over them', () => { + const grid = build([formula({ columns: { b: 'a * 2 & "-" & UPPER(a) & LEN(a)' } })]) + expect(() => grid.preWindowNodes.map((node) => node.row.b)).not.toThrow() + }) + + it('runs a range selection over them', () => { + const grid = build([rangeSelection()]) + void grid.preWindowNodes + + const range = getRangeSelection(grid)! + range.startRange(0, 0) + range.extendTo(hostile.length - 1, 1) + + expect(() => range.selectedValues).not.toThrow() + }) + + it('exports an empty grid, and one with no columns', () => { + expect(() => buildGridXlsx(build([], []))).not.toThrow() + + const none = createDataGrid({ + columns: [], + data: hostile, + getRowId: (row) => String(row.id) + }) + expect(() => buildGridXlsx(none)).not.toThrow() + }) +}) + +describe('a Date the app never checked', () => { + const withBadDate: Row[] = [{ id: 1, a: new Date('nope'), b: 'keep' }] + + it('does not take the find box down when the user types', () => { + const grid = build([findReplace()], withBadDate) + void grid.preWindowNodes + + const find = getFindReplace(grid)! + find.show() + find.query = 'keep' + + expect(() => find.total).not.toThrow() + expect(find.total).toBe(1) + }) + + it('is findable by the text it prints as', () => { + const grid = build([findReplace()], withBadDate) + void grid.preWindowNodes + + const find = getFindReplace(grid)! + find.show() + find.query = 'Invalid Date' + + expect(find.total).toBe(1) + }) + + it('becomes an error value in a formula rather than an exception', () => { + const grid = build([formula({ columns: { b: '"on " & a' } })], withBadDate) + + expect(() => grid.preWindowNodes).not.toThrow() + expect(grid.preWindowNodes[0]?.row.b).toBe('#VALUE') + }) + + it('compares as an error rather than silently answering false', () => { + const grid = build( + [formula({ columns: { b: 'IF(a > a, "yes", "no")' } })], + withBadDate + ) + + expect(grid.preWindowNodes[0]?.row.b).toBe('#VALUE') + }) + + it('still exports, because the writer already wrote such a date as text', () => { + expect(() => buildGridXlsx(build([], withBadDate))).not.toThrow() + }) +}) diff --git a/src/tests/server-mode.test.ts b/src/tests/server-mode.test.ts new file mode 100644 index 0000000..fb1ecf1 --- /dev/null +++ b/src/tests/server-mode.test.ts @@ -0,0 +1,260 @@ +import { + createDataGrid, + filtering, + virtualization, + type ColumnDef, + type GridFeature, + type GridState +} from '$lib/index.js' +import { describe, expect, it, vi } from 'vitest' +import { advancedFilter, getAdvancedFilter } from '../lib/features/advanced-filter/index.js' +import { commandPalette, getCommandPalette } from '../lib/features/command-palette/index.js' +import { formula } from '../lib/features/formula/index.js' +import { isDetailNode, masterDetail } from '../lib/features/master-detail/index.js' +import { getSavedViews, savedViews } from '../lib/features/saved-views/index.js' +import { + getServerRowModel, + serverRowModel, + type GetRowsRequest +} from '../lib/features/server-row-model/index.js' +import { getTree, tree } from '../lib/features/tree/index.js' + +interface Row { + id: number + parentId: number | null + name: string + unit: number + qty: number + total?: number +} + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + { id: 'unit', header: 'Unit' }, + { id: 'qty', header: 'Qty' }, + { id: 'total', header: 'Total' } +] + +const TOTAL = 40 +const BLOCK = 10 + +const table: Row[] = Array.from({ length: TOTAL }, (_, i) => ({ + id: i + 1, + parentId: i % 4 === 0 ? null : i, + name: `row ${i + 1}`, + unit: (i + 1) * 10, + qty: 2 +})) + +function serverGrid(extra: GridFeature[], served = BLOCK): GridState { + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization(), + serverRowModel( + { + getRows: async ({ startRow, endRow }) => ({ + rows: table.slice(startRow, Math.min(endRow, served)), + rowCount: TOTAL + }) + }, + { + mode: 'infinite', + blockSize: BLOCK, + placeholder: (index) => ({ + id: -index - 1, + parentId: null, + name: '', + unit: 0, + qty: 0 + }) + } + ), + ...extra + ] + }) +} + +async function loaded(grid: GridState): Promise> { + grid.feature<{ refresh: () => void }>('serverRowModel')!.refresh() + await expect.poll(() => grid.data.length).toBe(TOTAL) + return grid +} + +const isPlaceholder = (row: unknown) => Number((row as Row).id) < 0 + +describe('formula on a server row model', () => { + it('computes a loaded row and invents nothing for a row still on its way', async () => { + const grid = await loaded(serverGrid([formula({ columns: { total: 'unit * qty' } })])) + + const first = grid.preWindowNodes[0]!.row as unknown as Record + expect(first.total).toBe(20) + + const waiting = grid.preWindowNodes.find((node) => isPlaceholder(node.row))! + expect((waiting.row as unknown as Record).total).toBeUndefined() + expect(grid.data[0]!.total).toBeUndefined() + }) + + it('reads one row at a time, so nothing it shows depends on what else is loaded', async () => { + const half = await loaded( + serverGrid([formula({ columns: { total: 'unit * qty' } })], 5) + ) + const all = await loaded(serverGrid([formula({ columns: { total: 'unit * qty' } })])) + + const totalOf = (grid: GridState) => + (grid.preWindowNodes[0]!.row as unknown as Record).total + + expect(totalOf(half)).toBe(totalOf(all)) + }) +}) + +describe('master detail on a server row model', () => { + it('opens a panel under a loaded row', async () => { + const grid = await loaded(serverGrid([masterDetail({})])) + const before = grid.preWindowNodes.length + + grid.expansion.expand('1') + await expect.poll(() => grid.preWindowNodes.length).toBe(before + 1) + + const panel = grid.preWindowNodes.find((node) => isDetailNode(node.id)) + expect(panel).toBeDefined() + }) + + it('offers no panel on a row that has not arrived', async () => { + const grid = await loaded( + serverGrid([masterDetail({ hasDetail: (row) => Number(row.id) > 0 })]) + ) + + const waiting = grid.preWindowNodes.find((node) => isPlaceholder(node.row))! + expect(waiting.meta?.expandable).not.toBe(true) + }) +}) + +describe('tree on a server row model', () => { + it('never loses a loaded row: what is missing is collapsed, not gone', async () => { + const grid = await loaded( + serverGrid([ + tree({ + getParentId: (row) => (row.parentId === null ? null : String(row.parentId)) + }) + ]) + ) + + const collapsed = grid.preWindowNodes.length + getTree(grid)!.expandAll() + await expect.poll(() => grid.preWindowNodes.length).toBeGreaterThan(collapsed) + + const shown = new Set(grid.preWindowNodes.map((node) => node.id)) + const loadedIds = grid.data.filter((row) => row.id > 0).map((row) => String(row.id)) + expect(loadedIds.filter((id) => !shown.has(id))).toEqual([]) + }) + + it('keeps a row whose parent has not arrived, at the root', async () => { + const orphans: Row[] = table.map((row) => ({ ...row, parentId: 999 })) + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization(), + serverRowModel( + { + getRows: async ({ startRow, endRow }) => ({ + rows: orphans.slice(startRow, Math.min(endRow, BLOCK)), + rowCount: TOTAL + }) + }, + { + mode: 'infinite', + blockSize: BLOCK, + placeholder: (index) => ({ + id: -index - 1, + parentId: null, + name: '', + unit: 0, + qty: 0 + }) + } + ), + tree({ getParentId: (row) => String(row.parentId) }) + ] + }) + await loaded(grid) + + const first = grid.preWindowNodes.find((node) => node.id === '1') + expect(first, 'an orphan is a root, not a dropped row').toBeDefined() + expect(first!.meta?.level ?? 0).toBe(0) + }) +}) + +describe('features that never read a row', () => { + it('saved views round-trip on a server grid', async () => { + const grid = await loaded(serverGrid([savedViews({ storage: null })])) + const views = getSavedViews(grid)! + + views.save('one') + expect(views.views.map((view) => view.name)).toEqual(['one']) + + views.apply(views.views[0]!.id) + expect(grid.data.length).toBe(TOTAL) + }) + + it('the command palette lists column commands whatever the row model is', async () => { + const grid = await loaded(serverGrid([commandPalette()])) + const palette = getCommandPalette(grid)! + + palette.show() + expect(palette.commands.length).toBeGreaterThan(0) + }) +}) + +describe('what the request tells a backend', () => { + it('names the columns a quick filter meant, and carries the advanced tree', async () => { + const seen: GetRowsRequest[] = [] + const grid = createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + filtering(), + advancedFilter(), + virtualization({ rowHeight: 40 }), + serverRowModel( + { + async getRows(request) { + seen.push(request) + return { + rows: table.slice(request.startRow, request.endRow), + rowCount: TOTAL + } + } + }, + { + mode: 'infinite', + blockSize: BLOCK, + placeholder: (index) => ({ id: -(index + 1) }) as Row + } + ) + ] + }) + + getServerRowModel(grid)!.start() + await vi.waitFor(() => expect(seen.length).toBeGreaterThan(0)) + expect(seen[0]!.filterModel.quickFields).toEqual(['name', 'unit', 'qty', 'total']) + expect(seen[0]!.advancedFilter).toBeUndefined() + + getAdvancedFilter(grid)!.setModel({ + kind: 'group', + join: 'and', + children: [{ kind: 'condition', columnId: 'name', op: 'contains', value: 'row 1' }] + }) + + await vi.waitFor(() => expect(seen.length).toBeGreaterThan(1)) + expect(seen.at(-1)!.advancedFilter?.children).toHaveLength(1) + }) +}) diff --git a/src/tests/tool-panel.svelte.test.ts b/src/tests/tool-panel.svelte.test.ts new file mode 100644 index 0000000..8e2389e --- /dev/null +++ b/src/tests/tool-panel.svelte.test.ts @@ -0,0 +1,331 @@ +import { + columnOps, + createDataGrid, + editing, + pagination, + selection, + sorting, + virtualization, + type ColumnDef, + type GridFeature, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page } from 'vitest/browser' +import ToolPanel from '../lib/components/panels/ToolPanel.svelte' +import InRoot from './InRoot.svelte' +import { getGrouping, grouping } from '../lib/features/grouping/grouping.svelte.js' +import { getRangeSelection, rangeSelection } from '../lib/features/range-selection/index.js' +import { serverRowModel } from '../lib/features/server-row-model/index.js' + +interface Sale { + id: number + region: string + rep: string + total: number +} + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region', width: 120 }, + { id: 'rep', header: 'Rep', width: 120 }, + { id: 'total', header: 'Total', width: 120 } +] + +const data: Sale[] = [ + { id: 1, region: 'North', rep: 'Alice', total: 10 }, + { id: 2, region: 'South', rep: 'Bob', total: 20 } +] + +// The panel takes its grid from context, so it is mounted inside one. +const TypedPanel = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ props: { grid, component: ToolPanel } }) + +function makeGrid(): GridState { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [sorting(), columnOps(), selection(), editing(), grouping()] + }) +} + +async function openActions(container: Element, columnId: string) { + container + .querySelector(`[data-dg-tool-column="${columnId}"] button[aria-label]`)! + .click() + await vi.waitFor(() => + expect(document.querySelectorAll('[role="menuitem"]').length).toBeGreaterThan(0) + ) +} + +async function chooseAction(name: RegExp) { + await page.getByRole('menuitem', { name }).click() +} + +const rowFor = (container: Element, id: string) => + container.querySelector(`[data-dg-tool-column="${id}"]`)! + +describe('the tool panel drives the grid it is given', () => { + it('hides and shows a column', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + const checkbox = rowFor(screen.container, 'rep').querySelector( + '[role="checkbox"]' + )! + checkbox.click() + await vi.waitFor(() => expect(grid.columns.get('rep')?.hidden).toBe(true)) + + checkbox.click() + await vi.waitFor(() => expect(grid.columns.get('rep')?.hidden).toBe(false)) + }) + + it('pins a column and unpins it on a second press', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + await openActions(screen.container, 'total') + await chooseAction(/Pin left/) + await vi.waitFor(() => expect(grid.columns.get('total')?.pinned).toBe('left')) + + await openActions(screen.container, 'total') + await chooseAction(/Unpin/) + await vi.waitFor(() => expect(grid.columns.get('total')?.pinned).toBeFalsy()) + }) + + it('moves a column along the visible order', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + const before = grid.columns.visible.map((column) => column.id) + + await openActions(screen.container, 'total') + await chooseAction(/earlier/) + await vi.waitFor(() => + expect(grid.columns.visible.map((column) => column.id)).not.toEqual(before) + ) + }) + + it('never offers the selection checkbox column', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + expect(screen.container.querySelectorAll('[data-dg-tool-column]')).toHaveLength(3) + }) + + it('filters its own list without touching the grid', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + const search = screen.container.querySelector('input')! + + search.value = 'reg' + search.dispatchEvent(new Event('input', { bubbles: true })) + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-tool-column]')).toHaveLength(1) + ) + expect(grid.columns.visible.length).toBe(4) + }) + + it('groups and ungroups from the group tab', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + await page.getByRole('tab', { name: 'Groups' }).click() + screen.container + .querySelector('[data-dg-tool-groupable="region"] button')! + .click() + await vi.waitFor(() => expect(getGrouping(grid)!.by).toEqual(['region'])) + + screen.container + .querySelector('[data-dg-tool-grouped="region"] button')! + .click() + await vi.waitFor(() => expect(getGrouping(grid)!.by).toEqual([])) + }) + + it('is axe-clean on every tab', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + const settled = async () => { + let previous = '' + await vi.waitFor( + () => { + const active = screen.container.querySelector( + '[role="tab"][data-state="active"]' + ) + const box = JSON.stringify(active?.getBoundingClientRect()) + const stable = box === previous + previous = box + expect(stable, 'the tab indicator is still moving').toBe(true) + }, + { timeout: 2_000, interval: 120 } + ) + } + + for (const tab of ['Columns', 'Groups', 'Values']) { + await page.getByRole('tab', { name: tab }).click() + await settled() + const results = await axe.run(screen.container, { + rules: { region: { enabled: false } } + }) + expect( + results.violations.map( + (violation) => + `${violation.id}: ${violation.nodes.map((node) => node.html).join(' | ')}` + ) + ).toEqual([]) + } + }) +}) + +describe('the tool panel collapses', () => { + it('folds to a header and opens again', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + expect(screen.container.querySelectorAll('[data-dg-tool-column]')).toHaveLength(3) + await page.getByRole('button', { name: 'Collapse panel' }).click() + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-tool-column]')).toHaveLength(0) + ) + + await page.getByRole('button', { name: 'Expand panel' }).click() + await vi.waitFor(() => + expect(screen.container.querySelectorAll('[data-dg-tool-column]')).toHaveLength(3) + ) + }) + + it('starts folded when the app says so', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, { + props: { grid, component: ToolPanel, partProps: { collapsed: true } } + }) + + expect(screen.container.querySelectorAll('[data-dg-tool-column]')).toHaveLength(0) + expect(screen.container.querySelector('[data-dg-tool-panel]')?.textContent).toContain( + 'Tools' + ) + }) +}) + +describe('the tool panel offers only what the grid can actually do', () => { + const tabsOf = (container: Element) => + [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim()) + + it('drops the group and value tabs when the grid has no grouping feature', async () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [sorting(), columnOps()] + }) + const screen = await render(TypedPanel, inRoot(grid)) + + expect(tabsOf(screen.container)).toEqual(['Columns']) + expect(screen.container.querySelectorAll('[data-dg-tool-groupable]')).toHaveLength(0) + }) + + it('drops them on a server row model, where the aggregate would only cover what is loaded', async () => { + const grid = serverGrid('infinite') + const screen = await render(TypedPanel, inRoot(grid)) + + expect(tabsOf(screen.container)).toEqual(['Columns']) + }) +}) + +const table: Sale[] = Array.from({ length: 40 }, (_, i) => ({ + id: i + 1, + region: ['North', 'South'][i % 2]!, + rep: ['Alice', 'Bob', 'Chi'][i % 3]!, + total: (i + 1) * 10 +})) + +function serverGrid(mode: 'paged' | 'infinite'): GridState { + const paging: GridFeature[] = + mode === 'paged' ? [pagination({ pageSize: 10 })] : [virtualization()] + + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + columnOps(), + editing(), + ...paging, + serverRowModel( + { + getRows: async ({ startRow, endRow }) => ({ + rows: table.slice(startRow, endRow), + rowCount: table.length + }) + }, + mode === 'paged' + ? { mode: 'paged' } + : { + mode: 'infinite', + blockSize: 10, + placeholder: (index) => + ({ id: -index - 1, region: '', rep: '', total: 0 }) as Sale + } + ), + grouping(), + rangeSelection() + ] + }) +} + +describe('the tool panel on a grid whose rows come from a server', () => { + async function loaded(grid: GridState) { + grid.feature<{ refresh: () => void }>('serverRowModel')!.refresh() + await vi.waitFor(() => expect(grid.preWindowNodes.length).toBeGreaterThan(0)) + return grid + } + + it('keeps a column hidden across a page change', async () => { + const grid = await loaded(serverGrid('paged')) + const screen = await render(TypedPanel, inRoot(grid)) + + screen.container + .querySelector('[data-dg-tool-column="rep"] [role="checkbox"]')! + .click() + await vi.waitFor(() => expect(grid.columns.get('rep')?.hidden).toBe(true)) + + const setPage = grid.api.setPage as (page: number) => void + setPage(3) + await vi.waitFor(() => expect(grid.preWindowNodes[0]?.row.id).toBe(21)) + + expect(grid.columns.get('rep')?.hidden).toBe(true) + expect(grid.columns.visible.map((column) => column.id)).toEqual(['region', 'total']) + }) + + it('pins and reorders while rows are still placeholders', async () => { + const grid = await loaded(serverGrid('infinite')) + const screen = await render(TypedPanel, inRoot(grid)) + + await openActions(screen.container, 'total') + await chooseAction(/Pin left/) + await vi.waitFor(() => expect(grid.columns.get('total')?.pinned).toBe('left')) + expect(grid.columns.visible[0]?.id).toBe('total') + }) + + it('leaves a live range clamped rather than broken when a column goes away', async () => { + const grid = await loaded(serverGrid('paged')) + const range = getRangeSelection(grid)! + range.startRange(0, 0) + range.extendTo(2, 2) + range.endRange() + + const screen = await render(TypedPanel, inRoot(grid)) + screen.container + .querySelector('[data-dg-tool-column="region"] [role="checkbox"]')! + .click() + await vi.waitFor(() => expect(grid.columns.get('region')?.hidden).toBe(true)) + + expect(range.shape).toEqual({ rows: 3, cols: 2 }) + expect(range.getRangeTsv().split('\n')).toHaveLength(3) + }) +}) diff --git a/src/tests/worker-row-model.svelte.test.ts b/src/tests/worker-row-model.svelte.test.ts new file mode 100644 index 0000000..785b2dd --- /dev/null +++ b/src/tests/worker-row-model.svelte.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { workerDataSource } from '../lib/features/worker-row-model/index.js' +import { benchColumns, makeBenchRows, type BenchRow } from '../benchmarks/data.js' + +const rows: BenchRow[] = makeBenchRows(150_000) + +const emptyRequest = { + startRow: 0, + endRow: 50, + sortModel: [], + filterModel: { quick: 'person 1', quickFields: benchColumns.map((c) => c.id), columns: {} }, + groupKeys: [], + groupBy: [] +} + +async function longestBlock(run: () => Promise): Promise { + let longest = 0 + let previous = performance.now() + let watching = true + + const tick = () => { + const now = performance.now() + longest = Math.max(longest, now - previous) + previous = now + if (watching) setTimeout(tick, 0) + } + setTimeout(tick, 0) + + await run() + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + watching = false + return longest +} + +describe('the worker row model keeps the filtering off the main thread', () => { + it('really is on a worker in a browser', async () => { + const source = workerDataSource(rows, { columns: benchColumns }) + expect(source.usingWorker).toBe(true) + + const result = await source.getRows(emptyRequest) + expect(result.rowCount).toBeGreaterThan(0) + expect(result.rows.length).toBe(50) + source.dispose() + }) + + it('leaves the calling thread free while it filters', async () => { + const fresh = { + ...emptyRequest, + filterModel: { ...emptyRequest.filterModel, quick: 'person 2' } + } + + const onWorker = workerDataSource(rows, { columns: benchColumns }) + await onWorker.getRows(emptyRequest) + const workerBlock = await longestBlock(() => onWorker.getRows(fresh)) + onWorker.dispose() + + const inline = workerDataSource(rows, { columns: benchColumns, inline: true }) + await inline.getRows(emptyRequest) + const inlineBlock = await longestBlock(() => inline.getRows(fresh)) + + // eslint-disable-next-line no-console + console.info( + ` longest blocked task: worker ${workerBlock.toFixed(1)}ms vs inline ` + + `${inlineBlock.toFixed(1)}ms over ${rows.length} rows` + ) + + expect(workerBlock).toBeLessThan(16) + expect(inlineBlock).toBeGreaterThan(5) + }) + + it('answers the same rows either way', async () => { + const onWorker = workerDataSource(rows, { columns: benchColumns }) + const inline = workerDataSource(rows, { columns: benchColumns, inline: true }) + + const [a, b] = await Promise.all([ + onWorker.getRows(emptyRequest), + inline.getRows(emptyRequest) + ]) + onWorker.dispose() + + expect(a.rowCount).toBe(b.rowCount) + expect(a.rows.map((row) => row.id)).toEqual(b.rows.map((row) => row.id)) + }) + + it('never spends a whole frame handing the rows over', async () => { + const longest = await longestBlock(async () => { + const source = workerDataSource(rows, { columns: benchColumns }) + await source.getRows(emptyRequest) + source.dispose() + }) + + // eslint-disable-next-line no-console + console.info(` longest load task over ${rows.length} rows: ${longest.toFixed(1)}ms`) + expect(longest).toBeLessThan(16) + }) +}) diff --git a/src/tests/xlsx-export.svelte.test.ts b/src/tests/xlsx-export.svelte.test.ts new file mode 100644 index 0000000..95ad893 --- /dev/null +++ b/src/tests/xlsx-export.svelte.test.ts @@ -0,0 +1,217 @@ +import { + createDataGrid, + filtering, + selection, + sorting, + type ColumnDef, + type GridState +} from '$lib/index.js' +import { describe, expect, it, vi } from 'vitest' +import { buildGridXlsx, downloadGridXlsx } from '../lib/features/xlsx/export.js' +import { toSerialDate } from '../lib/features/xlsx/sheet.js' +import { XLSX_MIME } from '../lib/features/xlsx/workbook.js' + +interface Order { + id: number + customer: string + total: number + placedAt: string + active: boolean +} + +const orders: Order[] = [ + { id: 1, customer: 'Alice', total: 1200.5, placedAt: '2026-01-15T00:00:00Z', active: true }, + { id: 2, customer: 'Bob & Co', total: 950, placedAt: '2025-12-31T00:00:00Z', active: false }, + { id: 3, customer: 'Charlie', total: 300, placedAt: '2026-02-01T00:00:00Z', active: true } +] + +const columns: ColumnDef[] = [ + { id: 'customer', header: 'Customer', width: 200, filter: 'text' }, + { id: 'total', header: 'Total', type: 'currency', width: 140, sortable: true }, + { id: 'placedAt', header: 'Placed', type: 'date', width: 140 }, + { id: 'active', header: 'Active', type: 'boolean', width: 100 } +] + +function makeGrid(extra: Parameters>[0]['features'] = []) { + return createDataGrid({ + columns, + data: orders, + getRowId: (order) => String(order.id), + features: [sorting(), filtering(), ...(extra ?? [])] + }) +} + +function partOf(bytes: Uint8Array, path: string): string { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const decoder = new TextDecoder() + + let end = bytes.length - 22 + while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end-- + const count = view.getUint16(end + 10, true) + let offset = view.getUint32(end + 16, true) + + for (let i = 0; i < count; i++) { + const size = view.getUint32(offset + 24, true) + const nameLength = view.getUint16(offset + 28, true) + const localOffset = view.getUint32(offset + 42, true) + const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength)) + + if (name === path) { + const localNameLength = view.getUint16(localOffset + 26, true) + const extra = view.getUint16(localOffset + 28, true) + const start = localOffset + 30 + localNameLength + extra + return decoder.decode(bytes.subarray(start, start + size)) + } + offset += 46 + nameLength + } + throw new Error(`${path} missing from the archive`) +} + +const sheetOf = (grid: GridState, options = {}) => + partOf(buildGridXlsx(grid, options), 'xl/worksheets/sheet1.xml') + +describe('exporting a grid', () => { + it('writes the visible columns as the header row', () => { + const sheet = sheetOf(makeGrid()) + expect(sheet).toContain('Customer') + expect(sheet).toContain('Total') + expect(sheet).toContain('Placed') + }) + + it('writes numbers as numbers, not as formatted text', () => { + expect(sheetOf(makeGrid())).toContain('1200.5') + }) + + it('turns an ISO date column into an Excel serial so it sorts as a date', () => { + const local = new Date('2026-01-15T00:00:00Z') + const expected = toSerialDate( + new Date(local.getFullYear(), local.getMonth(), local.getDate()) + ) + const sheet = sheetOf(makeGrid()) + + expect(sheet).toContain(`${expected}`) + expect(sheet).not.toContain('2026-01-15T00:00:00Z') + }) + + it('writes a date column as a whole serial so it compares equal to DATE()', () => { + const sheet = sheetOf(makeGrid()) + const serials = [...sheet.matchAll(/([\d.]+)<\/v>/g)].map((match) => + Number(match[1]) + ) + + expect(serials).toHaveLength(3) + expect(serials.every(Number.isInteger)).toBe(true) + }) + + it('carries booleans as booleans', () => { + expect(sheetOf(makeGrid())).toContain('t="b">1') + }) + + it('gives a currency column a currency number format', () => { + const bytes = buildGridXlsx(makeGrid()) + const sheet = partOf(bytes, 'xl/worksheets/sheet1.xml') + const styles = partOf(bytes, 'xl/styles.xml') + + const id = Number(/]*?(?:\/>|>.*?<\/xf>)/g)].map((m) => m[0]) + const cellXfs = records.slice( + records.length - Number(/ { + expect(sheetOf(makeGrid())).toContain('Bob & Co') + }) + + it('exports what the pipeline produced, so a filter narrows the file', () => { + const grid = makeGrid() + ;(grid.api.setQuickFilter as (query: string) => void)('Charlie') + + const sheet = sheetOf(grid) + expect(sheet).toContain('Charlie') + expect(sheet).not.toContain('Alice') + }) + + it('follows the sort order rather than the source order', () => { + const grid = makeGrid() + ;(grid.api.setSort as (sort: { columnId: string; direction: 'asc' | 'desc' }[]) => void)([ + { columnId: 'total', direction: 'asc' } + ]) + + const sheet = sheetOf(grid) + expect(sheet.indexOf('Charlie')).toBeLessThan(sheet.indexOf('Alice')) + }) + + it('takes only the columns asked for, in that order', () => { + const sheet = sheetOf(makeGrid(), { columnIds: ['total', 'customer'] }) + expect(sheet.indexOf('Total')).toBeLessThan(sheet.indexOf('Customer')) + expect(sheet).not.toContain('Placed') + }) + + it('lets a caller override a cell value', () => { + const sheet = sheetOf(makeGrid(), { + value: (row: Order, column: { id: string }) => + column.id === 'customer' ? row.customer.toUpperCase() : undefined + }) + expect(sheet).toContain('ALICE') + }) + + it('exports only the selected rows when asked', () => { + const grid = makeGrid([selection()]) + ;(grid.api.selectRow as (id: string) => void)('3') + + const sheet = sheetOf(grid, { selectedOnly: true }) + expect(sheet).toContain('Charlie') + expect(sheet).not.toContain('Alice') + }) + + it('leaves the checkbox column out of the file', () => { + const grid = makeGrid([selection()]) + expect(sheetOf(grid)).not.toContain('__dg-select__') + }) +}) + +describe('downloading', () => { + it('hands the browser a blob of the right type and file name', async () => { + const clicks: HTMLAnchorElement[] = [] + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement + ) { + clicks.push(this) + }) + const createUrl = vi.spyOn(URL, 'createObjectURL') + const revoke = vi.spyOn(URL, 'revokeObjectURL') + + await downloadGridXlsx(makeGrid(), { filename: 'orders' }) + + expect(clicks[0]!.download).toBe('orders.xlsx') + const blob = createUrl!.mock.calls[0]![0] as Blob + expect(blob.type).toBe(XLSX_MIME) + + expect(revoke).not.toHaveBeenCalled() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(revoke).toHaveBeenCalled() + + const head = new Uint8Array(await blob.arrayBuffer()) + expect([...head.slice(0, 2)]).toEqual([0x50, 0x4b]) + expect(blob.size).toBeGreaterThan(0) + + click.mockRestore() + createUrl.mockRestore() + revoke.mockRestore() + }) + + it('does not double up the extension', async () => { + const clicks: string[] = [] + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement + ) { + clicks.push(this.download) + }) + + await downloadGridXlsx(makeGrid(), { filename: 'report.xlsx' }) + expect(clicks[0]).toBe('report.xlsx') + click.mockRestore() + }) +}) From 9905e7f460c0c5214c7742733e7e865380bc5292 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 21:55:24 +0700 Subject: [PATCH 09/46] test: bring over the suites built around the component that no longer exists Ten more: grouping, find and replace, range selection, show-values-as, tree and detail, conditional formatting, the rule panel, the import wizard, the server row model and formula editing. The old wrapper becomes `DataGrid`, its gate tests go, and a part is mounted inside a root rather than handed a grid. Two of them found real defects rather than needing adaptation, which is the reason they were worth porting. A server grid said "No data" during its first request. The old wrapper used to pass the model's loading state down and nothing replaced it when it went. `GridState.status` is what a feature that owns the rows sets instead: the body reads one field, a prop still wins, and `GridBody` never names `serverRowModel`, so the bundle is unchanged. The loading skeleton drew `role="row"` around plain divs. A row owning no `gridcell` is a row a screen reader cannot read, and axe says so as soon as a grid is both loading and holding rows, which a server grid routinely is. The cells have the role now. That last one changed a test here that had pinned the defect: it asserted zero gridcells while loading. It now asserts what it meant - that cells are on screen and every one of them is empty of data. Two more tests pinned mechanisms rather than behaviour and were rewritten to the behaviour: the fill drag suppresses text selection through a style on the grid root rather than a class on a wrapper that no longer exists, and the sweep for leaked English words feeds each label function three arguments now that the table is the whole grid's and not one feature's. --- src/lib/components/grid/GridBody.svelte | 26 +- src/lib/core/grid/grid.svelte.ts | 18 + src/lib/core/grid/index.ts | 2 +- .../server-row-model/ServerRows.svelte | 13 + src/tests/InRoot.svelte | 8 +- .../conditional-formatting.svelte.test.ts | 247 ++++++++ src/tests/find-replace.svelte.test.ts | 183 ++++++ src/tests/format-panel.svelte.test.ts | 346 +++++++++++ src/tests/formula-editing.svelte.test.ts | 131 ++++ src/tests/grouping.svelte.test.ts | 162 +++++ src/tests/import-wizard.svelte.test.ts | 239 +++++++ src/tests/keyboard.svelte.test.ts | 8 +- src/tests/range-selection.svelte.test.ts | 553 ++++++++++++++++ src/tests/server-row-model.svelte.test.ts | 588 ++++++++++++++++++ src/tests/show-values-as.svelte.test.ts | 78 +++ src/tests/tree-detail.svelte.test.ts | 254 ++++++++ 16 files changed, 2849 insertions(+), 7 deletions(-) create mode 100644 src/tests/conditional-formatting.svelte.test.ts create mode 100644 src/tests/find-replace.svelte.test.ts create mode 100644 src/tests/format-panel.svelte.test.ts create mode 100644 src/tests/formula-editing.svelte.test.ts create mode 100644 src/tests/grouping.svelte.test.ts create mode 100644 src/tests/import-wizard.svelte.test.ts create mode 100644 src/tests/range-selection.svelte.test.ts create mode 100644 src/tests/server-row-model.svelte.test.ts create mode 100644 src/tests/show-values-as.svelte.test.ts create mode 100644 src/tests/tree-detail.svelte.test.ts diff --git a/src/lib/components/grid/GridBody.svelte b/src/lib/components/grid/GridBody.svelte index 219b006..fb49650 100644 --- a/src/lib/components/grid/GridBody.svelte +++ b/src/lib/components/grid/GridBody.svelte @@ -49,15 +49,28 @@ let { emptyText, - loading = false, + loading: loadingProp, loadingRows, - error = null, - onRetry, + error: errorProp, + onRetry: onRetryProp, fullWidthRow, class: className }: GridBodyProps = $props() const grid = getGridContext() + + /** A feature may hand back anything it caught; the body draws a line. */ + function errorText(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined + return value instanceof Error ? value.message : String(value) + } + + // A prop wins; a feature that owns the rows answers when there is none. + // Without the fallback a server grid says "no data" during its first + // request, which is the one moment it certainly does not know that. + const loading = $derived(loadingProp ?? grid.status?.loading ?? false) + const error = $derived(errorProp ?? errorText(grid.status?.error) ?? null) + const onRetry = $derived(onRetryProp ?? grid.status?.onRetry) const labels = $derived(grid.labels) const virtualization = getVirtualization(grid) const reorder = getRowReorder(grid) @@ -729,7 +742,14 @@ {#each Array.from({ length: skeletonRows }, (_, i) => i) as i (i)}
{#each columnWindow.renderColumns as entry (entry.column.id)} +
diff --git a/src/lib/core/grid/grid.svelte.ts b/src/lib/core/grid/grid.svelte.ts index 47eb4b2..d36f495 100644 --- a/src/lib/core/grid/grid.svelte.ts +++ b/src/lib/core/grid/grid.svelte.ts @@ -237,6 +237,17 @@ export class GridState { return composeReaders(this.#valueGates, { grid: this, column, purpose }) } + /** + * What a feature that owns the rows wants the body to draw instead of + * rows: it is fetching, it failed, and how to try again. + * + * The body cannot ask the feature directly. Naming `serverRowModel` there + * would put it in every bundle, registered or not, so the feature says it + * here and the body reads one field. A prop on `DataGrid` still wins: an + * application that says `loading` means it. + */ + status = $state.raw(undefined) + /** * One cell, as the given purpose is allowed to see it. The default is what * the cell draws, which is what every renderer, tooltip and `cell` snippet @@ -251,6 +262,13 @@ export class GridState { } } +/** What the body draws when a feature is fetching the rows rather than holding them. */ +export interface GridStatus { + loading?: boolean + error?: unknown + onRetry?: () => void +} + export function createDataGrid(options: DataGridOptions): GridState { return new GridState(options) } diff --git a/src/lib/core/grid/index.ts b/src/lib/core/grid/index.ts index f0e70ab..adb1483 100644 --- a/src/lib/core/grid/index.ts +++ b/src/lib/core/grid/index.ts @@ -8,7 +8,7 @@ export { EventBus, type EventHandler } from './events.js' export { createDataGrid } from './grid.svelte.js' -export type { GridState } from './grid.svelte.js' +export type { GridState, GridStatus } from './grid.svelte.js' export { composePipeline, PIPELINE_ORDER, type Pipeline } from './pipeline.svelte.js' export { buildRowNodes, diff --git a/src/lib/features/server-row-model/ServerRows.svelte b/src/lib/features/server-row-model/ServerRows.svelte index 73929c0..e837236 100644 --- a/src/lib/features/server-row-model/ServerRows.svelte +++ b/src/lib/features/server-row-model/ServerRows.svelte @@ -18,6 +18,19 @@ untrack(() => model?.start()) }) + // What the body draws while there is no answer yet, and if one never came. + $effect(() => { + if (!model) return + grid.status = { + loading: model.loading || !model.answered, + error: model.error, + onRetry: model.refresh + } + return () => { + grid.status = undefined + } + }) + $effect(() => { const range = virtualization?.virtualizer.range if (range) untrack(() => model?.ensureRange(range.start, range.end)) diff --git a/src/tests/InRoot.svelte b/src/tests/InRoot.svelte index 22b35a1..4b0ed9a 100644 --- a/src/tests/InRoot.svelte +++ b/src/tests/InRoot.svelte @@ -15,11 +15,15 @@ partProps = {} }: { grid: GridState - component: Component> + /** Omit it to mount nothing but the root, which is what a feature that + * contributes its own component needs. */ + component?: Component> partProps?: Record } = $props() - + {#if Part} + + {/if} diff --git a/src/tests/conditional-formatting.svelte.test.ts b/src/tests/conditional-formatting.svelte.test.ts new file mode 100644 index 0000000..510eb5f --- /dev/null +++ b/src/tests/conditional-formatting.svelte.test.ts @@ -0,0 +1,247 @@ +import { + createDataGrid, + sorting, + virtualization, + type ColumnDef, + type DataGridProps, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import InRoot from './InRoot.svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import { + conditionalFormatting, + getConditionalFormatting +} from '../lib/features/conditional-formatting/conditional-formatting.svelte.js' +import type { ConditionalFormattingOptions } from '../lib/features/conditional-formatting/conditional-formatting.types.js' +import { formula } from '../lib/features/formula/formula.svelte.js' +import { serverRowModel } from '../lib/features/server-row-model/server-row-model.svelte.js' +import type { DataSource } from '../lib/features/server-row-model/server-row-model.types.js' + +interface Deal { + id: number + owner: string + status: string + unit: number + qty: number + total?: number +} + +const deals: Deal[] = [ + { id: 1, owner: 'Ann', status: 'open', unit: 100, qty: 1 }, + { id: 2, owner: 'Bob', status: 'overdue', unit: 400, qty: 1 }, + { id: 3, owner: 'Ann', status: 'open', unit: 200, qty: 1 } +] + +const columns: ColumnDef[] = [ + { id: 'owner', header: 'Owner', width: 140 }, + { id: 'status', header: 'Status', width: 140 }, + { id: 'unit', header: 'Unit', width: 120, align: 'right' }, + { id: 'total', header: 'Total', width: 120, align: 'right' } +] + +const TypedGrid = DataGrid as unknown as Component> +// `serverRowModel()` contributes its own component, so a root is enough. +const TypedServerRows = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ props: { grid } }) + +function makeGrid(options: ConditionalFormattingOptions): GridState { + return createDataGrid({ + columns, + data: deals, + getRowId: (deal) => String(deal.id), + features: [ + sorting(), + formula({ columns: { total: 'unit * qty' } }), + conditionalFormatting(options) + ] + }) +} + +async function renderGrid(grid: GridState) { + const screen = await render(TypedGrid, { grid }) + await expect.element(screen.getByRole('grid')).toBeVisible() + return screen +} + +function renderedRowIds(container: Element): string[] { + return [...container.querySelectorAll('[role="row"][data-dg-row-id]')].map( + (row) => row.getAttribute('data-dg-row-id') ?? '' + ) +} + +function cellAt(container: Element, row: number, col: number): HTMLElement { + return container.querySelector(`[data-dg-cell="${row}:${col}"]`)! +} + +describe('conditional formatting paints the rendered grid', () => { + it('writes the colour scale onto the cell it belongs to', async () => { + const screen = await renderGrid( + makeGrid({ rules: [{ kind: 'colorScale', column: 'unit' }] }) + ) + + expect(cellAt(screen.container, 1, 2).style.backgroundColor).not.toBe('') + expect(cellAt(screen.container, 1, 0).style.backgroundColor).toBe('') + }) + + it('paints a formula column, whose value exists only after the pipeline ran', async () => { + const screen = await renderGrid(makeGrid({ rules: [{ kind: 'dataBar', column: 'total' }] })) + + expect(cellAt(screen.container, 1, 3).style.backgroundImage).toContain('linear-gradient') + }) + + it('repaints when a rule is added at runtime', async () => { + const grid = makeGrid({}) + const screen = await renderGrid(grid) + expect(cellAt(screen.container, 1, 1).className).not.toContain('bg-tertiary') + + grid.api.addFormatRule?.({ + kind: 'expression', + id: 'overdue', + when: 'status = "overdue"' + }) + + await expect.poll(() => cellAt(screen.container, 1, 1).className).toContain('bg-tertiary') + }) + + it('leaves the cell out of the accessibility tree changes it does not make', async () => { + const screen = await renderGrid( + makeGrid({ rules: [{ kind: 'colorScale', column: 'unit' }] }) + ) + + expect(cellAt(screen.container, 0, 2).getAttribute('aria-selected')).toBeNull() + }) + + it('is axe-clean while painting', async () => { + const screen = await renderGrid( + makeGrid({ + rules: [ + { kind: 'colorScale', column: 'unit' }, + { kind: 'expression', when: 'status = "overdue"' } + ] + }) + ) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) + +const SERVER_TOTAL = 120 + +function serverSource(): DataSource { + return { + async getRows(request) { + const rows: Deal[] = [] + for (let i = request.startRow; i < Math.min(request.endRow, SERVER_TOTAL); i++) { + rows.push({ + id: i + 1, + owner: `Owner ${i % 5}`, + status: i % 2 === 0 ? 'open' : 'overdue', + unit: (i + 1) * 10, + qty: 1 + }) + } + return { rows, rowCount: SERVER_TOTAL } + } + } +} + +describe('conditional formatting under virtualization and a server row model', () => { + it('scales over every row the grid holds, not only the rendered window', async () => { + const many: Deal[] = Array.from({ length: 200 }, (_, i) => ({ + id: i + 1, + owner: `Owner ${i % 5}`, + status: 'open', + unit: i + 1, + qty: 1 + })) + const grid = createDataGrid({ + columns, + data: many, + getRowId: (deal) => String(deal.id), + features: [ + sorting(), + virtualization({ rowHeight: 40 }), + conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'unit' }] }) + ] + }) + const screen = await render(TypedGrid, { grid, class: 'h-[400px]' }) + await expect.element(screen.getByRole('grid')).toBeVisible() + + expect(renderedRowIds(screen.container).length).toBeLessThan(40) + expect(cellAt(screen.container, 0, 2).style.backgroundColor).toContain('0%') + }) + + it('does not re-read the rows while the user scrolls', async () => { + const many: Deal[] = Array.from({ length: 300 }, (_, i) => ({ + id: i + 1, + owner: `Owner ${i % 5}`, + status: 'open', + unit: i + 1, + qty: 1 + })) + const grid = createDataGrid({ + columns, + data: many, + getRowId: (deal) => String(deal.id), + features: [ + sorting(), + virtualization({ rowHeight: 40 }), + conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'unit' }] }) + ] + }) + const screen = await render(TypedGrid, { grid, class: 'h-[400px]' }) + await expect.element(screen.getByRole('grid')).toBeVisible() + + const viewport = screen.container.querySelector('[role="grid"]') as HTMLElement + const before = grid.preWindowNodes + const firstBefore = renderedRowIds(screen.container)[0] + + viewport.scrollTop = 4000 + viewport.dispatchEvent(new Event('scroll')) + await expect.poll(() => renderedRowIds(screen.container)[0]).not.toBe(firstBefore) + + expect(grid.preWindowNodes).toBe(before) + expect(renderedRowIds(screen.container).length).toBeLessThan(40) + }) + + it('paints nothing from a ranked rule on a server grid, placeholder or not', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const grid = createDataGrid({ + columns, + data: [], + getRowId: (deal) => String(deal.id), + rowModel: 'server', + features: [ + sorting(), + virtualization({ rowHeight: 40 }), + serverRowModel(serverSource(), { + mode: 'infinite', + blockSize: 20, + placeholder: (index) => ({ id: -(index + 1) }) as Deal + }), + conditionalFormatting({ rules: [{ kind: 'colorScale', column: 'unit' }] }) + ] + }) + await render(TypedServerRows, inRoot(grid)) + const screen = await renderGrid(grid) + + await expect.poll(() => grid.data.length).toBeGreaterThan(0) + await expect.poll(() => cellAt(screen.container, 0, 2).style.backgroundColor).toBe('') + + const state = getConditionalFormatting(grid)! + const unit = grid.columns.visible.find((entry) => entry.id === 'unit')! + const loading = grid.preWindowNodes.find((entry) => Number(entry.row.id) < 0) + expect(loading, 'a block past the first should still be a placeholder').toBeDefined() + expect(state.decoration(loading!, unit)).toBeUndefined() + expect(state.skippedRules).toHaveLength(1) + warn.mockRestore() + }) +}) diff --git a/src/tests/find-replace.svelte.test.ts b/src/tests/find-replace.svelte.test.ts new file mode 100644 index 0000000..7d44025 --- /dev/null +++ b/src/tests/find-replace.svelte.test.ts @@ -0,0 +1,183 @@ +import { + createDataGrid, + editing, + filtering, + sorting, + type ColumnDef, + type DataGridProps, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import InRoot from './InRoot.svelte' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { userEvent } from 'vitest/browser' +import FindReplace from '../lib/components/panels/FindReplace.svelte' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import { findReplace, getFindReplace } from '../lib/features/find-replace/find-replace.svelte.js' +import type { FindReplaceOptions } from '../lib/features/find-replace/find-replace.types.js' + +interface Row { + id: number + dept: string + name: string + city: string +} + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept', width: 120 }, + { id: 'name', header: 'Name', width: 160, editable: true }, + { id: 'city', header: 'City', width: 160, editable: true } +] + +const TypedGrid = DataGrid as unknown as Component> +// The part takes its grid from context, so it is mounted inside one. +const TypedPanel = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ props: { grid, component: FindReplace } }) + +function makeGrid(options: FindReplaceOptions = {}): GridState { + return createDataGrid({ + columns, + data: [ + { id: 1, dept: 'Core', name: 'Anna', city: 'Hà Nội' }, + { id: 2, dept: 'Data', name: 'Bình', city: 'Hà Nam' }, + { id: 3, dept: 'Core', name: 'Chi', city: 'Huế' } + ], + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), editing(), findReplace(options)] + }) +} + +async function mount(grid: GridState) { + const screen = await render(TypedPanel, inRoot(grid)) + await render(TypedGrid, { grid }) + return screen +} + +describe('the hotkeys reach the grid from anywhere on the page', () => { + it('opens on Ctrl+F without the grid ever being focused', async () => { + const grid = makeGrid() + await mount(grid) + expect(document.activeElement).toBe(document.body) + + await userEvent.keyboard('{Control>}f{/Control}') + await expect.poll(() => getFindReplace(grid)!.open).toBe(true) + }) + + it('opens on the Meta variant too, which is the one a Mac sends', async () => { + const grid = makeGrid() + await mount(grid) + + await userEvent.keyboard('{Meta>}f{/Meta}') + await expect.poll(() => getFindReplace(grid)!.open).toBe(true) + }) + + it('puts the caret in the search box, so typing starts a search', async () => { + const grid = makeGrid() + const screen = await mount(grid) + + await userEvent.keyboard('{Control>}f{/Control}') + await expect.poll(() => getFindReplace(grid)!.open).toBe(true) + + const box = screen.container.querySelector('[data-dg-find] input') + await expect.poll(() => document.activeElement).toBe(box) + }) + + it('Ctrl+H lands in the replace box', async () => { + const grid = makeGrid() + const screen = await mount(grid) + + await userEvent.keyboard('{Control>}h{/Control}') + await expect.poll(() => getFindReplace(grid)!.open).toBe(true) + + const inputs = screen.container.querySelectorAll('[data-dg-find] input') + await expect.poll(() => document.activeElement).toBe(inputs[1]) + }) + + it('leaves the keys alone when hotkeys are off', async () => { + const grid = makeGrid({ hotkeys: false }) + await mount(grid) + + await userEvent.keyboard('{Control>}f{/Control}') + expect(getFindReplace(grid)!.open).toBe(false) + }) +}) + +describe('the panel', () => { + it('steps with Enter and closes with Escape', async () => { + const grid = makeGrid() + const screen = await mount(grid) + await userEvent.keyboard('{Control>}f{/Control}') + await expect.poll(() => getFindReplace(grid)!.open).toBe(true) + + await userEvent.keyboard('Hà') + await expect.poll(() => getFindReplace(grid)!.total).toBe(2) + + await userEvent.keyboard('{Enter}') + await expect.poll(() => getFindReplace(grid)!.current).toBe(0) + await userEvent.keyboard('{Enter}') + await expect.poll(() => getFindReplace(grid)!.current).toBe(1) + + await userEvent.keyboard('{Enter}') + await expect.poll(() => getFindReplace(grid)!.current).toBe(0) + + await userEvent.keyboard('{Escape}') + await expect.poll(() => getFindReplace(grid)!.open).toBe(false) + expect(screen.container.querySelector('[data-dg-find]')).toBeNull() + }) + + it('greys out Replace and says why when nothing found is writable', async () => { + const grid = makeGrid() + const screen = await mount(grid) + const state = getFindReplace(grid)! + state.show() + state.query = 'Data' + state.step(1) + + const replaceButtons = () => + [...screen.container.querySelectorAll('[data-dg-find] button')].filter((button) => + /Replace/.test(button.textContent ?? '') + ) + + await expect.poll(() => replaceButtons().length).toBe(2) + expect(replaceButtons().every((button) => button.hasAttribute('disabled'))).toBe(true) + + await expect + .poll(() => + screen.container.querySelector('[data-dg-find] [aria-live]')?.textContent?.trim() + ) + .toBe('1 found, none editable') + }) + + it('says how many cells a replace wrote', async () => { + const grid = makeGrid() + const screen = await mount(grid) + const state = getFindReplace(grid)! + state.show() + state.query = 'Hà' + state.replacement = 'Ha' + state.replaceAll() + + await expect + .poll(() => + screen.container.querySelector('[data-dg-find] [aria-live]')?.textContent?.trim() + ) + .toBe('Replaced 2') + }) + + it('is axe-clean with matches on screen', async () => { + const grid = makeGrid() + const screen = await mount(grid) + const state = getFindReplace(grid)! + state.show() + state.query = 'Hà' + state.step(1) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) diff --git a/src/tests/format-panel.svelte.test.ts b/src/tests/format-panel.svelte.test.ts new file mode 100644 index 0000000..b758e77 --- /dev/null +++ b/src/tests/format-panel.svelte.test.ts @@ -0,0 +1,346 @@ +import { + createDataGrid, + sorting, + virtualization, + type ColumnDef, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { userEvent } from 'vitest/browser' +import ConditionalFormattingPanel from '../lib/components/panels/ConditionalFormattingPanel.svelte' +import { defaultLabels, mergeLabels } from '$lib/index.js' +import { conditionalFormatting } from '../lib/features/conditional-formatting/conditional-formatting.svelte.js' +import { formula } from '../lib/features/formula/formula.svelte.js' +import type { FormatRule } from '../lib/features/conditional-formatting/conditional-formatting.types.js' +import { serverRowModel } from '../lib/features/server-row-model/server-row-model.svelte.js' +import type { DataSource } from '../lib/features/server-row-model/server-row-model.types.js' +import { viVN } from '../lib/locales/vi-VN.js' +import InRoot from './InRoot.svelte' + +interface Deal { + id: number + owner: string + status: string + amount: number +} + +const columns: ColumnDef[] = [ + { id: 'owner', header: 'Owner', width: 140 }, + { id: 'status', header: 'Status', width: 140 }, + { id: 'amount', header: 'Amount', width: 120 } +] + +const deals: Deal[] = [ + { id: 1, owner: 'Ann', status: 'open', amount: 100 }, + { id: 2, owner: 'Bob', status: 'overdue', amount: 400 } +] + +// The panel takes its grid from context, so it is mounted inside one. +const TypedPanel = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ + props: { grid, component: ConditionalFormattingPanel } +}) + +function makeGrid(rules: FormatRule[] = [], locale?: string): GridState { + return createDataGrid({ + columns, + data: deals, + getRowId: (deal) => String(deal.id), + locales: [viVN], + locale, + features: [sorting(), conditionalFormatting({ rules })] + }) +} + +// A pack answers only for what it overrides, so English fills the rest in. +// The panel reads a complete table; a test comparing against one should too. +const vi = mergeLabels(viVN.labels) + +const panelOf = (container: Element) => container.querySelector('[data-dg-format-panel]')! + +// `serverRowModel()` contributes its own component, so a root is enough. +const TypedRoot = InRoot as unknown as Component> + +async function offeredColumns(container: Element): Promise { + const trigger = [...container.querySelectorAll('button[aria-label]')].find( + (button) => button.getAttribute('aria-label') === defaultLabels.formatColumn + )! + await userEvent.click(trigger as HTMLElement) + const items = [...document.querySelectorAll('[role="option"]')].map( + (option) => option.textContent?.trim() ?? '' + ) + await userEvent.keyboard('{Escape}') + return items +} + +describe('the rule panel builds rules the app never wrote', () => { + it('says so when a grid carries no rules', async () => { + const screen = await render(TypedPanel, inRoot(makeGrid())) + expect(panelOf(screen.container).textContent).toContain(defaultLabels.formatEmpty) + }) + + it('names each rule by its kind and the column it paints', async () => { + const grid = makeGrid([ + { kind: 'colorScale', column: 'amount' }, + { kind: 'expression', when: 'amount > 1' } + ]) + const screen = await render(TypedPanel, inRoot(grid)) + const text = panelOf(screen.container).textContent ?? '' + + expect(text).toContain( + defaultLabels.formatRuleName(defaultLabels.formatKindColorScale, 'Amount') + ) + expect(text).toContain( + defaultLabels.formatRuleName( + defaultLabels.formatKindExpression, + defaultLabels.formatWholeRow + ) + ) + }) + + it('adds the rule the form is showing, against the first column it can read', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + await userEvent.click( + screen.getByRole('button', { name: defaultLabels.formatAdd }).element() as HTMLElement + ) + + expect(grid.api.getFormatRules?.()).toEqual([ + expect.objectContaining({ kind: 'colorScale', column: 'amount' }) + ]) + expect(panelOf(screen.container).textContent).toContain( + defaultLabels.formatRuleName(defaultLabels.formatKindColorScale, 'Amount') + ) + }) + + it('offers a colour scale only the columns it can read', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + const picker = screen.container.querySelectorAll('button[aria-label]') + const columnPicker = [...picker].find( + (button) => button.getAttribute('aria-label') === defaultLabels.formatColumn + )! + await userEvent.click(columnPicker as HTMLElement) + + const offered = [...document.querySelectorAll('[role="option"]')].map( + (option) => option.textContent?.trim() ?? '' + ) + expect(offered).toContain('Amount') + expect(offered).not.toContain('Owner') + expect(offered).not.toContain('Status') + }) + + it('removes the rule the chip names, and only that one', async () => { + const grid = makeGrid([ + { kind: 'colorScale', column: 'amount', id: 'scale' }, + { kind: 'duplicates', column: 'owner', id: 'dupes' } + ]) + const screen = await render(TypedPanel, inRoot(grid)) + + const name = defaultLabels.formatRuleName(defaultLabels.formatKindColorScale, 'Amount') + const remove = screen.container.querySelector( + `[aria-label="${defaultLabels.formatRemoveRule(name)}"]` + )! + await userEvent.click(remove) + + expect(grid.api.getFormatRules?.().map((rule) => rule.id)).toEqual(['dupes']) + }) + + it('clears every rule at once', async () => { + const grid = makeGrid([{ kind: 'dataBar', column: 'amount' }]) + const screen = await render(TypedPanel, inRoot(grid)) + + await userEvent.click( + screen.getByRole('button', { name: defaultLabels.formatClear }).element() as HTMLElement + ) + + expect(grid.api.getFormatRules?.()).toHaveLength(0) + }) + + it('shows why an expression will not parse, next to the rule that will not', async () => { + const grid = makeGrid([{ kind: 'expression', when: 'status =' }]) + const screen = await render(TypedPanel, inRoot(grid)) + + const live = panelOf(screen.container).querySelector('[aria-live="polite"]') + expect(live?.textContent).toContain('Will not parse') + }) +}) + +describe('the picker under a grid that cannot answer yet', () => { + it('offers every column while there is no row to read', async () => { + const grid = createDataGrid({ + columns, + data: [], + getRowId: (deal) => String(deal.id), + features: [conditionalFormatting({})] + }) + const screen = await render(TypedPanel, inRoot(grid)) + + expect(await offeredColumns(screen.container)).toEqual(['Owner', 'Status', 'Amount']) + }) + + it('narrows to the numeric columns once a server model has loaded a page', async () => { + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const source: DataSource = { + async getRows(request) { + await held + const rows: Deal[] = [] + for (let i = request.startRow; i < Math.min(request.endRow, 40); i++) { + rows.push({ + id: i + 1, + owner: `Owner ${i}`, + status: 'open', + amount: (i + 1) * 10 + }) + } + return { rows, rowCount: 40 } + } + } + const grid = createDataGrid({ + columns, + data: [], + getRowId: (deal) => String(deal.id), + rowModel: 'server', + features: [ + sorting(), + virtualization({ rowHeight: 40 }), + serverRowModel(source, { + mode: 'infinite', + blockSize: 20, + placeholder: (index) => ({ id: -(index + 1) }) as Deal + }), + conditionalFormatting({}) + ] + }) + await render(TypedRoot, { props: { grid } }) + const screen = await render(TypedPanel, inRoot(grid)) + + expect(await offeredColumns(screen.container)).toEqual(['Owner', 'Status', 'Amount']) + + release() + await expect.poll(() => grid.data.length).toBeGreaterThan(0) + expect(await offeredColumns(screen.container)).toEqual(['Amount']) + }) + + it('offers a formula column, whose numbers exist only after the pipeline ran', async () => { + interface Line extends Deal { + total?: number + } + const grid = createDataGrid({ + columns: [...columns, { id: 'total', header: 'Total', width: 120 }], + data: deals, + getRowId: (deal) => String(deal.id), + features: [ + formula({ columns: { total: 'amount * 2' } }), + conditionalFormatting({}) + ] + }) + const screen = await render(TypedPanel, { + props: { grid, component: ConditionalFormattingPanel } + }) + + expect(await offeredColumns(screen.container)).toEqual(['Amount', 'Total']) + }) + + it('drops a column the switched kind cannot read rather than building on it', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + + const kindTrigger = [...screen.container.querySelectorAll('button[aria-label]')].find( + (button) => button.getAttribute('aria-label') === defaultLabels.formatKind + )! + await userEvent.click(kindTrigger as HTMLElement) + await userEvent.click( + [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes(defaultLabels.formatKindDuplicates) + ) as HTMLElement + ) + + const columnTrigger = [...screen.container.querySelectorAll('button[aria-label]')].find( + (button) => button.getAttribute('aria-label') === defaultLabels.formatColumn + )! + await userEvent.click(columnTrigger as HTMLElement) + await userEvent.click( + [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes('Owner') + ) as HTMLElement + ) + + await userEvent.click(kindTrigger as HTMLElement) + await userEvent.click( + [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes(defaultLabels.formatKindColorScale) + ) as HTMLElement + ) + await userEvent.click( + screen.getByRole('button', { name: defaultLabels.formatAdd }).element() as HTMLElement + ) + + expect(grid.api.getFormatRules?.()).toEqual([ + expect.objectContaining({ kind: 'colorScale', column: 'amount' }) + ]) + }) +}) + +describe('the panel speaks the configured language', () => { + it('renders in Vietnamese, aria-labels included', async () => { + const grid = makeGrid([{ kind: 'topN', column: 'amount' }], 'vi-VN') + const screen = await render(TypedPanel, inRoot(grid)) + + const panel = panelOf(screen.container) + expect(panel.textContent).toContain(vi.formatLabel) + expect(panel.textContent).toContain(vi.formatKindTopN) + + const name = vi.formatRuleName(vi.formatKindTopN, 'Amount') + expect(panel.querySelector(`[aria-label="${vi.formatRemoveRule(name)}"]`)).not.toBeNull() + }) + + it('leaves no English word behind in the panel chrome', async () => { + const screen = await render(TypedPanel, inRoot(makeGrid([], 'vi-VN'))) + + const text = (panelOf(screen.container).textContent ?? '').replace( + /Owner|Status|Amount/g, + '' + ) + // The table is the whole grid's now, not one feature's, so the + // functions in it take anything from one argument to three. Feed each + // enough and ignore the ones that want a shape rather than a value. + const english = Object.values(defaultLabels) + .map((label) => { + if (typeof label !== 'function') return label + try { + return (label as (...args: unknown[]) => string)('X', 1, 2) + } catch { + return '' + } + }) + .flatMap((value) => String(value).split(/[\s\-:()|"=]+/)) + .filter((word) => /^[A-Za-z]{3,}$/.test(word) && word !== 'sv5ui') + + for (const word of english) { + expect(text, `"${word}" leaked from a hardcoded label`).not.toContain(word) + } + }) +}) + +describe('accessibility', () => { + it('is axe-clean with rules on the panel', async () => { + const screen = await render(TypedPanel, { + grid: makeGrid([{ kind: 'colorScale', column: 'amount' }]) + }) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) diff --git a/src/tests/formula-editing.svelte.test.ts b/src/tests/formula-editing.svelte.test.ts new file mode 100644 index 0000000..66a3922 --- /dev/null +++ b/src/tests/formula-editing.svelte.test.ts @@ -0,0 +1,131 @@ +import { + createDataGrid, + editing, + type ColumnDef, + type DataGridProps, + type GridState +} from '$lib/index.js' +import type { Component } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import { formula } from '../lib/features/formula/formula.svelte.js' +import { tree } from '../lib/features/tree/tree.svelte.js' + +interface Line { + id: number + unit: number + qty: number + total?: number +} + +const columns: ColumnDef[] = [ + { id: 'unit', header: 'Unit', width: 120, editable: true }, + { id: 'qty', header: 'Qty', width: 120, editable: true }, + { id: 'total', header: 'Total', width: 140, editable: true } +] + +const TypedGrid = DataGrid as unknown as Component> + +function makeGrid(): GridState { + return createDataGrid({ + columns, + data: [{ id: 1, unit: 5, qty: 4 }], + getRowId: (row) => String(row.id), + features: [editing(), formula({ columns: { total: 'unit * qty' } })] + }) +} + +const cellOf = (container: Element, index: number): Element => + container.querySelectorAll('[data-dg-row-id="1"] [role="gridcell"]')[index]! + +async function renderGrid(grid: GridState) { + const screen = await render(TypedGrid, { grid }) + await expect.element(screen.getByRole('grid')).toBeVisible() + return screen +} + +describe('a formula column the app declared editable', () => { + it('opens no editor when the cell is double-clicked', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + const cell = cellOf(screen.container, 2) + expect(cell.textContent?.trim()).toBe('20') + cell.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + + await vi.waitFor(() => expect(grid.preWindowNodes[0]?.row.total).toBe(20)) + expect(screen.container.querySelectorAll('[role="gridcell"] input')).toHaveLength(0) + }) + + it('still opens one on the plain column beside it', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + cellOf(screen.container, 1).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + + await vi.waitFor(() => + expect( + screen.container.querySelectorAll('[role="gridcell"] input').length + ).toBeGreaterThan(0) + ) + }) +}) + +describe('a formula on a tree built from getChildren', () => { + interface Task { + id: number + name: string + rate: number + hours: number + cost?: number + children?: Task[] + } + + const TypedTree = DataGrid as unknown as Component> + + it('draws a computed value on a child three levels down', async () => { + const grid = createDataGrid({ + columns: [ + { id: 'name', header: 'Name', flex: 1, minWidth: 160 }, + { id: 'rate', header: 'Rate', width: 100 }, + { id: 'hours', header: 'Hours', width: 100 }, + { id: 'cost', header: 'Cost', width: 140 } + ], + data: [ + { + id: 1, + name: 'design', + rate: 100, + hours: 12, + children: [ + { + id: 2, + name: 'sketch', + rate: 50, + hours: 8, + children: [{ id: 3, name: 'revise', rate: 20, hours: 6 }] + } + ] + } + ], + getRowId: (row) => String(row.id), + features: [ + formula({ columns: { cost: 'rate * hours' } }), + tree({ getChildren: (row) => row.children, defaultExpandedDepth: 3 }) + ] + }) + + const screen = await render(TypedTree, { grid }) + await expect.element(screen.getByRole('treegrid')).toBeVisible() + + const costOf = (rowId: string) => + screen.container + .querySelector(`[data-dg-row-id="${rowId}"] [role="gridcell"]:last-child`) + ?.textContent?.trim() + + await vi.waitFor(() => expect(costOf('3')).toBe('120')) + expect(costOf('1')).toBe('1200') + expect(costOf('2')).toBe('400') + }) +}) diff --git a/src/tests/grouping.svelte.test.ts b/src/tests/grouping.svelte.test.ts new file mode 100644 index 0000000..4402a75 --- /dev/null +++ b/src/tests/grouping.svelte.test.ts @@ -0,0 +1,162 @@ +import { + createDataGrid, + sorting, + type ColumnDef, + type DataGridProps, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import InRoot from './InRoot.svelte' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page } from 'vitest/browser' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import GroupPanel from '../lib/components/panels/GroupPanel.svelte' +import { getGrouping, grouping } from '../lib/features/grouping/grouping.svelte.js' +import type { GroupingOptions } from '../lib/features/grouping/grouping.types.js' + +interface Person { + id: number + name: string + dept: string + country: string + salary: number +} + +const people: Person[] = [ + { id: 1, name: 'Alice', dept: 'Core', country: 'VN', salary: 100 }, + { id: 2, name: 'Bob', dept: 'Core', country: 'VN', salary: 200 }, + { id: 3, name: 'Carol', dept: 'Core', country: 'US', salary: 300 }, + { id: 4, name: 'Dave', dept: 'Data', country: 'US', salary: 400 } +] + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept', flex: 1, minWidth: 200 }, + { id: 'country', header: 'Country', width: 130 }, + { id: 'name', header: 'Name', width: 160 }, + { id: 'salary', header: 'Salary', align: 'right', width: 130 } +] + +const TypedGrid = DataGrid as unknown as Component> +// The part takes its grid from context, so it is mounted inside one. +const TypedPanel = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ props: { grid, component: GroupPanel } }) + +function makeGrid(options: GroupingOptions = {}): GridState { + return createDataGrid({ + columns, + data: people, + getRowId: (person) => String(person.id), + features: [ + sorting(), + grouping({ + expandedByDefault: false, + aggregations: { salary: 'sum' }, + ...options + }) + ] + }) +} + +function rowIds(container: Element): string[] { + return [...container.querySelectorAll('[data-dg-row-id]')].map((row) => + row.getAttribute('data-dg-row-id')! + ) +} + +async function renderGrid(grid: GridState) { + const screen = await render(TypedGrid, { grid }) + await expect.element(screen.getByRole('treegrid')).toBeVisible() + return screen +} + +describe('grouping through the Community kernel', () => { + it('renders group rows the kernel treats as treegrid nodes', async () => { + const grid = makeGrid({ by: ['dept'] }) + const screen = await renderGrid(grid) + + expect(rowIds(screen.container)).toEqual(['group:dept=Core', 'group:dept=Data']) + + const groupRow = screen.container.querySelector('[data-dg-row-id="group:dept=Core"]')! + expect(groupRow.getAttribute('aria-level')).toBe('1') + expect(groupRow.getAttribute('aria-expanded')).toBe('false') + expect(groupRow.getAttribute('aria-setsize')).toBe('2') + expect(groupRow.textContent).toContain('Core (3)') + }) + + it('expands and collapses a group with the kernel chevron', async () => { + const grid = makeGrid({ by: ['dept'] }) + const screen = await renderGrid(grid) + + await page.getByRole('button', { name: 'Expand row' }).first().click() + await expect + .poll(() => rowIds(screen.container)) + .toEqual(['group:dept=Core', '1', '2', '3', 'group:dept=Data']) + + await page.getByRole('button', { name: 'Collapse row' }).first().click() + await expect + .poll(() => rowIds(screen.container)) + .toEqual(['group:dept=Core', 'group:dept=Data']) + }) + + it('shows aggregates in their own column on the group row', async () => { + const grid = makeGrid({ by: ['dept'] }) + const screen = await renderGrid(grid) + + const cells = screen.container.querySelectorAll( + '[data-dg-row-id="group:dept=Core"] [role="gridcell"]' + ) + expect(cells[0]!.textContent).toContain('Core (3)') + expect(cells[3]!.textContent?.trim()).toBe('600') + }) + + it('nests several grouping levels', async () => { + const grid = makeGrid({ by: ['dept', 'country'] }) + const screen = await renderGrid(grid) + getGrouping(grid)!.expandAllGroups() + + await expect + .poll(() => rowIds(screen.container).slice(0, 3)) + .toEqual(['group:dept=Core', 'group:dept=Core|country=VN', '1']) + const nested = screen.container.querySelector( + '[data-dg-row-id="group:dept=Core|country=VN"]' + )! + expect(nested.getAttribute('aria-level')).toBe('2') + }) + + it('is axe-clean while grouped', async () => { + const grid = makeGrid({ by: ['dept'] }) + const screen = await renderGrid(grid) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) + +describe('GroupPanel', () => { + it('adds and removes grouping levels', async () => { + const grid = makeGrid() + const screen = await render(TypedPanel, inRoot(grid)) + await expect.element(page.getByText('Group by', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Add group' }).click() + await page.getByRole('menuitem', { name: 'Dept' }).click() + expect(getGrouping(grid)!.by).toEqual(['dept']) + + await page.getByRole('button', { name: 'Remove Dept grouping' }).click() + expect(getGrouping(grid)!.by).toEqual([]) + expect(screen.container.textContent).toContain('No grouping') + }) + + it('reorders levels with the chevron buttons', async () => { + const grid = makeGrid({ by: ['dept', 'country'] }) + await render(TypedPanel, inRoot(grid)) + + await page.getByRole('button', { name: 'Move Country earlier' }).click() + expect(getGrouping(grid)!.by).toEqual(['country', 'dept']) + }) +}) diff --git a/src/tests/import-wizard.svelte.test.ts b/src/tests/import-wizard.svelte.test.ts new file mode 100644 index 0000000..c9b3f65 --- /dev/null +++ b/src/tests/import-wizard.svelte.test.ts @@ -0,0 +1,239 @@ +import { createDataGrid, filtering, sorting, type ColumnDef, type GridState } from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page } from 'vitest/browser' +import ImportWizard from '../lib/components/panels/ImportWizard.svelte' +import InRoot from './InRoot.svelte' +import { dataImport, getDataImport } from '../lib/features/data-import/data-import.svelte.js' +import { viVN } from '../lib/locales/vi-VN.js' + +interface Member { + id: number + name: string + email: string + qty: number +} + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name' }, + { + id: 'email', + header: 'Email', + validate: (value) => (String(value ?? '').includes('@') ? null : 'Email needs an @') + }, + { id: 'qty', header: 'Qty', type: 'number' } +] + +const FILE = 'Name,Email,Qty\nChi,chi@x.vn,3\nAn,broken,abc\n' + +// The wizard takes its grid from context, so it is mounted inside one. +const TypedWizard = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ props: { grid, component: ImportWizard } }) + +function makeGrid( + onCommit: (rows: Member[]) => void = () => {}, + locale?: string +): GridState { + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + locales: [viVN], + locale, + features: [ + sorting(), + filtering(), + dataImport({ onCommit, newRow: (index) => ({ id: index + 1 }) }) + ] + }) +} + +describe('the wizard walks a file in', () => { + it('starts on the drop step and offers a paste', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + + const panel = screen.container.querySelector('[data-dg-import]') + expect(panel?.getAttribute('data-dg-import')).toBe('idle') + expect(panel?.textContent).toContain('Paste') + }) + + it('moves to matching once a file is taken, and guesses the columns', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText(FILE) + await vi.waitFor(() => + expect( + screen.container.querySelector('[data-dg-import]')?.getAttribute('data-dg-import') + ).toBe('mapping') + ) + + expect(importing.mappings.map((entry) => entry.sourceIndex)).toEqual([0, 1, 2]) + expect(screen.container.textContent).toContain('chi@x.vn') + }) + + it('counts what needs attention on the review step', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText(FILE) + await importing.review() + + await vi.waitFor(() => expect(screen.container.textContent).toContain('To check: 1')) + expect(screen.container.textContent).toContain('Email needs an @') + expect(screen.container.textContent).toContain('Add all (2)') + }) + + it('hands the ready rows over when that button is pressed', async () => { + const handed: Member[][] = [] + const grid = makeGrid((rows) => handed.push(rows)) + await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText(FILE) + await importing.review() + + await page.getByRole('button', { name: 'Add ready (1)' }).click() + await vi.waitFor(() => expect(handed).toHaveLength(1)) + expect(handed[0]!.map((row) => row.name)).toEqual(['Chi']) + }) + + it('says what it could not read rather than failing quietly', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.take(new File(['x'], 'report.pdf')) + await vi.waitFor(() => expect(screen.container.textContent).toContain('report.pdf')) + }) +}) + +describe('a file that repeats what is already there', () => { + const REPEATS = 'Name,Email,Qty\nChi,chi@x.vn,3\nHa,ha@x.vn,4\nHa again,HA@X.vn,5\n' + + function dedupeGrid(existing: Member[]): GridState { + return createDataGrid({ + columns, + data: existing, + getRowId: (row) => String(row.id), + features: [ + sorting(), + filtering(), + dataImport({ + onCommit: () => {}, + newRow: (index) => ({ id: 100 + index }), + dedupe: { columns: ['email'], against: 'both' } + }) + ] + }) + } + + it('names the row the grid already holds and the one the file repeats', async () => { + const grid = dedupeGrid([{ id: 1, name: 'Chi', email: 'chi@x.vn', qty: 3 }]) + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText(REPEATS) + await importing.review() + + await vi.waitFor(() => expect(screen.container.textContent).toContain('To check: 2')) + expect(screen.container.textContent).toContain('chi@x.vn is already in this grid') + expect(screen.container.textContent).toContain('HA@X.vn is in this file more than once') + expect(screen.container.textContent).toContain('Add ready (1)') + }) + + it('leaves the file alone when no key was declared', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText(REPEATS) + await importing.review() + + await vi.waitFor(() => expect(screen.container.textContent).toContain('Add all (3)')) + expect(screen.container.textContent).not.toContain('more than once') + }) +}) + +describe('a file whose columns match nothing', () => { + it('says so, refuses to move on, and leaves every column pickable', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText('Alpha,Beta\n1,two\n3,four\n') + await vi.waitFor(() => expect(importing.step).toBe('mapping')) + + expect(screen.container.textContent).toContain('Nothing matched') + expect(importing.matchedCount).toBe(0) + expect(importing.canReview).toBe(false) + + expect(screen.container.textContent).toContain('Alpha') + expect(screen.container.textContent).toContain('two') + }) + + it('counts the ones it did match rather than staying silent', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText('Email,Beta\nchi@x.vn,two\n') + await vi.waitFor(() => expect(importing.step).toBe('mapping')) + + expect(importing.matchedCount).toBe(1) + expect(screen.container.textContent).toContain('1 of 3 columns matched') + }) + + it('goes on once the user maps one by hand', async () => { + const grid = makeGrid() + await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + await importing.takeText('Alpha,Beta\nChi,two\n') + importing.setMapping('name', 0) + + expect(importing.canReview).toBe(true) + await importing.review() + expect(importing.staged.map((row) => row.name)).toEqual(['Chi']) + }) +}) + +describe('the wizard in another language', () => { + it('speaks the configured one', async () => { + const grid = makeGrid(() => {}, 'vi-VN') + const screen = await render(TypedWizard, inRoot(grid)) + + expect(screen.container.textContent).toContain('Dán') + }) +}) + +describe('a11y', () => { + it('is axe-clean on every step', async () => { + const grid = makeGrid() + const screen = await render(TypedWizard, inRoot(grid)) + const importing = getDataImport(grid)! + + const clean = async () => { + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + return results.violations.map((violation) => violation.id) + } + + expect(await clean()).toEqual([]) + + await importing.takeText(FILE) + await vi.waitFor(() => expect(importing.step).toBe('mapping')) + expect(await clean()).toEqual([]) + + await importing.review() + await vi.waitFor(() => expect(importing.step).toBe('review')) + expect(await clean()).toEqual([]) + }) +}) diff --git a/src/tests/keyboard.svelte.test.ts b/src/tests/keyboard.svelte.test.ts index c3993a4..563062c 100644 --- a/src/tests/keyboard.svelte.test.ts +++ b/src/tests/keyboard.svelte.test.ts @@ -207,7 +207,13 @@ describe('overlays', () => { const body = screen.container.querySelector('[role="rowgroup"][aria-busy="true"]') expect(body).not.toBeNull() - expect(screen.container.querySelectorAll('[role="gridcell"]')).toHaveLength(0) + + // The skeleton draws cells, because a `row` owing no `gridcell` is a + // row nothing can read. What it must not draw is data: every cell on + // screen while loading is empty of text. + const cells = [...screen.container.querySelectorAll('[role="gridcell"]')] + expect(cells).not.toHaveLength(0) + expect(cells.every((cell) => (cell.textContent ?? '').trim() === '')).toBe(true) }) it('fills the grid with skeletons rather than a fixed few', async () => { diff --git a/src/tests/range-selection.svelte.test.ts b/src/tests/range-selection.svelte.test.ts new file mode 100644 index 0000000..53edd22 --- /dev/null +++ b/src/tests/range-selection.svelte.test.ts @@ -0,0 +1,553 @@ +import { + createDataGrid, + editing, + filtering, + sorting, + type ColumnDef, + type DataGridProps, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import InRoot from './InRoot.svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page, userEvent } from 'vitest/browser' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import RangeStatusBar from '../lib/components/chrome/RangeStatusBar.svelte' +import { + getRangeSelection, + rangeSelection +} from '../lib/features/range-selection/range-selection.svelte.js' + +interface Cell { + id: number + region: string + q1: number + q2: number +} + +const toNumber = (input: unknown) => (input === null || input === '' ? null : Number(input)) + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region', width: 160, filter: 'text' }, + { id: 'q1', header: 'Q1', width: 120, editable: true, editor: 'number', parse: toNumber }, + { id: 'q2', header: 'Q2', width: 120, editable: true, editor: 'number', parse: toNumber } +] + +function makeData(): Cell[] { + return [ + { id: 1, region: 'North', q1: 10, q2: 20 }, + { id: 2, region: 'South', q1: 30, q2: 40 }, + { id: 3, region: 'East', q1: 50, q2: 60 } + ] +} + +const TypedGrid = DataGrid as unknown as Component> +// The part takes its grid from context, so it is mounted inside one. +const TypedStatus = InRoot as unknown as Component> + +const inRoot = (grid: GridState) => ({ props: { grid, component: RangeStatusBar } }) + +function makeGrid(): GridState { + return createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), editing(), rangeSelection()] + }) +} + +function cellAt(container: Element, row: number, col: number): HTMLElement { + return container.querySelector(`[data-dg-cell="${row}:${col}"]`)! +} + +function selectedCells(container: Element): string[] { + return [...container.querySelectorAll('[data-dg-cell][aria-selected="true"]')].map((cell) => + cell.getAttribute('data-dg-cell')! + ) +} + +function drag(container: Element, from: [number, number], to: [number, number]) { + const start = cellAt(container, from[0], from[1]) + const end = cellAt(container, to[0], to[1]) + start.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + end.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) +} + +async function renderGrid(grid: GridState) { + const screen = await render(TypedGrid, { grid }) + await expect.element(screen.getByRole('grid')).toBeVisible() + return screen +} + +describe('dragging a range', () => { + it('highlights every cell in the dragged rectangle', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + drag(screen.container, [0, 1], [1, 2]) + + await expect + .poll(() => selectedCells(screen.container).sort()) + .toEqual(['0:1', '0:2', '1:1', '1:2']) + expect(getRangeSelection(grid)!.ranges).toEqual([{ top: 0, left: 1, bottom: 1, right: 2 }]) + }) + + it('replaces the range on a plain drag and adds one with Ctrl', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + const state = getRangeSelection(grid)! + + drag(screen.container, [0, 0], [0, 0]) + expect(state.ranges).toHaveLength(1) + + cellAt(screen.container, 2, 2).dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, ctrlKey: true }) + ) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + + expect(state.ranges).toHaveLength(2) + await expect.poll(() => selectedCells(screen.container).sort()).toEqual(['0:0', '2:2']) + }) + + it('leaves editors alone so a double-click can still edit', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + cellAt(screen.container, 0, 1).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await expect.element(page.getByRole('spinbutton').first()).toBeVisible() + + const input = cellAt(screen.container, 0, 1).querySelector('input')! + input.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + expect(getRangeSelection(grid)!.dragging).toBe(false) + }) +}) + +describe('popovers above the range layer', () => { + it('keeps focus in the filter panel and starts no range from its inputs', async () => { + const grid = makeGrid() + await renderGrid(grid) + + await page.getByRole('button', { name: 'Filter Region' }).click() + const input = page.getByRole('dialog', { name: 'Filter Region' }).getByRole('textbox') + await input.click() + + await userEvent.keyboard('nor') + + const active = document.activeElement + expect(active?.closest('[role="dialog"]')).not.toBeNull() + expect((active as HTMLInputElement).value).toBe('nor') + expect(getRangeSelection(grid)!.ranges).toEqual([]) + }) + + it('ignores a pointerdown on a header cell', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + screen.container + .querySelector('[data-dg-cell="-1:0"]')! + .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + + expect(getRangeSelection(grid)!.ranges).toEqual([]) + }) +}) + +describe('keyboard range', () => { + it('extends with Shift+Arrow from the focused cell', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + await page.getByRole('gridcell', { name: 'North' }).click() + await expect.poll(() => grid.focus.active).toEqual({ row: 0, col: 0 }) + + await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowRight}{/Shift}') + + await expect + .poll(() => selectedCells(screen.container).sort()) + .toEqual(['0:0', '0:1', '1:0', '1:1']) + }) + + it('clears with Escape', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 0], [1, 1]) + + await page.getByRole('gridcell', { name: 'North' }).click() + await userEvent.keyboard('{Escape}') + await expect.poll(() => selectedCells(screen.container)).toEqual([]) + }) +}) + +describe('clipboard round-trip', () => { + it('copies the range as TSV and as a table', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + const flavours: Record = {} + vi.spyOn(navigator.clipboard, 'write').mockImplementation(async (items) => { + for (const item of items) { + for (const type of item.types) + flavours[type] = await (await item.getType(type)).text() + } + }) + + await getRangeSelection(grid)!.copyRange() + expect(flavours['text/plain']).toBe('10\t20\n30\t40') + expect(flavours['text/html']).toBe( + '
1020
3040
' + ) + vi.restoreAllMocks() + }) + + it('writes plain text alone when the browser has no ClipboardItem', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + const written: string[] = [] + const item = globalThis.ClipboardItem + Reflect.deleteProperty(globalThis, 'ClipboardItem') + vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(async (text) => { + written.push(text) + }) + + try { + await getRangeSelection(grid)!.copyRange() + } finally { + Reflect.set(globalThis, 'ClipboardItem', item) + } + + expect(written).toEqual(['10\t20\n30\t40']) + vi.restoreAllMocks() + }) + + it('skips the table flavour when the caller asks for plain text only', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + const written: string[] = [] + vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(async (text) => { + written.push(text) + }) + + await getRangeSelection(grid)!.copyRange({ html: false }) + expect(written).toEqual(['10\t20\n30\t40']) + vi.restoreAllMocks() + }) + + it('pastes a block, runs validation and undoes in one step', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [0, 1]) + + expect(getRangeSelection(grid)!.pasteText('1\t2\n3\t4')).toBe(true) + expect(grid.data[0]).toMatchObject({ q1: 1, q2: 2 }) + expect(grid.data[1]).toMatchObject({ q1: 3, q2: 4 }) + + const undo = grid.api.undo as () => void + undo() + expect(grid.data[0]).toMatchObject({ q1: 10, q2: 20 }) + expect(grid.data[1]).toMatchObject({ q1: 30, q2: 40 }) + void screen + }) +}) + +describe('Ctrl+Enter over a range', () => { + it('writes the value being typed into every selected cell', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + cellAt(screen.container, 0, 1).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await expect.element(page.getByRole('spinbutton').first()).toBeVisible() + + const input = cellAt(screen.container, 0, 1).querySelector('input')! + input.focus() + await userEvent.fill(input, '7') + await userEvent.keyboard('{Control>}{Enter}{/Control}') + + expect(grid.data[0]).toMatchObject({ q1: 7, q2: 7 }) + expect(grid.data[1]).toMatchObject({ q1: 7, q2: 7 }) + expect(grid.data[2]).toMatchObject({ q1: 50, q2: 60 }) + }) + + it('leaves Community to commit the one cell when nothing else is selected', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + + cellAt(screen.container, 0, 1).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await expect.element(page.getByRole('spinbutton').first()).toBeVisible() + + const input = cellAt(screen.container, 0, 1).querySelector('input')! + input.focus() + await userEvent.fill(input, '7') + await userEvent.keyboard('{Control>}{Enter}{/Control}') + + expect(grid.data[0]).toMatchObject({ q1: 7, q2: 20 }) + }) +}) + +describe('moving a range by its border', () => { + it('drags the block to a new place and clears the old one', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [0, 2]) + + const source = cellAt(screen.container, 0, 1) + const box = source.getBoundingClientRect() + source.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + button: 0, + clientX: box.left + 1, + clientY: box.top + box.height / 2 + }) + ) + cellAt(screen.container, 2, 1).dispatchEvent( + new PointerEvent('pointermove', { bubbles: true }) + ) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await vi.waitFor(() => expect(grid.data[2]).toMatchObject({ q1: 10, q2: 20 })) + + expect(grid.data[0]).toMatchObject({ q1: null, q2: null }) + expect(getRangeSelection(grid)!.ranges).toEqual([{ top: 2, left: 1, bottom: 2, right: 2 }]) + }) + + it('starts a range instead when the pointer lands away from the border', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + const inside = cellAt(screen.container, 1, 2) + const box = inside.getBoundingClientRect() + inside.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + button: 0, + clientX: box.left + box.width / 2, + clientY: box.top + box.height / 2 + }) + ) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + + expect(getRangeSelection(grid)!.cutSource).toBeNull() + expect(getRangeSelection(grid)!.ranges).toEqual([{ top: 1, left: 2, bottom: 1, right: 2 }]) + expect(grid.data[0]).toMatchObject({ q1: 10 }) + }) +}) + +describe('range status bar', () => { + it('sums the selected numeric cells', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + const bar = await render(TypedStatus, inRoot(grid)) + await expect.element(bar.getByText(/4 cells/)).toBeVisible() + await expect.element(bar.getByText('100')).toBeVisible() + }) + + it('drops the shape when the count spans more than one block', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + await render(TypedStatus, inRoot(grid)) + + drag(screen.container, [0, 1], [1, 2]) + await expect.element(page.getByText('4 cells (2x2)')).toBeVisible() + + const state = getRangeSelection(grid)! + state.startRange(2, 0, { additive: true }) + state.endRange() + + await vi.waitFor(() => { + const bar = document.querySelector('[data-dg-range-status]') + expect(bar?.textContent).toContain('5 cells') + }) + expect(document.querySelector('[data-dg-range-status]')?.textContent).not.toContain('(2x2)') + }) + + it('stays hidden without a range', async () => { + const grid = makeGrid() + const bar = await render(TypedStatus, inRoot(grid)) + expect(bar.container.querySelector('[data-dg-range-status]')).toBeNull() + }) +}) + +describe('a11y', () => { + it('is axe-clean with a range selected', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 0], [1, 1]) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) + + it('is axe-clean while a cut is marked and a move is previewed', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + + const state = getRangeSelection(grid)! + state.startMove(0, 1) + state.extendMove(2, 1) + await vi.waitFor(() => + expect( + screen.container.querySelectorAll('[aria-selected="true"]').length + ).toBeGreaterThan(0) + ) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) + +describe('the fill handle', () => { + function grabHandle(cell: HTMLElement, inset = 2) { + const box = cell.getBoundingClientRect() + cell.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + button: 0, + clientX: box.right - inset, + clientY: box.bottom - inset + }) + ) + } + + async function selectQ1Rows(container: Element) { + drag(container, [0, 1], [1, 1]) + await expect.poll(() => selectedCells(container)).toEqual(['0:1', '1:1']) + } + + it('draws itself on the range corner and nowhere else', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + await selectQ1Rows(screen.container) + + const handles = [...screen.container.querySelectorAll('[data-dg-cell]')] + .filter((cell) => cell.className.includes('after:bg-primary')) + .map((cell) => cell.getAttribute('data-dg-cell')) + expect(handles).toEqual(['1:1']) + }) + + it('writes through the handle and selects what it wrote', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + await selectQ1Rows(screen.container) + + grabHandle(cellAt(screen.container, 1, 1)) + cellAt(screen.container, 1, 2).dispatchEvent( + new PointerEvent('pointermove', { bubbles: true }) + ) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + + await expect.poll(() => grid.data.map((row) => row.q2)).toEqual([10, 30, 60]) + await expect + .poll(() => selectedCells(screen.container)) + .toEqual(['0:1', '0:2', '1:1', '1:2']) + }) + + it('draws no handle at all when fill is switched off', async () => { + const grid = createDataGrid({ + columns, + data: makeData(), + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), editing(), rangeSelection({ fill: false })] + }) + const screen = await renderGrid(grid) + await selectQ1Rows(screen.container) + + const handles = [...screen.container.querySelectorAll('[data-dg-cell]')].filter((cell) => + cell.className.includes('after:bg-primary') + ) + expect(handles).toEqual([]) + }) + + it('outlines the range once, not once per cell', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [0, 1], [1, 2]) + await expect + .poll(() => selectedCells(screen.container).sort()) + .toEqual(['0:1', '0:2', '1:1', '1:2']) + + const sides = (row: number, col: number) => { + const className = cellAt(screen.container, row, col).className + return { + top: className.includes('before:border-t'), + bottom: className.includes('before:border-b'), + start: className.includes('before:border-s'), + end: className.includes('before:border-e') + } + } + + expect(sides(0, 1)).toEqual({ top: true, bottom: false, start: true, end: false }) + expect(sides(0, 2)).toEqual({ top: true, bottom: false, start: false, end: true }) + expect(sides(1, 1)).toEqual({ top: false, bottom: true, start: true, end: false }) + expect(sides(1, 2)).toEqual({ top: false, bottom: true, start: false, end: true }) + }) + + it('boxes a single cell on all four sides', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + drag(screen.container, [1, 1], [1, 1]) + await expect.poll(() => selectedCells(screen.container)).toEqual(['1:1']) + + const className = cellAt(screen.container, 1, 1).className + for (const side of [ + 'before:border-t', + 'before:border-b', + 'before:border-s', + 'before:border-e' + ]) { + expect(className, side).toContain(side) + } + }) + + it('suppresses text selection while the handle is being dragged', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + await selectQ1Rows(screen.container) + + // The layer draws nothing of its own now: it listens on the grid's + // root and writes the suppression onto it, so that is what to read. + const layer = screen.container.querySelector('[data-dg-range-layer]')! + expect(layer.style.userSelect).toBe('') + + grabHandle(cellAt(screen.container, 1, 1)) + + await expect.poll(() => layer.style.userSelect).toBe('none') + + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await expect.poll(() => layer.style.userSelect).toBe('') + }) + + it('starts a new range when the pointer lands away from the corner', async () => { + const grid = makeGrid() + const screen = await renderGrid(grid) + await selectQ1Rows(screen.container) + + const cell = cellAt(screen.container, 1, 1) + const box = cell.getBoundingClientRect() + cell.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + button: 0, + clientX: box.left + box.width / 2, + clientY: box.top + box.height / 2 + }) + ) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + + await expect.poll(() => selectedCells(screen.container)).toEqual(['1:1']) + }) +}) diff --git a/src/tests/server-row-model.svelte.test.ts b/src/tests/server-row-model.svelte.test.ts new file mode 100644 index 0000000..d8c368e --- /dev/null +++ b/src/tests/server-row-model.svelte.test.ts @@ -0,0 +1,588 @@ +import { + createDataGrid, + DataGrid, + filtering, + pagination, + sorting, + virtualization, + type ColumnDef, + type DataGridProps, + type GridState, + type SortState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { isLoadingRow } from '../lib/core/grid/index.js' +import InRoot from './InRoot.svelte' +import { + getServerRowModel, + serverRowModel +} from '../lib/features/server-row-model/server-row-model.svelte.js' +import type { + DataSource, + GetRowsRequest +} from '../lib/features/server-row-model/server-row-model.types.js' + +interface Row { + id: number + name: string + region: string + level?: number +} + +const TOTAL = 350 + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', sortable: true, width: 200, filter: 'text' }, + { id: 'region', header: 'Region', sortable: true, width: 160 } +] + +const TypedDataGrid = DataGrid as unknown as Component> +const TypedProGrid = DataGrid as unknown as Component> +// `serverRowModel()` contributes the component that does the fetching, so a +// grid root is all a test has to put on screen. +const TypedRoot = InRoot as unknown as Component> + +function makeRow(index: number): Row { + return { id: index + 1, name: `Row ${index + 1}`, region: `R${index % 4}` } +} + +function makeSource(): DataSource & { calls: GetRowsRequest[] } { + const calls: GetRowsRequest[] = [] + return { + calls, + async getRows(request) { + calls.push(request) + const rows: Row[] = [] + for (let i = request.startRow; i < Math.min(request.endRow, TOTAL); i++) { + rows.push(makeRow(i)) + } + return { rows, rowCount: TOTAL } + } + } +} + +function pagedGrid(source: DataSource): GridState { + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + sorting(), + filtering(), + pagination({ pageSize: 25 }), + serverRowModel(source, { mode: 'paged' }) + ] + }) +} + +function infiniteGrid(source: DataSource): GridState { + return createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + sorting(), + virtualization({ rowHeight: 40 }), + serverRowModel(source, { + mode: 'infinite', + blockSize: 100, + placeholder: (index) => ({ id: -(index + 1) }) as Row + }) + ] + }) +} + +async function mount(grid: GridState) { + const screen = await render(TypedRoot, { props: { grid } }) + return screen +} + +describe('paged mode', () => { + it('fetches the first page and reports the server total', async () => { + const source = makeSource() + const grid = pagedGrid(source) + await mount(grid) + + await expect.poll(() => grid.data.length).toBe(25) + expect(source.calls[0]).toMatchObject({ startRow: 0, endRow: 25 }) + expect(getServerRowModel(grid)!.rowCount).toBe(TOTAL) + expect(grid.api.getState).toBeTypeOf('function') + }) + + it('refetches with the new window when the page changes', async () => { + const source = makeSource() + const grid = pagedGrid(source) + await mount(grid) + await expect.poll(() => grid.data.length).toBe(25) + + ;(grid.api.setPage as (page: number) => void)(3) + await expect.poll(() => source.calls.length).toBeGreaterThan(1) + expect(source.calls.at(-1)).toMatchObject({ startRow: 50, endRow: 75 }) + await expect.poll(() => grid.data[0]!.id).toBe(51) + }) + + it('sends the sort and filter model rather than sorting locally', async () => { + const source = makeSource() + const grid = pagedGrid(source) + await mount(grid) + await expect.poll(() => grid.data.length).toBe(25) + + ;(grid.api.setSort as (sort: SortState[]) => void)([ + { columnId: 'name', direction: 'desc' } + ]) + await expect.poll(() => source.calls.length).toBeGreaterThan(1) + expect(source.calls.at(-1)!.sortModel).toEqual([{ columnId: 'name', direction: 'desc' }]) + + ;(grid.api.setQuickFilter as (query: string) => void)('row 4') + await expect.poll(() => source.calls.at(-1)!.filterModel.quick).toBe('row 4') + + await expect + .poll(() => grid.nodes.map((node) => node.row.id)) + .toEqual(grid.data.map((row) => row.id)) + }) + + it('normalizes every column filter to conditions + join on the wire', async () => { + const source = makeSource() + const grid = pagedGrid(source) + await mount(grid) + await expect.poll(() => grid.data.length).toBe(25) + + const setColumnFilter = grid.api.setColumnFilter as ( + columnId: string, + filter: unknown + ) => void + + setColumnFilter('name', { kind: 'text', op: 'contains', value: 'row 1' }) + await expect + .poll(() => source.calls.at(-1)!.filterModel.columns.name) + .toEqual({ + join: 'and', + conditions: [{ kind: 'text', op: 'contains', value: 'row 1' }] + }) + + setColumnFilter('name', { + kind: 'group', + join: 'or', + conditions: [ + { kind: 'text', op: 'contains', value: 'row 1' }, + { kind: 'text', op: 'contains', value: 'row 2' } + ] + }) + await expect.poll(() => source!.calls.at(-1)!.filterModel.columns.name!.join).toBe('or') + expect(source!.calls.at(-1)!.filterModel.columns.name!.conditions).toHaveLength(2) + }) + + it('surfaces a rejection instead of hanging on loading', async () => { + const onError = vi.fn() + const failing: DataSource = { + getRows: () => Promise.reject(new Error('gateway timeout')) + } + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [pagination({ pageSize: 25 }), serverRowModel(failing, { onError })] + }) + await mount(grid) + + const model = getServerRowModel(grid)! + await expect.poll(() => model.error).toBe('gateway timeout') + expect(model.loading).toBe(false) + expect(onError).toHaveBeenCalled() + }) +}) + +describe('infinite mode', () => { + it('loads the first block and pads the rest with placeholders', async () => { + const source = makeSource() + const grid = infiniteGrid(source) + await mount(grid) + + await expect.poll(() => grid.data.length).toBe(TOTAL) + expect(source.calls[0]).toMatchObject({ startRow: 0, endRow: 100 }) + expect(isLoadingRow(grid.data[0])).toBe(false) + expect(isLoadingRow(grid.data[300])).toBe(true) + }) + + it('fetches the blocks a scrolled range needs, once each', async () => { + const source = makeSource() + const grid = infiniteGrid(source) + await mount(grid) + await expect.poll(() => grid.data.length).toBe(TOTAL) + + const model = getServerRowModel(grid)! + model.ensureRange(250, 320) + await expect.poll(() => isLoadingRow(grid.data[260])).toBe(false) + + model.ensureRange(250, 320) + const starts = source.calls.map((call) => call.startRow) + expect(starts).toEqual([...new Set(starts)]) + expect(starts).toContain(200) + expect(starts).toContain(300) + }) + + it('returns evicted blocks to placeholders instead of keeping their rows', async () => { + const source = makeSource() + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization({ rowHeight: 40 }), + serverRowModel(source, { + mode: 'infinite', + blockSize: 100, + maxBlocks: 2, + placeholder: (index) => ({ id: -(index + 1) }) as Row + }) + ] + }) + await mount(grid) + await expect.poll(() => grid.data.length).toBe(TOTAL) + expect(isLoadingRow(grid.data[0])).toBe(false) + + const model = getServerRowModel(grid)! + model.ensureRange(250, 320) + await expect.poll(() => isLoadingRow(grid.data[260])).toBe(false) + + model.ensureRange(250, 320) + expect(isLoadingRow(grid.data[0])).toBe(true) + }) + + it('starts over when the sort changes so pages cannot interleave', async () => { + const source = makeSource() + const grid = infiniteGrid(source) + await mount(grid) + await expect.poll(() => grid.data.length).toBe(TOTAL) + + getServerRowModel(grid)!.ensureRange(250, 320) + await expect.poll(() => isLoadingRow(grid.data[260])).toBe(false) + + ;(grid.api.setSort as (sort: SortState[]) => void)([{ columnId: 'name', direction: 'asc' }]) + await expect.poll(() => source.calls.at(-1)!.startRow).toBe(0) + await expect.poll(() => isLoadingRow(grid.data[260])).toBe(true) + }) +}) + +describe('infinite mode setup', () => { + it('refuses to run without a placeholder, which would collide every row id', () => { + expect(() => + createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [serverRowModel(makeSource(), { mode: 'infinite' })] + }) + ).toThrow(/placeholder/) + }) + + it('refuses to run on a client-model grid, which would re-filter the page', () => { + expect(() => + createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + features: [sorting(), filtering(), serverRowModel(makeSource())] + }) + ).toThrow(/rowModel/) + }) + + it('announces the total, which paged mode gets from pagination and this does not', async () => { + const totals: number[] = [] + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + virtualization(), + serverRowModel(makeSource(), { + mode: 'infinite', + blockSize: 50, + placeholder: (index) => ({ id: -index - 1, name: '', region: '' }) + }) + ] + }) + grid.events.on('rowCountChanged', ({ total }) => totals.push(total)) + + const model = getServerRowModel(grid)! + model.refresh() + await expect.poll(() => totals).toEqual([TOTAL]) + + model.ensureRange(50, 150) + await expect.poll(() => grid.data[60]?.name).toBe('Row 61') + expect(totals).toEqual([TOTAL]) + }) +}) + +describe('stale replies', () => { + it('discards a reply for a query the user has moved on from', async () => { + let release: (() => void) | null = null + const slow: DataSource = { + getRows: (request) => + new Promise((resolve) => { + const finish = () => + resolve({ + rows: [ + { id: 999, name: `stale ${request.sortModel.length}`, region: 'X' } + ], + rowCount: 1 + }) + if (release === null) release = finish + else finish() + }) + } + + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [sorting(), pagination({ pageSize: 25 }), serverRowModel(slow)] + }) + await mount(grid) + await expect.poll(() => release !== null).toBe(true) + + ;(grid.api.setSort as (sort: SortState[]) => void)([ + { columnId: 'name', direction: 'desc' } + ]) + release!() + + await expect.poll(() => grid.data.length).toBe(1) + expect(grid.data[0]!.name).toBe('stale 1') + }) + + it('discards a slow reply from a page the user has already left', async () => { + let releasePageTwo: (() => void) | null = null + const source: DataSource = { + getRows: (request) => + new Promise((resolve) => { + const finish = () => { + const rows: Row[] = [] + for (let i = request.startRow; i < request.endRow; i++) { + rows.push(makeRow(i)) + } + resolve({ rows, rowCount: TOTAL }) + } + if (request.startRow === 25) releasePageTwo = finish + else finish() + }) + } + + const grid = pagedGrid(source) + await mount(grid) + await expect.poll(() => grid.data[0]?.id).toBe(1) + + ;(grid.api.setPage as (page: number) => void)(2) + await expect.poll(() => releasePageTwo !== null).toBe(true) + ;(grid.api.setPage as (page: number) => void)(3) + await expect.poll(() => grid.data[0]?.id).toBe(51) + releasePageTwo!() + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(grid.data[0]!.id).toBe(51) + }) +}) + +describe('server-side group expand', () => { + it('asks for children with the group key and splices them below the group', async () => { + const groups: Row[] = [ + { id: 1, name: 'North', region: 'North', level: 0 }, + { id: 2, name: 'South', region: 'South', level: 0 } + ] + const calls: GetRowsRequest[] = [] + const source: DataSource = { + async getRows(request) { + calls.push(request) + if (request.groupKeys.length === 0) return { rows: groups, rowCount: 2 } + const key = String(request.groupKeys[0]) + return { + rows: [ + { id: 10, name: `${key} child A`, region: key, level: 1 }, + { id: 11, name: `${key} child B`, region: key, level: 1 } + ] + } + } + } + + const grid = createDataGrid({ + columns, + data: [], + getRowId: (row) => String(row.id), + rowModel: 'server', + features: [ + serverRowModel(source, { + groupBy: ['region'], + getRowMeta: (row) => + row.level === 0 ? { level: 0, expandable: true } : { level: 1 }, + // Both used to be props on the component; they are options + // now, beside the rest of the model's configuration. + groupKeysOf: (node) => [node.row.region], + isChildOf: () => (row) => row.level === 1 + }) + ] + }) + + await render(TypedRoot, { props: { grid } }) + await expect.poll(() => grid.data.length).toBe(2) + + grid.expansion.expand('1') + await expect.poll(() => grid.data.length).toBe(4) + expect(calls.at(-1)).toMatchObject({ groupKeys: ['North'], groupBy: ['region'] }) + expect(grid.data.map((row) => row.name)).toEqual([ + 'North', + 'North child A', + 'North child B', + 'South' + ]) + + grid.expansion.collapse('1') + await expect.poll(() => grid.data.length).toBe(2) + expect(grid.data.map((row) => row.name)).toEqual(['North', 'South']) + }) +}) + +describe('rendering', () => { + it('renders the server page through the grid', async () => { + const source = makeSource() + const grid = pagedGrid(source) + const screen = await render(TypedDataGrid, { grid }) + + await expect.element(screen.getByRole('grid')).toBeVisible() + await expect.poll(() => grid.data.length).toBe(25) + await expect + .poll(() => screen.container.querySelector('[data-dg-cell="0:0"]')?.textContent?.trim()) + .toBe('Row 1') + }) +}) + +describe('a11y', () => { + it('is axe-clean on a server page', async () => { + const grid = pagedGrid(makeSource()) + const screen = await render(TypedDataGrid, { grid }) + await expect.poll(() => grid.data.length).toBe(25) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) + + it('is axe-clean while placeholder rows are on screen', async () => { + let release: (() => void) | null = null + const slow: DataSource = { + getRows: (request) => + new Promise((resolve) => { + release = () => + resolve({ + rows: Array.from( + { length: request.endRow - request.startRow }, + (_, i) => makeRow(request.startRow + i) + ), + rowCount: TOTAL + }) + }) + } + + const grid = infiniteGrid(slow) + const screen = await render(TypedDataGrid, { grid }) + + await expect.poll(() => release !== null).toBe(true) + release!() + await expect.poll(() => grid.data.length).toBe(TOTAL) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) + +describe('what a server grid shows before its first answer', () => { + it('does not tell the user there is no data while there is no answer yet', async () => { + let release: (() => void) | undefined + const held = new Promise((resolve) => { + release = resolve + }) + + const grid = createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + virtualization({ rowHeight: 40 }), + serverRowModel( + { + async getRows(request) { + await held + const answer: Row[] = [] + for ( + let i = request.startRow; + i < Math.min(request.endRow, TOTAL); + i++ + ) { + answer.push(makeRow(i)) + } + return { rows: answer, rowCount: TOTAL } + } + }, + { + mode: 'infinite', + blockSize: 10, + placeholder: (index) => ({ id: -(index + 1) }) as Row + } + ) + ] + }) + + const screen = await render(TypedProGrid, { grid, class: 'h-[300px]' }) + await expect.element(screen.getByRole('grid')).toBeVisible() + + expect(screen.container.textContent).not.toContain('No data') + + getServerRowModel(grid)!.start() + expect(screen.container.textContent).not.toContain('No data') + + release!() + await vi.waitFor(() => expect(getServerRowModel(grid)!.answered).toBe(true)) + expect(screen.container.textContent).not.toContain('No data') + }) + + it('still says so when the source really answers with nothing', async () => { + const grid = createDataGrid({ + columns, + data: [], + rowModel: 'server', + getRowId: (row) => String(row.id), + features: [ + virtualization({ rowHeight: 40 }), + serverRowModel( + { + async getRows() { + return { rows: [], rowCount: 0 } + } + }, + { + mode: 'infinite', + blockSize: 10, + placeholder: (index) => ({ id: -(index + 1) }) as Row + } + ) + ] + }) + + const screen = await render(TypedProGrid, { grid, class: 'h-[300px]' }) + getServerRowModel(grid)!.start() + + await vi.waitFor(() => expect(screen.container.textContent).toContain('No data')) + }) +}) diff --git a/src/tests/show-values-as.svelte.test.ts b/src/tests/show-values-as.svelte.test.ts new file mode 100644 index 0000000..8e0e206 --- /dev/null +++ b/src/tests/show-values-as.svelte.test.ts @@ -0,0 +1,78 @@ +import { createDataGrid, type ColumnDef, type DataGridProps, type GridState } from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import { grouping } from '../lib/features/grouping/grouping.svelte.js' +import { showValuesAs } from '../lib/features/show-values-as/show-values-as.svelte.js' +import type { ShowAs } from '../lib/features/show-values-as/show-values-as.types.js' + +interface Sale { + id: number + region: string + revenue: number +} + +const sales: Sale[] = [ + { id: 1, region: 'North', revenue: 100 }, + { id: 2, region: 'North', revenue: 300 }, + { id: 3, region: 'South', revenue: 600 } +] + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region', flex: 1, minWidth: 200 }, + { id: 'revenue', header: 'Revenue', type: 'percent', align: 'right', width: 140 } +] + +const TypedGrid = DataGrid as unknown as Component> + +function makeGrid(showAs: ShowAs, by: string[] = []): GridState { + return createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: [ + grouping({ by, aggregations: { revenue: 'sum' } }), + showValuesAs({ columns: { revenue: showAs } }) + ] + }) +} + +async function renderGrid(grid: GridState) { + const screen = await render(TypedGrid, { grid }) + await expect.element(screen.getByRole('grid').or(screen.getByRole('treegrid'))).toBeVisible() + return screen +} + +const cellText = (container: Element, rowId: string): string => + container + .querySelector(`[data-dg-row-id="${rowId}"] [role="gridcell"]:last-child`)! + .textContent!.trim() + +describe('a share on screen', () => { + it('draws the percent the column type promises, not the fraction underneath', async () => { + const screen = await renderGrid(makeGrid('percentOfGrandTotal')) + + expect(cellText(screen.container, '1')).toBe('10%') + expect(cellText(screen.container, '2')).toBe('30%') + expect(cellText(screen.container, '3')).toBe('60%') + }) + + it('shows a group row and its members against their own totals', async () => { + const screen = await renderGrid(makeGrid('percentOfParent', ['region'])) + + expect(cellText(screen.container, 'group:region=North')).toBe('40%') + expect(cellText(screen.container, '1')).toBe('25%') + expect(cellText(screen.container, '2')).toBe('75%') + }) + + it('is axe-clean while a column is shown as a share', async () => { + const screen = await renderGrid(makeGrid('percentOfParent', ['region'])) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) diff --git a/src/tests/tree-detail.svelte.test.ts b/src/tests/tree-detail.svelte.test.ts new file mode 100644 index 0000000..2b7499e --- /dev/null +++ b/src/tests/tree-detail.svelte.test.ts @@ -0,0 +1,254 @@ +import { + createDataGrid, + filtering, + sorting, + type ColumnDef, + type DataGridProps, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import type { Component } from 'svelte' +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { page, userEvent } from 'vitest/browser' +import DataGrid from '../lib/components/grid/DataGrid.svelte' +import { + getMasterDetail, + masterDetail +} from '../lib/features/master-detail/master-detail.svelte.js' +import { getTree, tree } from '../lib/features/tree/tree.svelte.js' + +interface Node { + id: string + name: string + parent: string | null + size: number +} + +const rows: Node[] = [ + { id: 'src', name: 'src', parent: null, size: 0 }, + { id: 'lib', name: 'lib', parent: 'src', size: 0 }, + { id: 'grid.ts', name: 'grid.ts', parent: 'lib', size: 12 }, + { id: 'util.ts', name: 'util.ts', parent: 'lib', size: 4 }, + { id: 'readme', name: 'README.md', parent: null, size: 2 } +] + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', width: 240, filter: 'text' }, + { id: 'size', header: 'Size', align: 'right', width: 100 } +] + +const TypedGrid = DataGrid as unknown as Component> + +function treeGrid(depth = 0): GridState { + return createDataGrid({ + columns, + data: rows, + getRowId: (row) => row.id, + features: [ + sorting(), + filtering(), + tree({ getParentId: (row) => row.parent, defaultExpandedDepth: depth }) + ] + }) +} + +async function renderGrid(grid: GridState) { + const screen = await render(TypedGrid, { grid }) + await expect.element(screen.getByRole('treegrid')).toBeVisible() + return screen +} + +function renderedIds(grid: GridState): string[] { + return grid.nodes.map((node) => node.id) +} + +describe('tree data', () => { + it('renders as a treegrid with only the roots visible', async () => { + const grid = treeGrid() + const screen = await renderGrid(grid) + + expect(renderedIds(grid)).toEqual(['src', 'readme']) + await expect.element(screen.getByRole('treegrid')).toBeVisible() + }) + + it('expands a level at a time from the chevron', async () => { + const grid = treeGrid() + const screen = await renderGrid(grid) + + await page.getByRole('button', { name: 'Expand row' }).first().click() + await expect.poll(() => renderedIds(grid)).toEqual(['src', 'lib', 'readme']) + + await page.getByRole('button', { name: 'Expand row' }).first().click() + await expect + .poll(() => renderedIds(grid)) + .toEqual(['src', 'lib', 'grid.ts', 'util.ts', 'readme']) + void screen + }) + + it('carries level and set position into the DOM for assistive tech', async () => { + const grid = treeGrid(2) + const screen = await renderGrid(grid) + await expect.poll(() => renderedIds(grid).length).toBe(5) + + const row = (id: string) => + screen.container.querySelector(`[data-dg-row-id="${id}"]`) as HTMLElement + + expect(row('src').getAttribute('aria-level')).toBe('1') + expect(row('src').getAttribute('aria-expanded')).toBe('true') + expect(row('lib').getAttribute('aria-level')).toBe('2') + expect(row('grid.ts').getAttribute('aria-level')).toBe('3') + expect(row('grid.ts').getAttribute('aria-posinset')).toBe('1') + expect(row('grid.ts').getAttribute('aria-setsize')).toBe('2') + }) + + it('opens to the default depth without fighting a manual collapse', async () => { + const grid = treeGrid(1) + await renderGrid(grid) + await expect.poll(() => renderedIds(grid)).toEqual(['src', 'lib', 'readme']) + + grid.expansion.collapse('src') + await expect.poll(() => renderedIds(grid)).toEqual(['src', 'readme']) + }) + + it('expands and collapses everything through the api', async () => { + const grid = treeGrid() + await renderGrid(grid) + + ;(grid.api.expandAllRows as () => void)() + await expect.poll(() => renderedIds(grid).length).toBe(5) + ;(grid.api.collapseAllRows as () => void)() + await expect.poll(() => renderedIds(grid)).toEqual(['src', 'readme']) + }) + + it('keeps a filtered descendant reachable after its parent is gone', async () => { + const grid = treeGrid() + await renderGrid(grid) + ;(grid.api.setQuickFilter as (query: string) => void)('grid.ts') + + await expect.poll(() => renderedIds(grid)).toEqual(['grid.ts']) + }) + + it('refuses a config with no way to find the hierarchy', () => { + expect(() => + createDataGrid({ + columns, + data: rows, + getRowId: (row) => row.id, + features: [tree({})] + }) + ).toThrow(/getChildren or getParentId/) + }) + + it('is axe-clean with the tree open', async () => { + const grid = treeGrid(2) + const screen = await renderGrid(grid) + await expect.poll(() => renderedIds(grid).length).toBe(5) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) + +interface Order { + id: number + customer: string + lines: number +} + +const orders: Order[] = [ + { id: 1, customer: 'Alice', lines: 3 }, + { id: 2, customer: 'Bob', lines: 0 }, + { id: 3, customer: 'Charlie', lines: 2 } +] + +const orderColumns: ColumnDef[] = [ + { id: 'customer', header: 'Customer', width: 200 }, + { id: 'lines', header: 'Lines', align: 'right', width: 100 } +] + +const TypedOrders = DataGrid as unknown as Component> + +function detailGrid(single = false): GridState { + return createDataGrid({ + columns: orderColumns, + data: orders, + getRowId: (order) => String(order.id), + features: [masterDetail({ hasDetail: (order) => order.lines > 0, single })] + }) +} + +describe('master / detail', () => { + it('opens a full-width panel under the master row', async () => { + const grid = detailGrid() + const screen = await render(TypedOrders, { grid }) + await expect.element(screen.getByRole('treegrid')).toBeVisible() + + expect(grid.nodes.map((node) => node.id)).toEqual(['1', '2', '3']) + + await page.getByRole('button', { name: 'Expand row' }).first().click() + await expect + .poll(() => grid.nodes.map((node) => node.id)) + .toEqual(['1', 'detail:1', '2', '3']) + expect(grid.nodes[1]!.meta?.fullWidth).toBe(true) + }) + + it('offers no chevron to a row with nothing to show', async () => { + const grid = detailGrid() + await render(TypedOrders, { grid }) + await expect.poll(() => grid.nodes.length).toBe(3) + + const buttons = await page.getByRole('button', { name: 'Expand row' }).all() + expect(buttons).toHaveLength(2) + }) + + it('keeps a single panel open when asked', async () => { + const grid = detailGrid(true) + await render(TypedOrders, { grid }) + await expect.poll(() => grid.nodes.length).toBe(3) + + getMasterDetail(grid)!.toggle('1') + await expect.poll(() => grid.nodes.length).toBe(4) + + getMasterDetail(grid)!.toggle('3') + await expect + .poll(() => grid.nodes.map((node) => node.id)) + .toEqual(['1', '2', '3', 'detail:3']) + }) + + it('closes every panel through the api', async () => { + const grid = detailGrid() + await render(TypedOrders, { grid }) + getMasterDetail(grid)!.toggle('1') + await expect.poll(() => grid.nodes.length).toBe(4) + + ;(grid.api.closeAllDetails as () => void)() + await expect.poll(() => grid.nodes.length).toBe(3) + }) + + it('toggles with the keyboard like any other expandable row', async () => { + const grid = detailGrid() + const screen = await render(TypedOrders, { grid }) + await expect.poll(() => grid.nodes.length).toBe(3) + + screen.container.querySelector('[data-dg-cell="0:0"]')!.focus() + await userEvent.keyboard('{Enter}') + await expect.poll(() => grid.nodes.length).toBe(4) + }) +}) + +describe('tree and detail interop', () => { + it('reports which nodes are detail rows', async () => { + const grid = detailGrid() + await render(TypedOrders, { grid }) + getMasterDetail(grid)!.toggle('1') + await expect.poll(() => grid.nodes.length).toBe(4) + + const detail = grid.nodes[1]! + expect(getMasterDetail(grid)!.isDetailRow(detail)).toBe(true) + expect(getMasterDetail(grid)!.masterOf(detail)).toBe('1') + expect(getTree(grid)).toBeUndefined() + }) +}) From 66b650e95143bdbc5518c0ced663e2706dc4b3bb Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 22:05:10 +0700 Subject: [PATCH 10/46] refactor(api): offer a panel the way every other part is offered Nine components were leaving as loose names beside `DataGrid` while every other piece of chrome reached an application through `Grid.*`. They are parts by the same definition - what you assemble when `DataGrid` draws more, or less, than you want - so they join the namespace and the nine top-level names go. `Grid.FormatPanel` is the one that shortens: `Grid.ConditionalFormattingPanel` reads as a sentence rather than as a part. `GridStatus` becomes public. A feature sets `grid.status`, which is the extension point the server model uses, and a type nobody can name is an extension point nobody can implement. Two smaller things in the code behind it. The server layer wrote its status and cleared it from one effect, so the teardown ran on every change it reacted to, blanking the status and rewriting it in the same flush; clearing is its own effect now, with nothing to react to, and it runs when the grid goes. And nine suites had each written the same cast to get a context-reading part past `render`'s typing, which is one export in `in-root.ts` now. --- src/lib/components/index.ts | 18 +++----- src/lib/components/parts.ts | 41 ++++++++++++++++++- src/lib/core/index.ts | 2 +- .../server-row-model/ServerRows.svelte | 10 +++-- .../conditional-formatting.svelte.test.ts | 4 +- src/tests/filter-builder.svelte.test.ts | 5 +-- src/tests/find-replace.svelte.test.ts | 4 +- src/tests/format-panel.svelte.test.ts | 7 ++-- src/tests/grouping.svelte.test.ts | 4 +- src/tests/import-wizard.svelte.test.ts | 5 +-- src/tests/in-root.ts | 13 ++++++ src/tests/public-api.test.ts | 11 +---- src/tests/range-selection.svelte.test.ts | 4 +- src/tests/server-row-model.svelte.test.ts | 4 +- src/tests/tool-panel.svelte.test.ts | 5 +-- 15 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 src/tests/in-root.ts diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 452d6f6..368000b 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -1,7 +1,10 @@ /** * The presentation layer's public surface, assembled from the folder barrels - * below it. Four folders draw the grid — `grid`, `chrome`, `menus`, `cells` — - * and this file names the handful of them an app mounts itself. + * below it. Five folders draw the grid — `grid`, `chrome`, `panels`, `menus`, + * `cells` — and this file names the handful of them an app mounts itself. + * + * Only two names leave: `DataGrid`, and `Grid` holding every part. A panel is + * a part like the toolbar's controls are, so it reaches an app the same way. * * `cells` never appears: a cell is drawn by the grid or by the app's own * snippet. Nor does most of `internal`, beyond the two icon exports that an @@ -9,17 +12,6 @@ */ export { DataGrid } from './grid/index.js' -export { RangeStatusBar } from './chrome/index.js' -export { - CommandPalette, - ConditionalFormattingPanel, - FilterBuilder, - FindReplace, - GroupPanel, - ImportWizard, - SavedViews, - ToolPanel -} from './panels/index.js' export { Grid, type GridParts } from './parts.js' export { datagridIcons, registerDataGridIcons } from './internal/index.js' diff --git a/src/lib/components/parts.ts b/src/lib/components/parts.ts index b609cea..055e54b 100644 --- a/src/lib/components/parts.ts +++ b/src/lib/components/parts.ts @@ -8,13 +8,29 @@ import { GridPagination, GridQuickFilter, GridStatusBar, - GridToolbar + GridToolbar, + RangeStatusBar } from './chrome/index.js' +import { + CommandPalette, + ConditionalFormattingPanel, + FilterBuilder, + FindReplace, + GroupPanel, + ImportWizard, + SavedViews, + ToolPanel +} from './panels/index.js' /** * The compound API: one namespace holding the parts an app assembles when * `DataGrid` renders more, or less, than it wants. * + * The panels are here for the same reason the toolbar's controls are: they are + * parts, and offering nine of them as loose names beside `DataGrid` would say + * they are something else. `FormatPanel` is the one short name, because + * `Grid.ConditionalFormattingPanel` reads as a sentence rather than a part. + * * It lives beside the barrel rather than in it because it is a composition of * three folders — structure, chrome and menus — and the barrel's job is to * aggregate, not to build. The short keys are the API; the file names they @@ -35,6 +51,17 @@ export interface GridParts { ContextMenu: typeof GridContextMenu FilterChips: typeof GridFilterChips StatusBar: typeof GridStatusBar + + /** The panels, on the same namespace for the same reason. */ + GroupPanel: typeof GroupPanel + FilterBuilder: typeof FilterBuilder + FindReplace: typeof FindReplace + SavedViews: typeof SavedViews + ImportWizard: typeof ImportWizard + CommandPalette: typeof CommandPalette + FormatPanel: typeof ConditionalFormattingPanel + ToolPanel: typeof ToolPanel + RangeStatusBar: typeof RangeStatusBar } export const Grid: GridParts = { @@ -51,5 +78,15 @@ export const Grid: GridParts = { ExportMenu: GridExportMenu, ContextMenu: GridContextMenu, FilterChips: GridFilterChips, - StatusBar: GridStatusBar + StatusBar: GridStatusBar, + + GroupPanel, + FilterBuilder, + FindReplace, + SavedViews, + ImportWizard, + CommandPalette, + FormatPanel: ConditionalFormattingPanel, + ToolPanel, + RangeStatusBar } diff --git a/src/lib/core/index.ts b/src/lib/core/index.ts index c04b58f..cb94aca 100644 --- a/src/lib/core/index.ts +++ b/src/lib/core/index.ts @@ -19,7 +19,7 @@ export { isDataRow } from './grid/index.js' export { ShareTooLongError } from './grid/index.js' // Models an app reaches through the grid instance, never constructs itself. -export type { GridState } from './grid/index.js' +export type { GridState, GridStatus } from './grid/index.js' export type { EventBus, EventHandler } from './grid/index.js' export type { ColumnModel } from './columns/index.js' export type { Announcer, ExpansionModel } from './interaction/index.js' diff --git a/src/lib/features/server-row-model/ServerRows.svelte b/src/lib/features/server-row-model/ServerRows.svelte index e837236..99ff93a 100644 --- a/src/lib/features/server-row-model/ServerRows.svelte +++ b/src/lib/features/server-row-model/ServerRows.svelte @@ -26,9 +26,13 @@ error: model.error, onRetry: model.refresh } - return () => { - grid.status = undefined - } + }) + + // Cleared when the grid goes, and only then. Put on the effect above, the + // teardown would run on every change it reacts to, blanking the status and + // rewriting it in the same flush for no reason. + $effect(() => () => { + grid.status = undefined }) $effect(() => { diff --git a/src/tests/conditional-formatting.svelte.test.ts b/src/tests/conditional-formatting.svelte.test.ts index 510eb5f..df219c8 100644 --- a/src/tests/conditional-formatting.svelte.test.ts +++ b/src/tests/conditional-formatting.svelte.test.ts @@ -8,7 +8,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import DataGrid from '../lib/components/grid/DataGrid.svelte' @@ -45,7 +45,7 @@ const columns: ColumnDef[] = [ const TypedGrid = DataGrid as unknown as Component> // `serverRowModel()` contributes its own component, so a root is enough. -const TypedServerRows = InRoot as unknown as Component> +const TypedServerRows = InGrid const inRoot = (grid: GridState) => ({ props: { grid } }) diff --git a/src/tests/filter-builder.svelte.test.ts b/src/tests/filter-builder.svelte.test.ts index 046b062..36b7459 100644 --- a/src/tests/filter-builder.svelte.test.ts +++ b/src/tests/filter-builder.svelte.test.ts @@ -1,11 +1,10 @@ import { createDataGrid, sorting, type ColumnDef, type GridState } from '$lib/index.js' import axe from 'axe-core' -import type { Component } from 'svelte' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import FilterBuilder from '../lib/components/panels/FilterBuilder.svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { advancedFilter, getAdvancedFilter @@ -63,7 +62,7 @@ const data: Sale[] = [ ] // The panel takes its grid from context, so it is mounted inside one. -const TypedBuilder = InRoot as unknown as Component> +const TypedBuilder = InGrid const inRoot = (grid: GridState, partProps?: { debounce?: number }) => ({ props: { grid, component: FilterBuilder, partProps } diff --git a/src/tests/find-replace.svelte.test.ts b/src/tests/find-replace.svelte.test.ts index 7d44025..31679ea 100644 --- a/src/tests/find-replace.svelte.test.ts +++ b/src/tests/find-replace.svelte.test.ts @@ -9,7 +9,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { userEvent } from 'vitest/browser' @@ -33,7 +33,7 @@ const columns: ColumnDef[] = [ const TypedGrid = DataGrid as unknown as Component> // The part takes its grid from context, so it is mounted inside one. -const TypedPanel = InRoot as unknown as Component> +const TypedPanel = InGrid const inRoot = (grid: GridState) => ({ props: { grid, component: FindReplace } }) diff --git a/src/tests/format-panel.svelte.test.ts b/src/tests/format-panel.svelte.test.ts index b758e77..71fef69 100644 --- a/src/tests/format-panel.svelte.test.ts +++ b/src/tests/format-panel.svelte.test.ts @@ -6,7 +6,6 @@ import { type GridState } from '$lib/index.js' import axe from 'axe-core' -import type { Component } from 'svelte' import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { userEvent } from 'vitest/browser' @@ -18,7 +17,7 @@ import type { FormatRule } from '../lib/features/conditional-formatting/conditio import { serverRowModel } from '../lib/features/server-row-model/server-row-model.svelte.js' import type { DataSource } from '../lib/features/server-row-model/server-row-model.types.js' import { viVN } from '../lib/locales/vi-VN.js' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' interface Deal { id: number @@ -39,7 +38,7 @@ const deals: Deal[] = [ ] // The panel takes its grid from context, so it is mounted inside one. -const TypedPanel = InRoot as unknown as Component> +const TypedPanel = InGrid const inRoot = (grid: GridState) => ({ props: { grid, component: ConditionalFormattingPanel } @@ -63,7 +62,7 @@ const vi = mergeLabels(viVN.labels) const panelOf = (container: Element) => container.querySelector('[data-dg-format-panel]')! // `serverRowModel()` contributes its own component, so a root is enough. -const TypedRoot = InRoot as unknown as Component> +const TypedRoot = InGrid async function offeredColumns(container: Element): Promise { const trigger = [...container.querySelectorAll('button[aria-label]')].find( diff --git a/src/tests/grouping.svelte.test.ts b/src/tests/grouping.svelte.test.ts index 4402a75..1e23142 100644 --- a/src/tests/grouping.svelte.test.ts +++ b/src/tests/grouping.svelte.test.ts @@ -7,7 +7,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' @@ -40,7 +40,7 @@ const columns: ColumnDef[] = [ const TypedGrid = DataGrid as unknown as Component> // The part takes its grid from context, so it is mounted inside one. -const TypedPanel = InRoot as unknown as Component> +const TypedPanel = InGrid const inRoot = (grid: GridState) => ({ props: { grid, component: GroupPanel } }) diff --git a/src/tests/import-wizard.svelte.test.ts b/src/tests/import-wizard.svelte.test.ts index c9b3f65..7959c30 100644 --- a/src/tests/import-wizard.svelte.test.ts +++ b/src/tests/import-wizard.svelte.test.ts @@ -1,11 +1,10 @@ import { createDataGrid, filtering, sorting, type ColumnDef, type GridState } from '$lib/index.js' import axe from 'axe-core' -import type { Component } from 'svelte' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import ImportWizard from '../lib/components/panels/ImportWizard.svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { dataImport, getDataImport } from '../lib/features/data-import/data-import.svelte.js' import { viVN } from '../lib/locales/vi-VN.js' @@ -29,7 +28,7 @@ const columns: ColumnDef[] = [ const FILE = 'Name,Email,Qty\nChi,chi@x.vn,3\nAn,broken,abc\n' // The wizard takes its grid from context, so it is mounted inside one. -const TypedWizard = InRoot as unknown as Component> +const TypedWizard = InGrid const inRoot = (grid: GridState) => ({ props: { grid, component: ImportWizard } }) diff --git a/src/tests/in-root.ts b/src/tests/in-root.ts new file mode 100644 index 0000000..7d3bd61 --- /dev/null +++ b/src/tests/in-root.ts @@ -0,0 +1,13 @@ +import type { Component } from 'svelte' +import InRoot from './InRoot.svelte' + +/** + * One part, mounted inside a grid. + * + * Every chrome and panel component takes its grid from context rather than + * from a prop, so `render(Panel, { grid })` gets the error that says so. Nine + * suites were each writing the same cast to get past `render`'s typing; it + * lives here instead. Each of them keeps its own `inRoot`, because that one is + * three lines and knows the row type. + */ +export const InGrid = InRoot as unknown as Component> diff --git a/src/tests/public-api.test.ts b/src/tests/public-api.test.ts index 46e36e4..f31c7cb 100644 --- a/src/tests/public-api.test.ts +++ b/src/tests/public-api.test.ts @@ -57,16 +57,7 @@ const PUBLIC_API = [ 'toTsv', 'withHeaderRow', - // Panels and the one status bar that is not part of the frame - 'CommandPalette', - 'ConditionalFormattingPanel', - 'FilterBuilder', - 'FindReplace', - 'GroupPanel', - 'ImportWizard', - 'RangeStatusBar', - 'SavedViews', - 'ToolPanel', + // The panels reach an app through `Grid.*`, with the rest of the parts. // Columns worked out from the data 'autoColumns', // Rows a feature synthesized are not rows a pass over the data counts diff --git a/src/tests/range-selection.svelte.test.ts b/src/tests/range-selection.svelte.test.ts index 53edd22..5c5ef2d 100644 --- a/src/tests/range-selection.svelte.test.ts +++ b/src/tests/range-selection.svelte.test.ts @@ -9,7 +9,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page, userEvent } from 'vitest/browser' @@ -45,7 +45,7 @@ function makeData(): Cell[] { const TypedGrid = DataGrid as unknown as Component> // The part takes its grid from context, so it is mounted inside one. -const TypedStatus = InRoot as unknown as Component> +const TypedStatus = InGrid const inRoot = (grid: GridState) => ({ props: { grid, component: RangeStatusBar } }) diff --git a/src/tests/server-row-model.svelte.test.ts b/src/tests/server-row-model.svelte.test.ts index d8c368e..ba73c51 100644 --- a/src/tests/server-row-model.svelte.test.ts +++ b/src/tests/server-row-model.svelte.test.ts @@ -15,7 +15,7 @@ import type { Component } from 'svelte' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { isLoadingRow } from '../lib/core/grid/index.js' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { getServerRowModel, serverRowModel @@ -43,7 +43,7 @@ const TypedDataGrid = DataGrid as unknown as Component> const TypedProGrid = DataGrid as unknown as Component> // `serverRowModel()` contributes the component that does the fetching, so a // grid root is all a test has to put on screen. -const TypedRoot = InRoot as unknown as Component> +const TypedRoot = InGrid function makeRow(index: number): Row { return { id: index + 1, name: `Row ${index + 1}`, region: `R${index % 4}` } diff --git a/src/tests/tool-panel.svelte.test.ts b/src/tests/tool-panel.svelte.test.ts index 8e2389e..154cedc 100644 --- a/src/tests/tool-panel.svelte.test.ts +++ b/src/tests/tool-panel.svelte.test.ts @@ -11,12 +11,11 @@ import { type GridState } from '$lib/index.js' import axe from 'axe-core' -import type { Component } from 'svelte' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import ToolPanel from '../lib/components/panels/ToolPanel.svelte' -import InRoot from './InRoot.svelte' +import { InGrid } from './in-root.js' import { getGrouping, grouping } from '../lib/features/grouping/grouping.svelte.js' import { getRangeSelection, rangeSelection } from '../lib/features/range-selection/index.js' import { serverRowModel } from '../lib/features/server-row-model/index.js' @@ -40,7 +39,7 @@ const data: Sale[] = [ ] // The panel takes its grid from context, so it is mounted inside one. -const TypedPanel = InRoot as unknown as Component> +const TypedPanel = InGrid const inRoot = (grid: GridState) => ({ props: { grid, component: ToolPanel } }) From c13d4e9f6ecc6225ed9679fd1f1b415c80cfb60f Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 22:43:57 +0700 Subject: [PATCH 11/46] refactor: say it in characters a keyboard has, and in one product's words Three things, in one commit because they overlap in the same files and splitting them would tear a feature in half: fourteen locale packs carry both the punctuation sweep and the new operator's wording. The typography guard comes over and now covers everything: `src`, README and CHANGELOG. 412 characters across 102 files become ones a person can type - 323 em dashes, 52 middle dots, 24 en dashes, an ellipsis here and a curly quote there. Two marks stay, and only inside a locale pack's strings. French marks elision with a curly apostrophe, and `N'est pas vide` is bad French and a broken string literal at once; Russian punctuates with an em dash where English reaches for a colon. Their comments are still held to the rule, and the en dash is not exempt anywhere: a page range reads `1-25 of 300` in all twelve languages now. The vocabulary of two tiers goes with it. There is one grid, so nothing should say otherwise: two flags named for the tiers become `throughColumnFilter` and `throughBuilder`, the parity test is named for what it compares, and every phrase that named a tier now names the thing it actually meant. A reader in six months would otherwise go looking for a package that was deleted. `DateFilterOp` gains `notEqual`. The filter builder has offered it on dates since it was written and the column filter never did, which is what stood between the two implementations and being one. It keeps a blank day the way `textPredicate` keeps a blank word, it has wording in all twelve languages, and the server contract's reference implementation learned it too - `server-contract-ops.test.ts` enumerates the operators, so it failed the moment the list grew, which is exactly what it is for. The test fixtures move to `src/tests/fixtures/`, leaving 74 test files where 74 test files and 8 components used to sit together. --- CHANGELOG.md | 108 +++---- README.md | 32 +-- src/benchmarks/server-model.bench.ts | 4 +- .../components/cells/GridCellEditor.svelte | 8 +- .../components/cells/GridFilterCell.svelte | 6 +- .../components/cells/GridSelectionCell.svelte | 4 +- src/lib/components/datagrid.types.ts | 2 +- src/lib/components/datagrid.variants.test.ts | 4 +- src/lib/components/grid/GridBody.svelte | 6 +- src/lib/components/grid/GridHeader.svelte | 2 +- src/lib/components/grid/GridRoot.svelte | 4 +- src/lib/components/grid/GridViewport.svelte | 2 +- src/lib/components/index.ts | 4 +- src/lib/components/internal/icons.data.ts | 2 +- src/lib/components/internal/icons.test.ts | 2 +- src/lib/components/internal/row-drag.ts | 4 +- src/lib/components/internal/window.ts | 2 +- src/lib/components/parts.ts | 2 +- src/lib/core/columns/col-span.ts | 2 +- src/lib/core/columns/index.ts | 2 +- src/lib/core/grid/grid.svelte.ts | 10 +- src/lib/core/grid/index.ts | 4 +- src/lib/core/grid/value-gate.ts | 4 +- .../core/interaction/focus-model.svelte.ts | 4 +- src/lib/core/interaction/labels.ts | 4 +- src/lib/core/interaction/plural.ts | 2 +- src/lib/core/theme/config.ts | 2 +- src/lib/core/types/api.ts | 2 +- src/lib/core/types/columns.ts | 42 +-- src/lib/core/types/feature.ts | 6 +- src/lib/core/types/filtering.ts | 5 +- src/lib/core/types/grid.ts | 4 +- src/lib/core/types/labels.ts | 2 +- src/lib/core/types/rows.ts | 4 +- src/lib/core/utils/format.ts | 10 +- src/lib/core/utils/index.ts | 2 +- src/lib/core/utils/reactivity.ts | 2 +- src/lib/core/utils/url.ts | 2 +- src/lib/core/virtual/virtualizer.svelte.ts | 6 +- .../advanced-filter/advanced-filter.test.ts | 2 +- .../advanced-filter/advanced-filter.types.ts | 2 +- .../features/advanced-filter/evaluate.test.ts | 2 +- ...y-parity.test.ts => filter-parity.test.ts} | 24 +- .../conditional-formatting.types.ts | 2 +- src/lib/features/editing/editing.test.ts | 8 +- .../features/filtering/filter-predicates.ts | 4 + .../filtering/filtering.svelte.test.ts | 4 +- .../features/filtering/filtering.svelte.ts | 4 +- src/lib/features/filtering/floating-filter.ts | 4 +- src/lib/features/formula/formula.types.ts | 2 +- src/lib/features/grouping/grouping.types.ts | 2 +- .../pagination/pagination.svelte.test.ts | 4 +- .../features/pagination/pagination.svelte.ts | 4 +- .../features/pagination/pagination.test.ts | 4 +- src/lib/features/policy/leaks.test.ts | 2 +- src/lib/features/policy/policy.svelte.ts | 2 +- .../range-selection/range-selection.test.ts | 2 +- .../range-selection/range-selection.types.ts | 4 +- .../row-reorder/row-reorder.svelte.ts | 2 +- .../features/row-reorder/row-reorder.types.ts | 2 +- src/lib/features/selection/clipboard.test.ts | 4 +- src/lib/features/selection/clipboard.ts | 2 +- .../features/selection/selection.svelte.ts | 4 +- src/lib/features/selection/selection.test.ts | 2 +- src/lib/features/selection/selection.types.ts | 4 +- .../show-values-as/show-values-as.types.ts | 2 +- src/lib/features/sorting/sort.ts | 4 +- .../features/worker-row-model/parity.test.ts | 12 +- src/lib/index.ts | 2 +- src/lib/locales/de-DE.ts | 3 +- src/lib/locales/en-US.ts | 2 +- src/lib/locales/es-ES.ts | 3 +- src/lib/locales/fr-FR.ts | 3 +- src/lib/locales/id-ID.ts | 3 +- src/lib/locales/index.ts | 2 +- src/lib/locales/ja-JP.ts | 3 +- src/lib/locales/ko-KR.ts | 3 +- src/lib/locales/locales.test.ts | 6 +- src/lib/locales/pt-BR.ts | 3 +- src/lib/locales/ru-RU.ts | 3 +- src/lib/locales/th-TH.ts | 3 +- src/lib/locales/vi-VN.ts | 3 +- src/lib/locales/zh-CN.ts | 3 +- src/routes/+page.svelte | 60 ++-- src/routes/columns/+page.svelte | 10 +- src/routes/columns/+page.ts | 2 +- src/routes/editing/+page.svelte | 20 +- src/routes/editors/+page.svelte | 44 +-- src/routes/export/+page.svelte | 4 +- src/routes/filters/+page.svelte | 10 +- src/routes/groups/+page.svelte | 2 +- src/routes/headless/+page.svelte | 8 +- src/routes/i18n/+page.svelte | 16 +- src/routes/persistence/+page.svelte | 4 +- src/routes/persistence/+page.ts | 2 +- src/routes/qa/+page.svelte | 18 +- src/routes/renderers/+page.svelte | 10 +- src/routes/reorder/+page.svelte | 6 +- src/routes/rows/+page.svelte | 18 +- src/routes/selection/+page.svelte | 10 +- src/routes/server/+page.svelte | 12 +- src/routes/server/big/+page.svelte | 8 +- src/routes/server/infinite/+page.svelte | 12 +- src/routes/spans/+page.svelte | 8 +- src/routes/stress/+page.svelte | 6 +- src/routes/theming/+page.svelte | 6 +- src/routes/virtual/+page.svelte | 16 +- src/tests/api-types.test-d.ts | 2 +- src/tests/axe.svelte.test.ts | 2 +- src/tests/cell-formatting.svelte.test.ts | 4 +- src/tests/cell-types.svelte.test.ts | 4 +- src/tests/col-span.svelte.test.ts | 2 +- src/tests/column-extras.svelte.test.ts | 4 +- src/tests/column-groups.svelte.test.ts | 2 +- .../column-virtualization.svelte.test.ts | 2 +- .../conditional-formatting.svelte.test.ts | 2 +- src/tests/data-shapes.test.ts | 2 +- src/tests/datagrid.svelte.test.ts | 2 +- src/tests/demos.svelte.test.ts | 8 +- src/tests/editing.svelte.test.ts | 4 +- src/tests/feature-layers.svelte.test.ts | 2 +- src/tests/filter-builder.svelte.test.ts | 4 +- src/tests/filters.svelte.test.ts | 4 +- src/tests/find-replace.svelte.test.ts | 2 +- src/tests/{ => fixtures}/AllIcons.svelte | 4 +- src/tests/{ => fixtures}/CompoundGrid.svelte | 0 src/tests/{ => fixtures}/EveryIconGrid.svelte | 0 src/tests/{ => fixtures}/FeatureLayers.svelte | 0 .../{ => fixtures}/FormattedCells.svelte | 2 +- .../{ => fixtures}/GroupHeaderGrid.svelte | 0 src/tests/{ => fixtures}/InRoot.svelte | 9 +- src/tests/{ => fixtures}/VirtualGrid.svelte | 0 src/tests/{ => fixtures}/in-root.ts | 0 src/tests/focus-order.svelte.test.ts | 2 +- src/tests/format-panel.svelte.test.ts | 2 +- src/tests/grouping.svelte.test.ts | 4 +- src/tests/i18n.svelte.test.ts | 249 ++++++++++++++++ src/tests/icons.svelte.test.ts | 4 +- src/tests/import-wizard.svelte.test.ts | 2 +- src/tests/keyboard.svelte.test.ts | 6 +- src/tests/pagination-footer.svelte.test.ts | 2 +- src/tests/polish.svelte.test.ts | 4 +- src/tests/public-api.test.ts | 4 +- src/tests/public-usage.svelte.test.ts | 68 +++++ src/tests/public-usage.test.ts | 267 ++++++++++++++++++ src/tests/qa-demos.svelte.test.ts | 8 +- src/tests/range-selection.svelte.test.ts | 4 +- src/tests/row-reorder.svelte.test.ts | 4 +- src/tests/row-span.svelte.test.ts | 6 +- src/tests/server-contract-ops.test.ts | 5 + src/tests/server-model.svelte.test.ts | 4 +- src/tests/server-model.test.ts | 2 +- src/tests/server-row-model.svelte.test.ts | 2 +- src/tests/theming.svelte.test.ts | 8 +- src/tests/tool-panel.svelte.test.ts | 2 +- src/tests/typography.test.ts | 87 ++++++ src/tests/virtualization.svelte.test.ts | 2 +- 157 files changed, 1154 insertions(+), 457 deletions(-) rename src/lib/features/advanced-filter/{community-parity.test.ts => filter-parity.test.ts} (86%) rename src/tests/{ => fixtures}/AllIcons.svelte (63%) rename src/tests/{ => fixtures}/CompoundGrid.svelte (100%) rename src/tests/{ => fixtures}/EveryIconGrid.svelte (100%) rename src/tests/{ => fixtures}/FeatureLayers.svelte (100%) rename src/tests/{ => fixtures}/FormattedCells.svelte (97%) rename src/tests/{ => fixtures}/GroupHeaderGrid.svelte (100%) rename src/tests/{ => fixtures}/InRoot.svelte (78%) rename src/tests/{ => fixtures}/VirtualGrid.svelte (100%) rename src/tests/{ => fixtures}/in-root.ts (100%) create mode 100644 src/tests/i18n.svelte.test.ts create mode 100644 src/tests/public-usage.svelte.test.ts create mode 100644 src/tests/public-usage.test.ts create mode 100644 src/tests/typography.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3e453..9e1495c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `CellDecoration.style` — a feature decorating a cell can now write CSS +- `CellDecoration.style` - a feature decorating a cell can now write CSS declarations onto it, not only class names. A class can say _which_ of a fixed set of looks a cell takes; it cannot say a value computed per cell, which is what a colour scale, a data bar or a per-user cursor tint is. The @@ -62,8 +62,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 several features decorating the same cell merge per property with the later one winning, the way classes already concatenated. - The grid writes its own layout — the grid column, the pinned offsets, the - editor's padding — as style _directives_, which outrank the attribute a + The grid writes its own layout - the grid column, the pinned offsets, the + editor's padding - as style _directives_, which outrank the attribute a decoration lands in. So a decoration cannot move a cell out of its column, unpin it, or escape the row: it can only paint. A value is also cut at the first `;`, so one entry stays one declaration and a colour read out of row @@ -183,8 +183,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rather than each caller remembering to. `headerGroupCell` draws a group header the way `headerCell` draws a leaf - one. The snippet is handed the group cell — id, label, span, whether it is - folded — and a `toggle`, and the grid's own control stays beside what it + one. The snippet is handed the group cell - id, label, span, whether it is + folded - and a `toggle`, and the grid's own control stays beside what it draws, so a badge or a count up there costs nothing. It draws into a box of its own that shrinks and clips: a group is at its narrowest exactly when it is folded, and what an app drew for the open state has to give way @@ -445,7 +445,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a flat `find` over the defs it was handed, and a grid with header groups keeps groups at the top level and the real columns in their `children`, so every entry matched nothing and was dropped. A server-model grid built the way the - README shows — `toSortRequest(getSorting(grid)!.sort, grid.columns.defs)` — + README shows - `toSortRequest(getSorting(grid)!.sort, grid.columns.defs)` - sent an empty sort: the header arrow moved, `sortChanged` fired, the request went out, and the rows came back in the order they left, with no error or warning to say why. @@ -455,7 +455,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 genuinely does not have is still dropped, as documented. - `ui.headerCell` typography reaches a sortable column's label. The classes were - on the cell — `uppercase` and `text-primary` both in its class list — but a + on the cell - `uppercase` and `text-primary` both in its class list - but a sortable column wraps its label in the `
diff --git a/src/routes/persistence/+page.ts b/src/routes/persistence/+page.ts index 2fbc81f..4a151cd 100644 --- a/src/routes/persistence/+page.ts +++ b/src/routes/persistence/+page.ts @@ -1,6 +1,6 @@ // A persisted grid restores from localStorage, which only exists on the client. // Server-rendering it would paint the default state (every column shown) and -// then correct it after hydration — the flash you would otherwise see on +// then correct it after hydration - the flash you would otherwise see on // reload. Rendering client-side lets the synchronous restore apply before the // first paint, so the saved layout shows immediately. export const ssr = false diff --git a/src/routes/qa/+page.svelte b/src/routes/qa/+page.svelte index 16fa54b..287d83c 100644 --- a/src/routes/qa/+page.svelte +++ b/src/routes/qa/+page.svelte @@ -163,7 +163,7 @@ typeOptions: { currency: 'USD' }, editable: true, editor: 'number', - tooltip: ({ row }) => `${row.name} · ${row.dept}` + tooltip: ({ row }) => `${row.name} | ${row.dept}` }, { id: 'share', @@ -256,7 +256,7 @@ const selectionState = getSelection(grid)! const editState = getEditing(grid)! - let lastEvent = $state('—') + let lastEvent = $state('-') for (const event of [ 'sortChanged', 'filterChanged', @@ -304,12 +304,12 @@
-

QA — toàn bộ bản Free

+

QA - toàn bộ bản Free

- Một lưới bật mọi tính năng Community: header group 2 tầng, ghim trái/phải, 13 kiểu - ô, sắp xếp nhiều cột, lọc mọi kiểu (2 điều kiện + phủ định + match case), chọn dòng, - sửa ô, ghim dòng, phân trang. Các công tắc bên dưới đổi trạng thái mà không dựng lại - lưới. Bàn phím: sửa, chọn, + Một lưới bật mọi tính năng nền: header group 2 tầng, ghim trái/phải, 13 kiểu ô, sắp + xếp nhiều cột, lọc mọi kiểu (2 điều kiện + phủ định + match case), chọn dòng, sửa ô, + ghim dòng, phân trang. Các công tắc bên dưới đổi trạng thái mà không dựng lại lưới. + Bàn phím: sửa, chọn, + mở menu cột.

@@ -373,9 +373,9 @@

Lọc: {filterState.activeCount} cột

Chọn: {selectionState.count} dòng

- Đang sửa: {editState.active ? `${editState.active.columnId}` : '—'} + Đang sửa: {editState.active ? `${editState.active.columnId}` : '-'}

-

Announcer: {grid.announcer.message || '—'}

+

Announcer: {grid.announcer.message || '-'}

Event gần nhất

diff --git a/src/routes/renderers/+page.svelte b/src/routes/renderers/+page.svelte index 22142d2..965cb36 100644 --- a/src/routes/renderers/+page.svelte +++ b/src/routes/renderers/+page.svelte @@ -87,8 +87,8 @@ align: 'right', width: 130, typeOptions: { currency: 'USD', numberFormat: { maximumFractionDigits: 0 } }, - // Says what the cell says — the formatted amount, not the number - // behind it — through the sv5ui tooltip. + // Says what the cell says - the formatted amount, not the number + // behind it - through the sv5ui tooltip. tooltip: true }, { @@ -201,16 +201,16 @@
-

Cell renderers — sv5ui showcase

+

Cell renderers - sv5ui showcase

- Đặt type trên cột là xong — không cần viết snippet. 11 renderer dựng + Đặt type trên cột là xong - không cần viết snippet. 11 renderer dựng sẵn bằng chính component sv5ui: user, badge, currency, percent, date, datetime, boolean, progress, rating, link, actions.

- Cột Salary bật tooltip: true — di chuột vào ô để thấy + Cột Salary bật tooltip: true - di chuột vào ô để thấy tooltip sv5ui nói đúng chữ ô đang hiện ($204,000), không phải số thô phía sau. Cột Budget vừa khai type vừa có cell: snippet đọc formatted rồi gắn thêm badge. diff --git a/src/routes/reorder/+page.svelte b/src/routes/reorder/+page.svelte index 0f8b276..3273df6 100644 --- a/src/routes/reorder/+page.svelte +++ b/src/routes/reorder/+page.svelte @@ -26,7 +26,7 @@ const blurbs = [ 'Short note.', 'A longer note that wraps onto a second line once the column is narrow enough to force it.', - 'A much longer note again — auto row height measures whatever the cell actually renders, so three or four lines of prose are fine and the scrollbar still lands in the right place.' + 'A much longer note again - auto row height measures whatever the cell actually renders, so three or four lines of prose are fine and the scrollbar still lands in the right place.' ] let tasks = $state.raw( @@ -39,7 +39,7 @@ })) ) - let lastMove = $state('—') + let lastMove = $state('-') const columns: ColumnDef[] = [ { @@ -60,7 +60,7 @@ minWidth: 220, cell: notesCell, // The cell already wraps, so the hover-truncation tooltip has - // nothing to add — turn it off rather than have both. + // nothing to add - turn it off rather than have both. tooltip: false }, { id: 'owner', header: 'Owner', width: 130, filter: 'set' }, diff --git a/src/routes/rows/+page.svelte b/src/routes/rows/+page.svelte index e39b85f..2aca3a8 100644 --- a/src/routes/rows/+page.svelte +++ b/src/routes/rows/+page.svelte @@ -215,7 +215,7 @@ {#snippet orderDetail({ row }: DataGridFullWidthContext)}

- Order #{row.id} — {row.customer} + Order #{row.id} - {row.customer}
@@ -241,7 +241,7 @@
- Total {money.format(row.total)} · {row.items.length} line items + Total {money.format(row.total)} | {row.items.length} line items
{/snippet} @@ -249,11 +249,11 @@
-

Row structures — Phase 6

+

Row structures - Phase 6

- Row-structure kernel (RowMeta · ExpansionModel · treegrid ARIA · full-width rows) + - row pinning. Grouping/tree/master-detail hoàn chỉnh thuộc Pro — demo dưới dùng đúng - extension points công khai mà Pro sẽ dùng. + Row-structure kernel (RowMeta | ExpansionModel | treegrid ARIA | full-width rows) + + row pinning. Grouping, tree và master-detail đầy đủ là các feature riêng; demo dưới + dùng đúng những extension point công khai mà chúng dùng.

@@ -268,7 +268,7 @@ Expandable rows + virtualization ({ROWS.toLocaleString()} orders)

- Feature inline ~20 dòng qua pipeline stage flatten — detail full-width + Feature inline ~20 dòng qua pipeline stage flatten - detail full-width cao {DETAIL_HEIGHT}px, Fenwick variable heights.

@@ -294,13 +294,13 @@

Row pinning

- Grand total pinned top · Average pinned bottom — pinned rows bỏ qua sort/filter. + Grand total pinned top | Average pinned bottom - pinned rows bỏ qua sort/filter. Chuột phải một hàng để pin/unpin.

- Pinned: {metricPinning.topNodes.length} top · {metricPinning.bottomNodes.length} bottom + Pinned: {metricPinning.topNodes.length} top | {metricPinning.bottomNodes.length} bottom
diff --git a/src/routes/selection/+page.svelte b/src/routes/selection/+page.svelte index b4a52a6..054f589 100644 --- a/src/routes/selection/+page.svelte +++ b/src/routes/selection/+page.svelte @@ -101,16 +101,16 @@ {/snippet} {#snippet activeCell({ value }: DataGridCellContext)} - {value ? '✓' : '—'} + {value ? '✓' : '-'} {/snippet}
-

Selection + clipboard — Phase 5

+

Selection + clipboard - Phase 5

- Row selection (checkbox pinned trái, select-all indeterminate) · Shift+click range · - copy TSV · export CSV · context menu — hàng Active = ✗ không chọn được + Row selection (checkbox pinned trái, select-all indeterminate) | Shift+click range | + copy TSV | export CSV | context menu - hàng Active = ✗ không chọn được (isRowSelectable).

@@ -144,7 +144,7 @@ onclick={selectionState.clear} /> - Export CSV xuất selection — hoặc toàn bộ hàng đã lọc khi chưa chọn gì + Export CSV xuất selection - hoặc toàn bộ hàng đã lọc khi chưa chọn gì
diff --git a/src/routes/server/+page.svelte b/src/routes/server/+page.svelte index 6b77b01..a634e14 100644 --- a/src/routes/server/+page.svelte +++ b/src/routes/server/+page.svelte @@ -36,7 +36,7 @@ /** * Stands in for the API. It sorts, filters and slices, and hands back one - * page — the grid never sees the other 127 rows. + * page - the grid never sees the other 127 rows. */ async function fetchPage(request: { page: number @@ -107,7 +107,7 @@ * Driven by events, not by an effect: `pagination.page` clamps against the * row count on read, so an effect reading it and then calling * `setRowCount` would feed itself. The events fire after the feature has - * settled — `pageChanged` reports the page the grid actually moved to, and + * settled - `pageChanged` reports the page the grid actually moved to, and * pagination resets to page 1 on a sort or filter before this runs. */ async function load(): Promise { @@ -171,7 +171,7 @@

Server row model + selection

- rowModel: 'server' — grid chỉ giữ đúng một trang, sort/filter/paging do "API" + rowModel: 'server' - grid chỉ giữ đúng một trang, sort/filter/paging do "API" giả lập (250ms) làm. Chọn hàng ở trang 2 rồi sang trang khác: selection theo row id nên vẫn còn.

@@ -205,8 +205,8 @@ onclick={selectionState.clear} /> - page {paginationState.page}/{paginationState.pageCount} · {grid.nodes.length} rows loaded - · {paginationState.total} total · {selectionState.count} selected · {fetches} fetches + page {paginationState.page}/{paginationState.pageCount} | {grid.nodes.length} rows loaded + | {paginationState.total} total | {selectionState.count} selected | {fetches} fetches
@@ -227,7 +227,7 @@
Kiểm tra: - Sang trang 2 rồi bấm một ô bất kỳ — trang phải đứng yên, không nhảy về 1 + Sang trang 2 rồi bấm một ô bất kỳ - trang phải đứng yên, không nhảy về 1 ↓ hết trang cũng không đổi trang (server tự phân trang) Bấm vào khoảng trống trong cột checkbox vẫn chọn được hàng
diff --git a/src/routes/server/big/+page.svelte b/src/routes/server/big/+page.svelte index 641bacc..feb48b5 100644 --- a/src/routes/server/big/+page.svelte +++ b/src/routes/server/big/+page.svelte @@ -27,7 +27,7 @@ const statuses = ['paid', 'pending', 'refunded'] /** - * The backend, generated on demand. Ten million rows never exist at once — + * The backend, generated on demand. Ten million rows never exist at once - * only the page asked for does, which is the point being measured: what * the grid costs is a function of the page, not of the set behind it. */ @@ -64,7 +64,7 @@ /** * The filtered path, with no index behind it: this is what a table scan - * costs the server. The grid is not in it — it receives `pageSize` rows + * costs the server. The grid is not in it - it receives `pageSize` rows * either way, which is what the panel above the grid separates out. */ function fetchScan( @@ -234,7 +234,7 @@
-

Server row model — big data

+

Server row model - big data

Backend sinh dòng theo yêu cầu, tối đa 10 triệu dòng, nhưng grid không bao giờ giữ quá một trang. Số đo dưới đây tách phần của server ra khỏi phần của grid. @@ -299,7 +299,7 @@

Đổi backend size từ 100k lên 10M: grid → DOM không đổi, vì grid vẫn nhận - đúng page size dòng. Đổi page size mới là thứ làm nó tăng — 1000 dòng một trang là + đúng page size dòng. Đổi page size mới là thứ làm nó tăng - 1000 dòng một trang là 1000 dòng trong DOM, và đó là lúc cần virtualization thay cho phân trang. Ô tìm kiếm chạy quét bảng không index phía "server", giới hạn {SCAN_CAP.toLocaleString()} dòng: cột diff --git a/src/routes/server/infinite/+page.svelte b/src/routes/server/infinite/+page.svelte index ff4cea9..f538676 100644 --- a/src/routes/server/infinite/+page.svelte +++ b/src/routes/server/infinite/+page.svelte @@ -73,8 +73,8 @@ /** * Reads the window the virtualizer asks for and appends when it comes - * within `THRESHOLD` of the end. The write feeds the read — the row count - * is what the window is measured against — so the append is untracked and + * within `THRESHOLD` of the end. The write feeds the read - the row count + * is what the window is measured against - so the append is untracked and * the guard has to settle, which it does as soon as the window fits. */ $effect(() => { @@ -134,7 +134,7 @@

- Server row model — infinite scroll + Server row model - infinite scroll

Một triệu dòng log phía "server", lấy về từng khối {CHUNK} dòng khi cửa sổ của virtualizer @@ -151,8 +151,8 @@

@@ -164,7 +164,7 @@

- Số dòng trong DOM đứng yên dù cuộn bao xa — virtualizer chỉ dựng phần nhìn thấy. Số dòng + Số dòng trong DOM đứng yên dù cuộn bao xa - virtualizer chỉ dựng phần nhìn thấy. Số dòng held thì tăng theo từng khối: đó là bộ nhớ app chấp nhận đánh đổi để cuộn liên tục, và là khác biệt duy nhất so với phân trang, nơi grid vứt trang cũ đi. diff --git a/src/routes/spans/+page.svelte b/src/routes/spans/+page.svelte index 4ec234f..935094f 100644 --- a/src/routes/spans/+page.svelte +++ b/src/routes/spans/+page.svelte @@ -107,10 +107,10 @@

-

Spanning — rowSpan & colSpan

+

Spanning - rowSpan & colSpan

rowSpan(ctx) trả về số dòng ô này phủ; các ô bị phủ không render. Span tính - trên toàn bộ danh sách nên vẫn đúng khi cuộn — cuộn vào giữa một span, ô chủ nằm trên + trên toàn bộ danh sách nên vẫn đúng khi cuộn - cuộn vào giữa một span, ô chủ nằm trên vùng hiển thị vẫn được vẽ đúng chỗ. Cột Region còn được ghim trái để kiểm tra span không đè lên cột ghim.

@@ -128,8 +128,8 @@
  • Ô Region/Country cao bằng đúng số dòng nó phủ, không hở và không lệch.
  • Đường kẻ ngang giữa các dòng bị span che, không xuyên qua ô.
  • -
  • Cuộn dọc vào giữa một span — ô vẫn liền mạch, không nhảy.
  • -
  • Cuộn ngang — cột Region ghim trái vẫn nằm trên các ô span khác.
  • +
  • Cuộn dọc vào giữa một span - ô vẫn liền mạch, không nhảy.
  • +
  • Cuộn ngang - cột Region ghim trái vẫn nằm trên các ô span khác.
  • Sắp xếp theo Revenue làm span vỡ thành từng dòng, đúng như dữ liệu.
  • Bàn phím: mũi tên xuống đi qua vùng bị phủ vẫn dừng ở ô chủ.
diff --git a/src/routes/stress/+page.svelte b/src/routes/stress/+page.svelte index ceaa7a5..bfdf64d 100644 --- a/src/routes/stress/+page.svelte +++ b/src/routes/stress/+page.svelte @@ -260,13 +260,13 @@
-

Stress — nhiều dòng, nhiều cột

+

Stress - nhiều dòng, nhiều cột

39 cột với đủ kiểu hiển thị (tiền tệ, phần trăm, ngày, badge, progress, rating, boolean) trên tối đa một triệu dòng. Ảo hoá cả hai chiều, nên số ô thực sự nằm trong DOM không đổi theo lượng dữ liệu. Sắp xếp, lọc và cuộn đều chạy trên toàn bộ tập. Quá một triệu dòng thì tổng chiều cao vượt mức trình duyệt chịu vẽ, nên thanh cuộn - được nén lại — dòng cuối vẫn tới được, chỉ là mỗi pixel cuộn đi xa hơn. + được nén lại - dòng cuối vẫn tới được, chỉ là mỗi pixel cuộn đi xa hơn.

@@ -310,7 +310,7 @@

Cần soi

  • Số dòng và cột được vẽ giữ nguyên dù chọn 100k hay 1 triệu.
  • -
  • Cuộn dọc và ngang cùng lúc — cột # ghim trái phải đứng yên.
  • +
  • Cuộn dọc và ngang cùng lúc - cột # ghim trái phải đứng yên.
  • Sắp theo Total (số) và Customer (chuỗi) trên toàn bộ tập.
  • Lọc nhanh chạy trên mọi cột đang hiện, không chỉ trang đang xem.
  • Chọn hết rồi xem thanh trạng thái đếm đúng tổng.
  • diff --git a/src/routes/theming/+page.svelte b/src/routes/theming/+page.svelte index 264cb05..72be337 100644 --- a/src/routes/theming/+page.svelte +++ b/src/routes/theming/+page.svelte @@ -125,7 +125,7 @@
    -

    Theming — §9

    +

    Theming - §9

    Ba tầng ghi đè, từ rộng tới hẹp: defineDataGridConfig cho cả app → ui cho một grid → @@ -163,7 +163,7 @@

    2. App-wide config

    - defineDataGridConfig đặt mặc định cho mọi grid — ở đây là density + defineDataGridConfig đặt mặc định cho mọi grid - ở đây là density compact và một status bar in đậm. Grid nào tự khai báo density thì giữ nguyên lựa chọn của nó.

    @@ -184,7 +184,7 @@

    Hàng overdue có nền đỏ nhạt (rowClass). Ô Amount - và Due âm chuyển sang màu error (cellClass) — chú ý màu chữ mặc định + và Due âm chuyển sang màu error (cellClass) - chú ý màu chữ mặc định bị thay hẳn chứ không cộng dồn.

    diff --git a/src/routes/virtual/+page.svelte b/src/routes/virtual/+page.svelte index dfa78a1..f5324a6 100644 --- a/src/routes/virtual/+page.svelte +++ b/src/routes/virtual/+page.svelte @@ -210,14 +210,14 @@ {/snippet} {#snippet activeCell({ value }: DataGridCellContext)} - {value ? '✓' : '—'} + {value ? '✓' : '-'} {/snippet}

    - {ROWS.toLocaleString()} rows — row virtualization + {ROWS.toLocaleString()} rows - row virtualization

    Fixed-height virtualizer (40px), overscan 6, sticky header, rAF-batched scroll. Sort @@ -256,10 +256,10 @@

    - rendered rows {virt.virtualizer.range.start + 1}–{virt.virtualizer.range.end} / {grid.totalRows.toLocaleString()} - · rendered cols {virt.columnVirtualizer - ? `${virt.columnVirtualizer.range.start + 1}–${virt.columnVirtualizer.range.end}` - : 'all'} / {grid.columns.visible.length} · DOM giữ ~{virt.virtualizer.range.end - + rendered rows {virt.virtualizer.range.start + 1}-{virt.virtualizer.range.end} / {grid.totalRows.toLocaleString()} + | rendered cols {virt.columnVirtualizer + ? `${virt.columnVirtualizer.range.start + 1}-${virt.columnVirtualizer.range.end}` + : 'all'} / {grid.columns.visible.length} | DOM giữ ~{virt.virtualizer.range.end - virt.virtualizer.range.start} hàng dù danh sách cao {Math.round( virt.virtualizer.totalHeight / 1000 ).toLocaleString()}k px @@ -269,7 +269,7 @@

    Variable row heights

    - 5.000 hàng với getRowHeight (40/64/88px xen kẽ) — offset tính bằng + 5.000 hàng với getRowHeight (40/64/88px xen kẽ) - offset tính bằng Fenwick tree, scrollToRow vẫn nhảy chính xác.

    @@ -287,7 +287,7 @@ onclick={() => variableVirt.scrollToRow(0)} /> - rows {variableVirt.virtualizer.range.start + 1}–{variableVirt.virtualizer.range.end} / + rows {variableVirt.virtualizer.range.start + 1}-{variableVirt.virtualizer.range.end} / {variableGrid.totalRows.toLocaleString()}
    diff --git a/src/tests/api-types.test-d.ts b/src/tests/api-types.test-d.ts index a7b3069..1afe529 100644 --- a/src/tests/api-types.test-d.ts +++ b/src/tests/api-types.test-d.ts @@ -1,6 +1,6 @@ /** * Compile-time only. Neither vitest project matches `*.test-d.ts`, so nothing - * here runs — `npm run check` is what holds these shapes, and a regression + * here runs - `npm run check` is what holds these shapes, and a regression * shows up as a type error rather than a failing assertion. It lives outside * `src/lib` so it stays out of the published package. */ diff --git a/src/tests/axe.svelte.test.ts b/src/tests/axe.svelte.test.ts index ab02c68..33dde96 100644 --- a/src/tests/axe.svelte.test.ts +++ b/src/tests/axe.svelte.test.ts @@ -11,7 +11,7 @@ import { type ColumnDef, type DataGridProps } from '$lib/index.js' -import VirtualGrid from './VirtualGrid.svelte' +import VirtualGrid from './fixtures/VirtualGrid.svelte' interface Person { id: number diff --git a/src/tests/cell-formatting.svelte.test.ts b/src/tests/cell-formatting.svelte.test.ts index 77e0ee7..0404b24 100644 --- a/src/tests/cell-formatting.svelte.test.ts +++ b/src/tests/cell-formatting.svelte.test.ts @@ -10,7 +10,7 @@ import { type DataGridProps } from '$lib/index.js' import Renderers from '../routes/renderers/+page.svelte' -import FormattedCells from './FormattedCells.svelte' +import FormattedCells from './fixtures/FormattedCells.svelte' interface Row { id: number @@ -65,7 +65,7 @@ describe('formatted, handed to a cell snippet', () => { expect(byId('money')?.formatted).toBe('$1,235') expect(byId('when')?.formatted).toBe('Aug 11, 2026') expect(byId('plain')?.formatted).toBe('hello') - expect(byId('empty')?.formatted).toBe('—') + expect(byId('empty')?.formatted).toBe('-') // A widget has no string standing for it. expect(byId('bar')?.formatted).toBeUndefined() expect(byId('flag')?.formatted).toBeUndefined() diff --git a/src/tests/cell-types.svelte.test.ts b/src/tests/cell-types.svelte.test.ts index eee0f9e..4aa3623 100644 --- a/src/tests/cell-types.svelte.test.ts +++ b/src/tests/cell-types.svelte.test.ts @@ -143,7 +143,7 @@ describe('formatting renderers', () => { makeGrid([{ id: 'salary', header: 'Salary', type: 'currency' }], blank) ) - expect(cellAt(screen.container, 0, 0).textContent?.trim()).toBe('—') + expect(cellAt(screen.container, 0, 0).textContent?.trim()).toBe('-') }) it('honours a custom emptyText', async () => { @@ -179,7 +179,7 @@ describe('formatting renderers', () => { ) ) - expect(cellAt(screen.container, 0, 0).textContent?.trim()).toBe('—') + expect(cellAt(screen.container, 0, 0).textContent?.trim()).toBe('-') expect(cellAt(screen.container, 0, 1).textContent?.trim()).toBe('n/a') }) }) diff --git a/src/tests/col-span.svelte.test.ts b/src/tests/col-span.svelte.test.ts index e96b027..dd5d08b 100644 --- a/src/tests/col-span.svelte.test.ts +++ b/src/tests/col-span.svelte.test.ts @@ -26,7 +26,7 @@ const rows: Row[] = [ { id: 2, name: 'plain', a: 'A2', b: 'B2', c: 'C2', d: 'D2' } ] -// Row 1 spans column `a` (index 1) across three columns — covering 1, 2, 3 — +// Row 1 spans column `a` (index 1) across three columns - covering 1, 2, 3 - // leaving column `d` (index 4) as the first cell after the span. Row 2 is normal. const columns: ColumnDef[] = [ { id: 'name', header: 'Name' }, diff --git a/src/tests/column-extras.svelte.test.ts b/src/tests/column-extras.svelte.test.ts index af5e63b..7c7c458 100644 --- a/src/tests/column-extras.svelte.test.ts +++ b/src/tests/column-extras.svelte.test.ts @@ -100,7 +100,7 @@ describe('ColumnDef.tooltip', () => { /** * What the tooltip is showing. It lives in a portal, and sv5ui opens it - * after its own 700ms delay — longer than a default poll waits. + * after its own 700ms delay - longer than a default poll waits. */ const tooltipContent = () => document.querySelector('[data-bits-floating-content-wrapper]')?.textContent?.trim() @@ -168,7 +168,7 @@ describe('ColumnDef.tooltip', () => { await expect.element(screen.getByRole('grid')).toBeVisible() // bits-ui hands its trigger a tabindex of 0. Left alone, a page of rows - // would be a page of tab stops — the fault the checkbox column had. + // would be a page of tab stops - the fault the checkbox column had. const triggers = screen.container.querySelectorAll('[data-tooltip-trigger]') expect(triggers.length).toBeGreaterThan(0) for (const trigger of triggers) expect(trigger.tabIndex).toBe(-1) diff --git a/src/tests/column-groups.svelte.test.ts b/src/tests/column-groups.svelte.test.ts index ea4ef81..4ef00d1 100644 --- a/src/tests/column-groups.svelte.test.ts +++ b/src/tests/column-groups.svelte.test.ts @@ -5,7 +5,7 @@ import { render } from 'vitest-browser-svelte' import { page, userEvent } from 'vitest/browser' import ColumnsDemo from '../routes/columns/+page.svelte' import GroupsDemo from '../routes/groups/+page.svelte' -import GroupHeaderGrid from './GroupHeaderGrid.svelte' +import GroupHeaderGrid from './fixtures/GroupHeaderGrid.svelte' import { columnOps, createDataGrid, diff --git a/src/tests/column-virtualization.svelte.test.ts b/src/tests/column-virtualization.svelte.test.ts index fd56eba..9fe0371 100644 --- a/src/tests/column-virtualization.svelte.test.ts +++ b/src/tests/column-virtualization.svelte.test.ts @@ -8,7 +8,7 @@ import { type ColumnDef, type GridState } from '$lib/index.js' -import VirtualGrid from './VirtualGrid.svelte' +import VirtualGrid from './fixtures/VirtualGrid.svelte' interface Row { id: number diff --git a/src/tests/conditional-formatting.svelte.test.ts b/src/tests/conditional-formatting.svelte.test.ts index df219c8..6d6f511 100644 --- a/src/tests/conditional-formatting.svelte.test.ts +++ b/src/tests/conditional-formatting.svelte.test.ts @@ -8,7 +8,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import DataGrid from '../lib/components/grid/DataGrid.svelte' diff --git a/src/tests/data-shapes.test.ts b/src/tests/data-shapes.test.ts index 9c3d574..7fe6f90 100644 --- a/src/tests/data-shapes.test.ts +++ b/src/tests/data-shapes.test.ts @@ -36,7 +36,7 @@ interface Cell { } const LOCALE = 'en-US' -const EMPTY = '—' +const EMPTY = '-' /** Local, so the day the cell draws does not depend on where this runs. */ const JAN_10 = new Date(2024, 0, 10, 9, 30) diff --git a/src/tests/datagrid.svelte.test.ts b/src/tests/datagrid.svelte.test.ts index 41d9feb..69e8e7e 100644 --- a/src/tests/datagrid.svelte.test.ts +++ b/src/tests/datagrid.svelte.test.ts @@ -12,7 +12,7 @@ import { type ColumnDef, type DataGridProps } from '$lib/index.js' -import CompoundGrid from './CompoundGrid.svelte' +import CompoundGrid from './fixtures/CompoundGrid.svelte' interface Person { id: number diff --git a/src/tests/demos.svelte.test.ts b/src/tests/demos.svelte.test.ts index 7dbb47d..e9b51fa 100644 --- a/src/tests/demos.svelte.test.ts +++ b/src/tests/demos.svelte.test.ts @@ -60,7 +60,7 @@ describe('server row model demo', () => { // A server model holds one page, so its row indexes run 0..n on every // page. Numbering them from the page offset left every lookup into - // `preWindowNodes` — Space, Ctrl+C, type-to-edit — pointing past the end. + // `preWindowNodes` - Space, Ctrl+C, type-to-edit - pointing past the end. await page.getByRole('gridcell', { name: 'Charlie #11' }).click() await userEvent.keyboard(' ') await expect.poll(state).toContain('1 selected') @@ -82,7 +82,7 @@ describe('server row model demo', () => { const cell = document.querySelector('[data-dg-cell="0:0"]')! const box = cell.getBoundingClientRect() - // Off the checkbox, inside the cell — where a casual aim lands. + // Off the checkbox, inside the cell - where a casual aim lands. document .elementFromPoint(box.left + 2, box.top + 2)! .dispatchEvent(new MouseEvent('click', { bubbles: true })) @@ -107,8 +107,8 @@ describe('server row model demo', () => { await page.getByRole('button', { name: 'Go to page 2' }).click() await expect.element(page.getByRole('gridcell', { name: 'Charlie #11' })).toBeVisible() - // The header checkbox speaks for the rows the grid holds — one page - // here — so it must not throw away the pages it cannot see. + // The header checkbox speaks for the rows the grid holds - one page + // here - so it must not throw away the pages it cannot see. await page.getByRole('checkbox', { name: 'Select all rows' }).click() await expect.poll(state).toContain('12 selected') diff --git a/src/tests/editing.svelte.test.ts b/src/tests/editing.svelte.test.ts index 6ac1f88..50e602a 100644 --- a/src/tests/editing.svelte.test.ts +++ b/src/tests/editing.svelte.test.ts @@ -592,7 +592,7 @@ describe('leaving a widget editor', () => { await expect.poll(() => isOpen(screen.container)).toBe(true) // A widget editor leaves focus on the cell, so a handler on the editor - // inside it never sees the key — the binding has to be on the grid. + // inside it never sees the key - the binding has to be on the grid. await userEvent.keyboard('{Escape}') await expect.poll(() => isOpen(screen.container)).toBe(false) expect(getEditing(grid)!.active).toBeNull() @@ -639,7 +639,7 @@ describe('leaving a widget editor', () => { }) describe('segmented editors in a narrow column', () => { - /** name, age, dept, active, rating, skills, joined — `joined` is the date. */ + /** name, age, dept, active, rating, skills, joined - `joined` is the date. */ const JOINED = 6 it('grows past the cell rather than running its segments under the icon', async () => { diff --git a/src/tests/feature-layers.svelte.test.ts b/src/tests/feature-layers.svelte.test.ts index 3ef7151..c6b4e53 100644 --- a/src/tests/feature-layers.svelte.test.ts +++ b/src/tests/feature-layers.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' -import FeatureLayers from './FeatureLayers.svelte' +import FeatureLayers from './fixtures/FeatureLayers.svelte' /** * What `GridFeature.component` is for. Neither of the two things checked here diff --git a/src/tests/filter-builder.svelte.test.ts b/src/tests/filter-builder.svelte.test.ts index 36b7459..d3aeeaf 100644 --- a/src/tests/filter-builder.svelte.test.ts +++ b/src/tests/filter-builder.svelte.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import FilterBuilder from '../lib/components/panels/FilterBuilder.svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { advancedFilter, getAdvancedFilter @@ -280,7 +280,7 @@ describe('the filter builder writes the tree', () => { expect(row().querySelector('input')?.type).toBe('number') }) - it('gives a date column the same segmented picker the free grid uses', async () => { + it('gives a date column the same segmented picker a column filter uses', async () => { const grid = makeGrid() const screen = await render(TypedBuilder, inRoot(grid)) const state = getAdvancedFilter(grid)! diff --git a/src/tests/filters.svelte.test.ts b/src/tests/filters.svelte.test.ts index 1d08ccf..9d62fa0 100644 --- a/src/tests/filters.svelte.test.ts +++ b/src/tests/filters.svelte.test.ts @@ -97,7 +97,7 @@ describe('column filters', () => { }) await expect.element(screen.getByRole('grid')).toBeVisible() - // Open via the column menu, not the filter icon — the path that used to + // Open via the column menu, not the filter icon - the path that used to // leave the panel stranded at the top-left corner. await page.getByRole('button', { name: 'Name column menu' }).click() await page.getByRole('menuitem', { name: 'Filter...' }).click() @@ -594,7 +594,7 @@ describe('popup layering', () => { await expect.element(listbox).toBeVisible() // The panel and the sv5ui popup layer used to share `z-50`, which left - // the winner to DOM order — and the panel is appended last, so it + // the winner to DOM order - and the panel is appended last, so it // covered its own operator list. for (const role of coveredPoints(listbox.element() as HTMLElement)) { expect(role).toBe('') diff --git a/src/tests/find-replace.svelte.test.ts b/src/tests/find-replace.svelte.test.ts index 31679ea..1a8ce4d 100644 --- a/src/tests/find-replace.svelte.test.ts +++ b/src/tests/find-replace.svelte.test.ts @@ -9,7 +9,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { userEvent } from 'vitest/browser' diff --git a/src/tests/AllIcons.svelte b/src/tests/fixtures/AllIcons.svelte similarity index 63% rename from src/tests/AllIcons.svelte rename to src/tests/fixtures/AllIcons.svelte index a3bfc1e..178e7f2 100644 --- a/src/tests/AllIcons.svelte +++ b/src/tests/fixtures/AllIcons.svelte @@ -1,7 +1,7 @@ - + {#if Part} {/if} diff --git a/src/tests/VirtualGrid.svelte b/src/tests/fixtures/VirtualGrid.svelte similarity index 100% rename from src/tests/VirtualGrid.svelte rename to src/tests/fixtures/VirtualGrid.svelte diff --git a/src/tests/in-root.ts b/src/tests/fixtures/in-root.ts similarity index 100% rename from src/tests/in-root.ts rename to src/tests/fixtures/in-root.ts diff --git a/src/tests/focus-order.svelte.test.ts b/src/tests/focus-order.svelte.test.ts index ad52a99..15a712d 100644 --- a/src/tests/focus-order.svelte.test.ts +++ b/src/tests/focus-order.svelte.test.ts @@ -80,7 +80,7 @@ describe('the grid is one tab stop', () => { await userEvent.keyboard(' ') await expect.poll(() => selectionState.count).toBe(people.length) - // And back off again — `Ctrl+A` alone would only ever select. + // And back off again - `Ctrl+A` alone would only ever select. await userEvent.keyboard(' ') await expect.poll(() => selectionState.count).toBe(0) }) diff --git a/src/tests/format-panel.svelte.test.ts b/src/tests/format-panel.svelte.test.ts index 71fef69..74ec05b 100644 --- a/src/tests/format-panel.svelte.test.ts +++ b/src/tests/format-panel.svelte.test.ts @@ -17,7 +17,7 @@ import type { FormatRule } from '../lib/features/conditional-formatting/conditio import { serverRowModel } from '../lib/features/server-row-model/server-row-model.svelte.js' import type { DataSource } from '../lib/features/server-row-model/server-row-model.types.js' import { viVN } from '../lib/locales/vi-VN.js' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' interface Deal { id: number diff --git a/src/tests/grouping.svelte.test.ts b/src/tests/grouping.svelte.test.ts index 1e23142..ea74d36 100644 --- a/src/tests/grouping.svelte.test.ts +++ b/src/tests/grouping.svelte.test.ts @@ -7,7 +7,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' @@ -72,7 +72,7 @@ async function renderGrid(grid: GridState) { return screen } -describe('grouping through the Community kernel', () => { +describe('grouping through the pipeline', () => { it('renders group rows the kernel treats as treegrid nodes', async () => { const grid = makeGrid({ by: ['dept'] }) const screen = await renderGrid(grid) diff --git a/src/tests/i18n.svelte.test.ts b/src/tests/i18n.svelte.test.ts new file mode 100644 index 0000000..9d3043a --- /dev/null +++ b/src/tests/i18n.svelte.test.ts @@ -0,0 +1,249 @@ +import { + createDataGrid, + defaultLabels, + defineDataGridConfig, + mergeLabels, + resetDataGridConfig, + type ColumnDef, + type GridState +} from '$lib/index.js' +import axe from 'axe-core' +import { afterEach, describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import FilterBuilder from '../lib/components/panels/FilterBuilder.svelte' +import GroupPanel from '../lib/components/panels/GroupPanel.svelte' +import { advancedFilter } from '../lib/features/advanced-filter/advanced-filter.svelte.js' +import RangeStatusBar from '../lib/components/chrome/RangeStatusBar.svelte' +import { InGrid } from './fixtures/in-root.js' +import { grouping } from '../lib/features/grouping/grouping.svelte.js' +import { + getRangeSelection, + rangeSelection +} from '../lib/features/range-selection/range-selection.svelte.js' +import { deDE } from '../lib/locales/de-DE.js' +import { enUS } from '../lib/locales/en-US.js' +import { viVN } from '../lib/locales/vi-VN.js' + +interface Person { + id: number + dept: string + salary: number +} + +const columns: ColumnDef[] = [ + { id: 'dept', header: 'Dept', width: 200 }, + { id: 'salary', header: 'Salary', width: 130 } +] + +const data: Person[] = [ + { id: 1, dept: 'Core', salary: 100 }, + { id: 2, dept: 'Data', salary: 200 } +] + +const de = mergeLabels(deDE.labels) +const vi = mergeLabels(viVN.labels) + +const inRoot = (grid: GridState, component: unknown) => ({ + props: { grid, component } +}) + +// The language belongs to the grid now, not to a global the tests had to put +// back afterwards. Two grids on one page may disagree, and nothing resets. +function makeGrid(by: string[] = [], locale?: string): GridState { + return createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + locales: [viVN, deDE], + locale, + features: [grouping({ by }), rangeSelection(), advancedFilter()] + }) +} + +const ENGLISH_WORDS = [ + ...Object.values(enUS.labels ?? {}).map((label) => { + if (typeof label !== 'function') return label + try { + return (label as (...args: unknown[]) => string)('X', 1, 2) + } catch { + return '' + } + }) +] + .flatMap((text) => String(text).split(/[\s\-:()|]+/)) + .filter((word) => /^[A-Za-z]{3,}$/.test(word) && word !== 'sv5ui') + +describe('the chrome speaks the language its grid was given', () => { + it('renders the group panel in Vietnamese', async () => { + const screen = await render(InGrid, inRoot(makeGrid([], 'vi-VN'), GroupPanel)) + + const panel = screen.container.querySelector('[data-dg-group-panel]') + expect(panel?.textContent).toContain(vi.groupBy) + expect(panel?.textContent).toContain(vi.groupPanelEmpty) + }) + + it('translates the chip aria-labels, not just the visible text', async () => { + const screen = await render(InGrid, inRoot(makeGrid(['dept'], 'vi-VN'), GroupPanel)) + + const labels = [ + ...screen.container.querySelectorAll('[data-dg-group-panel] [aria-label]') + ].map((element) => element.getAttribute('aria-label')) + + expect(labels).toContain(vi.removeGroup('Dept')) + expect(labels).toContain(vi.moveGroupEarlier('Dept')) + }) + + it('leaves no English word in the group panel', async () => { + const screen = await render(InGrid, inRoot(makeGrid([], 'vi-VN'), GroupPanel)) + + const text = screen.container.querySelector('[data-dg-group-panel]')?.textContent ?? '' + + const chrome = text.replace(/Dept|Salary/g, '') + for (const word of ENGLISH_WORDS) { + expect(chrome, `"${word}" leaked from a hardcoded label`).not.toContain(word) + } + }) + + it('renders the filter builder in Vietnamese', async () => { + const grid = makeGrid([], 'vi-VN') + const screen = await render(InGrid, inRoot(grid, FilterBuilder)) + + const text = screen.container.querySelector('[data-dg-filter-panel]')?.textContent ?? '' + expect(text).toContain('Bộ lọc') + + for (const word of ENGLISH_WORDS) { + expect(text, `"${word}" leaked from a hardcoded label`).not.toContain(word) + } + }) + + it('renders the range status bar in Vietnamese', async () => { + const grid = makeGrid([], 'vi-VN') + getRangeSelection(grid)!.selectAll() + const screen = await render(InGrid, inRoot(grid, RangeStatusBar)) + + const bar = screen.container.querySelector('[data-dg-range-status]') + expect(bar?.textContent).toContain(vi.rangeSum) + expect(bar?.textContent).toContain('ô') + expect(bar?.textContent).not.toContain('cells') + }) + + it('lets a single label be overridden without replacing the pack', async () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + locales: [viVN], + locale: 'vi-VN', + labels: { groupBy: 'Xếp theo' }, + features: [grouping(), rangeSelection(), advancedFilter()] + }) + const screen = await render(InGrid, inRoot(grid, GroupPanel)) + + const text = screen.container.querySelector('[data-dg-group-panel]')?.textContent ?? '' + expect(text).toContain('Xếp theo') + expect(text).toContain(vi.groupPanelEmpty) + }) + + it('takes a pack that translates only part of the surface', async () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + locales: [{ tag: 'de-DE', labels: { groupBy: 'Gruppieren nach' }, announcer: {} }], + locale: 'de-DE', + features: [grouping(), rangeSelection(), advancedFilter()] + }) + const screen = await render(InGrid, inRoot(grid, GroupPanel)) + + const text = screen.container.querySelector('[data-dg-group-panel]')?.textContent ?? '' + expect(text).toContain('Gruppieren nach') + expect(text).toContain(defaultLabels.groupPanelEmpty) + }) + + it('speaks German now that a German pack ships', async () => { + const screen = await render(InGrid, inRoot(makeGrid([], 'de-DE'), GroupPanel)) + + const text = screen.container.querySelector('[data-dg-group-panel]')?.textContent ?? '' + expect(text).toContain(de.groupBy) + expect(text).toContain(de.groupPanelEmpty) + }) + + it('falls back to English when nothing is configured', async () => { + const screen = await render(InGrid, inRoot(makeGrid(), GroupPanel)) + + const text = screen.container.querySelector('[data-dg-group-panel]')?.textContent ?? '' + expect(text).toContain(defaultLabels.groupBy) + }) +}) + +describe('reading the wording from inside an effect', () => { + it('settles instead of re-running, which is how a language picker binds', async () => { + // `grid.labels` is derived from the pack and the overrides. An effect + // that reads it must settle after one pass, or a picker bound to it + // would loop the moment it drew the words it had just chosen. + const grid = makeGrid([], 'vi-VN') + let runs = 0 + let seen = '' + + const cleanup = $effect.root(() => { + $effect(() => { + runs++ + seen = grid.labels.groupBy + }) + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(runs).toBe(1) + expect(seen).toBe(vi.groupBy) + cleanup() + }) +}) + +describe('a11y', () => { + it('is axe-clean in Vietnamese, chips and all', async () => { + const screen = await render(InGrid, inRoot(makeGrid(['dept']), GroupPanel)) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) + + it('is axe-clean with the range status bar showing', async () => { + const grid = makeGrid() + getRangeSelection(grid)!.selectAll() + const screen = await render(InGrid, inRoot(grid, RangeStatusBar)) + + const results = await axe.run(screen.container, { + rules: { region: { enabled: false }, 'page-has-heading-one': { enabled: false } } + }) + expect(results.violations.map((violation) => violation.id)).toEqual([]) + }) +}) + +afterEach(() => { + resetDataGridConfig() +}) + +describe('the chrome takes classes from config and ui', () => { + it('appends the configured slot classes', async () => { + defineDataGridConfig({ slots: { groupPanel: 'border-solid' } }) + const screen = await render(InGrid, inRoot(makeGrid(), GroupPanel)) + + const panel = screen.container.querySelector('[data-dg-group-panel]') + expect(panel?.className).toContain('border-solid') + }) + + it('lets ui beat a default rather than sit next to it', async () => { + // `ui` belongs to the grid, not to the part: it is set once on the + // root and every part inside reads it. + const screen = await render(InGrid, { + props: { grid: makeGrid(), component: GroupPanel, ui: { groupPanel: 'px-8' } } + }) + + const panel = screen.container.querySelector('[data-dg-group-panel]') + expect(panel?.className).toContain('px-8') + + expect(panel?.className).not.toContain('px-3') + }) +}) diff --git a/src/tests/icons.svelte.test.ts b/src/tests/icons.svelte.test.ts index e7f4402..04df90a 100644 --- a/src/tests/icons.svelte.test.ts +++ b/src/tests/icons.svelte.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { render } from 'vitest-browser-svelte' import { userEvent } from 'vitest/browser' -import AllIcons from './AllIcons.svelte' -import EveryIconGrid from './EveryIconGrid.svelte' +import AllIcons from './fixtures/AllIcons.svelte' +import EveryIconGrid from './fixtures/EveryIconGrid.svelte' import { datagridIcons } from '../lib/components/internal/icons.data.js' const ICON_API = /iconify|simplesvg|unisvg/i diff --git a/src/tests/import-wizard.svelte.test.ts b/src/tests/import-wizard.svelte.test.ts index 7959c30..c875923 100644 --- a/src/tests/import-wizard.svelte.test.ts +++ b/src/tests/import-wizard.svelte.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import ImportWizard from '../lib/components/panels/ImportWizard.svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { dataImport, getDataImport } from '../lib/features/data-import/data-import.svelte.js' import { viVN } from '../lib/locales/vi-VN.js' diff --git a/src/tests/keyboard.svelte.test.ts b/src/tests/keyboard.svelte.test.ts index 563062c..2a42589 100644 --- a/src/tests/keyboard.svelte.test.ts +++ b/src/tests/keyboard.svelte.test.ts @@ -14,7 +14,7 @@ import { type ColumnDef, type DataGridProps } from '$lib/index.js' -import VirtualGrid from './VirtualGrid.svelte' +import VirtualGrid from './fixtures/VirtualGrid.svelte' interface Person { id: number @@ -226,7 +226,7 @@ describe('overlays', () => { }) // A flat count left most of a tall grid blank, which reads as broken - // rather than busy — the very thing the loading state answers. + // rather than busy - the very thing the loading state answers. const body = screen.container.querySelector('[role="rowgroup"][aria-busy="true"]')! expect(body.querySelectorAll('[role="row"]')).toHaveLength(25) }) @@ -322,7 +322,7 @@ describe('keyboard navigation past pinned columns', () => { viewport.querySelector('[data-dg-cell="0:0"]')!.focus() // The browser's own scroll-into-view stops at the viewport edge, which - // is where the pinned columns sit — a cell reached by keyboard used to + // is where the pinned columns sit - a cell reached by keyboard used to // park underneath one. for (let step = 0; step < 5; step++) { await userEvent.keyboard('{ArrowRight}') diff --git a/src/tests/pagination-footer.svelte.test.ts b/src/tests/pagination-footer.svelte.test.ts index 9d0b8ac..2bcf983 100644 --- a/src/tests/pagination-footer.svelte.test.ts +++ b/src/tests/pagination-footer.svelte.test.ts @@ -75,7 +75,7 @@ describe('footer layout', () => { it('states the row range once, not the page number twice', async () => { render(DataGrid as never, { grid: grid(12) } as never) - await expect.element(page.getByText('1–12 of 60')).toBeVisible() + await expect.element(page.getByText('1-12 of 60')).toBeVisible() // The range lives in the pagination footer; the status bar must not // repeat it as "page 1 of 5". expect(document.body.textContent).not.toMatch(/page \d+ of \d+/) diff --git a/src/tests/polish.svelte.test.ts b/src/tests/polish.svelte.test.ts index 886bbdd..86125c6 100644 --- a/src/tests/polish.svelte.test.ts +++ b/src/tests/polish.svelte.test.ts @@ -88,7 +88,7 @@ describe('pinned row keyboard access', () => { const screen = await renderGrid(grid) // DOM focus only follows the model once focus is already inside the - // grid — the grid must never steal it from elsewhere on the page. + // grid - the grid must never steal it from elsewhere on the page. screen.container.querySelector('[data-dg-cell="0:0"]')!.focus() grid.focus.focusCell({ row: grid.totalRows - 1, col: 0 }) grid.focus.moveBy(1, 0) @@ -242,7 +242,7 @@ describe('server-side pagination', () => { it('reports the server total in the footer', async () => { const grid = serverGrid() await renderGrid(grid) - await expect.element(page.getByText('1–5 of 137')).toBeVisible() + await expect.element(page.getByText('1-5 of 137')).toBeVisible() }) it('stays on the page whose cell was clicked', async () => { diff --git a/src/tests/public-api.test.ts b/src/tests/public-api.test.ts index f31c7cb..699378c 100644 --- a/src/tests/public-api.test.ts +++ b/src/tests/public-api.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import * as api from '$lib/index.js' /** - * The runtime surface, spelled out. Types are not in it — they cost nothing and - * break nobody at runtime — so this list is what an app can actually call. + * The runtime surface, spelled out. Types are not in it - they cost nothing and + * break nobody at runtime - so this list is what an app can actually call. * * A symbol earns a place by being something an app does with the grid. If a * change adds a name here, the question to answer is what documented task diff --git a/src/tests/public-usage.svelte.test.ts b/src/tests/public-usage.svelte.test.ts new file mode 100644 index 0000000..e583d35 --- /dev/null +++ b/src/tests/public-usage.svelte.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { userEvent } from 'vitest/browser' +import { + createDataGrid, + getRangeSelection, + rangeSelection, + sorting, + type ColumnDef, + type GridState +} from '$lib/index.js' +import type { Component } from 'svelte' +import DataGridComponent from '../lib/components/grid/DataGrid.svelte' + +interface Sale { + id: number + region: string + amount: number +} + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region', width: 160 }, + { id: 'amount', header: 'Amount', width: 120 } +] + +const sales: Sale[] = [ + { id: 1, region: 'North', amount: 100 }, + { id: 2, region: 'South', amount: 400 } +] + +const DataGrid = DataGridComponent as unknown as Component<{ grid: GridState }> + +function makeGrid(withRange: boolean): GridState { + return createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: withRange ? [sorting(), rangeSelection()] : [sorting()] + }) +} + +/** + * What registering a feature is worth, driven through the DOM. + * + * The layer that reads these gestures used to be a component an application + * wrapped the grid in, and this test used to be about remembering to. It is + * about the feature list now: register `rangeSelection()` and dragging selects + * cells, leave it out and the same drag does nothing. + */ +describe('a grid assembled from the public surface', () => { + async function drag(container: Element): Promise { + const from = container.querySelector('[data-dg-cell="0:0"]')! + const to = container.querySelector('[data-dg-cell="1:1"]')! + await userEvent.dragAndDrop(from, to) + } + + it('selects a range only when the feature is registered', async () => { + const bare = makeGrid(false) + const plain = await render(DataGrid, { grid: bare }) + await drag(plain.container) + expect(getRangeSelection(bare)).toBeUndefined() + + const ranged = makeGrid(true) + const layered = await render(DataGrid, { grid: ranged }) + await drag(layered.container) + expect(getRangeSelection(ranged)!.ranges).toHaveLength(1) + }) +}) diff --git a/src/tests/public-usage.test.ts b/src/tests/public-usage.test.ts new file mode 100644 index 0000000..a4a09ae --- /dev/null +++ b/src/tests/public-usage.test.ts @@ -0,0 +1,267 @@ +import { createDataGrid, type ColumnDef } from '$lib/index.js' +import { describe, expect, it } from 'vitest' +import { + aggregate, + commandPalette, + conditionalFormatting, + formula, + FormulaError, + FUNCTION_NAMES, + getCommandPalette, + isDataRow, + isDetailNode, + isFormulaError, + isLoadingRow, + localStorageViews, + masterDetail, + savedViews, + ShareTooLongError, + totalsKindOf, + type Aggregation, + type FormulaValue, + type SavedViewStorage +} from '$lib/index.js' +import { + BUILT_IN_STYLES, + buildGridXlsx, + cellRef, + columnLetter, + createWorkbook, + createWorkbookAsync, + DEFAULT_FORMAT_COLORS, + StyleTable, + toSerialDate, + XLSX_MIME, + type CellValue, + type SheetColumn, + type WorkbookOptions, + type XlsxStyle +} from '$lib/xlsx.js' + +interface Sale { + id: number + region: string + amount: number +} + +const sales: Sale[] = [ + { id: 1, region: 'North', amount: 100 }, + { id: 2, region: 'South', amount: 400 } +] + +const columns: ColumnDef[] = [ + { id: 'region', header: 'Region' }, + { id: 'amount', header: 'Amount' } +] + +const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes) + +describe('what the published surface is enough to do', () => { + it('builds a workbook of several sheets by hand', () => { + const styles = new StyleTable() + const header = styles.add(BUILT_IN_STYLES.header) + const money: XlsxStyle = { ...BUILT_IN_STYLES.currency, font: { bold: true } } + + const sheet = (name: string, rows: CellValue[][]): WorkbookOptions => ({ + sheetName: name, + columns: [ + { header: 'Region', width: 20 }, + { header: 'Amount', width: 14, style: styles.add(money) } + ] satisfies SheetColumn[], + rows, + headerStyle: header, + styles + }) + + const bytes = createWorkbook([ + sheet('Sales', [['North', 100]]), + sheet('Returns', [['South', -20]]) + ]) + + expect(text(bytes)).toContain('sheet2.xml') + expect(XLSX_MIME).toContain('spreadsheetml') + }) + + it('compresses that workbook the way a grid export does', async () => { + const styles = new StyleTable() + const options: WorkbookOptions = { + sheetName: 'Sales', + columns: [{ header: 'Region' }], + rows: Array.from({ length: 500 }, () => ['North']), + styles + } + + const stored = createWorkbook(options) + const deflated = await createWorkbookAsync(options) + + expect(deflated.byteLength).toBeLessThan(stored.byteLength) + }) + + it('styles a grid export through the callbacks the options declare', () => { + const grid = createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: [ + conditionalFormatting({ rules: [{ kind: 'dataBar', column: 'amount' }] }) + ] + }) + void grid.preWindowNodes + + const bytes = buildGridXlsx(grid, { + headerStyle: { font: { bold: true, color: '#FFFFFF' }, fill: '#1F2937' }, + columnStyle: (column) => + column.id === 'amount' ? BUILT_IN_STYLES.currency : undefined, + cellStyle: (value) => (Number(value) < 0 ? { font: { color: '#B00020' } } : undefined), + conditionalFormatting: { ...DEFAULT_FORMAT_COLORS, bar: '#112233' } + }) + + expect(text(bytes)).toContain('') + }) + + it('addresses a cell and a date the way the writer does', () => { + expect(cellRef(0, 3)).toBe('D1') + expect(columnLetter(26)).toBe('AA') + expect(toSerialDate(new Date(2026, 0, 1))).toBeGreaterThan(45_000) + }) + + it('tells the row kinds apart without reaching inside the package', () => { + const grid = createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id) + }) + + const node = grid.preWindowNodes[0]! + expect(isDataRow(node)).toBe(true) + expect(isLoadingRow(node.row)).toBe(false) + expect(totalsKindOf(node.id)).toBeNull() + }) +}) + +describe('the jobs the docs describe, done from outside the package', () => { + const grid = () => + createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id) + }) + + it('takes a label override per grid, and leaves the next grid alone', () => { + // It used to be one global table with a reset. A grid carries its own + // wording now, so two grids on a page can disagree and neither has to + // put anything back. + const renamed = createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + labels: { groupBy: 'Split by' } + }) + + expect(renamed.labels.groupBy).toBe('Split by') + expect(grid().labels.groupBy).toBe('Group by') + }) + + it('wraps the storage adapter the feature uses by default', () => { + const written: string[] = [] + const auditing: SavedViewStorage = { + read: (key) => localStorageViews.read(key), + write: (key, views) => { + written.push(key) + localStorageViews.write(key, views) + } + } + + const built = createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: [savedViews({ storage: auditing, key: 'public-usage' })] + }) + built.api.saveView?.('Mine') + + expect(written).toEqual(['public-usage']) + }) + + it('reuses a built-in aggregation inside a custom one', () => { + const half: Aggregation = (values, rows) => + Number(aggregate('sum', values, rows)) / 2 + + expect(aggregate('sum', [100, 400], sales)).toBe(500) + expect(half([100, 400], sales)).toBe(250) + }) + + it('lists the functions a formula editor can offer, as a user writes them', () => { + expect(FUNCTION_NAMES).toContain('IF') + expect(FUNCTION_NAMES).toContain('SUM') + expect(FUNCTION_NAMES.every((name) => name === name.toUpperCase())).toBe(true) + expect(FUNCTION_NAMES.length).toBeGreaterThan(10) + }) + + it('reads an error out of a formula column rather than guessing at it', () => { + const built = createDataGrid({ + columns: [...columns, { id: 'ratio', header: 'Ratio' }], + data: sales, + getRowId: (row) => String(row.id), + features: [formula({ columns: { ratio: 'amount / 0' } })] + }) + + const value = (built.preWindowNodes[0]!.row as unknown as Record).ratio + expect(isFormulaError(value)).toBe(false) + expect(value).toBe('#DIV/0') + + const raw: FormulaValue = new FormulaError('#DIV/0', 'divided by zero') + expect(isFormulaError(raw)).toBe(true) + }) + + it('tells a detail row apart from the row it hangs under', () => { + const built = createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: [masterDetail()] + }) + built.expansion.expand('1') + + const detail = built.nodes.find((node) => isDetailNode(node.id)) + expect(detail).toBeDefined() + expect(isDataRow(detail!)).toBe(false) + }) + + it('catches the error a share link throws when the state is too big', () => { + const thrown = new ShareTooLongError(9001) + + try { + throw thrown + } catch (error) { + expect(error instanceof ShareTooLongError).toBe(true) + expect((error as ShareTooLongError).length).toBe(9001) + } + }) + + it('adds a command of its own to the palette', () => { + const built = createDataGrid({ + columns, + data: sales, + getRowId: (row) => String(row.id), + features: [ + commandPalette({ + commands: () => [ + { id: 'app:refresh', label: 'Refresh from the server', run: () => {} } + ] + }) + ] + }) + + const palette = getCommandPalette(built)! + expect(palette.commands.map((command) => command.id)).toContain('app:refresh') + }) + + it('keeps the row helpers usable on a plain grid', () => { + const node = grid().preWindowNodes[0]! + + expect(isDataRow(node)).toBe(true) + expect(isLoadingRow(node.row)).toBe(false) + expect(totalsKindOf(node.id)).toBeNull() + }) +}) diff --git a/src/tests/qa-demos.svelte.test.ts b/src/tests/qa-demos.svelte.test.ts index 967d974..5054381 100644 --- a/src/tests/qa-demos.svelte.test.ts +++ b/src/tests/qa-demos.svelte.test.ts @@ -66,10 +66,10 @@ describe('i18n demo', () => { // The page compares its own object against `defaultLabels`, so a label // added to the library without a translation shows up here. await expect - .element(page.getByText(/vi-VN phủ đủ \d+ khoá — \d+ ngôn ngữ đóng sẵn\./)) + .element(page.getByText(/vi-VN phủ đủ \d+ khoá - \d+ ngôn ngữ đóng sẵn\./)) .toBeVisible() await expect.element(page.getByPlaceholder('Tìm kiếm...')).toBeVisible() - await expect.element(page.getByText('1–8 trên 60')).toBeVisible() + await expect.element(page.getByText('1-8 trên 60')).toBeVisible() await expect .element(page.getByRole('checkbox', { name: 'Chọn tất cả các dòng' })) .toBeVisible() @@ -88,7 +88,7 @@ describe('i18n demo', () => { await expect.element(page.getByPlaceholder('Search...')).toBeVisible() // The footer belongs to the label set too, not to the Pagination // component's own wording. - await expect.element(page.getByText('1–8 of 60')).toBeVisible() + await expect.element(page.getByText('1-8 of 60')).toBeVisible() }) it('reaches a language beyond the pair the demo started with', async () => { @@ -99,7 +99,7 @@ describe('i18n demo', () => { await page.getByRole('option', { name: '日本語' }).click() await expect.element(page.getByPlaceholder('検索...')).toBeVisible() - await expect.element(page.getByText('60件中 1–8件')).toBeVisible() + await expect.element(page.getByText('60件中 1-8件')).toBeVisible() // The date column names no locale, so it follows the grid: Japanese // writes the year first where Vietnamese writes the day. await expect diff --git a/src/tests/range-selection.svelte.test.ts b/src/tests/range-selection.svelte.test.ts index 5c5ef2d..9993429 100644 --- a/src/tests/range-selection.svelte.test.ts +++ b/src/tests/range-selection.svelte.test.ts @@ -9,7 +9,7 @@ import { } from '$lib/index.js' import axe from 'axe-core' import type { Component } from 'svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page, userEvent } from 'vitest/browser' @@ -275,7 +275,7 @@ describe('Ctrl+Enter over a range', () => { expect(grid.data[2]).toMatchObject({ q1: 50, q2: 60 }) }) - it('leaves Community to commit the one cell when nothing else is selected', async () => { + it('leaves the grid to commit the one cell when nothing else is selected', async () => { const grid = makeGrid() const screen = await renderGrid(grid) diff --git a/src/tests/row-reorder.svelte.test.ts b/src/tests/row-reorder.svelte.test.ts index 834c607..b9b2030 100644 --- a/src/tests/row-reorder.svelte.test.ts +++ b/src/tests/row-reorder.svelte.test.ts @@ -311,7 +311,7 @@ describe('landing and touch', () => { // The grid drives its geometry from custom properties on ancestors of // the row. Moved to `` the copy loses them, and the pinned grip - // and checkbox fall back to `auto` — their background then paints over + // and checkbox fall back to `auto` - their background then paints over // the copy's own edge, which shows up as a broken border. const offsets = (element: Element) => [...element.children].slice(0, 2).map((cell) => getComputedStyle(cell).insetInlineStart) @@ -321,7 +321,7 @@ describe('landing and touch', () => { // Those same pinned cells carry a square opaque background. Unclipped, // it spills past the rounded corners and eats the ring's arc on the - // left — the right corners stay clean, which is what gives it away. + // left - the right corners stay clean, which is what gives it away. expect(getComputedStyle(ghosts()[0]).overflow).toBe('hidden') pointer(handle, 'pointercancel', 120) diff --git a/src/tests/row-span.svelte.test.ts b/src/tests/row-span.svelte.test.ts index bb2db74..f1eebde 100644 --- a/src/tests/row-span.svelte.test.ts +++ b/src/tests/row-span.svelte.test.ts @@ -132,7 +132,7 @@ describe('rowSpan', () => { const screen = await render(TypedDataGrid, { grid: makeGrid({ pinned: true }) }) await expect.element(screen.getByRole('grid')).toBeVisible() - // The overhang covers every separator it crosses — including the one at + // The overhang covers every separator it crosses - including the one at // its own foot, which belongs to the next run and has to stay. It used // to swallow that line, leaving the runs welded together. const fill = cell(0, 1)!.firstElementChild as HTMLElement @@ -223,7 +223,7 @@ describe('rowSpan', () => { // The pinned column is sticky at the edge; a spanning cell scrolling // under it must not paint over it. Getting this wrong needs a cycle - // broken — separator over pinned, span over separator, pinned over span. + // broken - separator over pinned, span over separator, pinned over span. const pinned = cell(0, 1)!.getBoundingClientRect() await expect .poll(() => ownerAt(pinned.x + pinned.width / 2, pinned.top + ROW_HEIGHT / 2)) @@ -298,7 +298,7 @@ describe('rowSpan', () => { cell(0, 1)!.focus() expect(document.activeElement).toBe(cell(0, 1)) - // Down through the covered rows keeps the same cell focused — the + // Down through the covered rows keeps the same cell focused - the // covered ones are not in the DOM to receive it. await userEvent.keyboard('{ArrowDown}{ArrowDown}') expect(document.activeElement).toBe(cell(0, 1)) diff --git a/src/tests/server-contract-ops.test.ts b/src/tests/server-contract-ops.test.ts index 3695c21..858bebe 100644 --- a/src/tests/server-contract-ops.test.ts +++ b/src/tests/server-contract-ops.test.ts @@ -125,6 +125,11 @@ function dateHolds(value: unknown, condition: Extract) { return screen } -describe('server row model — what the page renders', () => { +describe('server row model - what the page renders', () => { it('tells a screen reader where in the whole set the page sits', async () => { const grid = serverGrid() const screen = await renderGrid(grid) @@ -141,7 +141,7 @@ describe('server row model — what the page renders', () => { await renderGrid(grid) getPagination(grid)!.setPage(14) grid.data = pageOf(14) - await expect.element(page.getByText('131–137 of 137')).toBeVisible() + await expect.element(page.getByText('131-137 of 137')).toBeVisible() }) it('passes axe on a page past the first', async () => { diff --git a/src/tests/server-model.test.ts b/src/tests/server-model.test.ts index 9971c45..d203cb9 100644 --- a/src/tests/server-model.test.ts +++ b/src/tests/server-model.test.ts @@ -69,7 +69,7 @@ function goToPage(grid: GridState, page: number): void { grid.data = pageOf(page) } -describe('server row model — what each feature does with one page', () => { +describe('server row model - what each feature does with one page', () => { it('selection: count spans pages but the rows do not', () => { const grid = serverGrid() const state = getSelection(grid)! diff --git a/src/tests/server-row-model.svelte.test.ts b/src/tests/server-row-model.svelte.test.ts index ba73c51..6be3b3f 100644 --- a/src/tests/server-row-model.svelte.test.ts +++ b/src/tests/server-row-model.svelte.test.ts @@ -15,7 +15,7 @@ import type { Component } from 'svelte' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { isLoadingRow } from '../lib/core/grid/index.js' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { getServerRowModel, serverRowModel diff --git a/src/tests/theming.svelte.test.ts b/src/tests/theming.svelte.test.ts index 57ef481..c7c6e5a 100644 --- a/src/tests/theming.svelte.test.ts +++ b/src/tests/theming.svelte.test.ts @@ -47,8 +47,8 @@ function cellOf(text: string): HTMLElement { afterEach(() => resetDataGridConfig()) -describe('theming — per-instance ui', () => { - it('adds slot classes without dropping the variant’s own', async () => { +describe('theming - per-instance ui', () => { + it("adds slot classes without dropping the variant's own", async () => { renderGrid({ ui: { cell: 'font-mono', headerCell: 'uppercase' } }) await expect.element(page.getByRole('grid')).toBeVisible() @@ -101,7 +101,7 @@ describe('theming — per-instance ui', () => { }) }) -describe('theming — global config', () => { +describe('theming - global config', () => { it('applies config slots to every grid', async () => { defineDataGridConfig({ slots: { cell: 'tracking-wide' } }) renderGrid() @@ -157,7 +157,7 @@ describe('theming — global config', () => { }) }) -describe('theming — data-driven callbacks', () => { +describe('theming - data-driven callbacks', () => { it('applies rowClass per row', async () => { renderGrid({ rowClass: (node) => node.row.amount < 0 && 'row-negative' }) await expect.element(page.getByRole('grid')).toBeVisible() diff --git a/src/tests/tool-panel.svelte.test.ts b/src/tests/tool-panel.svelte.test.ts index 154cedc..b21b0a4 100644 --- a/src/tests/tool-panel.svelte.test.ts +++ b/src/tests/tool-panel.svelte.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import ToolPanel from '../lib/components/panels/ToolPanel.svelte' -import { InGrid } from './in-root.js' +import { InGrid } from './fixtures/in-root.js' import { getGrouping, grouping } from '../lib/features/grouping/grouping.svelte.js' import { getRangeSelection, rangeSelection } from '../lib/features/range-selection/index.js' import { serverRowModel } from '../lib/features/server-row-model/index.js' diff --git a/src/tests/typography.test.ts b/src/tests/typography.test.ts new file mode 100644 index 0000000..64105e8 --- /dev/null +++ b/src/tests/typography.test.ts @@ -0,0 +1,87 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +const BANNED: Record = { + '—': 'em dash, write - or restructure the sentence', + '–': 'en dash, write -', + '…': 'ellipsis, write ...', + '·': 'middle dot, write |', + '×': 'multiplication sign, write x', + '‘': "curly quote, write '", + '’': "curly quote, write '", + '“': 'curly quote, write "', + '”': 'curly quote, write "' +} + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry.startsWith('.')) continue + const full = path.join(dir, entry) + if (statSync(full).isDirectory()) walk(full, out) + else if (/\.(ts|js|svelte|css|md)$/.test(entry)) out.push(full) + } + return out +} + +const files = [ + ...walk(path.join(ROOT, 'src')), + path.join(ROOT, 'README.md'), + path.join(ROOT, 'CHANGELOG.md') +] + +/** + * Files whose strings are a language rather than prose, and the two marks a + * language would be wrong without: French marks elision with a curly + * apostrophe, and `N'est pas vide` is both bad French and a broken string + * literal. Russian punctuates with an em dash where English would use a + * colon. + * + * Nothing else is exempt, comments included, and not the en dash either: a + * page range reads `1-25` in every language that ships here. + */ +const LANGUAGE_DATA = ['src/lib/locales/', 'src/lib/core/interaction/labels.ts'] + +const LANGUAGE_MARKS = new Set(['\u2019', '\u2014']) + +function isLanguageData(relative: string): boolean { + return LANGUAGE_DATA.some((prefix) => relative.replaceAll('\\\\', '/').startsWith(prefix)) +} + +const isComment = (line: string): boolean => /^\s*(\*|\/\/|\/\*)/.test(line) + +describe('the text a keyboard can type', () => { + it('finds the files to check, so a broken walk cannot pass silently', () => { + expect(files.length).toBeGreaterThan(50) + }) + + it('has no character a person cannot type', () => { + const found: string[] = [] + + for (const file of files) { + const relative = path.relative(ROOT, file) + if (relative.endsWith('typography.test.ts')) continue + + const data = isLanguageData(relative) + const lines = readFileSync(file, 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + for (const [character, advice] of Object.entries(BANNED)) { + if (!line.includes(character)) continue + if (data && !isComment(line) && LANGUAGE_MARKS.has(character)) continue + found.push(`${relative}:${index + 1} ${character} (${advice})`) + } + } + } + + expect(found).toEqual([]) + }) + + it('leaves a language the punctuation it writes with', () => { + const chinese = readFileSync(path.join(ROOT, 'src/lib/locales/zh-CN.ts'), 'utf8') + + expect(chinese, 'a full-width colon is how Chinese writes one').toContain(':') + }) +}) diff --git a/src/tests/virtualization.svelte.test.ts b/src/tests/virtualization.svelte.test.ts index fdbd686..3c4acff 100644 --- a/src/tests/virtualization.svelte.test.ts +++ b/src/tests/virtualization.svelte.test.ts @@ -12,7 +12,7 @@ import { type DataGridProps, type GridState } from '$lib/index.js' -import VirtualGrid from './VirtualGrid.svelte' +import VirtualGrid from './fixtures/VirtualGrid.svelte' interface Row { id: number From 00aef27c52054c0232b4a04ad7ca7d08bdee1ab9 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 23:27:50 +0700 Subject: [PATCH 12/46] refactor(filtering): compare a value in one place instead of three The filter builder carried its own comparison engine and the worker carried a third, all answering the same question and kept in step by a parity test. There is one now: a condition is mapped to the shape a column filter already speaks, and `valuePredicateFor` answers it. `evaluate.ts` loses 119 lines and the worker's `predicates.ts` all 117. Two things had to be settled first, and neither was the mechanical swap the plan described. The builder decided how to compare by looking at the cell. A column of numeric strings therefore compared as numbers in one row and as text in the next. The column filter is told the kind instead, and taking that straight would have read an untyped `{ id: 'total' }` of real numbers as text, which answers that 80 is greater than 100. So the kind is settled once per column: from the declaration when there is one, and otherwise from a sample of the rows the pass is filtering, read through the same gate so a masked column is guessed from what the user can see. The two engines also disagreed about a blank in a negative test, and the disagreement was a bug rather than a choice: `textPredicate` kept a blank on `notEqual` while `numberPredicate` dropped it on `neq`, from one guard applied to every comparator at once. A cell with no number in it is not the number being excluded. `neq` keeps it now, the server contract's reference implementation says so too, and the parity test that documented the difference documents the agreement. Three smaller answers change with them. `in` maps to a set filter whatever the column holds, because membership is not a comparison. A set matches by the key its value list was built with, so 2 and '2' are two entries rather than one. And an operator a column does not offer filters nothing rather than falling through to text: a half-built condition must not hide rows. --- .../advanced-filter/advanced-filter.svelte.ts | 27 ++- .../features/advanced-filter/evaluate.test.ts | 52 ++++-- src/lib/features/advanced-filter/evaluate.ts | 159 +++--------------- .../advanced-filter/filter-parity.test.ts | 12 +- src/lib/features/advanced-filter/kinds.ts | 34 ++++ src/lib/features/advanced-filter/operators.ts | 30 +++- .../advanced-filter/to-column-filter.ts | 120 +++++++++++++ .../filtering/filter-predicates.test.ts | 4 +- .../features/filtering/filter-predicates.ts | 4 + .../features/worker-row-model/predicates.ts | 117 ------------- src/lib/features/worker-row-model/query.ts | 31 +++- src/tests/server-contract-ops.test.ts | 4 +- 12 files changed, 305 insertions(+), 289 deletions(-) create mode 100644 src/lib/features/advanced-filter/kinds.ts create mode 100644 src/lib/features/advanced-filter/to-column-filter.ts delete mode 100644 src/lib/features/worker-row-model/predicates.ts diff --git a/src/lib/features/advanced-filter/advanced-filter.svelte.ts b/src/lib/features/advanced-filter/advanced-filter.svelte.ts index 29b2cf2..f9195c3 100644 --- a/src/lib/features/advanced-filter/advanced-filter.svelte.ts +++ b/src/lib/features/advanced-filter/advanced-filter.svelte.ts @@ -1,3 +1,5 @@ +import { kindsFor } from './kinds.js' +import type { FilterKind } from './operators.js' import { type CellRead, gateReader, @@ -81,19 +83,27 @@ export class AdvancedFilter { #matchesWith( node: RowNode, defs: Map>, - read: CellRead + read: CellRead, + kinds: Map ): boolean { if (this.#alwaysKeep?.(node) === true) return true if (!isDataRow(node)) return true - return matchesNode(this.model, (columnId) => { - const def = defs.get(columnId) - return def ? read(node, def) : undefined - }) + return matchesNode( + this.model, + (columnId) => { + const def = defs.get(columnId) + return def ? read(node, def) : undefined + }, + (columnId) => kinds.get(columnId) ?? 'text' + ) } - matches = (node: RowNode): boolean => - this.#matchesWith(node, this.#defsForModel(), gateReader(this.#grid, 'search')) + matches = (node: RowNode): boolean => { + const defs = this.#defsForModel() + const read = gateReader(this.#grid, 'search') + return this.#matchesWith(node, defs, read, kindsFor([node], defs, read)) + } #refuseOnServer(): boolean { if (this.#grid.rowModel !== 'server') return false @@ -110,7 +120,8 @@ export class AdvancedFilter { const defs = this.#defsForModel() const read = gateReader(this.#grid, 'search') - return nodes.filter((node) => this.#matchesWith(node, defs, read)) + const kinds = kindsFor(nodes, defs, read) + return nodes.filter((node) => this.#matchesWith(node, defs, read, kinds)) } } diff --git a/src/lib/features/advanced-filter/evaluate.test.ts b/src/lib/features/advanced-filter/evaluate.test.ts index 896969c..d3efa48 100644 --- a/src/lib/features/advanced-filter/evaluate.test.ts +++ b/src/lib/features/advanced-filter/evaluate.test.ts @@ -26,11 +26,15 @@ describe('one condition', () => { }) it('compares numbers as numbers, including numeric text', () => { - expect(matchesCondition(10, condition({ op: 'gt', value: 5 }))).toBe(true) - expect(matchesCondition(10, condition({ op: 'lte', value: 10 }))).toBe(true) - expect(matchesCondition('10', condition({ op: 'gte', value: '10' }))).toBe(true) - expect(matchesCondition(4, condition({ op: 'between', value: 1, to: 4 }))).toBe(true) - expect(matchesCondition(5, condition({ op: 'between', value: 1, to: 4 }))).toBe(false) + // The kind comes from the column, so a numeric string in a number + // column is a number. The feature works it out per column, from the + // declaration or from a sample of the data. + const n = 'number' as const + expect(matchesCondition(10, condition({ op: 'gt', value: 5 }), n)).toBe(true) + expect(matchesCondition(10, condition({ op: 'lte', value: 10 }), n)).toBe(true) + expect(matchesCondition('10', condition({ op: 'gte', value: '10' }), n)).toBe(true) + expect(matchesCondition(4, condition({ op: 'between', value: 1, to: 4 }), n)).toBe(true) + expect(matchesCondition(5, condition({ op: 'between', value: 1, to: 4 }), n)).toBe(false) }) it('compares dates by day, the way a column filter does', () => { @@ -53,13 +57,15 @@ describe('one condition', () => { expect( matchesCondition( new Date(2026, 2, 2, 10, 30), - condition({ op: 'equals', value: '2026-03-02' }) + condition({ op: 'equals', value: '2026-03-02' }), + 'date' ) ).toBe(true) expect( matchesCondition( new Date(2026, 2, 2, 10, 30), - condition({ op: 'after', value: '2026-03-02' }) + condition({ op: 'after', value: '2026-03-02' }), + 'date' ) ).toBe(false) }) @@ -68,7 +74,8 @@ describe('one condition', () => { expect( matchesCondition( new Date(2026, 2, 31, 9, 0), - condition({ op: 'between', value: '2026-01-01', to: '2026-03-31' }) + condition({ op: 'between', value: '2026-01-01', to: '2026-03-31' }), + 'date' ) ).toBe(true) }) @@ -80,18 +87,25 @@ describe('one condition', () => { expect(matchesCondition('x', condition({ op: 'notBlank' }))).toBe(true) }) - it('matches a list, by value or by its text', () => { - expect(matchesCondition('a', condition({ op: 'in', values: ['a', 'b'] }))).toBe(true) - expect(matchesCondition(2, condition({ op: 'in', values: ['2'] }))).toBe(true) - expect(matchesCondition('c', condition({ op: 'in', values: ['a', 'b'] }))).toBe(false) + it('matches a list by the key the list was built with', () => { + // The same key a set filter's value list uses, so an entry the user + // picked out of that list is the entry a cell holding it answers to. + // A number and its text are two entries, not one. + const s = 'set' as const + expect(matchesCondition('a', condition({ op: 'in', values: ['a', 'b'] }), s)).toBe(true) + expect(matchesCondition(2, condition({ op: 'in', values: [2] }), s)).toBe(true) + expect(matchesCondition(2, condition({ op: 'in', values: ['2'] }), s)).toBe(false) + expect(matchesCondition('c', condition({ op: 'in', values: ['a', 'b'] }), s)).toBe(false) }) it('waits rather than excluding, while a condition is still being filled in', () => { - expect(matchesCondition('a', condition({ op: 'in' }))).toBe(true) - expect(matchesCondition('a', condition({ op: 'in', values: [] }))).toBe(true) + expect(matchesCondition('a', condition({ op: 'in' }), 'set')).toBe(true) + expect(matchesCondition('a', condition({ op: 'in', values: [] }), 'set')).toBe(true) expect(matchesCondition('a', condition({ op: 'equals', value: '' }))).toBe(true) - expect(matchesCondition(5, condition({ op: 'between', value: 1 }))).toBe(true) - expect(matchesCondition(5, condition({ op: 'between', value: 1, to: 4 }))).toBe(false) + expect(matchesCondition(5, condition({ op: 'between', value: 1 }), 'number')).toBe(true) + expect(matchesCondition(5, condition({ op: 'between', value: 1, to: 4 }), 'number')).toBe( + false + ) }) it('treats booleans as booleans rather than as text', () => { @@ -100,8 +114,10 @@ describe('one condition', () => { expect(matchesCondition(false, condition({ op: 'notEqual', value: true }))).toBe(true) }) - it('falls back to text when the two sides are not comparable as numbers', () => { - expect(matchesCondition('abc', condition({ op: 'gt', value: 5 }))).toBe(false) + it('offers no comparison a text column cannot make, and waits rather than hiding', () => { + // `gt` is not on a text column's list. Reaching it anyway is a + // half-built condition, and a half-built condition filters nothing. + expect(matchesCondition('abc', condition({ op: 'gt', value: 5 }))).toBe(true) expect(matchesCondition('abc', condition({ op: 'equals', value: 'abc' }))).toBe(true) }) }) diff --git a/src/lib/features/advanced-filter/evaluate.ts b/src/lib/features/advanced-filter/evaluate.ts index dfa58e8..518e5a7 100644 --- a/src/lib/features/advanced-filter/evaluate.ts +++ b/src/lib/features/advanced-filter/evaluate.ts @@ -4,128 +4,18 @@ import type { FilterGroup, FilterNode } from './advanced-filter.types.js' -import { isBlank, numericOrNull } from '../../core/utils/index.js' +import { valuePredicateFor } from '../filtering/filter-predicates.js' +import { isBlank } from '../../core/utils/index.js' +import type { FilterKind } from './operators.js' +import { toColumnFilter } from './to-column-filter.js' const PRESENCE = new Set(['blank', 'notBlank']) -function asText(value: unknown, caseSensitive: boolean): string { - const text = value instanceof Date ? value.toISOString() : String(value ?? '') - return caseSensitive ? text : text.toLowerCase() -} - -const MS_PER_DAY = 86_400_000 -const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/ - -function localDay(date: Date): number { - return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / MS_PER_DAY -} - -function asDay(value: unknown): number | null { - if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : localDay(value) - if (typeof value === 'number') return localDay(new Date(value)) - if (typeof value !== 'string') return null - - const text = value.trim() - if (DATE_ONLY.test(text)) return Date.parse(text) / MS_PER_DAY - - const parsed = new Date(text) - return Number.isNaN(parsed.getTime()) ? null : localDay(parsed) -} - -function compareText(cell: unknown, condition: FilterCondition): boolean { - const sensitive = condition.caseSensitive ?? false - const left = asText(cell, sensitive) - const right = asText(condition.value, sensitive) - - switch (condition.op) { - case 'contains': - return left.includes(right) - case 'notContains': - return !left.includes(right) - case 'startsWith': - return left.startsWith(right) - case 'endsWith': - return left.endsWith(right) - case 'equals': - return left === right - case 'notEqual': - return left !== right - default: - return false - } -} - -const NUMERIC_TESTS: Partial boolean>> = { - equals: (left, right) => left === right, - notEqual: (left, right) => left !== right, - gt: (left, right) => left > right, - gte: (left, right) => left >= right, - lt: (left, right) => left < right, - lte: (left, right) => left <= right -} - -function compareNumbers(cell: unknown, condition: FilterCondition): boolean | null { - const left = numericOrNull(cell) - const right = numericOrNull(condition.value) - if (left === null || right === null) return null - - if (condition.op === 'between') { - const upper = numericOrNull(condition.to) - return upper === null ? null : left >= right && left <= upper - } - - const test = NUMERIC_TESTS[condition.op] - return test ? test(left, right) : null -} - -function compareDays(cell: unknown, condition: FilterCondition): boolean | null { - const left = asDay(cell) - const right = asDay(condition.value) - if (left === null || right === null) return null - - switch (condition.op) { - case 'before': - return left < right - case 'after': - return left > right - case 'equals': - return left === right - case 'notEqual': - return left !== right - case 'between': { - const upper = asDay(condition.to) - return upper === null ? null : left >= right && left <= upper - } - default: - return null - } -} - -function matchesIn(cell: unknown, condition: FilterCondition): boolean { - const sensitive = condition.caseSensitive ?? false - const values = condition.values ?? [] - - return values.some((value) => - value instanceof Date || cell instanceof Date - ? asDay(value) === asDay(cell) - : value === cell || asText(value, sensitive) === asText(cell, sensitive) - ) -} - function matchesPresence(cell: unknown, op: AdvancedFilterOp): boolean { const blank = isBlank(cell) return op === 'blank' ? blank : !blank } -function matchesBoolean(cell: unknown, condition: FilterCondition): boolean | null { - const equality = condition.op === 'equals' || condition.op === 'notEqual' - if (!equality) return null - if (typeof cell !== 'boolean' && typeof condition.value !== 'boolean') return null - - const same = cell === condition.value || asText(cell, false) === asText(condition.value, false) - return condition.op === 'notEqual' ? !same : same -} - export function isComplete(condition: FilterCondition): boolean { if (PRESENCE.has(condition.op)) return true if (condition.op === 'in') return (condition.values?.length ?? 0) > 0 @@ -133,26 +23,33 @@ export function isComplete(condition: FilterCondition): boolean { return condition.op === 'between' ? !isBlank(condition.to) : true } -export function matchesCondition(cell: unknown, condition: FilterCondition): boolean { +/** + * One condition against one cell, read as the column's kind says to read it. + * + * The kind comes from the column rather than from the value. It used to be + * guessed per cell, which made a column of numeric strings compare as numbers + * in one row and as text in the next depending on what the cell held. + */ +export function matchesCondition( + cell: unknown, + condition: FilterCondition, + kind: FilterKind = 'text' +): boolean { if (!isComplete(condition)) return true - if (PRESENCE.has(condition.op)) return matchesPresence(cell, condition.op) - if (condition.op === 'in') return matchesIn(cell, condition) - const asBoolean = matchesBoolean(cell, condition) - if (asBoolean !== null) return asBoolean - - const asNumbers = compareNumbers(cell, condition) - if (asNumbers !== null) return asNumbers - - const asDates = compareDays(cell, condition) - if (asDates !== null) return asDates - - return compareText(cell, condition) + const filter = toColumnFilter(condition, kind) + return filter ? valuePredicateFor(filter)(cell) : true } -export function matchesNode(node: FilterNode, read: (columnId: string) => unknown): boolean { - if (node.kind === 'condition') return matchesCondition(read(node.columnId), node) +export function matchesNode( + node: FilterNode, + read: (columnId: string) => unknown, + kindOf: (columnId: string) => FilterKind = () => 'text' +): boolean { + if (node.kind === 'condition') { + return matchesCondition(read(node.columnId), node, kindOf(node.columnId)) + } const children = node.children if (children.length === 0) return true @@ -160,8 +57,8 @@ export function matchesNode(node: FilterNode, read: (columnId: string) => unknow const join = node.join ?? 'and' const matched = join === 'and' - ? children.every((child) => matchesNode(child, read)) - : children.some((child) => matchesNode(child, read)) + ? children.every((child) => matchesNode(child, read, kindOf)) + : children.some((child) => matchesNode(child, read, kindOf)) return node.not === true ? !matched : matched } diff --git a/src/lib/features/advanced-filter/filter-parity.test.ts b/src/lib/features/advanced-filter/filter-parity.test.ts index 0606fff..766a5e3 100644 --- a/src/lib/features/advanced-filter/filter-parity.test.ts +++ b/src/lib/features/advanced-filter/filter-parity.test.ts @@ -189,13 +189,17 @@ describe('the builder answers a condition the way a column filter does', () => { }) }) -describe('the one answer that differs, and why it is not copied', () => { - it('keeps a blank cell out of a negative test, where a column filter drops it', () => { +describe('a blank cell in a negative test', () => { + it('passes on both sides, which it did not before they were one', () => { + // A column filter used to drop a blank from `neq` on a number while + // keeping it on text. There is one comparison now, so a cell with no + // number in it is not the number being excluded, the same way a cell + // with no word in it is not the word. const plain = throughColumnFilter('num', { kind: 'number', op: 'neq', value: 5 }) const built = throughBuilder({ columnId: 'num', op: 'notEqual', value: 5 }) - expect(plain).toEqual([2, 3, 5]) - expect(built).toEqual([2, 3, 4, 5]) + expect(plain).toEqual([2, 3, 4, 5]) + expect(built).toEqual(plain) }) it('answers a blank the same way a column filter answers one on text', () => { diff --git a/src/lib/features/advanced-filter/kinds.ts b/src/lib/features/advanced-filter/kinds.ts new file mode 100644 index 0000000..57c47e9 --- /dev/null +++ b/src/lib/features/advanced-filter/kinds.ts @@ -0,0 +1,34 @@ +import type { ColumnDef, RowNode } from '../../core/types/index.js' +import type { CellRead } from '../../core/grid/index.js' +import { declaresKind, inferKind, kindOf, type FilterKind } from './operators.js' + +/** Rows read to work out the kind of a column that declares none. */ +const SAMPLE = 50 + +/** + * One kind per column, worked out once for a pass. + * + * A column that says what it holds is taken at its word. One that says + * nothing is read: a sample of the rows this pass is filtering, through the + * same gate, so a masked column is guessed from what the user can see. + * + * It lives in a plain module rather than beside the state it serves because + * the `Map` here is a lookup built and thrown away within one pass, and a + * `.svelte.ts` file is held to using the reactive one. + */ +export function kindsFor( + nodes: readonly RowNode[], + defs: Map>, + read: CellRead +): Map { + const kinds = new Map() + const sample = nodes.slice(0, SAMPLE) + + for (const [columnId, def] of defs) { + kinds.set( + columnId, + declaresKind(def) ? kindOf(def) : inferKind(sample.map((node) => read(node, def))) + ) + } + return kinds +} diff --git a/src/lib/features/advanced-filter/operators.ts b/src/lib/features/advanced-filter/operators.ts index 20946aa..b9dd75a 100644 --- a/src/lib/features/advanced-filter/operators.ts +++ b/src/lib/features/advanced-filter/operators.ts @@ -1,6 +1,6 @@ +import { isBlank, numericOrNull, toDate } from '../../core/utils/index.js' import { type ColumnDef } from '../../core/types/index.js' import type { AdvancedFilterOp } from './advanced-filter.types.js' -import { isBlank } from '../../core/utils/index.js' export type FilterKind = 'text' | 'number' | 'date' | 'boolean' | 'set' @@ -33,6 +33,15 @@ export function isFilterable(def: ColumnDef | undefined): boolean { return !UNREADABLE.has(def.type ?? 'text') } +/** True when the column says what it holds, rather than leaving it to be read. */ +export function declaresKind(def: ColumnDef | undefined): boolean { + if (!def) return false + const declared = def.filter + if (typeof declared === 'string' && declared in OPS_BY_KIND) return true + if (declared && typeof declared === 'object' && declared.type in OPS_BY_KIND) return true + return def.type !== undefined && def.type in BY_COLUMN_TYPE +} + export function kindOf(def: ColumnDef | undefined): FilterKind { if (!def) return 'text' @@ -45,6 +54,25 @@ export function kindOf(def: ColumnDef | undefined): FilterKind { return BY_COLUMN_TYPE[def.type ?? 'text'] ?? 'text' } +/** + * The kind of a column that declares none, read off the data once. + * + * A `{ id: 'total' }` holding numbers is an ordinary way to write a column, + * and comparing it as text would answer that 80 is greater than 100. The old + * evaluator guessed this per cell, which made one column answer as a number + * in one row and as text in the next; guessing per column costs a sample and + * gives the whole column one answer. + */ +export function inferKind(values: readonly unknown[]): FilterKind { + const present = values.filter((value) => !isBlank(value)) + if (present.length === 0) return 'text' + + if (present.every((value) => typeof value === 'boolean')) return 'boolean' + if (present.every((value) => numericOrNull(value) !== null)) return 'number' + if (present.every((value) => value instanceof Date || toDate(value) !== null)) return 'date' + return 'text' +} + export function opsFor(kind: FilterKind): AdvancedFilterOp[] { return OPS_BY_KIND[kind] } diff --git a/src/lib/features/advanced-filter/to-column-filter.ts b/src/lib/features/advanced-filter/to-column-filter.ts new file mode 100644 index 0000000..8b7665b --- /dev/null +++ b/src/lib/features/advanced-filter/to-column-filter.ts @@ -0,0 +1,120 @@ +import type { + ColumnFilter, + DateFilterOp, + NumberFilterOp, + SetFilterValue, + TextFilterOp +} from '../../core/types/index.js' +import { numericOrNull } from '../../core/utils/index.js' +import type { AdvancedFilterOp, FilterCondition } from './advanced-filter.types.js' +import type { FilterKind } from './operators.js' + +/** + * One condition of the tree, said in the language the column filter speaks. + * + * The builder and the column filter used to compare values twice, in two + * files, kept in step by a test. There is one comparison now and this is the + * seam: everything above it is a tree, everything below it is + * `valuePredicateFor`. + * + * Presence never arrives here. `blank` and `notBlank` are answered before the + * mapping, because the column filter has no shape for them on a boolean or a + * set and the answer is the same for every kind anyway. + */ + +/** `equals` is the same word on both sides; a number spells it `eq`. */ +const NUMBER_OPS: Partial> = { + equals: 'eq', + notEqual: 'neq', + gt: 'gt', + gte: 'gte', + lt: 'lt', + lte: 'lte', + between: 'between' +} + +const DATE_OPS: Partial> = { + equals: 'equals', + notEqual: 'notEqual', + before: 'before', + after: 'after', + between: 'between' +} + +const TEXT_OPS: Partial> = { + contains: 'contains', + notContains: 'notContains', + equals: 'equals', + notEqual: 'notEqual', + startsWith: 'startsWith', + endsWith: 'endsWith' +} + +function asIsoDay(value: unknown): string | undefined { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10) + } + return value === null || value === undefined ? undefined : String(value) +} + +function asBooleanFilter(condition: FilterCondition): ColumnFilter | null { + // The column filter has no operator here, only the value to match, so + // `notEqual true` is `equals false`. + const { op, value } = condition + if (op !== 'equals' && op !== 'notEqual') return null + const wanted = value === true || value === 'true' + return { kind: 'boolean', value: op === 'notEqual' ? !wanted : wanted } +} + +function asNumberFilter(condition: FilterCondition): ColumnFilter | null { + const mapped = NUMBER_OPS[condition.op] + if (!mapped) return null + return { + kind: 'number', + op: mapped, + value: numericOrNull(condition.value) ?? undefined, + to: numericOrNull(condition.to) ?? undefined + } +} + +function asDateFilter(condition: FilterCondition): ColumnFilter | null { + const mapped = DATE_OPS[condition.op] + if (!mapped) return null + return { + kind: 'date', + op: mapped, + value: asIsoDay(condition.value), + to: asIsoDay(condition.to) + } +} + +function asTextFilter(condition: FilterCondition): ColumnFilter | null { + const mapped = TEXT_OPS[condition.op] + if (!mapped) return null + const { value } = condition + return { + kind: 'text', + op: mapped, + value: value === null || value === undefined ? '' : String(value), + caseSensitive: condition.caseSensitive + } +} + +/** + * A condition the column filter cannot express answers `null`, and the caller + * treats that as a test nothing fails: an operator a column does not offer is + * a half-built condition, not a filter that hides every row. + */ +export function toColumnFilter(condition: FilterCondition, kind: FilterKind): ColumnFilter | null { + // Membership, whatever the column holds. `in` is the one operator that + // does not read as the column's kind: it asks whether the cell is one of + // the entries, and a set filter is exactly that question. + if (condition.op === 'in') { + return { kind: 'set', values: (condition.values ?? []) as SetFilterValue[] } + } + + if (kind === 'boolean') return asBooleanFilter(condition) + if (kind === 'number') return asNumberFilter(condition) + if (kind === 'date') return asDateFilter(condition) + return asTextFilter(condition) +} diff --git a/src/lib/features/filtering/filter-predicates.test.ts b/src/lib/features/filtering/filter-predicates.test.ts index 18f76a1..ee5660d 100644 --- a/src/lib/features/filtering/filter-predicates.test.ts +++ b/src/lib/features/filtering/filter-predicates.test.ts @@ -57,7 +57,9 @@ describe('number predicates', () => { it('covers comparison ops and between boundaries', () => { expect(passes({ kind: 'number', op: 'eq', value: 5 }, 5)).toBe(true) expect(passes({ kind: 'number', op: 'neq', value: 5 }, 6)).toBe(true) - expect(passes({ kind: 'number', op: 'neq', value: 5 }, null)).toBe(false) + // A blank is not the number being excluded, the way it is not the + // word `notEqual` excludes on text. The two used to disagree. + expect(passes({ kind: 'number', op: 'neq', value: 5 }, null)).toBe(true) expect(passes({ kind: 'number', op: 'gt', value: 5 }, 6)).toBe(true) expect(passes({ kind: 'number', op: 'gte', value: 5 }, 5)).toBe(true) expect(passes({ kind: 'number', op: 'lt', value: 5 }, 4)).toBe(true) diff --git a/src/lib/features/filtering/filter-predicates.ts b/src/lib/features/filtering/filter-predicates.ts index 18a2582..99649f6 100644 --- a/src/lib/features/filtering/filter-predicates.ts +++ b/src/lib/features/filtering/filter-predicates.ts @@ -110,6 +110,10 @@ function numberPredicate( filter.op ] if (!compare) return PASSES + // `neq` keeps a blank, the way `textPredicate` keeps one: a cell with no + // number in it is not the number being excluded. Every other comparator + // needs a real value, or `Number('')` would answer 0 to `lt 5`. + if (filter.op === 'neq') return (value) => isBlank(value) || compare(Number(value), target) return (value) => !isBlank(value) && compare(Number(value), target) } diff --git a/src/lib/features/worker-row-model/predicates.ts b/src/lib/features/worker-row-model/predicates.ts deleted file mode 100644 index 74f13e7..0000000 --- a/src/lib/features/worker-row-model/predicates.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type ColumnFilter } from '../../core/types/index.js' -import { isBlank, setKeyOf, toEpochDay } from './columnar.js' - -const PASSES = (): boolean => true - -function folded(filter: Extract) { - const fold = filter.caseSensitive - ? (text: string) => text - : (text: string) => text.toLowerCase() - return { query: fold(String(filter.value ?? '').trim()), fold } -} - -function textPredicate( - filter: Extract -): (value: unknown) => boolean { - if (filter.op === 'blank') return (value) => isBlank(value) - if (filter.op === 'notBlank') return (value) => !isBlank(value) - - const { query, fold } = folded(filter) - const read = (value: unknown) => fold(String(value)) - - switch (filter.op) { - case 'equals': - return (value) => !isBlank(value) && read(value) === query - case 'notEqual': - return (value) => isBlank(value) || read(value) !== query - case 'startsWith': - return (value) => !isBlank(value) && read(value).startsWith(query) - case 'endsWith': - return (value) => !isBlank(value) && read(value).endsWith(query) - case 'contains': - return (value) => !isBlank(value) && read(value).includes(query) - case 'notContains': - return (value) => isBlank(value) || !read(value).includes(query) - default: - return PASSES - } -} - -const NUMBER_TESTS: Record boolean> = { - eq: (value, target) => value === target, - neq: (value, target) => value !== target, - gt: (value, target) => value > target, - gte: (value, target) => value >= target, - lt: (value, target) => value < target, - lte: (value, target) => value <= target -} - -function numberPredicate( - filter: Extract -): (value: unknown) => boolean { - if (filter.op === 'blank') return (value) => isBlank(value) - if (filter.op === 'notBlank') return (value) => !isBlank(value) - - const target = filter.value ?? Number.NaN - if (filter.op === 'between') { - const to = filter.to ?? Number.NaN - return (value) => { - if (isBlank(value)) return false - const numeric = Number(value) - return numeric >= target && numeric <= to - } - } - - const compare = NUMBER_TESTS[filter.op] - if (!compare) return PASSES - return (value) => !isBlank(value) && compare(Number(value), target) -} - -function datePredicate( - filter: Extract -): (value: unknown) => boolean { - if (filter.op === 'blank') return (value) => isBlank(value) - if (filter.op === 'notBlank') return (value) => !isBlank(value) - - const target = toEpochDay(filter.value) - const to = toEpochDay(filter.to) - - switch (filter.op) { - case 'equals': - return (value) => toEpochDay(value) === target - case 'before': - return (value) => toEpochDay(value) < target - case 'after': - return (value) => toEpochDay(value) > target - case 'between': - return (value) => { - const day = toEpochDay(value) - return day >= target && day <= to - } - default: - return PASSES - } -} - -function setPredicate(filter: Extract): (value: unknown) => boolean { - if (!Array.isArray(filter.values)) return PASSES - const allowed = new Set(filter.values.map((entry) => setKeyOf(entry))) - return (value) => allowed.has(setKeyOf(value)) -} - -export function predicateFor(filter: ColumnFilter): (value: unknown) => boolean { - switch (filter.kind) { - case 'text': - return textPredicate(filter) - case 'number': - return numberPredicate(filter) - case 'date': - return datePredicate(filter) - case 'set': - return setPredicate(filter) - case 'boolean': - return (value) => Boolean(value) === filter.value - default: - return PASSES - } -} diff --git a/src/lib/features/worker-row-model/query.ts b/src/lib/features/worker-row-model/query.ts index d913e24..795586d 100644 --- a/src/lib/features/worker-row-model/query.ts +++ b/src/lib/features/worker-row-model/query.ts @@ -1,6 +1,7 @@ import { type FilterRequest, type SortState } from '../../core/types/index.js' import type { FilterNode } from '../advanced-filter/advanced-filter.types.js' import { matchesNode } from '../advanced-filter/evaluate.js' +import type { FilterKind } from '../advanced-filter/operators.js' import { BOOLEAN_BLANK, columnOf, @@ -15,7 +16,7 @@ import { type ColumnStore, type RowStore } from './columnar.js' -import { predicateFor } from './predicates.js' +import { valuePredicateFor } from '../filtering/filter-predicates.js' export interface WorkerQuery { filter: FilterRequest @@ -43,7 +44,7 @@ const collator = new Intl.Collator(undefined, { numeric: true }) function stringTest( store: ColumnStore, - filter: Parameters[0], + filter: Parameters[0], accept: (value: unknown) => boolean ): RowTest { const dictionary = store.dictionary! @@ -73,8 +74,8 @@ function dateTest(store: ColumnStore, accept: (value: unknown) => boolean): RowT } } -function testFor(store: ColumnStore, filter: Parameters[0]): RowTest { - const accept = predicateFor(filter) +function testFor(store: ColumnStore, filter: Parameters[0]): RowTest { + const accept = valuePredicateFor(filter) if (store.kind === 'string') return stringTest(store, filter, accept) @@ -164,14 +165,28 @@ function columnTests( return tests } +const STORE_KINDS: Record = { + string: 'text', + number: 'number', + date: 'date', + boolean: 'boolean' +} + function treeTest(store: RowStore, tree: FilterNode | null | undefined): RowTest | null { if (!tree || (tree.kind === 'group' && tree.children.length === 0)) return null return (index) => - matchesNode(tree, (columnId) => { - const column = columnOf(store, columnId) - return column ? readStore(column, index) : undefined - }) + matchesNode( + tree, + (columnId) => { + const column = columnOf(store, columnId) + return column ? readStore(column, index) : undefined + }, + // The store already sorted each column into a kind when it was + // packed, which is the same question the main thread answers from + // the column definition. + (columnId) => STORE_KINDS[columnOf(store, columnId)?.kind ?? 'string'] + ) } function stringKeys(column: ColumnStore, rowCount: number, asDate: boolean): Float64Array { diff --git a/src/tests/server-contract-ops.test.ts b/src/tests/server-contract-ops.test.ts index 858bebe..a808dde 100644 --- a/src/tests/server-contract-ops.test.ts +++ b/src/tests/server-contract-ops.test.ts @@ -106,7 +106,9 @@ const comparisons: Record boolean> = function numberHolds(value: unknown, condition: Extract): boolean { if (condition.op === 'blank') return isBlank(value) if (condition.op === 'notBlank') return !isBlank(value) - if (isBlank(value)) return false + // A blank is not the number being excluded. Every other comparator needs a + // real value, or `Number('')` would answer 0 to `lt 5`. + if (isBlank(value)) return condition.op === 'neq' const numeric = Number(value) const target = condition.value ?? Number.NaN From b815f1ae0c1533cfecf0c0d37b8a0e4f387be745 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 6 Sep 2026 23:28:08 +0700 Subject: [PATCH 13/46] refactor(api): a barrel offers what something asks for, and nothing else Every barrel in `src/lib` was checked by parsing import statements rather than matching words, and by following the re-export chain up to the public entries, so a name reachable by an application counts as used and a name that merely exists elsewhere does not. Three earlier passes had missed things for exactly those two reasons. The feature id constants go. `SELECTION`, `EDITING`, `FILTERING`, `ADVANCED_FILTER` and eighteen more are in no root barrel, so no application can import one, and each is used in a single line inside its own module: the `grid.feature(ID)` that `getSelection` and its siblings already wrap. They stay exported from the file that defines them, where the module's own code reads them. Six types follow, on the rule that a type earns its place by appearing in the signature of something public: `UndoState`, `UndoCommand` and `Validated` are named only by private methods of `Editing`, and `DropTarget`, `FilterDraft` and `FloatingCell` by no exported class at all. `EditingCell`, `MoveDirection`, `ColumnDragState` and `ToggleModifiers` stay by the same rule - each is a field or a parameter of a class an application holds. `components/cells/index.ts` is deleted outright: nothing has ever imported it, and the five cell components are reached from the files that draw them. `menus` keeps only the context menu; the column menu and the filter panel are opened by the header from state the features own, and mean nothing mounted alone. The xlsx barrel drops from thirty-four names to nine, because `src/lib/xlsx.ts` already names the public surface from the files, and a barrel repeating that decision is a second copy nobody keeps in step. One thing is added rather than removed. `getPolicy`, `getDataImport` and `getShowValuesAs` are public while the types they hand back were not, so an application could call them and not name what it got. The other thirteen features export theirs; these three now do too. --- src/lib/components/cells/index.ts | 14 ----- src/lib/components/menus/index.ts | 3 -- src/lib/features/advanced-filter/index.ts | 8 +-- src/lib/features/column-ops/index.ts | 4 +- src/lib/features/command-palette/index.ts | 7 +-- .../features/conditional-formatting/index.ts | 1 - src/lib/features/data-import/index.ts | 2 +- src/lib/features/editing/index.ts | 15 ++---- src/lib/features/filtering/index.ts | 13 ++--- src/lib/features/find-replace/index.ts | 2 +- src/lib/features/grouping/index.ts | 2 +- src/lib/features/index.ts | 3 ++ src/lib/features/master-detail/index.ts | 7 +-- src/lib/features/pagination/index.ts | 1 - src/lib/features/policy/index.ts | 2 +- src/lib/features/range-selection/index.ts | 7 +-- src/lib/features/row-pinning/index.ts | 2 +- src/lib/features/row-reorder/index.ts | 2 +- src/lib/features/saved-views/index.ts | 2 +- src/lib/features/selection/index.ts | 2 +- src/lib/features/server-row-model/index.ts | 7 +-- src/lib/features/show-values-as/index.ts | 7 +-- src/lib/features/sorting/index.ts | 1 - src/lib/features/tree/index.ts | 2 +- src/lib/features/virtualization/index.ts | 7 +-- src/lib/features/xlsx/index.ts | 52 ++++++------------- 26 files changed, 42 insertions(+), 133 deletions(-) delete mode 100644 src/lib/components/cells/index.ts diff --git a/src/lib/components/cells/index.ts b/src/lib/components/cells/index.ts deleted file mode 100644 index a1c1100..0000000 --- a/src/lib/components/cells/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * The five things that can sit inside a cell. None of them is public: a cell - * is drawn by the grid, or by the app's own `cell` snippet, and never mounted - * by hand. - * - * Most belong to a body cell. Two do not: the selection checkbox also heads - * its column, and the filter field sits in the filter row under the header. - */ - -export { default as GridCellEditor } from './GridCellEditor.svelte' -export { default as GridCellValue } from './GridCellValue.svelte' -export { default as GridFilterCell } from './GridFilterCell.svelte' -export { default as GridRowHandleCell } from './GridRowHandleCell.svelte' -export { default as GridSelectionCell } from './GridSelectionCell.svelte' diff --git a/src/lib/components/menus/index.ts b/src/lib/components/menus/index.ts index 81b53ee..5b7b473 100644 --- a/src/lib/components/menus/index.ts +++ b/src/lib/components/menus/index.ts @@ -4,7 +4,4 @@ * features own, and have no meaning mounted on their own. */ -export { default as GridColumnMenu } from './GridColumnMenu.svelte' export { default as GridContextMenu } from './GridContextMenu.svelte' -export { default as GridFilterCondition } from './GridFilterCondition.svelte' -export { default as GridFilterPanel } from './GridFilterPanel.svelte' diff --git a/src/lib/features/advanced-filter/index.ts b/src/lib/features/advanced-filter/index.ts index 0aed94c..d7f1402 100644 --- a/src/lib/features/advanced-filter/index.ts +++ b/src/lib/features/advanced-filter/index.ts @@ -1,10 +1,4 @@ -export { - ADVANCED_FILTER, - advancedFilter, - AdvancedFilter, - getAdvancedFilter -} from './advanced-filter.svelte.js' -export { matchesNode } from './evaluate.js' +export { advancedFilter, AdvancedFilter, getAdvancedFilter } from './advanced-filter.svelte.js' export type { AdvancedFilterOp, AdvancedFilterOptions, diff --git a/src/lib/features/column-ops/index.ts b/src/lib/features/column-ops/index.ts index 28a72b9..dcd0c3a 100644 --- a/src/lib/features/column-ops/index.ts +++ b/src/lib/features/column-ops/index.ts @@ -1,3 +1,3 @@ -export { COLUMN_OPS, ColumnOps, columnOps, getColumnOps } from './column-ops.svelte.js' +export { ColumnOps, columnOps, getColumnOps } from './column-ops.svelte.js' export type { ColumnDragState, ColumnOpsOptions } from './column-ops.types.js' -export { dropTargetIndex, type DropTarget } from './drag.js' +export { dropTargetIndex } from './drag.js' diff --git a/src/lib/features/command-palette/index.ts b/src/lib/features/command-palette/index.ts index c8e86f4..62ae563 100644 --- a/src/lib/features/command-palette/index.ts +++ b/src/lib/features/command-palette/index.ts @@ -1,7 +1,2 @@ -export { - COMMAND_PALETTE, - CommandPalette, - commandPalette, - getCommandPalette -} from './command-palette.svelte.js' +export { CommandPalette, commandPalette, getCommandPalette } from './command-palette.svelte.js' export type { CommandPaletteOptions, GridCommand } from './command-palette.types.js' diff --git a/src/lib/features/conditional-formatting/index.ts b/src/lib/features/conditional-formatting/index.ts index 6972c20..96a831d 100644 --- a/src/lib/features/conditional-formatting/index.ts +++ b/src/lib/features/conditional-formatting/index.ts @@ -1,5 +1,4 @@ export { - CONDITIONAL_FORMATTING, ConditionalFormatting, conditionalFormatting, getConditionalFormatting diff --git a/src/lib/features/data-import/index.ts b/src/lib/features/data-import/index.ts index 52f0013..5f97269 100644 --- a/src/lib/features/data-import/index.ts +++ b/src/lib/features/data-import/index.ts @@ -1,4 +1,4 @@ -export { DATA_IMPORT, DataImport, dataImport, getDataImport } from './data-import.svelte.js' +export { DataImport, dataImport, getDataImport } from './data-import.svelte.js' export type { DataImportOptions, ImportDedupe, diff --git a/src/lib/features/editing/index.ts b/src/lib/features/editing/index.ts index 25c66f5..b6db29a 100644 --- a/src/lib/features/editing/index.ts +++ b/src/lib/features/editing/index.ts @@ -1,14 +1,5 @@ export { groupChangesByRow } from './edit-batch.js' -export { Editing, EDITING, editing, editorTypeOf, getEditing } from './editing.svelte.js' +export { Editing, editing, editorTypeOf, getEditing } from './editing.svelte.js' export type { EditingCell, EditingOptions, EditMode, MoveDirection } from './editing.types.js' -export { isPromise, runValidation, type Validated } from './validate.js' -export { - canRedo, - canUndo, - emptyUndo, - pushCommand, - redo, - undo, - type UndoCommand, - type UndoState -} from './undo-stack.js' +export { isPromise, runValidation } from './validate.js' +export { canRedo, canUndo, emptyUndo, pushCommand, redo, undo } from './undo-stack.js' diff --git a/src/lib/features/filtering/index.ts b/src/lib/features/filtering/index.ts index fe5cbf0..39267e8 100644 --- a/src/lib/features/filtering/index.ts +++ b/src/lib/features/filtering/index.ts @@ -6,8 +6,7 @@ export { emptyDraft, isPresenceOp, MAX_CONDITIONS, - type ConditionDraft, - type FilterDraft + type ConditionDraft } from './filter-draft.js' export { filterConditions, @@ -17,18 +16,12 @@ export { } from './filter-model.js' export { sanitizeFilterModel } from './filter-sanitize.js' export { filterUnitScaleOf, toDisplayUnit, toModelUnit } from './filter-units.js' -export { floatingCellOf, type FloatingCell } from './floating-filter.js' +export { floatingCellOf } from './floating-filter.js' export { compileColumnFilters, describeFilter, filterTypeOf, valuePredicateFor } from './filter-predicates.js' -export { - Filtering, - filtering, - FILTERING, - getFiltering, - type FilteringOptions -} from './filtering.svelte.js' +export { Filtering, filtering, getFiltering, type FilteringOptions } from './filtering.svelte.js' export { quickFilterNodes } from './quick-filter.js' diff --git a/src/lib/features/find-replace/index.ts b/src/lib/features/find-replace/index.ts index a773ee2..a0ced3c 100644 --- a/src/lib/features/find-replace/index.ts +++ b/src/lib/features/find-replace/index.ts @@ -1,3 +1,3 @@ export type { Match } from './find.js' -export { FIND_REPLACE, FindReplace, findReplace, getFindReplace } from './find-replace.svelte.js' +export { FindReplace, findReplace, getFindReplace } from './find-replace.svelte.js' export type { FindReplaceOptions } from './find-replace.types.js' diff --git a/src/lib/features/grouping/index.ts b/src/lib/features/grouping/index.ts index 447091f..1e5c8ad 100644 --- a/src/lib/features/grouping/index.ts +++ b/src/lib/features/grouping/index.ts @@ -1,5 +1,5 @@ export { aggregate } from './aggregate.js' -export { getGrouping, GROUPING, grouping, Grouping } from './grouping.svelte.js' +export { getGrouping, grouping, Grouping } from './grouping.svelte.js' export type { Aggregation, GroupingOptions, GroupRowValues } from './grouping.types.js' export { createSeenGroups } from './seen-groups.js' export { totalsKindOf } from './totals-nodes.js' diff --git a/src/lib/features/index.ts b/src/lib/features/index.ts index 09c05d4..eec5ae6 100644 --- a/src/lib/features/index.ts +++ b/src/lib/features/index.ts @@ -160,12 +160,14 @@ export { getShowValuesAs, showValuesAs, type ShowAs, + type ShowValuesAs, type ShowValuesAsOptions } from './show-values-as/index.js' export { dataImport, getDataImport, + type DataImport, type DataImportOptions, type DuplicateScope, type ImportDedupe, @@ -178,6 +180,7 @@ export { getPolicy, policy, type MaskKind, + type Policy, type PolicyContext, type PolicyOptions, type PolicyRule diff --git a/src/lib/features/master-detail/index.ts b/src/lib/features/master-detail/index.ts index c8ed071..ac7acd0 100644 --- a/src/lib/features/master-detail/index.ts +++ b/src/lib/features/master-detail/index.ts @@ -1,8 +1,3 @@ export { isDetailNode } from './detail-nodes.js' -export { - getMasterDetail, - MASTER_DETAIL, - masterDetail, - MasterDetail -} from './master-detail.svelte.js' +export { getMasterDetail, masterDetail, MasterDetail } from './master-detail.svelte.js' export type { MasterDetailOptions } from './master-detail.types.js' diff --git a/src/lib/features/pagination/index.ts b/src/lib/features/pagination/index.ts index 2e19d5c..f44b468 100644 --- a/src/lib/features/pagination/index.ts +++ b/src/lib/features/pagination/index.ts @@ -3,6 +3,5 @@ export { getPagination, Pagination, pagination, - PAGINATION, type PaginationOptions } from './pagination.svelte.js' diff --git a/src/lib/features/policy/index.ts b/src/lib/features/policy/index.ts index 075d610..f86b337 100644 --- a/src/lib/features/policy/index.ts +++ b/src/lib/features/policy/index.ts @@ -1,3 +1,3 @@ export type { MaskKind } from './masks.js' -export { getPolicy, POLICY, Policy, policy } from './policy.svelte.js' +export { getPolicy, Policy, policy } from './policy.svelte.js' export type { PolicyContext, PolicyOptions, PolicyRule } from './policy.types.js' diff --git a/src/lib/features/range-selection/index.ts b/src/lib/features/range-selection/index.ts index ea4e7f6..629488d 100644 --- a/src/lib/features/range-selection/index.ts +++ b/src/lib/features/range-selection/index.ts @@ -1,9 +1,4 @@ export { buildMoveEdits } from './range-clipboard.js' -export { - getRangeSelection, - RANGE_SELECTION, - rangeSelection, - RangeSelection -} from './range-selection.svelte.js' +export { getRangeSelection, rangeSelection, RangeSelection } from './range-selection.svelte.js' export type { CopyRangeOptions, RangeSelectionOptions } from './range-selection.types.js' export type { CellRange } from './range.js' diff --git a/src/lib/features/row-pinning/index.ts b/src/lib/features/row-pinning/index.ts index e11b721..842a040 100644 --- a/src/lib/features/row-pinning/index.ts +++ b/src/lib/features/row-pinning/index.ts @@ -1,2 +1,2 @@ -export { getRowPinning, ROW_PINNING, RowPinning, rowPinning } from './row-pinning.svelte.js' +export { getRowPinning, RowPinning, rowPinning } from './row-pinning.svelte.js' export type { RowPinningOptions } from './row-pinning.types.js' diff --git a/src/lib/features/row-reorder/index.ts b/src/lib/features/row-reorder/index.ts index c6d4ca4..9792eac 100644 --- a/src/lib/features/row-reorder/index.ts +++ b/src/lib/features/row-reorder/index.ts @@ -1,2 +1,2 @@ -export { getRowReorder, RowReorder, rowReorder, ROW_REORDER } from './row-reorder.svelte.js' +export { getRowReorder, RowReorder, rowReorder } from './row-reorder.svelte.js' export type { RowDragState, RowReorderOptions } from './row-reorder.types.js' diff --git a/src/lib/features/saved-views/index.ts b/src/lib/features/saved-views/index.ts index 9646790..301d322 100644 --- a/src/lib/features/saved-views/index.ts +++ b/src/lib/features/saved-views/index.ts @@ -1,3 +1,3 @@ -export { getSavedViews, SAVED_VIEWS, SavedViews, savedViews } from './saved-views.svelte.js' +export { getSavedViews, SavedViews, savedViews } from './saved-views.svelte.js' export type { SavedView, SavedViewsOptions, SavedViewStorage } from './saved-views.types.js' export { localStorageViews } from './storage.js' diff --git a/src/lib/features/selection/index.ts b/src/lib/features/selection/index.ts index b4353aa..51f9561 100644 --- a/src/lib/features/selection/index.ts +++ b/src/lib/features/selection/index.ts @@ -11,7 +11,7 @@ export { type CellMatrix, type ExportFormatter } from './clipboard.js' -export { getSelection, Selection, selection, SELECTION } from './selection.svelte.js' +export { getSelection, Selection, selection } from './selection.svelte.js' export type { CopyOptions, ExportCsvOptions, diff --git a/src/lib/features/server-row-model/index.ts b/src/lib/features/server-row-model/index.ts index 3ba6482..f04a7ff 100644 --- a/src/lib/features/server-row-model/index.ts +++ b/src/lib/features/server-row-model/index.ts @@ -1,9 +1,4 @@ -export { - getServerRowModel, - SERVER_ROW_MODEL, - ServerRowModel, - serverRowModel -} from './server-row-model.svelte.js' +export { getServerRowModel, ServerRowModel, serverRowModel } from './server-row-model.svelte.js' export type { DataSource, GetRowsRequest, diff --git a/src/lib/features/show-values-as/index.ts b/src/lib/features/show-values-as/index.ts index cd5cf93..81e12b9 100644 --- a/src/lib/features/show-values-as/index.ts +++ b/src/lib/features/show-values-as/index.ts @@ -1,7 +1,2 @@ -export { - getShowValuesAs, - SHOW_VALUES_AS, - ShowValuesAs, - showValuesAs -} from './show-values-as.svelte.js' +export { getShowValuesAs, ShowValuesAs, showValuesAs } from './show-values-as.svelte.js' export type { ShowAs, ShowValuesAsOptions } from './show-values-as.types.js' diff --git a/src/lib/features/sorting/index.ts b/src/lib/features/sorting/index.ts index e339e2f..0e153b0 100644 --- a/src/lib/features/sorting/index.ts +++ b/src/lib/features/sorting/index.ts @@ -4,7 +4,6 @@ export { getSorting, Sorting, sorting, - SORTING, type SortCycle, type SortingOptions, type ToggleSortOptions diff --git a/src/lib/features/tree/index.ts b/src/lib/features/tree/index.ts index ce471e6..272012e 100644 --- a/src/lib/features/tree/index.ts +++ b/src/lib/features/tree/index.ts @@ -1,2 +1,2 @@ -export { getTree, TREE, tree, Tree } from './tree.svelte.js' +export { getTree, tree, Tree } from './tree.svelte.js' export type { TreeOptions } from './tree.types.js' diff --git a/src/lib/features/virtualization/index.ts b/src/lib/features/virtualization/index.ts index 9513b97..7abbdc4 100644 --- a/src/lib/features/virtualization/index.ts +++ b/src/lib/features/virtualization/index.ts @@ -1,7 +1,2 @@ -export { - getVirtualization, - Virtualization, - virtualization, - VIRTUALIZATION -} from './virtualization.svelte.js' +export { getVirtualization, Virtualization, virtualization } from './virtualization.svelte.js' export type { ColumnVirtualizationOptions, VirtualizationOptions } from './virtualization.types.js' diff --git a/src/lib/features/xlsx/index.ts b/src/lib/features/xlsx/index.ts index ce32f6f..efa0828 100644 --- a/src/lib/features/xlsx/index.ts +++ b/src/lib/features/xlsx/index.ts @@ -1,39 +1,17 @@ -export { - buildGridXlsx, - buildGridXlsxAsync, - downloadGridXlsx, - type ExportXlsxOptions -} from './export.js' -export { - DEFAULT_FORMAT_COLORS, - type XlsxCfRule, - type XlsxFormatColors -} from './conditional-format.js' -export { - cellRef, - columnLetter, - DEFAULT_STYLE, - toSerialDate, - type CellValue, - type SheetColumn, - type SheetOptions, - type StyleId, - type XlsxFormula -} from './sheet.js' +/** + * What the rest of the library reaches into the writer for. + * + * An application does not come through here: the workbook writer has an entry + * of its own at `@sv5ui/datagrid/xlsx`, and `src/lib/xlsx.ts` names the whole + * public surface from the files directly. Listing all of it again here would + * be a second copy of that decision, kept in step by nobody. + * + * `buildGridXlsxAsync`, `workbookEntries` and `XlsxStyle` are here for the + * budget tests, which weigh the writer without going through the entry. + */ + +export { buildGridXlsx, buildGridXlsxAsync } from './export.js' export { BUILT_IN_STYLES, StyleTable } from './styles.js' -export type { - XlsxAlignment, - XlsxBorder, - XlsxBorderSide, - XlsxColor, - XlsxFont, - XlsxStyle -} from './styles.types.js' -export { - createWorkbook, - workbookEntries, - XLSX_MIME, - type WorkbookOptions, - type WorkbookSheet -} from './workbook.js' +export type { XlsxStyle } from './styles.types.js' +export { createWorkbook, workbookEntries } from './workbook.js' export { createZip, deflateEntries, zipEntry } from './zip.js' From 352a6e4c9fe70041a3b114e83349e880781a18ad Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Mon, 7 Sep 2026 00:51:17 +0700 Subject: [PATCH 14/46] feat(demos): take the seventeen playground routes, and mend what they broke Aggregation, auto columns, the combined grid, fill, both filter demos, find, formatting, formulas, import, policy, ranges, sharing, structures, views, the worker and xlsx. Roughly 4,600 lines of grids that had not been drawn since the modules moved. Drawing them found two things `svelte-check` cannot: it reads types, it does not run effects. Every demo writes `` beside ``, and a panel had stopped taking a grid. `DataGrid` owns its root, so there was nowhere left to put a panel except by abandoning it and assembling `Grid.Root` by hand - a step back from what these pages used to do. A panel reads its grid from context and takes one when it is handed one, which is what `getGridOrNull` in this codebase was already there for, and the generic comes back with it so the grid stays typed. The filter builder was handed a grid and its nested group still asked context for one, so two demos threw on mount even with the panel fixed. The labels are passed down now. `ported-demos.svelte.test.ts` mounts all seventeen and asserts the one thing worth asserting: a grid appeared. A page that throws on mount is the cheapest bug to find and the easiest to never look for. --- .../components/chrome/RangeStatusBar.svelte | 9 +- .../internal/FilterBuilderGroup.svelte | 8 +- .../components/panels/CommandPalette.svelte | 9 +- .../panels/ConditionalFormattingPanel.svelte | 11 +- .../components/panels/FilterBuilder.svelte | 10 +- src/lib/components/panels/FindReplace.svelte | 9 +- src/lib/components/panels/GroupPanel.svelte | 9 +- src/lib/components/panels/ImportWizard.svelte | 16 +- src/lib/components/panels/SavedViews.svelte | 12 +- src/lib/components/panels/ToolPanel.svelte | 21 +- src/lib/components/panels/index.ts | 7 - src/routes/aggregate/+page.svelte | 380 ++++++++++++++++++ src/routes/auto/+page.svelte | 310 ++++++++++++++ src/routes/combo/+page.svelte | 225 +++++++++++ src/routes/fill/+page.svelte | 260 ++++++++++++ src/routes/filter-types/+page.svelte | 316 +++++++++++++++ src/routes/filter/+page.svelte | 239 +++++++++++ src/routes/find/+page.svelte | 238 +++++++++++ src/routes/format/+page.svelte | 197 +++++++++ src/routes/formula/+page.svelte | 336 ++++++++++++++++ src/routes/import/+page.svelte | 268 ++++++++++++ src/routes/policy/+page.svelte | 220 ++++++++++ src/routes/range/+page.svelte | 262 ++++++++++++ src/routes/share/+page.svelte | 338 ++++++++++++++++ src/routes/structures/+page.svelte | 304 ++++++++++++++ src/routes/views/+page.svelte | 245 +++++++++++ src/routes/worker/+page.svelte | 255 ++++++++++++ src/routes/xlsx/+page.svelte | 186 +++++++++ src/tests/ported-demos.svelte.test.ts | 35 ++ 29 files changed, 4687 insertions(+), 48 deletions(-) create mode 100644 src/routes/aggregate/+page.svelte create mode 100644 src/routes/auto/+page.svelte create mode 100644 src/routes/combo/+page.svelte create mode 100644 src/routes/fill/+page.svelte create mode 100644 src/routes/filter-types/+page.svelte create mode 100644 src/routes/filter/+page.svelte create mode 100644 src/routes/find/+page.svelte create mode 100644 src/routes/format/+page.svelte create mode 100644 src/routes/formula/+page.svelte create mode 100644 src/routes/import/+page.svelte create mode 100644 src/routes/policy/+page.svelte create mode 100644 src/routes/range/+page.svelte create mode 100644 src/routes/share/+page.svelte create mode 100644 src/routes/structures/+page.svelte create mode 100644 src/routes/views/+page.svelte create mode 100644 src/routes/worker/+page.svelte create mode 100644 src/routes/xlsx/+page.svelte create mode 100644 src/tests/ported-demos.svelte.test.ts diff --git a/src/lib/components/chrome/RangeStatusBar.svelte b/src/lib/components/chrome/RangeStatusBar.svelte index cc52d6e..77be9e5 100644 --- a/src/lib/components/chrome/RangeStatusBar.svelte +++ b/src/lib/components/chrome/RangeStatusBar.svelte @@ -1,22 +1,27 @@ - + +{#snippet labelCell({ value }: DataGridCellContext)} + {@const text = String(value ?? '')} + {#if text === ''}{:else if text.includes('(') || text.startsWith('Total')} + {text} + {:else} + + {/if} +{/snippet} + +{#snippet moneyCell({ value }: DataGridCellContext)} + {value === null || value === undefined ? '' : money.format(Number(value))} +{/snippet} + +{#snippet ratingCell({ value }: DataGridCellContext)} + {value === null || value === undefined ? '' : oneDecimal.format(Number(value))} +{/snippet} + + +
    +
    +

    + Aggregation nâng cao - @sv5ui/datagrid +

    +

    + 320 nhân sự, gộp theo phòng ban. Mỗi cột dùng một aggregator khác nhau + - tên aggregator nằm ngay trong tiêu đề cột. Bảng dưới lưới bóc từng con số ra cho kiểm + chứng bằng mắt. +

    +
    +
    + ← Grouping + Range editing → + +
    +
    + +
    + Ba loại hàng trong lưới, đọc từ trái sang: hàng nhóm + (Core (81)) tổng hợp 81 hàng của phòng đó | + hàng footer đóng lại mỗi nhóm khi mở ra | + hàng Total ở đáy là grand total: + cùng những aggregator đó, nhưng chạy trên cả 320 + hàng. Nên Total | Salary $75,700 nghĩa là "trung vị lương của toàn bộ 320 + người", không phải "tổng lương" - cột đó khai + median, không phải sum. +
    + +
    +
    + + + + + +
    +
    + Bóc con số của: + {#each [ALL, ...depts] as name (name)} +
    + +
    + + + + + + + + + + + + {#each rows as row (row.aggregation)} + + + + + + + + {/each} + +
    CộtAggregationĐầu vàoTính thế nàoRa số nào
    {row.column}{row.aggregation}{row.input}{row.math}{row.result}
    +
    + +

    + Bảng này gọi thẳng aggregate() mà lưới đang dùng, nên số ở cột phải phải khớp + từng chữ số với hàng nhóm tương ứng trên lưới. Nếu lệch thì là thư viện sai, không phải trang + demo sai. +

    +
    +
    diff --git a/src/routes/auto/+page.svelte b/src/routes/auto/+page.svelte new file mode 100644 index 0000000..cbe5eb1 --- /dev/null +++ b/src/routes/auto/+page.svelte @@ -0,0 +1,310 @@ + + + +
    +
    +

    + Sinh cột từ dữ liệu - @sv5ui/datagrid +

    +

    + Anh có một mảng JSON và chưa có dòng ColumnDef nào. + autoColumns(rows) đọc thử vài dòng, đoán kiểu từng cột và trả về danh sách + cột - việc anh vẫn phải ngồi gõ tay mỗi lần dựng lưới cho một cấu trúc dữ liệu mới. +

    +
    +
    + ← Grouping + +
    +
    + +
    + Thử với: + {#each presets as preset (preset.label)} +
    + +
    +
    +

    + Lưới dựng ra: {columns.length} cột từ {rows.length} dòng, không viết dòng cột nào +

    + + sort, filter, sửa ô và chọn vùng đều chạy như lưới viết tay + +
    + + {#if grid} + {#key grid} + + {/key} + {:else} +

    + Không có cột nào dựng được từ dữ liệu này. +

    + {/if} +
    + +
    +
    +

    Nó đoán ra gì

    +
    + + + + + + + + + + + {#each columns as column (column.id)} + + + + + + + {/each} + +
    Khoá trong JSONTiêu đềtypefilter
    {column.id}{column.header}{column.type}{String(column.filter)}
    +
    +

    + Khoá nào không có ở đây là bị bỏ có chủ ý: giá trị là object hay mảng thì không ô + nào vẽ được, còn khoá bắt đầu bằng __ là chỗ package này đánh dấu hàng của + riêng nó. +

    +
    + +
    +
    +

    Thứ anh khỏi phải gõ

    +
    +
    {code}
    +
    +
    + +
    +

    + Dữ liệu vào (sửa được, rồi bấm Dựng lại) +

    + +

    + Độ dài: {token.length} ký tự. Giới hạn an toàn cho URL là 1800 - quá + thì + shareToken() ném lỗi thay vì trả link hỏng. +

    + + +
    +
    +

    Snapshot hiện tại

    +
    {snapshot}
    +
    +
    +

    Danh sách đã lưu

    +
    {JSON.stringify(
    +                    stored.map((view) => ({ id: view.id, name: view.name })),
    +                    null,
    +                    2
    +                )}
    +
    +
    + +
    +

    Cần thử những gì

    +
      +
    1. + Chọn Theo Dept (mẫu) → lưới phải nhóm theo Dept + và đổi mật độ sang compact +
    2. +
    3. + Đổi sang Nhóm Dept + Country → nhãn đã chỉnh phải hiện +
    4. +
    5. Hoàn nguyên → quay lại đúng view, nhãn đã chỉnh tắt
    6. +
    7. Đổi rồi bấm Cập nhật → nhãn tắt, snapshot của view đổi theo
    8. +
    9. + Bỏ nhóm rồi nhóm lại đúng như cũ → nhãn không được hiện (so + sánh phải chuẩn hoá thứ tự khoá) +
    10. +
    11. Đặt tên trống rồi bấm Lưu → nút phải mờ, không tạo view rác
    12. +
    13. Xoá view đang chọn → quay về "chọn góc nhìn", nhãn đã chỉnh biến mất
    14. +
    15. + Tạo token → dán vào ô, đổi lưới lung tung, rồi + Áp token → về đúng trạng thái lúc tạo +
    16. +
    17. Sửa vài ký tự trong token rồi Áp → phải báo không đọc được, lưới giữ nguyên
    18. +
    19. + Chép link → mở link ở tab mới; trang này + không tự đọc link (xem ghi chú bên dưới) +
    20. +
    +

    + Trang này để storage: null nên không ghi vào localStorage - chạy lại là sạch. + Ứng dụng thật bỏ tuỳ chọn đó để dùng mặc định localStorage. +

    +
    + diff --git a/src/routes/worker/+page.svelte b/src/routes/worker/+page.svelte new file mode 100644 index 0000000..4ab631c --- /dev/null +++ b/src/routes/worker/+page.svelte @@ -0,0 +1,255 @@ + + + +
    +
    +

    + Worker row model - @sv5ui/datagrid +

    +

    + {COUNT.toLocaleString('en-US')} rows loaded once into a worker as columnar typed arrays. + Filtering and sorting run there and return row indices, so the thread drawing this page + never does the work. No new extension point: a worker is a server that happens to live + in the same tab. +

    +
    +
    + ← Home + +
    +
    + +
    + {#each scenarios as scenario (scenario.label)} +
    + +
    + {#if onWorker !== null} + + {/if} + + {model.rowCount?.toLocaleString('en-US') ?? '...'} + rows match + + {#if blocked !== null} + + main thread busy for + {blocked.toFixed(2)}ms + asking the question + + {/if} + {#if model.loading} + loading a block... + {/if} + {#if model.error} + {model.error} + {/if} +
    + + + +

    + The number above is what this thread spent: it posts a message and returns. The worker + answers with the row indices for the window, and the grid maps them to rows only for what it + draws. v1 does not group, and a column with an accessor, a sortFn + or a custom predicate is refused rather than read behind your back - a function does not survive + postMessage. +

    +
    diff --git a/src/routes/xlsx/+page.svelte b/src/routes/xlsx/+page.svelte new file mode 100644 index 0000000..741e4b6 --- /dev/null +++ b/src/routes/xlsx/+page.svelte @@ -0,0 +1,186 @@ + + + +
    +
    +

    XLSX có style - bàn thử

    +

    + Writer tự viết, không thư viện bảng tính. Style gom vào một bảng khử trùng lặp: + nghìn ô cùng kiểu chỉ tốn một bản ghi. +

    +
    +
    + ← Home + +
    +
    + +
    +

    Điều khiển

    +
    +
    +
    + + + +
    +

    Cần thử những gì

    +
      +
    1. Tải file rồi mở bằng Excel / Numbers / Google Sheets - không được hỏi sửa lỗi
    2. +
    3. Hàng tiêu đề nền xanh, chữ trắng đậm, canh giữa, có viền dưới
    4. +
    5. + Cột Doanh số hiện 1.234 ₫ nhưng vẫn là số: + thử SUM lên nó +
    6. +
    7. Ô âm nền hồng chữ đỏ đậm, mà vẫn giữ định dạng tiền của cột
    8. +
    9. + Cột Biên hiện phần trăm; Ngày so sánh được với + DATE() +
    10. +
    11. Tắt Có style rồi tải lại → file trơn, vẫn mở được
    12. +
    13. + Bấm Đo kích thước ở cả hai chế độ → số bản ghi style phải nhỏ (một chữ + số), không tăng theo số hàng +
    14. +
    15. Lọc hoặc sắp xếp rồi tải → file chứa đúng thứ lưới đang hiện, đúng thứ tự
    16. +
    +

    + Bật Biên là công thức → cột Biên ghi =D2/SUM($D$2:$D$41), + Excel tự tính lại khi ai đó sửa Doanh số. Chuỗi dữ liệu vẫn không bao giờ tự thành công + thức: chỉ ô nào app bọc trong {'{ formula }'} mới là công thức, còn + =1+1 gõ trong dữ liệu vẫn ra chữ. +

    +
    +
    diff --git a/src/tests/ported-demos.svelte.test.ts b/src/tests/ported-demos.svelte.test.ts new file mode 100644 index 0000000..7255efb --- /dev/null +++ b/src/tests/ported-demos.svelte.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { render } from 'vitest-browser-svelte' + +const DEMOS = [ + 'aggregate', + 'auto', + 'combo', + 'fill', + 'filter', + 'filter-types', + 'find', + 'format', + 'formula', + 'import', + 'policy', + 'range', + 'share', + 'structures', + 'views', + 'worker', + 'xlsx' +] as const + +describe.each(DEMOS)('the %s demo', (name) => { + it('mounts and draws a grid', async () => { + const page$ = (await import(`../routes/${name}/+page.svelte`)) as { default: unknown } + render(page$.default as never) + + await expect + .poll(() => document.querySelectorAll('[role="grid"], [role="treegrid"]').length, { + timeout: 5_000 + }) + .toBeGreaterThan(0) + }) +}) From ef36c34144711181e07cd54bf6f1593578600a6c Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Mon, 7 Sep 2026 00:51:33 +0700 Subject: [PATCH 15/46] fix: four defects a deep review found in the new modules A date condition compared the wrong day. The target was written with `toISOString`, which calls a `Date` at local midnight the day before anywhere west of Greenwich, while the cell it is measured against is read as a local day. An epoch number fared worse: `String(number)` is not a date any parser reads, so the condition matched nothing. Both are written from local parts now. `matches` inferred a column's kind from the single row it was asked about. The same row therefore answered differently depending on which row it was, and a blank cell made its column text, which quietly turned `gt` into a condition that filters nothing. It samples the grid's rows, as a pass does. That sample looked at the first fifty rows rather than the first fifty values, so a column whose early rows are blank was called text for the same reason. It walks up to a thousand rows for fifty values now. Replacing without regard to case indexed the original string with offsets taken from a lowercased copy. `toLowerCase` does not preserve length - Turkish dotted I becomes two code units - so every offset past one drifted and the replacement was written into the middle of a character. It goes through a pattern. And `min` and `max` walk their column instead of spreading it into an argument list. The limit is an engine's rather than the language's, and low enough on JavaScriptCore for a column of this size to reach; the throw would land in the pipeline's `$derived` and cost the render pass rather than one cell. It did not reproduce on Node or Chromium at two million, so this one is a hazard removed rather than a bug observed. Each has a test that fails without its fix. --- .../advanced-filter/advanced-filter.svelte.ts | 3 +- .../features/advanced-filter/evaluate.test.ts | 22 ++++++++----- src/lib/features/advanced-filter/kinds.ts | 33 +++++++++---------- .../advanced-filter/to-column-filter.ts | 32 ++++-------------- src/lib/features/find-replace/find.test.ts | 14 ++++++++ src/lib/features/find-replace/find.ts | 23 +++---------- src/lib/features/grouping/aggregate.test.ts | 9 +++++ src/lib/features/grouping/aggregate.ts | 13 ++++++-- 8 files changed, 76 insertions(+), 73 deletions(-) diff --git a/src/lib/features/advanced-filter/advanced-filter.svelte.ts b/src/lib/features/advanced-filter/advanced-filter.svelte.ts index f9195c3..5c4923e 100644 --- a/src/lib/features/advanced-filter/advanced-filter.svelte.ts +++ b/src/lib/features/advanced-filter/advanced-filter.svelte.ts @@ -102,7 +102,8 @@ export class AdvancedFilter { matches = (node: RowNode): boolean => { const defs = this.#defsForModel() const read = gateReader(this.#grid, 'search') - return this.#matchesWith(node, defs, read, kindsFor([node], defs, read)) + const sample = this.#grid.preWindowNodes + return this.#matchesWith(node, defs, read, kindsFor(sample, defs, read)) } #refuseOnServer(): boolean { diff --git a/src/lib/features/advanced-filter/evaluate.test.ts b/src/lib/features/advanced-filter/evaluate.test.ts index d3efa48..0305260 100644 --- a/src/lib/features/advanced-filter/evaluate.test.ts +++ b/src/lib/features/advanced-filter/evaluate.test.ts @@ -26,9 +26,6 @@ describe('one condition', () => { }) it('compares numbers as numbers, including numeric text', () => { - // The kind comes from the column, so a numeric string in a number - // column is a number. The feature works it out per column, from the - // declaration or from a sample of the data. const n = 'number' as const expect(matchesCondition(10, condition({ op: 'gt', value: 5 }), n)).toBe(true) expect(matchesCondition(10, condition({ op: 'lte', value: 10 }), n)).toBe(true) @@ -88,9 +85,6 @@ describe('one condition', () => { }) it('matches a list by the key the list was built with', () => { - // The same key a set filter's value list uses, so an entry the user - // picked out of that list is the entry a cell holding it answers to. - // A number and its text are two entries, not one. const s = 'set' as const expect(matchesCondition('a', condition({ op: 'in', values: ['a', 'b'] }), s)).toBe(true) expect(matchesCondition(2, condition({ op: 'in', values: [2] }), s)).toBe(true) @@ -115,8 +109,6 @@ describe('one condition', () => { }) it('offers no comparison a text column cannot make, and waits rather than hiding', () => { - // `gt` is not on a text column's list. Reaching it anyway is a - // half-built condition, and a half-built condition filters nothing. expect(matchesCondition('abc', condition({ op: 'gt', value: 5 }))).toBe(true) expect(matchesCondition('abc', condition({ op: 'equals', value: 'abc' }))).toBe(true) }) @@ -200,3 +192,17 @@ describe('isBlank', () => { expect([0, false, ' ', []].map(isBlank)).toEqual([false, false, false, false]) }) }) + +describe('a date condition the user did not type by hand', () => { + it('reads a Date target as the day it is locally, not the day it is in UTC', () => { + const day = new Date(2026, 2, 2) + expect(matchesCondition(day, condition({ op: 'equals', value: day }), 'date')).toBe(true) + }) + + it('reads an epoch number as a date rather than as its digits', () => { + const at = new Date(2026, 2, 2) + expect(matchesCondition(at, condition({ op: 'equals', value: at.getTime() }), 'date')).toBe( + true + ) + }) +}) diff --git a/src/lib/features/advanced-filter/kinds.ts b/src/lib/features/advanced-filter/kinds.ts index 57c47e9..60f4a9e 100644 --- a/src/lib/features/advanced-filter/kinds.ts +++ b/src/lib/features/advanced-filter/kinds.ts @@ -2,33 +2,32 @@ import type { ColumnDef, RowNode } from '../../core/types/index.js' import type { CellRead } from '../../core/grid/index.js' import { declaresKind, inferKind, kindOf, type FilterKind } from './operators.js' -/** Rows read to work out the kind of a column that declares none. */ const SAMPLE = 50 -/** - * One kind per column, worked out once for a pass. - * - * A column that says what it holds is taken at its word. One that says - * nothing is read: a sample of the rows this pass is filtering, through the - * same gate, so a masked column is guessed from what the user can see. - * - * It lives in a plain module rather than beside the state it serves because - * the `Map` here is a lookup built and thrown away within one pass, and a - * `.svelte.ts` file is held to using the reactive one. - */ +const SCAN = 1_000 + export function kindsFor( nodes: readonly RowNode[], defs: Map>, read: CellRead ): Map { const kinds = new Map() - const sample = nodes.slice(0, SAMPLE) + const scanned = nodes.slice(0, SCAN) for (const [columnId, def] of defs) { - kinds.set( - columnId, - declaresKind(def) ? kindOf(def) : inferKind(sample.map((node) => read(node, def))) - ) + if (declaresKind(def)) { + kinds.set(columnId, kindOf(def)) + continue + } + + const values: unknown[] = [] + for (const node of scanned) { + const value = read(node, def) + if (value === null || value === undefined || value === '') continue + values.push(value) + if (values.length === SAMPLE) break + } + kinds.set(columnId, inferKind(values)) } return kinds } diff --git a/src/lib/features/advanced-filter/to-column-filter.ts b/src/lib/features/advanced-filter/to-column-filter.ts index 8b7665b..1ccbff6 100644 --- a/src/lib/features/advanced-filter/to-column-filter.ts +++ b/src/lib/features/advanced-filter/to-column-filter.ts @@ -9,20 +9,6 @@ import { numericOrNull } from '../../core/utils/index.js' import type { AdvancedFilterOp, FilterCondition } from './advanced-filter.types.js' import type { FilterKind } from './operators.js' -/** - * One condition of the tree, said in the language the column filter speaks. - * - * The builder and the column filter used to compare values twice, in two - * files, kept in step by a test. There is one comparison now and this is the - * seam: everything above it is a tree, everything below it is - * `valuePredicateFor`. - * - * Presence never arrives here. `blank` and `notBlank` are answered before the - * mapping, because the column filter has no shape for them on a boolean or a - * set and the answer is the same for every kind anyway. - */ - -/** `equals` is the same word on both sides; a number spells it `eq`. */ const NUMBER_OPS: Partial> = { equals: 'eq', notEqual: 'neq', @@ -51,15 +37,17 @@ const TEXT_OPS: Partial> = { } function asIsoDay(value: unknown): string | undefined { - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10) + const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null + + if (date) { + if (Number.isNaN(date.getTime())) return undefined + const pad = (part: number, width = 2) => String(part).padStart(width, '0') + return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } return value === null || value === undefined ? undefined : String(value) } function asBooleanFilter(condition: FilterCondition): ColumnFilter | null { - // The column filter has no operator here, only the value to match, so - // `notEqual true` is `equals false`. const { op, value } = condition if (op !== 'equals' && op !== 'notEqual') return null const wanted = value === true || value === 'true' @@ -100,15 +88,7 @@ function asTextFilter(condition: FilterCondition): ColumnFilter | null { } } -/** - * A condition the column filter cannot express answers `null`, and the caller - * treats that as a test nothing fails: an operator a column does not offer is - * a half-built condition, not a filter that hides every row. - */ export function toColumnFilter(condition: FilterCondition, kind: FilterKind): ColumnFilter | null { - // Membership, whatever the column holds. `in` is the one operator that - // does not read as the column's kind: it asks whether the cell is one of - // the entries, and a set filter is exactly that question. if (condition.op === 'in') { return { kind: 'set', values: (condition.values ?? []) as SetFilterValue[] } } diff --git a/src/lib/features/find-replace/find.test.ts b/src/lib/features/find-replace/find.test.ts index 6eb4771..757b452 100644 --- a/src/lib/features/find-replace/find.test.ts +++ b/src/lib/features/find-replace/find.test.ts @@ -117,3 +117,17 @@ describe('replaceIn', () => { expect(replaceIn(undefined, 'a', 'b')).toBeNull() }) }) + +describe('replacing without regard to case', () => { + it('does not drift on a character that changes length when lowercased', () => { + expect(replaceIn('\u0130stanbul office', 'office', 'branch')).toBe('\u0130stanbul branch') + }) + + it('treats the query as text, not as a pattern', () => { + expect(replaceIn('a.b.c', '.', '-')).toBe('a-b-c') + }) + + it('leaves a dollar sign in the replacement alone', () => { + expect(replaceIn('cost: x', 'x', '$&5')).toBe('cost: $&5') + }) +}) diff --git a/src/lib/features/find-replace/find.ts b/src/lib/features/find-replace/find.ts index 44bef2f..cb434d6 100644 --- a/src/lib/features/find-replace/find.ts +++ b/src/lib/features/find-replace/find.ts @@ -36,13 +36,6 @@ export function findMatches( columns: ColumnState[], query: string, options: FindOptions & { - /** - * How a cell is read, and not optional. A find goes through the same - * gate the cell itself does: a column the grid is masking is one whose - * values the user is not being shown, and a search that matched one - * would point at a cell they cannot read and confirm what it says. - * `rawRead` is the way to say the raw value is what you meant. - */ read: CellRead isDataNode?: (node: RowNode) => boolean } @@ -83,17 +76,9 @@ export function replaceIn( return replaceInsensitive(text, query, replacement) } -function replaceInsensitive(text: string, query: string, replacement: string): string { - const lower = text.toLowerCase() - const needle = query.toLowerCase() +const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g - let out = '' - let at = 0 - for (;;) { - const found = lower.indexOf(needle, at) - if (found === -1) break - out += text.slice(at, found) + replacement - at = found + needle.length - } - return out + text.slice(at) +function replaceInsensitive(text: string, query: string, replacement: string): string { + const pattern = new RegExp(query.replace(REGEXP_SPECIAL, '\\$&'), 'gi') + return text.replace(pattern, () => replacement) } diff --git a/src/lib/features/grouping/aggregate.test.ts b/src/lib/features/grouping/aggregate.test.ts index 64c81f2..2684e43 100644 --- a/src/lib/features/grouping/aggregate.test.ts +++ b/src/lib/features/grouping/aggregate.test.ts @@ -93,3 +93,12 @@ describe('aggregate', () => { expect(joined).toBe('2/3') }) }) + +describe('a column the size this grid is built for', () => { + it('takes a minimum and a maximum without spreading the column', () => { + const many = Array.from({ length: 200_000 }, (_, index) => index) + + expect(aggregate('min', many, [])).toBe(0) + expect(aggregate('max', many, [])).toBe(199_999) + }) +}) diff --git a/src/lib/features/grouping/aggregate.ts b/src/lib/features/grouping/aggregate.ts index cb01c57..ca2547e 100644 --- a/src/lib/features/grouping/aggregate.ts +++ b/src/lib/features/grouping/aggregate.ts @@ -22,6 +22,15 @@ function distinctCount(values: unknown[]): number { return seen.size } +function extreme(numbers: number[], lowest: boolean): number { + let best = numbers[0]! + for (let index = 1; index < numbers.length; index += 1) { + const value = numbers[index]! + if (lowest ? value < best : value > best) best = value + } + return best +} + function percentileOf(numbers: number[], fraction: number): number { const sorted = [...numbers].sort((left, right) => left - right) const position = (sorted.length - 1) * Math.min(1, Math.max(0, fraction)) @@ -66,9 +75,9 @@ function aggregateNumbers( case 'sum': return numbers.reduce((total, value) => total + value, 0) case 'min': - return Math.min(...numbers) + return extreme(numbers, true) case 'max': - return Math.max(...numbers) + return extreme(numbers, false) case 'avg': return numbers.reduce((total, value) => total + value, 0) / numbers.length case 'median': From 3bf4a0bb8ae8907f29790f4a8374d6744b1cdb14 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Mon, 7 Sep 2026 00:51:50 +0700 Subject: [PATCH 16/46] refactor: keep the comments a reader hovers, drop the ones that shout Roughly three thousand lines of commentary go. What is left is what shows up in an editor: a `/** */` block sitting on an export, and the option types beside each feature. An implementation note in the middle of a function was never visible to anyone using the package, and there were far too many of them to read past. `no-comments.test.ts` holds the line, and it judges by shape rather than by folder: a doc block on an export is documentation, anything else is not. A suppression stays, because `eslint-disable` and `svelte-ignore` are instructions to a tool rather than notes to a person. Four `catch` blocks lost their only content and became empty, which is the idiom here - each has its fallback on the next line - so `no-empty` allows one. The documents catch up with the code in the same pass. `CHANGELOG.md` describes this release by what it does. It names no second package, because none was ever published, and telling everyone that something they never had is gone explains nothing. One line in the 0.1.0 entry pointed at that package and now points at nothing instead. `MIGRATING.md` is written for the people who actually have to act: those on 1.x. Three things break - the label table grew, a blank passes `neq` on a number, and the loading skeleton draws cells - and each has the diff that fixes it. `README.md` was still describing nine feature areas. Someone reading the front door had no way to learn that sixteen modules exist. The tables cover them now, and `component` joins the extension points. The last traces go with them: the release script no longer tells anyone to update a second repository, the issue template stops promising that grouping and xlsx are somewhere else, a storage key drops a word from its name, and three demos stop dressing an old product split as sample data. --- .github/ISSUE_TEMPLATE/feature_request.yml | 10 +- CHANGELOG.md | 103 +++++++++++- MIGRATING.md | 74 +++++++++ README.md | 68 ++++++-- eslint.config.js | 1 + scripts/release.mjs | 3 - src/app.d.ts | 10 +- src/benchmarks/budgets.test.ts | 40 ----- src/benchmarks/data.ts | 1 - src/benchmarks/server-model.bench.ts | 5 - .../components/cells/GridCellEditor.svelte | 40 ----- src/lib/components/cells/GridCellValue.svelte | 3 - .../components/cells/GridFilterCell.svelte | 61 ------- .../components/cells/GridRowHandleCell.svelte | 2 - .../components/cells/GridSelectionCell.svelte | 13 -- .../chrome/GridColumnChooser.svelte | 4 - .../chrome/GridDensityToggle.svelte | 2 - .../components/chrome/GridExportMenu.svelte | 10 -- .../components/chrome/GridFilterChips.svelte | 1 - .../components/chrome/GridPagination.svelte | 2 - .../components/chrome/GridQuickFilter.svelte | 7 - .../components/chrome/GridStatusBar.svelte | 1 - src/lib/components/chrome/index.ts | 8 - src/lib/components/datagrid.config.ts | 6 - src/lib/components/datagrid.variants.test.ts | 9 - src/lib/components/grid/DataGrid.svelte | 4 - src/lib/components/grid/GridBody.svelte | 89 +--------- src/lib/components/grid/GridFilterRow.svelte | 20 --- src/lib/components/grid/GridHeader.svelte | 98 +---------- src/lib/components/grid/GridRoot.svelte | 9 - .../grid/GridStatePersistence.svelte | 13 +- src/lib/components/grid/GridViewport.svelte | 54 ------ src/lib/components/grid/index.ts | 6 - src/lib/components/index.ts | 14 -- src/lib/components/internal/context.ts | 10 -- .../components/internal/editor-values.test.ts | 8 - src/lib/components/internal/editor-values.ts | 5 - src/lib/components/internal/focus.ts | 8 - src/lib/components/internal/icons.test.ts | 11 -- src/lib/components/internal/index.ts | 10 -- src/lib/components/internal/row-drag.ts | 45 +---- src/lib/components/internal/window.ts | 56 ------- .../components/menus/GridColumnMenu.svelte | 7 - .../components/menus/GridContextMenu.svelte | 1 - .../menus/GridFilterCondition.svelte | 3 - .../components/menus/GridFilterPanel.svelte | 13 -- src/lib/components/menus/index.ts | 6 - src/lib/components/parts.ts | 1 - src/lib/core/columns/auto-columns.test.ts | 2 - src/lib/core/columns/auto-columns.ts | 6 - src/lib/core/columns/col-span.test.ts | 1 - src/lib/core/columns/col-span.ts | 3 - src/lib/core/columns/column-collapse.test.ts | 10 -- src/lib/core/columns/column-model.svelte.ts | 38 ----- src/lib/core/columns/column-order.test.ts | 3 - src/lib/core/columns/column-order.ts | Bin 2040 -> 1898 bytes src/lib/core/columns/column-rail.test.ts | 6 - src/lib/core/columns/column-sizing.test.ts | 3 - src/lib/core/columns/column-sizing.ts | 14 -- src/lib/core/columns/header-groups.test.ts | 6 - src/lib/core/columns/header-groups.ts | 45 ----- src/lib/core/columns/index.ts | 10 -- src/lib/core/columns/row-span.test.ts | 7 - src/lib/core/columns/row-span.ts | 3 - src/lib/core/grid/events.ts | 2 - src/lib/core/grid/grid.svelte.ts | 56 ------- src/lib/core/grid/index.ts | 8 - src/lib/core/grid/row-node.ts | 18 -- src/lib/core/grid/share-link.test.ts | 3 - src/lib/core/grid/share-link.ts | 17 -- src/lib/core/grid/snapshot.test.ts | 1 - src/lib/core/grid/snapshot.ts | 30 ---- src/lib/core/grid/value-gate.test.ts | 5 - src/lib/core/grid/value-gate.ts | 19 --- src/lib/core/index.ts | 13 -- src/lib/core/interaction/announcer.svelte.ts | 7 - .../core/interaction/focus-model.svelte.ts | 57 ------- src/lib/core/interaction/focus-model.test.ts | 14 -- src/lib/core/interaction/index.ts | 5 - src/lib/core/interaction/labels.ts | 2 - src/lib/core/interaction/locale.test.ts | 3 - src/lib/core/interaction/locale.ts | 9 - src/lib/core/theme/config.ts | 2 - src/lib/core/theme/index.ts | 2 - src/lib/core/theme/slots.ts | 47 ------ src/lib/core/utils/format.test.ts | 5 - src/lib/core/utils/format.ts | 18 -- src/lib/core/utils/index.ts | 9 - src/lib/core/utils/number.ts | 15 -- src/lib/core/utils/popup.ts | 5 - src/lib/core/utils/raf-batch.ts | 2 - src/lib/core/utils/style.test.ts | 1 - src/lib/core/utils/url.ts | 3 - .../core/virtual/column-virtualizer.svelte.ts | 15 -- src/lib/core/virtual/index.ts | 5 - src/lib/core/virtual/virtualizer.svelte.ts | 20 --- src/lib/core/virtual/virtualizer.test.ts | 16 -- src/lib/features/advanced-filter/evaluate.ts | 7 - .../advanced-filter/filter-parity.test.ts | 4 - src/lib/features/advanced-filter/operators.ts | 10 -- .../features/column-ops/column-ops.svelte.ts | 23 --- .../conditional-formatting/stats.test.ts | 1 - .../features/conditional-formatting/stats.ts | 6 - .../data-import/data-import.svelte.ts | 4 +- .../features/data-import/data-import.types.ts | 2 +- src/lib/features/editing/edit-batch.ts | 4 - src/lib/features/editing/editing.svelte.ts | 67 -------- src/lib/features/editing/editing.test.ts | 5 - src/lib/features/editing/paste.test.ts | 2 - src/lib/features/editing/undo-stack.ts | 2 - src/lib/features/editing/validate.ts | 2 - src/lib/features/filtering/distinct-values.ts | 12 -- .../features/filtering/filter-draft.test.ts | 3 - src/lib/features/filtering/filter-draft.ts | 2 - .../filtering/filter-predicates.test.ts | 8 - .../features/filtering/filter-predicates.ts | 37 ----- .../filtering/filter-sanitize.test.ts | 5 - src/lib/features/filtering/filter-sanitize.ts | 20 --- .../features/filtering/filter-units.test.ts | 1 - src/lib/features/filtering/filter-units.ts | 1 - .../filtering/filtering.svelte.test.ts | 2 - .../features/filtering/filtering.svelte.ts | 19 --- .../filtering/floating-filter.test.ts | 5 - src/lib/features/filtering/floating-filter.ts | 20 --- src/lib/features/filtering/quick-filter.ts | 19 --- src/lib/features/index.ts | 13 -- .../pagination/pagination.svelte.test.ts | 3 - .../features/pagination/pagination.svelte.ts | 11 -- .../features/pagination/pagination.test.ts | 1 - .../range-selection/RangeLayer.svelte | 5 - .../row-pinning/row-pinning.svelte.ts | 2 - .../row-reorder/row-reorder.svelte.ts | 14 -- src/lib/features/selection/clipboard.test.ts | 1 - src/lib/features/selection/clipboard.ts | 21 --- src/lib/features/selection/selection-set.ts | 2 - .../features/selection/selection.svelte.ts | 14 -- src/lib/features/selection/selection.test.ts | 4 - .../server-row-model/ServerRows.svelte | 7 - .../server-row-model.svelte.ts | 1 - src/lib/features/setters.svelte.test.ts | 5 - src/lib/features/sorting/cycle.test.ts | 1 - src/lib/features/sorting/sort-model.test.ts | 9 - src/lib/features/sorting/sort.test.ts | 2 - src/lib/features/sorting/sort.ts | 28 ---- src/lib/features/sorting/sorting.svelte.ts | 6 - .../virtualization/virtualization.svelte.ts | 7 - src/lib/features/worker-row-model/query.ts | 3 - src/lib/features/xlsx/index.ts | 12 -- src/lib/index.ts | 11 -- src/lib/locales/de-DE.ts | 1 - src/lib/locales/en-US.ts | 5 - src/lib/locales/es-ES.ts | 2 - src/lib/locales/fr-FR.ts | 2 - src/lib/locales/id-ID.ts | 1 - src/lib/locales/index.ts | 6 - src/lib/locales/ja-JP.ts | 1 - src/lib/locales/ko-KR.ts | 1 - src/lib/locales/locales.test.ts | 38 ----- src/lib/locales/pt-BR.ts | 2 - src/lib/locales/ru-RU.ts | 4 - src/lib/locales/th-TH.ts | 1 - src/lib/locales/vi-VN.ts | 1 - src/lib/locales/zh-CN.ts | 1 - src/lib/xlsx.ts | 9 - src/routes/columns/+page.svelte | 10 -- src/routes/columns/+page.ts | 5 - src/routes/editing/+page.svelte | 1 - src/routes/editors/+page.svelte | 19 --- src/routes/export/+page.svelte | 7 - src/routes/filters/+page.svelte | 2 - src/routes/groups/+page.svelte | 16 -- src/routes/groups/cases/+page.svelte | 16 -- src/routes/groups/cases/CaseCard.svelte | 2 - src/routes/headless/+page.svelte | 6 - src/routes/i18n/+page.svelte | 6 - src/routes/persistence/+page.svelte | 4 - src/routes/persistence/+page.ts | 5 - src/routes/qa/+page.svelte | 5 - src/routes/renderers/+page.svelte | 4 - src/routes/reorder/+page.svelte | 6 - src/routes/server/+page.svelte | 15 -- src/routes/server/big/+page.svelte | 16 -- src/routes/server/infinite/+page.svelte | 11 -- src/routes/spans/+page.svelte | 5 - src/routes/stress/+page.svelte | 9 +- src/routes/theming/+page.svelte | 6 - src/routes/value-gate/+page.svelte | 18 -- src/tests/api-types.test-d.ts | 14 -- src/tests/auto-row-height.svelte.test.ts | 5 - src/tests/bundle-shape.test.ts | 15 -- src/tests/cell-decoration.svelte.test.ts | 5 - src/tests/cell-formatting.svelte.test.ts | 9 - src/tests/cell-types.svelte.test.ts | 2 - src/tests/col-span.svelte.test.ts | 7 - src/tests/column-extras.svelte.test.ts | 10 -- src/tests/column-groups.svelte.test.ts | 49 ------ src/tests/column-ops.svelte.test.ts | 6 - src/tests/column-rail-cases.svelte.test.ts | 44 ----- src/tests/column-rail.svelte.test.ts | 36 ---- .../column-virtualization.svelte.test.ts | 8 - .../column-width-snapshot.svelte.test.ts | 8 - .../conditional-formatting.svelte.test.ts | 1 - src/tests/data-shapes-features.test.ts | 6 - src/tests/data-shapes.test.ts | 26 --- src/tests/data-volume.test.ts | 20 --- src/tests/demos.svelte.test.ts | 12 -- src/tests/editing.svelte.test.ts | 59 ------- src/tests/export-menu.svelte.test.ts | 11 -- src/tests/feature-layers.svelte.test.ts | 11 -- src/tests/filter-builder.svelte.test.ts | 1 - src/tests/filter-row.svelte.test.ts | 74 +-------- src/tests/filters.svelte.test.ts | 40 ----- src/tests/find-replace.svelte.test.ts | 1 - src/tests/fixtures/AllIcons.svelte | 1 - src/tests/fixtures/EveryIconGrid.svelte | 3 - src/tests/fixtures/FormattedCells.svelte | 1 - src/tests/fixtures/GroupHeaderGrid.svelte | 1 - src/tests/fixtures/InRoot.svelte | 9 - src/tests/fixtures/in-root.ts | 9 - src/tests/focus-order.svelte.test.ts | 7 - src/tests/format-panel.svelte.test.ts | 7 - src/tests/grouping.svelte.test.ts | 1 - src/tests/header-cell.svelte.test.ts | 11 -- src/tests/horizontal-overflow.svelte.test.ts | 2 - src/tests/i18n.svelte.test.ts | 7 - src/tests/icons.svelte.test.ts | 21 --- src/tests/import-wizard.svelte.test.ts | 1 - src/tests/keyboard.svelte.test.ts | 14 -- src/tests/labels.svelte.test.ts | 9 - src/tests/layout-overflow.svelte.test.ts | 8 - src/tests/no-comments.test.ts | 154 ++++++++++++++++++ src/tests/pagination-footer.svelte.test.ts | 8 - src/tests/persist-routes.test.ts | 4 - src/tests/persistence.svelte.test.ts | 2 - src/tests/polish.svelte.test.ts | 10 -- src/tests/public-api.test.ts | 28 ---- src/tests/public-usage.svelte.test.ts | 8 - src/tests/public-usage.test.ts | 3 - src/tests/qa-demos.svelte.test.ts | 21 --- src/tests/range-selection.svelte.test.ts | 3 - src/tests/row-reorder.svelte.test.ts | 34 ---- src/tests/row-span.svelte.test.ts | 47 ------ src/tests/rows.svelte.test.ts | 5 - src/tests/selection.svelte.test.ts | 10 -- src/tests/server-async.test.ts | 16 -- src/tests/server-contract-ops.test.ts | 26 --- src/tests/server-contract.test.ts | 20 --- src/tests/server-demos.svelte.test.ts | 15 -- src/tests/server-model.test.ts | 2 - src/tests/server-row-model.svelte.test.ts | 4 - src/tests/ssr-render.test.ts | 6 - src/tests/theming.svelte.test.ts | 9 - src/tests/tool-panel.svelte.test.ts | 1 - src/tests/typography.test.ts | 13 +- src/tests/value-gate.svelte.test.ts | 27 --- src/tests/value-gate.test.ts | 36 ---- 256 files changed, 403 insertions(+), 3012 deletions(-) create mode 100644 MIGRATING.md create mode 100644 src/tests/no-comments.test.ts diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0f7828d..f4af49e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,12 +8,10 @@ body: value: | Thanks for the suggestion. Please search existing issues first. - Row grouping, a tree data model, master and detail rows, range selection, - aggregation and xlsx export are deliberately not in this package; they are - planned for a separate pro package. The kernel already renders nested rows as a - `treegrid`, and infinite scroll is a shape this package supports today — - `rowModel: 'server'` with `virtualization()`, appending as the window nears the - end of what is loaded. See `/server/infinite` in the playground. + Row grouping, tree data, master and detail rows, range selection, + aggregation and xlsx export all ship here. Register the feature module + you need and check the playground route for it before opening an issue: + a request that is already answered costs you a wait and us a reply. - type: textarea id: problem diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1495c..18ec225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +The largest release the grid has had. Sixteen feature modules, nine parts to +draw them with, and a second entry point for writing spreadsheets. Everything +here is opt-in the way the nine before it were: a feature you do not register +is code your bundle never sees, and there is now a test that builds a real +entry to prove it. + +[Upgrading from 1.x](MIGRATING.md) is two behaviour changes and one type; the +rest is additive. + +### Added + +- **Grouping.** `grouping()` groups by any number of columns and aggregates + with thirteen functions, including `percentile` and `weightedAvg`. Group + footers, a grand total, and a `Grid.GroupPanel` to drive it. +- **Show values as.** `showValuesAs()` reads a number as a share of the grand + total, of its parent group, or of its row. It replaces the value, so sorting, + copying and exporting all agree with what is on screen. +- **Tree data and master/detail.** `tree()` takes either nested children or a + parent id, and lifts an orphan to the root rather than dropping it. + `masterDetail()` opens a panel under any row. +- **Calculated columns.** `formula()` compiles an expression to a tree and + walks it - no `eval` - with 31 functions and a dependency graph that refuses + a cycle. The expression is a string, so it survives a saved view and a link. +- **A filter builder.** `advancedFilter()` holds a nested `(A AND B) OR C`, + with the operators each column type can answer, and narrows what the column + filters already narrowed rather than replacing them. `Grid.FilterBuilder` + draws it. +- **Cell ranges.** `rangeSelection()` brings Excel-style ranges: several at + once with Ctrl, a fill handle that reads a series out of numbers, dates or + text, writing a whole range with `Ctrl+Enter`, cut and move, a clipboard that + carries both plain text and HTML, and `Grid.RangeStatusBar`. +- **Find and replace.** `findReplace()` searches the rows a filter left, + honours case and whole-cell, and counts what it can write apart from what it + found. +- **Conditional formatting.** `conditionalFormatting()` paints colour scales, + data bars, duplicates, top N and expression rules. Colours mix through + `color-mix`, so a theme token works, and the rules export into a workbook as + Excel's own. +- **An import wizard.** `dataImport()` reads CSV, TSV, XLSX and the clipboard, + guesses types across locales, remembers a column mapping per grid, validates + through each column's schema, and stages the rows in the grid itself so they + can be fixed in place before they are committed. +- **Saved views and shareable links.** `savedViews()` keeps named views in + `localStorage` or storage of your own, and packs a whole grid state into a + URL. +- **Value masking.** `policy()` masks a column, a row or a cell with `hide`, + `redact`, `last4`, `email`, `initials` or a function of your own, and applies + it at render, export, clipboard, search, facet and edit. +- **A server row model.** `serverRowModel()` fetches in pages or in blocks as + the viewport moves, with placeholders and a `fetchAll` for exporting the + whole set. +- **A worker row model.** `workerDataSource()` loads a columnar copy into a + worker, filters and sorts on indices, and falls back to the main thread for a + column a worker cannot see. +- **A command palette.** `commandPalette()` gathers what the registered + features offer behind `Ctrl/Cmd+K`. +- **XLSX, without a dependency.** `@sv5ui/datagrid/xlsx` writes a real workbook: + several sheets, styles, Excel's own conditional formatting rules, formula + cells, and 200k rows for 26 MB of heap because it writes in chunks. +- **`GridFeature.component`**, a component the grid mounts inside its root, for + the work a feature can only do from inside the render tree: an effect, a + listener on the grid's element, a layer over the rows. Nothing imports it, so + a feature nobody registered is a component nobody bundles. +- **`GridState.status`**, which a feature that owns the rows sets so the body + can draw a fetch in progress without naming the feature. +- `notEqual` on a date column, in `DateFilterOp` and in all twelve languages. +- `autoColumns`, which reads a `ColumnDef[]` off the data. +- 147 more label keys (67 to 214) and 66 more slots (73 to 139), in all twelve + languages, and seventeen demo routes. + +### Changed + +- **Breaking.** `DataGridLabels` gains 147 required members. An application + passing its own complete table as `mergeLabels`' base no longer compiles; + `DataGridLabelsInput`, which is what an application normally hands the grid, + is unaffected. +- **Breaking.** `neq` on a number column now keeps a blank cell, the way + `notEqual` on a text column always has. A cell with no number in it is not + the number being excluded. The two disagreed because one guard was applied to + every numeric comparator at once. +- A row of skeletons drawn while loading now gives its cells `role="gridcell"`. + A `row` owning none is a row a screen reader cannot read. A test counting + gridcells during a load will see them now. +- The footer's page range reads `1-25 of 300` in every language. It was an en + dash, which is not a character a keyboard has. + +### Fixed + +- A server grid no longer says "no data" while its first request is still out. +- `gateReader` composes a column's readers once per pass rather than once per + cell: 15,000 compositions became three over 5,000 rows and three columns. +- A snapshot carrying a `Date` survives a share link. The canonical form + rebuilt every object from its entries, which turns a `Date` into `{}`, so a + link and `localStorage` disagreed about the same slice. +- `autoColumns` no longer reads a column of `1234-56-78` part numbers as dates, + which typed the column `date` and then drew every cell blank. +- Aggregating `min` or `max` walks the column instead of spreading it into an + argument list, which has an engine limit a grid this size can reach. +- Replacing without regard to case no longer corrupts text around a character + that changes length when lowercased. + ## [1.3.1] - 2026-09-03 ### Fixed @@ -898,7 +999,7 @@ full table. - **Row reorder rewrites `data`**, so an active sort re-sorts it immediately: clear the sort before offering the grip. - **Server row model covers filter, sort and paging.** Grouping, tree data and - infinite scroll belong to `@sv5ui/datagrid-pro`. + infinite scroll are not in this release. ### Development diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..9b5c530 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,74 @@ +# Upgrading to 2.0 + +Almost everything in this release is additive: register a feature you did not +have before and it works. Three things are not, and this is all of them. + +## `DataGridLabels` gained 147 members + +The new features brought their own wording, and the label table is one table. +If you hand the grid a subset, which is the usual way, nothing changes: + +```ts +createDataGrid({ labels: { search: 'Find...' }, columns, data, getRowId }) +``` + +If you built a complete `DataGridLabels` of your own and passed it as the base +of `mergeLabels`, it no longer type-checks, because it is now missing 147 +members. Merge onto the built-in table instead: + +```diff +-mergeLabels(overrides, myCompleteTable) ++mergeLabels({ ...myCompleteTable, ...overrides }) +``` + +## A blank cell now passes `neq` on a number + +A text column has always kept a blank on `notEqual`: a cell with no word in it +is not the word being excluded. A number column dropped it, from one guard +applied to every numeric comparator at once. They agree now, and the text +reading is the one that won. + +If you were relying on `neq` to exclude blanks, say so: + +```diff + setColumnFilter('score', { +- kind: 'number', op: 'neq', value: 5 ++ kind: 'group', ++ join: 'and', ++ conditions: [ ++ { kind: 'number', op: 'neq', value: 5 }, ++ { kind: 'number', op: 'notBlank' } ++ ] + }) +``` + +The server contract's reference implementation in +`src/tests/server-contract-ops.test.ts` shows the same rule, so a server you +wrote against it needs the same change. + +## The loading skeleton draws cells + +A row of skeletons used to be a `role="row"` around plain divs, which a screen +reader cannot read and axe reports. Its cells now carry `role="gridcell"`. + +A test asserting that no `gridcell` exists while loading will fail. Assert on +what it meant instead - that no data is on screen: + +```diff +-expect(container.querySelectorAll('[role="gridcell"]')).toHaveLength(0) ++const cells = [...container.querySelectorAll('[role="gridcell"]')] ++expect(cells.every((cell) => cell.textContent?.trim() === '')).toBe(true) +``` + +## Worth knowing, but not breaking + +`DateFilterOp` gained `notEqual`. A `switch` of your own over that union is no +longer exhaustive, which TypeScript will point out if you asked it to. + +The footer's page range separates the two numbers with a hyphen; it used to use +an en dash. If you snapshot that string, it changed. + +A grid's language is set per grid, through `locales` and `locale` on +`createDataGrid`. That has not changed, but it is worth repeating now that +there is far more wording to translate: two grids on one page may speak +different languages, and nothing global has to be put back afterwards. diff --git a/README.md b/README.md index 5fd67e8..f1e1414 100644 --- a/README.md +++ b/README.md @@ -58,21 +58,36 @@ you use, and nothing else reaches your bundle. ## Features -| Area | What you get | -| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| **Rows** | Row and column virtualization past a million rows, fixed or per-row heights, `'auto'` measured rows, pinned rows, full-width rows | -| **Columns** | Resize, reorder, pin left/right, hide, nested header groups that fold, autosize, `colSpan` and `rowSpan` | -| **Sorting** | Multi-sort with priority badges, per-type comparators, null ordering, `sortFn`, `sortField` | -| **Filtering** | Quick filter plus text, number, date, set and boolean column filters, two conditions per column, a filter row under the header, chips | -| **Selection** | Single or multi, checkbox column, select-all, Shift-range, TSV copy, CSV export | -| **Editing** | Cell and row editing with ten sv5ui editors, schema validation, transactions, undo/redo, clipboard paste | -| **Reordering** | Pointer and keyboard row reorder with an auto-scrolling drag preview | -| **Persistence** | Versioned JSON snapshots, `localStorage` auto-sync, `migrate` hook | -| **Localization** | Twelve languages, chosen from the page's own; number and date formatting follow | -| **Accessibility** | ARIA `grid` and `treegrid`, one tab stop, full keyboard navigation, axe-clean | -| **Server** | `rowModel: 'server'` with normalized filter and sort requests | -| Features are opt-in. A feature you do not register is never imported, so its | -| code stays out of your bundle. | +| Area | What you get | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **Rows** | Row and column virtualization past a million rows, fixed or per-row heights, `'auto'` measured rows, pinned rows, full-width rows | +| **Columns** | Resize, reorder, pin left/right, hide, nested header groups that fold, autosize, `colSpan` and `rowSpan` | +| **Sorting** | Multi-sort with priority badges, per-type comparators, null ordering, `sortFn`, `sortField` | +| **Filtering** | Quick filter plus text, number, date, set and boolean column filters, two conditions per column, a filter row under the header, chips | +| **Selection** | Single or multi, checkbox column, select-all, Shift-range, TSV copy, CSV export | +| **Editing** | Cell and row editing with ten sv5ui editors, schema validation, transactions, undo/redo, clipboard paste | +| **Reordering** | Pointer and keyboard row reorder with an auto-scrolling drag preview | +| **Persistence** | Versioned JSON snapshots, `localStorage` auto-sync, `migrate` hook | +| **Localization** | Twelve languages, chosen from the page's own; number and date formatting follow | +| **Accessibility** | ARIA `grid` and `treegrid`, one tab stop, full keyboard navigation, axe-clean | +| **Server** | `rowModel: 'server'` with normalized filter and sort requests, paged or infinite blocks | +| **Grouping** | Group by any number of columns, thirteen aggregators, group footers, a grand total, and values read as a share of one of them | +| **Structure** | Tree data from `getChildren` or `getParentId`, and a detail panel under any row | +| **Formulas** | Calculated columns from an expression, 31 functions, a dependency graph that catches a cycle, and no `eval` | +| **Ranges** | Excel-style cell ranges, multiple selections, a fill handle that reads a series, cut and move, and a summary bar | +| **Finding** | Find and replace across the rows a filter left, whole-cell and case options | +| **Conditions** | Colour scales, data bars, duplicates, top N and expression rules, exported into the workbook as real Excel rules | +| **Filter builder** | Nested `(A AND B) OR C`, operators per column type, alongside the column filters rather than instead of them | +| **Import** | CSV, TSV, XLSX and clipboard, type guessing, column mapping, validation, and rows staged in the grid to fix in place | +| **Export** | CSV, and XLSX written without a dependency, on its own entry at `@sv5ui/datagrid/xlsx` | +| **Views** | Named views in `localStorage` or storage of your own, and a link that carries the whole state | +| **Policy** | Column, row and cell masking applied at render, export, clipboard, search, facet and edit | +| **Off the main thread** | A worker row model that filters and sorts a columnar copy, with a main-thread fallback | +| **Command palette** | `Ctrl/Cmd+K` over the commands the registered features offer | + +Features are opt-in. A feature you do not register is never imported, so its +code stays out of your bundle, and `src/tests/bundle-shape.test.ts` builds a +real entry to prove it. ## Installation @@ -219,6 +234,28 @@ registered. | `rowPinning()` | Rows pinned to the top or bottom | | `rowReorder()` | Drag grip and keyboard reorder | +| Feature | Adds | +| ------------------------- | -------------------------------------------------------------- | +| `grouping()` | Group by N columns, thirteen aggregators, footers, grand total | +| `tree()` | Nested or flat parent/child data | +| `masterDetail()` | A detail panel under a row | +| `showValuesAs()` | A number read as a share of a total, a parent or a row | +| `formula()` | Calculated columns from an expression | +| `advancedFilter()` | A nested condition tree beside the column filters | +| `rangeSelection()` | Cell ranges, fill handle, cut and move, clipboard | +| `findReplace()` | Find and replace across the rows a filter left | +| `conditionalFormatting()` | Colour scales, data bars, duplicates, top N, expressions | +| `dataImport()` | CSV, TSV, XLSX and clipboard, staged in the grid | +| `savedViews()` | Named views and a link that carries the whole state | +| `policy()` | Masking at render, export, clipboard, search, facet and edit | +| `serverRowModel()` | Paged or infinite blocks fetched from a source | +| `workerDataSource()` | The same, filtered and sorted off the main thread | +| `commandPalette()` | `Ctrl/Cmd+K` over what the registered features offer | + +The workbook writer is not a feature: `buildGridXlsx` and `createWorkbook` are +functions, on their own entry at `@sv5ui/datagrid/xlsx`, because writing a +spreadsheet involves no grid. + Call a factory inside the `features` array, as above, and `TRow` is inferred from the array's own type. A factory held in a variable first has nothing to infer from and resolves to `GridFeature`, so spell the argument out @@ -288,6 +325,7 @@ available to yours. | `menuItems` | column and context menu entries | | `cellDecoration` | per-cell classes, inline style and `aria-selected` | | `cellValue` | stands between a cell's value and every way it leaves the grid | +| `component` | a component the grid mounts in its root, for effects and the DOM | | `serialize` | the feature's slice of a state snapshot | | `hydrate` | restores what `serialize` produced | diff --git a/eslint.config.js b/eslint.config.js index 3abdc96..c81d369 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,7 @@ export default defineConfig( quotes: ['error', 'single', { avoidEscape: true }], 'comma-dangle': ['error', 'never'], eqeqeq: ['error', 'always'], + 'no-empty': ['error', { allowEmptyCatch: true }], 'no-console': 'warn', 'no-unused-vars': 'off', // A leading underscore is how this codebase says a binding is diff --git a/scripts/release.mjs b/scripts/release.mjs index ba1b284..8330c6b 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -356,6 +356,3 @@ try { } console.log(`\n\x1b[32m${pkg().name}@${version} published.\x1b[0m\n`) -console.log('Consumers install a tarball, not the registry. To update the Pro repo:') -console.log(' cd ../sv5ui-datagrid-pro && pnpm sync:community') -console.log(` then point devDependencies at vendor/sv5ui-datagrid-${version}.tgz\n`) diff --git a/src/app.d.ts b/src/app.d.ts index 530b01a..ada0f46 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,13 +1,5 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App {} } export {} diff --git a/src/benchmarks/budgets.test.ts b/src/benchmarks/budgets.test.ts index e4f744e..489a5ae 100644 --- a/src/benchmarks/budgets.test.ts +++ b/src/benchmarks/budgets.test.ts @@ -17,13 +17,8 @@ const benchColumnStates = benchColumns.map((def) => createColumnState(def)) const SAMPLES = 3 -/** - * What a gated column costs the passes that read whole columns. One reader, - * reused, which is the shape a feature is asked to hand back. - */ const maskReader = () => '***' -/** A grid on one page of a backend it never sees the rest of. */ function serverGrid(pageSize: number, rowCount: number): GridState { const grid = createDataGrid({ columns: benchColumns, @@ -36,7 +31,6 @@ function serverGrid(pageSize: number, rowCount: number): GridState { return grid } -/** Swapping `data` and reading the output is the whole of a page turn. */ function turnPages(grid: GridState, count: number, pageSize: number): void { for (let page = 1; page <= count; page++) { grid.data = serverPageOf(page, pageSize) @@ -44,21 +38,8 @@ function turnPages(grid: GridState, count: number, pageSize: number): } } -/** - * `measure` runs an operation SAMPLES + 1 times, so a test here costs several - * times what it asserts. On the CI runner a multi-sort of 100k rows measures - * about 1.2s, which is four seconds of work against vitest's 5s default and - * timed the suite out even with the ceilings sized correctly. A benchmark is - * not a unit test and should not borrow its timeout. - */ const BUDGET_TIMEOUT = 60_000 -/** - * The fastest of several samples, after a warm-up. A genuine regression slows - * every sample, so the ceiling still catches it; background load on the - * machine only slows some, which is what made a single-sample measurement - * fail this suite intermittently. - */ function measure(run: () => void): number { run() @@ -71,20 +52,6 @@ function measure(run: () => void): number { return best } -/** - * Every ceiling here is sized for the slowest machine the suite runs on, which - * is the two-core CI runner rather than a development machine. The runner - * measures about 3.7x slower: a string sort of 100k rows takes 325ms locally - * and 1172ms there. A ceiling calibrated locally therefore passes for whoever - * writes it and fails for everyone else, which is what these two did. - * - * The point is to catch a regression, not to state a performance target, so - * each ceiling is roughly double what the runner already measures. Anything - * that slows an operation by half again is caught; a fast machine reporting a - * number far under the ceiling is expected, not slack to be tightened up. - * PLAN section 8 holds the real targets. - */ - describe( 'performance budgets (coarse regression ceilings; PLAN §8 targets are stricter)', { timeout: BUDGET_TIMEOUT }, @@ -179,13 +146,6 @@ describe( expect(elapsed).toBeLessThan(150) }) - /** - * A server model holds one page, so a page turn should cost what the - * page costs and nothing for the set behind it. The two backends here - * differ by a factor of a hundred and are measured against the same - * ceiling on purpose: if the set ever starts to count, only the second - * one breaks. - */ it('turns 200 pages of 50 rows within budget, whatever the backend holds', () => { const small = serverGrid(50, 100_000) const huge = serverGrid(50, 10_000_000) diff --git a/src/benchmarks/data.ts b/src/benchmarks/data.ts index 78e59e9..12c0bb7 100644 --- a/src/benchmarks/data.ts +++ b/src/benchmarks/data.ts @@ -30,7 +30,6 @@ export function makeBenchNodes(count: number): RowNode[] { return buildRowNodes(makeBenchRows(count), (row) => String(row.id)) } -/** One row of a backend generated on demand: its size costs nothing unasked. */ export function serverRowAt(index: number): BenchRow { return { id: index + 1, diff --git a/src/benchmarks/server-model.bench.ts b/src/benchmarks/server-model.bench.ts index b839fca..a5af042 100644 --- a/src/benchmarks/server-model.bench.ts +++ b/src/benchmarks/server-model.bench.ts @@ -18,11 +18,6 @@ function serverGrid(pageSize: number, rowCount: number) { return grid } -/** - * What a page turn costs the grid under a server model: swap `data`, then read - * the pipeline output the renderer will iterate. The size of the set behind it - * is the variable being held up against the page size - one of them matters. - */ describe('server row model - a page turn', () => { const page50of100k = serverGrid(50, 100_000) const page50of10m = serverGrid(50, 10_000_000) diff --git a/src/lib/components/cells/GridCellEditor.svelte b/src/lib/components/cells/GridCellEditor.svelte index cd525a9..3e3261e 100644 --- a/src/lib/components/cells/GridCellEditor.svelte +++ b/src/lib/components/cells/GridCellEditor.svelte @@ -34,10 +34,6 @@ node: RowNode column: ColumnState rowMode?: boolean - /** True for the first editable column of a row edit. It takes the caret - * - every editor mounts at once, and without this the last to run ends - * up with it - and it is the one field the row's ring already fences, - * so it draws no divider of its own. */ first?: boolean } = $props() @@ -60,8 +56,6 @@ return Number.isNaN(parsed) ? null : parsed }) - // Commit on leaving, not per change: date/time are typed segment by - // segment and would close the editor mid-entry. const inputBased = $derived( type === 'text' || type === 'number' || @@ -70,18 +64,10 @@ type === 'time' || type === 'date' ) - // Where Enter commits rather than belonging to the widget. const enterCommits = $derived( type === 'text' || type === 'number' || type === 'time' || type === 'date' ) - // Focus the first segment so the user can type instead of picking. const segmented = $derived(type === 'date' || type === 'time') - /** - * Opening the editor is the choice; the list is what the user came for, so - * it is already down rather than waiting for a second key. Only the editor - * taking the caret opens: in a row edit, every list at once buries the rows - * below. - */ const listEditor = $derived(type === 'select' || type === 'selectMenu') let listOpen = $state(false) $effect(() => { @@ -94,11 +80,6 @@ const containerClass = $derived( [ slots.cellEditor({ class: theme('cellEditor') }), - // Widget editors take the same surface as the text ones in a row - // edit, or they read as gaps in it. What they do not take is the - // focus mark: a Select or a date field draws its own border and - // its own focus state, and a second one around it is the third - // line in the same corner. rowMode ? flatText ? slots.cellEditorInRow({ class: theme('cellEditorInRow') }) @@ -117,7 +98,6 @@ ) const fieldClass = $derived(slots.cellEditorField({ class: theme('cellEditorField') })) const fieldUi = { base: 'h-full min-h-(--dg-row-h) rounded-none border-0 bg-transparent px-3' } - // No stepper buttons: their focus churn glitched the edit. const numberUi = { ...fieldUi, increment: 'hidden', decrement: 'hidden', base: 'ps-0 pe-0' } function setValue(next: unknown) { @@ -140,12 +120,6 @@ if (!rowMode) commit() } - /** - * What a key means inside an editor, or null to leave it to the widget. - * `Ctrl`/`Cmd`+`Enter` is the way out of one that owns Enter for itself - - * a textarea takes a newline, tags take a tag. `Tab` commits too, but only - * by leaving; this one stays on the cell. - */ function enterAction(event: KeyboardEvent): (() => void) | null { if (event.ctrlKey || event.metaKey) return commit if (!enterCommits) return null @@ -164,20 +138,13 @@ function onKeydown(event: KeyboardEvent) { const action = editorAction(event) if (!action) return - // Escape keeps the browser's own default: it is the widget's cue to - // close whatever it opened. if (event.key !== 'Escape') event.preventDefault() event.stopPropagation() action() } - // Outside-click rather than blur: a widget's inner control blurs the - // input to . A portalled popup the editor opened is not outside. function onClickOutside(event: PointerEvent) { if (rowMode || !editing.active || isInPortal(event.target)) return - // A widget commits as its value changes, so nothing is left pending - // and leaving simply ends the edit. Without this the editor stayed - // open with no way out but picking a value. if (!inputBased) cancel() else if (editing.commitOnBlur) void editing.commit() } @@ -190,11 +157,6 @@ container?.querySelector('[role="spinbutton"]')?.focus() } - /** - * A widget renders its own control, so the caret has to be handed over: - * left on the cell, the arrows never reach the list that just opened and - * Space never reaches the checkbox. - */ function focusWidget() { container ?.querySelector( @@ -203,8 +165,6 @@ ?.focus() } - // The calendar hands focus back to the cell, where Enter would never - // reach the editor. Only then - moving it while typing restarts entry. function setDate(next: { year: number; month: number; day: number } | undefined) { setValue(fromDateValue(next)) requestAnimationFrame(() => { diff --git a/src/lib/components/cells/GridCellValue.svelte b/src/lib/components/cells/GridCellValue.svelte index 7a1ef4c..62e5d66 100644 --- a/src/lib/components/cells/GridCellValue.svelte +++ b/src/lib/components/cells/GridCellValue.svelte @@ -24,13 +24,10 @@ const grid = getGridOrNull() const labels = $derived(grid?.labels ?? defaultLabels) - /** Inherits the grid's language unless `typeOptions.locale` says otherwise. */ const options = $derived({ locale: grid?.locale, ...def.typeOptions }) const emptyText = $derived(options.emptyText ?? DEFAULT_EMPTY_TEXT) const blank = $derived(isBlank(value)) - // The same function a `cell` snippet reads through `formatted`, so the two - // cannot drift. Blank is handled there; this branch only runs when not. const text = $derived(formatCellText(value, def, grid?.locale) ?? String(value ?? '')) const actions = $derived[]>( diff --git a/src/lib/components/cells/GridFilterCell.svelte b/src/lib/components/cells/GridFilterCell.svelte index 9d1b79a..65d1a80 100644 --- a/src/lib/components/cells/GridFilterCell.svelte +++ b/src/lib/components/cells/GridFilterCell.svelte @@ -33,23 +33,15 @@ const theme = getGridTheme() const labels = $derived(grid.labels) - // ---- What this column's filter needs ----------------------------------- - const type = $derived(filterTypeOf(column.def)) const scale = $derived(filterUnitScaleOf(column.def)) const entry = $derived(filteringState.columnFilters[column.id]) const cell = $derived(floatingCellOf(type, entry, scale)) - /** No steppers: a filter field is not a place to count up from. */ const numberUi = { increment: 'hidden', decrement: 'hidden' } - /** - * The picker takes no `aria-label`; it takes an `id` for a label to point - * at, which is the way sv5ui means it to be named. - */ const dateFieldId = $props.id() - /** What the panel holds, in the words the chips use for the same filter. */ const summary = $derived.by(() => { if (!entry) return '' const written = (value: unknown) => @@ -62,12 +54,6 @@ untrack(() => debounce) ) - /** - * What the field holds, for the widget that speaks numbers. A value that - * is not a number at all - a snapshot written by hand, a filter set by an - * app - reads as no value rather than as `NaN`, which the widget would - * draw and then hand back. - */ const numberValue = $derived.by(() => { const raw = String(field.current ?? '') if (raw === '') return null @@ -75,9 +61,6 @@ return Number.isFinite(parsed) ? parsed : null }) - // ---- The typed field: text, number, date ------------------------------- - - /** One condition, in the operator the column already filters by. */ function apply(raw: string): void { if (!type || cell.kind !== 'input') return filteringState.setColumnFilter( @@ -87,8 +70,6 @@ { join: 'and', conditions: [{ op: cell.op, value: raw, to: '' }], - // Kept, so typing in the row does not quietly undo a Match - // case the panel turned on. caseSensitive: cell.caseSensitive, boolValue: 'true', setSelected: [] @@ -98,9 +79,6 @@ ) } - // Pushes what the field produced, and nothing else - the two halves the - // quick filter box is built from, for the same reason: one effect reading - // both directions writes the field back over a filter set in code. $effect(() => { const value = String(field.debounced ?? '') untrack(() => { @@ -108,8 +86,6 @@ }) }) - // The other direction: a filter cleared by a chip, set by the panel or - // restored from a snapshot shows here. $effect(() => { const value = cell.kind === 'input' ? cell.value : '' untrack(() => { @@ -117,13 +93,6 @@ }) }) - // ---- The choice: boolean ----------------------------------------------- - - /** - * A choice with no filter on it needs a value of its own. An empty string - * reads to the select as nothing selected, which drew an empty control - * with no way back to it. - */ const ANY = 'any' const asChoice = (value: string) => (value === '' ? ANY : value) @@ -147,34 +116,20 @@ }) }) - // ---- The list of ticks: set -------------------------------------------- - - /** - * A set value keyed for a list of strings, without collapsing two values a - * column may genuinely hold apart: the number 5 and the string `5` are one - * tick each, and a blank is neither of them. - */ function keyOf(value: SetFilterValue): string { if (value === null) return 'x:' const tag = typeof value === 'number' ? 'n' : typeof value === 'boolean' ? 'b' : 's' return `${tag}:${String(value)}` } - /** Order is not part of a set, so it is not part of comparing two. */ const sameKeys = (a: string[], b: string[]) => a.length === b.length && [...a].sort().join('\u0000') === [...b].sort().join('\u0000') - /** A value as its own column writes it, and a blank as the panel names it. */ const shownValue = (value: SetFilterValue) => value === null ? labels.blankValue : (formatCellText(value, column.def, grid.locale) ?? String(value)) - /** - * Reading the column's distinct values is a pass over every row the grid - * holds, so it waits until the list is opened. Until then the ticks are - * the only entries, which is all the trigger needs to name them. - */ let listOpened = $state(false) const setValues = $derived.by(() => { @@ -182,7 +137,6 @@ if (!listOpened) return ticked const offered = filteringState.distinctFor(column.id) const known = new Set(offered.map(keyOf)) - // A ticked value the data no longer holds keeps its place in the list. return [...offered, ...ticked.filter((value) => !known.has(keyOf(value)))] }) @@ -229,12 +183,6 @@ onValueChange={(next) => (field.current = next === null ? '' : String(next))} /> {:else if cell.kind === 'input' && type === 'date'} - (field.current = fromDateValue(next))} /> {#if String(field.current ?? '') !== ''} -
    - @@ -115,7 +110,6 @@ -
    {page.total > 0 ? `${rangeStart}-${rangeEnd} / ${page.total}` : '0'} | sort: {sortLabel} diff --git a/src/routes/i18n/+page.svelte b/src/routes/i18n/+page.svelte index e15adb1..bf9be32 100644 --- a/src/routes/i18n/+page.svelte +++ b/src/routes/i18n/+page.svelte @@ -30,7 +30,6 @@ const packs = [enUS, viVN, zhCN, jaJP, koKR, frFR, deDE, esES, ptBR, ruRU, idID, thTH] - /** Each language names itself, the way a language picker should read. */ const languages = [ { value: 'en-US', label: 'English' }, { value: 'vi-VN', label: 'Tiếng Việt' }, @@ -86,8 +85,6 @@ sortable: true, filter: 'number', type: 'currency', - // No `locale` here on purpose: the column inherits the grid's, so - // the amounts reformat when the language changes. typeOptions: { currency: 'VND' }, editable: true, editor: 'number' @@ -110,9 +107,6 @@ } ] - // The packs handed in are the whole configuration: the grid takes the - // page's language from here. Assigning `grid.locale` switches it in place - - // the sort, filter and selection on screen all survive. const grid: GridState = createDataGrid({ data: orders, columns, diff --git a/src/routes/persistence/+page.svelte b/src/routes/persistence/+page.svelte index 26f528b..7c97d3e 100644 --- a/src/routes/persistence/+page.svelte +++ b/src/routes/persistence/+page.svelte @@ -48,8 +48,6 @@ const STORAGE_KEY = 'datagrid-persistence-demo' - // A migrate hook upgrades snapshots an older build wrote. Returning - // undefined discards an unreadable one and falls back to the defaults. const persistState: PersistStateOptions = { key: STORAGE_KEY, migrate: (stored) => (stored.version === 1 ? stored : undefined) @@ -65,10 +63,8 @@ const sort = getSorting(grid)! const ops = getColumnOps(grid)! - // Live snapshot - the exact shape written to localStorage on every change. let snapshot = $state(grid.getState()) $effect(() => { - // Touch the reactive surfaces the snapshot is built from. void grid.columns.orderIds void grid.columns.widthOverrides void grid.columns.hiddenOverrides diff --git a/src/routes/persistence/+page.ts b/src/routes/persistence/+page.ts index 4a151cd..62ad4e4 100644 --- a/src/routes/persistence/+page.ts +++ b/src/routes/persistence/+page.ts @@ -1,6 +1 @@ -// A persisted grid restores from localStorage, which only exists on the client. -// Server-rendering it would paint the default state (every column shown) and -// then correct it after hydration - the flash you would otherwise see on -// reload. Rendering client-side lets the synchronous restore apply before the -// first paint, so the saved layout shows immediately. export const ssr = false diff --git a/src/routes/qa/+page.svelte b/src/routes/qa/+page.svelte index 287d83c..3ca94b0 100644 --- a/src/routes/qa/+page.svelte +++ b/src/routes/qa/+page.svelte @@ -51,8 +51,6 @@ const lastNames = ['Nguyễn', 'Trần', 'Lê', 'Phạm', 'Võ', 'Đặng', 'Hồ', 'Bùi'] const depts = ['Core', 'Platform', 'Growth', 'Data', 'Infra', 'Design'] const countries = ['VN', 'US', 'DE', 'JP', 'SG'] - // Deliberately awkward values: blanks, a very long string, punctuation that - // a CSV has to quote, and text that a formula-happy spreadsheet would run. const notes = [ '', 'Ngắn.', @@ -98,7 +96,6 @@ align: 'right', pinned: 'left', sortable: true, - // Frozen on purpose: the handle must not appear on this one. resizable: false, meta: { role: 'key' } }, @@ -179,7 +176,6 @@ header: 'Progress', width: 150, type: 'progress', - // The bar speaks for itself; no hover tooltip wanted. tooltip: false }, { id: 'rating', header: 'Rating', width: 130, type: 'rating', sortable: true } @@ -250,7 +246,6 @@ rowClass: (node) => !node.row.active && 'text-on-surface-variant' }) - // ── Live state, so a wrong model is visible without opening devtools ── const sortState = getSorting(grid)! const filterState = getFiltering(grid)! const selectionState = getSelection(grid)! diff --git a/src/routes/renderers/+page.svelte b/src/routes/renderers/+page.svelte index 965cb36..b7a8fd2 100644 --- a/src/routes/renderers/+page.svelte +++ b/src/routes/renderers/+page.svelte @@ -87,8 +87,6 @@ align: 'right', width: 130, typeOptions: { currency: 'USD', numberFormat: { maximumFractionDigits: 0 } }, - // Says what the cell says - the formatted amount, not the number - // behind it - through the sv5ui tooltip. tooltip: true }, { @@ -98,8 +96,6 @@ sortable: true, align: 'right', width: 150, - // `type` says what the value is and the snippet decorates it: the - // snippet reads `formatted` rather than restating typeOptions. type: 'currency', typeOptions: { currency: 'USD', numberFormat: { maximumFractionDigits: 0 } }, cell: budgetCell diff --git a/src/routes/reorder/+page.svelte b/src/routes/reorder/+page.svelte index 3273df6..a5467e1 100644 --- a/src/routes/reorder/+page.svelte +++ b/src/routes/reorder/+page.svelte @@ -47,7 +47,6 @@ header: '#', width: 64, align: 'right', - // Frozen: an id column has nothing to gain from being dragged wider. resizable: false, meta: { role: 'key' } }, @@ -59,8 +58,6 @@ flex: 1, minWidth: 220, cell: notesCell, - // The cell already wraps, so the hover-truncation tooltip has - // nothing to add - turn it off rather than have both. tooltip: false }, { id: 'owner', header: 'Owner', width: 130, filter: 'set' }, @@ -82,7 +79,6 @@ getRowId: (task) => String(task.id), features: [ rowReorder({ - // The first task is the backlog anchor and stays put. isRowDraggable: (task) => task.id !== 1, onReorder: ({ node, from, to }) => { lastMove = `${node.row.title}: ${from + 1} → ${to + 1}` @@ -96,8 +92,6 @@ }) $effect(() => { - // The feature rewrites `grid.data`; mirror it back so the page state - // and the grid never disagree about the order. tasks = grid.data }) diff --git a/src/routes/server/+page.svelte b/src/routes/server/+page.svelte index a634e14..3952f1c 100644 --- a/src/routes/server/+page.svelte +++ b/src/routes/server/+page.svelte @@ -34,10 +34,6 @@ total: 50 + ((i * 37) % 950) })) - /** - * Stands in for the API. It sorts, filters and slices, and hands back one - * page - the grid never sees the other 127 rows. - */ async function fetchPage(request: { page: number pageSize: number @@ -96,20 +92,10 @@ const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) - // True from the first frame: the page renders on the server with no rows - // and a request already owed, so an empty grid would tell the user there - // is no data for as long as that request takes. let loading = $state(true) let fetches = $state(0) let inFlight = 0 - /** - * Driven by events, not by an effect: `pagination.page` clamps against the - * row count on read, so an effect reading it and then calling - * `setRowCount` would feed itself. The events fire after the feature has - * settled - `pageChanged` reports the page the grid actually moved to, and - * pagination resets to page 1 on a sort or filter before this runs. - */ async function load(): Promise { const ticket = ++inFlight loading = true @@ -121,7 +107,6 @@ sort: sortingState.sort, quick: filteringState.quick }) - // A page the user has already navigated away from must not land. if (ticket !== inFlight) return grid.data = rows paginationState.setRowCount(total) diff --git a/src/routes/server/big/+page.svelte b/src/routes/server/big/+page.svelte index feb48b5..dd9928e 100644 --- a/src/routes/server/big/+page.svelte +++ b/src/routes/server/big/+page.svelte @@ -26,11 +26,6 @@ const regions = ['North', 'South', 'East', 'West', 'Central'] const statuses = ['paid', 'pending', 'refunded'] - /** - * The backend, generated on demand. Ten million rows never exist at once - - * only the page asked for does, which is the point being measured: what - * the grid costs is a function of the page, not of the set behind it. - */ function rowAt(index: number): Order { return { id: index + 1, @@ -41,7 +36,6 @@ } } - /** How far a filtered fetch will read before giving up, as an index would. */ const SCAN_CAP = 250_000 interface Fetched { @@ -51,7 +45,6 @@ scanned: number } - /** The unfiltered path: a slice, which is all a real backend would do. */ function fetchSlice(page: number, pageSize: number, total: number, descending: boolean) { const rows: Order[] = [] const start = (page - 1) * pageSize @@ -62,11 +55,6 @@ return rows } - /** - * The filtered path, with no index behind it: this is what a table scan - * costs the server. The grid is not in it - it receives `pageSize` rows - * either way, which is what the panel above the grid separates out. - */ function fetchScan( request: { page: number; pageSize: number; total: number }, descending: boolean, @@ -179,8 +167,6 @@ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)) if (ticket !== inFlight) return - // From handing the page over to the rows being in the DOM: the only - // part of the round trip the grid is responsible for. const started = performance.now() grid.data = result.rows paginationState.setRowCount(result.total) @@ -210,8 +196,6 @@ } } - // The controls drive the grid, never the other way round: reading the - // pagination state here would tie the effect to the row count `load` sets. $effect(() => { const size = Number(pageSizeChoice) untrack(() => { diff --git a/src/routes/server/infinite/+page.svelte b/src/routes/server/infinite/+page.svelte index f538676..2ba1026 100644 --- a/src/routes/server/infinite/+page.svelte +++ b/src/routes/server/infinite/+page.svelte @@ -19,7 +19,6 @@ const actions = ['created', 'updated', 'archived', 'restored', 'exported'] const TOTAL = 1_000_000 const CHUNK = 200 - /** How close to the end of what is loaded the window may come. */ const THRESHOLD = 60 function rowAt(index: number): Event { @@ -31,7 +30,6 @@ } } - /** One chunk of the log, oldest first, the way a cursor API hands it over. */ function fetchChunk(from: number): Event[] { const rows: Event[] = [] for (let index = from; index < Math.min(from + CHUNK, TOTAL); index++) { @@ -71,12 +69,6 @@ loading = false } - /** - * Reads the window the virtualizer asks for and appends when it comes - * within `THRESHOLD` of the end. The write feeds the read - the row count - * is what the window is measured against - so the append is untracked and - * the guard has to settle, which it does as soon as the window fits. - */ $effect(() => { const end = virtual.virtualizer.range.end untrack(() => { @@ -84,7 +76,6 @@ }) }) - /** Frame times across a scripted scroll: what a fling actually costs. */ async function measureScroll(): Promise { const element = virtual.element if (!element) return @@ -121,8 +112,6 @@ loadMore() } - // No initial fetch here: the effect above already sees an empty grid as a - // window standing at the end of what is loaded, and asks for the chunk. onMount(() => { void tick().then(() => { domRows = document.querySelectorAll('[role="row"][data-dg-row-id]').length diff --git a/src/routes/spans/+page.svelte b/src/routes/spans/+page.svelte index 935094f..ea0e8ef 100644 --- a/src/routes/spans/+page.svelte +++ b/src/routes/spans/+page.svelte @@ -37,8 +37,6 @@ USA: ['New York', 'Austin', 'Seattle'] } - // Rows arrive grouped, the way a report does: region, then country, then a - // row per city. That ordering is what makes the spans meaningful. const entries: Entry[] = [] let id = 0 for (let cycle = 0; cycle < 40; cycle++) { @@ -59,10 +57,7 @@ } } - /** How many rows from `index` share the same value of `key`. */ function runLength(index: number, key: 'region' | 'country'): number { - // A run only starts where the value changes; inside one, the cell is - // covered and its span is never asked for. if (index > 0 && entries[index - 1][key] === entries[index][key]) return 1 let n = 1 while (index + n < entries.length && entries[index + n][key] === entries[index][key]) n++ diff --git a/src/routes/stress/+page.svelte b/src/routes/stress/+page.svelte index bfdf64d..6da1f26 100644 --- a/src/routes/stress/+page.svelte +++ b/src/routes/stress/+page.svelte @@ -58,7 +58,7 @@ const countries = ['VN', 'US', 'DE', 'JP', 'SG', 'AU', 'BR', 'FR'] const cities = ['Hanoi', 'Austin', 'Berlin', 'Osaka', 'Singapore', 'Sydney', 'Rio', 'Lyon'] const statuses = ['Open', 'Won', 'Lost', 'Pending'] - const tiers = ['Free', 'Pro', 'Enterprise'] + const tiers = ['Basic', 'Plus', 'Enterprise'] const owners = ['Ada', 'Linus', 'Grace', 'Alan', 'Margaret', 'Edsger'] const channels = ['Direct', 'Partner', 'Web', 'Referral'] const regions = ['APAC', 'EMEA', 'AMER'] @@ -68,11 +68,6 @@ const stages = ['Discovery', 'Trial', 'Negotiation', 'Closed'] const priorities = ['P1', 'P2', 'P3'] - /** - * Built as a plain loop over preallocated storage: at a million rows the - * difference between this and `Array.from` is seconds, and the point of - * the page is to measure the grid rather than the generator. - */ function build(count: number): Record_[] { const rows = new Array(count) for (let i = 0; i < count; i++) { @@ -228,8 +223,6 @@ async function load() { loading = true - // Building a million rows holds the main thread, so the skeleton only - // reaches the screen if the browser is given a frame first. await nextFrame() const count = Number(size) diff --git a/src/routes/theming/+page.svelte b/src/routes/theming/+page.svelte index 72be337..b2580e7 100644 --- a/src/routes/theming/+page.svelte +++ b/src/routes/theming/+page.svelte @@ -28,7 +28,6 @@ id: i + 1, client: `${clients[i % clients.length]} #${100 + i}`, status: statuses[i % statuses.length], - // Credit notes are negative, so the cellClass demo has something to catch. amount: (i % 7 === 0 ? -1 : 1) * (250 + ((i * 137) % 4000)), dueInDays: ((i * 13) % 45) - 15 })) @@ -52,7 +51,6 @@ filter: 'number', align: 'right', type: 'currency', - // Data-driven cell styling: a credit note reads as a credit note. cellClass: (ctx) => (Number(ctx.value) < 0 ? 'text-error font-medium' : undefined) }, { @@ -69,12 +67,10 @@ columns, data: invoices, getRowId: (invoice) => String(invoice.id), - // Row-level styling reads the whole row, not one cell. rowClass: (node) => node.row.status === 'overdue' && 'bg-error-container/30', features: [filtering(), sorting(), columnOps(), pagination({ pageSize: 12 })] }) - // ── Per-instance `ui` ──────────────────────────────────────────────── const presets = { none: undefined, compactMono: { @@ -97,7 +93,6 @@ let preset = $state('none') const ui = $derived(presets[preset]) - // ── App-wide config ────────────────────────────────────────────────── let configOn = $state(false) function toggleConfig() { @@ -110,7 +105,6 @@ } else { resetDataGridConfig() } - // The config is read when a grid mounts, so show the effect immediately. grid.density = configOn ? 'compact' : 'standard' } diff --git a/src/routes/value-gate/+page.svelte b/src/routes/value-gate/+page.svelte index 19a0a47..2605135 100644 --- a/src/routes/value-gate/+page.svelte +++ b/src/routes/value-gate/+page.svelte @@ -102,15 +102,6 @@ let role = $state('staff') const hidden = $derived(role !== 'hr') - /** - * One feature, one hook. It is asked per column and returns a reader for - * the three it hides, so the other two are read straight through and cost - * nothing at all. - * - * A reader is a transform, not just a blank: the salary leaves as a mark, - * the email keeps its domain, and the phone leaves as nothing, which the - * cell draws as its empty text. - */ const readers: Record unknown> = { salary: () => '***', email: (value) => String(value).replace(/^[^@]+/, '***'), @@ -120,8 +111,6 @@ const policy: GridFeature = { id: 'hr-policy', cellValue: ({ column }) => (hidden ? readers[column.id] : undefined), - // Classes say which cells the policy touched; the values above are - // what it actually held back. cellDecoration: ({ column }) => hidden && readers[column.id] ? { class: 'text-on-surface-variant italic' } : undefined } @@ -137,21 +126,16 @@ const selected = getSelection(grid)! const editor = getEditing(grid)! - // Selected once, up front, so the clipboard panel below has something to - // copy and stays a pure read of the selection. selected.selectAll() const firstNode = $derived(grid.nodes[0]) - /** Exit 1: what the cell draws, read back out of the grid. */ const drawn = $derived( firstNode ? String(grid.getValue(firstNode, grid.columns.get('salary')!)) : '' ) - /** Exit 2: the clipboard, exactly what `Ctrl+C` would put there. */ const clipboard = $derived(selected.copyText({ headers: true }) ?? '') - /** Exit 3: the file, built from the pieces `exportCsv` itself uses. */ const csv = $derived.by(() => { const visible = grid.columns.visible const matrix = rowsToMatrix(grid.preWindowNodes, visible, undefined, { @@ -160,10 +144,8 @@ return toCsv(withHeaderRow(matrix, visible)) }) - /** Exit 5: the values a set filter offers on a hidden column. */ const facets = $derived(filter.distinctFor('salary').map((value) => String(value))) - /** Exit 6: whether an editor may open on the cell at all. */ const editable = $derived( firstNode ? editor.editableAt(firstNode, grid.columns.get('salary')!.def) : false ) diff --git a/src/tests/api-types.test-d.ts b/src/tests/api-types.test-d.ts index 1afe529..efb5eb1 100644 --- a/src/tests/api-types.test-d.ts +++ b/src/tests/api-types.test-d.ts @@ -1,9 +1,3 @@ -/** - * Compile-time only. Neither vitest project matches `*.test-d.ts`, so nothing - * here runs - `npm run check` is what holds these shapes, and a regression - * shows up as a type error rather than a failing assertion. It lives outside - * `src/lib` so it stays out of the published package. - */ import { createDataGrid, filtering, @@ -25,28 +19,20 @@ const grid = createDataGrid({ columns, data: [] as Person[], getRowId: (person) => person.id, - // Written in place, so TRow is inferred from the array's own type. A factory - // held in a variable first resolves to GridFeature and needs the - // argument spelled out: `const s = sorting()`. features: [sorting(), filtering(), pagination({ pageSize: 25 })] }) -/** Fails to compile if the annotation and the expression disagree. */ function exact(value: T): T { return value } -// The kernel's own methods are always there, and callable without a guard. exact(grid.api.getState()) grid.api.setState(grid.api.getState()) -// A feature method is optional: the grid that has it is the one that registered -// the feature. `?.` is the price of the flat bag. exact<((page: number) => void) | undefined>(grid.api.setPage) exact<((rowCount: number | null) => void) | undefined>(grid.api.setRowCount) exact(grid.api.getFilterModel?.()) grid.api.setPage?.(2) -// TRow survives the features array. exact(grid.data) exact(grid.getRowId({ id: '1', name: 'Alice' })) diff --git a/src/tests/auto-row-height.svelte.test.ts b/src/tests/auto-row-height.svelte.test.ts index 5b00b6b..62e56b9 100644 --- a/src/tests/auto-row-height.svelte.test.ts +++ b/src/tests/auto-row-height.svelte.test.ts @@ -18,7 +18,6 @@ interface Note { const TypedDataGrid = DataGrid as unknown as Component> -/** Row 2 wraps onto several lines; the rest are one line. */ const notes: Note[] = [ { id: 1, text: 'short' }, { id: 2, text: 'a very long note that has to wrap over several lines in a narrow column' }, @@ -53,9 +52,7 @@ describe("getRowHeight: 'auto'", () => { const screen = await render(TypedDataGrid, { grid }) await expect.element(screen.getByRole('grid')).toBeVisible() - // No inline height: the content decides. expect(rowElement(screen.container, '1').style.height).toBe('') - // And the wrapping row really is taller than the one-liners. await expect .poll(() => rowElement(screen.container, '2').offsetHeight) .toBeGreaterThan(rowElement(screen.container, '1').offsetHeight) @@ -72,7 +69,6 @@ describe("getRowHeight: 'auto'", () => { 0 ) - // Starts at the 40px estimate, then settles on what was measured. await expect.poll(() => Math.round(virtualizer.totalHeight)).toBe(Math.round(rendered)) expect(virtualizer.totalHeight).toBeGreaterThan(notes.length * 40) }) @@ -96,7 +92,6 @@ describe("getRowHeight: 'auto'", () => { await expect.poll(() => virtualizer.sizeOf(1)).toBeGreaterThan(40) const tall = virtualizer.sizeOf(1) - // Reverse the data: the tall row is now first and must stay tall. grid.data = [...notes].reverse() await expect.poll(() => virtualizer.sizeOf(2)).toBe(tall) }) diff --git a/src/tests/bundle-shape.test.ts b/src/tests/bundle-shape.test.ts index 938f379..189eaf5 100644 --- a/src/tests/bundle-shape.test.ts +++ b/src/tests/bundle-shape.test.ts @@ -6,17 +6,6 @@ import { describe, expect, it } from 'vitest' import { svelte } from '@sveltejs/vite-plugin-svelte' import { build } from 'vite' -/** - * The promise on the box: a feature nobody registered is code nobody ships. - * - * It is one sentence in the README and it stopped being obvious the day the - * package went from nine feature modules to twenty-five. Every barrel here - * re-exports rather than runs, and `sideEffects` names only CSS, but neither - * is worth anything unless something bundles the thing and looks. - * - * The markers are strings that exist in exactly one module each, so a hit is - * that module reaching the output rather than a coincidence of minification. - */ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const MARKERS: Record = { @@ -34,8 +23,6 @@ async function bundle(source: string): Promise { const output = (await build({ root: ROOT, logLevel: 'error', - // No project config: `vite.config.ts` mounts SvelteKit, and a route - // tree is not what is being weighed here. configFile: false, plugins: [svelte()], resolve: { alias: { $lib: path.join(ROOT, 'src/lib') } }, @@ -73,8 +60,6 @@ describe('what a grid drags in', () => { }, 120_000) it('brings one in when the entry does name it', async () => { - // The other half of the claim: the markers are reachable, so the test - // above is measuring tree shaking rather than a typo. const code = await bundle( `import { formula } from '$lib/index.js' export const feature = formula({})` diff --git a/src/tests/cell-decoration.svelte.test.ts b/src/tests/cell-decoration.svelte.test.ts index 8882ba7..fc39288 100644 --- a/src/tests/cell-decoration.svelte.test.ts +++ b/src/tests/cell-decoration.svelte.test.ts @@ -40,7 +40,6 @@ function makeGrid(features: GridFeature[] = []): GridState { }) } -/** Decorates the 2x2 block at rows 0-1, columns 0-1. */ function blockDecorator(className: string): GridFeature { return { id: 'demo-decorator', @@ -66,7 +65,6 @@ describe('cellDecoration extension point', () => { expect(cellAt(screen.container, 0, 0).className).toContain('x-decorated') expect(cellAt(screen.container, 1, 1).className).toContain('x-decorated') expect(cellAt(screen.container, 2, 0).className).not.toContain('x-decorated') - // the cell keeps its own classes expect(cellAt(screen.container, 0, 0).className).toContain('items-center') }) @@ -135,9 +133,6 @@ describe('cellDecoration extension point', () => { expect(cellAt(screen.container, 0, 1).style.color).toBe('rgb(1, 1, 1)') }) - // The grid writes its layout as style directives, which outrank the - // attribute a decoration lands in: a feature cannot move a cell out of - // its own column, whatever it asks for. it('does not let a decoration override the layout the grid wrote', async () => { const escapee: GridFeature = { id: 'escapee-decorator', diff --git a/src/tests/cell-formatting.svelte.test.ts b/src/tests/cell-formatting.svelte.test.ts index 0404b24..31caffb 100644 --- a/src/tests/cell-formatting.svelte.test.ts +++ b/src/tests/cell-formatting.svelte.test.ts @@ -20,11 +20,6 @@ interface Row { const TypedDataGrid = DataGrid as unknown as Component> -/** - * What a snippet is handed. AG Grid calls it `valueFormatted` and MUI calls it - * `formattedValue`; either way the grid formats and the renderer decorates, - * rather than the renderer restating the column's own options. - */ describe('formatted, handed to a cell snippet', () => { it('prints through the built-in renderer when no snippet takes over', async () => { const columns: ColumnDef[] = [ @@ -45,8 +40,6 @@ describe('formatted, handed to a cell snippet', () => { const screen = await render(TypedDataGrid, { grid }) await expect.element(screen.getByRole('grid')).toBeVisible() - // The same string the snippet test reads off `formatted`, so the two - // paths are pinned to one another rather than to a literal each. const printed = screen.container.querySelector('[data-dg-cell="0:0"]')!.textContent?.trim() expect(printed).toContain('1.234,50') }) @@ -66,10 +59,8 @@ describe('formatted, handed to a cell snippet', () => { expect(byId('when')?.formatted).toBe('Aug 11, 2026') expect(byId('plain')?.formatted).toBe('hello') expect(byId('empty')?.formatted).toBe('-') - // A widget has no string standing for it. expect(byId('bar')?.formatted).toBeUndefined() expect(byId('flag')?.formatted).toBeUndefined() - // And the column comes with it, so nothing is restated. expect(byId('money')?.column.def.typeOptions?.currency).toBe('USD') }) diff --git a/src/tests/cell-types.svelte.test.ts b/src/tests/cell-types.svelte.test.ts index 4aa3623..546b95a 100644 --- a/src/tests/cell-types.svelte.test.ts +++ b/src/tests/cell-types.svelte.test.ts @@ -166,8 +166,6 @@ describe('formatting renderers', () => { }) it('marks a blank the same way on a column that declares no type', async () => { - // Untyped columns take the plain-text path, which used to print the raw - // value and so drew nothing at all where a hole was. const blank = [{ ...rows[0], name: null as unknown as string, role: '' }] const screen = await renderGrid( makeGrid( diff --git a/src/tests/col-span.svelte.test.ts b/src/tests/col-span.svelte.test.ts index dd5d08b..7f4f4b2 100644 --- a/src/tests/col-span.svelte.test.ts +++ b/src/tests/col-span.svelte.test.ts @@ -26,8 +26,6 @@ const rows: Row[] = [ { id: 2, name: 'plain', a: 'A2', b: 'B2', c: 'C2', d: 'D2' } ] -// Row 1 spans column `a` (index 1) across three columns - covering 1, 2, 3 - -// leaving column `d` (index 4) as the first cell after the span. Row 2 is normal. const columns: ColumnDef[] = [ { id: 'name', header: 'Name' }, { id: 'a', header: 'A', colSpan: (ctx) => (ctx.row.name === 'wide' ? 3 : 1) }, @@ -62,7 +60,6 @@ describe('column spanning', () => { expect(spanned.getAttribute('aria-colspan')).toBe('3') expect(cell(screen.container, 0, 2)).toBeNull() expect(cell(screen.container, 0, 3)).toBeNull() - // The cell after the span still renders. expect(cell(screen.container, 0, 4)).not.toBeNull() }) @@ -81,11 +78,9 @@ describe('column spanning', () => { expect(g.focus.active).toMatchObject({ row: 0, col: 1 }) await userEvent.keyboard('{ArrowRight}') - // `d` (index 4) is the first cell after the 3-wide span. expect(activeCell()).toBe('0:4') await userEvent.keyboard('{ArrowLeft}') - // Back onto the span, which owns column 1. expect(activeCell()).toBe('0:1') }) @@ -93,8 +88,6 @@ describe('column spanning', () => { const g = grid() const screen = await renderGrid(g) - // Row 1 column 2 is a normal cell; moving up lands in row 0 where column - // 2 is covered by the span, so focus snaps onto the spanning cell. cell(screen.container, 1, 2)!.focus() expect(g.focus.active).toMatchObject({ row: 1, col: 2 }) diff --git a/src/tests/column-extras.svelte.test.ts b/src/tests/column-extras.svelte.test.ts index 7c7c458..4707108 100644 --- a/src/tests/column-extras.svelte.test.ts +++ b/src/tests/column-extras.svelte.test.ts @@ -89,7 +89,6 @@ describe('ColumnDef.resizable', () => { }) describe('ColumnDef.tooltip', () => { - /** The sv5ui tooltip wraps the cell in a trigger; this is that trigger. */ function triggerIn(container: Element, row: number, col: number): HTMLElement { const trigger = cellAt(container, row, col).querySelector( '[data-tooltip-trigger]' @@ -98,10 +97,6 @@ describe('ColumnDef.tooltip', () => { return trigger } - /** - * What the tooltip is showing. It lives in a portal, and sv5ui opens it - * after its own 700ms delay - longer than a default poll waits. - */ const tooltipContent = () => document.querySelector('[data-bits-floating-content-wrapper]')?.textContent?.trim() @@ -125,7 +120,6 @@ describe('ColumnDef.tooltip', () => { const screen = await render(TypedDataGrid, { grid }) await expect.element(screen.getByRole('grid')).toBeVisible() - // Not the raw 91 behind it, and not through a native title. expect(cellAt(screen.container, 0, 0).title).toBe('') expect(await openTooltip(triggerIn(screen.container, 0, 0))).toBe('$91.00') }) @@ -167,8 +161,6 @@ describe('ColumnDef.tooltip', () => { const screen = await render(TypedDataGrid, { grid }) await expect.element(screen.getByRole('grid')).toBeVisible() - // bits-ui hands its trigger a tabindex of 0. Left alone, a page of rows - // would be a page of tab stops - the fault the checkbox column had. const triggers = screen.container.querySelectorAll('[data-tooltip-trigger]') expect(triggers.length).toBeGreaterThan(0) for (const trigger of triggers) expect(trigger.tabIndex).toBe(-1) @@ -183,7 +175,6 @@ describe('ColumnDef.tooltip', () => { await expect.element(screen.getByRole('grid')).toBeVisible() expect(cellAt(screen.container, 0, 0).hasAttribute('data-dg-manual-tooltip')).toBe(true) - // The column that said nothing is still the hover measure's business. expect(cellAt(screen.container, 0, 1).hasAttribute('data-dg-manual-tooltip')).toBe(false) }) }) @@ -203,7 +194,6 @@ describe('tooltip on the playground', () => { const screen = await render(Renderers as never) await expect.element(screen.getByRole('grid').first()).toBeVisible() - // Found by the trigger rather than by a column index the demo may move. const trigger = screen.container.querySelector('[data-tooltip-trigger]')! const salary = trigger.closest('[data-dg-cell]')! expect(trigger.tabIndex).toBe(-1) diff --git a/src/tests/column-groups.svelte.test.ts b/src/tests/column-groups.svelte.test.ts index 4ef00d1..c237c92 100644 --- a/src/tests/column-groups.svelte.test.ts +++ b/src/tests/column-groups.svelte.test.ts @@ -33,7 +33,6 @@ const people: Person[] = [ { id: 2, name: 'Grace', total: 210, base: 200, bonus: 10 } ] -/** `Pay` folds down to its total; `#` and `Name` belong to no group. */ function payColumns(): ColumnDef[] { return [ { id: 'id', header: '#', width: 70 }, @@ -60,7 +59,6 @@ function makeGrid(columns = payColumns(), extra = {}): GridState { }) } -/** The leaf row: one entry per column the grid is drawing. */ const headers = (container: Element) => [...container.querySelectorAll('[data-dg-cell^="-1:"]')].map( (cell) => cell.querySelector('[data-dg-truncate]')?.textContent?.trim() ?? '' @@ -104,8 +102,6 @@ describe('a collapsible header group', () => { ]) ) - // The column menu trigger carries `aria-expanded` too, so the check - // is for a header cell that does. expect(groupCell(screen.container)).toBeNull() }) @@ -118,7 +114,6 @@ describe('a collapsible header group', () => { await expect.poll(() => headers(screen.container)).toEqual(['#', 'Name', 'Total']) - // And the way back out, from the column that stayed. await page.getByRole('button', { name: 'Total column menu' }).click() await page.getByRole('menuitem', { name: 'Expand Pay' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Name', 'Base', 'Bonus']) @@ -146,8 +141,6 @@ describe('a collapsible header group', () => { await page.getByRole('button', { name: 'Collapse Pay' }).click() await expect.poll(() => headers(screen.container)).toHaveLength(3) - // Fixed widths here, so the row narrows rather than the columns - // stretching; what matters is that nothing was left behind. expect(widthOf('Name')).toBe(before) expect(screen.container.querySelectorAll('[role="gridcell"]').length).toBe(6) }) @@ -157,9 +150,6 @@ describe('a collapsible header group', () => { const cell = groupCell(screen.container)!.getBoundingClientRect() const button = groupCell(screen.container)!.querySelector('button')!.getBoundingClientRect() - // At the trailing edge of the group it belongs to, rather than in the - // middle beside the label, where it reads as the seam between two - // groups instead of a control of one. expect(cell.right - button.right).toBeLessThan(8) expect(button.left).toBeGreaterThan(cell.left + cell.width / 2) }) @@ -181,9 +171,6 @@ describe('a collapsible header group', () => { await page.getByRole('button', { name: 'Collapse Performance review' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'YTD']) - // Folding is what makes a group narrow, so this is the state the label - // has to survive: it gives way to an ellipsis rather than running - // under the toggle or out of the cell. const cellEl = groupCell(screen.container)! const cell = cellEl.getBoundingClientRect() const label = cellEl.querySelector('[data-dg-truncate]')!.getBoundingClientRect() @@ -198,13 +185,9 @@ describe('a collapsible header group', () => { const screen = await render(GroupHeaderGrid as never) await expect.element(page.getByRole('grid')).toBeVisible() - // The app's own drawing, with the span it was handed. await expect.element(page.getByText('Pay (2)')).toBeVisible() - // And the grid's own control beside it, as a leaf header keeps its - // sort button beside a custom `headerCell`. await expect.element(page.getByRole('button', { name: 'Collapse Pay' })).toBeVisible() - // The toggle handed to the snippet does what the built-in one does. await screen.container.querySelector('[data-testid="own-toggle"]')!.click() await expect.element(page.getByText('Pay (1)')).toBeVisible() @@ -229,13 +212,11 @@ describe('a collapsible header group', () => { const grid = makeGrid() const screen = await renderGrid(grid) - // Into the leaf header of a column inside the group, then up. grid.focus.focusCell({ row: -1, col: 2 }) screen.container.querySelector('[data-dg-cell="-1:2"]')!.focus() await userEvent.keyboard('{ArrowUp}') await expect.poll(() => grid.focus.active).toEqual({ row: 0, col: 2, section: 'header' }) - // The cell itself holds the caret, not the button inside it. await expect .poll(() => document.activeElement?.getAttribute('data-dg-header-cell')) .toBe('0:2') @@ -243,12 +224,10 @@ describe('a collapsible header group', () => { await userEvent.keyboard('{Enter}') await expect.poll(() => headers(screen.container)).toEqual(['#', 'Name', 'Total']) - // Space folds it back, and the caret has not moved off the group. await userEvent.keyboard(' ') await expect.poll(() => headers(screen.container)).toEqual(['#', 'Name', 'Base', 'Bonus']) expect(grid.focus.active).toEqual({ row: 0, col: 2, section: 'header' }) - // And back down the way it came. await userEvent.keyboard('{ArrowDown}') await expect.poll(() => grid.focus.active).toEqual({ row: -1, col: 2 }) }) @@ -261,7 +240,6 @@ describe('a collapsible header group', () => { screen.container.querySelector('[data-dg-cell="-1:0"]')!.focus() await userEvent.keyboard('{ArrowUp}') - // The level above `#` holds a placeholder, which names nothing. expect(grid.focus.active).toEqual({ row: -1, col: 0 }) expect(document.activeElement?.getAttribute('data-dg-cell')).toBe('-1:0') }) @@ -289,9 +267,6 @@ describe('a collapsible header group', () => { const cellEl = groupCell(screen.container)! await expect.poll(() => cellEl.textContent).toContain('tóm tắt') - // Folded is a group at its narrowest, and a header an app drew has no - // reason to know that. The cell clips, so nothing lands in the group - // beside it or under the toggle. expect(getComputedStyle(cellEl).overflow).toBe('hidden') const cell = cellEl.getBoundingClientRect() const toggle = cellEl.querySelector('button')!.getBoundingClientRect() @@ -312,9 +287,6 @@ describe('a collapsible header group', () => { await page.getByRole('button', { name: 'Collapse Pay' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Name', 'Total']) - // Focus lives on cells everywhere else in the grid; a control holding - // it after a click leaves a ring sitting in the header, and leaves the - // arrow keys with nothing to move. await expect .poll(() => document.activeElement?.getAttribute('data-dg-header-cell')) .toBe('0:2') @@ -349,22 +321,17 @@ describe('a collapsible header group', () => { `${cell.getAttribute('data-dg-header-cell')}=${cell.getAttribute('aria-expanded')}` ) - // A toggle per level, not one for the outermost group only. expect(groupCells()).toEqual(['0:1=true', '1:1=true']) expect(headers(screen.container)).toEqual(['#', 'A', 'B']) - // The inner one folds on its own account. await page.getByRole('button', { name: 'Collapse Inner' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Sub']) expect(groupCells()).toEqual(['0:1=true', '1:1=false']) - // And the outer one folds over the top of it. await page.getByRole('button', { name: 'Collapse Outer' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'All']) - // Nothing of the inner group is on screen, so it has no cell either. expect(groupCells()).toEqual(['0:1=false']) - // Reopening the outer one hands the inner one back as it was left. await page.getByRole('button', { name: 'Expand Outer' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Sub']) expect(groupCells()).toEqual(['0:1=true', '1:1=false']) @@ -413,7 +380,6 @@ describe('a collapsible header group', () => { }) await renderGrid(grid) - // The group sits far off to the right, so its cells are not drawn. expect(grid.columns.visible.map((column) => column.id)).toContain('base') grid.columns.toggleGroup('pay') @@ -443,8 +409,6 @@ describe('a collapsible header group', () => { await page.getByRole('button', { name: 'Collapse Pay' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Name', 'Total']) - // `Base` and `Bonus` are folded away, and still ticked: the user did - // not put them away, the group did. expect(grid.columns.all.filter((column) => column.hidden)).toHaveLength(0) }) }) @@ -454,7 +418,6 @@ describe('the columns demo', () => { const screen = await render(ColumnsDemo as never) await expect.element(page.getByRole('grid').first()).toBeVisible() - // The page draws three grids; the foldable group is on the first. const first = () => screen.container.querySelector('[role="grid"]')! const leafHeaders = () => [...first().querySelectorAll('[data-dg-cell^="-1:"]')].map( @@ -463,7 +426,6 @@ describe('the columns demo', () => { expect(leafHeaders().some((header) => header.startsWith('Q1'))).toBe(true) expect(leafHeaders().some((header) => header.startsWith('YTD'))).toBe(false) - // One group declares a fold, the other declares nothing. expect( screen.container.querySelectorAll('[role="columnheader"][aria-expanded]') ).toHaveLength(1) @@ -472,10 +434,6 @@ describe('the columns demo', () => { await expect.poll(() => leafHeaders().some((h) => h.startsWith('YTD'))).toBe(true) expect(leafHeaders().some((header) => header.startsWith('Q1'))).toBe(false) - // The summary column draws what its accessor adds up, rather than a - // field of its own that nothing holds. - // Row two holds 37, 53, 71 and 89 in its quarters; the summary is - // what its accessor makes of them, not a field anything stores. const ytdIndex = leafHeaders().findIndex((header) => header.startsWith('YTD')) const cell = first().querySelector(`[data-dg-cell="1:${ytdIndex}"]`) expect(cell?.textContent?.trim()).toBe('250') @@ -493,7 +451,6 @@ describe('the header groups demo', () => { await expect.element(page.getByRole('grid')).toBeVisible() const banner = () => groupCell(screen.container)?.textContent?.replace(/\s+/g, ' ').trim() - // The app draws the group header, and is handed the span to show. expect(banner()).toContain('4 quý') expect(leafHeaders(screen.container)).toContain('Q1') @@ -503,7 +460,6 @@ describe('the header groups demo', () => { await expect.poll(() => leafHeaders(screen.container)).not.toContain('Q1') expect(leafHeaders(screen.container)).toContain('Cả') - // One door: the event reached the page's own log. const log = () => screen.container.querySelector('[data-testid="group-log"]')?.textContent await expect.poll(log).toContain('revenue: gập') }) @@ -517,19 +473,14 @@ describe('the header groups demo', () => { (cell) => cell.textContent?.trim().split(/\s+/)[0] ) - // Doanh thu over its two halves, each with a toggle of its own, and - // Kế hoạch, which folds to a rail. Định danh declares nothing and is - // no rail, so it gets none. expect(toggles()).toEqual(['Doanh', 'Kế', 'Nửa', 'Nửa']) - // An inner fold leaves the group over it alone. await page.getByRole('button', { name: 'Collapse Nửa đầu' }).click() await expect .poll(() => leafHeaders(screen.container)) .toEqual(['#', 'Vùng', 'Phụ', 'H1', 'Q3', 'Q4', 'Chỉ', 'Chênh']) expect(toggles()).toEqual(['Doanh', 'Kế', 'Nửa', 'Nửa']) - // And the outer one folds both halves away with it. await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect .poll(() => leafHeaders(screen.container)) diff --git a/src/tests/column-ops.svelte.test.ts b/src/tests/column-ops.svelte.test.ts index 0b7d6f0..2fbea75 100644 --- a/src/tests/column-ops.svelte.test.ts +++ b/src/tests/column-ops.svelte.test.ts @@ -314,8 +314,6 @@ describe('column menu + chooser + state round-trip', () => { await screen.getByRole('button', { name: 'Choose columns' }).click() const entries = page.getByRole('menuitemcheckbox').elements() - // The grid's own checkbox column carries no header and cannot be - // hidden, so it showed up as a blank row with a tick beside it. expect(entries).toHaveLength(flatColumns.length) expect(entries.every((entry) => entry.textContent?.trim())).toBe(true) }) @@ -337,8 +335,6 @@ describe('column menu + chooser + state round-trip', () => { await screen.getByRole('button', { name: 'Choose columns' }).click() const menu = document.querySelector('[role="menu"]')! - // Forty columns make a menu taller than the screen, which then has no - // way to reach its own end. await expect.poll(() => menu.scrollHeight > menu.clientHeight).toBe(true) expect(menu.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight + 1) }) @@ -434,7 +430,6 @@ describe('header groups', () => { describe('header alignment', () => { it('keeps the label on the same side as the cell content', async () => { - /** Wide enough that the label has room to sit anywhere in the cell. */ const wide: ColumnDef[] = [ { id: 'name', header: 'Name', sortable: true, width: 240 }, { id: 'age', header: 'Age', sortable: true, align: 'right', width: 240 } @@ -450,7 +445,6 @@ describe('header alignment', () => { const headers = [...screen.container.querySelectorAll('[role="columnheader"]')] - /** Free space on each side of the label, which is where the align shows up. */ function slack(headerText: string) { const cell = headers.find((header) => header.textContent?.includes(headerText))! const label = cell.querySelector('button')! diff --git a/src/tests/column-rail-cases.svelte.test.ts b/src/tests/column-rail-cases.svelte.test.ts index 3b77e84..59437d5 100644 --- a/src/tests/column-rail-cases.svelte.test.ts +++ b/src/tests/column-rail-cases.svelte.test.ts @@ -59,11 +59,6 @@ const heads = (container: Element) => [ const strips = (container: Element) => [ ...container.querySelectorAll('[data-dg-rail]') ] -/** - * Clicked where it stands. The locator scrolls what it clicks into view - * first, and under column virtualization that scroll rebuilds the header the - * button is in, so the click lands on an element that has been replaced. - */ function clickToggle(container: Element, name: string): void { const button = [...container.querySelectorAll('button')].find((candidate) => (candidate.textContent ?? '').includes(name) @@ -75,7 +70,6 @@ function clickToggle(container: Element, name: string): void { const left = (el: Element, of: Element) => Math.round(el.getBoundingClientRect().left - of.getBoundingClientRect().left) -/** A group pinned to one side, wide columns between, so the grid must scroll. */ function pinnedColumns(side: 'left' | 'right'): ColumnDef[] { const railed: ColumnDef = { id: 'plan', @@ -110,9 +104,6 @@ describe('a drawer over a pinned group', () => { screen.container.querySelector(`[data-dg-cell="0:${railIndex}"]`) ?? [...screen.container.querySelector('[data-dg-row-id]')!.children][railIndex]! - // The cells hold their place with `sticky`; the drawer is one - // element over every row, which cannot be sticky, so it has to be - // told where the pin is. const before = left(cell(), viewport) expect(left(heads(screen.container)[0], viewport)).toBe(before) expect(left(strips(screen.container)[0], viewport)).toBe(before) @@ -150,10 +141,6 @@ describe('a drawer among other things', () => { clickToggle(screen.container, 'Collapse Kế hoạch') await expect.poll(() => strips(screen.container).length).toBe(1) - // Standing inside a group, nothing beside it draws a line: the grid - // only draws them where a group ends. So the drawer draws its own, - // and the cell before it gives up the one it would have drawn, or - // the two land on neighbouring pixels and read as one thick line. const drawer = [heads(screen.container)[0], strips(screen.container)[0]] for (const piece of drawer) { const style = getComputedStyle(piece) @@ -182,10 +169,6 @@ describe('a drawer among other things', () => { clickToggle(screen.container, 'Collapse Nửa sau') await expect.poll(() => strips(screen.container).length).toBe(1) - // A pinned cell is raised over the separator and the spans so it - // draws what it covers. A drawer's own cells carry nothing to draw, - // and raised they cover the drawer itself: the surface survives, - // being the same colour, but the edge down its leading side does not. const strip = strips(screen.container)[0] const railIndex = grid.columns.visible.findIndex((column) => column.id.includes('rail')) const cell = [...screen.container.querySelector('[data-dg-row-id]')!.children].find( @@ -214,9 +197,6 @@ describe('a drawer among other things', () => { clickToggle(wide.container, 'Collapse Kế hoạch') await expect.poll(() => strips(wide.container).length).toBe(1) - // The grid's own border is the trailing edge of the last column, but - // only when the columns reach it. Here they stop 400px short, and a - // drawer left open on that side is a drawer with one side. for (const piece of [heads(wide.container)[0], strips(wide.container)[0]]) { const style = getComputedStyle(piece) expect(style.borderLeftWidth).toBe('1px') @@ -240,8 +220,6 @@ describe('a drawer among other things', () => { clickToggle(screen.container, 'Collapse Kế hoạch') await expect.poll(() => strips(screen.container).length).toBe(1) - // Drawn there it would stand one pixel inside a line the grid has - // already drawn, which is the thick smudge in another place. await expect .poll(() => getComputedStyle(strips(screen.container)[0]).borderRightWidth) .toBe('0px') @@ -279,9 +257,7 @@ describe('a drawer among other things', () => { await page.getByRole('button', { name: 'Collapse Kế hoạch' }).click() await expect.poll(() => strips(screen.container).length).toBe(1) - // The parent keeps its header and shrinks to what is left of it. await expect.poll(parent).toBe('2') - // And the drawer runs past the parent's own level, not from under it. const head = heads(screen.container)[0].getBoundingClientRect() const header = screen.container.querySelector('[role="rowgroup"]')!.getBoundingClientRect() expect(head.top).toBeCloseTo(header.top, 0) @@ -309,8 +285,6 @@ describe('a drawer among other things', () => { clickToggle(screen.container, 'Collapse Nửa đầu') await expect.poll(() => strips(screen.container).length).toBe(2) - // One line between them, drawn once and by one side only, and it - // runs the whole height rather than stopping where the header does. const [firstHead, secondHead] = heads(screen.container) const [firstStrip, secondStrip] = strips(screen.container) const edge = (el: Element, side: 'Left' | 'Right') => @@ -319,11 +293,9 @@ describe('a drawer among other things', () => { expect(edge(secondStrip, 'Left')).toBe('1px') expect(edge(firstHead, 'Right')).toBe('0px') expect(edge(firstStrip, 'Right')).toBe('0px') - // Both are framed on their leading side, so neither reads as open. expect(edge(firstHead, 'Left')).toBe('1px') expect(edge(firstStrip, 'Left')).toBe('1px') - // The two pieces of that line meet, and reach the last row. expect(secondHead.getBoundingClientRect().bottom).toBeCloseTo( secondStrip.getBoundingClientRect().top, 0 @@ -353,9 +325,6 @@ describe('a drawer among other things', () => { clickToggle(screen.container, 'Collapse Nửa sau') await expect.poll(() => strips(screen.container).length).toBe(1) - // A pinned cell travels no further than it has to, and with nothing - // to scroll it does not travel at all. The drawer has to say the same - // thing, or it sits at the far edge with its column left behind. const viewport = screen.container.querySelector('[role="grid"]')! expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth + 1) const cell = [...screen.container.querySelector('[data-dg-row-id]')!.children].at(-1)! @@ -388,16 +357,10 @@ describe('a drawer among other things', () => { clickToggle(screen.container, 'Collapse Kế hoạch') await expect.poll(() => strips(screen.container).length).toBe(1) - // The room a group cell keeps for its toggle is padding, and padding - // is a floor no width can go under. A drawer is 44px wide, so a cell - // still holding that room stands a pixel wider than the column and - // lays a second line beside the drawer's own. const covered = screen.container.querySelector( '[role="columnheader"][aria-expanded="false"]' )! const drawer = heads(screen.container)[0] - // Polled: until the grid has been measured the drawer stands on - // estimated widths, which a flex column only matches once measured. await expect .poll(() => Math.abs( @@ -437,7 +400,6 @@ describe('a drawer among other things', () => { second.click() await expect.poll(() => grid.columns.isCollapsed('half')).toBe(false) - // The one beside it did not go with it. expect(grid.columns.isCollapsed('plan')).toBe(true) }) @@ -467,8 +429,6 @@ describe('a drawer among other things', () => { await page.getByRole('button', { name: 'Collapse Kế hoạch' }).click() await expect.poll(() => strips(screen.container).length).toBe(1) - // The strip covers the rows it folded away, but a pinned row group - // stands outside it, so those cells wear the surface themselves. const surface = getComputedStyle(strips(screen.container)[0]).backgroundColor const pinned = [...screen.container.querySelectorAll('[data-dg-pinned-cell]')].filter( (cell) => cell.getAttribute('data-dg-pinned-cell')?.endsWith(':1') @@ -479,8 +439,6 @@ describe('a drawer among other things', () => { const style = getComputedStyle(cell) expect(style.backgroundColor).toBe(surface) expect(cell.textContent?.trim()).toBe('') - // And the lines down its sides, or the drawer is cut in two - // wherever a pinned row crosses it. expect(style.borderLeftWidth).toBe(edges.borderLeftWidth) expect(style.borderRightWidth).toBe(edges.borderRightWidth) } @@ -521,7 +479,6 @@ describe('a drawer among other things', () => { (child) => child.getAttribute('data-dg-cell')?.endsWith(`:${railIndex}`) )! expect(left(strips(screen.container)[0], viewport)).toBe(left(cell, viewport)) - // The name rides in the header, so 600px of rows change nothing. const name = screen.container.querySelector('[data-dg-rail-head] [data-dg-truncate]')! expect( name.getBoundingClientRect().top - viewport.getBoundingClientRect().top @@ -589,7 +546,6 @@ describe('the cases demo', () => { await expect.element(page.getByRole('grid').first()).toBeVisible() expect(screen.container.querySelectorAll('[role="grid"]')).toHaveLength(10) - // Two of them open folded, by declaration, and say so. expect(screen.container.textContent).toContain('đang gập: rev, planning') expect(strips(screen.container).length).toBeGreaterThan(0) }) diff --git a/src/tests/column-rail.svelte.test.ts b/src/tests/column-rail.svelte.test.ts index b93c413..18b71df 100644 --- a/src/tests/column-rail.svelte.test.ts +++ b/src/tests/column-rail.svelte.test.ts @@ -14,7 +14,6 @@ import { type DataGridProps, type GridState } from '$lib/index.js' -// Not public: a rail is the grid's own column, and nothing an app mounts. import { railColumnId } from '$lib/core/types/index.js' interface Row { @@ -34,7 +33,6 @@ const rows: Row[] = Array.from({ length: 60 }, (_, i) => ({ plan: i * 3 })) -/** A group that folds to a strip rather than to a summary column. */ function railColumns(): ColumnDef[] { return [ { id: 'id', header: '#', width: 80 }, @@ -88,7 +86,6 @@ describe('a group folded to a rail', () => { await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() - // One narrow column where two stood, and no data in it. await expect .poll(() => grid.columns.visible.map((column) => column.id)) .toEqual(['id', RAIL, 'plan']) @@ -106,8 +103,6 @@ describe('a group folded to a rail', () => { expect(name.textContent?.trim()).toBe('Doanh thu') expect(getComputedStyle(name).writingMode).toBe('vertical-rl') - // It starts at the top of the drawer, which is the top of the grid, - // and it is only as long as its own text. const box = name.getBoundingClientRect() const drawer = head(screen.container)!.getBoundingClientRect() expect(box.top - drawer.top).toBeLessThan(40) @@ -121,11 +116,8 @@ describe('a group folded to a rail', () => { await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect.poll(() => strip(screen.container)).not.toBeNull() - // The group's own cell keeps the control and nothing else: 44px of - // header would clip the name, and the strip already carries it. const groupCell = screen.container.querySelector('[role="columnheader"][aria-expanded]')! expect(groupCell.querySelector('[data-dg-truncate]')).toBeNull() - // The column is still named for a reader, out of sight. const railHeader = screen.container.querySelector('[data-dg-cell="-1:1"]')! expect(railHeader.querySelector('.sr-only')?.textContent).toBe('Doanh thu') }) @@ -136,8 +128,6 @@ describe('a group folded to a rail', () => { await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect.poll(() => strip(screen.container)).not.toBeNull() - // A drawer that starts at the first row reads as a hole in the header - // above it, so the header carries the head of the same band. const drawer = strip(screen.container)!.getBoundingClientRect() const top = head(screen.container)!.getBoundingClientRect() const header = screen.container.querySelector('[role="rowgroup"]')!.getBoundingClientRect() @@ -149,11 +139,6 @@ describe('a group folded to a rail', () => { expect(top.left).toBeCloseTo(drawer.left, 0) expect(top.width).toBeCloseTo(drawer.width, 0) - // And nothing of the header's own is drawn across it: the rule under - // each level of headers, and the one along the header's foot, both - // stop at the drawer rather than striking through it. The rules are - // pseudo-elements, which no hit test can see, so what is measured is - // the order they paint in. const headZ = Number(getComputedStyle(head(screen.container)!).zIndex) for (const row of screen.container.querySelectorAll('[role="rowgroup"] [role="row"]')) { const ruleZ = Number(getComputedStyle(row, '::after').zIndex) @@ -176,8 +161,6 @@ describe('a group folded to a rail', () => { await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect.poll(() => head(screen.container)).not.toBeNull() - // The head covers the cell the caret stands on, so the drawer shows - // the caret for it. A pointer gets none of it. const marks = () => [head(screen.container)!, strip(screen.container)!].map( (piece) => getComputedStyle(piece).boxShadow @@ -189,7 +172,6 @@ describe('a group folded to a rail', () => { leaf.focus() await userEvent.keyboard('{ArrowUp}') - // Both pieces, or the caret would land on a drawer cut in half. await expect.poll(() => marks()[0]).not.toBe('none') const [top, length] = marks() expect(length).toBe(top) @@ -201,9 +183,6 @@ describe('a group folded to a rail', () => { await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect.poll(() => strip(screen.container)).not.toBeNull() - // The last row hides its own separator because the grid's border is - // already that line, and the rule for it reads `:last-child`. Drawing - // the strip after the rows takes that away and the two lines stack. const all = [...screen.container.querySelectorAll('[data-dg-row-id]')] const last = all[all.length - 1]! expect(getComputedStyle(last, '::after').display).toBe('none') @@ -224,8 +203,6 @@ describe('a group folded to a rail', () => { label(screen.container)!.getBoundingClientRect().top - viewport.getBoundingClientRect().top - // The name rides in the header, which is what holds it in view: the - // rows go by under it and it does not move at all. const before = offset() viewport.scrollTop = 900 await new Promise((resolve) => requestAnimationFrame(resolve)) @@ -237,12 +214,9 @@ describe('a group folded to a rail', () => { const grid = makeGrid() const screen = await renderGrid(grid) - // No child declares `columnGroupShow`; a summary fold would refuse. await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Doanh', 'Chỉ']) - // No toggle in the header to unfold it with: the drawer is the - // control now, over its whole length. expect(screen.container.querySelector('[data-dg-header-cell="0:1"] button')).toBeNull() strip(screen.container)!.click() await expect.poll(() => headers(screen.container)).toEqual(['#', 'Q1', 'Q2', 'Chỉ']) @@ -261,7 +235,6 @@ describe('a group folded to a rail', () => { await userEvent.keyboard('{Enter}') await expect.poll(() => grid.columns.isCollapsed('revenue')).toBe(true) - // The group still has a cell over the strip, so Enter brings it back. await userEvent.keyboard('{Enter}') await expect.poll(() => grid.columns.isCollapsed('revenue')).toBe(false) }) @@ -271,7 +244,6 @@ describe('a group folded to a rail', () => { await renderGrid(grid) grid.columns.toggleGroup('revenue') - // A rail holds no data, so it is not a column an export writes. const { rowsToMatrix } = await import('$lib/index.js') const matrix = rowsToMatrix(grid.nodes.slice(0, 1), grid.columns.visible) expect(matrix[0]).toEqual(['1', '0']) @@ -285,23 +257,17 @@ describe('a group folded to a rail', () => { const el = strip(screen.container)! const style = getComputedStyle(el) - // A surface of its own, and warm to the pointer. expect(style.cursor).toBe('pointer') expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)') - // One line down its leading edge, not two. The column before it - // already draws that edge, so a border of the strip's own lands on - // the same pixel and reads as a thick smudge. const lines = (edge: 'Left' | 'Right', ...cells: Element[]) => cells.filter((cell) => getComputedStyle(cell)[`border${edge}Width`] !== '0px').length const before = screen.container.querySelector('[data-dg-cell="-1:0"]')! const railHeader = screen.container.querySelector('[data-dg-cell="-1:1"]')! expect(lines('Right', before) + lines('Left', railHeader, el)).toBe(1) - // The way back is drawn, not only implied: an arrow over the name. const inner = head(screen.container)!.firstElementChild! expect(inner.children).toHaveLength(2) - // The name starts clear of the grid's own edge rather than on it. const name = label(screen.container)!.getBoundingClientRect() expect(name.top - head(screen.container)!.getBoundingClientRect().top).toBeGreaterThan(8) }) @@ -347,14 +313,12 @@ describe('the header groups demo', () => { await page.getByRole('button', { name: 'Collapse Kế hoạch' }).click() - // Both its columns are gone, and one strip stands where they were. await expect.poll(() => leafHeaders()).not.toContain('Chỉ') expect(leafHeaders()).not.toContain('Chênh') const name = label(screen.container)! expect(name.textContent?.trim()).toBe('Kế hoạch') expect(getComputedStyle(name).writingMode).toBe('vertical-rl') - // The revenue group beside it still folds the other way, to a column. await page.getByRole('button', { name: 'Collapse Doanh thu' }).click() await expect.poll(() => leafHeaders()).toContain('Cả') }) diff --git a/src/tests/column-virtualization.svelte.test.ts b/src/tests/column-virtualization.svelte.test.ts index 9fe0371..007d5af 100644 --- a/src/tests/column-virtualization.svelte.test.ts +++ b/src/tests/column-virtualization.svelte.test.ts @@ -103,7 +103,6 @@ describe('column virtualization', () => { const rtlViewport = rtlScreen.container.querySelector('[role="grid"]')! rtlViewport.dir = 'rtl' - // RTL reports zero at the right edge and counts down from there. rtlViewport.scrollLeft = -1200 await expect.poll(() => headerIndices(rtlScreen.container)).toEqual(expected) expect(firstBodyRowIndices(rtlScreen.container)).toEqual(expected) @@ -212,9 +211,6 @@ describe('a grid wider than anyone reads at once', () => { const screen = await render(VirtualGrid, { grid }) await expect.element(screen.getByRole('grid')).toBeVisible() - // Widths resolve against a container the first paint has not measured, - // so there is nothing to window by yet. Rendering all two thousand - // columns of every rendered row there is the cost this bounds. const cells = screen.container.querySelectorAll('[data-dg-cell]').length expect(cells).toBeGreaterThan(0) expect(cells).toBeLessThan(WIDE) @@ -228,14 +224,10 @@ describe('a grid wider than anyone reads at once', () => { it('finds its window without walking the columns to get there', async () => { const grid = wideGrid() await render(VirtualGrid, { grid }) - // Widths resolve once the container has a width, and the offsets they - // produce are what the window is found in. grid.columns.containerWidth = 1200 const columnVirtualizer = getVirtualization(grid)!.columnVirtualizer! columnVirtualizer.viewportWidth = 1200 - // Far along a list where a scan from the first column would cost the - // whole list on every frame. columnVirtualizer.scrollLeft = 120 * 1800 const range = columnVirtualizer.range expect(range.start).toBeGreaterThan(1780) diff --git a/src/tests/column-width-snapshot.svelte.test.ts b/src/tests/column-width-snapshot.svelte.test.ts index 379af10..e6cd0a5 100644 --- a/src/tests/column-width-snapshot.svelte.test.ts +++ b/src/tests/column-width-snapshot.svelte.test.ts @@ -39,14 +39,6 @@ function cellAt(container: Element, row: number, col: number): HTMLElement { return cell } -/** - * Asserted on the computed style rather than on `widthOverrides`, because the - * override record looked perfectly reasonable while the layout was already - * gone: a width of `NaN` reached the custom property as `NaNpx`, which makes - * `grid-template-columns` invalid at computed-value time. The browser drops - * the whole declaration, every column folds into one track, and the cells - * stack down the page. Nothing throws on the way. - */ function tracksOf(container: Element, row: number): string[] { const line = cellAt(container, row, 0).parentElement if (!line) throw new Error('no row element') diff --git a/src/tests/conditional-formatting.svelte.test.ts b/src/tests/conditional-formatting.svelte.test.ts index 6d6f511..2065651 100644 --- a/src/tests/conditional-formatting.svelte.test.ts +++ b/src/tests/conditional-formatting.svelte.test.ts @@ -44,7 +44,6 @@ const columns: ColumnDef[] = [ ] const TypedGrid = DataGrid as unknown as Component> -// `serverRowModel()` contributes its own component, so a root is enough. const TypedServerRows = InGrid const inRoot = (grid: GridState) => ({ props: { grid } }) diff --git a/src/tests/data-shapes-features.test.ts b/src/tests/data-shapes-features.test.ts index 5123f06..4d5ebde 100644 --- a/src/tests/data-shapes-features.test.ts +++ b/src/tests/data-shapes-features.test.ts @@ -7,11 +7,6 @@ import { sortNodes } from '../lib/features/sorting/sort.js' import { compileColumnFilters } from '../lib/features/filtering/filter-predicates.js' import { rowsToMatrix, toCsv } from '../lib/features/selection/clipboard.js' -/** - * The features that read a value, against the shapes an API delivers. The - * matrix beside this one asks what a cell draws; this one asks whether the - * rest of the grid agrees with it. - */ interface Row { id: string value: unknown @@ -125,7 +120,6 @@ describe('a filter survives being written to a snapshot and read back', () => { getFiltering(grid)!.setColumnFilter('value', filter) const before = grid.nodes.length - // What `persistState` does: through JSON and back, not by reference. const snapshot = JSON.parse(JSON.stringify(grid.api.getState())) as GridSnapshot const restored = createDataGrid({ diff --git a/src/tests/data-shapes.test.ts b/src/tests/data-shapes.test.ts index 7fe6f90..671635c 100644 --- a/src/tests/data-shapes.test.ts +++ b/src/tests/data-shapes.test.ts @@ -7,26 +7,12 @@ import { formatCellText } from '../lib/core/utils/index.js' import { quickFilterNodes } from '../lib/features/filtering/index.js' import { rowsToMatrix, toCsv } from '../lib/features/selection/index.js' -/** - * What a JSON API delivers, which is not what a test invents: no date type, no - * decimal type, booleans that arrived as text, holes of three different kinds. - * - * One invariant runs through all of it. What the cell draws, what the quick - * filter finds it by, and what an export writes are the same fact about the - * same row, so a shape that breaks any one of the three breaks the row. - */ interface Shape { name: string type?: ColumnType typeOptions?: ColumnDef['typeOptions'] value: unknown - /** The text the cell draws. Spelled out, so a change of mind shows here. */ drawn: string - /** - * Typed into the quick filter, this must find the row. `null` where there - * is nothing a user could sensibly type: the cell draws a placeholder or a - * widget rather than its value. - */ search: string | null } @@ -38,11 +24,9 @@ interface Cell { const LOCALE = 'en-US' const EMPTY = '-' -/** Local, so the day the cell draws does not depend on where this runs. */ const JAN_10 = new Date(2024, 0, 10, 9, 30) const shapes: Shape[] = [ - // Text and the three kinds of hole. { name: 'a string', type: 'text', value: 'Alice', drawn: 'Alice', search: 'ali' }, { name: 'an empty string', type: 'text', value: '', drawn: EMPTY, search: null }, { name: 'null', type: 'text', value: null, drawn: EMPTY, search: null }, @@ -64,7 +48,6 @@ const shapes: Shape[] = [ }, { name: 'text with a newline', type: 'text', value: 'a\nb', drawn: 'a\nb', search: 'a' }, - // Numbers, including the shapes an API sends instead of numbers. { name: 'a number', type: 'number', value: 1234.5, drawn: '1,234.5', search: '1,234.5' }, { name: 'zero', type: 'number', value: 0, drawn: '0', search: '0' }, { name: 'a negative number', type: 'number', value: -5, drawn: '-5', search: '-5' }, @@ -83,7 +66,6 @@ const shapes: Shape[] = [ search: '1,000,000,000,000,000,000,000' }, - // Money and ratios, where the drawn unit is not the stored one. { name: 'currency', type: 'currency', value: 1234.5, drawn: '$1,234.50', search: '$1,234.50' }, { name: 'a ratio percent', type: 'percent', value: 0.05, drawn: '5%', search: '5%' }, { @@ -95,7 +77,6 @@ const shapes: Shape[] = [ search: '5%' }, - // Dates, in every form JSON has for them. { name: 'a Date object', type: 'date', @@ -103,8 +84,6 @@ const shapes: Shape[] = [ drawn: 'Jan 10, 2024', search: 'Jan 10, 2024' }, - // Midnight, because that is when a local day and the UTC day it is written - // as fall apart: at 9:30 the two agree and the shape proves nothing. { name: 'a Date object at midnight', type: 'date', @@ -141,7 +120,6 @@ const shapes: Shape[] = [ search: 'Jan 10, 2024' }, - // A column with no type renders the value as it is. { name: 'no type at all', value: 'plain', drawn: 'plain', search: 'plain' } ] @@ -155,8 +133,6 @@ function columnOf(shape: Shape): ColumnDef { } function nodeOf(shape: Shape): RowNode[] { - // A missing key is a hole too, so the row is built without one where the - // shape says undefined rather than carrying an explicit undefined. const row: Cell = shape.value === undefined ? ({ id: '1' } as Cell) : { id: '1', value: shape.value } return buildRowNodes([row], (cell) => cell.id) @@ -186,8 +162,6 @@ describe('an export writes what the row holds', () => { it.each(shapes)('writes $name without losing it', (shape) => { const column = createColumnState(columnOf(shape)) const csv = toCsv(rowsToMatrix(nodeOf(shape), [column])) - // Raw by default, so a spreadsheet keeps a number a number. What it must - // not do is write something that means a different value. expect(csv).not.toContain('[object') expect(csv).not.toContain('Invalid Date') }) diff --git a/src/tests/data-volume.test.ts b/src/tests/data-volume.test.ts index caa42c6..856f397 100644 --- a/src/tests/data-volume.test.ts +++ b/src/tests/data-volume.test.ts @@ -16,11 +16,6 @@ import { distinctValues, DISTINCT_VALUES_CAP } from '../lib/features/filtering/i import { rowsToMatrix, toCsv } from '../lib/features/selection/index.js' import { buildRowNodes } from '../lib/core/grid/index.js' -/** - * One row list, grown from nothing to a million, asked whether it still holds - * every row it started with. Speed is the benchmarks' business; this is about - * whether a page, a window or an export loses one. - */ interface Row { id: string name: string @@ -30,7 +25,6 @@ interface Row { const DEPTS = ['Core', 'Data', 'Infra', 'Ops'] -/** Holes every seventh row, because a hole is where an off-by-one hides. */ function makeRows(count: number): Row[] { return Array.from({ length: count }, (_, i) => ({ id: String(i + 1), @@ -94,8 +88,6 @@ describe('paging never loses a row, whatever the size', () => { const page = getPagination(grid)! page.setPage(10) getFiltering(grid)!.setColumnFilter('dept', { kind: 'set', values: ['Core'] }) - // Filtering resets to the first page rather than stranding the reader - // on a page that no longer exists. expect(page.page).toBe(1) expect(grid.nodes.length).toBeGreaterThan(0) }) @@ -141,7 +133,6 @@ describe('sorting keeps the list it was given', () => { }) describe('the quick filter answers the same as reading every cell', () => { - /** What the cache has to agree with, at every size. */ function naive(rows: Row[], query: string): string[] { const needle = query.toLowerCase() return rows @@ -171,8 +162,6 @@ describe('the quick filter answers the same as reading every cell', () => { state.setQuickFilter('core') const before = grid.nodes.length - // A new array of the same rows: the cache is keyed by row, so this is - // the case where it must still be right rather than merely fast. grid.data = [...rows] expect(grid.nodes).toHaveLength(before) @@ -219,17 +208,8 @@ describe('selection and export carry the whole list', () => { }) describe('a million rows', () => { - // One size the rest of the suite cannot afford to run at every assertion, - // kept to the properties that would hide a lost row. const rows = makeRows(1_000_000) - /** - * These do a million rows of real work each, so the default five seconds - * is not a deadline they should be held to: it is the deadline for a test - * that has hung. A shared runner is slower than the machine this was - * written on, and one of them passed here and timed out there, which is - * worse than being slow. - */ const HEAVY = 60_000 it( diff --git a/src/tests/demos.svelte.test.ts b/src/tests/demos.svelte.test.ts index e9b51fa..e8b0e6c 100644 --- a/src/tests/demos.svelte.test.ts +++ b/src/tests/demos.svelte.test.ts @@ -9,7 +9,6 @@ describe('headless demo', () => { it('builds a working grid from the compound parts', async () => { render(Headless as never) await expect.element(page.getByRole('grid')).toBeVisible() - // Custom toolbar + custom footer, not . await expect.element(page.getByPlaceholder('Tìm task...')).toBeVisible() await expect.element(page.getByText(/Trang 1 \//)).toBeVisible() }) @@ -19,7 +18,6 @@ describe('headless demo', () => { await expect.element(page.getByRole('grid')).toBeVisible() const rowCount = () => document.querySelectorAll('[role="row"][data-dg-row-id]').length - // A unique term, so the result is smaller than the page of 8. await userEvent.fill(page.getByPlaceholder('Tìm task...'), 'Task 48') await expect.poll(rowCount).toBe(1) await expect.element(page.getByRole('gridcell', { name: 'Task 48' })).toBeVisible() @@ -46,8 +44,6 @@ describe('server row model demo', () => { await page.getByRole('gridcell', { name: 'Charlie #11' }).click() - // The whole point of the server model: the fetched page survives the - // click instead of the focus dragging the grid back to page 1. expect(state()).toContain('page 2/14') await expect.element(page.getByRole('gridcell', { name: 'Charlie #11' })).toBeVisible() expect(events().filter((entry) => entry.includes('pageChanged'))).toEqual([ @@ -58,13 +54,9 @@ describe('server row model demo', () => { it('indexes the rows it holds, so the keyboard still reaches them', async () => { await openPageTwo() - // A server model holds one page, so its row indexes run 0..n on every - // page. Numbering them from the page offset left every lookup into - // `preWindowNodes` - Space, Ctrl+C, type-to-edit - pointing past the end. await page.getByRole('gridcell', { name: 'Charlie #11' }).click() await userEvent.keyboard(' ') await expect.poll(state).toContain('1 selected') - // The focused row, not whichever row the stale index landed on. const selected = document.querySelectorAll('[aria-selected="true"][data-dg-row-id]') expect(Array.from(selected).map((row) => row.getAttribute('data-dg-row-id'))).toEqual([ '11' @@ -82,7 +74,6 @@ describe('server row model demo', () => { const cell = document.querySelector('[data-dg-cell="0:0"]')! const box = cell.getBoundingClientRect() - // Off the checkbox, inside the cell - where a casual aim lands. document .elementFromPoint(box.left + 2, box.top + 2)! .dispatchEvent(new MouseEvent('click', { bubbles: true })) @@ -92,7 +83,6 @@ describe('server row model demo', () => { await page.getByRole('button', { name: 'Go to page 5' }).click() await expect.poll(state).toContain('page 5/14') - // Selection is keyed by row id, so a row off the current page keeps it. expect(state()).toContain('1 selected') }) @@ -107,8 +97,6 @@ describe('server row model demo', () => { await page.getByRole('button', { name: 'Go to page 2' }).click() await expect.element(page.getByRole('gridcell', { name: 'Charlie #11' })).toBeVisible() - // The header checkbox speaks for the rows the grid holds - one page - // here - so it must not throw away the pages it cannot see. await page.getByRole('checkbox', { name: 'Select all rows' }).click() await expect.poll(state).toContain('12 selected') diff --git a/src/tests/editing.svelte.test.ts b/src/tests/editing.svelte.test.ts index 50e602a..1db8069 100644 --- a/src/tests/editing.svelte.test.ts +++ b/src/tests/editing.svelte.test.ts @@ -182,7 +182,6 @@ describe('cell editing', () => { expect(grid.data[0].dept).toBe('Core') cellAt(screen.container, 0, 2).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) - // The list opens with the editor, so there is nothing to click first. await page.getByRole('option', { name: 'Data' }).click() await expect.poll(() => grid.data[0].dept).toBe('Data') @@ -253,9 +252,6 @@ describe('cell editing', () => { .poll(() => cellAt(screen.container, 0, 6).querySelector('[role="spinbutton"]')) .not.toBeNull() - // Leaving while the year is still one digit. What lands is wrong, as - // any half-typed value is, but it has to be a date: `2-07-04` parsed - // as nothing, drew as nothing, and went to a server as nothing. await userEvent.keyboard('07') await userEvent.keyboard('04') await userEvent.keyboard('2') @@ -332,8 +328,6 @@ describe('row edit mode', () => { getEditing(grid)!.startRowEdit('1') await expect.element(page.getByRole('textbox').first()).toBeVisible() - // Every editor mounts at once; without a rule the last one to run keeps - // the focus and the eye starts somewhere the caret is not. const first = cellAt(screen.container, 0, 0).querySelector('input') expect(document.activeElement).toBe(first) expect(first?.getAttribute('aria-label')).toBe('Name') @@ -345,8 +339,6 @@ describe('row edit mode', () => { getEditing(grid)!.startRowEdit('1') await expect.element(page.getByRole('textbox').first()).toBeVisible() - // The cell carries a roving tabindex and used to claim this focus, so - // the keystrokes went nowhere. const age = cellAt(screen.container, 0, 1).querySelector('input')! await userEvent.click(age) expect(document.activeElement).toBe(age) @@ -361,8 +353,6 @@ describe('row edit mode', () => { getEditing(grid)!.startRowEdit('1') await expect.element(page.getByRole('textbox').first()).toBeVisible() - // A cell edit drops its list open because that is what the user came - // for. A row edit opening every list at once buries the rows below. expect(document.querySelectorAll('[role="listbox"]').length).toBe(0) }) @@ -372,27 +362,18 @@ describe('row edit mode', () => { getEditing(grid)!.startRowEdit('1') await expect.element(page.getByRole('textbox').first()).toBeVisible() - // Two inset rings meeting at a seam read as one doubled rule, which is - // what a ring per editor produced across the row. const name = cellAt(screen.container, 0, 0).firstElementChild as HTMLElement const age = cellAt(screen.container, 0, 1).firstElementChild as HTMLElement const seam = age.getBoundingClientRect().left - name.getBoundingClientRect().right expect(Math.abs(seam)).toBeLessThanOrEqual(1) - // The row carries the outline, and it is the only one: a field marking - // focus with a second box put four pixels of primary along the seam it - // shares with the row, and three lines in the corner of a widget that - // draws its own border. const row = cellAt(screen.container, 0, 0).parentElement! expect(getComputedStyle(row, '::after').boxShadow).not.toBe('none') - // `name` holds the caret: the first editable field takes it. expect(name.contains(document.activeElement)).toBe(true) expect(getComputedStyle(name).boxShadow).toBe('none') expect(getComputedStyle(age).boxShadow).toBe('none') - // What it marks focus with instead: a tint, and a rule along the one - // edge the row's outline does not already occupy. expect(getComputedStyle(name, '::after').backgroundColor).not.toBe('rgba(0, 0, 0, 0)') expect(getComputedStyle(age, '::after').backgroundColor).toBe('rgba(0, 0, 0, 0)') }) @@ -407,13 +388,11 @@ describe('row edit mode', () => { const screen = await renderGrid(grid) const paging = getPagination(grid)! - // The last row the page holds, which is where the jump showed up. cellAt(screen.container, 11, 0).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) await page.getByRole('textbox').first().fill('Edited') await userEvent.keyboard('{Enter}') expect(grid.data[11].name).toBe('Edited') - // Pressing Enter to save is not a request to go to page two. expect(paging.page).toBe(1) expect(grid.focus.active.row).toBe(11) }) @@ -446,7 +425,6 @@ describe('row edit mode', () => { cellAt(screen.container, 11, 0).focus() await userEvent.keyboard('{ArrowDown}') - // Going somewhere is what an arrow key is for. expect(grid.focus.active.row).toBe(12) expect(paging.page).toBe(2) }) @@ -462,12 +440,7 @@ describe('row edit mode', () => { const row = cellAt(screen.container, 0, 0).parentElement! const separator = Number(getComputedStyle(row, '::after').zIndex) - // The editor fills its cell, so the separator lands on the ring rather - // than beside it: one grey pixel along one blue edge, which reads as - // three edges of one weight and a fourth of another. expect(Number(getComputedStyle(box).zIndex)).toBeGreaterThan(separator) - // And no higher than the pinned cells, which stay above what scrolls - // beneath them whether or not it is being edited. expect(Number(getComputedStyle(box).zIndex)).toBeLessThan(8) }) @@ -477,8 +450,6 @@ describe('row edit mode', () => { getEditing(grid)!.startRowEdit('1') await expect.element(page.getByRole('textbox').first()).toBeVisible() - // The dept column edits through a Select, which has a border and a - // focus state of its own. const dept = cellAt(screen.container, 0, 2).firstElementChild as HTMLElement expect(getComputedStyle(dept).boxShadow).toBe('none') expect(getComputedStyle(dept, '::after').backgroundColor).toBe('rgba(0, 0, 0, 0)') @@ -572,13 +543,11 @@ describe('clipboard paste', () => { const input = screen.container.querySelector('input')! pasteInto(input, 'FromEditor') - // The grid paste must not fire while an editor owns the event. expect(grid.data[0].name).toBe('Alice') }) }) describe('leaving a widget editor', () => { - /** name, age, dept, active, rating, skills, joined. */ const DEPT = 2 const isOpen = (container: Element) => @@ -591,8 +560,6 @@ describe('leaving a widget editor', () => { await userEvent.dblClick(cellAt(screen.container, 0, DEPT)) await expect.poll(() => isOpen(screen.container)).toBe(true) - // A widget editor leaves focus on the cell, so a handler on the editor - // inside it never sees the key - the binding has to be on the grid. await userEvent.keyboard('{Escape}') await expect.poll(() => isOpen(screen.container)).toBe(false) expect(getEditing(grid)!.active).toBeNull() @@ -605,8 +572,6 @@ describe('leaving a widget editor', () => { await userEvent.dblClick(cellAt(screen.container, 0, DEPT)) await expect.poll(() => isOpen(screen.container)).toBe(true) - // It used to sit there with no way out but picking a value: the - // outside-click handler only ever ran for text-field editors. await userEvent.click(cellAt(screen.container, 2, 1)) await expect.poll(() => isOpen(screen.container)).toBe(false) expect(getEditing(grid)!.active).toBeNull() @@ -621,8 +586,6 @@ describe('leaving a widget editor', () => { await expect.poll(() => isOpen(screen.container)).toBe(true) await userEvent.keyboard('{Escape}') - // A widget commits as its value changes, so ending the edit discards - // nothing the user had entered. await expect.poll(() => grid.data[0].dept).toBe(before) }) @@ -639,12 +602,9 @@ describe('leaving a widget editor', () => { }) describe('segmented editors in a narrow column', () => { - /** name, age, dept, active, rating, skills, joined - `joined` is the date. */ const JOINED = 6 it('grows past the cell rather than running its segments under the icon', async () => { - // Narrow enough that the segments cannot fit beside the trigger: the - // width a real report column would give a date. const grid = createDataGrid({ columns: columns.map((column) => column.id === 'joined' ? { ...column, width: 110 } : column @@ -660,9 +620,6 @@ describe('segmented editors in a narrow column', () => { const editor = () => cell.firstElementChild as HTMLElement await expect.poll(() => Boolean(editor()?.querySelector('[role="spinbutton"]'))).toBe(true) - // A date field lays out fixed segments and reserves room for its - // trigger. Held to a narrower column the segments used to overflow and - // print under that trigger. const segments = [...cell.querySelectorAll('[role="spinbutton"]')] const last = segments.at(-1)!.getBoundingClientRect() const trigger = cell.querySelector('button')!.getBoundingClientRect() @@ -685,7 +642,6 @@ describe('segmented editors in a narrow column', () => { }) describe('committing an editor that owns Enter', () => { - /** name, age, dept, active, rating, skills, joined. */ const SKILLS = 5 it('commits a tags editor with Ctrl+Enter, without leaving the cell', async () => { @@ -696,15 +652,12 @@ describe('committing an editor that owns Enter', () => { await userEvent.dblClick(cell) await expect.poll(() => Boolean(cell.querySelector('input'))).toBe(true) - // Enter belongs to the widget: it adds the tag rather than committing. await userEvent.keyboard('svelte{Enter}') expect(getEditing(grid)!.active).not.toBeNull() await userEvent.keyboard('{Control>}{Enter}{/Control}') await expect.poll(() => getEditing(grid)!.active).toBeNull() expect(grid.data[0].skills).toContain('svelte') - // The grid's own position is what moves, or does not: unlike Tab, - // this commits without leaving the cell. expect(grid.focus.active).toMatchObject({ row: 0, col: SKILLS }) }) @@ -724,7 +677,6 @@ describe('committing an editor that owns Enter', () => { describe('focus ring while editing', () => { const hasRing = (element: Element) => /rgb|oklch/.test(getComputedStyle(element).boxShadow) - /** `:focus-visible` needs the keyboard, so focus arrives by arrow key. */ async function focusByKeyboard(container: Element) { await userEvent.click(cellAt(container, 0, 1)) await userEvent.keyboard('{ArrowLeft}') @@ -740,8 +692,6 @@ describe('focus ring while editing', () => { await userEvent.keyboard('{Enter}') await expect.poll(() => Boolean(cell.querySelector('input'))).toBe(true) - // Two rings around one control read as a mistake, so the cell yields - // to the editor rather than framing it. expect(hasRing(cell)).toBe(false) expect(hasRing(cell.firstElementChild!)).toBe(true) }) @@ -756,16 +706,12 @@ describe('focus ring while editing', () => { await expect.poll(() => Boolean(cell.querySelector('input'))).toBe(true) expect(cell.className).not.toMatch(ringClass) - // The class comes back, not the painted ring: `:focus-visible` follows - // the browser's own idea of how focus arrived, and focus returning to - // the cell in code does not count as a keypress. await userEvent.keyboard('{Escape}') await expect.poll(() => ringClass.test(cell.className)).toBe(true) }) }) describe('opening an editor from the keyboard', () => { - /** name, age, dept, active, rating, skills, joined. */ const DEPT = 2 it('drops the list open and hands it the keyboard', async () => { @@ -776,13 +722,9 @@ describe('opening an editor from the keyboard', () => { cell.focus() await userEvent.keyboard('{Enter}') - // Opening the editor is the choice; the list is what the user came - // for, so it is already down rather than waiting for a second key. await expect .poll(() => document.querySelectorAll('[data-bits-floating-content-wrapper]').length) .toBe(1) - // And the caret is on the control, or the arrows would still be - // steering the grid instead of the list. expect(cell.contains(document.activeElement)).toBe(true) expect(document.activeElement).not.toBe(cell) }) @@ -806,7 +748,6 @@ describe('opening an editor from the keyboard', () => { cellAt(screen.container, 0, DEPT).focus() await userEvent.keyboard('Z') - // A select would be left holding a value none of its options offer. await expect.poll(() => getEditing(grid)!.active).not.toBeNull() expect(getEditing(grid)!.draft).not.toBe('Z') }) diff --git a/src/tests/export-menu.svelte.test.ts b/src/tests/export-menu.svelte.test.ts index 02da62d..e2ba5b4 100644 --- a/src/tests/export-menu.svelte.test.ts +++ b/src/tests/export-menu.svelte.test.ts @@ -43,10 +43,6 @@ function makeGrid(withSelection = true): GridState { }) } -/** - * Downloads never leave the page here: the blob handed to `createObjectURL` is - * kept, and the anchor click that would save it is recorded instead. - */ let downloads: { name: string; blob: Blob }[] = [] let createObjectURL: typeof URL.createObjectURL let clickAnchor: typeof HTMLAnchorElement.prototype.click @@ -62,7 +58,6 @@ beforeEach(() => { return 'blob:stub' } HTMLAnchorElement.prototype.click = function click(this: HTMLAnchorElement) { - // Only a download anchor is ours; anything else still behaves. if (this.download && pending) downloads.push({ name: this.download, blob: pending }) else clickAnchor.call(this) } @@ -89,8 +84,6 @@ describe('toolbar export menu', () => { await page.getByRole('button', { name: 'Export CSV' }).click() await expect.element(page.getByRole('menuitem', { name: 'All rows' })).toBeVisible() - // Nothing is selected, so exporting the selection would produce an - // empty file: the item is offered but not usable. const selected = page.getByRole('menuitem', { name: 'Selected rows' }) await expect.element(selected).toBeVisible() await expect.element(selected).toHaveAttribute('aria-disabled', 'true') @@ -137,8 +130,6 @@ describe('toolbar export menu', () => { await page.getByRole('menuitem', { name: 'All rows' }).click() await expect.poll(() => downloads).toHaveLength(1) - // "All rows" means all the filter left, which is the set the user is - // actually looking at. const csv = await bodyOf() expect(csv).toContain('Ada,Core') expect(csv).not.toContain('Linus,Data') @@ -161,8 +152,6 @@ describe('toolbar export menu', () => { }) it('is absent when the grid cannot export', async () => { - // Export lives on the selection feature; without it the button would - // only ever be dead. const screen = await render(TypedDataGrid, { grid: makeGrid(false), toolbar: true }) await expect.element(screen.getByRole('grid')).toBeVisible() diff --git a/src/tests/feature-layers.svelte.test.ts b/src/tests/feature-layers.svelte.test.ts index c6b4e53..315ee95 100644 --- a/src/tests/feature-layers.svelte.test.ts +++ b/src/tests/feature-layers.svelte.test.ts @@ -3,27 +3,16 @@ import { render } from 'vitest-browser-svelte' import { page } from 'vitest/browser' import FeatureLayers from './fixtures/FeatureLayers.svelte' -/** - * What `GridFeature.component` is for. Neither of the two things checked here - * is reachable by importing anything from `DataGrid`: the layer is named by - * the feature the app registered, and the panel takes the grid out of context - * rather than off a prop. - */ describe('a feature that contributes a component', () => { it('mounts the layer into the grid root, once per grid', async () => { render(FeatureLayers as never) await expect.element(page.getByRole('treegrid')).toBeVisible() - // `rangeSelection()` contributes `RangeLayer`, which draws nothing and - // marks the root it listens on. await expect.poll(() => document.querySelectorAll('[data-dg-range-layer]').length).toBe(1) }) it('draws a chrome part that was never handed a grid', async () => { render(FeatureLayers as never) - // `GroupPanel` takes no `grid` prop and no `ui`: the grid comes from - // the context `Grid.Root` set, and the wording from that grid's own - // labels rather than from a table of its own. const panel = () => document.querySelector('[data-dg-group-panel]')?.textContent ?? '' await expect.poll(panel).toContain('Group by') await expect.poll(panel).toContain('No grouping') diff --git a/src/tests/filter-builder.svelte.test.ts b/src/tests/filter-builder.svelte.test.ts index d3aeeaf..be8ee32 100644 --- a/src/tests/filter-builder.svelte.test.ts +++ b/src/tests/filter-builder.svelte.test.ts @@ -61,7 +61,6 @@ const data: Sale[] = [ } ] -// The panel takes its grid from context, so it is mounted inside one. const TypedBuilder = InGrid const inRoot = (grid: GridState, partProps?: { debounce?: number }) => ({ diff --git a/src/tests/filter-row.svelte.test.ts b/src/tests/filter-row.svelte.test.ts index d877ae5..5240386 100644 --- a/src/tests/filter-row.svelte.test.ts +++ b/src/tests/filter-row.svelte.test.ts @@ -76,21 +76,8 @@ async function renderGrid(grid: GridState) { return screen } -/** What `GridFilterCell` waits before a typed value reaches the model. */ const FILTER_DEBOUNCE_MS = 200 -/** - * Types through the driver and reports the most writes a working debounce could - * have produced. - * - * Counting writes only means something against the time the typing took. The - * browser driver round-trips once per key, and on a loaded machine that round - * trip outlasts the window, so the debounce fires between keystrokes and a - * write per key is correct rather than a regression. A debounce can write once - * per window it is left alone for, plus once at the end, which is the bound - * this returns: exactly 1 when the typing fitted in one window, which is what - * an unloaded machine and CI do. - */ async function typeWithin(keys: string) { const started = performance.now() await userEvent.keyboard(keys) @@ -104,13 +91,11 @@ describe('the filter row', () => { expect(row.querySelectorAll('[role="gridcell"]')).toHaveLength(columns.length) - // One control per column, each the one its filter needs. const cell = (index: number) => filterCell(screen.container, index) - expect(cell(0).querySelector('input:not([role])')).not.toBeNull() // text - expect(cell(1).querySelector('[role="combobox"]')).not.toBeNull() // set - expect(cell(2).querySelector('input[role="spinbutton"]')).not.toBeNull() // number - expect(cell(3).querySelector('[data-select-trigger]')).not.toBeNull() // boolean - // `note` declares no filter, so its cell stays empty. + expect(cell(0).querySelector('input:not([role])')).not.toBeNull() + expect(cell(1).querySelector('[role="combobox"]')).not.toBeNull() + expect(cell(2).querySelector('input[role="spinbutton"]')).not.toBeNull() + expect(cell(3).querySelector('[data-select-trigger]')).not.toBeNull() expect(cell(4).textContent?.trim()).toBe('') }) @@ -170,12 +155,9 @@ describe('the filter row', () => { const screen = await renderGrid(grid) const cell = filterCell(screen.container, 1) - // Nothing chosen reads as the any choice, the same word the boolean - // column uses for it. expect(cell.textContent).toContain('(any)') await page.getByRole('button', { name: 'Team filter value' }).click() - // The values come from the column, and only once the list is opened. await expect.element(page.getByRole('option', { name: 'Core' })).toBeVisible() await page.getByRole('option', { name: 'Core' }).click() @@ -184,7 +166,6 @@ describe('the filter row', () => { .toEqual({ kind: 'set', values: ['Core'] }) await expect.poll(() => bodyRows(screen.container)).toBe(2) - // A second value is the same one condition, with two values in it. await page.getByRole('option', { name: 'Tools' }).click() await expect .poll(() => getFiltering(grid)!.columnFilters['team'], { timeout: 2000 }) @@ -207,7 +188,6 @@ describe('the filter row', () => { it('hands a set column back to the panel when it holds something else', async () => { const grid = makeGrid() const screen = await renderGrid(grid) - // Not a shape this row wrote, so it is not one it may flatten. getFiltering(grid)!.setColumnFilter('team', { kind: 'text', op: 'contains', value: 'Co' }) const cell = filterCell(screen.container, 1) @@ -221,7 +201,6 @@ describe('the filter row', () => { const trigger = () => filterCell(screen.container, 3).querySelector('[data-select-trigger]')! - // Not an empty control: unfiltered reads as the any choice. expect(trigger().textContent?.trim()).toBe('(any)') getFiltering(grid)!.setColumnFilter('active', { kind: 'boolean', value: true }) @@ -234,7 +213,6 @@ describe('the filter row', () => { it('filters from the boolean choice, and clears back to any', async () => { const grid = makeGrid() const screen = await renderGrid(grid) - // The sv5ui Select is a button opening a portalled listbox. const open = () => page.getByRole('button', { name: 'Active filter value' }).click() await open() @@ -257,7 +235,6 @@ describe('the filter row', () => { it('opens the column one panel, not a second of its own', async () => { const grid = makeGrid() const screen = await renderGrid(grid) - // A range needs two bounds, so this cell is the one that asks. getFiltering(grid)!.setColumnFilter('age', { kind: 'number', op: 'between', @@ -267,8 +244,6 @@ describe('the filter row', () => { const cell = filterCell(screen.container, 2) await expect.poll(() => cell.querySelector('button')).not.toBeNull() - // The row asks; the header's panel answers. Two instances of the panel - // for one column both read the same open flag and both appear. await cell.querySelector('button')!.click() await expect.poll(() => document.querySelectorAll('[role="dialog"]').length).toBe(1) @@ -300,7 +275,6 @@ describe('the filter row', () => { const screen = await renderGrid(makeGrid()) const grid = screen.container.querySelector('[role="grid"]')! - // One header row plus the filter row plus three body rows. expect(grid.getAttribute('aria-rowcount')).toBe('5') expect(filterRow(screen.container)!.getAttribute('aria-rowindex')).toBe('2') expect( @@ -320,7 +294,6 @@ describe('the filter row', () => { await userEvent.keyboard('{ArrowDown}') await expect.poll(() => grid.focus.active).toEqual({ row: -2, col: 0 }) - // The cell passes focus on, or the row could be reached and not typed in. await expect.poll(() => document.activeElement?.tagName).toBe('INPUT') await userEvent.keyboard('{ArrowDown}') @@ -387,7 +360,6 @@ describe('the data ops demo', () => { const cell = filterCell(screen.container, 0) expect(getComputedStyle(cell).position).toBe('sticky') - // And the cell beside it, on an unpinned column, does not. expect(getComputedStyle(filterCell(screen.container, 2)).position).not.toBe('sticky') }) @@ -410,8 +382,6 @@ describe('the data ops demo', () => { const headerCells = drawn('[data-dg-cell^="-1:"]') expect(headerCells).toBeGreaterThan(0) expect(headerCells).toBeLessThan(many.length) - // One filter cell per drawn header cell: the row cannot lag the window - // it shares, or a field would sit over the wrong column. expect(drawn('[data-dg-cell^="-2:"]')).toBe(headerCells) }) @@ -428,7 +398,6 @@ describe('the data ops demo', () => { input.focus() await userEvent.keyboard('a') - // Type-to-edit reads the focused row, and the filter row is not one. expect(getEditing(grid)!.active).toBeNull() expect(getEditing(grid)!.rowEditId).toBeNull() await expect.poll(() => input.value).toBe('a') @@ -446,8 +415,6 @@ describe('the data ops demo', () => { const input = filterCell(screen.container, 0).querySelector('input')! await userEvent.fill(input, 'ada') - // The model is what an app forwards; the rows stay as the server sent - // them, because filtering them again would drop rows it meant to send. await expect .poll(() => getFiltering(grid)!.columnFilters['name'], { timeout: 2000 }) .toEqual({ kind: 'text', op: 'contains', value: 'ada' }) @@ -484,7 +451,6 @@ describe('the data ops demo', () => { const before = { ...grid.focus.active } await userEvent.keyboard('{ArrowDown}') - // The list is moving its own highlight; the grid must not also move. expect(grid.focus.active).toEqual(before) }) @@ -507,7 +473,6 @@ describe('the data ops demo', () => { }) const screen = await renderGrid(grid) - // Group level, leaf header, filter row, then the three body rows. expect(screen.container.querySelector('[role="grid"]')?.getAttribute('aria-rowcount')).toBe( '6' ) @@ -524,8 +489,6 @@ describe('the data ops demo', () => { const screen = await renderGrid(grid) const cell = filterCell(screen.container, 2) - // The number widget, not a bare text box: a spinbutton with the - // steppers taken out of the way. const input = cell.querySelector('input[role="spinbutton"]')! expect(input).not.toBeNull() expect(cell.querySelector('input[type="text"]:not([role])')).toBeNull() @@ -564,7 +527,6 @@ describe('the data ops demo', () => { await expect.poll(() => cell.textContent).toContain('2026') await expect.poll(() => bodyRows(screen.container)).toBe(1) - // A picker has no clear of its own, so the cell carries one. await page.getByRole('button', { name: 'Remove filter Joined' }).click() await expect.poll(() => getFiltering(grid)!.columnFilters['joined']).toBeUndefined() await expect.poll(() => bodyRows(screen.container)).toBe(2) @@ -586,20 +548,11 @@ describe('the data ops demo', () => { value: '2026-01-05' }) - // Day before month, as the language writes it. await expect .poll(() => filterCell(screen.container, 0).textContent?.replace(/\s+/g, '')) .toContain('05/01/2026') }) - /** - * A segmented date field reports every keystroke. Typing 01/05/2026 walks - * the year through 2, 20 and 202, and each of those is a complete date as - * far as the field is concerned. Un-debounced, that was three filters over - * the whole set, the first two on years nobody asked for, and the last of - * them left the model empty because the value pushed back into the field - * was one the typing had already moved past. - */ it('writes the model once for a date typed a digit at a time', async () => { const grid = createDataGrid({ columns: [{ id: 'joined', header: 'Joined', width: 260, filter: 'date' }], @@ -662,7 +615,6 @@ describe('the data ops demo', () => { await expect.element(clear).toBeVisible() await clear.click() - // A finished gesture, so no debounce stands between it and the model. expect(getFiltering(grid)!.columnFilters['joined']).toBeUndefined() expect(filterCell(screen.container, 0).textContent).not.toContain('2026') }) @@ -690,14 +642,12 @@ describe('the data ops demo', () => { 'input[role="spinbutton"]' )! - // 5 in the field is 5%, and 5% is 0.05 in the row. await userEvent.fill(input, '5') await expect .poll(() => getFiltering(grid)!.columnFilters['age'], { timeout: 2000 }) .toEqual({ kind: 'number', op: 'eq', value: 0.05 }) await expect.poll(() => bodyRows(screen.container)).toBe(1) - // And back the other way: what the panel stored reads as 50, not 0.5. getFiltering(grid)!.setColumnFilter('age', { kind: 'number', op: 'eq', value: 0.5 }) await expect.poll(() => input.value).toBe('50') }) @@ -733,8 +683,6 @@ describe('the data ops demo', () => { }) const screen = await renderGrid(grid) - // The pinned block sticks below the header, and the header is one row - // taller than it was. const row = filterRow(screen.container)!.getBoundingClientRect() const pinned = screen.container .querySelector('[data-dg-pinned-cell^="top:"]')! @@ -752,8 +700,6 @@ describe('the data ops demo', () => { getFiltering(grid)!.floatingRow = false - // A grid whose only tab stop was a row it no longer draws cannot be - // tabbed into at all. await expect.poll(() => grid.focus.active).toEqual({ row: -1, col: 0 }) expect(screen.container.querySelectorAll('[data-dg-cell][tabindex="0"]')).toHaveLength(1) }) @@ -784,8 +730,6 @@ describe('the data ops demo', () => { const grid = makeGrid() const screen = await renderGrid(grid) - // The bare token, not the `group-hover:` and `hover:none` variants - // the base class already carries. const pinned = () => screen.container .querySelector('[data-dg-cell="-1:0"]')! @@ -793,15 +737,10 @@ describe('the data ops demo', () => { .className.split(/\s+/) .includes('opacity-100') - // Hidden until hovered, so the header label gets the cell. expect(pinned()).toBe(false) - // What the column menu's Filter item does, and what the filter row - // does for a column it hands back to the panel. getFiltering(grid)!.filterFor = 'name' - // The panel is anchored to that trigger and portalled away from it, so - // `focus-within` cannot hold it on screen by itself. await expect.poll(pinned).toBe(true) await expect.element(page.getByRole('dialog', { name: 'Filter Name' })).toBeVisible() }) @@ -821,7 +760,6 @@ describe('the data ops demo', () => { await userEvent.keyboard('{ArrowDown}') await expect.poll(() => document.activeElement?.getAttribute('role')).toBe('spinbutton') - // The first of the three, so the date can be typed from its start. const segments = [ ...filterCell(screen.container, 0).querySelectorAll('[role="spinbutton"]') ] @@ -860,7 +798,6 @@ describe('the data ops demo', () => { header: 'Team', width: 220, filter: 'set', - // Every read of this column passes through here. accessor: (row) => { reads++ return row.team @@ -873,15 +810,12 @@ describe('the data ops demo', () => { }) await renderGrid(grid) - // A page of ten cells, not a pass over five hundred rows: drawing a - // filter row nobody has touched must not scan the whole column. const drawn = reads expect(drawn).toBeLessThan(100) await page.getByRole('button', { name: 'Team filter value' }).click() await expect.element(page.getByRole('option', { name: 'T0' })).toBeVisible() - // And now it has read them all, once. expect(reads - drawn).toBeGreaterThanOrEqual(500) }) }) diff --git a/src/tests/filters.svelte.test.ts b/src/tests/filters.svelte.test.ts index 9d62fa0..fb9c627 100644 --- a/src/tests/filters.svelte.test.ts +++ b/src/tests/filters.svelte.test.ts @@ -97,8 +97,6 @@ describe('column filters', () => { }) await expect.element(screen.getByRole('grid')).toBeVisible() - // Open via the column menu, not the filter icon - the path that used to - // leave the panel stranded at the top-left corner. await page.getByRole('button', { name: 'Name column menu' }).click() await page.getByRole('menuitem', { name: 'Filter...' }).click() @@ -108,7 +106,6 @@ describe('column filters', () => { const trigger = screen.container.querySelector('[aria-label="Filter Name"]')! const panelRect = (dialog.element() as HTMLElement).getBoundingClientRect() const triggerRect = trigger.getBoundingClientRect() - // Not parked at the corner, and sitting just below its trigger. expect(panelRect.top).toBeGreaterThan(0) expect(panelRect.left).toBeGreaterThan(0) expect(Math.abs(panelRect.top - triggerRect.bottom)).toBeLessThan(20) @@ -126,8 +123,6 @@ describe('column filters', () => { await page.getByRole('button', { name: 'Filter Name' }).click() const input = page.getByRole('dialog', { name: 'Filter Name' }).getByRole('textbox') await input.click() - // Type key-by-key (not .fill), the path that used to lose focus to the - // header cell the panel is rendered inside. await userEvent.keyboard('car') const active = document.activeElement @@ -193,7 +188,6 @@ describe('column filters', () => { }) describe('quick filter, driven from code', () => { - /** The toolbar is the default shape of a grid, so it renders here. */ async function renderWithToolbar(grid: GridState) { const screen = await render(TypedDataGrid, { grid, toolbar: true }) await expect.element(screen.getByRole('grid')).toBeVisible() @@ -206,8 +200,6 @@ describe('quick filter, driven from code', () => { const grid = makeGrid() await renderWithToolbar(grid) - // The box used to own both directions of the sync, so it wrote its own - // empty value back over this call before anything rendered. getFiltering(grid)!.setQuickFilter('Core') await expect.poll(() => grid.nodes.length).toBe(2) await expect.poll(() => box()?.value).toBe('Core') @@ -275,14 +267,11 @@ describe('the date condition, through the sv5ui picker', () => { const dialog = page.getByRole('dialog', { name: 'Filter Joined' }) await expect.element(dialog).toBeVisible() - // A segmented field and a calendar, not the browser's own date input. expect(document.querySelector('input[type="date"]')).toBeNull() await dialog.getByRole('button', { name: 'Open calendar' }).click() await page.getByRole('button', { name: '15' }).click() - // The calendar is portalled out of the dialog; the panel's click - // handler has to know that is still itself, or picking closes it. await expect.element(dialog).toBeVisible() await dialog.getByRole('button', { name: 'Apply' }).click() @@ -306,10 +295,6 @@ describe('typing a date into the panel', () => { const dialog = page.getByRole('dialog', { name: 'Filter Joined' }) await expect.element(dialog).toBeVisible() - // The field is controlled by the draft it writes to, so every - // keystroke round-trips through an ISO string and back. A year mid-way - // to 2025 is 2, then 20, then 202, and the trip has to return each of - // them unchanged or the segments fight what is being typed. const segment = screen.container.ownerDocument.querySelector( '[role="dialog"] [role="spinbutton"]' )! @@ -343,7 +328,6 @@ describe('two conditions on one column', () => { return { screen, dialog } } - /** The sv5ui Select is a button opening a portalled listbox, not a