diff --git a/frontend/.oxlintrc-plugin.json b/frontend/.oxlintrc-plugin.json index 51398c3d461d..d3762817b51d 100644 --- a/frontend/.oxlintrc-plugin.json +++ b/frontend/.oxlintrc-plugin.json @@ -7,8 +7,8 @@ }, "overrides": [ { - "jsPlugins": ["eslint-plugin-solid"], - "files": ["src/**/*.tsx"], + "jsPlugins": ["eslint-plugin-solid", "@tanstack/eslint-plugin-query"], + "files": ["src/**/*.tsx", "src/**/*.ts"], "rules": { "solid/components-return-once": "error", "solid/event-handlers": "error", @@ -32,7 +32,14 @@ "html": "void" } ], - "solid/style-prop": "error" + "solid/style-prop": "error", + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/no-rest-destructuring": "error", + "@tanstack/query/stable-query-client": "error", + "@tanstack/query/no-unstable-deps": "error", + "@tanstack/query/infinite-query-property-order": "error", + "@tanstack/query/no-void-query-fn": "error", + "@tanstack/query/mutation-property-order": "error" } } ] diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx deleted file mode 100644 index 0147be215dd3..000000000000 --- a/frontend/__tests__/components/AsyncContent.spec.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { render, screen, waitFor } from "@solidjs/testing-library"; -import { createResource, Resource } from "solid-js"; -import { describe, it, expect } from "vitest"; - -import AsyncContent from "../../src/ts/components/common/AsyncContent"; - -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)); - return "data"; - }); - - const { container } = renderWithResource(resource); - - 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 resource resolves", async () => { - const [resource] = createResource(async () => { - return "Test Data"; - }); - - renderWithResource(resource); - - 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"); - }); - - renderWithResource(resource, "Custom error message"); - - await waitFor(() => { - expect(screen.getByText(/Custom error message/)).toBeInTheDocument(); - }); - }); - - it("renders default error message when no custom message provided", async () => { - const [resource] = createResource(async () => { - throw new Error("Test error"); - }); - - renderWithResource(resource); - - await waitFor(() => { - expect(screen.getByText(/An error occurred/)).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/__tests__/components/common/AsyncContent.spec.tsx b/frontend/__tests__/components/common/AsyncContent.spec.tsx new file mode 100644 index 000000000000..3e71764f398d --- /dev/null +++ b/frontend/__tests__/components/common/AsyncContent.spec.tsx @@ -0,0 +1,154 @@ +import { render, screen, waitFor } from "@solidjs/testing-library"; +import { createResource, Resource, Show } from "solid-js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AsyncContent from "../../../src/ts/components/common/AsyncContent"; +import * as Notifications from "../../../src/ts/elements/notifications"; + +describe("AsyncContent", () => { + const addNotificationMock = vi.spyOn(Notifications, "add"); + + 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); + + 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 resource resolves", async () => { + const [resource] = createResource(async () => { + return "Test Data"; + }); + + renderWithResource(resource); + + 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"); + }); + + renderWithResource(resource, "Custom error message"); + + await waitFor(() => { + expect(screen.getByText(/Custom error message/)).toBeInTheDocument(); + }); + expect(addNotificationMock).toHaveBeenCalledWith( + "Custom error message: Test error", + -1, + ); + }); + + it("renders default error message when no custom message provided", async () => { + const [resource] = createResource(async () => { + throw new Error("Test error"); + }); + + renderWithResource(resource); + + await waitFor(() => { + expect(screen.getByText(/An error occurred/)).toBeInTheDocument(); + }); + expect(addNotificationMock).toHaveBeenCalledWith( + "An error occurred: 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"; + }); + + const { container } = renderWithResource(resource, undefined, true); + + const preloader = container.querySelector(".preloader"); + expect(preloader).not.toBeInTheDocument(); + expect(container.querySelector("div")).toHaveTextContent("no data"); + }); + + it("renders data when resource resolves if alwaysShowContent", async () => { + const [resource] = createResource(async () => { + return "Test Data"; + }); + + const { container } = renderWithResource(resource, undefined, true); + + await waitFor(() => { + expect(screen.getByTestId("content")).toHaveTextContent("Test Data"); + }); + const preloader = container.querySelector(".preloader"); + expect(preloader).not.toBeInTheDocument(); + }); + + it("renders error message when resource fails if alwaysShowContent", async () => { + const [resource] = createResource(async () => { + throw new Error("Test error"); + }); + + const { container } = renderWithResource( + resource, + "Custom error message", + true, + ); + + await waitFor(() => { + expect(screen.getByText(/Custom error message/)).toBeInTheDocument(); + }); + expect(addNotificationMock).toHaveBeenCalledWith( + "Custom error message: Test error", + -1, + ); + console.log(container.innerHTML); + }); + function renderWithResource( + resource: Resource, + errorMessage?: string, + alwaysShowContent?: true, + ): { + container: HTMLElement; + } { + const { container } = render(() => ( + + {(data: T | undefined) => ( + <> + foo + no data}> +
{String(data)}
+
+ + )} +
+ )); + + return { + container, + }; + } + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 86bee53b75f5..d97f5c655374 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,8 @@ "@sentry/browser": "9.14.0", "@sentry/vite-plugin": "3.3.1", "@solidjs/meta": "0.29.4", + "@tanstack/solid-query": "5.90.23", + "@tanstack/solid-query-devtools": "5.91.3", "@tanstack/solid-table": "8.21.3", "@ts-rest/core": "3.52.1", "animejs": "4.2.2", @@ -66,6 +68,7 @@ "@monkeytype/typescript-config": "workspace:*", "@solidjs/testing-library": "0.8.10", "@tailwindcss/vite": "4.1.18", + "@tanstack/eslint-plugin-query": "5.91.4", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "14.6.1", diff --git a/frontend/src/index.html b/frontend/src/index.html index fbdcff813444..b7ad14bba257 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -72,5 +72,6 @@ + diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 5d235672e904..be3e4a37037f 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,4 +1,13 @@ -import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js"; +import { UseQueryResult } from "@tanstack/solid-query"; +import { + createMemo, + ErrorBoundary, + JSXElement, + Match, + Resource, + Show, + Switch, +} from "solid-js"; import * as Notifications from "../../elements/notifications"; import { createErrorMessage } from "../../utils/misc"; @@ -8,76 +17,110 @@ import { Fa } from "./Fa"; export default function AsyncContent( props: { - resource: Resource; errorMessage?: string; } & ( | { - alwaysShowContent?: never; - children: (data: T) => JSXElement; + resource: Resource; + query?: never; } | { - alwaysShowContent: true; - showLoader?: true; - children: (data: T | undefined) => JSXElement; + 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, + }; + } + + if (props.query !== undefined) { + return { + value: () => props.query.data, + isLoading: () => props.query?.isLoading, + isError: () => props.query.isError, + error: () => props.query.error, + }; + } + throw new Error("missing source"); + }); + const value = () => { try { - return props.resource(); + return source().value; } catch (err) { - const message = createErrorMessage( - err, - props.errorMessage ?? "An error occurred", - ); - console.error("AsyncContent error:", message); - Notifications.add(message, -1); + handleError(err); return undefined; } }; const handleError = (err: unknown): string => { - console.error(err); - return createErrorMessage(err, props.errorMessage ?? "An error occurred"); + const message = createErrorMessage( + err, + props.errorMessage ?? "An error occurred", + ); + console.error("AsyncContext failed", message, err); + Notifications.add(message, -1); + return message; }; + const loader = ( +
+ +
+ ); + + const errorText = (err: unknown): JSXElement => ( +
{handleError(err)}
+ ); return ( - { - const p = props as { - showLoader?: true; - children: (data: T | undefined) => JSXElement; - }; - return ( + + - -
- -
+ + {loader} - {p.children(value())} + { + const p = props as { + children: (data: T | undefined) => JSXElement; + }; + return <>{p.children(value()?.())}; + })()} + else={ + + {props.children(source().value() as T)} + + } + /> - ); - })()} - else={ -
{handleError(err)}
} - > - - - - } - > - - {props.children(props.resource() as T)} - - -
- } - /> + } + > + {errorText(source().error?.())} + + {loader} + +
+
); } diff --git a/frontend/src/ts/components/core/DevTools.tsx b/frontend/src/ts/components/core/DevTools.tsx new file mode 100644 index 000000000000..ce32cc39bacf --- /dev/null +++ b/frontend/src/ts/components/core/DevTools.tsx @@ -0,0 +1,6 @@ +import { SolidQueryDevtools } from "@tanstack/solid-query-devtools"; +import { JSXElement } from "solid-js"; + +export function DevTools(): JSXElement { + return ; +} diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index adde13f61edb..a4dae541c9c4 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -1,8 +1,11 @@ +import { QueryClientProvider } from "@tanstack/solid-query"; import { JSXElement } from "solid-js"; import { render } from "solid-js/web"; +import { queryClient } from "../queries"; import { qsa } from "../utils/dom"; +import { DevTools } from "./core/DevTools"; import { Theme } from "./core/Theme"; import { Footer } from "./layout/footer/Footer"; import { Overlays } from "./layout/overlays/Overlays"; @@ -15,11 +18,19 @@ const components: Record JSXElement> = { modals: () => , overlays: () => , theme: () => , + devtools: () => , }; function mountToMountpoint(name: string, component: () => JSXElement): void { for (const mountPoint of qsa(name)) { - render(() => component(), mountPoint.native); + render( + () => ( + + {component()} + + ), + mountPoint.native, + ); } } diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx index 632b0b38620e..20335d005d87 100644 --- a/frontend/src/ts/components/pages/AboutPage.tsx +++ b/frontend/src/ts/components/pages/AboutPage.tsx @@ -1,12 +1,17 @@ -import { intervalToDuration } from "date-fns"; -import { createResource, For, JSXElement, Show } from "solid-js"; +import { useQuery } from "@tanstack/solid-query"; +import { For, JSXElement, Show } from "solid-js"; -import Ape from "../../ape"; +import { queryClient } from "../../queries"; +import { + getContributorsQueryOptions, + getSpeedHistogramQueryOptions, + getSupportersQueryOptions, + getTypingStatsQueryOptions, +} from "../../queries/public"; import { getConfig } from "../../signals/config"; import { getActivePage } from "../../signals/core"; import { showModal } from "../../stores/modals"; -import { getContributorsList, getSupportersList } from "../../utils/json-data"; -import { getNumberWithMagnitude, numberWithSpaces } from "../../utils/numbers"; +import { qsr } from "../../utils/dom"; import AsyncContent from "../common/AsyncContent"; import { Button } from "../common/Button"; import { ChartJs } from "../common/ChartJs"; @@ -14,7 +19,7 @@ import { Fa, FaProps } from "../common/Fa"; function H2(props: { text: string; fa: FaProps }): JSXElement { return ( -

+

{props.text}

@@ -23,503 +28,417 @@ function H2(props: { text: string; fa: FaProps }): JSXElement { function H3(props: { text: string; fa: FaProps }): JSXElement { return ( -

+

{props.text}

); } +qsr("nav .view-about").on("mouseenter", () => { + prefetch(); +}); + export function AboutPage(): JSXElement { const isOpen = (): boolean => getActivePage() === "about"; - const [contributors] = createResource(isOpen, async (open) => - open ? await getContributorsList() : undefined, - ); - const [supporters] = createResource(isOpen, async (open) => - open ? await getSupportersList() : undefined, - ); - const [typingStats] = createResource(isOpen, async (open) => - open ? await fetchTypingStats() : undefined, - ); + const contributors = useQuery(() => ({ + ...getContributorsQueryOptions(), + enabled: isOpen(), + })); - const [speedHistogram] = createResource(isOpen, async (open) => - open ? await fetchSpeedHistogram() : undefined, - ); + const supporters = useQuery(() => ({ + ...getSupportersQueryOptions(), + enabled: isOpen(), + })); + + const typingStats = useQuery(() => ({ + ...getTypingStatsQueryOptions(), + enabled: isOpen(), + })); + + const speedHistogram = useQuery(() => ({ + ...getSpeedHistogramQueryOptions(), + enabled: isOpen(), + })); return ( - -
-
- Created with love by Miodec. -
- Supported and{" "} - expanded by many awesome people. -
- Launched on 15th of May, 2020. -
-
- - {(data) => ( -
- - {([title, data]) => ( -
-
{title}
-
{data?.text ?? "-"}
-
{data?.subText ?? "-"}
-
- )} -
-
- )} -
-
-
- - {(data) => ( - +
+ Created with love by Miodec. +
+ Supported and{" "} + expanded by many awesome people. +
+ Launched on 15th of May, 2020. +
+
+ + {(data) => ( +
+ + {([title, data]) => ( +
+
{title}
+
{data?.text ?? "-"}
+
{data?.subText ?? "-"}
+
+ )} +
+
+ )} +
+
+
+ + {(data) => ( + - )} - -
- distribution of time 60 leaderboard results (wpm) -
-
-
-

-

- Monkeytype is a minimalistic and customizable typing test. It - features many test modes, an account system to save your typing - speed history, and user-configurable features such as themes, - sounds, a smooth caret, and more. Monkeytype attempts to emulate the - experience of natural keyboard typing during a typing test, by - unobtrusively presenting the text prompts and displaying typed - characters in-place, providing straightforward, real-time feedback - on typos, speed, and accuracy. -
-
- Test yourself in various modes, track your progress and improve your - speed. -

-

-
-

-

- By default, this website uses the most common 200 words in the - English language to generate its tests. You can change to an - expanded set (1000 most common words) in the options, or change the - language entirely. -

-

-
-

-

- You can use tab and enter (or just{" "} - tab if you have quick tab mode enabled) to restart the - typing test. Open the command line by pressing ctrl/cmd +{" "} - shift + p or esc - there you can - access all the functionality you need without touching your mouse. -

-

-
-

-
-
wpm
-
- - total number of characters in the correctly typed words - (including spaces), divided by 5 and normalised to 60 seconds. -
+ tooltip: { + animation: { duration: 250 }, + intersect: false, + mode: "index", + }, + }, + }} + /> + )} + +
+ distribution of time 60 leaderboard results (wpm) +
+

+
+

+

+ Monkeytype is a minimalistic and customizable typing test. It features + many test modes, an account system to save your typing speed history, + and user-configurable features such as themes, sounds, a smooth caret, + and more. Monkeytype attempts to emulate the experience of natural + keyboard typing during a typing test, by unobtrusively presenting the + text prompts and displaying typed characters in-place, providing + straightforward, real-time feedback on typos, speed, and accuracy. +
+
+ Test yourself in various modes, track your progress and improve your + speed. +

+

+
+

+

+ By default, this website uses the most common 200 words in the English + language to generate its tests. You can change to an expanded set + (1000 most common words) in the options, or change the language + entirely. +

+

+
+

+

+ You can use tab and enter (or just{" "} + tab if you have quick tab mode enabled) to restart the + typing test. Open the command line by pressing ctrl/cmd +{" "} + shift + p or esc - there you can + access all the functionality you need without touching your mouse. +

+

+
+

+
+
wpm
+
+ - total number of characters in the correctly typed words (including + spaces), divided by 5 and normalised to 60 seconds. +
-
raw wpm
-
- {" "} - - calculated just like wpm, but also includes incorrect words. -
+
raw wpm
+
+ {" "} + - calculated just like wpm, but also includes incorrect words. +
-
acc
-
- percentage of correctly pressed keys.
+
acc
+
- percentage of correctly pressed keys.
-
char
-
- - correct characters / incorrect characters. Calculated after the - test has ended. -
+
char
+
+ - correct characters / incorrect characters. Calculated after the + test has ended. +
-
consistency
-
- - based on the variance of your raw wpm. Closer to 100% is better. - Calculated using the coefficient of variation of raw wpm and - mapped onto a scale from 0 to 100. -
-
-

- -
+ +