finished refactoring. all the code is now way more clearly seperated and reusable

This commit is contained in:
Chris Kruining 2024-12-19 11:42:03 +01:00
parent c2b7a9ccf3
commit 998a788baa
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
13 changed files with 141 additions and 231 deletions

View file

@ -1,14 +1,14 @@
import { Accessor, createContext, createEffect, createMemo, createSignal, JSX, useContext } from "solid-js";
import { Mutation } from "~/utilities";
import { SelectionMode, Table, Column as TableColumn, TableApi, DataSet, CellRenderer } from "~/components/table";
import { SelectionMode, Table, Column as TableColumn, TableApi, DataSet, CellRenderer as TableCellRenderer } from "~/components/table";
import css from './grid.module.css';
export interface CellEditor<T extends Record<string, any>, K extends keyof T> {
(cell: Parameters<CellRenderer<T, K>>[0] & { mutate: (next: T[K]) => any }): JSX.Element;
export interface CellRenderer<T extends Record<string, any>, K extends keyof T> {
(cell: Parameters<TableCellRenderer<T, K>>[0] & { mutate: (next: T[K]) => any }): JSX.Element;
}
export interface Column<T extends Record<string, any>> extends TableColumn<T> {
editor?: CellEditor<T, keyof T>;
export interface Column<T extends Record<string, any>> extends Omit<TableColumn<T>, 'renderer'> {
renderer?: CellRenderer<T, keyof T>;
}
export interface GridApi<T extends Record<string, any>> extends TableApi<T> {
@ -63,16 +63,16 @@ export function Grid<T extends Record<string, any>>(props: GridProps<T>) {
},
};
const cellEditors = createMemo(() => Object.fromEntries(
const cellRenderers = createMemo(() => Object.fromEntries(
props.columns
.filter(c => c.editor !== undefined)
.filter(c => c.renderer !== undefined)
.map(c => {
const Editor: CellRenderer<T, keyof T> = ({ row, column, value }) => {
const mutate = (next: T[keyof T]) => {
ctx.mutate(row, column, next);
};
return c.editor!({ row, column, value, mutate });
return c.renderer!({ row, column, value, mutate });
};
return [c.id, Editor] as const;
@ -83,7 +83,7 @@ export function Grid<T extends Record<string, any>>(props: GridProps<T>) {
<Api api={props.api} table={table()} />
<Table api={setTable} class={`${css.grid} ${props.class}`} rows={rows()} columns={columns()} selectionMode={SelectionMode.Multiple}>{
cellEditors()
cellRenderers()
}</Table>
</GridContext.Provider>;
};

View file

@ -1,4 +1,4 @@
export type { DataSetRowNode, DataSetGroupNode, DataSetNode, SelectionMode, SortingFunction, SortOptions, GroupingFunction, GroupOptions } from '../table';
export type { GridApi, Column, CellEditor } from './grid';
export type { GridApi, Column, CellRenderer as CellEditor } from './grid';
export { Grid } from './grid';

View file

@ -1,5 +1,5 @@
import { Accessor, createEffect, createMemo } from "solid-js";
import { createStore, NotWrappable, StoreSetter, unwrap } from "solid-js/store";
import { createStore, NotWrappable, produce, StoreSetter, unwrap } from "solid-js/store";
import { CustomPartial } from "solid-js/store/types/store.js";
import { deepCopy, deepDiff, Mutation } from "~/utilities";
@ -38,13 +38,14 @@ export type Setter<T> =
export interface DataSet<T extends Record<string, any>> {
data: T[];
value: Accessor<DataSetNode<keyof T, T>[]>;
nodes: Accessor<DataSetNode<keyof T, T>[]>;
value: Accessor<(T | undefined)[]>;
mutations: Accessor<Mutation[]>;
sorting: Accessor<SortOptions<T> | undefined>;
grouping: Accessor<GroupOptions<T> | undefined>;
// mutate<K extends keyof T>(index: number, value: T): void;
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;
@ -59,15 +60,14 @@ function defaultGroupingFunction<T>(groupBy: keyof T): GroupingFunction<number,
}
export const createDataSet = <T extends Record<string, any>>(data: T[], initialOptions?: { sort?: SortOptions<T>, group?: GroupOptions<T> }): DataSet<T> => {
const nodes = data;
const [state, setState] = createStore<DataSetState<T>>({
value: deepCopy(nodes),
snapshot: nodes,
value: deepCopy(data),
snapshot: data,
sorting: initialOptions?.sort,
grouping: initialOptions?.group,
});
const value = createMemo(() => {
const nodes = createMemo(() => {
const sorting = state.sorting;
const grouping = state.grouping;
@ -106,7 +106,8 @@ export const createDataSet = <T extends Record<string, any>>(data: T[], initialO
const set: DataSet<T> = {
data,
value,
nodes,
value: createMemo(() => state.value),
mutations,
sorting,
grouping,
@ -115,6 +116,10 @@ export const createDataSet = <T extends Record<string, any>>(data: T[], initialO
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));
},

View file

@ -163,6 +163,7 @@
& > .header {
border-block-end-color: transparent;
animation: none;
& .cell {
justify-content: start;
@ -171,6 +172,7 @@
& > label {
--state: 0;
display: contents;
cursor: pointer;
& input[type="checkbox"] {
display: none;
@ -181,6 +183,7 @@
transition: rotate .3s ease-in-out;
inline-size: 1em;
aspect-ratio: 1;
opacity: 1 !important;
}
&:has(input:not(:checked)) {

View file

@ -6,17 +6,18 @@ import css from './table.module.css';
selectable;
export type Column<T> = {
export type CellRenderer<T extends Record<string, any>, K extends keyof T> = (cell: { row: number, column: K, value: T[K] }) => JSX.Element;
export type CellRenderers<T extends Record<string, any>> = { [K in keyof T]?: CellRenderer<T, K> };
export interface Column<T extends Record<string, any>> {
id: keyof T,
label: string,
sortable?: boolean,
group?: string,
renderer?: CellRenderer<T, keyof T>,
readonly groupBy?: (rows: DataSetRowNode<keyof T, T>[]) => DataSetNode<keyof T, T>[],
};
export type CellRenderer<T extends Record<string, any>, K extends keyof T> = (cell: { row: number, column: K, value: T[K] }) => JSX.Element;
export type CellRenderers<T extends Record<string, any>> = { [K in keyof T]?: CellRenderer<T, K> };
export interface TableApi<T extends Record<string, any>> {
readonly selection: Accessor<SelectionItem<keyof T, T>[]>;
readonly rows: Accessor<DataSet<T>>;
@ -96,7 +97,7 @@ function InnerTable<T extends Record<string, any>>(props: InnerTableProps<T>) {
<Head />
<tbody class={css.main}>
<For each={props.rows.value()}>{
<For each={props.rows.nodes()}>{
node => <Node node={node} depth={0} />
}</For>
</tbody>

View file

@ -0,0 +1,19 @@
.textarea {
resize: vertical;
min-block-size: max(2em, 100%);
max-block-size: 50em;
background-color: var(--surface-600);
color: var(--text-1);
border-color: var(--text-2);
border-radius: var(--radii-s);
&:has(::spelling-error, ::grammar-error) {
border-color: var(--fail);
}
& ::spelling-error {
outline: 1px solid var(--fail);
text-decoration: yellow underline;
}
}

View file

@ -1,185 +1,70 @@
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, ParentComponent, Show, useContext } from "solid-js";
import { createStore, produce, unwrap } from "solid-js/store";
import { debounce, deepCopy, deepDiff, Mutation, splitAt } from "~/utilities";
import { DataSetRowNode, DataSetNode, SelectionMode, Table } from "~/components/table";
import css from './grid.module.css';
type Rows = Map<string, Record<string, string>>;
export interface GridContextType {
readonly rows: Accessor<Rows>;
readonly mutations: Accessor<Mutation[]>;
// readonly selection: Accessor<SelectionItem[]>;
mutate(prop: string, value: string): void;
remove(props: string[]): void;
insert(prop: string): void;
addColumn(name: string): void;
}
import { Accessor, Component, createEffect, createMemo, createSignal } from "solid-js";
import { debounce, Mutation } from "~/utilities";
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
import { createDataSet, DataSetNode, DataSetRowNode } from "~/components/table";
import css from "./grid.module.css"
export type Entry = { key: string } & { [lang: string]: string };
export interface GridApi {
readonly selection: Accessor<Record<string, Record<string, string>>>;
readonly rows: Accessor<Record<string, Record<string, string>>>;
readonly mutations: Accessor<Mutation[]>;
selectAll(): void;
clear(): void;
remove(keys: string[]): void;
insert(prop: string): void;
addColumn(name: string): void;
remove(indices: number[]): void;
addKey(key: string): void;
addLocale(locale: string): void;
};
const groupBy = (rows: DataSetRowNode<number, Entry>[]) => {
type R = DataSetRowNode<number, Entry> & { _key: string };
const group = (nodes: R[]): DataSetNode<number, Entry>[] => Object
.entries(Object.groupBy(nodes, r => r._key.split('.').at(0)!) as Record<number, R[]>)
.map<any>(([key, nodes]) => nodes.at(0)?._key === key
? nodes[0]
: ({ kind: 'group', key, groupedBy: 'key', nodes: group(nodes.map(n => ({ ...n, _key: n._key.slice(key.length + 1) }))) })
);
return group(rows.map<R>(r => ({ ...r, _key: r.value.key }))) as any;
}
const GridContext = createContext<GridContextType>();
export function Grid(props: { class?: string, rows: Entry[], api?: (api: GridApi) => any }) {
const rows = createMemo(() => createDataSet<Entry>(props.rows, { group: { by: 'key', with: groupBy } }));
const locales = createMemo(() => Object.keys(rows().value().at(0) ?? {}).filter(k => k !== 'key'));
const columns = createMemo<Column<Entry>[]>(() => [
{
id: 'key',
label: 'Key',
renderer: ({ value }) => value.split('.').at(-1),
},
...locales().map<Column<Entry>>(lang => ({
id: lang,
label: lang,
renderer: ({ row, column, value, mutate }) => {
const entry = rows().value()[row]!;
const useGrid = () => useContext(GridContext)!;
export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => {
const [table, setTable] = createSignal();
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, columns: string[], snapshot: Rows, numberOfRows: number }>({
rows: {},
columns: [],
snapshot: new Map,
numberOfRows: 0,
});
const mutations = createMemo(() => {
// enumerate all values to make sure the memo is recalculated on any change
Object.values(state.rows).map(entry => Object.values(entry));
return deepDiff(state.snapshot, state.rows).toArray();
});
type Entry = { key: string, [lang: string]: string };
const groupBy = (rows: DataSetRowNode<Entry>[]) => {
const group = (nodes: DataSetRowNode<Entry>[]): DataSetNode<Entry>[] => Object
.entries(Object.groupBy(nodes, r => r.key.split('.').at(0)!) as Record<string, DataSetRowNode<Entry>[]>)
.map<DataSetNode<Entry>>(([key, nodes]) => nodes.at(0)?.key === key
? { ...nodes[0], key: nodes[0].value.key, value: { ...nodes[0].value, key: nodes[0].key } }
: ({ kind: 'group', key, groupedBy: 'key', nodes: group(nodes.map(n => ({ ...n, key: n.key.slice(key.length + 1) }))) })
);
return group(rows.map(r => ({ ...r, key: r.value.key })));
}
const rows = createMemo(() => Object.entries(state.rows).map(([key, values]) => ({ key, ...values })));
const columns = createMemo(() => [
{ id: 'key', label: 'Key', groupBy },
...state.columns.map(c => ({ id: c, label: c })),
return <TextArea row={row} key={entry.key} lang={String(column)} value={value} oninput={e => mutate(e.data ?? '')} />;
},
}))
]);
createEffect(() => {
setState('rows', Object.fromEntries(deepCopy(props.rows).entries()));
setState('snapshot', props.rows);
const r = rows();
props.api?.({
mutations: r.mutations,
remove: r.remove,
addKey(key) {
r.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) });
},
addLocale(locale) {
r.mutateEach(entry => ({ ...entry, [locale]: '' }));
},
});
});
createEffect(() => {
setState('columns', [...props.columns]);
});
createEffect(() => {
setState('numberOfRows', Object.keys(state.rows).length);
});
const ctx: GridContextType = {
rows,
mutations,
// selection,
mutate(prop: string, value: string) {
const [key, lang] = splitAt(prop, prop.lastIndexOf('.'));
setState('rows', key, lang, value);
},
remove(props: string[]) {
setState('rows', produce(rows => {
for (const prop of props) {
delete rows[prop];
}
return rows;
}));
},
insert(prop: string) {
setState('rows', prop, Object.fromEntries(state.columns.map(lang => [lang, ''])));
},
addColumn(name: string): void {
if (state.columns.includes(name)) {
return;
}
setState(produce(state => {
state.columns.push(name);
state.rows = Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, { ...row, [name]: '' }]));
return state;
}))
},
};
return <GridContext.Provider value={ctx}>
<Api api={props.api} table={table()} />
<Table api={setTable} class={props.class} rows={rows()} columns={columns()} groupBy="key" selectionMode={SelectionMode.Multiple}>{
Object.fromEntries(state.columns.map(c => [c, ({ key, value }: any) => {
return <TextArea key={key} value={value} oninput={(e) => ctx.mutate(key, e.data ?? '')} />;
}]))
}</Table>
</GridContext.Provider>;
return <GridComp rows={rows()} columns={columns()} />;
};
const Api: Component<{ api: undefined | ((api: GridApi) => any), table?: any }> = (props) => {
const gridContext = useGrid();
const api = createMemo<GridApi | undefined>(() => {
const table = props.table;
if (!table) {
return;
}
return {
selection: createMemo(() => {
const selection = props.table?.selection() ?? [];
return Object.fromEntries(selection.map(({ key, value }) => [key, value()] as const));
}),
rows: createMemo(() => props.table?.rows ?? []),
mutations: gridContext.mutations,
selectAll() {
props.table.selectAll();
},
clear() {
props.table.clear();
},
remove(props: string[]) {
gridContext.remove(props);
},
insert(prop: string) {
gridContext.insert(prop);
},
addColumn(name: string): void {
gridContext.addColumn(name);
},
};
});
createEffect(() => {
const value = api();
if (value) {
props.api?.(value);
}
});
return null;
};
const TextArea: Component<{ key: string, value: string, oninput?: (event: InputEvent) => any }> = (props) => {
const TextArea: Component<{ row: number, key: string, lang: string, value: string, oninput?: (event: InputEvent) => any }> = (props) => {
const [element, setElement] = createSignal<HTMLTextAreaElement>();
const key = createMemo(() => props.key.slice(0, props.key.lastIndexOf('.')));
const lang = createMemo(() => props.key.slice(props.key.lastIndexOf('.') + 1));
const resize = () => {
const el = element();
@ -205,10 +90,11 @@ const TextArea: Component<{ key: string, value: string, oninput?: (event: InputE
return <textarea
ref={setElement}
class={css.textarea}
value={props.value}
lang={lang()}
placeholder={`${key()} in ${lang()}`}
name={`${key()}:${lang()}`}
lang={props.lang}
placeholder={`${props.key} in ${props.lang}`}
name={`${props.row}[${props.lang}]`}
spellcheck={true}
wrap="soft"
onkeyup={onKeyUp}

View file

@ -1,4 +1,7 @@
.root {
margin: 0;
padding: 0;
& > div {
display: contents;
}

View file

@ -5,7 +5,7 @@ import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/componen
import { Menu } from "~/features/menu";
import { Grid, load, useFiles } from "~/features/file";
import { Command, CommandType, Context, createCommand, Modifier, noop, useCommands } from "~/features/command";
import { GridApi } from "~/features/file/grid";
import { Entry, GridApi } from "~/features/file/grid";
import { Tab, Tabs } from "~/components/tabs";
import { isServer } from "solid-js/web";
import { Prompt, PromptApi } from "~/components/prompt";
@ -38,7 +38,8 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []):
};
interface Entries extends Map<string, Record<string, { value: string, handle: FileSystemFileHandle, id: string }>> { }
// interface Entries extends Map<string, Record<string, { value: string, handle: FileSystemFileHandle, id: string }>> { };
interface Entries extends Map<string, { key: string, } & Record<string, { value: string, handle: FileSystemFileHandle, id: string }>> { };
export default function Edit(props: ParentProps) {
const filesContext = useFiles();
@ -105,26 +106,22 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
const files = tab.files();
const mutations = tab.api()?.mutations() ?? [];
// console.log(mutations);
return mutations.flatMap((m): any => {
const [index, lang] = splitAt(m.key, m.key.indexOf('.'));
return mutations.flatMap(m => {
switch (m.kind) {
case MutarionKind.Update: {
const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.'));
return { kind: MutarionKind.Update, key, lang, file: entries.get(key)?.[lang] };
const entry = entries.get(index as any)!;
return { kind: MutarionKind.Update, key: entry.key, lang, file: files.get(lang)! };
}
case MutarionKind.Create: {
if (typeof m.value === 'object') {
return Object.entries(m.value).map(([lang, value]) => {
return ({ kind: MutarionKind.Create, key: m.key, lang, file: files.get(lang)!, value });
});
return Object.entries(m.value).map(([lang, value]) => ({ kind: MutarionKind.Create, key: m.key, lang, file: files.get(lang)!, value }));
}
const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.'));
return { kind: MutarionKind.Create, key, lang, file: undefined, value: m.value };
const entry = entries.get(index as any)!;
return { kind: MutarionKind.Create, key: entry.key, lang, file: undefined, value: m.value };
}
case MutarionKind.Delete: {
@ -226,6 +223,11 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
return existingFiles.concat(newFiles);
});
// createEffect(() => {
// console.table(mutations());
// console.log(mutatedFiles(), mutatedData());
// });
createEffect(() => {
const directory = props.root;
@ -296,19 +298,17 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
return;
}
api()?.insert(key);
api()?.addKey(key);
}),
inserNewLanguage: createCommand('insert new language', async () => {
const formData = await newLanguagePrompt()?.showModal();
const language = formData?.get('locale')?.toString();
const locale = formData?.get('locale')?.toString();
if (!language) {
if (!locale) {
return;
}
console.log(language);
api()?.addColumn(language);
api()?.addLocale(locale);
}),
} as const;
@ -375,11 +375,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
<Tabs class={css.content} active={setActive} onClose={commands.closeTab}>
<For each={tabs()}>{
({ key, handle, setApi, setEntries }) => <Tab
id={key}
label={handle.name}
closable
>
({ key, handle, setApi, setEntries }) => <Tab id={key} label={handle.name} closable>
<Content directory={handle} api={setApi} entries={setEntries} />
</Tab>
}</For>
@ -389,8 +385,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<GridApi | undefined>, entries?: Setter<Entries> }> = (props) => {
const [entries, setEntries] = createSignal<Entries>(new Map());
const [columns, setColumns] = createSignal<string[]>([]);
const [rows, setRows] = createSignal<Map<string, Record<string, string>>>(new Map);
const [rows, setRows] = createSignal<Entry[]>([]);
const [api, setApi] = createSignal<GridApi>();
createEffect(() => {
@ -420,7 +415,6 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<Gr
return { id, handle, lang, entries };
}
);
const languages = new Set(contents.map(c => c.lang));
const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]);
const merged = contents.reduce((aggregate, { id, handle, lang, entries }) => {
@ -435,13 +429,12 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<Gr
return aggregate;
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
setColumns(languages.values().toArray());
setEntries(merged);
setRows(new Map(merged.entries().map(([key, langs]) => [key, Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value]))] as const)));
setEntries(new Map(merged.entries().map(([key, langs], i) => [i.toString(), { key, ...langs }])) as Entries);
setRows(merged.entries().map(([key, langs]) => ({ key, ...Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value])) } as Entry)).toArray());
})();
});
return <Grid columns={columns()} rows={rows()} api={setApi} />;
return <Grid rows={rows()} api={setApi} />;
};
const Blank: Component<{ open: CommandType }> = (props) => {

View file

@ -26,37 +26,37 @@ export default function GridExperiment() {
id: 'name',
label: 'Name',
sortable: true,
editor,
renderer: editor,
},
{
id: 'email',
label: 'Email',
sortable: true,
editor,
renderer: editor,
},
{
id: 'address',
label: 'Address',
sortable: true,
editor,
renderer: editor,
},
{
id: 'currency',
label: 'Currency',
sortable: true,
editor,
renderer: editor,
},
{
id: 'phone',
label: 'Phone',
sortable: true,
editor,
renderer: editor,
},
{
id: 'country',
label: 'Country',
sortable: true,
editor,
renderer: editor,
},
];