diff --git a/CHANGELOG.md b/CHANGELOG.md index fa46e80c..064a1c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add sort order toggle (ASC/DESC) and time range filter (created after/before) to the job list dashboard. [PR #720](https://github.com/riverqueue/riverui/pull/720). + ## [v0.22.0] - 2026-10-24 ### Added diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 947b6ea8..ff26406e 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -421,16 +421,35 @@ func (*jobListEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { } type jobListRequest struct { - IDs []int64 `json:"-" validate:"omitempty,min=1,max=1000"` // from ExtractRaw - Kinds []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw - Limit *int `json:"-" validate:"omitempty,min=0,max=1000"` // from ExtractRaw - Priorities []int16 `json:"-" validate:"omitempty,min=0,max=10"` // from ExtractRaw - Queues []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw - State *rivertype.JobState `json:"-" validate:"omitempty,oneof=available cancelled completed discarded pending retryable running scheduled"` // from ExtractRaw - Tags []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw + CreatedAfter *time.Time `json:"-"` // from ExtractRaw + CreatedBefore *time.Time `json:"-"` // from ExtractRaw + IDs []int64 `json:"-" validate:"omitempty,min=1,max=1000"` // from ExtractRaw + Kinds []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw + Limit *int `json:"-" validate:"omitempty,min=0,max=1000"` // from ExtractRaw + Priorities []int16 `json:"-" validate:"omitempty,min=0,max=10"` // from ExtractRaw + Queues []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw + SortOrder *string `json:"-" validate:"omitempty,oneof=asc desc"` // from ExtractRaw + State *rivertype.JobState `json:"-" validate:"omitempty,oneof=available cancelled completed discarded pending retryable running scheduled"` // from ExtractRaw + Tags []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw } func (req *jobListRequest) ExtractRaw(r *http.Request) error { + if createdAfter := r.URL.Query().Get("created_after"); createdAfter != "" { + t, err := time.Parse(time.RFC3339, createdAfter) + if err != nil { + return apierror.NewBadRequestf("Invalid `created_after` timestamp: %s.", err) + } + req.CreatedAfter = &t + } + + if createdBefore := r.URL.Query().Get("created_before"); createdBefore != "" { + t, err := time.Parse(time.RFC3339, createdBefore) + if err != nil { + return apierror.NewBadRequestf("Invalid `created_before` timestamp: %s.", err) + } + req.CreatedBefore = &t + } + if ids := r.URL.Query()["ids"]; len(ids) > 0 { req.IDs = sliceutil.Map(ids, func(id string) int64 { value, err := strconv.ParseInt(id, 10, 64) @@ -464,14 +483,18 @@ func (req *jobListRequest) ExtractRaw(r *http.Request) error { }) } - if state := r.URL.Query().Get("state"); state != "" { - req.State = (*rivertype.JobState)(&state) - } - if queues := r.URL.Query()["queues"]; len(queues) > 0 { req.Queues = queues } + if sortOrder := r.URL.Query().Get("sort_order"); sortOrder != "" { + req.SortOrder = &sortOrder + } + + if state := r.URL.Query().Get("state"); state != "" { + req.State = (*rivertype.JobState)(&state) + } + if tags := r.URL.Query()["tags"]; len(tags) > 0 { req.Tags = tags } @@ -505,17 +528,45 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) params = params.TagsAny(req.Tags...) } + // Determine sort direction: use explicit request value, or fall back + // to a sensible default per state. + sortOrder := river.SortOrderAsc + if req.SortOrder != nil { + switch *req.SortOrder { + case "desc": + sortOrder = river.SortOrderDesc + default: + sortOrder = river.SortOrderAsc + } + } else { + // Preserve original default: finalized states sort descending. + if req.State != nil { + switch *req.State { + case rivertype.JobStateCancelled, rivertype.JobStateCompleted, rivertype.JobStateDiscarded: + sortOrder = river.SortOrderDesc + } + } + } + if req.State == nil { - params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, river.SortOrderAsc) + params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, sortOrder) } else { switch *req.State { case rivertype.JobStateCancelled, rivertype.JobStateCompleted, rivertype.JobStateDiscarded: - params = params.States(*req.State).OrderBy(river.JobListOrderByTime, river.SortOrderDesc) + params = params.States(*req.State).OrderBy(river.JobListOrderByTime, sortOrder) case rivertype.JobStateAvailable, rivertype.JobStateRetryable, rivertype.JobStatePending, rivertype.JobStateRunning, rivertype.JobStateScheduled: - params = params.States(*req.State) + params = params.States(*req.State).OrderBy(river.JobListOrderByTime, sortOrder) } } + // Time range filters on created_at. + if req.CreatedAfter != nil { + params = params.Where("created_at >= @created_after", river.NamedArgs{"created_after": *req.CreatedAfter}) + } + if req.CreatedBefore != nil { + params = params.Where("created_at <= @created_before", river.NamedArgs{"created_before": *req.CreatedBefore}) + } + result, err := a.Client.JobListTx(ctx, tx, params) if err != nil { return nil, fmt.Errorf("error listing jobs: %w", err) diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index 74b4a28d..bd2a23c9 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -25,6 +25,7 @@ import ( "github.com/riverqueue/river/rivershared/riversharedtest" "github.com/riverqueue/river/rivershared/startstop" "github.com/riverqueue/river/rivershared/uniquestates" + "github.com/riverqueue/river/rivershared/util/ptrutil" "github.com/riverqueue/river/rivertype" "riverqueue.com/riverui/internal/apibundle" @@ -767,6 +768,74 @@ func TestAPIHandlerJobList(t *testing.T) { require.Len(t, resp.Data, 1) require.Equal(t, job.ID, resp.Data[0].ID) }) + + t.Run("SortOrderDesc", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint) + + job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + Kind: ptrutil.Ptr("kind1"), + State: ptrutil.Ptr(rivertype.JobStateRunning), + }) + job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + Kind: ptrutil.Ptr("kind2"), + State: ptrutil.Ptr(rivertype.JobStateRunning), + }) + + // Default order should be ascending (job1 first) + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{}) + require.NoError(t, err) + require.Len(t, resp.Data, 2) + require.Equal(t, job1.ID, resp.Data[0].ID) + require.Equal(t, job2.ID, resp.Data[1].ID) + + // Explicit descending order should reverse + desc := "desc" + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + SortOrder: &desc, + }) + require.NoError(t, err) + require.Len(t, resp.Data, 2) + require.Equal(t, job2.ID, resp.Data[0].ID) + require.Equal(t, job1.ID, resp.Data[1].ID) + }) + + t.Run("CreatedAfterAndBefore", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint) + + now := time.Now().UTC() + oldTime := now.Add(-48 * time.Hour) + recentTime := now.Add(-1 * time.Hour) + + job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + CreatedAt: &oldTime, + State: ptrutil.Ptr(rivertype.JobStateRunning), + }) + job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + CreatedAt: &recentTime, + State: ptrutil.Ptr(rivertype.JobStateRunning), + }) + + // Filter to only recent jobs (created after 24 hours ago) + cutoff := now.Add(-24 * time.Hour) + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + CreatedAfter: &cutoff, + }) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + require.Equal(t, job2.ID, resp.Data[0].ID) + + // Filter to only old jobs (created before 24 hours ago) + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + CreatedBefore: &cutoff, + }) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + require.Equal(t, job1.ID, resp.Data[0].ID) + }) } func TestAPIHandlerJobListCustomSchema(t *testing.T) { diff --git a/src/components/JobList.test.tsx b/src/components/JobList.test.tsx index 242ec352..900a5e05 100644 --- a/src/components/JobList.test.tsx +++ b/src/components/JobList.test.tsx @@ -3,7 +3,14 @@ import { StatesAndCounts } from "@services/states"; import { JobState } from "@services/types"; import { jobMinimalFactory } from "@test/factories/job"; import { createFeatures } from "@test/utils/features"; -import { act, render, screen, waitFor, within } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { type ReactNode } from "react"; import { userEvent } from "storybook/test"; import { @@ -119,6 +126,51 @@ describe("JobList", () => { }, ); + it("reports sort order and time range filter changes", async () => { + mockUseSettings.mockReturnValue(settingsMock({})); + const onSortOrderChange = vi.fn(); + const onTimeRangeChange = vi.fn(); + const features = createFeatures({}); + + render( + + + , + ); + + // Running defaults to ascending, so the control offers descending next. + await userEvent.click( + screen.getByRole("button", { name: "Sort ascending" }), + ); + expect(onSortOrderChange).toHaveBeenCalledWith("desc"); + + await userEvent.click( + screen.getByRole("button", { name: "Filter by time range" }), + ); + fireEvent.change(screen.getByLabelText("Created after"), { + target: { value: "2026-03-01T10:00" }, + }); + expect(onTimeRangeChange).toHaveBeenCalledWith( + new Date("2026-03-01T10:00").toISOString(), + undefined, + ); + }); + it("shows job args by default", () => { const job = jobMinimalFactory.build({ argsRaw: '{"z":2,"id":1970670598291982290,"a":1}', diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx index 024ef1da..4f7e0b7a 100644 --- a/src/components/JobList.tsx +++ b/src/components/JobList.tsx @@ -14,9 +14,13 @@ import { MenuButton as HeadlessMenuButton } from "@headlessui/react"; import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { ArrowUturnLeftIcon, + BarsArrowDownIcon, + BarsArrowUpIcon, ChevronUpDownIcon, + FunnelIcon, TrashIcon, XCircleIcon, + XMarkIcon, } from "@heroicons/react/24/outline"; import { useSelected } from "@hooks/use-selected"; import { useSettings } from "@hooks/use-settings"; @@ -157,15 +161,23 @@ export type JobRowsProps = { cancelJobs: (jobIDs: bigint[]) => void; canShowFewer: boolean; canShowMore: boolean; + createdAfter?: string; + createdBefore?: string; deleteJobs: (jobIDs: bigint[]) => void; hideArgs: boolean; initialFilters?: Filter[]; jobs: JobMinimal[]; onFiltersChange?: (filters: Filter[]) => void; + onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void; + onTimeRangeChange?: ( + createdAfter: string | undefined, + createdBefore: string | undefined, + ) => void; retryJobs: (jobIDs: bigint[]) => void; setJobRefetchesPaused: (value: boolean) => void; showFewer: () => void; showMore: () => void; + sortOrder?: "asc" | "desc"; state: JobState; }; @@ -173,15 +185,23 @@ type JobListProps = { cancelJobs: (jobIDs: bigint[]) => void; canShowFewer: boolean; canShowMore: boolean; + createdAfter?: string; + createdBefore?: string; deleteJobs: (jobIDs: bigint[]) => void; initialFilters?: Filter[]; jobs: JobMinimal[]; loading?: boolean; onFiltersChange?: (filters: Filter[]) => void; + onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void; + onTimeRangeChange?: ( + createdAfter: string | undefined, + createdBefore: string | undefined, + ) => void; retryJobs: (jobIDs: bigint[]) => void; setJobRefetchesPaused: (value: boolean) => void; showFewer: () => void; showMore: () => void; + sortOrder?: "asc" | "desc"; state: JobState; statesAndCounts: StatesAndCounts | undefined; }; @@ -284,19 +304,206 @@ function JobListActionButtons({ ); } +const formatDateTimeLocal = (isoString: string | undefined): string => { + if (!isoString) return ""; + try { + const d = new Date(isoString); + // Format as YYYY-MM-DDTHH:MM for datetime-local input + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + return `${year.toString()}-${month}-${day}T${hours}:${minutes}`; + } catch { + return ""; + } +}; + +const dateTimeLocalToISO = (value: string): string | undefined => { + if (!value) return undefined; + try { + return new Date(value).toISOString(); + } catch { + return undefined; + } +}; + +const TimeRangeFilter = ({ + createdAfter, + createdBefore, + onTimeRangeChange, +}: { + createdAfter?: string; + createdBefore?: string; + onTimeRangeChange?: ( + createdAfter: string | undefined, + createdBefore: string | undefined, + ) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + const hasFilter = !!createdAfter || !!createdBefore; + + return ( +
+ + )} + + {isOpen && ( +
+
+
+ + { + onTimeRangeChange?.( + dateTimeLocalToISO(e.target.value), + createdBefore, + ); + }} + type="datetime-local" + value={formatDateTimeLocal(createdAfter)} + /> +
+
+ + { + onTimeRangeChange?.( + createdAfter, + dateTimeLocalToISO(e.target.value), + ); + }} + type="datetime-local" + value={formatDateTimeLocal(createdBefore)} + /> +
+ {hasFilter && ( + + )} +
+
+ )} +
+ ); +}; + +const SortOrderButton = ({ + onSortOrderChange, + sortOrder, + state, +}: { + onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void; + sortOrder?: "asc" | "desc"; + state: JobState; +}) => { + // Default sort direction per state (matches backend defaults) + const defaultIsDesc = [ + JobState.Cancelled, + JobState.Completed, + JobState.Discarded, + ].includes(state); + const effectiveSortOrder = sortOrder ?? (defaultIsDesc ? "desc" : "asc"); + const isDesc = effectiveSortOrder === "desc"; + const isCustom = sortOrder !== undefined; + + const toggle = () => { + const newOrder = isDesc ? "asc" : "desc"; + // If toggling back to default, clear the param + const defaultOrder = defaultIsDesc ? "desc" : "asc"; + onSortOrderChange?.(newOrder === defaultOrder ? undefined : newOrder); + }; + + return ( + + ); +}; + const JobRows = ({ cancelJobs, canShowFewer, canShowMore, + createdAfter, + createdBefore, deleteJobs, hideArgs, initialFilters, jobs, onFiltersChange, + onSortOrderChange, + onTimeRangeChange, retryJobs, setJobRefetchesPaused, showFewer, showMore, + sortOrder, state, }: JobRowsProps) => { const { @@ -356,6 +563,21 @@ const JobRows = ({ onFiltersChange={onFiltersChange} /> + {selectedJobs.length === 0 && ( +
+ + +
+ )} + {selectedJobs.length > 0 && ( { cancelJobs, canShowFewer, canShowMore, + createdAfter, + createdBefore, deleteJobs, initialFilters, jobs, loading = false, onFiltersChange, + onSortOrderChange, + onTimeRangeChange, retryJobs, setJobRefetchesPaused, showFewer, showMore, + sortOrder, state, statesAndCounts, } = props; @@ -520,15 +747,20 @@ const JobList = (props: JobListProps) => { cancelJobs={cancelJobs} canShowFewer={canShowFewer} canShowMore={canShowMore} + createdAfter={createdAfter} + createdBefore={createdBefore} deleteJobs={deleteJobs} hideArgs={hideArgs} initialFilters={initialFilters} jobs={jobs} onFiltersChange={onFiltersChange} + onSortOrderChange={onSortOrderChange} + onTimeRangeChange={onTimeRangeChange} retryJobs={retryJobs} setJobRefetchesPaused={setJobRefetchesPaused} showFewer={showFewer} showMore={showMore} + sortOrder={sortOrder} state={state} /> )} diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts index f4978cf7..4b7c2ea1 100644 --- a/src/routes/jobs/index.schema.ts +++ b/src/routes/jobs/index.schema.ts @@ -5,14 +5,19 @@ export const minimumLimit = 20; export const defaultLimit = 20; export const maximumLimit = 200; +export type SortOrder = "asc" | "desc"; + // Search params kept when moving between job states. Other panes clear // them when they link to Jobs. export const retainedJobSearchKeys = [ + "createdAfter", + "createdBefore", "id", "kind", "limit", "priority", "queue", + "sortOrder", "tags", ] as const; @@ -23,6 +28,8 @@ export const defaultValues = { }; export const jobSearchSchema = z.object({ + createdAfter: z.string().optional(), + createdBefore: z.string().optional(), id: z .union([z.string(), z.array(z.string().min(1))]) .optional() @@ -59,6 +66,10 @@ export const jobSearchSchema = z.object({ .union([z.string(), z.array(z.string().min(1))]) .optional() .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), + sortOrder: z + .enum(["asc", "desc"]) + .optional() + .transform((v) => v as SortOrder | undefined), state: z.nativeEnum(JobState).default(defaultValues.state), tags: z .union([z.string(), z.array(z.string().min(1))]) diff --git a/src/routes/jobs/index.test.tsx b/src/routes/jobs/index.test.tsx index 46a827a0..bd34f133 100644 --- a/src/routes/jobs/index.test.tsx +++ b/src/routes/jobs/index.test.tsx @@ -9,10 +9,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type JobListHarnessProps = { cancelJobs: (jobIDs: bigint[]) => void; + createdAfter?: string; + createdBefore?: string; deleteJobs: (jobIDs: bigint[]) => void; initialFilters?: Filter[]; onFiltersChange?: (filters: Filter[]) => void; + onSortOrderChange?: (sortOrder: "asc" | "desc" | undefined) => void; + onTimeRangeChange?: ( + createdAfter: string | undefined, + createdBefore: string | undefined, + ) => void; retryJobs: (jobIDs: bigint[]) => void; + sortOrder?: "asc" | "desc"; }; type NavigateOptions = { @@ -80,21 +88,27 @@ vi.mock("@services/toast", () => ({ })); const loaderDeps = { + createdAfter: "2026-01-01T00:00:00.000Z", + createdBefore: "2026-02-01T00:00:00.000Z", id: undefined, kind: ["email"], limit: 20, priority: [1], queue: ["default"], + sortOrder: "desc" as const, state: JobState.Running, tags: ["customer:123", "urgent"], }; const activeJobsKey = listJobsKey({ + createdAfter: loaderDeps.createdAfter, + createdBefore: loaderDeps.createdBefore, ids: loaderDeps.id, kinds: loaderDeps.kind, limit: loaderDeps.limit, priorities: loaderDeps.priority, queues: loaderDeps.queue, + sortOrder: loaderDeps.sortOrder, state: loaderDeps.state, tags: loaderDeps.tags, }); @@ -189,6 +203,18 @@ describe("Jobs Route Search Schema", () => { tags: ["customer:123", "urgent"], }); }); + + it("validates sort order and time range parameters", () => { + expect(jobSearchSchema.parse({ sortOrder: "asc" }).sortOrder).toBe("asc"); + expect(jobSearchSchema.parse({ sortOrder: "desc" }).sortOrder).toBe("desc"); + expect(() => jobSearchSchema.parse({ sortOrder: "newest" })).toThrow(); + + const createdAfter = "2026-01-01T00:00:00.000Z"; + const createdBefore = "2026-01-02T00:00:00.000Z"; + expect( + jobSearchSchema.parse({ createdAfter, createdBefore }), + ).toMatchObject({ createdAfter, createdBefore }); + }); }); describe("JobsIndexComponent", () => { @@ -260,6 +286,40 @@ describe("JobsIndexComponent", () => { }); }); + it("round trips sort order and time range into route search", async () => { + renderJobsIndex(); + + await waitFor(() => expect(mockListJobs).toHaveBeenCalled()); + const props = latestJobListProps(); + expect(props.createdAfter).toBe(loaderDeps.createdAfter); + expect(props.createdBefore).toBe(loaderDeps.createdBefore); + expect(props.sortOrder).toBe(loaderDeps.sortOrder); + + act(() => props.onSortOrderChange?.("asc")); + let navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({ + sortOrder: "asc", + }); + + act(() => props.onSortOrderChange?.(undefined)); + navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({ + sortOrder: undefined, + }); + + const createdAfter = "2026-03-01T00:00:00.000Z"; + const createdBefore = "2026-04-01T00:00:00.000Z"; + act(() => props.onTimeRangeChange?.(createdAfter, createdBefore)); + navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({ + createdAfter, + createdBefore, + }); + }); + it("refreshes the active tag-filtered query after mutations", async () => { const { queryClient } = renderJobsIndex(); const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index 268afbce..9fc91840 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -10,6 +10,7 @@ import { defaultValues, jobSearchSchema, retainedJobSearchKeys, + SortOrder, } from "@routes/jobs/index.schema"; import { cancelJobs, @@ -62,13 +63,27 @@ export const Route = createFileRoute("/jobs/")({ return context; }, loaderDeps: ({ - search: { limit, state, kind, queue, priority, id, tags }, + search: { + createdAfter, + createdBefore, + limit, + state, + kind, + queue, + priority, + id, + sortOrder, + tags, + }, }) => { return { + createdAfter, + createdBefore, kind, limit: limit || defaultValues.limit, priority: priority?.map((p) => parseInt(p, 10)), queue, + sortOrder, state, id, tags, @@ -76,11 +91,31 @@ export const Route = createFileRoute("/jobs/")({ }, loader: async ({ context: { queryClient }, - deps: { limit, state, kind, queue, id, tags }, + deps: { + createdAfter, + createdBefore, + limit, + state, + kind, + queue, + id, + sortOrder, + tags, + }, }) => { await Promise.all([ queryClient.ensureQueryData({ - ...jobsQueryOptions({ limit, state, kind, queue, id, tags }), + ...jobsQueryOptions({ + createdAfter, + createdBefore, + limit, + state, + kind, + queue, + id, + sortOrder, + tags, + }), }), queryClient.ensureQueryData(statesQueryOptions()), ]); @@ -91,8 +126,18 @@ export const Route = createFileRoute("/jobs/")({ export function JobsIndexComponent() { const navigate = Route.useNavigate(); - const { id, limit, state, kind, queue, priority, tags } = - Route.useLoaderDeps(); + const { + createdAfter, + createdBefore, + id, + limit, + state, + kind, + queue, + priority, + sortOrder, + tags, + } = Route.useLoaderDeps(); const refreshSettings = useRefreshSetting(); const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const [pauseRefetches, setJobRefetchesPaused] = useState(false); @@ -101,12 +146,15 @@ export function JobsIndexComponent() { const jobsQuery = useQuery( jobsQueryOptions( { + createdAfter, + createdBefore, id, limit, state, kind, queue, priority, + sortOrder, tags, }, { @@ -291,12 +339,15 @@ export function JobsIndexComponent() { }); queryClient.invalidateQueries({ queryKey: listJobsKey({ + createdAfter, + createdBefore, limit, state, kinds: kind, queues: queue, priorities: priority, ids: id, + sortOrder, tags, }), }); @@ -315,12 +366,15 @@ export function JobsIndexComponent() { }); await queryClient.removeQueries({ queryKey: listJobsKey({ + createdAfter, + createdBefore, limit, state, kinds: kind, queues: queue, priorities: priority, ids: id, + sortOrder, tags, }), }); @@ -332,12 +386,15 @@ export function JobsIndexComponent() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: listJobsKey({ + createdAfter, + createdBefore, limit, state, kinds: kind, queues: queue, priorities: priority, ids: id, + sortOrder, tags, }), }); @@ -346,20 +403,73 @@ export function JobsIndexComponent() { successMessage: "Jobs enqueued for retry", }); + const handleSortOrderChange = useCallback( + (newSortOrder: SortOrder | undefined) => { + navigate({ + replace: true, + search: (old) => + ({ ...old, sortOrder: newSortOrder }) as { + id?: string[]; + kind?: string[]; + limit: number; + priority?: string[]; + queue?: string[]; + sortOrder?: SortOrder; + state: JobState; + tags?: string[]; + }, + }); + }, + [navigate], + ); + + const handleTimeRangeChange = useCallback( + ( + newCreatedAfter: string | undefined, + newCreatedBefore: string | undefined, + ) => { + navigate({ + replace: true, + search: (old) => + ({ + ...old, + createdAfter: newCreatedAfter, + createdBefore: newCreatedBefore, + }) as { + createdAfter?: string; + createdBefore?: string; + id?: string[]; + kind?: string[]; + limit: number; + priority?: string[]; + queue?: string[]; + state: JobState; + tags?: string[]; + }, + }); + }, + [navigate], + ); + return ( @@ -368,19 +478,25 @@ export function JobsIndexComponent() { const jobsQueryOptions = ( { + createdAfter, + createdBefore, id, limit, state, kind, queue, priority, + sortOrder, tags, }: { + createdAfter?: string; + createdBefore?: string; id?: bigint[]; kind?: string[]; limit: number; priority?: number[]; queue?: string[]; + sortOrder?: SortOrder; state: JobState; tags?: string[]; }, @@ -399,12 +515,15 @@ const jobsQueryOptions = ( }; return queryOptions({ queryKey: listJobsKey({ + createdAfter, + createdBefore, limit, state, kinds: kind, queues: queue, priorities: priority, ids: id, + sortOrder, tags, }), queryFn: listJobs, diff --git a/src/services/jobs.ts b/src/services/jobs.ts index 26d6396b..a35e8120 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -185,22 +185,28 @@ export const deleteJobs: MutationFunction = async ({ export type ListJobsKey = [ "listJobs", { + createdAfter: string | undefined; + createdBefore: string | undefined; ids: bigint[] | undefined; kinds: string[] | undefined; limit: number; priorities: number[] | undefined; queues: string[] | undefined; + sortOrder: "asc" | "desc" | undefined; state: JobState | undefined; tags: string[] | undefined; }, ]; type ListJobsFilters = { + createdAfter?: string; + createdBefore?: string; ids?: bigint[]; kinds?: string[]; limit: number; priorities?: number[]; queues?: string[]; + sortOrder?: "asc" | "desc"; state?: JobState; tags?: string[]; }; @@ -212,11 +218,14 @@ export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { return [ "listJobs", { + createdAfter: args.createdAfter, + createdBefore: args.createdBefore, ids: args.ids, kinds: args.kinds, limit: args.limit, priorities: args.priorities, queues: args.queues, + sortOrder: args.sortOrder, state: args.state, tags: args.tags, }, @@ -227,16 +236,33 @@ export const listJobs: QueryFunction = async ({ queryKey, signal, }) => { - const [, { ids, kinds, limit, priorities, queues, state, tags }] = queryKey; + const [ + , + { + createdAfter, + createdBefore, + ids, + kinds, + limit, + priorities, + queues, + sortOrder, + state, + tags, + }, + ] = queryKey; // Build query params object with only defined values const params: Record = { limit: String(limit), }; + if (createdAfter) params.created_after = createdAfter; + if (createdBefore) params.created_before = createdBefore; if (ids?.length) params.ids = ids.map(String); if (kinds?.length) params.kinds = kinds; if (priorities?.length) params.priorities = priorities.map(String); if (queues?.length) params.queues = queues; + if (sortOrder) params.sort_order = sortOrder; if (state) params.state = state; if (tags?.length) params.tags = tags;