diff --git a/package-lock.json b/package-lock.json index 5d318bd..c5af195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "svelte-headless-table", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "svelte-headless-table", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { "svelte-keyed": "^1.1.5", diff --git a/package.json b/package.json index e74cc18..494392b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "svelte-headless-table", - "version": "0.6.1", + "version": "0.7.0", "scripts": { "dev": "svelte-kit dev", "build": "svelte-kit build", diff --git a/src/lib/bodyCells.HeaderCell.render.test.ts b/src/lib/bodyCells.HeaderCell.render.test.ts index 88b3e60..1a08298 100644 --- a/src/lib/bodyCells.HeaderCell.render.test.ts +++ b/src/lib/bodyCells.HeaderCell.render.test.ts @@ -10,7 +10,17 @@ interface User { status: string; } -class TestHeaderCell extends HeaderCell {} +class TestHeaderCell extends HeaderCell { + clone(): TestHeaderCell { + return new TestHeaderCell({ + id: this.id, + colspan: this.colspan, + label: this.label, + isData: this.isData, + isFlat: this.isFlat, + }); + } +} it('renders string label', () => { const actual = new TestHeaderCell({ diff --git a/src/lib/bodyCells.ts b/src/lib/bodyCells.ts index c4c818e..cf51dd6 100644 --- a/src/lib/bodyCells.ts +++ b/src/lib/bodyCells.ts @@ -32,8 +32,10 @@ export abstract class BodyCell< abstract attrs(): Readable>; + abstract clone(): BodyCell; + rowColId(): string { - return `${this.row.id}-${this.column.id}`; + return `${this.row.id}:${this.column.id}`; } } @@ -81,6 +83,17 @@ export class DataBodyCell< return {}; }); } + + clone(): DataBodyCell { + const clonedCell = new DataBodyCell({ + row: this.row, + column: this.column, + label: this.label, + value: this.value, + }); + clonedCell.metadataForName = this.metadataForName; + return clonedCell; + } } export type DisplayBodyCellInit = Omit< @@ -115,4 +128,14 @@ export class DisplayBodyCell exte return {}; }); } + + clone(): DisplayBodyCell { + const clonedCell = new DisplayBodyCell({ + row: this.row, + column: this.column, + label: this.label, + }); + clonedCell.metadataForName = this.metadataForName; + return clonedCell; + } } diff --git a/src/lib/bodyRows.ts b/src/lib/bodyRows.ts index 8b84a91..a2f4585 100644 --- a/src/lib/bodyRows.ts +++ b/src/lib/bodyRows.ts @@ -3,7 +3,6 @@ import { BodyCell, DataBodyCell, DisplayBodyCell } from './bodyCells'; import { DataColumn, DisplayColumn, type FlatColumn } from './columns'; import { TableComponent } from './tableComponent'; import type { AnyPlugins } from './types/TablePlugin'; -import { getCloned } from './utils/clone'; import { nonUndefined } from './utils/filter'; export interface BodyRowInit { @@ -17,6 +16,10 @@ export interface BodyRowInit { // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-interface export interface BodyRowAttributes {} +interface BodyRowCloneProps { + includeCells?: boolean; +} + export class BodyRow extends TableComponent< Item, Plugins, @@ -45,6 +48,31 @@ export class BodyRow extends Tabl return {}; }); } + + clone({ includeCells = false }: BodyRowCloneProps = {}): BodyRow { + const clonedRow = new BodyRow({ + id: this.id, + cellForId: this.cellForId, + cells: this.cells, + original: this.original, + depth: this.depth, + }); + clonedRow.metadataForName = this.metadataForName; + if (!includeCells) { + return clonedRow; + } + const clonedCellsForId = Object.fromEntries( + Object.entries(clonedRow.cellForId).map(([id, cell]) => { + const clonedCell = cell.clone(); + clonedCell.row = clonedRow; + return [id, clonedCell]; + }) + ); + const clonedCells = clonedRow.cells.map(({ id }) => clonedCellsForId[id]); + clonedRow.cellForId = clonedCellsForId; + clonedRow.cells = clonedCells; + return clonedRow; + } } /** @@ -120,9 +148,9 @@ export const getColumnedBodyRows = { - return getCloned(cell, { - row: columnedRows[rowIdx], - }); + const clonedCell = cell.clone(); + clonedCell.row = columnedRows[rowIdx]; + return clonedCell; }); const visibleCells = columnIdOrder .map((cid) => { diff --git a/src/lib/headerCells.ts b/src/lib/headerCells.ts index 7abf167..48009a0 100644 --- a/src/lib/headerCells.ts +++ b/src/lib/headerCells.ts @@ -51,6 +51,8 @@ export abstract class HeaderCell< }; }); } + + abstract clone(): HeaderCell; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -72,6 +74,14 @@ export class FlatHeaderCell exten constructor({ id, label, isData }: FlatHeaderCellInit) { super({ id, label, isData, colspan: 1, isFlat: true }); } + + clone(): FlatHeaderCell { + return new FlatHeaderCell({ + id: this.id, + label: this.label, + isData: this.isData, + }); + } } export type DataHeaderCellInit = Omit< @@ -93,6 +103,15 @@ export class DataHeaderCell exten this.accessorKey = accessorKey; this.accessorFn = accessorFn; } + + clone(): DataHeaderCell { + return new DataHeaderCell({ + id: this.id, + label: this.label, + accessorFn: this.accessorFn, + accessorKey: this.accessorKey, + }); + } } export type DisplayHeaderCellInit = Omit< @@ -109,6 +128,13 @@ export class DisplayHeaderCell< constructor({ id, label = NBSP }: DisplayHeaderCellInit) { super({ id, label }); } + + clone(): DisplayHeaderCell { + return new DisplayHeaderCell({ + id: this.id, + label: this.label, + }); + } } export type GroupHeaderCellInit = Omit< @@ -132,12 +158,23 @@ export class GroupHeaderCell exte this.allId = `[${allIds.join(',')}]`; this.allIds = allIds; } + setIds(ids: string[]) { this.ids = ids; this.id = `[${this.ids.join(',')}]`; } + pushId(id: string) { this.ids = [...this.ids, id]; this.id = `[${this.ids.join(',')}]`; } + + clone(): GroupHeaderCell { + return new GroupHeaderCell({ + label: this.label, + colspan: this.colspan, + ids: this.ids, + allIds: this.allIds, + }); + } } diff --git a/src/lib/headerRows.ts b/src/lib/headerRows.ts index 0d11df7..f24c046 100644 --- a/src/lib/headerRows.ts +++ b/src/lib/headerRows.ts @@ -9,7 +9,6 @@ import { import { TableComponent } from './tableComponent'; import type { Matrix } from './types/Matrix'; import type { AnyPlugins } from './types/TablePlugin'; -import { getCloned } from './utils/clone'; import { sum } from './utils/math'; import { getNullMatrix, getTransposed } from './utils/matrix'; @@ -31,6 +30,13 @@ export class HeaderRow extends Ta super({ id }); this.cells = cells; } + + clone(): HeaderRow { + return new HeaderRow({ + id: this.id, + cells: this.cells, + }); + } } export const getHeaderRows = ( @@ -173,7 +179,7 @@ export const getMergedRow = ( let startIdx = 0; let endIdx = 1; while (startIdx < cells.length) { - const cell = getCloned(cells[startIdx]); + const cell = cells[startIdx].clone(); if (!(cell instanceof GroupHeaderCell)) { mergedCells.push(cell); startIdx++; diff --git a/src/lib/plugins/addColumnFilters.ts b/src/lib/plugins/addColumnFilters.ts index 7c7537e..0aefb35 100644 --- a/src/lib/plugins/addColumnFilters.ts +++ b/src/lib/plugins/addColumnFilters.ts @@ -6,7 +6,6 @@ import type { RenderConfig } from '$lib/render'; import type { PluginInitTableState } from '$lib/createViewModel'; import { DataBodyCell } from '$lib/bodyCells'; import { DataHeaderCell } from '$lib/headerCells'; -import { getCloned } from '$lib/utils/clone'; export interface ColumnFiltersState { filterValues: Writable>; @@ -54,7 +53,7 @@ const getFilteredRows = >( filterValues: Record, columnOptions: Record> ): Row[] => { - const _filteredRows = rows + const $filteredRows = rows // Filter `subRows` .map((row) => { const { subRows } = row; @@ -62,9 +61,9 @@ const getFilteredRows = >( return row; } const filteredSubRows = getFilteredRows(subRows, filterValues, columnOptions); - return getCloned(row, { - subRows: filteredSubRows, - } as unknown as Row); + const clonedRow = row.clone() as Row; + clonedRow.subRows = filteredSubRows; + return clonedRow; }) .filter((row) => { if ((row.subRows?.length ?? 0) !== 0) { @@ -87,7 +86,7 @@ const getFilteredRows = >( } return true; }); - return _filteredRows; + return $filteredRows; }; export const addColumnFilters = diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts new file mode 100644 index 0000000..00b3d06 --- /dev/null +++ b/src/lib/plugins/addGroupBy.ts @@ -0,0 +1,270 @@ +import { DataBodyCell } from '$lib/bodyCells'; +import { BodyRow } from '$lib/bodyRows'; +import type { DataColumn } from '$lib/columns'; +import type { DataLabel } from '$lib/types/Label'; +import type { DeriveRowsFn, NewTablePropSet, TablePlugin } from '$lib/types/TablePlugin'; +import { isShiftClick } from '$lib/utils/event'; +import { nonUndefined } from '$lib/utils/filter'; +import { arraySetStore } from '$lib/utils/store'; +import { derived, writable, type Readable, type Writable } from 'svelte/store'; + +export interface GroupByConfig { + initialGroupByIds?: string[]; + disableMultiGroup?: boolean; + isMultiGroupEvent?: (event: Event) => boolean; +} + +export interface GroupByState { + groupByIds: Writable; +} + +export interface GroupByColumnOptions< + Item, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Value = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + GroupOn extends string | number = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Aggregate = any +> { + disable?: boolean; + getAggregateValue?: (values: GroupOn[]) => Aggregate; + getGroupOn?: (value: Value) => GroupOn; + cell?: DataLabel; +} + +export type GroupByPropSet = NewTablePropSet<{ + 'thead.tr.th': { + grouped: boolean; + toggle: (event: Event) => void; + clear: () => void; + }; + 'tbody.tr.td': { + repeated: boolean; + aggregated: boolean; + grouped: boolean; + }; +}>; + +interface GetGroupedRowsMetadata { + repeatCellIds: Record; + aggregateCellIds: Record; + groupCellIds: Record; + allGroupByIds: string[]; +} + +const getIdPrefix = (id: string): string => { + const prefixTokens = id.split('>').slice(0, -1); + if (prefixTokens.length === 0) { + return ''; + } + return `${prefixTokens.join('>')}>`; +}; + +const getIdLeaf = (id: string): string => { + const tokens = id.split('>'); + return tokens[tokens.length - 1] ?? ''; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const getGroupedRows = < + Item, + Row extends BodyRow, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + GroupOn extends string | number = any +>( + rows: Row[], + groupByIds: string[], + columnOptions: Record>, + { repeatCellIds, aggregateCellIds, groupCellIds, allGroupByIds }: GetGroupedRowsMetadata +): Row[] => { + if (groupByIds.length === 0) { + return rows; + } + if (rows.length === 0) { + return rows; + } + const idPrefix = getIdPrefix(rows[0].id); + const [groupById, ...restIds] = groupByIds; + + const subRowsForGroupOnValue = new Map(); + for (const row of rows) { + const cell = row.cellForId[groupById]; + if (!(cell instanceof DataBodyCell)) { + break; + } + const columnOption = columnOptions[groupById] ?? {}; + const { getGroupOn } = columnOption; + const groupOnValue = getGroupOn === undefined ? cell.value : getGroupOn(cell.value); + if (typeof groupOnValue === 'function' || typeof groupOnValue === 'object') { + console.warn( + `Missing \`getGroupOn\` column option to aggregate column "${groupById}" with object values` + ); + } + const subRows = subRowsForGroupOnValue.get(groupOnValue) ?? []; + subRowsForGroupOnValue.set(groupOnValue, [...subRows, row]); + } + + const groupedRows: Row[] = []; + let groupRowIdx = 0; + for (const [groupOnValue, subRows] of subRowsForGroupOnValue.entries()) { + // Guaranteed to have at least one subRow. + const firstRow = subRows[0]; + const groupRow = new BodyRow({ + id: `${idPrefix}${groupRowIdx++}`, + // TODO Differentiate data rows and grouped rows. + original: firstRow.original, + depth: firstRow.depth, + cells: [], + cellForId: {}, + }); + const groupRowCellForId = Object.fromEntries( + Object.entries(firstRow.cellForId).map(([id, cell]) => { + if (id === groupById) { + const newCell = new DataBodyCell({ + column: cell.column as DataColumn, + row: groupRow, + value: groupOnValue, + }); + return [id, newCell]; + } + const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined); + if (!(columnCells[0] instanceof DataBodyCell)) { + const clonedCell = columnCells[0].clone(); + clonedCell.row = groupRow; + return [id, clonedCell]; + } + const { cell: label, getAggregateValue } = columnOptions[id] ?? {}; + const columnValues = (columnCells as DataBodyCell[]).map((cell) => cell.value); + const value = getAggregateValue === undefined ? '' : getAggregateValue(columnValues); + const newCell = new DataBodyCell({ + column: cell.column as DataColumn, + row: groupRow, + value, + label, + }); + return [id, newCell]; + }) + ); + const groupRowCells = firstRow.cells.map((cell) => { + return groupRowCellForId[cell.id]; + }); + groupRow.cellForId = groupRowCellForId; + groupRow.cells = groupRowCells; + const groupRowSubRows = subRows.map((subRow) => { + const clonedRow = subRow.clone({ includeCells: true }); + clonedRow.id = `${groupRow.id}>${getIdLeaf(subRow.id)}`; + clonedRow.depth = subRow.depth + 1; + return clonedRow; + }); + groupRow.subRows = getGroupedRows(groupRowSubRows, restIds, columnOptions, { + repeatCellIds, + aggregateCellIds, + groupCellIds, + allGroupByIds, + }); + groupedRows.push(groupRow as Row); + groupRow.cells.forEach((cell) => { + if (cell.id === groupById) { + groupCellIds[cell.rowColId()] = true; + } else { + aggregateCellIds[cell.rowColId()] = true; + } + }); + groupRow.subRows.forEach((subRow) => { + subRow.cells.forEach((cell) => { + if (allGroupByIds.includes(cell.id) && groupCellIds[cell.rowColId()] !== true) { + repeatCellIds[cell.rowColId()] = true; + } + }); + }); + } + return groupedRows; +}; + +export const addGroupBy = + ({ + initialGroupByIds = [], + disableMultiGroup = false, + isMultiGroupEvent = isShiftClick, + }: GroupByConfig = {}): TablePlugin< + Item, + GroupByState, + GroupByColumnOptions, + GroupByPropSet + > => + ({ columnOptions }) => { + const disabledGroupIds = Object.entries(columnOptions) + .filter(([, option]) => option.disable === true) + .map(([columnId]) => columnId); + + const groupByIds = arraySetStore(initialGroupByIds); + + const repeatCellIds = writable>({}); + const aggregateCellIds = writable>({}); + const groupCellIds = writable>({}); + + const pluginState: GroupByState = { + groupByIds, + }; + + const deriveRows: DeriveRowsFn = (rows) => { + return derived([rows, groupByIds], ([$rows, $groupByIds]) => { + const $repeatCellIds = {}; + const $aggregateCellIds = {}; + const $groupCellIds = {}; + const $groupedRows = getGroupedRows($rows, $groupByIds, columnOptions, { + repeatCellIds: $repeatCellIds, + aggregateCellIds: $aggregateCellIds, + groupCellIds: $groupCellIds, + allGroupByIds: $groupByIds, + }); + repeatCellIds.set($repeatCellIds); + aggregateCellIds.set($aggregateCellIds); + groupCellIds.set($groupCellIds); + return $groupedRows; + }); + }; + + return { + pluginState, + deriveRows, + hooks: { + 'thead.tr.th': (cell) => { + const disabled = disabledGroupIds.includes(cell.id); + const props = derived(groupByIds, ($groupByIds) => { + const grouped = $groupByIds.includes(cell.id); + const toggle = (event: Event) => { + if (!cell.isData) return; + if (disabled) return; + groupByIds.toggle(cell.id, { + clearOthers: disableMultiGroup || !isMultiGroupEvent(event), + }); + }; + const clear = () => { + groupByIds.remove(cell.id); + }; + return { + grouped, + toggle, + clear, + }; + }); + return { props }; + }, + 'tbody.tr.td': (cell) => { + const props: Readable = derived( + [repeatCellIds, aggregateCellIds, groupCellIds], + ([$repeatCellIds, $aggregateCellIds, $groupCellIds]) => { + return { + repeated: $repeatCellIds[cell.rowColId()] === true, + aggregated: $aggregateCellIds[cell.rowColId()] === true, + grouped: $groupCellIds[cell.rowColId()] === true, + }; + } + ); + return { props }; + }, + }, + }; + }; diff --git a/src/lib/plugins/addSortBy.ts b/src/lib/plugins/addSortBy.ts index 0d750ff..ba49661 100644 --- a/src/lib/plugins/addSortBy.ts +++ b/src/lib/plugins/addSortBy.ts @@ -1,8 +1,8 @@ import { DataBodyCell } from '$lib/bodyCells'; import type { BodyRow } from '$lib/bodyRows'; import type { TablePlugin, NewTablePropSet, DeriveRowsFn } from '$lib/types/TablePlugin'; -import { getCloned } from '$lib/utils/clone'; import { compare } from '$lib/utils/compare'; +import { isShiftClick } from '$lib/utils/event'; import { derived, writable, type Readable, type Writable } from 'svelte/store'; export interface SortByConfig { @@ -96,27 +96,23 @@ export type WritableSortKeys = Writable & { clearId: (id: string) => void; }; -const isShiftClick = (event: Event) => { - if (!(event instanceof MouseEvent)) return false; - return event.shiftKey; -}; - const getSortedRows = >( rows: Row[], sortKeys: SortKey[], columnOptions: Record ): Row[] => { // Shallow clone to prevent sort affecting `preSortedRows`. - const _sortedRows = [...rows] as typeof rows; - _sortedRows.sort((a, b) => { + const $sortedRows = [...rows] as typeof rows; + $sortedRows.sort((a, b) => { for (const key of sortKeys) { const invert = columnOptions[key.id]?.invert ?? false; + // TODO check why cellForId returns `undefined`. const cellA = a.cellForId[key.id]; const cellB = b.cellForId[key.id]; let order = 0; // Only need to check properties of `cellA` as both should have the same // properties. - const getSortValue = columnOptions[cellA.id]?.getSortValue; + const getSortValue = columnOptions[key.id]?.getSortValue; if (!(cellA instanceof DataBodyCell)) { return 0; } @@ -146,15 +142,17 @@ const getSortedRows = >( } return 0; }); - for (let i = 0; i < _sortedRows.length; i++) { - const { subRows } = _sortedRows[i]; + for (let i = 0; i < $sortedRows.length; i++) { + const { subRows } = $sortedRows[i]; if (subRows === undefined) { continue; } const sortedSubRows = getSortedRows(subRows as Row[], sortKeys, columnOptions); - _sortedRows[i] = getCloned(_sortedRows[i], { subRows: sortedSubRows } as unknown as Row); + const clonedRow = $sortedRows[i].clone() as Row; + clonedRow.subRows = sortedSubRows; + $sortedRows[i] = clonedRow; } - return _sortedRows; + return $sortedRows; }; export const addSortBy = @@ -197,7 +195,7 @@ export const addSortBy = const key = $sortKeys.find((k) => k.id === cell.id); const toggle = (event: Event) => { if (!cell.isData) return; - if (disabledSortIds.includes(cell.id)) return; + if (disabled) return; sortKeys.toggleId(cell.id, { multiSort: disableMultiSort ? false : isMultiSortEvent(event), }); diff --git a/src/lib/plugins/addTableFilter.ts b/src/lib/plugins/addTableFilter.ts index 8ba509a..eb61705 100644 --- a/src/lib/plugins/addTableFilter.ts +++ b/src/lib/plugins/addTableFilter.ts @@ -1,7 +1,7 @@ import { DataBodyCell } from '$lib/bodyCells'; import type { BodyRow } from '$lib/bodyRows'; import type { TablePlugin, NewTablePropSet, DeriveRowsFn } from '$lib/types/TablePlugin'; -import { getCloned } from '$lib/utils/clone'; +import { recordSetStore } from '$lib/utils/store'; import { derived, writable, type Readable, type Writable } from 'svelte/store'; export interface TableFilterConfig { @@ -38,8 +38,12 @@ export type TableFilterPropSet = NewTablePropSet<{ }; }>; -interface GetFilteredRowsProps { - tableCellMatches: Writable>; +type TableFilterBodyCellMetadata = { + uuid: string; +}; + +interface GetFilteredRowsOptions { + tableCellMatches: Record; fn: TableFilterFn; includeHiddenColumns: boolean; } @@ -48,9 +52,9 @@ const getFilteredRows = >( rows: Row[], filterValue: string, columnOptions: Record>, - { tableCellMatches, fn, includeHiddenColumns }: GetFilteredRowsProps + { tableCellMatches, fn, includeHiddenColumns }: GetFilteredRowsOptions ): Row[] => { - const _filteredRows = rows + const $filteredRows = rows // Filter `subRows` .map((row) => { const { subRows } = row; @@ -62,9 +66,9 @@ const getFilteredRows = >( fn, includeHiddenColumns, }); - return getCloned(row, { - subRows: filteredSubRows, - } as unknown as Row); + const clonedRow = row.clone() as Row; + clonedRow.subRows = filteredSubRows; + return clonedRow; }) .filter((row) => { if ((row.subRows?.length ?? 0) !== 0) { @@ -88,16 +92,15 @@ const getFilteredRows = >( value = options?.getFilterValue(value); } const matches = fn({ value: String(value), filterValue }); - tableCellMatches.update(($tableCellMatches) => ({ - ...$tableCellMatches, - [cell.rowColId()]: matches, - })); + if (matches) { + tableCellMatches[cell.rowColId()] = matches; + } return matches; }); // If any cell matches, include in the filtered results. return rowCellMatches.includes(true); }); - return _filteredRows; + return $filteredRows; }; export const addTableFilter = @@ -111,25 +114,35 @@ export const addTableFilter = TableFilterColumnOptions, TableFilterPropSet > => - ({ columnOptions }) => { + ({ pluginName, columnOptions }) => { const filterValue = writable(initialFilterValue); const preFilteredRows = writable[]>([]); const filteredRows = writable[]>([]); - const tableCellMatches = writable>({}); + const tableCellMatches = recordSetStore(); const pluginState: TableFilterState = { filterValue, preFilteredRows }; const deriveRows: DeriveRowsFn = (rows) => { return derived([rows, filterValue], ([$rows, $filterValue]) => { + // Set invariant metadata to track rowColId through other transformations. + $rows.forEach((row) => { + Object.values(row.cellForId).forEach((cell) => { + cell.metadataForName[pluginName] = { + uuid: cell.rowColId(), + } as TableFilterBodyCellMetadata; + }); + }); preFilteredRows.set($rows); - tableCellMatches.set({}); - const _filteredRows = getFilteredRows($rows, $filterValue, columnOptions, { - tableCellMatches, + tableCellMatches.clear(); + const $tableCellMatches: Record = {}; + const $filteredRows = getFilteredRows($rows, $filterValue, columnOptions, { + tableCellMatches: $tableCellMatches, fn, includeHiddenColumns, }); - filteredRows.set(_filteredRows); - return _filteredRows; + tableCellMatches.set($tableCellMatches); + filteredRows.set($filteredRows); + return $filteredRows; }); }; @@ -141,8 +154,15 @@ export const addTableFilter = const props = derived( [filterValue, tableCellMatches], ([$filterValue, $tableCellMatches]) => { + // Get invariant metadata to track rowColId through other transformations. + const metadata = cell.metadataForName[pluginName] as TableFilterBodyCellMetadata; + if (metadata === undefined) { + return { + matches: false, + }; + } return { - matches: $filterValue !== '' && ($tableCellMatches[cell.rowColId()] ?? false), + matches: $filterValue !== '' && ($tableCellMatches[metadata.uuid] ?? false), }; } ); diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts index f4c7bc8..4fe0ef8 100644 --- a/src/lib/plugins/index.ts +++ b/src/lib/plugins/index.ts @@ -13,3 +13,4 @@ export { addSortBy } from './addSortBy'; export { addTableFilter } from './addTableFilter'; export { addExpandedRows } from './addExpandedRows'; export { addSubRows } from './addSubRows'; +export { addGroupBy } from './addGroupBy'; diff --git a/src/lib/tableComponent.applyHook.test.ts b/src/lib/tableComponent.applyHook.test.ts index bdd4975..9dfafb4 100644 --- a/src/lib/tableComponent.applyHook.test.ts +++ b/src/lib/tableComponent.applyHook.test.ts @@ -2,7 +2,13 @@ import { get, readable } from 'svelte/store'; import { TableComponent } from './tableComponent'; import type { AnyPlugins } from './types/TablePlugin'; -class TestComponent extends TableComponent {} +class TestComponent extends TableComponent { + clone(): TableComponent { + return new TestComponent({ + id: this.id, + }); + } +} it('hooks plugin props', () => { const component = new TestComponent({ id: '0' }); diff --git a/src/lib/tableComponent.ts b/src/lib/tableComponent.ts index 6e0ea23..29e778b 100644 --- a/src/lib/tableComponent.ts +++ b/src/lib/tableComponent.ts @@ -8,12 +8,15 @@ import type { PluginTablePropSet, } from './types/TablePlugin'; import type { TableState } from './createViewModel'; +import type { Clonable } from './utils/clone'; export interface TableComponentInit { id: string; } -export abstract class TableComponent { +export abstract class TableComponent + implements Clonable> +{ id: string; constructor({ id }: TableComponentInit) { this.id = id; @@ -23,6 +26,7 @@ export abstract class TableComponent> = {}; private propsForName: Record>> = {}; props(): Readable[Key]> { return derivedKeys(this.propsForName) as Readable[Key]>; @@ -38,4 +42,6 @@ export abstract class TableComponent; } diff --git a/src/lib/types/TablePlugin.ts b/src/lib/types/TablePlugin.ts index 0fe6e94..6c4a5b5 100644 --- a/src/lib/types/TablePlugin.ts +++ b/src/lib/types/TablePlugin.ts @@ -33,13 +33,15 @@ export type TablePluginInstance< }; export type AnyPlugins = Record< - string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TablePlugin >; export type AnyPluginInstances = Record< - string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TablePluginInstance >; diff --git a/src/lib/utils/clone.ts b/src/lib/utils/clone.ts index b14f0fd..42c493a 100644 --- a/src/lib/utils/clone.ts +++ b/src/lib/utils/clone.ts @@ -1,6 +1,21 @@ -export const getCloned = (source: T, props?: Partial): T => { - const clone = Object.create(source); - Object.assign(clone, source); +export interface Clonable { + clone(): T; +} + +export const isClonable = (obj: unknown): obj is Clonable => { + return typeof (obj as Clonable).clone === 'function'; +}; + +/** + * Create a new instance of a class instance with all properties shallow + * copied. This is unsafe as it does not re-run the constructor. Therefore, + * cloned instances will share a reference to the same property instances. + * @param source The original instance object. + * @param props Any additional properties to override. + * @returns A new instance object with all properties shallow copied. + */ +export const unsafeClone = (source: T, props?: Partial): T => { + const clone = Object.assign(Object.create(Object.getPrototypeOf(source)), source); if (props !== undefined) { Object.assign(clone, props); } diff --git a/src/lib/utils/event.ts b/src/lib/utils/event.ts new file mode 100644 index 0000000..b5506e8 --- /dev/null +++ b/src/lib/utils/event.ts @@ -0,0 +1,4 @@ +export const isShiftClick = (event: Event) => { + if (!(event instanceof MouseEvent)) return false; + return event.shiftKey; +}; diff --git a/src/lib/utils/store.arraySetStore.test.ts b/src/lib/utils/store.arraySetStore.test.ts new file mode 100644 index 0000000..fd648ad --- /dev/null +++ b/src/lib/utils/store.arraySetStore.test.ts @@ -0,0 +1,154 @@ +import { get } from 'svelte/store'; +import { arraySetStore } from './store'; + +it('initializes correctly', () => { + const actual = arraySetStore(); + + const expected: never[] = []; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('initializes with values correctly', () => { + const actual = arraySetStore([1, 2, 3]); + + const expected: number[] = [1, 2, 3]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles an existing value to remove it', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.toggle(1); + + const expected: number[] = [2, 3]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles the last value to remove it', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.toggle(3); + + const expected: number[] = [1, 2]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles a non-existing value to add it', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.toggle(4); + + const expected: number[] = [1, 2, 3, 4]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles an existing value to remove it and clears others', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.toggle(1, { clearOthers: true }); + + const expected: number[] = []; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles the last value to remove it', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.toggle(3, { clearOthers: true }); + + const expected: number[] = []; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles a non-existing value to add it', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.toggle(4, { clearOthers: true }); + + const expected: number[] = [4]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('adds a value', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.add(4); + + const expected: number[] = [1, 2, 3, 4]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('adds an existing value and changes nothing', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.add(3); + + const expected: number[] = [1, 2, 3]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('removes a value', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.remove(3); + + const expected: number[] = [1, 2]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('removes a non-existing value and changes nothing', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.remove(4); + + const expected: number[] = [1, 2, 3]; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('resets the set', () => { + const actual = arraySetStore([1, 2, 3]); + + actual.clear(); + + const expected: number[] = []; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('finds the right element with a custom isEqual function', () => { + type User = { + id: number; + name: string; + }; + const actual = arraySetStore( + [ + { id: 0, name: 'Ada' }, + { id: 1, name: 'Alan' }, + ], + { isEqual: (a, b) => a.id === b.id } + ); + + actual.add({ + id: 0, + name: 'Ken', + }); + + const expected: User[] = [ + { id: 0, name: 'Ada' }, + { id: 1, name: 'Alan' }, + ]; + + expect(get(actual)).toStrictEqual(expected); +}); diff --git a/src/lib/utils/store.recordSetStore.test.ts b/src/lib/utils/store.recordSetStore.test.ts new file mode 100644 index 0000000..e914a3e --- /dev/null +++ b/src/lib/utils/store.recordSetStore.test.ts @@ -0,0 +1,165 @@ +import { get } from 'svelte/store'; +import { recordSetStore } from './store'; + +it('initializes correctly', () => { + const actual = recordSetStore(); + + const expected: Record = {}; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('initializes with values correctly', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + const expected = { + 1: true, + 2: true, + 3: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles an existing value to remove it', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.toggle(1); + + const expected = { + 2: true, + 3: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles the last value to remove it', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.toggle(3); + + const expected = { + 1: true, + 2: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('toggles a non-existing value to add it', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.toggle(4); + + const expected = { + 1: true, + 2: true, + 3: true, + 4: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('adds a value', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.add(4); + + const expected = { + 1: true, + 2: true, + 3: true, + 4: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('adds an existing value and changes nothing', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.add(3); + + const expected = { + 1: true, + 2: true, + 3: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('removes a value', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.remove(3); + + const expected = { + 1: true, + 2: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('removes a non-existing value and changes nothing', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.remove(4); + + const expected = { + 1: true, + 2: true, + 3: true, + }; + + expect(get(actual)).toStrictEqual(expected); +}); + +it('resets the set', () => { + const actual = recordSetStore({ + 1: true, + 2: true, + 3: true, + }); + + actual.clear(); + + const expected = {}; + + expect(get(actual)).toStrictEqual(expected); +}); diff --git a/src/lib/utils/store.ts b/src/lib/utils/store.ts index 2666cc4..3a5abe3 100644 --- a/src/lib/utils/store.ts +++ b/src/lib/utils/store.ts @@ -1,4 +1,4 @@ -import { readable, type Readable, type Writable } from 'svelte/store'; +import { readable, writable, type Readable, type Writable } from 'svelte/store'; export type ReadOrWritable = Readable | Writable; @@ -26,3 +26,119 @@ export type ReadOrWritableKeys = { export const Undefined = readable(undefined); export const UndefinedAs = () => Undefined as unknown as Readable; + +export interface ToggleOptions { + clearOthers?: boolean; +} + +export interface ArraySetStoreOptions { + isEqual?: (a: T, b: T) => boolean; +} + +export interface ArraySetStore extends Writable { + toggle: (item: T, options?: ToggleOptions) => void; + add: (item: T) => void; + remove: (item: T) => void; + clear: () => void; +} + +export const arraySetStore = ( + initial: T[] = [], + { isEqual = (a, b) => a === b }: ArraySetStoreOptions = {} +): ArraySetStore => { + const { subscribe, update, set } = writable(initial); + const toggle = (item: T, { clearOthers = false }: ToggleOptions = {}) => { + update(($arraySet) => { + const index = $arraySet.findIndex(($item) => isEqual($item, item)); + if (index === -1) { + if (clearOthers) { + return [item]; + } + return [...$arraySet, item]; + } + if (clearOthers) { + return []; + } + return [...$arraySet.slice(0, index), ...$arraySet.slice(index + 1)]; + }); + }; + const add = (item: T) => { + update(($arraySet) => { + const index = $arraySet.findIndex(($item) => isEqual($item, item)); + if (index === -1) { + return [...$arraySet, item]; + } + return $arraySet; + }); + }; + const remove = (item: T) => { + update(($arraySet) => { + const index = $arraySet.findIndex(($item) => isEqual($item, item)); + if (index === -1) { + return $arraySet; + } + return [...$arraySet.slice(0, index), ...$arraySet.slice(index + 1)]; + }); + }; + const clear = () => { + set([]); + }; + return { + subscribe, + update, + set, + toggle, + add, + remove, + clear, + }; +}; + +export interface RecordSetStore extends Writable> { + toggle: (item: T) => void; + add: (item: T) => void; + remove: (item: T) => void; + clear: () => void; +} + +export const recordSetStore = ( + initial: Record = {} as Record +): RecordSetStore => { + const { subscribe, update, set } = writable(initial); + const toggle = (item: T) => { + update(($recordSet) => { + if ($recordSet[item] === true) { + delete $recordSet[item]; + return $recordSet; + } + return { + ...$recordSet, + [item]: true, + }; + }); + }; + const add = (item: T) => { + update(($recordSet) => ({ + ...$recordSet, + [item]: true, + })); + }; + const remove = (item: T) => { + update(($recordSet) => { + delete $recordSet[item]; + return $recordSet; + }); + }; + const clear = () => { + set({} as Record); + }; + return { + subscribe, + update, + set, + toggle, + add, + remove, + clear, + }; +}; diff --git a/src/routes/index.svelte b/src/routes/index.svelte index dab3984..02d7ecf 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -13,7 +13,9 @@ numberRangeFilter, textPrefixFilter, addSubRows, + addGroupBy, } from '$lib/plugins'; + import { mean, sum } from '$lib/utils/math'; import { getShuffled } from './_getShuffled'; import { createSamples } from './_createSamples'; import Italic from './_Italic.svelte'; @@ -23,18 +25,22 @@ import NumberRangeFilter from './_NumberRangeFilter.svelte'; import SelectFilter from './_SelectFilter.svelte'; import ExpandIndicator from './_ExpandIndicator.svelte'; + import { getDistinct } from '$lib/utils/array'; - const data = readable(createSamples(10, 5, 5)); + const data = readable(createSamples(50)); const table = createTable(data, { subRows: addSubRows({ children: 'children', }), - sort: addSortBy(), filter: addColumnFilters(), tableFilter: addTableFilter({ includeHiddenColumns: true, }), + group: addGroupBy({ + initialGroupByIds: ['status'], + }), + sort: addSortBy(), expand: addExpandedRows({ initialExpandedIds: { 1: true }, }), @@ -90,12 +96,13 @@ header: createRender(Italic, { text: 'First Name' }), accessor: 'firstName', plugins: { + group: { + getAggregateValue: (values) => getDistinct(values).length, + cell: ({ value }) => `${value} unique`, + }, sort: { invert: true, }, - tableFilter: { - exclude: true, - }, filter: { fn: textPrefixFilter, render: ({ filterValue, values }) => @@ -107,8 +114,9 @@ header: () => 'Last Name', accessor: 'lastName', plugins: { - sort: { - disable: true, + group: { + getAggregateValue: (values) => getDistinct(values).length, + cell: ({ value }) => `${value} unique`, }, }, }), @@ -124,23 +132,39 @@ table.column({ header: 'Age', accessor: 'age', + plugins: { + group: { + getAggregateValue: (values) => mean(values), + cell: ({ value }) => `${(value as number).toFixed(2)} (avg)`, + }, + }, }), table.column({ header: createRender(Tick), id: 'status', accessor: (item) => item.status, plugins: { + sort: { + disable: true, + }, filter: { fn: matchFilter, render: ({ filterValue, preFilteredValues }) => createRender(SelectFilter, { filterValue, preFilteredValues }), }, + tableFilter: { + exclude: true, + }, }, }), table.column({ header: 'Visits', accessor: 'visits', plugins: { + group: { + getAggregateValue: (values) => sum(values), + cell: ({ value }) => `${value} (total)`, + }, filter: { fn: numberRangeFilter, initialFilterValue: [null, null], @@ -152,6 +176,12 @@ table.column({ header: 'Profile Progress', accessor: 'progress', + plugins: { + group: { + getAggregateValue: (values) => mean(values), + cell: ({ value }) => `${(value as number).toFixed(2)} (avg)`, + }, + }, }), ], }), @@ -159,11 +189,14 @@ const { visibleColumns, headerRows, pageRows, pluginStates } = table.createViewModel(columns); + const { groupByIds } = pluginStates.group; const { sortKeys } = pluginStates.sort; const { filterValues } = pluginStates.filter; const { filterValue } = pluginStates.tableFilter; const { pageIndex, pageCount, pageSize, hasPreviousPage, hasNextPage } = pluginStates.page; + const { expandedIds } = pluginStates.expand; const { columnIdOrder } = pluginStates.orderColumns; + // $: $columnIdOrder = ['expanded', ...$groupByIds]; const { hiddenColumnIds } = pluginStates.hideColumns; $hiddenColumnIds = ['progress']; @@ -198,6 +231,13 @@ ⬆️ {/if} + {#if props.filter !== undefined} {/if} @@ -214,15 +254,20 @@ {#each $pageRows as row (row.id)} - + {#each row.cells as cell (cell.id)} - + {#if !props.group.repeated} + + {/if} {/each} @@ -233,10 +278,12 @@
{JSON.stringify(
 		{
+			groupByIds: $groupByIds,
 			sortKeys: $sortKeys,
 			filterValues: $filterValues,
 			columnIdOrder: $columnIdOrder,
 			hiddenColumnIds: $hiddenColumnIds,
+			expandedIds: $expandedIds,
 		},
 		null,
 		2
@@ -269,6 +316,16 @@
 	}
 
 	.matches {
-		outline: 2px solid rgb(144, 191, 148);
+		font-weight: 700;
+	}
+
+	.group {
+		background: rgb(144, 191, 148);
+	}
+	.aggregate {
+		background: rgb(238, 212, 100);
+	}
+	.repeat {
+		background: rgb(255, 139, 139);
 	}