From 39ff928362dbc849e5575d708a6948ab864ac657 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Wed, 18 May 2022 02:46:53 +0800 Subject: [PATCH 01/23] feat: addGroupBy --- src/lib/plugins/addGroupBy.ts | 150 ++++++++++++++++++++++++++++++++++ src/lib/plugins/index.ts | 1 + src/routes/index.svelte | 36 +++++++- 3 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/lib/plugins/addGroupBy.ts diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts new file mode 100644 index 0000000..534b982 --- /dev/null +++ b/src/lib/plugins/addGroupBy.ts @@ -0,0 +1,150 @@ +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 { getCloned } from '$lib/utils/clone'; +import { nonUndefined } from '$lib/utils/filter'; +import { derived, writable, type Writable } from 'svelte/store'; + +export interface GroupByConfig { + initialGroupByIds?: string[]; +} + +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<{ + 'tbody.tr.td': { + isRepeat: boolean; + isAggregate: boolean; + isGroup: boolean; + }; +}>; + +// 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> +): Row[] => { + if (groupByIds.length === 0) { + return rows; + } + 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') { + throw new Error( + `Missing \`getGroupOn\` column option to aggregate column ${groupById} with object values` + ); + } + const subRows = subRowsForGroupOnValue.get(groupOnValue) ?? []; + // TODO track which cells are repeat, aggregate, and group + 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: `${groupRowIdx++}`, + original: firstRow.original, + depth: firstRow.depth, + cells: [], + cellForId: {}, + }); + const cells = firstRow.cells.map((cell) => { + const { id } = cell.column; + const { cell: label, getAggregateValue } = columnOptions[id] ?? {}; + if (id === groupById) { + return new DataBodyCell({ + column: cell.column as DataColumn, + row: groupRow, + value: groupOnValue, + label, + }); + } + const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined); + const firstColumnCell = columnCells[0]; + if (!(firstColumnCell instanceof DataBodyCell)) { + return getCloned(firstColumnCell, { + row: groupRow, + } as Partial); + } + const columnValues = (columnCells as DataBodyCell[]).map((cell) => cell.value); + const value = getAggregateValue === undefined ? '' : getAggregateValue(columnValues); + return new DataBodyCell({ + column: cell.column as DataColumn, + row: groupRow, + value, + label, + }); + }); + groupRow.cells = cells; + groupRow.subRows = subRows.map((row) => + getCloned(row, { + id: `${groupRow.id}>${row.id}`, + depth: row.depth + 1, + } as Partial) + ); + groupedRows.push(groupRow as Row); + } + return groupedRows; +}; + +export const addGroupBy = + ({ initialGroupByIds = [] }: GroupByConfig = {}): TablePlugin< + Item, + GroupByState, + GroupByColumnOptions, + GroupByPropSet + > => + ({ columnOptions }) => { + const groupByIds = writable(initialGroupByIds); + + const pluginState: GroupByState = { + groupByIds, + }; + + const deriveRows: DeriveRowsFn = (rows) => { + return derived([rows, groupByIds], ([$rows, $groupByIds]) => { + const _groupedRows = getGroupedRows($rows, $groupByIds, columnOptions); + return _groupedRows; + }); + }; + + return { + pluginState, + deriveRows, + }; + }; 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/routes/index.svelte b/src/routes/index.svelte index dab3984..37d1ac8 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,13 +25,17 @@ 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', }), + group: addGroupBy({ + initialGroupByIds: ['status'], + }), sort: addSortBy(), filter: addColumnFilters(), tableFilter: addTableFilter({ @@ -90,6 +96,9 @@ header: createRender(Italic, { text: 'First Name' }), accessor: 'firstName', plugins: { + group: { + getAggregateValue: (values) => `${getDistinct(values).length} unique`, + }, sort: { invert: true, }, @@ -107,8 +116,8 @@ header: () => 'Last Name', accessor: 'lastName', plugins: { - sort: { - disable: true, + group: { + getAggregateValue: (values) => `${getDistinct(values).length} unique`, }, }, }), @@ -124,12 +133,24 @@ table.column({ header: 'Age', accessor: 'age', + plugins: { + group: { + getAggregateValue: (values) => mean(values), + cell: ({ value }) => (value as number).toFixed(2), + }, + }, }), table.column({ header: createRender(Tick), id: 'status', accessor: (item) => item.status, plugins: { + sort: { + disable: true, + }, + group: { + getAggregateValue: (values) => `${getDistinct(values).length} unique`, + }, filter: { fn: matchFilter, render: ({ filterValue, preFilteredValues }) => @@ -141,6 +162,10 @@ header: 'Visits', accessor: 'visits', plugins: { + group: { + getAggregateValue: (values) => sum(values), + cell: ({ value }) => `Total visits: ${value}`, + }, filter: { fn: numberRangeFilter, initialFilterValue: [null, null], @@ -152,6 +177,11 @@ table.column({ header: 'Profile Progress', accessor: 'progress', + plugins: { + group: { + getAggregateValue: (values) => mean(values), + }, + }, }), ], }), From 8efacfde265e21e48990284d4e98c933feb2c578 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Wed, 18 May 2022 03:18:37 +0800 Subject: [PATCH 02/23] fix: don't apply aggregate label on group cell --- src/lib/plugins/addGroupBy.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts index 534b982..f174a11 100644 --- a/src/lib/plugins/addGroupBy.ts +++ b/src/lib/plugins/addGroupBy.ts @@ -91,7 +91,6 @@ export const getGroupedRows = < column: cell.column as DataColumn, row: groupRow, value: groupOnValue, - label, }); } const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined); From 2990ccdba8ea7354a2f402d13f70b8b0a2879428 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 16:01:46 +0800 Subject: [PATCH 03/23] use `:` separator for rowColId --- src/lib/bodyCells.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/bodyCells.ts b/src/lib/bodyCells.ts index c4c818e..f250d58 100644 --- a/src/lib/bodyCells.ts +++ b/src/lib/bodyCells.ts @@ -33,7 +33,7 @@ export abstract class BodyCell< abstract attrs(): Readable>; rowColId(): string { - return `${this.row.id}-${this.column.id}`; + return `${this.row.id}:${this.column.id}`; } } From d2c2d4358ff3527b05e753e2952e8c576f31fc58 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 16:05:24 +0800 Subject: [PATCH 04/23] feat: track aggregate and group cells --- src/lib/plugins/addGroupBy.ts | 85 +++++++++++++++++++++++++++-------- src/routes/index.svelte | 34 ++++++++++---- 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts index f174a11..9f2310e 100644 --- a/src/lib/plugins/addGroupBy.ts +++ b/src/lib/plugins/addGroupBy.ts @@ -1,11 +1,10 @@ -import { DataBodyCell } from '$lib/bodyCells'; +import { DataBodyCell, DisplayBodyCell } 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 { getCloned } from '$lib/utils/clone'; import { nonUndefined } from '$lib/utils/filter'; -import { derived, writable, type Writable } from 'svelte/store'; +import { derived, writable, type Readable, type Writable } from 'svelte/store'; export interface GroupByConfig { initialGroupByIds?: string[]; @@ -38,6 +37,12 @@ export type GroupByPropSet = NewTablePropSet<{ }; }>; +interface CellMetadata { + repeatCellIds: Record; + aggregateCellIds: Record; + groupCellIds: Record; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const getGroupedRows = < Item, @@ -47,12 +52,14 @@ export const getGroupedRows = < >( rows: Row[], groupByIds: string[], - columnOptions: Record> + columnOptions: Record>, + { repeatCellIds, aggregateCellIds, groupCellIds }: CellMetadata ): Row[] => { if (groupByIds.length === 0) { return rows; } const [groupById, ...restIds] = groupByIds; + const subRowsForGroupOnValue = new Map(); for (const row of rows) { const cell = row.cellForId[groupById]; @@ -71,6 +78,7 @@ export const getGroupedRows = < // TODO track which cells are repeat, aggregate, and group subRowsForGroupOnValue.set(groupOnValue, [...subRows, row]); } + const groupedRows: Row[] = []; let groupRowIdx = 0; for (const [groupOnValue, subRows] of subRowsForGroupOnValue.entries()) { @@ -83,9 +91,8 @@ export const getGroupedRows = < cells: [], cellForId: {}, }); - const cells = firstRow.cells.map((cell) => { + const groupRowCells = firstRow.cells.map((cell) => { const { id } = cell.column; - const { cell: label, getAggregateValue } = columnOptions[id] ?? {}; if (id === groupById) { return new DataBodyCell({ column: cell.column as DataColumn, @@ -95,11 +102,14 @@ export const getGroupedRows = < } const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined); const firstColumnCell = columnCells[0]; - if (!(firstColumnCell instanceof DataBodyCell)) { - return getCloned(firstColumnCell, { + if (firstColumnCell instanceof DisplayBodyCell) { + return new DisplayBodyCell({ row: groupRow, - } as Partial); + column: firstColumnCell.column, + label: firstColumnCell.label, + }); } + const { cell: label, getAggregateValue } = columnOptions[id] ?? {}; const columnValues = (columnCells as DataBodyCell[]).map((cell) => cell.value); const value = getAggregateValue === undefined ? '' : getAggregateValue(columnValues); return new DataBodyCell({ @@ -109,14 +119,23 @@ export const getGroupedRows = < label, }); }); - groupRow.cells = cells; - groupRow.subRows = subRows.map((row) => - getCloned(row, { - id: `${groupRow.id}>${row.id}`, - depth: row.depth + 1, - } as Partial) - ); + groupRow.cells = groupRowCells; + // FIXME How do we get a copy of subRows and cells with updated depth and + // id, while preserving the `cells` and `cellForId` relationships? + // Temporarily modify the subRow properties in place. + subRows.forEach((subRow) => { + subRow.id = `${groupRow.id}>${subRow.id}`; + subRow.depth = subRow.depth + 1; + }); + groupRow.subRows = subRows; groupedRows.push(groupRow as Row); + groupRow.cells.forEach((cell) => { + if (cell.id === groupById) { + groupCellIds[cell.rowColId()] = true; + } else { + aggregateCellIds[cell.rowColId()] = true; + } + }); } return groupedRows; }; @@ -131,19 +150,49 @@ export const addGroupBy = ({ columnOptions }) => { const groupByIds = writable(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 _groupedRows = getGroupedRows($rows, $groupByIds, columnOptions); - return _groupedRows; + const $repeatCellIds = {}; + const $aggregateCellIds = {}; + const $groupCellIds = {}; + const $groupedRows = getGroupedRows($rows, $groupByIds, columnOptions, { + repeatCellIds: $repeatCellIds, + aggregateCellIds: $aggregateCellIds, + groupCellIds: $groupCellIds, + }); + repeatCellIds.set($repeatCellIds); + aggregateCellIds.set($aggregateCellIds); + groupCellIds.set($groupCellIds); + return $groupedRows; }); }; return { pluginState, deriveRows, + hooks: { + 'tbody.tr.td': (cell) => { + const props: Readable = derived( + [repeatCellIds, aggregateCellIds, groupCellIds], + ([$repeatCellIds, $aggregateCellIds, $groupCellIds]) => { + const p = { + isRepeat: $repeatCellIds[cell.rowColId()] === true, + isAggregate: $aggregateCellIds[cell.rowColId()] === true, + isGroup: $groupCellIds[cell.rowColId()] === true, + }; + return p; + } + ); + return { props }; + }, + }, }; }; diff --git a/src/routes/index.svelte b/src/routes/index.svelte index 37d1ac8..34904bb 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -27,7 +27,7 @@ import ExpandIndicator from './_ExpandIndicator.svelte'; import { getDistinct } from '$lib/utils/array'; - const data = readable(createSamples(50)); + const data = readable(createSamples(10)); const table = createTable(data, { subRows: addSubRows({ @@ -97,7 +97,8 @@ accessor: 'firstName', plugins: { group: { - getAggregateValue: (values) => `${getDistinct(values).length} unique`, + getAggregateValue: (values) => getDistinct(values).length, + cell: ({ value }) => `${value} unique`, }, sort: { invert: true, @@ -117,7 +118,8 @@ accessor: 'lastName', plugins: { group: { - getAggregateValue: (values) => `${getDistinct(values).length} unique`, + getAggregateValue: (values) => getDistinct(values).length, + cell: ({ value }) => `${value} unique`, }, }, }), @@ -136,7 +138,7 @@ plugins: { group: { getAggregateValue: (values) => mean(values), - cell: ({ value }) => (value as number).toFixed(2), + cell: ({ value }) => `${(value as number).toFixed(2)} (avg)`, }, }, }), @@ -148,9 +150,6 @@ sort: { disable: true, }, - group: { - getAggregateValue: (values) => `${getDistinct(values).length} unique`, - }, filter: { fn: matchFilter, render: ({ filterValue, preFilteredValues }) => @@ -164,7 +163,7 @@ plugins: { group: { getAggregateValue: (values) => sum(values), - cell: ({ value }) => `Total visits: ${value}`, + cell: ({ value }) => `${value} (total)`, }, filter: { fn: numberRangeFilter, @@ -180,6 +179,7 @@ plugins: { group: { getAggregateValue: (values) => mean(values), + cell: ({ value }) => `${(value as number).toFixed(2)} (avg)`, }, }, }), @@ -189,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']; @@ -251,7 +254,11 @@ {...attrs} class:sorted={props.sort.order !== undefined} class:matches={props.tableFilter.matches} + class:group={props.group.isGroup} + class:aggregate={props.group.isAggregate} + class:repeat={props.group.isRepeat} > + {cell.rowColId()} @@ -267,6 +274,7 @@ filterValues: $filterValues, columnIdOrder: $columnIdOrder, hiddenColumnIds: $hiddenColumnIds, + expandedIds: $expandedIds, }, null, 2 @@ -301,4 +309,14 @@ .matches { outline: 2px solid rgb(144, 191, 148); } + + .group { + background: rgb(144, 191, 148); + } + .aggregate { + background: rgb(238, 212, 100); + } + .repeat { + background: rgb(255, 139, 139); + } From b725918a7c633201c60004f99f793820c2952338 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 16:06:59 +0800 Subject: [PATCH 05/23] feat: track repeat cells --- src/lib/plugins/addGroupBy.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts index 9f2310e..3ba912b 100644 --- a/src/lib/plugins/addGroupBy.ts +++ b/src/lib/plugins/addGroupBy.ts @@ -136,6 +136,13 @@ export const getGroupedRows = < aggregateCellIds[cell.rowColId()] = true; } }); + subRows.forEach((subRow) => { + subRow.cells.forEach((cell) => { + if (cell.id === groupById) { + repeatCellIds[cell.rowColId()] = true; + } + }); + }); } return groupedRows; }; From 125534b17de9c019f5044665d0aff5843c259907 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 16:10:56 +0800 Subject: [PATCH 06/23] chore: remove id from DisplayBodyCell --- src/routes/index.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/index.svelte b/src/routes/index.svelte index 34904bb..f8de9ad 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -258,7 +258,6 @@ class:aggregate={props.group.isAggregate} class:repeat={props.group.isRepeat} > - {cell.rowColId()} From 49671cab8baebe81d35554f468cfb182ead2b674 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 23:35:01 +0800 Subject: [PATCH 07/23] feat: store sets --- src/lib/utils/store.arraySetStore.test.ts | 124 ++++++++++++++++ src/lib/utils/store.recordSetStore.test.ts | 165 +++++++++++++++++++++ src/lib/utils/store.ts | 108 +++++++++++++- 3 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/store.arraySetStore.test.ts create mode 100644 src/lib/utils/store.recordSetStore.test.ts diff --git a/src/lib/utils/store.arraySetStore.test.ts b/src/lib/utils/store.arraySetStore.test.ts new file mode 100644 index 0000000..55e97e0 --- /dev/null +++ b/src/lib/utils/store.arraySetStore.test.ts @@ -0,0 +1,124 @@ +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('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.reset(); + + 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..44f98ea --- /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.reset(); + + const expected = {}; + + expect(get(actual)).toStrictEqual(expected); +}); diff --git a/src/lib/utils/store.ts b/src/lib/utils/store.ts index 2666cc4..ba36c07 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,109 @@ export type ReadOrWritableKeys = { export const Undefined = readable(undefined); export const UndefinedAs = () => Undefined as unknown as Readable; + +export interface ArraySetStoreOptions { + isEqual?: (a: T, b: T) => boolean; +} + +export interface ArraySetStore extends Writable { + toggle: (item: T) => void; + add: (item: T) => void; + remove: (item: T) => void; + reset: () => void; +} + +export const arraySetStore = ( + initial: T[] = [], + { isEqual = (a, b) => a === b }: ArraySetStoreOptions = {} +): ArraySetStore => { + const { subscribe, update, set } = writable(initial); + const toggle = (item: T) => { + update(($arraySet) => { + const index = $arraySet.findIndex(($item) => isEqual($item, item)); + if (index === -1) { + return [...$arraySet, item]; + } + 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 reset = () => { + set([]); + }; + return { + subscribe, + update, + set, + toggle, + add, + remove, + reset, + }; +}; + +export interface RecordSetStore extends Writable> { + toggle: (item: T) => void; + add: (item: T) => void; + remove: (item: T) => void; + reset: () => 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 reset = () => { + set({} as Record); + }; + return { + subscribe, + update, + set, + toggle, + add, + remove, + reset, + }; +}; From 59587e2508a63d43f51fc4b290dbe63076f76640 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 23:44:00 +0800 Subject: [PATCH 08/23] feat: clearOthers when toggling --- src/lib/utils/store.arraySetStore.test.ts | 32 +++++++++++++++++++++- src/lib/utils/store.recordSetStore.test.ts | 2 +- src/lib/utils/store.ts | 26 ++++++++++++------ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/lib/utils/store.arraySetStore.test.ts b/src/lib/utils/store.arraySetStore.test.ts index 55e97e0..fd648ad 100644 --- a/src/lib/utils/store.arraySetStore.test.ts +++ b/src/lib/utils/store.arraySetStore.test.ts @@ -47,6 +47,36 @@ it('toggles a non-existing value to add it', () => { 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]); @@ -90,7 +120,7 @@ it('removes a non-existing value and changes nothing', () => { it('resets the set', () => { const actual = arraySetStore([1, 2, 3]); - actual.reset(); + actual.clear(); const expected: number[] = []; diff --git a/src/lib/utils/store.recordSetStore.test.ts b/src/lib/utils/store.recordSetStore.test.ts index 44f98ea..e914a3e 100644 --- a/src/lib/utils/store.recordSetStore.test.ts +++ b/src/lib/utils/store.recordSetStore.test.ts @@ -157,7 +157,7 @@ it('resets the set', () => { 3: true, }); - actual.reset(); + actual.clear(); const expected = {}; diff --git a/src/lib/utils/store.ts b/src/lib/utils/store.ts index ba36c07..3a5abe3 100644 --- a/src/lib/utils/store.ts +++ b/src/lib/utils/store.ts @@ -27,15 +27,19 @@ 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) => void; + toggle: (item: T, options?: ToggleOptions) => void; add: (item: T) => void; remove: (item: T) => void; - reset: () => void; + clear: () => void; } export const arraySetStore = ( @@ -43,12 +47,18 @@ export const arraySetStore = ( { isEqual = (a, b) => a === b }: ArraySetStoreOptions = {} ): ArraySetStore => { const { subscribe, update, set } = writable(initial); - const toggle = (item: T) => { + 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)]; }); }; @@ -70,7 +80,7 @@ export const arraySetStore = ( return [...$arraySet.slice(0, index), ...$arraySet.slice(index + 1)]; }); }; - const reset = () => { + const clear = () => { set([]); }; return { @@ -80,7 +90,7 @@ export const arraySetStore = ( toggle, add, remove, - reset, + clear, }; }; @@ -88,7 +98,7 @@ export interface RecordSetStore extends Writable void; add: (item: T) => void; remove: (item: T) => void; - reset: () => void; + clear: () => void; } export const recordSetStore = ( @@ -119,7 +129,7 @@ export const recordSetStore = ( return $recordSet; }); }; - const reset = () => { + const clear = () => { set({} as Record); }; return { @@ -129,6 +139,6 @@ export const recordSetStore = ( toggle, add, remove, - reset, + clear, }; }; From 6faa5e959203c96eeb2b7c017c325cb56ae7c856 Mon Sep 17 00:00:00 2001 From: Bryan Lee Date: Sat, 21 May 2022 23:45:09 +0800 Subject: [PATCH 09/23] feat: toggle groupBy ids --- src/lib/plugins/addGroupBy.ts | 58 +++++++++++++++++++++++++++++------ src/lib/plugins/addSortBy.ts | 8 ++--- src/lib/utils/event.ts | 4 +++ src/routes/index.svelte | 10 +++--- 4 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 src/lib/utils/event.ts diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts index 3ba912b..97ae230 100644 --- a/src/lib/plugins/addGroupBy.ts +++ b/src/lib/plugins/addGroupBy.ts @@ -3,11 +3,15 @@ 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 { @@ -30,10 +34,15 @@ export interface GroupByColumnOptions< } export type GroupByPropSet = NewTablePropSet<{ + 'thead.tr.th': { + grouped: boolean; + toggle: (event: Event) => void; + clear: () => void; + }; 'tbody.tr.td': { - isRepeat: boolean; - isAggregate: boolean; - isGroup: boolean; + repeated: boolean; + aggregated: boolean; + grouped: boolean; }; }>; @@ -148,14 +157,22 @@ export const getGroupedRows = < }; export const addGroupBy = - ({ initialGroupByIds = [] }: GroupByConfig = {}): TablePlugin< + ({ + initialGroupByIds = [], + disableMultiGroup = false, + isMultiGroupEvent = isShiftClick, + }: GroupByConfig = {}): TablePlugin< Item, GroupByState, GroupByColumnOptions, GroupByPropSet > => ({ columnOptions }) => { - const groupByIds = writable(initialGroupByIds); + const disabledGroupIds = Object.entries(columnOptions) + .filter(([, option]) => option.disable === true) + .map(([columnId]) => columnId); + + const groupByIds = arraySetStore(initialGroupByIds); const repeatCellIds = writable>({}); const aggregateCellIds = writable>({}); @@ -186,16 +203,37 @@ export const addGroupBy = 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]) => { - const p = { - isRepeat: $repeatCellIds[cell.rowColId()] === true, - isAggregate: $aggregateCellIds[cell.rowColId()] === true, - isGroup: $groupCellIds[cell.rowColId()] === true, + return { + repeated: $repeatCellIds[cell.rowColId()] === true, + aggregated: $aggregateCellIds[cell.rowColId()] === true, + grouped: $groupCellIds[cell.rowColId()] === true, }; - return p; } ); return { props }; diff --git a/src/lib/plugins/addSortBy.ts b/src/lib/plugins/addSortBy.ts index 0d750ff..33b52b2 100644 --- a/src/lib/plugins/addSortBy.ts +++ b/src/lib/plugins/addSortBy.ts @@ -3,6 +3,7 @@ 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,11 +97,6 @@ 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[], @@ -197,7 +193,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/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/routes/index.svelte b/src/routes/index.svelte index f8de9ad..135ea5b 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -196,7 +196,7 @@ const { pageIndex, pageCount, pageSize, hasPreviousPage, hasNextPage } = pluginStates.page; const { expandedIds } = pluginStates.expand; const { columnIdOrder } = pluginStates.orderColumns; - $: $columnIdOrder = ['expanded', ...$groupByIds]; + // $: $columnIdOrder = ['expanded', ...$groupByIds]; const { hiddenColumnIds } = pluginStates.hideColumns; $hiddenColumnIds = ['progress']; @@ -231,6 +231,7 @@ ⬆️ {/if} + group {#if props.filter !== undefined} {/if} @@ -254,9 +255,9 @@ {...attrs} class:sorted={props.sort.order !== undefined} class:matches={props.tableFilter.matches} - class:group={props.group.isGroup} - class:aggregate={props.group.isAggregate} - class:repeat={props.group.isRepeat} + class:group={props.group.grouped} + class:aggregate={props.group.aggregated} + class:repeat={props.group.repeated} > @@ -269,6 +270,7 @@
{JSON.stringify(
 		{
+			groupByIds: $groupByIds,
 			sortKeys: $sortKeys,
 			filterValues: $filterValues,
 			columnIdOrder: $columnIdOrder,

From 5ec59db9ad33b9eedc47e5ff580689f715fa27c7 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sat, 21 May 2022 23:59:13 +0800
Subject: [PATCH 10/23] fix: warn instead of throwing error if missing
 getGroupOn

---
 src/lib/plugins/addGroupBy.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts
index 97ae230..81b1522 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -79,8 +79,8 @@ export const getGroupedRows = <
 		const { getGroupOn } = columnOption;
 		const groupOnValue = getGroupOn === undefined ? cell.value : getGroupOn(cell.value);
 		if (typeof groupOnValue === 'function' || typeof groupOnValue === 'object') {
-			throw new Error(
-				`Missing \`getGroupOn\` column option to aggregate column ${groupById} with object values`
+			console.warn(
+				`Missing \`getGroupOn\` column option to aggregate column "${groupById}" with object values`
 			);
 		}
 		const subRows = subRowsForGroupOnValue.get(groupOnValue) ?? [];

From 0c7f9babb2e09b6fc41b5758bb517af8e252d8c9 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 00:02:44 +0800
Subject: [PATCH 11/23] fix: AnyPlugin as supertype of all Plugin

---
 src/lib/types/TablePlugin.ts | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

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
 >;

From cb023fa2e701858337ae9de54b2326a9f56e1162 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 00:03:05 +0800
Subject: [PATCH 12/23] fix: find value with key.id in case cellForId missing

---
 src/lib/plugins/addSortBy.ts | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/lib/plugins/addSortBy.ts b/src/lib/plugins/addSortBy.ts
index 33b52b2..c8aa980 100644
--- a/src/lib/plugins/addSortBy.ts
+++ b/src/lib/plugins/addSortBy.ts
@@ -107,12 +107,13 @@ const getSortedRows = >(
 	_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;
 			}

From 74c968f39f04b94b4d9966965d3df4fc8407301a Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 01:55:06 +0800
Subject: [PATCH 13/23] feat: Clonable for all TableComponent

---
 src/lib/bodyCells.ts          | 17 +++++++++++++++++
 src/lib/bodyRows.ts           | 10 ++++++++++
 src/lib/headerCells.ts        | 35 ++++++++++++++++++++++++++++++++++
 src/lib/headerRows.ts         |  7 +++++++
 src/lib/plugins/addGroupBy.ts | 10 ++++------
 src/lib/tableComponent.ts     |  7 ++++++-
 src/lib/utils/clone.ts        | 36 +++++++++++++++++++++++++++++++++--
 7 files changed, 113 insertions(+), 9 deletions(-)

diff --git a/src/lib/bodyCells.ts b/src/lib/bodyCells.ts
index f250d58..fa68006 100644
--- a/src/lib/bodyCells.ts
+++ b/src/lib/bodyCells.ts
@@ -81,6 +81,15 @@ export class DataBodyCell<
 			return {};
 		});
 	}
+
+	clone(): DataBodyCell {
+		return new DataBodyCell({
+			row: this.row,
+			column: this.column,
+			label: this.label,
+			value: this.value,
+		});
+	}
 }
 
 export type DisplayBodyCellInit = Omit<
