implemented feature
TODOs: - extract logic to feature file to simplify component - add unit test - add end-to-end tests
This commit is contained in:
parent
316e3158db
commit
4e98849e07
3 changed files with 101 additions and 20 deletions
|
@ -19,6 +19,7 @@ export interface GridContextType {
|
||||||
mutate(prop: string, lang: string, value: string): void;
|
mutate(prop: string, lang: string, value: string): void;
|
||||||
remove(props: string[]): void;
|
remove(props: string[]): void;
|
||||||
insert(prop: string): void;
|
insert(prop: string): void;
|
||||||
|
addColumn(name: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridApi {
|
export interface GridApi {
|
||||||
|
@ -29,6 +30,7 @@ export interface GridApi {
|
||||||
clear(): void;
|
clear(): void;
|
||||||
remove(keys: string[]): void;
|
remove(keys: string[]): void;
|
||||||
insert(prop: string): void;
|
insert(prop: string): void;
|
||||||
|
addColumn(name: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridContext = createContext<GridContextType>();
|
const GridContext = createContext<GridContextType>();
|
||||||
|
@ -38,8 +40,9 @@ const useGrid = () => useContext(GridContext)!;
|
||||||
|
|
||||||
export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => {
|
export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => {
|
||||||
const [selection, setSelection] = createSignal<SelectionItem[]>([]);
|
const [selection, setSelection] = createSignal<SelectionItem[]>([]);
|
||||||
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({
|
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, columns: string[], snapshot: Rows, numberOfRows: number }>({
|
||||||
rows: {},
|
rows: {},
|
||||||
|
columns: [],
|
||||||
snapshot: new Map,
|
snapshot: new Map,
|
||||||
numberOfRows: 0,
|
numberOfRows: 0,
|
||||||
});
|
});
|
||||||
|
@ -51,12 +54,17 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Rows, ap
|
||||||
return deepDiff(state.snapshot, state.rows).toArray();
|
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 rows = createMemo(() => Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, unwrap(row)] as const)));
|
||||||
|
const columns = createMemo(() => state.columns);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setState('rows', Object.fromEntries(deepCopy(props.rows).entries()));
|
setState('rows', Object.fromEntries(deepCopy(props.rows).entries()));
|
||||||
setState('snapshot', props.rows);
|
setState('snapshot', props.rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setState('columns', [...props.columns]);
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setState('numberOfRows', Object.keys(state.rows).length);
|
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) {
|
insert(prop: string) {
|
||||||
setState('rows', produce(rows => {
|
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
|
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 <GridContext.Provider value={ctx}>
|
return <GridContext.Provider value={ctx}>
|
||||||
<SelectionProvider selection={setSelection} multiSelect>
|
<SelectionProvider selection={setSelection} multiSelect>
|
||||||
<Api api={props.api} />
|
<Api api={props.api} />
|
||||||
|
|
||||||
<_Grid class={props.class} columns={props.columns} rows={rows()} />
|
<_Grid class={props.class} columns={columns()} rows={rows()} />
|
||||||
</SelectionProvider>
|
</SelectionProvider>
|
||||||
</GridContext.Provider>;
|
</GridContext.Provider>;
|
||||||
};
|
};
|
||||||
|
@ -154,6 +171,10 @@ const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) =>
|
||||||
insert(prop: string) {
|
insert(prop: string) {
|
||||||
gridContext.insert(prop);
|
gridContext.insert(prop);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addColumn(name: string): void {
|
||||||
|
gridContext.addColumn(name);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, createEffect, createMemo, createSignal, For, onMount, ParentProps, Setter, Show } from "solid-js";
|
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 { Sidebar } from "~/components/sidebar";
|
||||||
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree";
|
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree";
|
||||||
import { Menu } from "~/features/menu";
|
import { Menu } from "~/features/menu";
|
||||||
|
@ -91,7 +91,8 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
const [active, setActive] = createSignal<string>();
|
const [active, setActive] = createSignal<string>();
|
||||||
const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map());
|
const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map());
|
||||||
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
|
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
|
||||||
const [prompt, setPrompt] = createSignal<PromptApi>();
|
const [newKeyPrompt, setNewKeyPrompt] = createSignal<PromptApi>();
|
||||||
|
const [newLanguagePrompt, setNewLanguagePrompt] = createSignal<PromptApi>();
|
||||||
|
|
||||||
const tab = createMemo(() => {
|
const tab = createMemo(() => {
|
||||||
const name = active();
|
const name = active();
|
||||||
|
@ -99,7 +100,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
return tabs().find(t => t.handle.name === name);
|
return tabs().find(t => t.handle.name === name);
|
||||||
});
|
});
|
||||||
const api = createMemo(() => tab()?.api());
|
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 entries = tab.entries();
|
||||||
const files = tab.files();
|
const files = tab.files();
|
||||||
const mutations = tab.api()?.mutations() ?? [];
|
const mutations = tab.api()?.mutations() ?? [];
|
||||||
|
@ -109,11 +110,19 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
case MutarionKind.Update: {
|
case MutarionKind.Update: {
|
||||||
const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.'));
|
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: {
|
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: {
|
case MutarionKind.Delete: {
|
||||||
|
@ -137,8 +146,35 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedByFileId = Object.groupBy(muts, m => m.file?.id ?? 'undefined');
|
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<string, any>);
|
||||||
|
|
||||||
|
return [{ existing: false, name: lang }, data] as const;
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingFiles = entries.map(({ id, handle }) => {
|
||||||
const existing = new Map(files.get(id)!);
|
const existing = new Map(files.get(id)!);
|
||||||
const mutations = groupedByFileId[id]!;
|
const mutations = groupedByFileId[id]!;
|
||||||
|
|
||||||
|
@ -158,7 +194,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
handle,
|
{ existing: true, handle },
|
||||||
existing.entries().reduce((aggregate, [key, value]) => {
|
existing.entries().reduce((aggregate, [key, value]) => {
|
||||||
let obj = aggregate;
|
let obj = aggregate;
|
||||||
const i = key.lastIndexOf('.');
|
const i = key.lastIndexOf('.');
|
||||||
|
@ -183,9 +219,15 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
return aggregate;
|
return aggregate;
|
||||||
}, {} as Record<string, any>)
|
}, {} as Record<string, any>)
|
||||||
] as const;
|
] as const;
|
||||||
}).toArray();
|
}).toArray() as (readonly [({ existing: true, handle: FileSystemFileHandle } | { existing: false, name: string }), Record<string, any>])[];
|
||||||
|
|
||||||
|
return existingFiles.concat(newFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// createEffect(() => {
|
||||||
|
// console.log(mutatedData());
|
||||||
|
// });
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const directory = props.root;
|
const directory = props.root;
|
||||||
|
|
||||||
|
@ -208,7 +250,10 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
filesContext.remove(id);
|
filesContext.remove(id);
|
||||||
}, { key: 'w', modifier: Modifier.Control | (isInstalledPWA ? Modifier.None : Modifier.Alt) }),
|
}, { key: 'w', modifier: Modifier.Control | (isInstalledPWA ? Modifier.None : Modifier.Alt) }),
|
||||||
save: createCommand('save', async () => {
|
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 });
|
const stream = await handle.createWritable({ keepExistingData: false });
|
||||||
|
|
||||||
stream.write(JSON.stringify(data, null, 4));
|
stream.write(JSON.stringify(data, null, 4));
|
||||||
|
@ -246,7 +291,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
remove(Object.keys(selection()));
|
remove(Object.keys(selection()));
|
||||||
}, { key: 'delete', modifier: Modifier.None }),
|
}, { key: 'delete', modifier: Modifier.None }),
|
||||||
inserNewKey: createCommand('insert new key', async () => {
|
inserNewKey: createCommand('insert new key', async () => {
|
||||||
const formData = await prompt()?.showModal();
|
const formData = await newKeyPrompt()?.showModal();
|
||||||
const key = formData?.get('key')?.toString();
|
const key = formData?.get('key')?.toString();
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
@ -255,7 +300,18 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
|
|
||||||
api()?.insert(key);
|
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;
|
} as const;
|
||||||
|
|
||||||
return <div class={css.root}>
|
return <div class={css.root}>
|
||||||
|
@ -295,8 +351,12 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
<Menu.Item command={noop.withLabel('view')} />
|
<Menu.Item command={noop.withLabel('view')} />
|
||||||
</Menu.Root>
|
</Menu.Root>
|
||||||
|
|
||||||
<Prompt api={setPrompt} title="Which key do you want to create?" description={<>hint: use <code>.</code> to denote nested keys,<br /> i.e. <code>this.is.some.key</code> would be a key that is four levels deep</>}>
|
<Prompt api={setNewKeyPrompt} title="Which key do you want to create?" description={<>hint: use <code>.</code> to denote nested keys,<br /> i.e. <code>this.is.some.key</code> would be a key that is four levels deep</>}>
|
||||||
<input name="key" value="this.is.an.awesome.key" placeholder="name of new key ()" />
|
<input name="key" placeholder="name of new key ()" value="keyF.some.deeper.nested.value" />
|
||||||
|
</Prompt>
|
||||||
|
|
||||||
|
<Prompt api={setNewLanguagePrompt}>
|
||||||
|
<input name="locale" placeholder="locale code, i.e. en-GB" value="fr-FR" />
|
||||||
</Prompt>
|
</Prompt>
|
||||||
|
|
||||||
<Sidebar as="aside" label={tree().name} class={css.sidebar}>
|
<Sidebar as="aside" label={tree().name} class={css.sidebar}>
|
||||||
|
|
|
@ -54,10 +54,10 @@ export enum MutarionKind {
|
||||||
Update = 'updated',
|
Update = 'updated',
|
||||||
Delete = 'deleted',
|
Delete = 'deleted',
|
||||||
}
|
}
|
||||||
type Created = { kind: MutarionKind.Create, value: any };
|
export type Created = { kind: MutarionKind.Create, key: string, value: any };
|
||||||
type Updated = { kind: MutarionKind.Update, value: any, original: any };
|
export type Updated = { kind: MutarionKind.Update, key: string, value: any, original: any };
|
||||||
type Deleted = { kind: MutarionKind.Delete };
|
export type Deleted = { kind: MutarionKind.Delete, key: string };
|
||||||
export type Mutation = { key: string } & (Created | Updated | Deleted);
|
export type Mutation = Created | Updated | Deleted;
|
||||||
|
|
||||||
export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, path: string[] = []): Generator<Mutation, void, unknown> {
|
export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, path: string[] = []): Generator<Mutation, void, unknown> {
|
||||||
if (!isIterable(a) || !isIterable(b)) {
|
if (!isIterable(a) || !isIterable(b)) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue