diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx
index 3e71764f398d..da25c43ee814 100644
--- a/frontend/__tests__/components/common/AsyncContent.spec.tsx
+++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx
@@ -1,8 +1,15 @@
import { render, screen, waitFor } from "@solidjs/testing-library";
-import { createResource, Resource, Show } from "solid-js";
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+} from "@tanstack/solid-query";
+import { JSXElement, Show } from "solid-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import AsyncContent from "../../../src/ts/components/common/AsyncContent";
+import AsyncContent, {
+ Props,
+} from "../../../src/ts/components/common/AsyncContent";
import * as Notifications from "../../../src/ts/elements/notifications";
describe("AsyncContent", () => {
@@ -11,18 +18,15 @@ describe("AsyncContent", () => {
beforeEach(() => {
addNotificationMock.mockClear();
});
- describe("with resource", () => {
- it("renders loading state while resource is pending", () => {
- const [resource] = createResource(async () => {
- await new Promise((resolve) => setTimeout(resolve, 100));
- return "data";
- });
- const { container } = renderWithResource(resource);
+ describe("with single query", () => {
+ const queryClient = new QueryClient();
+
+ it("renders loading state while pending", () => {
+ const { container } = renderWithQuery({ result: "data" });
const preloader = container.querySelector(".preloader");
expect(preloader).toBeInTheDocument();
- expect(preloader).toHaveClass("preloader");
expect(preloader?.querySelector("i")).toHaveClass(
"fas",
"fa-fw",
@@ -31,24 +35,42 @@ describe("AsyncContent", () => {
);
});
- it("renders data when resource resolves", async () => {
- const [resource] = createResource(async () => {
- return "Test Data";
- });
+ it("renders custom loader while pending", () => {
+ const { container } = renderWithQuery(
+ { result: "data" },
+ { loader: Loading... },
+ );
+
+ const preloader = container.querySelector(".preloader");
+ expect(preloader).toBeInTheDocument();
+ expect(preloader).toHaveTextContent("Loading...");
+ });
- renderWithResource(resource);
+ it("renders on resolve", async () => {
+ renderWithQuery({ result: "Test Data" });
await waitFor(() => {
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
});
});
- it("renders error message when resource fails", async () => {
- const [resource] = createResource(async () => {
- throw new Error("Test error");
+ it("renders default error message on fail", async () => {
+ renderWithQuery({ result: new Error("Test error") });
+
+ await waitFor(() => {
+ expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
});
+ expect(addNotificationMock).toHaveBeenCalledWith(
+ "An error occurred: Test error",
+ -1,
+ );
+ });
- renderWithResource(resource, "Custom error message");
+ it("renders custom error message on fail", async () => {
+ renderWithQuery(
+ { result: new Error("Test error") },
+ { errorMessage: "Custom error message" },
+ );
await waitFor(() => {
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
@@ -59,58 +81,215 @@ describe("AsyncContent", () => {
);
});
- it("renders default error message when no custom message provided", async () => {
- const [resource] = createResource(async () => {
- throw new Error("Test error");
+ it("ignores error on fail if ignoreError is set", async () => {
+ renderWithQuery(
+ { result: new Error("Test error") },
+ { ignoreError: true, alwaysShowContent: true },
+ );
+ await waitFor(() => {
+ expect(screen.getByText(/no data/)).toBeInTheDocument();
});
+ expect(addNotificationMock).not.toHaveBeenCalled();
+ });
- renderWithResource(resource);
+ it("renders on pending if alwaysShowContent", async () => {
+ const { container } = renderWithQuery({ result: "Test Data" });
await waitFor(() => {
- expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
+ expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
+ });
+ const preloader = container.querySelector(".preloader");
+ expect(preloader).not.toBeInTheDocument();
+ });
+
+ it("renders on resolve if alwaysShowContent", async () => {
+ renderWithQuery({ result: "Test Data" }, { alwaysShowContent: true });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
+ });
+ });
+
+ it("renders on fail if alwaysShowContent", async () => {
+ renderWithQuery(
+ { result: new Error("Test error") },
+ { errorMessage: "Custom error message" },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
});
expect(addNotificationMock).toHaveBeenCalledWith(
- "An error occurred: Test error",
+ "Custom error message: Test error",
-1,
);
});
- it("renders content while resource is pending if alwaysShowContent", () => {
- const [resource] = createResource(async () => {
- await new Promise((resolve) => setTimeout(resolve, 100));
- return "data";
- });
+ function renderWithQuery(
+ query: {
+ result: string | Error;
+ },
+ options?: Omit, "query" | "queries" | "children">,
+ ): {
+ container: HTMLElement;
+ } {
+ const wrapper = (): JSXElement => {
+ const myQuery = useQuery(() => ({
+ queryKey: ["test", Math.random() * 1000],
+ queryFn: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ if (query.result instanceof Error) {
+ throw query.result;
+ }
+ return query.result;
+ },
+ retry: 0,
+ }));
+
+ return (
+
+ {(data: string | undefined) => (
+ <>
+ foo
+ no data}>
+ {data}
+
+ >
+ )}
+
+ );
+ };
+ const { container } = render(() => (
+
+ {wrapper()}
+
+ ));
+
+ return {
+ container,
+ };
+ }
+ });
- const { container } = renderWithResource(resource, undefined, true);
+ describe("with multiple queries", () => {
+ const queryClient = new QueryClient();
+
+ it("renders loading state while pending", () => {
+ const { container } = renderWithQuery({ first: "data", second: "data" });
const preloader = container.querySelector(".preloader");
- expect(preloader).not.toBeInTheDocument();
- expect(container.querySelector("div")).toHaveTextContent("no data");
+ expect(preloader).toBeInTheDocument();
+ expect(preloader?.querySelector("i")).toHaveClass(
+ "fas",
+ "fa-fw",
+ "fa-spin",
+ "fa-circle-notch",
+ );
+ });
+
+ it("renders custom loader while pending", () => {
+ const { container } = renderWithQuery(
+ { first: "data", second: "data" },
+ { loader: Loading... },
+ );
+
+ const preloader = container.querySelector(".preloader");
+ expect(preloader).toBeInTheDocument();
+ expect(preloader).toHaveTextContent("Loading...");
});
- it("renders data when resource resolves if alwaysShowContent", async () => {
- const [resource] = createResource(async () => {
- return "Test Data";
+ it("renders on resolve", async () => {
+ renderWithQuery({ first: "First Data", second: "Second Data" });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("first")).toHaveTextContent("First Data");
});
+ await waitFor(() => {
+ expect(screen.getByTestId("second")).toHaveTextContent("Second Data");
+ });
+ });
- const { container } = renderWithResource(resource, undefined, true);
+ it("renders default error message on fail", async () => {
+ renderWithQuery({ first: "data", second: new Error("Test error") });
await waitFor(() => {
- expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
+ expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
+ });
+ expect(addNotificationMock).toHaveBeenCalledWith(
+ "An error occurred: Test error",
+ -1,
+ );
+ });
+
+ it("renders custom error message on fail", async () => {
+ renderWithQuery(
+ { first: new Error("First error"), second: new Error("Second error") },
+ { errorMessage: "Custom error message" },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
+ });
+ expect(addNotificationMock).toHaveBeenCalledWith(
+ "Custom error message: First error",
+ -1,
+ );
+ });
+
+ it("ignores error on fail if ignoreError is set", async () => {
+ renderWithQuery(
+ { first: new Error("First error"), second: new Error("Second error") },
+ { ignoreError: true, alwaysShowContent: true },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/no data/)).toBeInTheDocument();
});
+
+ expect(addNotificationMock).not.toHaveBeenCalled();
+ });
+
+ it("renders on pending if alwaysShowContent", async () => {
+ const { container } = renderWithQuery(
+ {
+ first: undefined,
+ second: undefined,
+ },
+ { alwaysShowContent: true },
+ );
+
const preloader = container.querySelector(".preloader");
expect(preloader).not.toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText(/no data/)).toBeInTheDocument();
+ });
});
- it("renders error message when resource fails if alwaysShowContent", async () => {
- const [resource] = createResource(async () => {
- throw new Error("Test error");
+ it("renders on resolve if alwaysShowContent", async () => {
+ renderWithQuery({
+ first: "First Data",
+ second: "Second Data",
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("first")).toHaveTextContent("First Data");
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId("second")).toHaveTextContent("Second Data");
});
+ });
- const { container } = renderWithResource(
- resource,
- "Custom error message",
- true,
+ it("renders on fail if alwaysShowContent", async () => {
+ renderWithQuery(
+ { first: "data", second: new Error("Test error") },
+ { errorMessage: "Custom error message" },
);
await waitFor(() => {
@@ -120,30 +299,72 @@ describe("AsyncContent", () => {
"Custom error message: Test error",
-1,
);
- console.log(container.innerHTML);
});
- function renderWithResource(
- resource: Resource,
- errorMessage?: string,
- alwaysShowContent?: true,
+
+ function renderWithQuery(
+ queries: {
+ first: string | Error | undefined;
+ second: string | Error | undefined;
+ },
+ options?: Omit, "query" | "queries" | "children">,
): {
container: HTMLElement;
} {
+ const wrapper = (): JSXElement => {
+ const firstQuery = useQuery(() => ({
+ queryKey: ["first", Math.random() * 1000],
+ queryFn: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ if (queries.first instanceof Error) {
+ throw queries.first;
+ }
+ return queries.first;
+ },
+ retry: 0,
+ }));
+ const secondQuery = useQuery(() => ({
+ queryKey: ["second", Math.random() * 1000],
+ queryFn: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ if (queries.second instanceof Error) {
+ throw queries.second;
+ }
+ return queries.second;
+ },
+ retry: 0,
+ }));
+
+ return (
+
+ {(results: {
+ first: string | undefined;
+ second: string | undefined;
+ }) => (
+ <>
+ no data}
+ >
+ {results.first}
+ {results.second}
+
+ >
+ )}
+
+ );
+ };
const { container } = render(() => (
-
- {(data: T | undefined) => (
- <>
- foo
- no data}>
- {String(data)}
-
- >
- )}
-
+
+ {wrapper()}
+
));
return {
diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx
index e56e4173055f..13438a7e70ec 100644
--- a/frontend/src/ts/components/common/AsyncContent.tsx
+++ b/frontend/src/ts/components/common/AsyncContent.tsx
@@ -4,121 +4,162 @@ import {
ErrorBoundary,
JSXElement,
Match,
- Resource,
Show,
Switch,
} from "solid-js";
import * as Notifications from "../../elements/notifications";
-import { createErrorMessage } from "../../utils/misc";
+import { createErrorMessage, typedKeys } from "../../utils/misc";
import { Conditional } from "./Conditional";
import { LoadingCircle } from "./LoadingCircle";
-export default function AsyncContent(
- props: {
- errorMessage?: string;
- loader?: JSXElement;
- } & (
- | {
- resource: Resource;
- query?: never;
- }
- | {
- resource?: never;
- query: UseQueryResult;
- }
- ) &
- (
- | {
- alwaysShowContent?: never;
- children: (data: T) => JSXElement;
- }
- | {
- alwaysShowContent: true;
- showLoader?: true;
- children: (data: T | undefined) => JSXElement;
- }
- ),
-): JSXElement {
- const source = createMemo(() => {
- if (props.resource !== undefined) {
- return {
- value: props.resource,
- isLoading: () => props.resource.loading,
- isError: () => false,
- };
- }
+type AsyncEntry = {
+ value: () => T | undefined;
+ isLoading: () => boolean;
+ isError: () => boolean;
+ error?: () => unknown;
+};
+
+type QueryMapping = Record | unknown;
+type AsyncMap = {
+ [K in keyof T]: AsyncEntry;
+};
+
+type BaseProps = {
+ errorMessage?: string;
+ ignoreError?: true;
+ loader?: JSXElement;
+};
+
+type QueryProps = {
+ queries: { [K in keyof T]: UseQueryResult };
+ query?: never;
+};
+type SingleQueryProps = {
+ query: UseQueryResult;
+ queries?: never;
+};
+
+type DeferredChildren = {
+ alwaysShowContent?: false;
+ children: (data: { [K in keyof T]: T[K] }) => JSXElement;
+};
+
+type EagerChildren = {
+ alwaysShowContent: true;
+ showLoader?: true;
+ children: (data: { [K in keyof T]: T[K] | undefined }) => JSXElement;
+};
+
+export type Props = BaseProps &
+ (QueryProps | SingleQueryProps) &
+ (DeferredChildren | EagerChildren);
+
+export default function AsyncContent(
+ props: Props,
+): JSXElement {
+ //@ts-expect-error this is fine
+ const source = createMemo>(() => {
if (props.query !== undefined) {
- return {
- value: () => props.query.data,
- isLoading: () => props.query?.isLoading,
- isError: () => props.query.isError,
- error: () => props.query.error,
- };
+ return fromQueries({ defaultQuery: props.query });
+ } else {
+ return fromQueries(props.queries);
}
- throw new Error("missing source");
});
- const value = () => {
- try {
- return source().value;
- } catch (err) {
- handleError(err);
- return undefined;
+ const value = (): T => {
+ if ("defaultQuery" in source()) {
+ //@ts-expect-error we know the property is present
+ // oxlint-disable-next-line typescript/no-unsafe-call typescript/no-unsafe-member-access
+ return source().defaultQuery.value() as T;
+ } else {
+ return Object.fromEntries(
+ typedKeys(source()).map((key) => [key, source()[key].value()]),
+ ) as T; // For multiple queries
}
};
+
const handleError = (err: unknown): string => {
const message = createErrorMessage(
err,
props.errorMessage ?? "An error occurred",
);
- console.error("AsyncContext failed", message, err);
+ console.error("AsyncMultiContent failed", message, err);
+
Notifications.add(message, -1);
+
return message;
};
+ function allResolved(
+ data: ReturnType,
+ ): data is { [K in keyof T]: T[K] } {
+ //single query
+ if (data === undefined || data === null) {
+ return false;
+ }
+
+ return Object.values(data).every((v) => v !== undefined && v !== null);
+ }
+
+ const isLoading = (): boolean =>
+ Object.values(source() as AsyncEntry[]).some((s) => s.isLoading());
+
+ const firstError = (): unknown | undefined =>
+ Object.values(source() as AsyncEntry[])
+ .find((s) => s.isError())
+ ?.error?.();
+
const loader = (): JSXElement =>
props.loader ?? ;
- const errorText = (err: unknown): JSXElement => (
- {handleError(err)}
- );
+ const errorText = (err: unknown): JSXElement | undefined =>
+ props.ignoreError ? undefined : {handleError(err)}
;
+
return (
-
+
-
+
{loader()}
+
{
- const p = props as {
- children: (data: T | undefined) => JSXElement;
- };
- return <>{p.children(value()?.())}>;
- })()}
+ then={<>{props.children(value())}>}
else={
-
- {props.children(source().value() as T)}
+
+ {props.children(value())}
}
/>
>
}
>
- {errorText(source().error?.())}
-
- {loader()}
+
+ {errorText(firstError())}
+
+ {loader()}
);
}
+
+function fromQueries>(queries: {
+ [K in keyof T]: UseQueryResult;
+}): AsyncMap {
+ return typedKeys(queries).reduce((acc, key) => {
+ const q = queries[key];
+ acc[key] = {
+ value: () => q.data,
+ isLoading: () => q.isLoading,
+ isError: () => q.isError,
+ error: () => q.error,
+ };
+ return acc;
+ }, {} as AsyncMap);
+}
diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts
index 36b0b88279a6..fbdeda0f09a6 100644
--- a/frontend/src/ts/test/test-ui.ts
+++ b/frontend/src/ts/test/test-ui.ts
@@ -1814,7 +1814,8 @@ export function beforeTestWordChange(
if (
(Config.stopOnError === "letter" && (correct || correct === null)) ||
nospaceEnabled ||
- forceUpdateActiveWordLetters
+ forceUpdateActiveWordLetters ||
+ Config.strictSpace
) {
void updateWordLetters({
input: TestInput.input.current,