@@ -115,4 +124,12 @@ export class DisplayBodyCell exte
 			return {};
 		});
 	}
+
+	clone(): DisplayBodyCell {
+		return new DisplayBodyCell({
+			row: this.row,
+			column: this.column,
+			label: this.label,
+		});
+	}
 }
diff --git a/src/lib/bodyRows.ts b/src/lib/bodyRows.ts
index 8b84a91..e142e7e 100644
--- a/src/lib/bodyRows.ts
+++ b/src/lib/bodyRows.ts
@@ -45,6 +45,16 @@ export class BodyRow extends Tabl
 			return {};
 		});
 	}
+
+	clone(): BodyRow {
+		return new BodyRow({
+			id: this.id,
+			cellForId: this.cellForId,
+			cells: this.cells,
+			original: this.original,
+			depth: this.depth,
+		});
+	}
 }
 
 /**
diff --git a/src/lib/headerCells.ts b/src/lib/headerCells.ts
index 7abf167..a2cbaaa 100644
--- a/src/lib/headerCells.ts
+++ b/src/lib/headerCells.ts
@@ -72,6 +72,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 +101,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 +126,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 +156,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..b04c4ad 100644
--- a/src/lib/headerRows.ts
+++ b/src/lib/headerRows.ts
@@ -31,6 +31,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 = (
diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts
index 81b1522..e4bc659 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -1,8 +1,9 @@
-import { DataBodyCell, DisplayBodyCell } from '$lib/bodyCells';
+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 { getCloned } from '$lib/utils/clone';
 import { isShiftClick } from '$lib/utils/event';
 import { nonUndefined } from '$lib/utils/filter';
 import { arraySetStore } from '$lib/utils/store';
@@ -84,7 +85,6 @@ export const getGroupedRows = <
 			);
 		}
 		const subRows = subRowsForGroupOnValue.get(groupOnValue) ?? [];
-		// TODO track which cells are repeat, aggregate, and group
 		subRowsForGroupOnValue.set(groupOnValue, [...subRows, row]);
 	}
 
@@ -111,11 +111,9 @@ export const getGroupedRows = <
 			}
 			const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined);
 			const firstColumnCell = columnCells[0];
-			if (firstColumnCell instanceof DisplayBodyCell) {
-				return new DisplayBodyCell({
+			if (!(firstColumnCell instanceof DataBodyCell)) {
+				return getCloned(firstColumnCell, {
 					row: groupRow,
-					column: firstColumnCell.column,
-					label: firstColumnCell.label,
 				});
 			}
 			const { cell: label, getAggregateValue } = columnOptions[id] ?? {};
diff --git a/src/lib/tableComponent.ts b/src/lib/tableComponent.ts
index 6e0ea23..44fbe51 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;
@@ -38,4 +41,6 @@ export abstract class TableComponent;
 }
diff --git a/src/lib/utils/clone.ts b/src/lib/utils/clone.ts
index b14f0fd..aee6edc 100644
--- a/src/lib/utils/clone.ts
+++ b/src/lib/utils/clone.ts
@@ -1,8 +1,40 @@
+import type { BodyRow } from '$lib/bodyRows';
+
+export interface Clonable {
+	clone(): T;
+}
+
+export const isClonable = (obj: unknown): obj is Clonable => {
+	return typeof (obj as Clonable).clone === 'function';
+};
+
 export const getCloned = (source: T, props?: Partial): T => {
-	const clone = Object.create(source);
-	Object.assign(clone, source);
+	const clone = isClonable(source)
+		? source.clone()
+		: Object.assign(Object.create(Object.getPrototypeOf(source)), source);
 	if (props !== undefined) {
 		Object.assign(clone, props);
 	}
 	return clone;
 };
+
+export const getClonedRow = >(
+	row: Row,
+	props?: Partial
+): Row => {
+	const clonedRow = getCloned(row, props);
+	const clonedCellsForId = Object.fromEntries(
+		Object.entries(clonedRow.cellForId).map(([id, cell]) => {
+			return [
+				id,
+				getCloned(cell, {
+					row: clonedRow,
+				}),
+			];
+		})
+	);
+	const clonedCells = clonedRow.cells.map(({ id }) => clonedCellsForId[id]);
+	clonedRow.cellForId = clonedCellsForId;
+	clonedRow.cells = clonedCells;
+	return clonedRow;
+};

From 5d928f60aaa1c2b9606ae2cea085811b4136525a Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 02:13:43 +0800
Subject: [PATCH 14/23] feat: clone rows for subRows

---
 src/lib/plugins/addGroupBy.ts | 21 +++++++++------------
 1 file changed, 9 insertions(+), 12 deletions(-)

diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts
index e4bc659..7425113 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -3,7 +3,7 @@ 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 { getCloned } from '$lib/utils/clone';
+import { getCloned, getClonedRow } from '$lib/utils/clone';
 import { isShiftClick } from '$lib/utils/event';
 import { nonUndefined } from '$lib/utils/filter';
 import { arraySetStore } from '$lib/utils/store';
@@ -110,9 +110,8 @@ export const getGroupedRows = <
 				});
 			}
 			const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined);
-			const firstColumnCell = columnCells[0];
-			if (!(firstColumnCell instanceof DataBodyCell)) {
-				return getCloned(firstColumnCell, {
+			if (!(columnCells[0] instanceof DataBodyCell)) {
+				return getCloned(columnCells[0], {
 					row: groupRow,
 				});
 			}
@@ -127,14 +126,12 @@ export const getGroupedRows = <
 			});
 		});
 		groupRow.cells = groupRowCells;
-		// FIXME How do we get a copy of subRows and cells with updated depth and
-		// id, while preserving the `cells` and `cellForId` relationships?
-		// Temporarily modify the subRow properties in place.
-		subRows.forEach((subRow) => {
-			subRow.id = `${groupRow.id}>${subRow.id}`;
-			subRow.depth = subRow.depth + 1;
+		groupRow.subRows = subRows.map((subRow) => {
+			return getClonedRow(subRow, {
+				id: `${groupRow.id}>${subRow.id}`,
+				depth: subRow.depth + 1,
+			} as Partial);
 		});
-		groupRow.subRows = subRows;
 		groupedRows.push(groupRow as Row);
 		groupRow.cells.forEach((cell) => {
 			if (cell.id === groupById) {
@@ -143,7 +140,7 @@ export const getGroupedRows = <
 				aggregateCellIds[cell.rowColId()] = true;
 			}
 		});
-		subRows.forEach((subRow) => {
+		groupRow.subRows.forEach((subRow) => {
 			subRow.cells.forEach((cell) => {
 				if (cell.id === groupById) {
 					repeatCellIds[cell.rowColId()] = true;

From bc5f55f9b221494a267cff5792115e5c6059a668 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 02:15:13 +0800
Subject: [PATCH 15/23] doc: group / ungroup button

---
 src/routes/index.svelte | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index 135ea5b..cde7967 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -231,7 +231,13 @@
 									⬆️
 								{/if}
 							
-							group
+							
 							{#if props.filter !== undefined}
 								
 							{/if}
@@ -248,7 +254,7 @@
 	
 	
 		{#each $pageRows as row (row.id)}
-			
+			
 				{#each row.cells as cell (cell.id)}
 					
 						
+							{cell.rowColId()}
 							
 						
 					

From d882c1e10e03cec52732c5d14884a0e97867ad00 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 02:28:00 +0800
Subject: [PATCH 16/23] fix: cellForId for groupRows

---
 src/lib/plugins/addGroupBy.ts | 65 +++++++++++++++++++++++------------
 1 file changed, 43 insertions(+), 22 deletions(-)

diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts
index 7425113..38e0a08 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -53,6 +53,14 @@ interface CellMetadata {
 	groupCellIds: Record;
 }
 
+const getIdPrefix = (id: string) => {
+	const prefixTokens = id.split('>').slice(0, -1);
+	if (prefixTokens.length === 0) {
+		return '';
+	}
+	return `${prefixTokens.join('>')}>`;
+};
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const getGroupedRows = <
 	Item,
@@ -68,6 +76,10 @@ export const getGroupedRows = <
 	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();
@@ -94,37 +106,46 @@ export const getGroupedRows = <
 		// Guaranteed to have at least one subRow.
 		const firstRow = subRows[0];
 		const groupRow = new BodyRow({
-			id: `${groupRowIdx++}`,
+			id: `${idPrefix}${groupRowIdx++}`,
+			// TODO Differentiate data rows and grouped rows.
 			original: firstRow.original,
 			depth: firstRow.depth,
 			cells: [],
 			cellForId: {},
 		});
-		const groupRowCells = firstRow.cells.map((cell) => {
-			const { id } = cell.column;
-			if (id === groupById) {
-				return new DataBodyCell({
+		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 newCell = getCloned(columnCells[0], {
+						row: groupRow,
+					});
+					return [id, newCell];
+				}
+				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: groupOnValue,
-				});
-			}
-			const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined);
-			if (!(columnCells[0] instanceof DataBodyCell)) {
-				return getCloned(columnCells[0], {
-					row: groupRow,
+					value,
+					label,
 				});
-			}
-			const { cell: label, getAggregateValue } = columnOptions[id] ?? {};
-			const columnValues = (columnCells as DataBodyCell[]).map((cell) => cell.value);
-			const value = getAggregateValue === undefined ? '' : getAggregateValue(columnValues);
-			return 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;
 		groupRow.subRows = subRows.map((subRow) => {
 			return getClonedRow(subRow, {

From 308daadb194928a04b9494583d53b0ed31d03d82 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 02:48:10 +0800
Subject: [PATCH 17/23] feat: nested grouping

---
 src/lib/plugins/addGroupBy.ts | 16 +++++++++++++---
 src/routes/index.svelte       |  3 +--
 2 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts
index 38e0a08..1ca860c 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -53,7 +53,7 @@ interface CellMetadata {
 	groupCellIds: Record;
 }
 
-const getIdPrefix = (id: string) => {
+const getIdPrefix = (id: string): string => {
 	const prefixTokens = id.split('>').slice(0, -1);
 	if (prefixTokens.length === 0) {
 		return '';
@@ -61,6 +61,11 @@ const getIdPrefix = (id: string) => {
 	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,
@@ -147,12 +152,17 @@ export const getGroupedRows = <
 		});
 		groupRow.cellForId = groupRowCellForId;
 		groupRow.cells = groupRowCells;
-		groupRow.subRows = subRows.map((subRow) => {
+		const groupRowSubRows = subRows.map((subRow) => {
 			return getClonedRow(subRow, {
-				id: `${groupRow.id}>${subRow.id}`,
+				id: `${groupRow.id}>${getIdLeaf(subRow.id)}`,
 				depth: subRow.depth + 1,
 			} as Partial);
 		});
+		groupRow.subRows = getGroupedRows(groupRowSubRows, restIds, columnOptions, {
+			repeatCellIds,
+			aggregateCellIds,
+			groupCellIds,
+		});
 		groupedRows.push(groupRow as Row);
 		groupRow.cells.forEach((cell) => {
 			if (cell.id === groupById) {
diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index cde7967..04e54fe 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -27,7 +27,7 @@
 	import ExpandIndicator from './_ExpandIndicator.svelte';
 	import { getDistinct } from '$lib/utils/array';
 
-	const data = readable(createSamples(10));
+	const data = readable(createSamples(50));
 
 	const table = createTable(data, {
 		subRows: addSubRows({
@@ -265,7 +265,6 @@
 							class:aggregate={props.group.aggregated}
 							class:repeat={props.group.repeated}
 						>
-							{cell.rowColId()}
 							
 						
 					

From 6405e82af3051ff900d4d3ab467d782cef1be6b2 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 02:56:58 +0800
Subject: [PATCH 18/23] fix: repeated cell props for nested grouped

---
 src/lib/plugins/addGroupBy.ts | 9 ++++++---
 src/routes/index.svelte       | 4 +++-
 2 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/src/lib/plugins/addGroupBy.ts b/src/lib/plugins/addGroupBy.ts
index 1ca860c..56699c6 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -47,10 +47,11 @@ export type GroupByPropSet = NewTablePropSet<{
 	};
 }>;
 
-interface CellMetadata {
+interface GetGroupedRowsMetadata {
 	repeatCellIds: Record;
 	aggregateCellIds: Record;
 	groupCellIds: Record;
+	allGroupByIds: string[];
 }
 
 const getIdPrefix = (id: string): string => {
@@ -76,7 +77,7 @@ export const getGroupedRows = <
 	rows: Row[],
 	groupByIds: string[],
 	columnOptions: Record>,
-	{ repeatCellIds, aggregateCellIds, groupCellIds }: CellMetadata
+	{ repeatCellIds, aggregateCellIds, groupCellIds, allGroupByIds }: GetGroupedRowsMetadata
 ): Row[] => {
 	if (groupByIds.length === 0) {
 		return rows;
@@ -162,6 +163,7 @@ export const getGroupedRows = <
 			repeatCellIds,
 			aggregateCellIds,
 			groupCellIds,
+			allGroupByIds,
 		});
 		groupedRows.push(groupRow as Row);
 		groupRow.cells.forEach((cell) => {
@@ -173,7 +175,7 @@ export const getGroupedRows = <
 		});
 		groupRow.subRows.forEach((subRow) => {
 			subRow.cells.forEach((cell) => {
-				if (cell.id === groupById) {
+				if (allGroupByIds.includes(cell.id) && groupCellIds[cell.rowColId()] !== true) {
 					repeatCellIds[cell.rowColId()] = true;
 				}
 			});
@@ -217,6 +219,7 @@ export const addGroupBy =
 					repeatCellIds: $repeatCellIds,
 					aggregateCellIds: $aggregateCellIds,
 					groupCellIds: $groupCellIds,
+					allGroupByIds: $groupByIds,
 				});
 				repeatCellIds.set($repeatCellIds);
 				aggregateCellIds.set($aggregateCellIds);
diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index 04e54fe..b198914 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -265,7 +265,9 @@
 							class:aggregate={props.group.aggregated}
 							class:repeat={props.group.repeated}
 						>
-							
+							{#if !props.group.repeated}
+								
+							{/if}
 						
 					
 				{/each}

From 399ae2e936eaf6071b20c117b7986fcef5b7acbd Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 03:03:33 +0800
Subject: [PATCH 19/23] doc: filter should come before groupBy

---
 src/routes/index.svelte | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index b198914..b08eeef 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -33,14 +33,14 @@
 		subRows: addSubRows({
 			children: 'children',
 		}),
-		group: addGroupBy({
-			initialGroupByIds: ['status'],
-		}),
-		sort: addSortBy(),
 		filter: addColumnFilters(),
 		tableFilter: addTableFilter({
 			includeHiddenColumns: true,
 		}),
+		group: addGroupBy({
+			initialGroupByIds: ['status'],
+		}),
+		sort: addSortBy(),
 		expand: addExpandedRows({
 			initialExpandedIds: { 1: true },
 		}),
@@ -103,9 +103,6 @@
 						sort: {
 							invert: true,
 						},
-						tableFilter: {
-							exclude: true,
-						},
 						filter: {
 							fn: textPrefixFilter,
 							render: ({ filterValue, values }) =>
@@ -155,6 +152,9 @@
 							render: ({ filterValue, preFilteredValues }) =>
 								createRender(SelectFilter, { filterValue, preFilteredValues }),
 						},
+						tableFilter: {
+							exclude: true,
+						},
 					},
 				}),
 				table.column({

From ed657e461ec803a0993d82d7a12a5f995b47ef9e Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 03:10:38 +0800
Subject: [PATCH 20/23] fix: test components

---
 src/lib/bodyCells.HeaderCell.render.test.ts | 12 +++++++++++-
 src/lib/tableComponent.applyHook.test.ts    |  8 +++++++-
 2 files changed, 18 insertions(+), 2 deletions(-)

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/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' });

From ccfbb9391d04154abb29069ed928896840cfdf0d Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 10:36:42 +0800
Subject: [PATCH 21/23] feat: invariant metadata for plugins to keep track

---
 src/lib/bodyCells.ts              |  4 ++-
 src/lib/plugins/addTableFilter.ts | 51 ++++++++++++++++++++++---------
 src/lib/tableComponent.ts         |  1 +
 3 files changed, 40 insertions(+), 16 deletions(-)

diff --git a/src/lib/bodyCells.ts b/src/lib/bodyCells.ts
index fa68006..0309d01 100644
--- a/src/lib/bodyCells.ts
+++ b/src/lib/bodyCells.ts
@@ -83,12 +83,14 @@ export class DataBodyCell<
 	}
 
 	clone(): DataBodyCell {
-		return new DataBodyCell({
+		const cell = new DataBodyCell({
 			row: this.row,
 			column: this.column,
 			label: this.label,
 			value: this.value,
 		});
+		cell.metadataForName = this.metadataForName;
+		return cell;
 	}
 }
 
diff --git a/src/lib/plugins/addTableFilter.ts b/src/lib/plugins/addTableFilter.ts
index 8ba509a..2521c00 100644
--- a/src/lib/plugins/addTableFilter.ts
+++ b/src/lib/plugins/addTableFilter.ts
@@ -2,6 +2,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 +39,12 @@ export type TableFilterPropSet = NewTablePropSet<{
 	};
 }>;
 
-interface GetFilteredRowsProps {
-	tableCellMatches: Writable>;
+type TableFilterBodyCellMetadata = {
+	uuid: string;
+};
+
+interface GetFilteredRowsOptions {
+	tableCellMatches: Record;
 	fn: TableFilterFn;
 	includeHiddenColumns: boolean;
 }
@@ -48,7 +53,7 @@ const getFilteredRows = >(
 	rows: Row[],
 	filterValue: string,
 	columnOptions: Record>,
-	{ tableCellMatches, fn, includeHiddenColumns }: GetFilteredRowsProps
+	{ tableCellMatches, fn, includeHiddenColumns }: GetFilteredRowsOptions
 ): Row[] => {
 	const _filteredRows = rows
 		// Filter `subRows`
@@ -88,10 +93,9 @@ 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.
@@ -111,25 +115,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 +155,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/tableComponent.ts b/src/lib/tableComponent.ts
index 44fbe51..29e778b 100644
--- a/src/lib/tableComponent.ts
+++ b/src/lib/tableComponent.ts
@@ -26,6 +26,7 @@ export abstract class TableComponent> = {};
 	private propsForName: Record>> = {};
 	props(): Readable[Key]> {
 		return derivedKeys(this.propsForName) as Readable[Key]>;

From 514ddf31b471361ed97efb988a3a847ffc06c795 Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 10:50:30 +0800
Subject: [PATCH 22/23] refactor: use clone() method

Deprecated `getCloned` as `unsafeClone`
---
 src/lib/bodyCells.ts                | 12 ++++++----
 src/lib/bodyRows.ts                 | 30 ++++++++++++++++++-----
 src/lib/headerCells.ts              |  2 ++
 src/lib/headerRows.ts               |  3 +--
 src/lib/plugins/addColumnFilters.ts | 11 ++++-----
 src/lib/plugins/addGroupBy.ts       | 16 ++++++-------
 src/lib/plugins/addSortBy.ts        | 15 ++++++------
 src/lib/plugins/addTableFilter.ts   | 11 ++++-----
 src/lib/utils/clone.ts              | 37 ++++++++---------------------
 src/routes/index.svelte             |  2 +-
 10 files changed, 71 insertions(+), 68 deletions(-)

diff --git a/src/lib/bodyCells.ts b/src/lib/bodyCells.ts
index 0309d01..cf51dd6 100644
--- a/src/lib/bodyCells.ts
+++ b/src/lib/bodyCells.ts
@@ -32,6 +32,8 @@ export abstract class BodyCell<
 
 	abstract attrs(): Readable>;
 
+	abstract clone(): BodyCell;
+
 	rowColId(): string {
 		return `${this.row.id}:${this.column.id}`;
 	}
@@ -83,14 +85,14 @@ export class DataBodyCell<
 	}
 
 	clone(): DataBodyCell {
-		const cell = new DataBodyCell({
+		const clonedCell = new DataBodyCell({
 			row: this.row,
 			column: this.column,
 			label: this.label,
 			value: this.value,
 		});
-		cell.metadataForName = this.metadataForName;
-		return cell;
+		clonedCell.metadataForName = this.metadataForName;
+		return clonedCell;
 	}
 }
 
@@ -128,10 +130,12 @@ export class DisplayBodyCell exte
 	}
 
 	clone(): DisplayBodyCell {
-		return new 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 e142e7e..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,
@@ -46,14 +49,29 @@ export class BodyRow extends Tabl
 		});
 	}
 
-	clone(): BodyRow {
-		return new BodyRow({
+	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;
 	}
 }
 
@@ -130,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 a2cbaaa..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
diff --git a/src/lib/headerRows.ts b/src/lib/headerRows.ts
index b04c4ad..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';
 
@@ -180,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
index 56699c6..00b3d06 100644
--- a/src/lib/plugins/addGroupBy.ts
+++ b/src/lib/plugins/addGroupBy.ts
@@ -3,7 +3,6 @@ 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 { getCloned, getClonedRow } from '$lib/utils/clone';
 import { isShiftClick } from '$lib/utils/event';
 import { nonUndefined } from '$lib/utils/filter';
 import { arraySetStore } from '$lib/utils/store';
@@ -131,10 +130,9 @@ export const getGroupedRows = <
 				}
 				const columnCells = subRows.map((row) => row.cellForId[id]).filter(nonUndefined);
 				if (!(columnCells[0] instanceof DataBodyCell)) {
-					const newCell = getCloned(columnCells[0], {
-						row: groupRow,
-					});
-					return [id, newCell];
+					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);
@@ -154,10 +152,10 @@ export const getGroupedRows = <
 		groupRow.cellForId = groupRowCellForId;
 		groupRow.cells = groupRowCells;
 		const groupRowSubRows = subRows.map((subRow) => {
-			return getClonedRow(subRow, {
-				id: `${groupRow.id}>${getIdLeaf(subRow.id)}`,
-				depth: subRow.depth + 1,
-			} as Partial);
+			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,
diff --git a/src/lib/plugins/addSortBy.ts b/src/lib/plugins/addSortBy.ts
index c8aa980..ba49661 100644
--- a/src/lib/plugins/addSortBy.ts
+++ b/src/lib/plugins/addSortBy.ts
@@ -1,7 +1,6 @@
 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';
@@ -103,8 +102,8 @@ const getSortedRows = >(
 	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`.
@@ -143,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 =
diff --git a/src/lib/plugins/addTableFilter.ts b/src/lib/plugins/addTableFilter.ts
index 2521c00..eb61705 100644
--- a/src/lib/plugins/addTableFilter.ts
+++ b/src/lib/plugins/addTableFilter.ts
@@ -1,7 +1,6 @@
 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';
 
@@ -55,7 +54,7 @@ const getFilteredRows = >(
 	columnOptions: Record>,
 	{ tableCellMatches, fn, includeHiddenColumns }: GetFilteredRowsOptions
 ): Row[] => {
-	const _filteredRows = rows
+	const $filteredRows = rows
 		// Filter `subRows`
 		.map((row) => {
 			const { subRows } = row;
@@ -67,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) {
@@ -101,7 +100,7 @@ const getFilteredRows = >(
 			// If any cell matches, include in the filtered results.
 			return rowCellMatches.includes(true);
 		});
-	return _filteredRows;
+	return $filteredRows;
 };
 
 export const addTableFilter =
diff --git a/src/lib/utils/clone.ts b/src/lib/utils/clone.ts
index aee6edc..42c493a 100644
--- a/src/lib/utils/clone.ts
+++ b/src/lib/utils/clone.ts
@@ -1,5 +1,3 @@
-import type { BodyRow } from '$lib/bodyRows';
-
 export interface Clonable {
 	clone(): T;
 }
@@ -8,33 +6,18 @@ export const isClonable = (obj: unknown): obj is Clonable => {
 	return typeof (obj as Clonable).clone === 'function';
 };
 
-export const getCloned = (source: T, props?: Partial): T => {
-	const clone = isClonable(source)
-		? source.clone()
-		: Object.assign(Object.create(Object.getPrototypeOf(source)), source);
+/**
+ * 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);
 	}
 	return clone;
 };
-
-export const getClonedRow = >(
-	row: Row,
-	props?: Partial
-): Row => {
-	const clonedRow = getCloned(row, props);
-	const clonedCellsForId = Object.fromEntries(
-		Object.entries(clonedRow.cellForId).map(([id, cell]) => {
-			return [
-				id,
-				getCloned(cell, {
-					row: clonedRow,
-				}),
-			];
-		})
-	);
-	const clonedCells = clonedRow.cells.map(({ id }) => clonedCellsForId[id]);
-	clonedRow.cellForId = clonedCellsForId;
-	clonedRow.cells = clonedCells;
-	return clonedRow;
-};
diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index b08eeef..02d7ecf 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -316,7 +316,7 @@
 	}
 
 	.matches {
-		outline: 2px solid rgb(144, 191, 148);
+		font-weight: 700;
 	}
 
 	.group {

From c784451668523c180960feddfa0c1394817ba27a Mon Sep 17 00:00:00 2001
From: Bryan Lee 
Date: Sun, 22 May 2022 10:55:23 +0800
Subject: [PATCH 23/23] bump version

---
 package-lock.json | 4 ++--
 package.json      | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

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",