From 76a55d8ef5246c28621612047d2d218151979594 Mon Sep 17 00:00:00 2001 From: merencia Date: Sat, 4 Jul 2026 11:39:44 -0300 Subject: [PATCH 1/2] feat(web): add the typed api client + headless hooks (dashboard foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the data layer the react dashboard and the pro consume: a typed hono client (hc, relative paths) plus headless hooks over it (logic separated from presentation, the pro's anti-fork seam). - createApiClient + ApiClientProvider/useApiClient: the client is injected via context, so the pro passes its superset client and the façade a base-path client. - useApiQuery: minimal fetch primitive with loading/error, refetch, polling and a stale-response race guard. no react-query. - domain hooks: useJobs/useJob/useJobsMeta/useJobActions, useQueues/useQueueActions, useOverview/useOverviewTimeseries. source only for now, not built or exported; the ./dashboard subpath + build land with the app. 20 hook tests in jsdom (tdd); vitest/eslint/tsconfig wired to treat src/dashboard as browser code. --- eslint.config.js | 4 +- packages/web/src/dashboard/client.test.tsx | 29 ++++++ packages/web/src/dashboard/client.ts | 14 +++ packages/web/src/dashboard/context.ts | 12 +++ .../web/src/dashboard/hooks/to-query.test.ts | 15 +++ packages/web/src/dashboard/hooks/to-query.ts | 8 ++ .../dashboard/hooks/use-api-query.test.tsx | 96 +++++++++++++++++++ .../web/src/dashboard/hooks/use-api-query.ts | 67 +++++++++++++ .../web/src/dashboard/hooks/use-jobs.test.tsx | 63 ++++++++++++ packages/web/src/dashboard/hooks/use-jobs.ts | 69 +++++++++++++ .../src/dashboard/hooks/use-overview.test.tsx | 28 ++++++ .../web/src/dashboard/hooks/use-overview.ts | 29 ++++++ .../src/dashboard/hooks/use-queues.test.tsx | 27 ++++++ .../web/src/dashboard/hooks/use-queues.ts | 27 ++++++ packages/web/src/dashboard/index.ts | 7 ++ .../web/src/dashboard/testing/harness.tsx | 17 ++++ packages/web/tsconfig.ui.json | 2 +- packages/web/vitest.config.js | 2 +- vitest.config.js | 2 +- 19 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/dashboard/client.test.tsx create mode 100644 packages/web/src/dashboard/client.ts create mode 100644 packages/web/src/dashboard/context.ts create mode 100644 packages/web/src/dashboard/hooks/to-query.test.ts create mode 100644 packages/web/src/dashboard/hooks/to-query.ts create mode 100644 packages/web/src/dashboard/hooks/use-api-query.test.tsx create mode 100644 packages/web/src/dashboard/hooks/use-api-query.ts create mode 100644 packages/web/src/dashboard/hooks/use-jobs.test.tsx create mode 100644 packages/web/src/dashboard/hooks/use-jobs.ts create mode 100644 packages/web/src/dashboard/hooks/use-overview.test.tsx create mode 100644 packages/web/src/dashboard/hooks/use-overview.ts create mode 100644 packages/web/src/dashboard/hooks/use-queues.test.tsx create mode 100644 packages/web/src/dashboard/hooks/use-queues.ts create mode 100644 packages/web/src/dashboard/index.ts create mode 100644 packages/web/src/dashboard/testing/harness.tsx diff --git a/eslint.config.js b/eslint.config.js index 60aac0f..37b925a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -50,8 +50,8 @@ export default tseslint.config( }, }, { - // React UI library lives in its own tsconfig (JSX, DOM libs, bundler resolution). - files: ["packages/web/src/ui/**/*.{ts,tsx}"], + // React UI + dashboard app live in their own tsconfig (JSX, DOM libs, bundler resolution). + files: ["packages/web/src/ui/**/*.{ts,tsx}", "packages/web/src/dashboard/**/*.{ts,tsx}"], plugins: { "react-hooks": reactHooks, "jsx-a11y": jsxA11y }, languageOptions: { globals: { ...globals.browser }, diff --git a/packages/web/src/dashboard/client.test.tsx b/packages/web/src/dashboard/client.test.tsx new file mode 100644 index 0000000..2f4703b --- /dev/null +++ b/packages/web/src/dashboard/client.test.tsx @@ -0,0 +1,29 @@ +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { createApiClient } from "./client"; +import { ApiClientProvider, useApiClient } from "./context"; + +describe("createApiClient", () => { + it("exposes the api resource accessors", () => { + const client = createApiClient(); + expect(client.jobs).toBeDefined(); + expect(client.queues).toBeDefined(); + expect(client.overview).toBeDefined(); + }); +}); + +describe("useApiClient", () => { + it("returns the client from the provider", () => { + const custom = createApiClient("/base"); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useApiClient(), { wrapper }); + expect(result.current).toBe(custom); + }); + + it("defaults to a same-origin client without a provider", () => { + const { result } = renderHook(() => useApiClient()); + expect(result.current.jobs).toBeDefined(); + }); +}); diff --git a/packages/web/src/dashboard/client.ts b/packages/web/src/dashboard/client.ts new file mode 100644 index 0000000..3c532b8 --- /dev/null +++ b/packages/web/src/dashboard/client.ts @@ -0,0 +1,14 @@ +import { hc } from "hono/client"; +import type { ApiApp } from "../api/app"; + +/** The typed client for the Sidequest management API. */ +export type ApiClient = ReturnType>; + +/** + * Creates a typed client for the management API. Requests are relative to `baseUrl` + * (default: same-origin root), so the same client hits the OSS server or a Pro superset + * serving the same paths without any change. + */ +export function createApiClient(baseUrl = "/"): ApiClient { + return hc(baseUrl); +} diff --git a/packages/web/src/dashboard/context.ts b/packages/web/src/dashboard/context.ts new file mode 100644 index 0000000..bbde80a --- /dev/null +++ b/packages/web/src/dashboard/context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; +import { type ApiClient, createApiClient } from "./client"; + +const ApiClientContext = createContext(createApiClient()); + +/** Provides the {@link ApiClient} to the dashboard hooks. The Pro passes its superset client. */ +export const ApiClientProvider = ApiClientContext.Provider; + +/** The {@link ApiClient} from context, defaulting to a same-origin client. */ +export function useApiClient(): ApiClient { + return useContext(ApiClientContext); +} diff --git a/packages/web/src/dashboard/hooks/to-query.test.ts b/packages/web/src/dashboard/hooks/to-query.test.ts new file mode 100644 index 0000000..9974a6a --- /dev/null +++ b/packages/web/src/dashboard/hooks/to-query.test.ts @@ -0,0 +1,15 @@ +import { toQuery } from "./to-query"; + +describe("toQuery", () => { + it("keeps present values as strings", () => { + expect(toQuery({ state: "failed", page: 2 })).toEqual({ state: "failed", page: "2" }); + }); + + it("drops undefined and empty values", () => { + expect(toQuery({ state: undefined, queue: "", class: "Email" })).toEqual({ class: "Email" }); + }); + + it("returns an empty object for an empty filter", () => { + expect(toQuery({})).toEqual({}); + }); +}); diff --git a/packages/web/src/dashboard/hooks/to-query.ts b/packages/web/src/dashboard/hooks/to-query.ts new file mode 100644 index 0000000..1f6fc5e --- /dev/null +++ b/packages/web/src/dashboard/hooks/to-query.ts @@ -0,0 +1,8 @@ +/** Serializes a filter object into string query params, dropping empty/undefined values. */ +export function toQuery(filters: Record): Record { + const query: Record = {}; + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== "") query[key] = String(value); + } + return query; +} diff --git a/packages/web/src/dashboard/hooks/use-api-query.test.tsx b/packages/web/src/dashboard/hooks/use-api-query.test.tsx new file mode 100644 index 0000000..23b0115 --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-api-query.test.tsx @@ -0,0 +1,96 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useApiQuery } from "./use-api-query"; + +/** A promise you can resolve/reject from the outside, for deterministic race tests. */ +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("useApiQuery", () => { + it("starts loading, then exposes the resolved data", async () => { + const { result } = renderHook(() => useApiQuery(() => Promise.resolve("hi"), [])); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBe("hi"); + expect(result.current.error).toBeUndefined(); + }); + + it("surfaces a rejected fetch as an error", async () => { + const { result } = renderHook(() => useApiQuery(() => Promise.reject(new Error("boom")), [])); + + await waitFor(() => expect(result.current.error).toBeDefined()); + expect(result.current.error?.message).toBe("boom"); + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + }); + + it("refetches when the deps change", async () => { + const fetcher = vi.fn((n: number) => Promise.resolve(n)); + const { result, rerender } = renderHook(({ n }) => useApiQuery(() => fetcher(n), [n]), { + initialProps: { n: 1 }, + }); + + await waitFor(() => expect(result.current.data).toBe(1)); + rerender({ n: 2 }); + await waitFor(() => expect(result.current.data).toBe(2)); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("re-runs the fetcher on refetch()", async () => { + const fetcher = vi.fn(() => Promise.resolve("x")); + const { result } = renderHook(() => useApiQuery(fetcher, [])); + + await waitFor(() => expect(result.current.data).toBe("x")); + act(() => result.current.refetch()); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + }); + + it("ignores a stale in-flight response when a newer request resolves first", async () => { + const first = deferred(); + const second = deferred(); + const calls = [first, second]; + let i = 0; + + const { result, rerender } = renderHook(({ n }) => useApiQuery(() => calls[i++].promise, [n]), { + initialProps: { n: 1 }, + }); + + // trigger the second request, resolve it first, then resolve the stale first + rerender({ n: 2 }); + await act(async () => { + second.resolve("new"); + await second.promise; + }); + await waitFor(() => expect(result.current.data).toBe("new")); + + await act(async () => { + first.resolve("stale"); + await first.promise; + }); + // the late, stale response must not overwrite the newer data + expect(result.current.data).toBe("new"); + }); + + it("polls on the given interval", async () => { + vi.useFakeTimers(); + try { + const fetcher = vi.fn(() => Promise.resolve("tick")); + renderHook(() => useApiQuery(fetcher, [], { refetchInterval: 1000 })); + + expect(fetcher).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(fetcher).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/web/src/dashboard/hooks/use-api-query.ts b/packages/web/src/dashboard/hooks/use-api-query.ts new file mode 100644 index 0000000..d38addc --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-api-query.ts @@ -0,0 +1,67 @@ +/* eslint-disable react-hooks/set-state-in-effect -- + this is the single, deliberate data-fetching primitive: its effect subscribes to the API + endpoint and sets state as results arrive. every other hook builds on it and never fetches + this way, so the pattern is contained here on purpose. */ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** The state a query hook exposes. */ +export interface QueryState { + /** The last successfully fetched value, or `undefined` before the first success. */ + data: T | undefined; + /** The last error, cleared on the next attempt. */ + error: Error | undefined; + /** True while a fetch is in flight. */ + isLoading: boolean; + /** Triggers a fresh fetch, keeping the current data until it resolves. */ + refetch: () => void; +} + +/** Options for {@link useApiQuery}. */ +export interface UseApiQueryOptions { + /** If set, re-fetch on this interval (ms). Cleared on unmount. */ + refetchInterval?: number; +} + +/** + * Minimal data-fetching primitive: runs `fetcher` on mount, whenever `deps` change, and on + * `refetch()`. A monotonic request id guards against races so a slow, stale response can + * never overwrite a newer one. No caching or dedup, by design; the API surface is small. + */ +export function useApiQuery(fetcher: () => Promise, deps: unknown[], options?: UseApiQueryOptions): QueryState { + const [state, setState] = useState<{ data?: T; error?: Error; isLoading: boolean }>({ isLoading: true }); + + // Keep the latest fetcher in a ref (updated in an effect, never during render) so the run + // callback can stay stable without capturing a stale closure. + const fetcherRef = useRef(fetcher); + useEffect(() => { + fetcherRef.current = fetcher; + }); + + // Monotonic id: only the most recent request may write state. + const requestId = useRef(0); + + const run = useCallback(() => { + const id = ++requestId.current; + setState((prev) => ({ ...prev, isLoading: true, error: undefined })); + fetcherRef.current().then( + (data) => { + if (id === requestId.current) setState({ data, isLoading: false }); + }, + (error: unknown) => { + if (id === requestId.current) setState({ error: error as Error, isLoading: false }); + }, + ); + }, []); + + useEffect(() => { + run(); + if (options?.refetchInterval) { + const timer = setInterval(run, options.refetchInterval); + return () => clearInterval(timer); + } + // deps are the caller's cache key; run/refetchInterval are stable enough to omit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return { data: state.data, error: state.error, isLoading: state.isLoading, refetch: run }; +} diff --git a/packages/web/src/dashboard/hooks/use-jobs.test.tsx b/packages/web/src/dashboard/hooks/use-jobs.test.tsx new file mode 100644 index 0000000..9616d8f --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-jobs.test.tsx @@ -0,0 +1,63 @@ +import { waitFor } from "@testing-library/react"; +import type { ApiClient } from "../client"; +import { jsonResponse, renderHookWithClient } from "../testing/harness"; +import { useJob, useJobActions, useJobs, useJobsMeta } from "./use-jobs"; + +describe("useJobs", () => { + it("fetches the list, sending the filter as query params", async () => { + const $get = vi.fn().mockResolvedValue( + jsonResponse({ jobs: [{ id: 1 }], pagination: { page: 2, pageSize: 30, hasNextPage: false } }), + ); + const client = { jobs: { $get } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useJobs({ state: "failed", page: 2 }), client); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect($get).toHaveBeenCalledWith({ query: { state: "failed", page: "2" } }); + }); +}); + +describe("useJob", () => { + it("fetches a single job by id", async () => { + const $get = vi.fn().mockResolvedValue(jsonResponse({ id: 7 })); + const client = { jobs: { ":id": { $get } } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useJob(7), client); + + await waitFor(() => expect(result.current.data).toEqual({ id: 7 })); + expect($get).toHaveBeenCalledWith({ param: { id: "7" } }); + }); +}); + +describe("useJobsMeta", () => { + it("fetches the distinct queue names", async () => { + const $get = vi.fn().mockResolvedValue(jsonResponse({ queues: ["email"] })); + const client = { jobs: { meta: { $get } } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useJobsMeta(), client); + + await waitFor(() => expect(result.current.data).toEqual({ queues: ["email"] })); + }); +}); + +describe("useJobActions", () => { + it("posts run/cancel/rerun for a job id", async () => { + const post = () => vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + const run = post(); + const cancel = post(); + const rerun = post(); + const client = { + jobs: { ":id": { run: { $post: run }, cancel: { $post: cancel }, rerun: { $post: rerun } } }, + } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useJobActions(), client); + + await result.current.run(1); + await result.current.cancel(1); + await result.current.rerun(1); + + expect(run).toHaveBeenCalledWith({ param: { id: "1" } }); + expect(cancel).toHaveBeenCalledWith({ param: { id: "1" } }); + expect(rerun).toHaveBeenCalledWith({ param: { id: "1" } }); + }); +}); diff --git a/packages/web/src/dashboard/hooks/use-jobs.ts b/packages/web/src/dashboard/hooks/use-jobs.ts new file mode 100644 index 0000000..8a49526 --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-jobs.ts @@ -0,0 +1,69 @@ +import { useMemo } from "react"; +import { useApiClient } from "../context"; +import { toQuery } from "./to-query"; +import { useApiQuery, type UseApiQueryOptions } from "./use-api-query"; + +/** The jobs list query, mirroring the API's query string. */ +export interface JobsFilter { + state?: string; + queue?: string; + class?: string; + time?: string; + start?: string; + end?: string; + page?: number; + pageSize?: number; +} + +/** Queries the jobs list (with pagination) for the given filter. */ +export function useJobs(filters: JobsFilter = {}, options?: UseApiQueryOptions) { + const client = useApiClient(); + return useApiQuery( + async () => { + const res = await client.jobs.$get({ query: toQuery(filters) }); + return res.json(); + }, + [JSON.stringify(filters)], + options, + ); +} + +/** Queries a single job by id. */ +export function useJob(id: number, options?: UseApiQueryOptions) { + const client = useApiClient(); + return useApiQuery( + async () => { + const res = await client.jobs[":id"].$get({ param: { id: String(id) } }); + return res.json(); + }, + [id], + options, + ); +} + +/** Queries the distinct queue names that appear on jobs (for the filter dropdown). */ +export function useJobsMeta(options?: UseApiQueryOptions) { + const client = useApiClient(); + return useApiQuery( + async () => { + const res = await client.jobs.meta.$get(); + return res.json(); + }, + [], + options, + ); +} + +/** Job mutations. Each returns the updated job; callers refetch the affected queries. */ +export function useJobActions() { + const client = useApiClient(); + return useMemo(() => { + const post = (id: number, action: "run" | "cancel" | "rerun") => + client.jobs[":id"][action].$post({ param: { id: String(id) } }).then((res) => res.json()); + return { + run: (id: number) => post(id, "run"), + cancel: (id: number) => post(id, "cancel"), + rerun: (id: number) => post(id, "rerun"), + }; + }, [client]); +} diff --git a/packages/web/src/dashboard/hooks/use-overview.test.tsx b/packages/web/src/dashboard/hooks/use-overview.test.tsx new file mode 100644 index 0000000..4f0a5f4 --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-overview.test.tsx @@ -0,0 +1,28 @@ +import { waitFor } from "@testing-library/react"; +import type { ApiClient } from "../client"; +import { jsonResponse, renderHookWithClient } from "../testing/harness"; +import { useOverview, useOverviewTimeseries } from "./use-overview"; + +describe("useOverview", () => { + it("fetches counts, sending the range as a query param", async () => { + const $get = vi.fn().mockResolvedValue(jsonResponse({ total: 9 })); + const client = { overview: { $get } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useOverview("12h"), client); + + await waitFor(() => expect(result.current.data).toEqual({ total: 9 })); + expect($get).toHaveBeenCalledWith({ query: { range: "12h" } }); + }); +}); + +describe("useOverviewTimeseries", () => { + it("fetches the series, sending the range as a query param", async () => { + const $get = vi.fn().mockResolvedValue(jsonResponse([{ timestamp: "t", total: 1 }])); + const client = { overview: { timeseries: { $get } } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useOverviewTimeseries("12h"), client); + + await waitFor(() => expect(result.current.data).toEqual([{ timestamp: "t", total: 1 }])); + expect($get).toHaveBeenCalledWith({ query: { range: "12h" } }); + }); +}); diff --git a/packages/web/src/dashboard/hooks/use-overview.ts b/packages/web/src/dashboard/hooks/use-overview.ts new file mode 100644 index 0000000..55b7d64 --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-overview.ts @@ -0,0 +1,29 @@ +import { useApiClient } from "../context"; +import { toQuery } from "./to-query"; +import { useApiQuery, type UseApiQueryOptions } from "./use-api-query"; + +/** Queries the overview counts for a range (`12m` | `12h` | `12d`). */ +export function useOverview(range?: string, options?: UseApiQueryOptions) { + const client = useApiClient(); + return useApiQuery( + async () => { + const res = await client.overview.$get({ query: toQuery({ range }) }); + return res.json(); + }, + [range], + options, + ); +} + +/** Queries the overview time series for a range, for the chart. */ +export function useOverviewTimeseries(range?: string, options?: UseApiQueryOptions) { + const client = useApiClient(); + return useApiQuery( + async () => { + const res = await client.overview.timeseries.$get({ query: toQuery({ range }) }); + return res.json(); + }, + [range], + options, + ); +} diff --git a/packages/web/src/dashboard/hooks/use-queues.test.tsx b/packages/web/src/dashboard/hooks/use-queues.test.tsx new file mode 100644 index 0000000..91eab21 --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-queues.test.tsx @@ -0,0 +1,27 @@ +import { waitFor } from "@testing-library/react"; +import type { ApiClient } from "../client"; +import { jsonResponse, renderHookWithClient } from "../testing/harness"; +import { useQueueActions, useQueues } from "./use-queues"; + +describe("useQueues", () => { + it("fetches the queues list", async () => { + const $get = vi.fn().mockResolvedValue(jsonResponse([{ name: "email", jobs: { total: 2 } }])); + const client = { queues: { $get } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useQueues(), client); + + await waitFor(() => expect(result.current.data).toEqual([{ name: "email", jobs: { total: 2 } }])); + }); +}); + +describe("useQueueActions", () => { + it("posts toggle for a queue name", async () => { + const $post = vi.fn().mockResolvedValue(jsonResponse({ name: "email", state: "paused" })); + const client = { queues: { ":name": { toggle: { $post } } } } as unknown as ApiClient; + + const { result } = renderHookWithClient(() => useQueueActions(), client); + + await result.current.toggle("email"); + expect($post).toHaveBeenCalledWith({ param: { name: "email" } }); + }); +}); diff --git a/packages/web/src/dashboard/hooks/use-queues.ts b/packages/web/src/dashboard/hooks/use-queues.ts new file mode 100644 index 0000000..d3a1656 --- /dev/null +++ b/packages/web/src/dashboard/hooks/use-queues.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { useApiClient } from "../context"; +import { useApiQuery, type UseApiQueryOptions } from "./use-api-query"; + +/** Queries the queues list, each annotated with its job counts. */ +export function useQueues(options?: UseApiQueryOptions) { + const client = useApiClient(); + return useApiQuery( + async () => { + const res = await client.queues.$get(); + return res.json(); + }, + [], + options, + ); +} + +/** Queue mutations. Returns the updated queue; callers refetch the queues query. */ +export function useQueueActions() { + const client = useApiClient(); + return useMemo( + () => ({ + toggle: (name: string) => client.queues[":name"].toggle.$post({ param: { name } }).then((res) => res.json()), + }), + [client], + ); +} diff --git a/packages/web/src/dashboard/index.ts b/packages/web/src/dashboard/index.ts new file mode 100644 index 0000000..5a5af9e --- /dev/null +++ b/packages/web/src/dashboard/index.ts @@ -0,0 +1,7 @@ +export * from "./client"; +export * from "./context"; +export * from "./hooks/to-query"; +export * from "./hooks/use-api-query"; +export * from "./hooks/use-jobs"; +export * from "./hooks/use-overview"; +export * from "./hooks/use-queues"; diff --git a/packages/web/src/dashboard/testing/harness.tsx b/packages/web/src/dashboard/testing/harness.tsx new file mode 100644 index 0000000..35886c2 --- /dev/null +++ b/packages/web/src/dashboard/testing/harness.tsx @@ -0,0 +1,17 @@ +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import type { ApiClient } from "../client"; +import { ApiClientProvider } from "../context"; + +/** A stand-in for a `fetch` Response whose `.json()` resolves to `body`. */ +export function jsonResponse(body: T) { + return { json: () => Promise.resolve(body) }; +} + +/** Renders a hook with a fake API client supplied through context. */ +export function renderHookWithClient(hook: () => R, client: ApiClient) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(hook, { wrapper }); +} diff --git a/packages/web/tsconfig.ui.json b/packages/web/tsconfig.ui.json index 66977f6..d33a0e0 100644 --- a/packages/web/tsconfig.ui.json +++ b/packages/web/tsconfig.ui.json @@ -9,5 +9,5 @@ "noEmit": true, "types": ["vitest/globals", "@testing-library/jest-dom"] }, - "include": ["src/ui/**/*.ts", "src/ui/**/*.tsx"] + "include": ["src/ui/**/*.ts", "src/ui/**/*.tsx", "src/dashboard/**/*.ts", "src/dashboard/**/*.tsx"] } diff --git a/packages/web/vitest.config.js b/packages/web/vitest.config.js index 57600d1..04ef5a9 100644 --- a/packages/web/vitest.config.js +++ b/packages/web/vitest.config.js @@ -11,7 +11,7 @@ export default { test: { ...base.test, environment: "jsdom", - include: ["src/ui/**/*.test.tsx"], + include: ["src/ui/**/*.test.tsx", "src/dashboard/**/*.test.tsx"], coverage: { ...base.test.coverage, include: ["src/ui/**/*.tsx"], diff --git a/vitest.config.js b/vitest.config.js index 8adcbad..5db86e1 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -24,7 +24,7 @@ export default defineConfig({ name: "ui", globals: true, environment: "jsdom", - include: ["packages/web/src/ui/**/*.test.tsx"], + include: ["packages/web/src/ui/**/*.test.tsx", "packages/web/src/dashboard/**/*.test.tsx"], setupFiles: ["./packages/web/vitest.setup.ts"], }, }, From 13d039353915e19823353573cb6ee4c0b9c81862 Mon Sep 17 00:00:00 2001 From: merencia Date: Wed, 8 Jul 2026 11:14:59 -0300 Subject: [PATCH 2/2] refactor(web): use TanStack Query for the dashboard data hooks Replace the hand-rolled useApiQuery primitive with TanStack Query (useQuery/useMutation) over the typed hono client, per the plan-mode decision for the dashboard v2 data layer. - query hooks keep the { data, error, isLoading, refetch } shape, so consumers do not change; query keys are structural (no JSON.stringify) - action hooks keep { run, cancel, rerun } / { toggle } but now invalidate their queries on success, dropping the manual refetch the callers did - refetchInterval option preserves the existing poll - adds @tanstack/react-query; test harness wraps a QueryClientProvider Addresses review on use-api-query.ts (Giovani: use TanStack Query). --- packages/web/package.json | 1 + .../web/src/dashboard/hooks/query-client.ts | 24 +++++ .../dashboard/hooks/use-api-query.test.tsx | 96 ------------------- .../web/src/dashboard/hooks/use-api-query.ts | 67 ------------- packages/web/src/dashboard/hooks/use-jobs.ts | 71 ++++++++------ .../web/src/dashboard/hooks/use-overview.ts | 29 +++--- .../web/src/dashboard/hooks/use-queues.ts | 33 ++++--- packages/web/src/dashboard/index.ts | 2 +- .../web/src/dashboard/testing/harness.tsx | 13 ++- yarn.lock | 19 ++++ 10 files changed, 133 insertions(+), 222 deletions(-) create mode 100644 packages/web/src/dashboard/hooks/query-client.ts delete mode 100644 packages/web/src/dashboard/hooks/use-api-query.test.tsx delete mode 100644 packages/web/src/dashboard/hooks/use-api-query.ts diff --git a/packages/web/package.json b/packages/web/package.json index f0ef115..c88658e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -51,6 +51,7 @@ "@sidequest/backend": "workspace:*", "@sidequest/core": "workspace:*", "@sidequest/engine": "workspace:*", + "@tanstack/react-query": "^5.101.2", "hono": "^4.12.0" }, "devDependencies": { diff --git a/packages/web/src/dashboard/hooks/query-client.ts b/packages/web/src/dashboard/hooks/query-client.ts new file mode 100644 index 0000000..6305063 --- /dev/null +++ b/packages/web/src/dashboard/hooks/query-client.ts @@ -0,0 +1,24 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** + * The narrow options a dashboard query hook forwards to TanStack Query. Kept small on purpose + * (the hooks own their query keys and fetchers); callers only steer polling and enablement. + */ +export interface QueryHookOptions { + /** If set, refetch on this interval (ms). Replaces the old hand-rolled poll. */ + refetchInterval?: number; + /** Set false to hold the query (e.g. an id not chosen yet). Defaults to enabled. */ + enabled?: boolean; +} + +/** + * The {@link QueryClient} for the dashboard, with defaults tuned for a live ops view: + * short stale time (data is always moving), no refetch-on-focus (the poll already covers it). + */ +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { staleTime: 5_000, refetchOnWindowFocus: false }, + }, + }); +} diff --git a/packages/web/src/dashboard/hooks/use-api-query.test.tsx b/packages/web/src/dashboard/hooks/use-api-query.test.tsx deleted file mode 100644 index 23b0115..0000000 --- a/packages/web/src/dashboard/hooks/use-api-query.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { useApiQuery } from "./use-api-query"; - -/** A promise you can resolve/reject from the outside, for deterministic race tests. */ -function deferred() { - let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -describe("useApiQuery", () => { - it("starts loading, then exposes the resolved data", async () => { - const { result } = renderHook(() => useApiQuery(() => Promise.resolve("hi"), [])); - - expect(result.current.isLoading).toBe(true); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBe("hi"); - expect(result.current.error).toBeUndefined(); - }); - - it("surfaces a rejected fetch as an error", async () => { - const { result } = renderHook(() => useApiQuery(() => Promise.reject(new Error("boom")), [])); - - await waitFor(() => expect(result.current.error).toBeDefined()); - expect(result.current.error?.message).toBe("boom"); - expect(result.current.data).toBeUndefined(); - expect(result.current.isLoading).toBe(false); - }); - - it("refetches when the deps change", async () => { - const fetcher = vi.fn((n: number) => Promise.resolve(n)); - const { result, rerender } = renderHook(({ n }) => useApiQuery(() => fetcher(n), [n]), { - initialProps: { n: 1 }, - }); - - await waitFor(() => expect(result.current.data).toBe(1)); - rerender({ n: 2 }); - await waitFor(() => expect(result.current.data).toBe(2)); - expect(fetcher).toHaveBeenCalledTimes(2); - }); - - it("re-runs the fetcher on refetch()", async () => { - const fetcher = vi.fn(() => Promise.resolve("x")); - const { result } = renderHook(() => useApiQuery(fetcher, [])); - - await waitFor(() => expect(result.current.data).toBe("x")); - act(() => result.current.refetch()); - await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); - }); - - it("ignores a stale in-flight response when a newer request resolves first", async () => { - const first = deferred(); - const second = deferred(); - const calls = [first, second]; - let i = 0; - - const { result, rerender } = renderHook(({ n }) => useApiQuery(() => calls[i++].promise, [n]), { - initialProps: { n: 1 }, - }); - - // trigger the second request, resolve it first, then resolve the stale first - rerender({ n: 2 }); - await act(async () => { - second.resolve("new"); - await second.promise; - }); - await waitFor(() => expect(result.current.data).toBe("new")); - - await act(async () => { - first.resolve("stale"); - await first.promise; - }); - // the late, stale response must not overwrite the newer data - expect(result.current.data).toBe("new"); - }); - - it("polls on the given interval", async () => { - vi.useFakeTimers(); - try { - const fetcher = vi.fn(() => Promise.resolve("tick")); - renderHook(() => useApiQuery(fetcher, [], { refetchInterval: 1000 })); - - expect(fetcher).toHaveBeenCalledTimes(1); - await act(async () => { - await vi.advanceTimersByTimeAsync(1000); - }); - expect(fetcher).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/packages/web/src/dashboard/hooks/use-api-query.ts b/packages/web/src/dashboard/hooks/use-api-query.ts deleted file mode 100644 index d38addc..0000000 --- a/packages/web/src/dashboard/hooks/use-api-query.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable react-hooks/set-state-in-effect -- - this is the single, deliberate data-fetching primitive: its effect subscribes to the API - endpoint and sets state as results arrive. every other hook builds on it and never fetches - this way, so the pattern is contained here on purpose. */ -import { useCallback, useEffect, useRef, useState } from "react"; - -/** The state a query hook exposes. */ -export interface QueryState { - /** The last successfully fetched value, or `undefined` before the first success. */ - data: T | undefined; - /** The last error, cleared on the next attempt. */ - error: Error | undefined; - /** True while a fetch is in flight. */ - isLoading: boolean; - /** Triggers a fresh fetch, keeping the current data until it resolves. */ - refetch: () => void; -} - -/** Options for {@link useApiQuery}. */ -export interface UseApiQueryOptions { - /** If set, re-fetch on this interval (ms). Cleared on unmount. */ - refetchInterval?: number; -} - -/** - * Minimal data-fetching primitive: runs `fetcher` on mount, whenever `deps` change, and on - * `refetch()`. A monotonic request id guards against races so a slow, stale response can - * never overwrite a newer one. No caching or dedup, by design; the API surface is small. - */ -export function useApiQuery(fetcher: () => Promise, deps: unknown[], options?: UseApiQueryOptions): QueryState { - const [state, setState] = useState<{ data?: T; error?: Error; isLoading: boolean }>({ isLoading: true }); - - // Keep the latest fetcher in a ref (updated in an effect, never during render) so the run - // callback can stay stable without capturing a stale closure. - const fetcherRef = useRef(fetcher); - useEffect(() => { - fetcherRef.current = fetcher; - }); - - // Monotonic id: only the most recent request may write state. - const requestId = useRef(0); - - const run = useCallback(() => { - const id = ++requestId.current; - setState((prev) => ({ ...prev, isLoading: true, error: undefined })); - fetcherRef.current().then( - (data) => { - if (id === requestId.current) setState({ data, isLoading: false }); - }, - (error: unknown) => { - if (id === requestId.current) setState({ error: error as Error, isLoading: false }); - }, - ); - }, []); - - useEffect(() => { - run(); - if (options?.refetchInterval) { - const timer = setInterval(run, options.refetchInterval); - return () => clearInterval(timer); - } - // deps are the caller's cache key; run/refetchInterval are stable enough to omit. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - - return { data: state.data, error: state.error, isLoading: state.isLoading, refetch: run }; -} diff --git a/packages/web/src/dashboard/hooks/use-jobs.ts b/packages/web/src/dashboard/hooks/use-jobs.ts index 8a49526..c0db6c7 100644 --- a/packages/web/src/dashboard/hooks/use-jobs.ts +++ b/packages/web/src/dashboard/hooks/use-jobs.ts @@ -1,7 +1,8 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; import { useApiClient } from "../context"; +import type { QueryHookOptions } from "./query-client"; import { toQuery } from "./to-query"; -import { useApiQuery, type UseApiQueryOptions } from "./use-api-query"; /** The jobs list query, mirroring the API's query string. */ export interface JobsFilter { @@ -16,54 +17,66 @@ export interface JobsFilter { } /** Queries the jobs list (with pagination) for the given filter. */ -export function useJobs(filters: JobsFilter = {}, options?: UseApiQueryOptions) { +export function useJobs(filters: JobsFilter = {}, options?: QueryHookOptions) { const client = useApiClient(); - return useApiQuery( - async () => { + return useQuery({ + queryKey: ["jobs", "list", filters], + queryFn: async () => { const res = await client.jobs.$get({ query: toQuery(filters) }); return res.json(); }, - [JSON.stringify(filters)], - options, - ); + refetchInterval: options?.refetchInterval, + enabled: options?.enabled, + }); } /** Queries a single job by id. */ -export function useJob(id: number, options?: UseApiQueryOptions) { +export function useJob(id: number, options?: QueryHookOptions) { const client = useApiClient(); - return useApiQuery( - async () => { + return useQuery({ + queryKey: ["jobs", "detail", id], + queryFn: async () => { const res = await client.jobs[":id"].$get({ param: { id: String(id) } }); return res.json(); }, - [id], - options, - ); + refetchInterval: options?.refetchInterval, + enabled: options?.enabled, + }); } /** Queries the distinct queue names that appear on jobs (for the filter dropdown). */ -export function useJobsMeta(options?: UseApiQueryOptions) { +export function useJobsMeta(options?: QueryHookOptions) { const client = useApiClient(); - return useApiQuery( - async () => { + return useQuery({ + queryKey: ["jobs", "meta"], + queryFn: async () => { const res = await client.jobs.meta.$get(); return res.json(); }, - [], - options, - ); + refetchInterval: options?.refetchInterval, + enabled: options?.enabled, + }); } -/** Job mutations. Each returns the updated job; callers refetch the affected queries. */ +/** + * Job mutations (run/cancel/rerun). Each posts and, on success, invalidates the jobs queries so + * any mounted list/detail refetches on its own. The call shape stays `{ run, cancel, rerun }` so + * callers do not change; the manual refetch they used to do is now redundant. + */ export function useJobActions() { const client = useApiClient(); - return useMemo(() => { - const post = (id: number, action: "run" | "cancel" | "rerun") => - client.jobs[":id"][action].$post({ param: { id: String(id) } }).then((res) => res.json()); - return { - run: (id: number) => post(id, "run"), - cancel: (id: number) => post(id, "cancel"), - rerun: (id: number) => post(id, "rerun"), - }; - }, [client]); + const queryClient = useQueryClient(); + const { mutateAsync } = useMutation({ + mutationFn: ({ id, action }: { id: number; action: "run" | "cancel" | "rerun" }) => + client.jobs[":id"][action].$post({ param: { id: String(id) } }).then((res) => res.json()), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["jobs"] }), + }); + return useMemo( + () => ({ + run: (id: number) => mutateAsync({ id, action: "run" }), + cancel: (id: number) => mutateAsync({ id, action: "cancel" }), + rerun: (id: number) => mutateAsync({ id, action: "rerun" }), + }), + [mutateAsync], + ); } diff --git a/packages/web/src/dashboard/hooks/use-overview.ts b/packages/web/src/dashboard/hooks/use-overview.ts index 55b7d64..8c2d568 100644 --- a/packages/web/src/dashboard/hooks/use-overview.ts +++ b/packages/web/src/dashboard/hooks/use-overview.ts @@ -1,29 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; import { useApiClient } from "../context"; +import type { QueryHookOptions } from "./query-client"; import { toQuery } from "./to-query"; -import { useApiQuery, type UseApiQueryOptions } from "./use-api-query"; /** Queries the overview counts for a range (`12m` | `12h` | `12d`). */ -export function useOverview(range?: string, options?: UseApiQueryOptions) { +export function useOverview(range?: string, options?: QueryHookOptions) { const client = useApiClient(); - return useApiQuery( - async () => { + return useQuery({ + queryKey: ["overview", "counts", range], + queryFn: async () => { const res = await client.overview.$get({ query: toQuery({ range }) }); return res.json(); }, - [range], - options, - ); + refetchInterval: options?.refetchInterval, + enabled: options?.enabled, + }); } /** Queries the overview time series for a range, for the chart. */ -export function useOverviewTimeseries(range?: string, options?: UseApiQueryOptions) { +export function useOverviewTimeseries(range?: string, options?: QueryHookOptions) { const client = useApiClient(); - return useApiQuery( - async () => { + return useQuery({ + queryKey: ["overview", "timeseries", range], + queryFn: async () => { const res = await client.overview.timeseries.$get({ query: toQuery({ range }) }); return res.json(); }, - [range], - options, - ); + refetchInterval: options?.refetchInterval, + enabled: options?.enabled, + }); } diff --git a/packages/web/src/dashboard/hooks/use-queues.ts b/packages/web/src/dashboard/hooks/use-queues.ts index d3a1656..aab1be3 100644 --- a/packages/web/src/dashboard/hooks/use-queues.ts +++ b/packages/web/src/dashboard/hooks/use-queues.ts @@ -1,27 +1,32 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; import { useApiClient } from "../context"; -import { useApiQuery, type UseApiQueryOptions } from "./use-api-query"; +import type { QueryHookOptions } from "./query-client"; /** Queries the queues list, each annotated with its job counts. */ -export function useQueues(options?: UseApiQueryOptions) { +export function useQueues(options?: QueryHookOptions) { const client = useApiClient(); - return useApiQuery( - async () => { + return useQuery({ + queryKey: ["queues"], + queryFn: async () => { const res = await client.queues.$get(); return res.json(); }, - [], - options, - ); + refetchInterval: options?.refetchInterval, + enabled: options?.enabled, + }); } -/** Queue mutations. Returns the updated queue; callers refetch the queues query. */ +/** + * Queue mutations. Posts a toggle and, on success, invalidates the queues query so the list + * refetches on its own. The call shape stays `{ toggle }` so callers do not change. + */ export function useQueueActions() { const client = useApiClient(); - return useMemo( - () => ({ - toggle: (name: string) => client.queues[":name"].toggle.$post({ param: { name } }).then((res) => res.json()), - }), - [client], - ); + const queryClient = useQueryClient(); + const { mutateAsync } = useMutation({ + mutationFn: (name: string) => client.queues[":name"].toggle.$post({ param: { name } }).then((res) => res.json()), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["queues"] }), + }); + return useMemo(() => ({ toggle: (name: string) => mutateAsync(name) }), [mutateAsync]); } diff --git a/packages/web/src/dashboard/index.ts b/packages/web/src/dashboard/index.ts index 5a5af9e..6aef72b 100644 --- a/packages/web/src/dashboard/index.ts +++ b/packages/web/src/dashboard/index.ts @@ -1,7 +1,7 @@ export * from "./client"; export * from "./context"; +export * from "./hooks/query-client"; export * from "./hooks/to-query"; -export * from "./hooks/use-api-query"; export * from "./hooks/use-jobs"; export * from "./hooks/use-overview"; export * from "./hooks/use-queues"; diff --git a/packages/web/src/dashboard/testing/harness.tsx b/packages/web/src/dashboard/testing/harness.tsx index 35886c2..a1859ea 100644 --- a/packages/web/src/dashboard/testing/harness.tsx +++ b/packages/web/src/dashboard/testing/harness.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; import type { ApiClient } from "../client"; @@ -8,10 +9,18 @@ export function jsonResponse(body: T) { return { json: () => Promise.resolve(body) }; } -/** Renders a hook with a fake API client supplied through context. */ +/** + * Renders a hook with a fake API client and a fresh QueryClient supplied through context. + * Retries are off so a rejected query/mutation surfaces its error at once instead of backing off. + */ export function renderHookWithClient(hook: () => R, client: ApiClient) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); return renderHook(hook, { wrapper }); } diff --git a/yarn.lock b/yarn.lock index 9b4dc59..3861360 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4042,6 +4042,7 @@ __metadata: "@sidequest/core": "workspace:*" "@sidequest/engine": "workspace:*" "@storybook/react-vite": "npm:^10.4.6" + "@tanstack/react-query": "npm:^5.101.2" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/react": "npm:^16.3.2" @@ -4469,6 +4470,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.101.2": + version: 5.101.2 + resolution: "@tanstack/query-core@npm:5.101.2" + checksum: 10c0/c3288757bfd563ca35cee21bf6ed0edb2f4425a8c92f75d84dcb36a0580a59e48bd97fcdbbc7c8d8e95a4c40376edfc5772b14665b85d183079a0ba7e17793b2 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.101.2": + version: 5.101.2 + resolution: "@tanstack/react-query@npm:5.101.2" + dependencies: + "@tanstack/query-core": "npm:5.101.2" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/076b2db0d85a6dc96d461789715ae0df2e439d9a20a41e629446275f8177a856c3b89d242ad3d31eadaf421f080b9834bcd9f94e5143f4cd7f947a10af9e42b3 + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1"