Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 65 additions & 14 deletions handler_api_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions handler_api_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 53 additions & 1 deletion src/components/JobList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
<FeaturesContext.Provider value={{ features }}>
<JobList
cancelJobs={vi.fn()}
canShowFewer={false}
canShowMore={false}
deleteJobs={vi.fn()}
jobs={[]}
onSortOrderChange={onSortOrderChange}
onTimeRangeChange={onTimeRangeChange}
retryJobs={vi.fn()}
setJobRefetchesPaused={vi.fn()}
showFewer={vi.fn()}
showMore={vi.fn()}
sortOrder="asc"
state={JobState.Running}
statesAndCounts={undefined}
/>
</FeaturesContext.Provider>,
);

// 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}',
Expand Down
Loading