-
-
Notifications
You must be signed in to change notification settings - Fork 23
feat(web): typed api client + headless hooks (dashboard foundation) #201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
merencia
wants to merge
2
commits into
feat/web-api
Choose a base branch
from
feat/web-dashboard-hooks
base: feat/web-api
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) => ( | ||
| <ApiClientProvider value={custom}>{children}</ApiClientProvider> | ||
| ); | ||
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof hc<ApiApp>>; | ||
|
|
||
| /** | ||
| * 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<ApiApp>(baseUrl); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { createContext, useContext } from "react"; | ||
| import { type ApiClient, createApiClient } from "./client"; | ||
|
|
||
| const ApiClientContext = createContext<ApiClient>(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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }, | ||
| }, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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({}); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| /** Serializes a filter object into string query params, dropping empty/undefined values. */ | ||
| export function toQuery(filters: Record<string, string | number | undefined>): Record<string, string> { | ||
| const query: Record<string, string> = {}; | ||
| for (const [key, value] of Object.entries(filters)) { | ||
| if (value !== undefined && value !== "") query[key] = String(value); | ||
| } | ||
| return query; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| 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"; | ||
|
|
||
| /** 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?: QueryHookOptions) { | ||
| const client = useApiClient(); | ||
| return useQuery({ | ||
| queryKey: ["jobs", "list", filters], | ||
| queryFn: async () => { | ||
| const res = await client.jobs.$get({ query: toQuery(filters) }); | ||
| return res.json(); | ||
| }, | ||
| refetchInterval: options?.refetchInterval, | ||
| enabled: options?.enabled, | ||
| }); | ||
| } | ||
|
|
||
| /** Queries a single job by id. */ | ||
| export function useJob(id: number, options?: QueryHookOptions) { | ||
| const client = useApiClient(); | ||
| return useQuery({ | ||
| queryKey: ["jobs", "detail", id], | ||
| queryFn: async () => { | ||
| const res = await client.jobs[":id"].$get({ param: { id: String(id) } }); | ||
| return res.json(); | ||
| }, | ||
| refetchInterval: options?.refetchInterval, | ||
| enabled: options?.enabled, | ||
| }); | ||
| } | ||
|
|
||
| /** Queries the distinct queue names that appear on jobs (for the filter dropdown). */ | ||
| export function useJobsMeta(options?: QueryHookOptions) { | ||
| const client = useApiClient(); | ||
| return useQuery({ | ||
| queryKey: ["jobs", "meta"], | ||
| queryFn: async () => { | ||
| const res = await client.jobs.meta.$get(); | ||
| return res.json(); | ||
| }, | ||
| refetchInterval: options?.refetchInterval, | ||
| enabled: options?.enabled, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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(); | ||
| 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], | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { useApiClient } from "../context"; | ||
| import type { QueryHookOptions } from "./query-client"; | ||
| import { toQuery } from "./to-query"; | ||
|
|
||
| /** Queries the overview counts for a range (`12m` | `12h` | `12d`). */ | ||
| export function useOverview(range?: string, options?: QueryHookOptions) { | ||
| const client = useApiClient(); | ||
| return useQuery({ | ||
| queryKey: ["overview", "counts", range], | ||
| queryFn: async () => { | ||
| const res = await client.overview.$get({ query: toQuery({ range }) }); | ||
| return res.json(); | ||
| }, | ||
| refetchInterval: options?.refetchInterval, | ||
| enabled: options?.enabled, | ||
| }); | ||
| } | ||
|
|
||
| /** Queries the overview time series for a range, for the chart. */ | ||
| export function useOverviewTimeseries(range?: string, options?: QueryHookOptions) { | ||
| const client = useApiClient(); | ||
| return useQuery({ | ||
| queryKey: ["overview", "timeseries", range], | ||
| queryFn: async () => { | ||
| const res = await client.overview.timeseries.$get({ query: toQuery({ range }) }); | ||
| return res.json(); | ||
| }, | ||
| refetchInterval: options?.refetchInterval, | ||
| enabled: options?.enabled, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| import { useMemo } from "react"; | ||
| import { useApiClient } from "../context"; | ||
| import type { QueryHookOptions } from "./query-client"; | ||
|
|
||
| /** Queries the queues list, each annotated with its job counts. */ | ||
| export function useQueues(options?: QueryHookOptions) { | ||
| const client = useApiClient(); | ||
| return useQuery({ | ||
| queryKey: ["queues"], | ||
| queryFn: async () => { | ||
| const res = await client.queues.$get(); | ||
| return res.json(); | ||
| }, | ||
| refetchInterval: options?.refetchInterval, | ||
| enabled: options?.enabled, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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(); | ||
| 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]); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like this pattern for mutations. Wrapping mutateasync to a single function is a bit weird and it obfuscates the other (maybe useful) methods of the mutation object, like isPending, or data.
I suggest returning the full mutation object and calling mutateAsync or mutate on the UI directly.
Also, why useMemo and not useCallback?