From b579951bdc12123852514e73878c106b1a8e2621 Mon Sep 17 00:00:00 2001 From: Marcel Heers Date: Wed, 28 Oct 2026 17:13:17 +0200 Subject: [PATCH 1/4] Job list: sort and filter jobs by time Add sort_order=asc|desc and created_after/created_before (RFC 3339) query parameters to GET /api/jobs. Explicit values override the existing per-state defaults while the old defaults are preserved when no sort order is given. The created-at range is applied through JobListParams.Where, which works across PostgreSQL and SQLite. Expose both in the UI with a newest/oldest toggle and a created-at range popover in the job list header. The search params are retained across navigation and included in the jobs query key so cancel, delete, and retry mutations refresh the active result set. Adds backend coverage for sort order and created-at filtering. --- CHANGELOG.md | 4 + handler_api_endpoint.go | 79 +++++++++-- handler_api_endpoint_test.go | 69 ++++++++++ src/components/JobList.tsx | 230 ++++++++++++++++++++++++++++++++ src/routes/jobs/index.schema.ts | 11 ++ src/routes/jobs/index.tsx | 129 +++++++++++++++++- src/services/jobs.ts | 28 +++- 7 files changed, 530 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa46e80c..b8a8c031 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 #XXX](https://github.com/riverqueue/riverui/pull/XXX). + ## [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.tsx b/src/components/JobList.tsx index 024ef1da..6849933c 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,204 @@ 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 +561,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 +745,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.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; From fe2c21a7516aa8261b4140ef325bd03d6abb97a7 Mon Sep 17 00:00:00 2001 From: Marcel Heers Date: Wed, 28 Oct 2026 17:13:21 +0200 Subject: [PATCH 2/4] Add job list time filter regression tests Cover the sort order and created-at range filters at the component and route layers: the JobList controls report changes, the search schema accepts asc/desc and timestamps, the active query key carries all three filters, and the route callbacks round-trip values into search. --- src/components/JobList.test.tsx | 54 ++++++++++++++++++++++++++++- src/routes/jobs/index.test.tsx | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) 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/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"); From f9b44cf604f53bfe90c4c189df6208191227830d Mon Sep 17 00:00:00 2001 From: Marcel Heers Date: Wed, 28 Oct 2026 17:15:17 +0200 Subject: [PATCH 3/4] Keep job list sort controls on one line The Time Range label wrapped to two lines when the job search input took up the remaining header width. --- src/components/JobList.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx index 6849933c..4f7e0b7a 100644 --- a/src/components/JobList.tsx +++ b/src/components/JobList.tsx @@ -358,7 +358,7 @@ const TimeRangeFilter = ({ type="button" > - Time Range + Time Range {hasFilter && ( ); }; From 99c91dbf2b0a48fecf6bace830d5109d8b40c5c2 Mon Sep 17 00:00:00 2001 From: Marcel Heers Date: Wed, 28 Oct 2026 17:28:52 +0200 Subject: [PATCH 4/4] Update changelog entry for PR #720 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a8c031..064a1c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add sort order toggle (ASC/DESC) and time range filter (created after/before) to the job list dashboard. [PR #XXX](https://github.com/riverqueue/riverui/pull/XXX). +- 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