From 4e98849e07661da91856159ccebe18787afaa0ef Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 2 Dec 2024 16:26:00 +0100 Subject: [PATCH 01/23] implemented feature TODOs: - extract logic to feature file to simplify component - add unit test - add end-to-end tests --- src/features/file/grid.tsx | 27 +++++++++-- src/routes/(editor)/edit.tsx | 86 ++++++++++++++++++++++++++++++------ src/utilities.ts | 8 ++-- 3 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index 7baf4f0..d3c381c 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -19,6 +19,7 @@ export interface GridContextType { mutate(prop: string, lang: string, value: string): void; remove(props: string[]): void; insert(prop: string): void; + addColumn(name: string): void; } export interface GridApi { @@ -29,6 +30,7 @@ export interface GridApi { clear(): void; remove(keys: string[]): void; insert(prop: string): void; + addColumn(name: string): void; } const GridContext = createContext(); @@ -38,8 +40,9 @@ const useGrid = () => useContext(GridContext)!; export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => { const [selection, setSelection] = createSignal([]); - const [state, setState] = createStore<{ rows: Record>, snapshot: Rows, numberOfRows: number }>({ + const [state, setState] = createStore<{ rows: Record>, columns: string[], snapshot: Rows, numberOfRows: number }>({ rows: {}, + columns: [], snapshot: new Map, numberOfRows: 0, }); @@ -51,12 +54,17 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Rows, ap return deepDiff(state.snapshot, state.rows).toArray(); }); const rows = createMemo(() => Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, unwrap(row)] as const))); + const columns = createMemo(() => state.columns); createEffect(() => { setState('rows', Object.fromEntries(deepCopy(props.rows).entries())); setState('snapshot', props.rows); }); + createEffect(() => { + setState('columns', [...props.columns]); + }); + createEffect(() => { setState('numberOfRows', Object.keys(state.rows).length); }); @@ -82,18 +90,27 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Rows, ap insert(prop: string) { setState('rows', produce(rows => { - rows[prop] = Object.fromEntries(props.columns.slice(1).map(lang => [lang, ''])); + rows[prop] = Object.fromEntries(state.columns.slice(1).map(lang => [lang, ''])); return rows })) }, + + addColumn(name: string): void { + setState(produce(state => { + state.columns.push(name); + state.rows = Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, { ...row, [name]: '' }])); + + return state; + })) + }, }; return - <_Grid class={props.class} columns={props.columns} rows={rows()} /> + <_Grid class={props.class} columns={columns()} rows={rows()} /> ; }; @@ -154,6 +171,10 @@ const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) => insert(prop: string) { gridContext.insert(prop); }, + + addColumn(name: string): void { + gridContext.addColumn(name); + }, }; createEffect(() => { diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index 37f7fc7..9d50b1e 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -1,5 +1,5 @@ import { Component, createEffect, createMemo, createSignal, For, onMount, ParentProps, Setter, Show } from "solid-js"; -import { filter, MutarionKind, Mutation, splitAt } from "~/utilities"; +import { Created, filter, MutarionKind, Mutation, splitAt } from "~/utilities"; import { Sidebar } from "~/components/sidebar"; import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; import { Menu } from "~/features/menu"; @@ -91,7 +91,8 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { const [active, setActive] = createSignal(); const [contents, setContents] = createSignal>>(new Map()); const [tree, setFiles] = createSignal(emptyFolder); - const [prompt, setPrompt] = createSignal(); + const [newKeyPrompt, setNewKeyPrompt] = createSignal(); + const [newLanguagePrompt, setNewLanguagePrompt] = createSignal(); const tab = createMemo(() => { const name = active(); @@ -99,7 +100,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { return tabs().find(t => t.handle.name === name); }); const api = createMemo(() => tab()?.api()); - const mutations = createMemo<(Mutation & { file?: { value: string, handle: FileSystemFileHandle, id: string } })[]>(() => tabs().flatMap(tab => { + const mutations = createMemo<(Mutation & { lang: string, file?: { value: string, handle: FileSystemFileHandle, id: string } })[]>(() => tabs().flatMap(tab => { const entries = tab.entries(); const files = tab.files(); const mutations = tab.api()?.mutations() ?? []; @@ -109,11 +110,19 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { case MutarionKind.Update: { const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.')); - return { kind: MutarionKind.Update, key, file: entries.get(key)?.[lang] }; + return { kind: MutarionKind.Update, key, lang, file: entries.get(key)?.[lang] }; } case MutarionKind.Create: { - return Object.entries(m.value).map(([lang, value]) => ({ kind: MutarionKind.Create, key: m.key, file: files.get(lang)!, value })); + 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 }); + }); + } + + const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.')); + + return { kind: MutarionKind.Create, key, lang, file: undefined, value: m.value }; } case MutarionKind.Delete: { @@ -137,8 +146,35 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { } const groupedByFileId = Object.groupBy(muts, m => m.file?.id ?? 'undefined'); + const newFiles = Object.entries(Object.groupBy((groupedByFileId['undefined'] ?? []) as (Created & { lang: string, file: undefined })[], m => m.lang)).map(([lang, mutations]) => { + const data = mutations!.reduce((aggregate, { key, value }) => { + let obj = aggregate; + const i = key.lastIndexOf('.'); - return entries.map(({ id, handle }) => { + if (i !== -1) { + const [k, lastPart] = splitAt(key, i); + + for (const part of k.split('.')) { + if (!Object.hasOwn(obj, part)) { + obj[part] = {}; + } + + obj = obj[part]; + } + + obj[lastPart] = value; + } + else { + obj[key] = value; + } + + return aggregate; + }, {} as Record); + + return [{ existing: false, name: lang }, data] as const; + }) + + const existingFiles = entries.map(({ id, handle }) => { const existing = new Map(files.get(id)!); const mutations = groupedByFileId[id]!; @@ -158,7 +194,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { } return [ - handle, + { existing: true, handle }, existing.entries().reduce((aggregate, [key, value]) => { let obj = aggregate; const i = key.lastIndexOf('.'); @@ -183,9 +219,15 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { return aggregate; }, {} as Record) ] as const; - }).toArray(); + }).toArray() as (readonly [({ existing: true, handle: FileSystemFileHandle } | { existing: false, name: string }), Record])[]; + + return existingFiles.concat(newFiles); }); + // createEffect(() => { + // console.log(mutatedData()); + // }); + createEffect(() => { const directory = props.root; @@ -208,7 +250,10 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { filesContext.remove(id); }, { key: 'w', modifier: Modifier.Control | (isInstalledPWA ? Modifier.None : Modifier.Alt) }), save: createCommand('save', async () => { - await Promise.allSettled(mutatedData().map(async ([handle, data]) => { + await Promise.allSettled(mutatedData().map(async ([file, data]) => { + // TODO :: add the newly created file to the known files list to that the save file picker is not shown again on subsequent saves + const handle = file.existing ? file.handle : await window.showSaveFilePicker({ suggestedName: file.name, excludeAcceptAllOption: true, types: [{ description: 'JSON file', accept: { 'application/json': ['.json'] } }] }); + const stream = await handle.createWritable({ keepExistingData: false }); stream.write(JSON.stringify(data, null, 4)); @@ -246,7 +291,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { remove(Object.keys(selection())); }, { key: 'delete', modifier: Modifier.None }), inserNewKey: createCommand('insert new key', async () => { - const formData = await prompt()?.showModal(); + const formData = await newKeyPrompt()?.showModal(); const key = formData?.get('key')?.toString(); if (!key) { @@ -255,7 +300,18 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { api()?.insert(key); }), - inserNewLanguage: noop.withLabel('insert new language'), + inserNewLanguage: createCommand('insert new language', async () => { + const formData = await newLanguagePrompt()?.showModal(); + const language = formData?.get('locale')?.toString(); + + if (!language) { + return; + } + + console.log(language); + + api()?.addColumn(language); + }), } as const; return
@@ -295,8 +351,12 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { - hint: use . to denote nested keys,
i.e. this.is.some.key would be a key that is four levels deep}> - + hint: use . to denote nested keys,
i.e. this.is.some.key would be a key that is four levels deep}> + +
+ + + diff --git a/src/utilities.ts b/src/utilities.ts index 8408a4e..c47a58b 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -54,10 +54,10 @@ export enum MutarionKind { Update = 'updated', Delete = 'deleted', } -type Created = { kind: MutarionKind.Create, value: any }; -type Updated = { kind: MutarionKind.Update, value: any, original: any }; -type Deleted = { kind: MutarionKind.Delete }; -export type Mutation = { key: string } & (Created | Updated | Deleted); +export type Created = { kind: MutarionKind.Create, key: string, value: any }; +export type Updated = { kind: MutarionKind.Update, key: string, value: any, original: any }; +export type Deleted = { kind: MutarionKind.Delete, key: string }; +export type Mutation = Created | Updated | Deleted; export function* deepDiff(a: T1, b: T2, path: string[] = []): Generator { if (!isIterable(a) || !isIterable(b)) { -- 2.50.1 From 17e49c23d879bee4ebf2286ee506ada9b5b4cf7a Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Tue, 3 Dec 2024 16:01:14 +0100 Subject: [PATCH 02/23] started splitting of into components --- src/components/grid.module.css | 128 +++++++++ src/components/grid.tsx | 280 ++++++++++++++++++++ src/components/table.module.css | 108 ++++++++ src/components/table.tsx | 114 ++++++++ src/routes/(editor)/edit.tsx | 4 - src/routes/(editor)/experimental.css | 27 -- src/routes/(editor)/experimental.module.css | 26 ++ src/routes/(editor)/experimental.tsx | 157 +++-------- 8 files changed, 686 insertions(+), 158 deletions(-) create mode 100644 src/components/grid.module.css create mode 100644 src/components/grid.tsx create mode 100644 src/components/table.module.css create mode 100644 src/components/table.tsx delete mode 100644 src/routes/(editor)/experimental.css create mode 100644 src/routes/(editor)/experimental.module.css diff --git a/src/components/grid.module.css b/src/components/grid.module.css new file mode 100644 index 0000000..05c99e3 --- /dev/null +++ b/src/components/grid.module.css @@ -0,0 +1,128 @@ +.table { + position: relative; + display: grid; + grid-template-columns: 2em minmax(10em, max-content) repeat(var(--columns), auto); + align-content: start; + padding-inline: 1px; + margin-inline: -1px; + + block-size: 100%; + overflow: clip auto; + + background-color: var(--surface-600); + + & input[type="checkbox"] { + margin: .1em; + } + + & 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; + } + } + + & .cell { + display: grid; + padding: .5em; + border: 1px solid transparent; + border-radius: var(--radii-m); + + &:has(textarea:focus) { + border-color: var(--info); + } + + & > span { + align-self: center; + } + } + + & :is(.header, .main, .footer) { + grid-column: span calc(2 + var(--columns)); + display: grid; + grid-template-columns: subgrid; + } + + & .header { + position: sticky; + inset-block-start: 0; + background-color: var(--surface-600); + border-block-end: 1px solid var(--surface-300); + } + + & .row { + --bg: var(--text); + --alpha: 0; + grid-column: span calc(2 + var(--columns)); + display: grid; + grid-template-columns: subgrid; + border: 1px solid transparent; + background-color: color(from var(--bg) srgb r g b / var(--alpha)); + + &:has(> .cell > :checked) { + --bg: var(--info); + --alpha: .1; + border-color: var(--bg); + + & span { + font-variation-settings: 'GRAD' 1000; + } + + & + :has(> .cell> :checked) { + border-block-start-color: transparent; + } + + &:has(+ .row > .cell > :checked) { + border-block-end-color: transparent; + } + } + + &:hover { + --alpha: .2 !important; + } + } + + & details { + display: contents; + + &::details-content { + grid-column: span calc(2 + var(--columns)); + display: grid; + grid-template-columns: subgrid; + } + + &:not([open])::details-content { + display: none; + } + + & > summary { + grid-column: 2 / span calc(1 + var(--columns)); + padding: .5em; + padding-inline-start: calc(var(--depth) * 1em + .5em); + + } + + & > .row > .cell > span { + padding-inline-start: calc(var(--depth) * 1em); + } + } +} + +@property --depth { + syntax: ""; + inherits: false; + initial-value: 0; +} \ No newline at end of file diff --git a/src/components/grid.tsx b/src/components/grid.tsx new file mode 100644 index 0000000..c0e0c2a --- /dev/null +++ b/src/components/grid.tsx @@ -0,0 +1,280 @@ +import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, ParentComponent, Show, useContext } from "solid-js"; +import { createStore, produce, unwrap } from "solid-js/store"; +import { SelectionProvider, useSelection, selectable } from "../features/selectable"; +import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities"; +import css from './grid.module.css'; + +selectable // prevents removal of import + +type Rows = Map>; +type SelectionItem = { key: string, value: Accessor>, element: WeakRef }; + +type Insertion = { kind: 'row', key: string } | { kind: 'column', value: string }; + +export interface GridContextType { + readonly rows: Accessor>>; + readonly mutations: Accessor; + readonly selection: Accessor; + mutate(prop: string, lang: string, value: string): void; + remove(props: string[]): void; + insert(insertion: Insertion): void; +} + +export interface GridApi { + readonly selection: Accessor>>; + readonly rows: Accessor>>; + readonly mutations: Accessor; + selectAll(): void; + clear(): void; + remove(keys: string[]): void; + insert(insertion: Insertion): void; +} + +const GridContext = createContext(); + +const useGrid = () => useContext(GridContext)!; + +export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => { + const [selection, setSelection] = createSignal([]); + const [state, setState] = createStore<{ rows: Record>, 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(); + }); + const rows = createMemo(() => Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, unwrap(row)] as const))); + const columns = createMemo(() => state.columns); + + createEffect(() => { + setState('rows', Object.fromEntries(deepCopy(props.rows).entries())); + setState('snapshot', props.rows); + }); + + createEffect(() => { + setState('columns', [...props.columns]); + }); + + createEffect(() => { + setState('numberOfRows', Object.keys(state.rows).length); + }); + + const ctx: GridContextType = { + rows, + mutations, + selection, + + mutate(prop: string, lang: string, value: string) { + setState('rows', prop, lang, value); + }, + + remove(props: string[]) { + setState('rows', produce(rows => { + for (const prop of props) { + delete rows[prop]; + } + + return rows; + })); + }, + + insert(prop: string) { + setState('rows', produce(rows => { + rows[prop] = Object.fromEntries(state.columns.slice(1).map(lang => [lang, ''])); + + return rows + })) + }, + + addColumn(name: string): void { + setState(produce(state => { + state.columns.push(name); + state.rows = Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, { ...row, [name]: '' }])); + + return state; + })) + }, + }; + + return + + + + <_Grid class={props.class} columns={columns()} rows={rows()} /> + + ; +}; + +const _Grid: Component<{ class?: string, columns: string[], rows: Record> }> = (props) => { + const columnCount = createMemo(() => props.columns.length - 1); + const root = createMemo(() => Object.entries(props.rows) + .reduce((aggregate, [key, value]) => { + let obj: any = aggregate; + const parts = key.split('.'); + + for (const [i, part] of parts.entries()) { + if (Object.hasOwn(obj, part) === false) { + obj[part] = {}; + } + + if (i === (parts.length - 1)) { + obj[part] = value; + } + else { + obj = obj[part]; + } + } + + return aggregate; + }, {})); + + return
+ + +
+ +
+
+}; + +const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) => { + const gridContext = useGrid(); + const selectionContext = useSelection<{ key: string, value: Accessor>, element: WeakRef }>(); + + const api: GridApi = { + selection: createMemo(() => { + const selection = selectionContext.selection(); + + return Object.fromEntries(selection.map(({ key, value }) => [key, value()] as const)); + }), + rows: gridContext.rows, + mutations: gridContext.mutations, + selectAll() { + selectionContext.selectAll(); + }, + clear() { + selectionContext.clear(); + }, + remove(props: string[]) { + gridContext.remove(props); + }, + insert(prop: string) { + gridContext.insert(prop); + }, + + addColumn(name: string): void { + gridContext.addColumn(name); + }, + }; + + createEffect(() => { + props.api?.(api); + }); + + return null; +}; + +const Head: Component<{ headers: string[] }> = (props) => { + const context = useSelection(); + + return
+
+ 0 && context.selection().length === context.length()} + indeterminate={context.selection().length !== 0 && context.selection().length !== context.length()} + on:input={(e: InputEvent) => e.target.checked ? context.selectAll() : context.clear()} + /> +
+ + { + header => {header} + } +
; +}; + +const Row: Component<{ entry: Entry, path?: string[] }> = (props) => { + const grid = useGrid(); + + return { + ([key, value]) => { + const values = Object.entries(value); + const path = [...(props.path ?? []), key]; + const k = path.join('.'); + const context = useSelection(); + + const isSelected = context.isSelected(k); + + return }> +
+
+ context.select([k])} on:pointerdown={e => e.stopPropagation()} /> +
+ +
+ {key} +
+ + { + ([lang, value]) =>
+