From 012b4eca119345f28319abcb69038d89e1031196 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 25 Jan 2026 20:47:10 +0100 Subject: [PATCH 1/3] refactor: add asyncStore (@fehmer) --- .../components/AsyncContent.spec.tsx | 111 +++++++-- frontend/__tests__/hooks/asyncStore.spec.tsx | 214 +++++++++++++++++ .../src/ts/components/common/AsyncContent.tsx | 97 +++++--- frontend/src/ts/hooks/asyncStore.ts | 219 ++++++++++++++++++ frontend/src/ts/utils/misc.ts | 6 +- 5 files changed, 598 insertions(+), 49 deletions(-) create mode 100644 frontend/__tests__/hooks/asyncStore.spec.tsx create mode 100644 frontend/src/ts/hooks/asyncStore.ts diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx index 0147be215dd3..e7ba7136e0b2 100644 --- a/frontend/__tests__/components/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/AsyncContent.spec.tsx @@ -3,25 +3,9 @@ import { createResource, Resource } from "solid-js"; import { describe, it, expect } from "vitest"; import AsyncContent from "../../src/ts/components/common/AsyncContent"; +import { AsyncStore, createAsyncStore } from "../../src/ts/hooks/asyncStore"; describe("AsyncContent", () => { - function renderWithResource( - resource: Resource, - errorMessage?: string, - ): { - container: HTMLElement; - } { - const { container } = render(() => ( - - {(data) =>
{String(data)}
} -
- )); - - return { - container, - }; - } - it("renders loading state while resource is pending", () => { const [resource] = createResource(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -76,4 +60,97 @@ describe("AsyncContent", () => { expect(screen.getByText(/An error occurred/)).toBeInTheDocument(); }); }); + + it("renders loading state while asyncStore is pending", () => { + const asyncStore = createAsyncStore({ + name: "test", + fetcher: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { data: "data" }; + }, + }); + + const { container } = renderWithAsyncStore(asyncStore); + + const preloader = container.querySelector(".preloader"); + expect(preloader).toBeInTheDocument(); + expect(preloader).toHaveClass("preloader"); + expect(preloader?.querySelector("i")).toHaveClass( + "fas", + "fa-fw", + "fa-spin", + "fa-circle-notch", + ); + }); + + it("renders data when asyncStore resolves", async () => { + const asyncStore = createAsyncStore<{ data?: string }>({ + name: "test", + fetcher: async () => { + return { data: "Test Data" }; + }, + autoLoad: true, + }); + + renderWithAsyncStore(asyncStore); + + await waitFor(() => { + expect(screen.getByTestId("content")).toHaveTextContent("Test Data"); + }); + }); + + it.skip("renders error message when asyncStore fails", async () => { + const asyncStore = createAsyncStore({ + name: "test", + fetcher: async () => { + throw new Error("Test error"); + }, + }); + + try { + renderWithAsyncStore(asyncStore as any, "Custom error message"); + } catch {} + + await expect(() => asyncStore.ready()).rejects; + await waitFor(() => { + expect( + screen.getByText(/Custom error message: Test error/), + ).toBeInTheDocument(); + }); + }); + + function renderWithResource( + resource: Resource, + errorMessage?: string, + ): { + container: HTMLElement; + } { + const { container } = render(() => ( + + {(data) =>
{String(data)}
} +
+ )); + + return { + container, + }; + } + + function renderWithAsyncStore( + asyncStore: AsyncStore<{ data?: string }>, + errorMessage?: string, + ): { + container: HTMLElement; + } { + asyncStore.load(); + const { container } = render(() => ( + + {(data) => {data.data}} + + )); + + return { + container, + }; + } }); diff --git a/frontend/__tests__/hooks/asyncStore.spec.tsx b/frontend/__tests__/hooks/asyncStore.spec.tsx new file mode 100644 index 000000000000..5b2cb6582a8f --- /dev/null +++ b/frontend/__tests__/hooks/asyncStore.spec.tsx @@ -0,0 +1,214 @@ +import { render, waitFor } from "@solidjs/testing-library"; +import { For } from "solid-js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createAsyncStore } from "../../src/ts/hooks/asyncStore"; + +const fetcher = vi.fn(); +const initialValue = vi.fn(() => ({ data: null })); + +describe("createAsyncStore", () => { + beforeEach(() => { + fetcher.mockClear(); + initialValue.mockClear(); + }); + + it("should initialize with the correct state", () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + + expect(store.state().state).toBe("unresolved"); + expect(store.state().loading).toBe(false); + expect(store.state().ready).toBe(false); + expect(store.state().refreshing).toBe(false); + expect(store.state().error).toBeUndefined(); + expect(store.store()).toEqual({ data: null }); + }); + + it("should transition to loading when load is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + store.load(); + + expect(store.state().state).toBe("pending"); + expect(store.state().loading).toBe(true); + }); + + it("should enable loading if ready is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + + await store.ready(); + }); + + it("should call the fetcher when load is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(store.state().state).toBe("ready"); + expect(store.store()).toEqual({ data: "test" }); + }); + + it("should handle error when fetcher fails", async () => { + fetcher.mockRejectedValueOnce(new Error("Failed to load")); + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + + store.load(); + + await expect(store.ready()).rejects.toThrow("Failed to load"); + + expect(store.state().state).toBe("errored"); + expect(store.state().error).toEqual(new Error("Failed to load")); + }); + + it("should transition to refreshing state on refresh", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + store.refresh(); // trigger refresh + expect(store.state().state).toBe("refreshing"); + expect(store.state().refreshing).toBe(true); + }); + + it("should trigger load when refresh is called and shouldLoad is false", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + expect(store.state().state).toBe("unresolved"); + + store.refresh(); + expect(store.state().state).toBe("refreshing"); + expect(store.state().refreshing).toBe(true); + + // Wait for the store to be ready after fetching + await store.ready(); + + // Ensure the store's state is 'ready' after the refresh + expect(store.state().state).toBe("ready"); + expect(store.store()).toEqual({ data: "test" }); + }); + + it("should reset the store to its initial value on reset", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + expect(store.store()).toEqual({ data: "test" }); + + store.reset(); + expect(store.state().state).toBe("unresolved"); + expect(store.state().loading).toBe(false); + expect(store.store()).toEqual({ data: null }); + }); + + it("should persist changes", async () => { + const persist = vi.fn(); + persist.mockResolvedValue({}); + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + persist, + }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + store.update({ data: "newValue" }); + expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + }); + + it("fails updating when not ready", async () => { + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + }); + + expect(() => store.update({})).toThrowError( + "Store test cannot update in state unresolved", + ); + }); + + it("should be reactive", async () => { + const store = createAsyncStore<{ + data: string; + nested?: { number: number }; + list: string[]; + }>({ name: "test", fetcher }); + fetcher.mockResolvedValueOnce({ + data: "test", + nested: { number: 1 }, + list: ["Bob", "Kevin"], + }); + fetcher.mockResolvedValueOnce({ + data: "updated", + nested: { number: 2 }, + list: ["Bob", "Stuart"], + }); + + const { container } = render(() => ( + + State: {store.state().state}
+ Loading: {store.state().loading ? "true" : "false"}
+ Data: {store.store()?.data ?? "empty"}
+ Number: {store.store()?.nested?.number ?? "no number"}; List:{" "} + + {(item) => {item},} + +
+ )); + + //initial state + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + + //load + store.load(); + expect(container.textContent).toContain("Loading: true"); + expect(container.textContent).toContain("State: pending"); + expect(container.textContent).toContain("Data: empty"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + + //resource loaded successfull + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //modify + store.update({ nested: { number: 3 } }); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 3"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //refresh + store.refresh(); + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: updated"); + expect(container.textContent).toContain("Number: 2"); + expect(container.textContent).toContain("List: Bob,Stuart,"); + + //reset back to initial state + store.reset(); + await waitFor(() => + store.state().state === "unresolved" ? true : undefined, + ); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + }); +}); diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 5d235672e904..443ff1fc4973 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,6 +1,13 @@ -import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js"; +import { + createMemo, + ErrorBoundary, + JSXElement, + Resource, + Show, +} from "solid-js"; import * as Notifications from "../../elements/notifications"; +import { AsyncStore } from "../../hooks/asyncStore"; import { createErrorMessage } from "../../utils/misc"; import { Conditional } from "./Conditional"; @@ -8,23 +15,44 @@ import { Fa } from "./Fa"; export default function AsyncContent( props: { - resource: Resource; errorMessage?: string; } & ( | { - alwaysShowContent?: never; - children: (data: T) => JSXElement; + resource: Resource; + asyncStore?: never; } | { - alwaysShowContent: true; - showLoader?: true; - children: (data: T | undefined) => JSXElement; + asyncStore: AsyncStore; + resource?: never; } - ), + ) & + ( + | { + alwaysShowContent?: never; + children: (data: T) => JSXElement; + } + | { + alwaysShowContent: true; + showLoader?: true; + children: (data: T | undefined) => JSXElement; + } + ), ): JSXElement { + const source = createMemo(() => + props.resource !== undefined + ? { + value: props.resource, + loading: () => props.resource.loading, + } + : { + value: props.asyncStore.store, + loading: () => props.asyncStore.state().loading, + }, + ); + const value = () => { try { - return props.resource(); + return source().value; } catch (err) { const message = createErrorMessage( err, @@ -36,10 +64,19 @@ export default function AsyncContent( } }; const handleError = (err: unknown): string => { - console.error(err); + console.error("AsyncContext failed", err); return createErrorMessage(err, props.errorMessage ?? "An error occurred"); }; + const loader: JSXElement = ( +
+ +
+ ); + + const errorText = (err: unknown): JSXElement => ( +
{handleError(err)}
+ ); return ( ( }; return ( <> - -
- -
-
- {p.children(value())} + {loader} + {p.children(value()?.())} ); })()} else={ -
{handleError(err)}
} - > - - - + + + {errorText(props.asyncStore?.state().error)} + + + + {props.children(source().value() as T)} + } - > - - {props.children(props.resource() as T)} - - + />
} /> diff --git a/frontend/src/ts/hooks/asyncStore.ts b/frontend/src/ts/hooks/asyncStore.ts new file mode 100644 index 000000000000..62ada670a932 --- /dev/null +++ b/frontend/src/ts/hooks/asyncStore.ts @@ -0,0 +1,219 @@ +import type { Accessor } from "solid-js"; +import { createEffect, createResource, createSignal } from "solid-js"; +import { createStore, reconcile, Store } from "solid-js/store"; +import { promiseWithResolvers } from "../utils/misc"; + +export type LoadError = Error | { message?: string }; +type State = + | { + state: "unresolved"; + loading: false; + ready: false; + refreshing: false; + error?: undefined; + } + | { + state: "pending"; + loading: true; + ready: false; + refreshing: false; + error?: undefined; + } + | { + state: "ready"; + loading: false; + ready: true; + refreshing: false; + error?: undefined; + } + | { + state: "refreshing"; + loading: true; + ready: true; + refreshing: true; + error?: undefined; + } + | { + state: "errored"; + loading: false; + ready: false; + refreshing: false; + error: LoadError; + }; + +export type AsyncStore = { + /** + * request store to be loaded + */ + load: () => void; + + /** + * request store to be refreshed + */ + refresh: () => void; + + /** + * reset the resource + store + */ + reset: () => void; + + /** + * store state + */ + state: Accessor; + + /** + * the data store + */ + store: Accessor>; + + /** + * update store with the merged value + */ + update: (value: Partial) => void; + + /** + * promise that resolves when the store is ready. + * rejects if shouldLoad is false + */ + ready: () => Promise; +}; + +export function createAsyncStore({ + name, + fetcher, + persist, + initialValue, + autoLoad, +}: { + name: string; + fetcher: () => Promise; + persist?: (value: T) => Promise; + initialValue?: () => T; + autoLoad?: boolean; +}): AsyncStore { + console.debug(`AsyncStore ${name}: created`); + const [shouldLoad, setShouldLoad] = createSignal(autoLoad ?? false); + const [getState, setState] = createSignal({ + state: "unresolved", + loading: false, + ready: false, + refreshing: false, + error: undefined, + }); + + const [res, { refetch }] = createResource(shouldLoad, async (load) => { + if (!load) return undefined as unknown as T; + return fetcher(); + }); + + const initVal = initialValue?.(); + const [store, setStore] = createStore<{ + available: boolean; + value: T | undefined; + }>({ available: initVal !== undefined, value: initVal }); + let ready = promiseWithResolvers(); + + const resetStore = (): void => { + const fallbackValue = initialValue?.(); + setStore({ + available: fallbackValue !== undefined, + value: fallbackValue, + }); + }; + + const updateState = (state: State["state"], error?: LoadError): void => { + console.debug(`AsyncStore ${name}: update state to ${state}.`); + setState({ + state, + loading: state === "pending", + ready: state === "ready", + refreshing: state === "refreshing", + error: error, + } as State); + }; + + //TODO create effect on resource? + createEffect(() => { + if (!shouldLoad()) return; + if (res.error !== undefined) { + ready.reject(res.error); + updateState(res.state, res.error as LoadError); + resetStore(); + return; + } + + const data = res(); + if (data) { + updateState(res.state); + setStore(reconcile({ available: true, value: data })); + console.debug(`AsyncStore ${name}: updated store to`, store.value); + ready.resolve(data); + } + }); + + createEffect(() => { + if (!shouldLoad()) { + updateState("unresolved"); + return; + } + updateState("pending"); + }); + + const load = (): void => { + if (!shouldLoad()) setShouldLoad(true); + }; + const refresh = (): void => { + if (!shouldLoad()) { + setShouldLoad(true); + } + ready.reset(); + updateState("refreshing"); + void refetch(); + }; + + const reset = (): void => { + setShouldLoad(false); + resetStore(); + updateState("unresolved"); + + // reject any waiters + const oldReady = ready; + ready = promiseWithResolvers(); + oldReady.reject?.(new Error("Reset")); + }; + + return { + load, + refresh, + reset, + state: getState, + store: () => store.value, + update: (value): void => { + if (!getState().ready) { + throw new Error( + `Store ${name} cannot update in state ${getState().state}`, + ); + } + setStore( + reconcile( + { + available: value !== undefined, + value: { ...store.value, ...value } as T, + }, + { merge: true }, + ), + ); + + if (persist !== undefined && store.value !== undefined) { + void persist(store.value).then(() => + console.debug(`Store ${name} persisted.`), + ); + } + }, + ready: async () => { + load(); + return ready.promise; + }, + }; +} diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 8666e9eece3a..f20fe0e6bf18 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -622,7 +622,11 @@ export function promiseWithResolvers(): { }; const reject = (reason?: unknown): void => { - innerReject(reason); + try { + innerReject(reason); + } catch (e) { + //ignore no awaits + } }; return { From 7a38c7b305a8ed910ca2de3ed2599cfb60cd7838 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 Jan 2026 12:06:25 +0100 Subject: [PATCH 2/3] refetch if persist fails --- frontend/__tests__/hooks/asyncStore.spec.tsx | 23 ++++++++++++++++++++ frontend/src/ts/hooks/asyncStore.ts | 13 ++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/frontend/__tests__/hooks/asyncStore.spec.tsx b/frontend/__tests__/hooks/asyncStore.spec.tsx index 5b2cb6582a8f..85863ed41041 100644 --- a/frontend/__tests__/hooks/asyncStore.spec.tsx +++ b/frontend/__tests__/hooks/asyncStore.spec.tsx @@ -3,6 +3,7 @@ import { For } from "solid-js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createAsyncStore } from "../../src/ts/hooks/asyncStore"; +import { sleep } from "../../src/ts/utils/misc"; const fetcher = vi.fn(); const initialValue = vi.fn(() => ({ data: null })); @@ -120,6 +121,7 @@ describe("createAsyncStore", () => { store.update({ data: "newValue" }); expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + expect(store.store()?.data).toEqual("newValue"); }); it("fails updating when not ready", async () => { @@ -133,6 +135,27 @@ describe("createAsyncStore", () => { ); }); + it("should refresh if persist fails", async () => { + const persist = vi.fn(); + persist.mockRejectedValue("no good"); + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + persist, + }); + fetcher.mockResolvedValue({ data: "oldValue" }); + store.load(); + + await store.ready(); + + fetcher.mockClear().mockResolvedValue({ data: "refetchedValue" }); + store.update({ data: "newValue" }); + expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + + await sleep(100); + expect(store.store()?.data).toEqual("refetchedValue"); + }); + it("should be reactive", async () => { const store = createAsyncStore<{ data: string; diff --git a/frontend/src/ts/hooks/asyncStore.ts b/frontend/src/ts/hooks/asyncStore.ts index 62ada670a932..81e975b21eaa 100644 --- a/frontend/src/ts/hooks/asyncStore.ts +++ b/frontend/src/ts/hooks/asyncStore.ts @@ -180,6 +180,9 @@ export function createAsyncStore({ // reject any waiters const oldReady = ready; ready = promiseWithResolvers(); + oldReady.promise.catch(() => { + /* */ + }); oldReady.reject?.(new Error("Reset")); }; @@ -206,9 +209,13 @@ export function createAsyncStore({ ); if (persist !== undefined && store.value !== undefined) { - void persist(store.value).then(() => - console.debug(`Store ${name} persisted.`), - ); + void persist(store.value) + .then(() => console.debug(`Store ${name} persisted.`)) + .catch((error: unknown) => { + console.debug(`AsyncStore ${name}: persist failed with`, error); + //on error refresh the local store with the remote content + refresh(); + }); } }, ready: async () => { From 906303ef1decfbd6c9383b5d06b0bd038bfb5420 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 27 Jan 2026 01:23:10 +0100 Subject: [PATCH 3/3] convert to classes, add array functions --- .../components/AsyncContent.spec.tsx | 6 +- frontend/__tests__/hooks/asyncStore.spec.tsx | 168 ++++++-- .../src/ts/components/common/AsyncContent.tsx | 8 +- frontend/src/ts/hooks/asyncStore.ts | 404 +++++++++++------- 4 files changed, 401 insertions(+), 185 deletions(-) diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx index e7ba7136e0b2..2a042e72f841 100644 --- a/frontend/__tests__/components/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/AsyncContent.spec.tsx @@ -62,7 +62,7 @@ describe("AsyncContent", () => { }); it("renders loading state while asyncStore is pending", () => { - const asyncStore = createAsyncStore({ + const asyncStore = createAsyncStore<{ data?: string }>({ name: "test", fetcher: async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -89,7 +89,7 @@ describe("AsyncContent", () => { fetcher: async () => { return { data: "Test Data" }; }, - autoLoad: true, + autoLoad: () => true, }); renderWithAsyncStore(asyncStore); @@ -145,7 +145,7 @@ describe("AsyncContent", () => { asyncStore.load(); const { container } = render(() => ( - {(data) => {data.data}} + {(data) => {data?.data}} )); diff --git a/frontend/__tests__/hooks/asyncStore.spec.tsx b/frontend/__tests__/hooks/asyncStore.spec.tsx index 85863ed41041..2dd2c023863c 100644 --- a/frontend/__tests__/hooks/asyncStore.spec.tsx +++ b/frontend/__tests__/hooks/asyncStore.spec.tsx @@ -2,7 +2,10 @@ import { render, waitFor } from "@solidjs/testing-library"; import { For } from "solid-js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createAsyncStore } from "../../src/ts/hooks/asyncStore"; +import { + createAsyncArrayStore, + createAsyncStore, +} from "../../src/ts/hooks/asyncStore"; import { sleep } from "../../src/ts/utils/misc"; const fetcher = vi.fn(); @@ -17,20 +20,20 @@ describe("createAsyncStore", () => { it("should initialize with the correct state", () => { const store = createAsyncStore({ name: "test", fetcher, initialValue }); - expect(store.state().state).toBe("unresolved"); - expect(store.state().loading).toBe(false); - expect(store.state().ready).toBe(false); - expect(store.state().refreshing).toBe(false); - expect(store.state().error).toBeUndefined(); - expect(store.store()).toEqual({ data: null }); + expect(store.state.state).toBe("unresolved"); + expect(store.state.loading).toBe(false); + expect(store.state.ready).toBe(false); + expect(store.state.refreshing).toBe(false); + expect(store.state.error).toBeUndefined(); + expect(store.store).toEqual({ data: null }); }); it("should transition to loading when load is called", async () => { const store = createAsyncStore({ name: "test", fetcher, initialValue }); store.load(); - expect(store.state().state).toBe("pending"); - expect(store.state().loading).toBe(true); + expect(store.state.state).toBe("pending"); + expect(store.state.loading).toBe(true); }); it("should enable loading if ready is called", async () => { @@ -48,8 +51,8 @@ describe("createAsyncStore", () => { await store.ready(); expect(fetcher).toHaveBeenCalledTimes(1); - expect(store.state().state).toBe("ready"); - expect(store.store()).toEqual({ data: "test" }); + expect(store.state.state).toBe("ready"); + expect(store.store).toEqual({ data: "test" }); }); it("should handle error when fetcher fails", async () => { @@ -60,8 +63,8 @@ describe("createAsyncStore", () => { await expect(store.ready()).rejects.toThrow("Failed to load"); - expect(store.state().state).toBe("errored"); - expect(store.state().error).toEqual(new Error("Failed to load")); + expect(store.state.state).toBe("errored"); + expect(store.state.error).toEqual(new Error("Failed to load")); }); it("should transition to refreshing state on refresh", async () => { @@ -70,25 +73,25 @@ describe("createAsyncStore", () => { store.load(); store.refresh(); // trigger refresh - expect(store.state().state).toBe("refreshing"); - expect(store.state().refreshing).toBe(true); + expect(store.state.state).toBe("refreshing"); + expect(store.state.refreshing).toBe(true); }); it("should trigger load when refresh is called and shouldLoad is false", async () => { const store = createAsyncStore({ name: "test", fetcher, initialValue }); fetcher.mockResolvedValueOnce({ data: "test" }); - expect(store.state().state).toBe("unresolved"); + expect(store.state.state).toBe("unresolved"); store.refresh(); - expect(store.state().state).toBe("refreshing"); - expect(store.state().refreshing).toBe(true); + expect(store.state.state).toBe("refreshing"); + expect(store.state.refreshing).toBe(true); // Wait for the store to be ready after fetching await store.ready(); // Ensure the store's state is 'ready' after the refresh - expect(store.state().state).toBe("ready"); - expect(store.store()).toEqual({ data: "test" }); + expect(store.state.state).toBe("ready"); + expect(store.store).toEqual({ data: "test" }); }); it("should reset the store to its initial value on reset", async () => { @@ -98,12 +101,12 @@ describe("createAsyncStore", () => { await store.ready(); - expect(store.store()).toEqual({ data: "test" }); + expect(store.store).toEqual({ data: "test" }); store.reset(); - expect(store.state().state).toBe("unresolved"); - expect(store.state().loading).toBe(false); - expect(store.store()).toEqual({ data: null }); + expect(store.state.state).toBe("unresolved"); + expect(store.state.loading).toBe(false); + expect(store.store).toEqual({ data: null }); }); it("should persist changes", async () => { @@ -121,7 +124,7 @@ describe("createAsyncStore", () => { store.update({ data: "newValue" }); expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); - expect(store.store()?.data).toEqual("newValue"); + expect(store.store?.data).toEqual("newValue"); }); it("fails updating when not ready", async () => { @@ -153,7 +156,7 @@ describe("createAsyncStore", () => { expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); await sleep(100); - expect(store.store()?.data).toEqual("refetchedValue"); + expect(store.store?.data).toEqual("refetchedValue"); }); it("should be reactive", async () => { @@ -175,11 +178,11 @@ describe("createAsyncStore", () => { const { container } = render(() => ( - State: {store.state().state}
- Loading: {store.state().loading ? "true" : "false"}
- Data: {store.store()?.data ?? "empty"}
- Number: {store.store()?.nested?.number ?? "no number"}; List:{" "} - + State: {store.state.state}
+ Loading: {store.state.loading ? "true" : "false"}
+ Data: {store.store?.data ?? "empty"}
+ Number: {store.store?.nested?.number ?? "no number"}; List:{" "} + {(item) => {item},}
@@ -199,7 +202,7 @@ describe("createAsyncStore", () => { expect(container.textContent).toContain("Number: no number"); expect(container.textContent).toContain("List: no list"); - //resource loaded successfull + //resource loaded successful await store.ready(); expect(container.textContent).toContain("Loading: false"); expect(container.textContent).toContain("State: ready"); @@ -227,7 +230,7 @@ describe("createAsyncStore", () => { //reset back to initial state store.reset(); await waitFor(() => - store.state().state === "unresolved" ? true : undefined, + store.state.state === "unresolved" ? true : undefined, ); expect(container.textContent).toContain("Loading: false"); expect(container.textContent).toContain("State: unresolved"); @@ -235,3 +238,102 @@ describe("createAsyncStore", () => { expect(container.textContent).toContain("List: no list"); }); }); + +describe("createAsyncArrayStore", () => { + beforeEach(() => { + fetcher.mockClear(); + initialValue.mockClear(); + }); + + it("should be reactive", async () => { + const store = createAsyncArrayStore<{ + data: string; + nested?: { number: number }; + list: string[]; + }>({ name: "test", fetcher }); + fetcher.mockResolvedValueOnce([ + { + data: "test", + nested: { number: 1 }, + list: ["Bob", "Kevin"], + }, + ]); + fetcher.mockResolvedValueOnce([ + { + data: "updated", + nested: { number: 2 }, + list: ["Bob", "Stuart"], + }, + ]); + + const { container } = render(() => ( + + State: {store.state.state}
+ Loading: {store.state.loading ? "true" : "false"}
+ + {(item) => ( + <> + Data: {item.data ?? "empty"}
+ Number: {item.nested?.number ?? "no number"}; List:{" "} + + {(li) => {li},} + + + )} +
+
+ )); + + //initial state + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("no items"); + + //load + store.load(); + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //add item + store.addItem({ + data: "new", + nested: { number: 42 }, + list: ["apple", "banana"], + }); + + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + expect(container.textContent).toContain("Data: new"); + expect(container.textContent).toContain("Number: 42"); + expect(container.textContent).toContain("List: apple,banana,"); + + //modify + store.updateItem((it) => it.data === "new", { + list: ["apple", "banana", "cherry"], + }); + expect(container.textContent).toContain("Data: new"); + expect(container.textContent).toContain("Number: 42"); + expect(container.textContent).toContain("List: apple,banana,cherry"); + + //remove + store.removeItem((it) => it.nested?.number === 42); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).not.toContain("Data: new"); + expect(container.textContent).not.toContain("Number: 42"); + expect(container.textContent).not.toContain("List: apple,banana,cherry"); + + //reset back to initial state + store.reset(); + await waitFor(() => + store.state.state === "unresolved" ? true : undefined, + ); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("no items"); + }); +}); diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 443ff1fc4973..4ffa6ede2640 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -45,8 +45,8 @@ export default function AsyncContent( loading: () => props.resource.loading, } : { - value: props.asyncStore.store, - loading: () => props.asyncStore.state().loading, + value: () => props.asyncStore.store, + loading: () => props.asyncStore.state.loading, }, ); @@ -94,8 +94,8 @@ export default function AsyncContent( })()} else={ - - {errorText(props.asyncStore?.state().error)} + + {errorText(props.asyncStore?.state.error)} = { + available: boolean; + value: T | undefined; +}; + type State = | { state: "unresolved"; @@ -41,186 +54,287 @@ type State = error: LoadError; }; -export type AsyncStore = { +export type AsyncStorePropertries = { + name: string; + fetcher: () => Promise; + persist?: (value: T) => Promise; + initialValue?: () => T; + autoLoad?: Accessor; +}; + +export function createAsyncStore( + props: AsyncStorePropertries, +): AsyncStore { + return new AsyncStore(props); +} + +export function createAsyncArrayStore( + props: AsyncStorePropertries, +): AsyncArrayStore { + return new AsyncArrayStore(props); +} + +export class AsyncStore { + private name: string; + private fetcher: () => Promise; + private persist?: (value: T) => Promise; + private initialValue?: () => T; + + private shouldLoad: Accessor; + private setShouldLoad: Setter; + + private getState: Accessor; + private setState: Setter; + + private res: Resource; + private refetch: () => void; + + protected _store: ValueWrapper; + protected setStore: SetStoreFunction>; + + private readyPromise = promiseWithResolvers(); + constructor({ + name, + fetcher, + persist, + initialValue, + autoLoad, + }: AsyncStorePropertries) { + console.debug(`AsyncStore ${name}: created`); + + this.name = name; + this.fetcher = fetcher; + this.persist = persist; + this.initialValue = initialValue; + + [this.shouldLoad, this.setShouldLoad] = createSignal(autoLoad?.() ?? false); + + [this.getState, this.setState] = createSignal({ + state: "unresolved", + loading: false, + ready: false, + refreshing: false, + error: undefined, + }); + + [this.res, { refetch: this.refetch }] = createResource( + this.shouldLoad, + async (load) => { + if (!load) return undefined as unknown as T; + return this.fetcher(); + }, + ); + + const initVal = this.initialValue?.(); + [this._store, this.setStore] = createStore>({ + available: initVal !== undefined, + value: initVal, + }); + + this.setupEffects(autoLoad); + } + /** * request store to be loaded */ - load: () => void; + load(): void { + if (!this.shouldLoad()) this.setShouldLoad(true); + } /** * request store to be refreshed */ - refresh: () => void; + refresh(): void { + if (!this.shouldLoad()) { + this.setShouldLoad(true); + } + this.readyPromise.reset(); + this.updateState("refreshing"); + this.refetch(); + } /** * reset the resource + store */ - reset: () => void; - - /** - * store state - */ - state: Accessor; + reset(): void { + this.setShouldLoad(false); + this.resetStore(); + this.updateState("unresolved"); - /** - * the data store - */ - store: Accessor>; + const oldReady = this.readyPromise; + this.readyPromise = promiseWithResolvers(); + oldReady.promise.catch(() => { + /* */ + }); + oldReady.reject?.(new Error("Reset")); + } /** * update store with the merged value */ - update: (value: Partial) => void; + update(value: Partial): void { + this.checkReady(); + this.setStore( + reconcile( + { + available: value !== undefined, + value: { ...this._store.value, ...value } as T, + }, + { merge: true }, + ), + ); + this.doPersist(); + } /** * promise that resolves when the store is ready. * rejects if shouldLoad is false */ - ready: () => Promise; -}; + async ready(): Promise { + this.load(); + return this.readyPromise.promise; + } -export function createAsyncStore({ - name, - fetcher, - persist, - initialValue, - autoLoad, -}: { - name: string; - fetcher: () => Promise; - persist?: (value: T) => Promise; - initialValue?: () => T; - autoLoad?: boolean; -}): AsyncStore { - console.debug(`AsyncStore ${name}: created`); - const [shouldLoad, setShouldLoad] = createSignal(autoLoad ?? false); - const [getState, setState] = createSignal({ - state: "unresolved", - loading: false, - ready: false, - refreshing: false, - error: undefined, - }); - - const [res, { refetch }] = createResource(shouldLoad, async (load) => { - if (!load) return undefined as unknown as T; - return fetcher(); - }); - - const initVal = initialValue?.(); - const [store, setStore] = createStore<{ - available: boolean; - value: T | undefined; - }>({ available: initVal !== undefined, value: initVal }); - let ready = promiseWithResolvers(); - - const resetStore = (): void => { - const fallbackValue = initialValue?.(); - setStore({ + get store(): Store { + return this._store.value; + } + get state(): State { + return this.getState(); + } + + get value(): Store { + return this._store.value; + } + + private resetStore(): void { + const fallbackValue = this.initialValue?.(); + this.setStore({ available: fallbackValue !== undefined, value: fallbackValue, }); - }; + } - const updateState = (state: State["state"], error?: LoadError): void => { - console.debug(`AsyncStore ${name}: update state to ${state}.`); - setState({ + private updateState(state: State["state"], error?: LoadError): void { + console.debug(`AsyncStore ${this.name}: update state to ${state}.`); + this.setState({ state, loading: state === "pending", ready: state === "ready", refreshing: state === "refreshing", - error: error, + error, } as State); - }; - - //TODO create effect on resource? - createEffect(() => { - if (!shouldLoad()) return; - if (res.error !== undefined) { - ready.reject(res.error); - updateState(res.state, res.error as LoadError); - resetStore(); - return; - } + } - const data = res(); - if (data) { - updateState(res.state); - setStore(reconcile({ available: true, value: data })); - console.debug(`AsyncStore ${name}: updated store to`, store.value); - ready.resolve(data); + protected checkReady(): void { + if (!this.getState().ready) { + throw new Error( + `Store ${this.name} cannot update in state ${this.getState().state}`, + ); } - }); + } - createEffect(() => { - if (!shouldLoad()) { - updateState("unresolved"); - return; + protected doPersist(): void { + if (this.persist && this._store.value !== undefined) { + void this.persist(this._store.value) + .then(() => console.debug(`Store ${this.name} persisted.`)) + .catch((error: unknown) => { + console.debug(`AsyncStore ${this.name}: persist failed with`, error); + this.refresh(); + }); } - updateState("pending"); - }); - - const load = (): void => { - if (!shouldLoad()) setShouldLoad(true); - }; - const refresh = (): void => { - if (!shouldLoad()) { - setShouldLoad(true); - } - ready.reset(); - updateState("refreshing"); - void refetch(); - }; - - const reset = (): void => { - setShouldLoad(false); - resetStore(); - updateState("unresolved"); - - // reject any waiters - const oldReady = ready; - ready = promiseWithResolvers(); - oldReady.promise.catch(() => { - /* */ - }); - oldReady.reject?.(new Error("Reset")); - }; - - return { - load, - refresh, - reset, - state: getState, - store: () => store.value, - update: (value): void => { - if (!getState().ready) { - throw new Error( - `Store ${name} cannot update in state ${getState().state}`, + } + + private setupEffects(autoLoad?: Accessor): void { + createEffect(() => { + if (!this.shouldLoad()) return; + + if (this.res.error !== undefined) { + this.readyPromise.reject(this.res.error); + this.updateState(this.res.state, this.res.error as LoadError); + this.resetStore(); + return; + } + + const data = this.res(); + if (data !== undefined) { + this.updateState(this.res.state); + this.setStore(reconcile({ available: true, value: data })); + console.debug( + `AsyncStore ${this.name}: updated store to`, + this._store.value, ); + this.readyPromise.resolve(data); } - setStore( - reconcile( - { - available: value !== undefined, - value: { ...store.value, ...value } as T, - }, - { merge: true }, - ), - ); + }); - if (persist !== undefined && store.value !== undefined) { - void persist(store.value) - .then(() => console.debug(`Store ${name} persisted.`)) - .catch((error: unknown) => { - console.debug(`AsyncStore ${name}: persist failed with`, error); - //on error refresh the local store with the remote content - refresh(); - }); + createEffect(() => { + if (!this.shouldLoad()) { + this.updateState("unresolved"); + return; } - }, - ready: async () => { - load(); - return ready.promise; - }, - }; + this.updateState("pending"); + }); + + if (autoLoad) { + createEffectOn(autoLoad, (val) => { + if (val !== undefined) this.setShouldLoad(val); + }); + } + } +} + +class AsyncArrayStore extends AsyncStore { + /** + * add item to the end of the array + * @param item + */ + addItem(item: T): void { + this.checkReady(); + this.setStore( + "value", + produce((items) => { + items?.push(item); + }), + ); + this.doPersist(); + } + + /** + * remove all items from the array matching the predicate + * @param predicate + */ + removeItem(predicate: (item: T) => boolean): void { + this.checkReady(); + + this.setStore( + reconcile({ + available: true, + value: this._store?.value?.filter((item) => !predicate(item)), + }), + ); + this.doPersist(); + } + + /** + * update all items in the array matching the predicate + * @param predicate + * @param updater + */ + updateItem(predicate: (item: T) => boolean, value: Partial): void { + this.checkReady(); + + const items = this._store.value; + if (!items) return; + + const index = items.findIndex(predicate); + if (index === -1) return; + + this.setStore( + "value", + index, + reconcile({ ...items[index], ...value } as T), + ); + + this.doPersist(); + } }