diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da77c24..3c6c2169 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 + +- Job list: filter jobs matching any of the selected exact tags. [PR #548](https://github.com/riverqueue/riverui/pull/548). + ### Fixed - Job args: preserve large numeric JSON values exactly when displaying and copying args, while keeping object keys sorted. [Fixes #593](https://github.com/riverqueue/riverui/issues/593). [PR #594](https://github.com/riverqueue/riverui/pull/594). diff --git a/go.mod b/go.mod index 712eeac6..d2cc2b8e 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,11 @@ require ( github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.10.0 github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e - github.com/riverqueue/river v0.41.1 - github.com/riverqueue/river/riverdriver v0.41.1 - github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.1 - github.com/riverqueue/river/rivershared v0.41.1 - github.com/riverqueue/river/rivertype v0.41.1 + github.com/riverqueue/river v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/riverdriver v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/rivershared v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/rivertype v0.42.1-0.20260803004224-dc39f530d6db github.com/rs/cors v1.11.1 github.com/samber/slog-http v1.12.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 6c4f1119..9f51f6b8 100644 --- a/go.sum +++ b/go.sum @@ -35,16 +35,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e h1:OwOgxT3MRpOj5Mp6DhFdZP43FOQOf2hhywAuT5XZCR4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e/go.mod h1:O7UmsAMjpMYuToN4au5GNXdmN1gli+5FTldgXqAfaD0= -github.com/riverqueue/river v0.41.1 h1:Eat4tnIL16PTX1StVTwTAm2iKDowG99t2fDZPfwFPE8= -github.com/riverqueue/river v0.41.1/go.mod h1:5KXJLKDPwjVC99ISXv2OHDQtNm1YwAeEAx9VYCd1RF0= -github.com/riverqueue/river/riverdriver v0.41.1 h1:cwmmFCJkJlH6geaUTbOqC8vBUe00vTBm248GVD3HrqY= -github.com/riverqueue/river/riverdriver v0.41.1/go.mod h1:nAZirY7z2clR4gaZxuGzvae8bwgycl9mrvb3+RWOehQ= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.1 h1:9VCL/tebPcMqrg9dwxQnL80/np4b2HnXwSwaICsszGM= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.1/go.mod h1:Ga+M+38X+cDMk6GsONljP3PrY5ylfBYMBn3MRj2+c9U= -github.com/riverqueue/river/rivershared v0.41.1 h1:wHYBCXxCocvPhkH90B4fAUyMMWMVGG+/EFubyH15R2E= -github.com/riverqueue/river/rivershared v0.41.1/go.mod h1:wQJhQ+jP7SILzEF3t7vrUQvuCMRL0sIhCU2DwjFfn/g= -github.com/riverqueue/river/rivertype v0.41.1 h1:wBO2+nRfmpSohBRkC1/dxEkRyLS96FWlkUjukexOydc= -github.com/riverqueue/river/rivertype v0.41.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= +github.com/riverqueue/river v0.42.1-0.20260803004224-dc39f530d6db h1:a8TDxboPR2Yyf+k9ifyzlEvGOoYBeKQWu3r1HCKAQh8= +github.com/riverqueue/river v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:pD+hDP0ZW3SbuTwh0CXDlYQ/M3Q7HDntT3Q4lhfYugY= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260803004224-dc39f530d6db h1:YmnE4JbUsi+XfvhkJD6IAbuiXmc/GB/0RzM7LEFd3SQ= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:b2IBlA29E3H233XwgbiJlezdoALSWhetZKt1LlBJEQU= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260803004224-dc39f530d6db h1:1aDHGtTlxeA9f0oE4/MsdGPXX18YaB5ngTDh/tb2eEQ= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:x+Yx1dcPLuriu8TqDHpAvZ5YQRJVnh9mxuxlqS0I6HA= +github.com/riverqueue/river/rivershared v0.42.1-0.20260803004224-dc39f530d6db h1:DK/GXyMms5565iH7SSxSQ4I19tNa35cOdUjiqAUap9M= +github.com/riverqueue/river/rivershared v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:EThAIEr49dlUQFhVLJcQGKoMlnPKOq+UxdMi9jevVsk= +github.com/riverqueue/river/rivertype v0.42.1-0.20260803004224-dc39f530d6db h1:02uJDqbCs41J3IPL9YWdxLabiIpdxWGpt/AfAo+T1d8= +github.com/riverqueue/river/rivertype v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 1ded8ce3..dce312f1 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -423,6 +423,7 @@ type jobListRequest struct { 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 } func (req *jobListRequest) ExtractRaw(r *http.Request) error { @@ -467,6 +468,10 @@ func (req *jobListRequest) ExtractRaw(r *http.Request) error { req.Queues = queues } + if tags := r.URL.Query()["tags"]; len(tags) > 0 { + req.Tags = tags + } + return nil } @@ -492,6 +497,10 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) params = params.Queues(req.Queues...) } + if len(req.Tags) > 0 { + params = params.TagsAny(req.Tags...) + } + if req.State == nil { params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, river.SortOrderAsc) } else { diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index fd8e6450..a5b918a9 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "net/http" + "net/http/httptest" "testing" "time" @@ -671,6 +672,33 @@ func TestAPIHandlerJobList(t *testing.T) { require.Equal(t, job.ID, resp.Data[0].ID) }) + t.Run("FilterByTags", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint) + + job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"alpha-tag", "shared"}, + }) + job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"beta"}, + }) + _ = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"ALPHA-TAG"}, + }) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + Tags: []string{"alpha-tag", "beta"}, + }) + 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) + }) + t.Run("FilterByState", func(t *testing.T) { t.Parallel() @@ -713,6 +741,42 @@ func TestAPIHandlerJobList(t *testing.T) { }) } +func TestAPIHandlerJobListCustomSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + endpoint, bundle := setupEndpointWithCustomSchema(ctx, t, newJobListEndpoint) + jobParams := testfactory.Job_Build(t, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"custom-schema-tag"}, + }) + jobParams.Schema = bundle.client.Schema() + job, err := bundle.exec.JobInsertFull(ctx, jobParams) + require.NoError(t, err) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + Tags: []string{"custom-schema-tag"}, + }) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + require.Equal(t, job.ID, resp.Data[0].ID) +} + +func TestJobListRequestExtractRaw(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/api/jobs?tags=ALPHA&tags=customer%3A123", + nil, + ) + params := &jobListRequest{} + + require.NoError(t, params.ExtractRaw(req)) + require.Equal(t, []string{"ALPHA", "customer:123"}, params.Tags) +} + func TestAPIHandlerJobRetry(t *testing.T) { t.Parallel() diff --git a/src/components/job-search/parser.test.ts b/src/components/job-search/parser.test.ts index 54bcfedc..9417f16c 100644 --- a/src/components/job-search/parser.test.ts +++ b/src/components/job-search/parser.test.ts @@ -12,12 +12,17 @@ import { JobFilterTypeID } from "./types"; describe("parser", () => { describe("parseFiltersFromText", () => { it("parses simple filters", () => { - const result = parseFiltersFromText("kind:batch queue:priority"); - expect(result).toHaveLength(2); + const result = parseFiltersFromText( + "kind:batch queue:priority tags:customer,urgent", + ); + expect(result).toHaveLength(3); expect(result[0].match).toBe("kind:"); expect(result[0].values).toEqual(["batch"]); expect(result[1].match).toBe("queue:"); expect(result[1].values).toEqual(["priority"]); + expect(result[2].match).toBe("tags:"); + expect(result[2].typeId).toBe(JobFilterTypeID.TAGS); + expect(result[2].values).toEqual(["customer", "urgent"]); }); it("parses comma-separated values", () => { diff --git a/src/components/job-search/types.ts b/src/components/job-search/types.ts index 853ed5d4..6775b6aa 100644 --- a/src/components/job-search/types.ts +++ b/src/components/job-search/types.ts @@ -3,6 +3,7 @@ export enum JobFilterTypeID { KIND = "kind", PRIORITY = "priority", QUEUE = "queue", + TAGS = "tags", } export interface FilterType { @@ -39,4 +40,9 @@ export const AVAILABLE_FILTERS: FilterType[] = [ label: "queue", match: "queue:", }, + { + id: JobFilterTypeID.TAGS, + label: "tags", + match: "tags:", + }, ]; diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts index 356b9cd0..ab1b4064 100644 --- a/src/routes/jobs/index.schema.ts +++ b/src/routes/jobs/index.schema.ts @@ -49,4 +49,8 @@ export const jobSearchSchema = z.object({ .optional() .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), state: z.nativeEnum(JobState).default(defaultValues.state), + tags: z + .union([z.string(), z.array(z.string().min(1))]) + .optional() + .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), }); diff --git a/src/routes/jobs/index.test.tsx b/src/routes/jobs/index.test.tsx index 542b8483..46a827a0 100644 --- a/src/routes/jobs/index.test.tsx +++ b/src/routes/jobs/index.test.tsx @@ -1,6 +1,129 @@ +import { Filter, FilterTypeId } from "@components/job-search/JobSearch"; +import { JobsIndexComponent, Route } from "@routes/jobs/index"; import { jobSearchSchema } from "@routes/jobs/index.schema"; +import { listJobsKey } from "@services/jobs"; import { JobState } from "@services/types"; -import { describe, expect, it } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type JobListHarnessProps = { + cancelJobs: (jobIDs: bigint[]) => void; + deleteJobs: (jobIDs: bigint[]) => void; + initialFilters?: Filter[]; + onFiltersChange?: (filters: Filter[]) => void; + retryJobs: (jobIDs: bigint[]) => void; +}; + +type NavigateOptions = { + search: ( + old: Record, + ) => Record; +}; + +const { + mockCancelJobs, + mockCountsByState, + mockDeleteJobs, + mockJobList, + mockListJobs, + mockNavigate, + mockUseRetryJobs, +} = vi.hoisted(() => ({ + mockCancelJobs: vi.fn(), + mockCountsByState: vi.fn(), + mockDeleteJobs: vi.fn(), + mockJobList: vi.fn(), + mockListJobs: vi.fn(), + mockNavigate: vi.fn(), + mockUseRetryJobs: vi.fn(), +})); + +vi.mock("@components/JobList", () => ({ + default: (props: JobListHarnessProps) => { + mockJobList(props); + return null; + }, +})); + +vi.mock("@contexts/RefreshSettings.hook", () => ({ + useRefreshSetting: () => ({ intervalMs: 0 }), +})); + +vi.mock("@hooks/use-retry-jobs", () => ({ + useRetryJobs: (opts: { onSuccess: () => void }) => { + mockUseRetryJobs(opts); + return { mutate: (_jobIDs: bigint[]) => opts.onSuccess() }; + }, +})); + +vi.mock("@services/jobs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cancelJobs: mockCancelJobs, + deleteJobs: mockDeleteJobs, + listJobs: mockListJobs, + }; +}); + +vi.mock("@services/states", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countsByState: mockCountsByState, + }; +}); + +vi.mock("@services/toast", () => ({ + toastError: vi.fn(), +})); + +const loaderDeps = { + id: undefined, + kind: ["email"], + limit: 20, + priority: [1], + queue: ["default"], + state: JobState.Running, + tags: ["customer:123", "urgent"], +}; + +const activeJobsKey = listJobsKey({ + ids: loaderDeps.id, + kinds: loaderDeps.kind, + limit: loaderDeps.limit, + priorities: loaderDeps.priority, + queues: loaderDeps.queue, + state: loaderDeps.state, + tags: loaderDeps.tags, +}); + +const latestJobListProps = (): JobListHarnessProps => { + const props = mockJobList.mock.calls.at(-1)?.[0] as + JobListHarnessProps | undefined; + expect(props).toBeDefined(); + if (!props) throw new Error("JobList was not rendered"); + return props; +}; + +const renderJobsIndex = () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + + return { + queryClient, + ...render( + + + , + ), + }; +}; describe("Jobs Route Search Schema", () => { it("validates search parameters correctly", () => { @@ -55,4 +178,114 @@ describe("Jobs Route Search Schema", () => { // Test invalid limit type expect(() => jobSearchSchema.parse({ limit: "invalid" })).toThrow(); }); + + it("normalizes tag filters", () => { + expect(jobSearchSchema.parse({ tags: "urgent" })).toMatchObject({ + tags: ["urgent"], + }); + expect( + jobSearchSchema.parse({ tags: ["customer:123", "urgent"] }), + ).toMatchObject({ + tags: ["customer:123", "urgent"], + }); + }); +}); + +describe("JobsIndexComponent", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCancelJobs.mockResolvedValue(undefined); + mockCountsByState.mockResolvedValue({}); + mockDeleteJobs.mockResolvedValue(undefined); + mockListJobs.mockResolvedValue([]); + vi.spyOn(Route, "useLoaderDeps").mockReturnValue(loaderDeps); + vi.spyOn(Route, "useNavigate").mockReturnValue(mockNavigate); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("round trips tags between route search and job filters", async () => { + renderJobsIndex(); + + await waitFor(() => expect(mockListJobs).toHaveBeenCalled()); + expect(mockListJobs.mock.calls.at(-1)?.[0]).toMatchObject({ + queryKey: activeJobsKey, + }); + + const props = latestJobListProps(); + expect(props.initialFilters).toEqual( + expect.arrayContaining([ + { + id: "tags-filter", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: loaderDeps.tags, + }, + ]), + ); + + act(() => { + props.onFiltersChange?.([ + { + id: "replacement-tags", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: ["replacement"], + }, + ]); + }); + let navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({ + tags: ["replacement"], + }); + + act(() => props.onFiltersChange?.([])); + navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect( + navigateOpts?.search({ + state: JobState.Running, + tags: loaderDeps.tags, + }), + ).toEqual({ + id: undefined, + kind: undefined, + priority: undefined, + queue: undefined, + state: JobState.Running, + tags: undefined, + }); + }); + + it("refreshes the active tag-filtered query after mutations", async () => { + const { queryClient } = renderJobsIndex(); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + const removeQueries = vi.spyOn(queryClient, "removeQueries"); + + await waitFor(() => expect(mockListJobs).toHaveBeenCalled()); + const props = latestJobListProps(); + + act(() => props.cancelJobs([123n])); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: activeJobsKey, + }), + ); + + act(() => props.deleteJobs([123n])); + await waitFor(() => + expect(removeQueries).toHaveBeenCalledWith({ + queryKey: activeJobsKey, + }), + ); + + invalidateQueries.mockClear(); + act(() => props.retryJobs([123n])); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: activeJobsKey, + }); + }); }); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index 903ae4ef..69c4fe0f 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -50,14 +50,16 @@ export const Route = createFileRoute("/jobs/")({ search: { middlewares: [ stripSearchParams(defaultValues), - retainSearchParams(["id", "kind", "limit", "priority", "queue"]), + retainSearchParams(["id", "kind", "limit", "priority", "queue", "tags"]), ], }, beforeLoad: async ({ context }) => { // No need to check for search.state since it has a default value now return context; }, - loaderDeps: ({ search: { limit, state, kind, queue, priority, id } }) => { + loaderDeps: ({ + search: { limit, state, kind, queue, priority, id, tags }, + }) => { return { kind, limit: limit || defaultValues.limit, @@ -65,15 +67,16 @@ export const Route = createFileRoute("/jobs/")({ queue, state, id, + tags, }; }, loader: async ({ context: { queryClient }, - deps: { limit, state, kind, queue, id }, + deps: { limit, state, kind, queue, id, tags }, }) => { await Promise.all([ queryClient.ensureQueryData({ - ...jobsQueryOptions({ limit, state, kind, queue, id }), + ...jobsQueryOptions({ limit, state, kind, queue, id, tags }), }), queryClient.ensureQueryData(statesQueryOptions()), ]); @@ -82,9 +85,10 @@ export const Route = createFileRoute("/jobs/")({ component: JobsIndexComponent, }); -function JobsIndexComponent() { +export function JobsIndexComponent() { const navigate = Route.useNavigate(); - const { id, limit, state, kind, queue, priority } = Route.useLoaderDeps(); + const { id, limit, state, kind, queue, priority, tags } = + Route.useLoaderDeps(); const refreshSettings = useRefreshSetting(); const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const [pauseRefetches, setJobRefetchesPaused] = useState(false); @@ -99,6 +103,7 @@ function JobsIndexComponent() { kind, queue, priority, + tags, }, { pauseRefetches, @@ -125,6 +130,7 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }; @@ -139,6 +145,7 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }; @@ -151,6 +158,7 @@ function JobsIndexComponent() { priority: undefined, queue: undefined, id: undefined, + tags: undefined, }; // Only set values for filters that exist and have values @@ -174,6 +182,11 @@ function JobsIndexComponent() { ? filter.values : undefined; break; + case FilterTypeId.TAGS: + searchParams.tags = filter.values.length + ? filter.values + : undefined; + break; } }); @@ -182,6 +195,7 @@ function JobsIndexComponent() { kind, priority: priority?.map(String), queue, + tags, }; // Avoid no-op navigations that can race with route transitions. @@ -192,7 +206,8 @@ function JobsIndexComponent() { currentSearchParams.priority, searchParams.priority, ) && - areStringArraysEqual(currentSearchParams.queue, searchParams.queue) + areStringArraysEqual(currentSearchParams.queue, searchParams.queue) && + areStringArraysEqual(currentSearchParams.tags, searchParams.tags) ) { return; } @@ -208,10 +223,11 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }, - [id, kind, navigate, priority, queue], + [id, kind, navigate, priority, queue, tags], ); // Convert current search params to initial filters @@ -249,8 +265,16 @@ function JobsIndexComponent() { values: queue, }); } + if (tags?.length) { + filters.push({ + id: "tags-filter", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: tags, + }); + } return filters; - }, [id, kind, priority, queue]); + }, [id, kind, priority, queue, tags]); const cancelMutation = useMutation({ mutationFn: async (jobIDs: bigint[], context) => @@ -269,6 +293,7 @@ function JobsIndexComponent() { queues: queue, priorities: priority, ids: id, + tags, }), }); queryClient.invalidateQueries({ queryKey: countsByStateKey() }); @@ -285,7 +310,15 @@ function JobsIndexComponent() { duration: 2000, }); await queryClient.removeQueries({ - queryKey: listJobsKey({ limit, state }), + queryKey: listJobsKey({ + limit, + state, + kinds: kind, + queues: queue, + priorities: priority, + ids: id, + tags, + }), }); queryClient.invalidateQueries({ queryKey: countsByStateKey() }); }, @@ -294,7 +327,15 @@ function JobsIndexComponent() { const retryMutation = useRetryJobs({ onSuccess: () => { queryClient.invalidateQueries({ - queryKey: listJobsKey({ limit, state }), + queryKey: listJobsKey({ + limit, + state, + kinds: kind, + queues: queue, + priorities: priority, + ids: id, + tags, + }), }); queryClient.invalidateQueries({ queryKey: countsByStateKey() }); }, @@ -329,6 +370,7 @@ const jobsQueryOptions = ( kind, queue, priority, + tags, }: { id?: bigint[]; kind?: string[]; @@ -336,6 +378,7 @@ const jobsQueryOptions = ( priority?: number[]; queue?: string[]; state: JobState; + tags?: string[]; }, opts?: { pauseRefetches: boolean; refreshOptions: RefreshQueryOptions }, ) => { @@ -358,6 +401,7 @@ const jobsQueryOptions = ( queues: queue, priorities: priority, ids: id, + tags, }), queryFn: listJobs, placeholderData: keepPreviousDataUnlessStateChanged, diff --git a/src/services/jobs.test.ts b/src/services/jobs.test.ts index e57f0887..2c007745 100644 --- a/src/services/jobs.test.ts +++ b/src/services/jobs.test.ts @@ -73,6 +73,35 @@ describe("jobs service", () => { expect(jobs[0]?.argsRaw).toBe('{"id":1970670598291982290}'); }); + it("serializes tag filters as repeated query parameters", async () => { + document.body.innerHTML = + ''; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + + await listJobs({ + client: undefined as never, + meta: undefined, + queryKey: listJobsKey({ + limit: 10, + tags: ["customer:123", "urgent"], + }), + signal: new AbortController().signal, + }); + + const requestURL = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(requestURL.pathname).toBe("/api/jobs"); + expect(requestURL.searchParams.getAll("tags")).toEqual([ + "customer:123", + "urgent", + ]); + }); + it("preserves job detail args as raw JSON text", async () => { document.body.innerHTML = ''; diff --git a/src/services/jobs.ts b/src/services/jobs.ts index aae08b5c..487620e1 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -191,6 +191,7 @@ export type ListJobsKey = [ priorities: number[] | undefined; queues: string[] | undefined; state: JobState | undefined; + tags: string[] | undefined; }, ]; @@ -201,6 +202,7 @@ type ListJobsFilters = { priorities?: number[]; queues?: string[]; state?: JobState; + tags?: string[]; }; export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { @@ -213,6 +215,7 @@ export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { priorities: args.priorities, queues: args.queues, state: args.state, + tags: args.tags, }, ]; }; @@ -221,7 +224,7 @@ export const listJobs: QueryFunction = async ({ queryKey, signal, }) => { - const [, { ids, kinds, limit, priorities, queues, state }] = queryKey; + const [, { ids, kinds, limit, priorities, queues, state, tags }] = queryKey; // Build query params object with only defined values const params: Record = { @@ -232,6 +235,7 @@ export const listJobs: QueryFunction = async ({ if (priorities?.length) params.priorities = priorities.map(String); if (queues?.length) params.queues = queues; if (state) params.state = state; + if (tags?.length) params.tags = tags; // Convert to URLSearchParams, handling arrays correctly const query = new URLSearchParams();