refactor dataset to standalone feature and update components accordingly

This commit is contained in:
Chris Kruining 2025-02-04 15:48:23 +11:00
parent 3bd17306f2
commit 9ace9b9f4f
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
7 changed files with 109 additions and 34 deletions

View file

@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import { createDataSet } from "./index";
import { createSignal } from "solid-js";
interface DataEntry {
id: string;
name: string;
amount: number;
};
const [defaultData] = createSignal<DataEntry[]>([
{ id: '1', name: 'a first name', amount: 30 },
{ id: '2', name: 'a second name', amount: 20 },
{ id: '3', name: 'a third name', amount: 10 },
]);
describe('dataset', () => {
describe('createDataset', () => {
it('can create an instance', async () => {
// Arrange
// Act
const actual = createDataSet(defaultData);
// Assert
expect(actual).toMatchObject({ data: defaultData })
});
it('can sort by a property', async () => {
// Arrange
// Act
const actual = createDataSet(defaultData, { sort: { by: 'amount', reversed: false } });
// Assert
expect(actual.nodes()).toEqual([
expect.objectContaining({ key: 2 }),
expect.objectContaining({ key: 1 }),
expect.objectContaining({ key: 0 }),
])
});
it('can group by a property', async () => {
// Arrange
// Act
const actual = createDataSet(defaultData, { group: { by: 'name' } });
// Assert
expect(actual).toEqual(expect.objectContaining({ data: defaultData }))
});
describe('mutate', () => {
it('mutates the value', async () => {
// Arrange
const dataset = createDataSet(defaultData);
// Act
dataset.mutate(0, 'amount', 100);
// Assert
expect(dataset.value[0]!.amount).toBe(100);
});
});
describe('mutateEach', () => {
it('mutates all the entries', async () => {
// Arrange
const dataset = createDataSet(defaultData);
// Act
dataset.mutateEach(entry => ({ ...entry, amount: entry.amount + 5 }));
// Assert
expect(dataset.value).toEqual([
expect.objectContaining({ amount: 35 }),
expect.objectContaining({ amount: 25 }),
expect.objectContaining({ amount: 15 }),
]);
});
});
describe('remove', () => {
it('removes the 2nd entry', async () => {
// Arrange
const dataset = createDataSet(defaultData);
// Act
dataset.remove([1]);
// Assert
expect(dataset.value[1]).toBeUndefined();
});
});
describe('insert', () => {
it('adds an entry to the dataset', async () => {
// Arrange
const dataset = createDataSet(defaultData);
// Act
dataset.insert({ id: '4', name: 'name', amount: 100 });
// Assert
expect(dataset.value[3]).toEqual({ id: '4', name: 'name', amount: 100 });
});
});
describe('sort', () => {
it('can set the sorting', async () => {
// Arrange
const dataset = createDataSet(defaultData);
// Act
dataset.sort({ by: 'id', reversed: true });
// Assert
expect(dataset.sorting).toEqual({ by: 'id', reversed: true });
});
});
describe('group', () => {
it('can set the grouping', async () => {
// Arrange
const dataset = createDataSet(defaultData);
// Act
dataset.group({ by: 'id' });
// Assert
expect(dataset.grouping).toEqual({ by: 'id' });
});
});
});
});

View file

@ -0,0 +1,221 @@
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { CustomPartial } from "solid-js/store/types/store.js";
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
export type DataSetRowNode<K, T> = { kind: 'row', key: K, value: T }
export type DataSetGroupNode<K, T> = { kind: 'group', key: K, groupedBy: keyof T, nodes: DataSetNode<K, T>[] };
export type DataSetNode<K, T> = DataSetRowNode<K, T> | DataSetGroupNode<K, T>;
export interface SortingFunction<T> {
(a: T, b: T): -1 | 0 | 1;
}
export interface SortOptions<T extends Record<string, any>> {
by: keyof T;
reversed: boolean;
with?: SortingFunction<T>;
}
export interface GroupingFunction<K, T> {
(nodes: DataSetRowNode<K, T>[]): DataSetNode<K, T>[];
}
export interface GroupOptions<T extends Record<string, any>> {
by: keyof T;
with?: GroupingFunction<number, T>;
}
interface DataSetState<T extends Record<string, any>> {
value: (T | undefined)[];
snapshot: (T | undefined)[];
sorting?: SortOptions<T>;
grouping?: GroupOptions<T>;
}
export type Setter<T> =
| T
| CustomPartial<T>
| ((prevState: T) => T | CustomPartial<T>);
export interface DataSet<T extends Record<string, any>> {
nodes: Accessor<DataSetNode<keyof T, T>[]>;
mutations: Accessor<Mutation[]>;
readonly value: (T | undefined)[];
readonly sorting: SortOptions<T> | undefined;
readonly grouping: GroupOptions<T> | undefined;
mutate<K extends keyof T>(index: number, prop: K, value: T[K]): void;
mutateEach(setter: (value: T) => T): void;
remove(indices: number[]): void;
insert(item: T, at?: number): void;
sort(options: Setter<SortOptions<T> | undefined>): DataSet<T>;
group(options: Setter<GroupOptions<T> | undefined>): DataSet<T>;
}
const defaultComparer = <T>(a: T, b: T) => a < b ? -1 : a > b ? 1 : 0;
function defaultGroupingFunction<T>(groupBy: keyof T): GroupingFunction<number, T> {
return <K>(nodes: DataSetRowNode<K, T>[]): DataSetNode<K, T>[] => Object.entries(Object.groupBy(nodes, r => r.value[groupBy] as PropertyKey))
.map(([key, nodes]) => ({ kind: 'group', key, groupedBy: groupBy, nodes: nodes! } as DataSetGroupNode<K, T>));
}
export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>, initialOptions?: { sort?: SortOptions<T>, group?: GroupOptions<T> }): DataSet<T> => {
const [state, setState] = createStore<DataSetState<T>>({
value: deepCopy(data()),
snapshot: data(),
sorting: initialOptions?.sort,
grouping: initialOptions?.group,
});
const nodes = createMemo(() => {
const sorting = state.sorting;
const grouping = state.grouping;
let value: DataSetNode<number, T>[] = state.value
.map<DataSetRowNode<number, T> | undefined>((value, key) => value === undefined ? undefined : ({ kind: 'row', key, value }))
.filter(node => node !== undefined);
if (sorting) {
const comparer = sorting.with ?? defaultComparer;
value = value.filter(entry => entry.kind === 'row').toSorted((a, b) => comparer(a.value[sorting.by], b.value[sorting.by]));
if (sorting.reversed) {
value.reverse();
}
}
if (grouping) {
const implementation = grouping.with ?? defaultGroupingFunction(grouping.by);
value = implementation(value as DataSetRowNode<number, T>[]);
}
return value as DataSetNode<keyof T, T>[];
});
const mutations = createMemo(() => {
// enumerate all values to make sure the memo is recalculated on any change
Object.values(state.value).map(entry => Object.values(entry ?? {}));
return deepDiff(state.snapshot, state.value).toArray();
});
const apply = (data: T[], mutations: Mutation[]) => {
for (const mutation of mutations) {
const path = mutation.key.split('.');
switch (mutation.kind) {
case MutarionKind.Create: {
let v: any = data;
for (const part of path.slice(0, -1)) {
if (v[part] === undefined) {
v[part] = {};
}
v = v[part];
}
v[path.at(-1)!] = mutation.value;
break;
}
case MutarionKind.Delete: {
let v: any = data;
for (const part of path.slice(0, -1)) {
if (v === undefined) {
break;
}
v = v[part];
}
if (v !== undefined) {
delete v[path.at(-1)!];
}
break;
}
case MutarionKind.Update: {
let v: any = data;
for (const part of path.slice(0, -1)) {
if (v === undefined) {
break;
}
v = v[part];
}
if (v !== undefined) {
v[path.at(-1)!] = mutation.value;
}
break;
}
}
}
return data;
};
createEffect(() => {
const next = data();
const nextValue = apply(deepCopy(next), untrack(() => mutations()));
setState('value', nextValue);
setState('snapshot', next);
;
});
createEffect(() => {
console.log('dataset', mutations());
});
const set: DataSet<T> = {
nodes,
get value() {
return state.value;
},
mutations,
get sorting() {
return state.sorting;
},
get grouping() {
return state.grouping;
},
mutate(index, prop, value) {
setState('value', index, prop as any, value);
},
mutateEach(setter) {
setState('value', value => value.map(i => i === undefined ? undefined : setter(i)));
},
remove(indices) {
setState('value', value => value.map((item, i) => indices.includes(i) ? undefined : item));
},
insert(item, at) {
if (at === undefined) {
setState('value', state.value.length, item);
} else {
}
},
sort(options) {
setState('sorting', options);
return set;
},
group(options) {
setState('grouping', options)
return set;
},
};
return set;
};

View file

@ -1,7 +1,7 @@
import { Accessor, Component, createEffect, createMemo, createSignal, JSX } from "solid-js";
import { Accessor, Component, createEffect, createMemo, createSignal, JSX, untrack } from "solid-js";
import { decode, Mutation } from "~/utilities";
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
import { createDataSet, DataSetNode, DataSetRowNode } from "~/components/table";
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
import { SelectionItem } from "../selectable";
import { useI18n } from "../i18n";
import { debounce } from "@solid-primitives/scheduled";
@ -35,7 +35,7 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
const { t } = useI18n();
const [addedLocales, setAddedLocales] = createSignal<string[]>([]);
const rows = createMemo(() => createDataSet<Entry>(props.rows, { group: { by: 'key', with: groupBy } }));
const rows = createDataSet<Entry>(() => props.rows, { group: { by: 'key', with: groupBy } });
const locales = createMemo(() => [...props.locales, ...addedLocales()]);
const columns = createMemo<Column<Entry>[]>(() => [
{
@ -47,7 +47,7 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
id: lang,
label: lang,
renderer: ({ row, column, value, mutate }) => {
const entry = rows().value[row]!;
const entry = rows.value[row]!;
return <TextArea row={row} key={entry.key} lang={String(column)} value={value ?? ''} oninput={e => mutate(e.data ?? '')} />;
},
@ -56,22 +56,28 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
const [api, setApi] = createSignal<GridCompApi<Entry>>();
// Normalize dataset in order to make sure all the files have the correct structure
createEffect(() => {
const r = rows();
const l = addedLocales();
// For tracking
props.rows
const value = untrack(() => rows.value);
r.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) }));
rows.mutateEach(({ key, ...locales }) => ({ key, ...Object.fromEntries(Object.entries(locales).map(([locale, value]) => [locale, value ?? ''])) }))
});
createEffect(() => {
const r = rows();
const l = addedLocales();
rows.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) }));
});
createEffect(() => {
props.api?.({
mutations: r.mutations,
mutations: rows.mutations,
selection: createMemo(() => api()?.selection() ?? []),
remove: r.remove,
remove: rows.remove,
addKey(key) {
r.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) });
rows.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) });
},
addLocale(locale) {
setAddedLocales(locales => new Set([...locales, locale]).values().toArray())
@ -85,7 +91,7 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
});
});
return <GridComp data={rows()} columns={columns()} api={setApi} />;
return <GridComp data={rows} columns={columns()} api={setApi} />;
};
const TextArea: Component<{ row: number, key: string, lang: string, value: string, oninput?: (event: InputEvent) => any }> = (props) => {