From 3f3123e5de8db547dfc61d81536c7e7b9f0a6aec Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Wed, 8 May 2024 23:10:18 -0500 Subject: [PATCH] add load more to job list, limit param to API query --- api_handler.go | 22 +++++++- ui/.gitignore | 1 + ui/src/components/JobList.tsx | 35 ++++++++++++- ui/src/routes/jobs/index.tsx | 99 +++++++++++++++++++++++++---------- ui/src/services/jobs.ts | 10 ++-- 5 files changed, 133 insertions(+), 34 deletions(-) diff --git a/api_handler.go b/api_handler.go index 1ef7d9b..d3eca3a 100644 --- a/api_handler.go +++ b/api_handler.go @@ -163,7 +163,12 @@ func (a *apiHandler) JobList(rw http.ResponseWriter, req *http.Request) { return } - params := river.NewJobListParams().First(20) + limit, err := limitFromReq(req, 20) + if err != nil { + http.Error(rw, fmt.Sprintf("invalid limit: %s", err), http.StatusBadRequest) + return + } + params := river.NewJobListParams().First(int(limit)) state := rivertype.JobState(req.Form.Get("state")) switch state { @@ -403,6 +408,21 @@ func (a *apiHandler) WorkflowGet(rw http.ResponseWriter, req *http.Request) { } } +func limitFromReq(req *http.Request, defaultLimit int) (int, error) { + limitString := req.Form.Get("limit") + if limitString == "" { + return defaultLimit, nil + } + limit, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + return 0, err + } + if limit > 1000 { + return 1000, nil + } + return int(limit), nil +} + type RiverJob struct { ID int64 `json:"id"` Args json.RawMessage `json:"args"` diff --git a/ui/.gitignore b/ui/.gitignore index 620fd39..82678d8 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -12,6 +12,7 @@ dist !dist/.gitkeep dist-ssr *.local +.eslintcache # Editor directories and files .vscode/* diff --git a/ui/src/components/JobList.tsx b/ui/src/components/JobList.tsx index f418615..3e77434 100644 --- a/ui/src/components/JobList.tsx +++ b/ui/src/components/JobList.tsx @@ -127,12 +127,24 @@ const JobListItem = ({ job }: JobListItemProps) => ( ); type JobListProps = { + canShowFewer: boolean; + canShowMore: boolean; loading?: boolean; jobs: Job[]; + showFewer: () => void; + showMore: () => void; statesAndCounts: StatesAndCounts | undefined; }; -const JobList = ({ jobs, loading, statesAndCounts }: JobListProps) => { +const JobList = ({ + canShowFewer, + canShowMore, + jobs, + loading, + showFewer, + showMore, + statesAndCounts, +}: JobListProps) => { return (
@@ -170,6 +182,27 @@ const JobList = ({ jobs, loading, statesAndCounts }: JobListProps) => { ))} +
)} diff --git a/ui/src/routes/jobs/index.tsx b/ui/src/routes/jobs/index.tsx index 14dfa6c..1bc132b 100644 --- a/ui/src/routes/jobs/index.tsx +++ b/ui/src/routes/jobs/index.tsx @@ -1,5 +1,9 @@ import { z } from "zod"; -import { useQuery } from "@tanstack/react-query"; +import { + queryOptions, + useQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; import { JobState } from "@services/types"; import JobList from "@components/JobList"; @@ -9,34 +13,29 @@ import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; import { createFileRoute } from "@tanstack/react-router"; import { countsByState, countsByStateKey } from "@services/states"; +const minimumLimit = 20; +const maximumLimit = 200; + const jobSearchSchema = z.object({ + limit: z + .number() + .int() + .min(minimumLimit) + .max(maximumLimit) + .default(minimumLimit) + .catch(minimumLimit), state: z.nativeEnum(JobState).catch(JobState.Running).optional(), }); export const Route = createFileRoute("/jobs/")({ validateSearch: jobSearchSchema, - beforeLoad: ({ abortController, search }) => { - return { - jobsQueryOptions: { - queryKey: listJobsKey(search), - queryFn: listJobs, - refetchInterval: 2000, - signal: abortController.signal, - }, - statesQueryOptions: { - queryKey: countsByStateKey(), - queryFn: countsByState, - refetchInterval: 2000, - signal: abortController.signal, - }, - }; - }, - loader: async ({ - context: { queryClient, jobsQueryOptions, statesQueryOptions }, - }) => { + loaderDeps: ({ search: { limit, state } }) => ({ limit, state }), + loader: async ({ context: { queryClient }, deps: { limit, state } }) => { + // TODO: how to pass abortController.signal into ensureQueryData or queryOptions? + // signal: abortController.signal, await Promise.all([ - queryClient.ensureQueryData(jobsQueryOptions), - queryClient.ensureQueryData(statesQueryOptions), + queryClient.ensureQueryData({ ...jobsQueryOptions({ limit, state }) }), + queryClient.ensureQueryData(statesQueryOptions()), ]); }, @@ -44,24 +43,66 @@ export const Route = createFileRoute("/jobs/")({ }); function JobsIndexComponent() { - const { jobsQueryOptions, statesQueryOptions } = Route.useRouteContext(); + const navigate = Route.useNavigate(); + const { limit } = Route.useSearch(); const refreshSettings = useRefreshSetting(); const refetchInterval = !refreshSettings.disabled ? refreshSettings.intervalMs : 0; - const jobsQuery = useQuery( - Object.assign(jobsQueryOptions, { refetchInterval }) - ); - const statesQuery = useQuery( - Object.assign(statesQueryOptions, { refetchInterval }) + const jobsQuery = useSuspenseQuery( + jobsQueryOptions(Route.useLoaderDeps(), { refetchInterval }) ); + const statesQuery = useQuery(statesQueryOptions({ refetchInterval })); + + const canShowFewer = limit > minimumLimit; + const canShowMore = limit < maximumLimit; + + const showFewer = () => { + navigate({ + replace: true, + search: (old) => ({ ...old, limit: Math.max(limit - 20, minimumLimit) }), + }); + }; + const showMore = () => { + navigate({ + replace: true, + search: (old) => ({ ...old, limit: Math.min(limit + 20, maximumLimit) }), + }); + }; return ( ); } + +const jobsQueryOptions = ( + { + limit, + state, + }: { + limit?: number; + state?: JobState; + }, + opts?: { refetchInterval: number } +) => + queryOptions({ + queryKey: listJobsKey({ limit, state }), + queryFn: listJobs, + refetchInterval: opts?.refetchInterval, + }); + +const statesQueryOptions = (opts?: { refetchInterval: number }) => + queryOptions({ + queryKey: countsByStateKey(), + queryFn: countsByState, + refetchInterval: opts?.refetchInterval, + }); diff --git a/ui/src/services/jobs.ts b/ui/src/services/jobs.ts index 1452e39..10862e6 100644 --- a/ui/src/services/jobs.ts +++ b/ui/src/services/jobs.ts @@ -56,8 +56,8 @@ export type Job = { | undefined ? Date : JobFromAPI[Key] extends AttemptErrorFromAPI[] - ? AttemptError[] - : JobFromAPI[Key]; + ? AttemptError[] + : JobFromAPI[Key]; }; export type JobWithKnownMetadata = Job & { @@ -107,6 +107,7 @@ export const cancelJobs: MutationFunction = async ({ }; type ListJobsFilters = { + limit?: number; state?: JobState; }; @@ -121,7 +122,10 @@ export const listJobs: QueryFunction = async ({ signal, }) => { const [, searchParams] = queryKey; - const query = new URLSearchParams(searchParams); + const searchParamsStringValues = Object.fromEntries( + Object.entries(searchParams).map(([k, v]) => [k, String(v)]) + ); + const query = new URLSearchParams(searchParamsStringValues); return API.get({ path: "/jobs", query }, { signal }).then( // Map from JobFromAPI to Job: