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,