From e052dcb37a4af7f0ab2aa5069553d9db390ede39 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:06:09 -0300 Subject: [PATCH 1/2] feat(workorders): filter board to an exact work-order id set /workorders?ids=101,205,318 opens the board on exactly those work orders through board/search's ids filter. The id set replaces every other drill-down filter, a chip reads "Showing N selected work orders" and clearing it returns to the weekly board. buildWorkOrderIdsHref builds the link for alerts and linked-work-order lists. --- .../types/work-order-advanced-filters.ts | 3 + .../work-orders/types/work-order-board.ts | 2 + .../utils/advanced-filter-chip-builders.ts | 14 +++ .../utils/advanced-filters-utils.ts | 4 + .../work-orders/utils/board-query-params.ts | 6 + .../utils/dashboard-drilldown-params.ts | 7 ++ .../utils/work-order-ids-filter.ts | 26 ++++ .../work-order-ids-drilldown.test.tsx | 112 ++++++++++++++++++ .../utils/work-order-ids-filter.test.ts | 33 ++++++ 9 files changed, 207 insertions(+) create mode 100644 src/domain/work-orders/utils/work-order-ids-filter.ts create mode 100644 src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx create mode 100644 src/test/domain/work-orders/utils/work-order-ids-filter.test.ts diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index 66f831dc..e440da76 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -46,6 +46,8 @@ export interface WorkOrderAdvancedFilters { internalOnly: boolean; hasUplift: boolean; upliftStatuses: Set; + /** Exact work orders to show; when non-empty the server ignores every other filter. */ + workOrderIds: Set; } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { @@ -70,6 +72,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { internalOnly: false, hasUplift: false, upliftStatuses: new Set(), + workOrderIds: new Set(), }; export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 317e7cad..978ef988 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -326,6 +326,8 @@ export interface WorkOrderBoardSearchQueryParams { hasUplift?: boolean; upliftStatuses?: string[]; myWorkOrders?: boolean; + /** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */ + ids?: string; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortDir?: "asc" | "desc" | string; } diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index c7222b82..f1c43846 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -306,3 +306,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder clear: clearUplift, }); } + +export function appendWorkOrderIdsChip( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + const count = filters.workOrderIds?.size ?? 0; + if (count === 0) return; + chips.push({ + key: "workOrderIds", + group: "Work orders", + value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`, + clear: (current) => ({ ...current, workOrderIds: new Set() }), + }); +} diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 871bd0cd..9bcf6633 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -12,6 +12,7 @@ import { appendSeverityChip, appendStatusPmVendorDocChips, appendUpliftChip, + appendWorkOrderIdsChip, type AdvancedFilterChip, } from "@/domain/work-orders/utils/advanced-filter-chip-builders"; import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -33,6 +34,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte docs: new Set(filters.docs), flagColors: new Set(filters.flagColors ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []), + workOrderIds: new Set(filters.workOrderIds ?? []), unscheduledOnly: Boolean(filters.unscheduledOnly), }; } @@ -56,6 +58,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool filters.internalOnly, filters.hasUplift, filters.unscheduledOnly, + Boolean(filters.workOrderIds?.size), ]; export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { @@ -120,6 +123,7 @@ export function buildAdvancedFilterChips( appendIndicatorChips(chips, filters); appendFlagAndInternalChips(chips, filters); appendUpliftChip(chips, filters); + appendWorkOrderIdsChip(chips, filters); return chips; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 998d0605..27ef48c8 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -133,6 +133,11 @@ function mapSearchFacetParams( }; } +/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ +function mapWorkOrderIds(ids: Set | undefined): string | undefined { + return ids?.size ? [...ids].join(",") : undefined; +} + /** Build GET /board query from weekly filter bar. */ export function toBoardQueryParams( bar: WorkOrderBarFilters, @@ -226,6 +231,7 @@ export function toBoardSearchQueryParams(input: { docStatuses: mapDocStatuses(filters.docs), unscheduledOnly: filters.unscheduledOnly || undefined, ...mapSearchFacetParams(filters), + ids: mapWorkOrderIds(filters.workOrderIds), sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", }; diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index bb868e51..2334b9b6 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter"; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -63,6 +64,12 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { export function parseDashboardDrilldownParams( params: URLSearchParams, ): WorkOrderAdvancedFilters | null { + // An exact id set replaces every other filter: nothing else may hide one of those work orders. + const workOrderIds = parseWorkOrderIdsParam(params.get("ids")); + if (workOrderIds.length > 0) { + return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) }; + } + const dateFrom = (params.get("dateFrom") ?? "").trim(); const dateTo = (params.get("dateTo") ?? "").trim(); const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); diff --git a/src/domain/work-orders/utils/work-order-ids-filter.ts b/src/domain/work-orders/utils/work-order-ids-filter.ts new file mode 100644 index 00000000..fc60a7b3 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-ids-filter.ts @@ -0,0 +1,26 @@ +/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */ +export const WORK_ORDER_IDS_MAX = 200; + +/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */ +export function normalizeWorkOrderIds(values: Iterable): number[] { + const ids = new Set(); + for (const value of values) { + const text = typeof value === "number" ? String(value) : value.trim(); + if (!/^\d+$/.test(text)) continue; + const id = Number(text); + if (Number.isSafeInteger(id) && id > 0) ids.add(id); + if (ids.size === WORK_ORDER_IDS_MAX) break; + } + return [...ids]; +} + +/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */ +export function parseWorkOrderIdsParam(raw: string | null): number[] { + return raw ? normalizeWorkOrderIds(raw.split(",")) : []; +} + +/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */ +export function buildWorkOrderIdsHref(ids: number[]): string { + const normalized = normalizeWorkOrderIds(ids); + return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders"; +} diff --git a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx new file mode 100644 index 00000000..24ae3255 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -0,0 +1,112 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { describe, expect, it } from "vitest"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; +import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; +import { renderWithProviders } from "@/test/test-utils"; + +/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */ +function BoardFilterHarness() { + const filters = useWorkOrdersListFilters(); + useDashboardDrilldownFilters(filters.applyAdvancedFilters); + const location = useLocation(); + const params = buildResolvedBoardSearchParams({ + advApplied: filters.advApplied, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + return ( + <> + + {params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))} + + {location.search} + {filters.advApplied != null && ( + + )} + + ); +} + +function searchParams(): Record | "weekly board" { + const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? ""; + return text === "weekly board" ? text : (JSON.parse(text) as Record); +} + +describe("Work Orders exact id filter", () => { + it("opens the board on exactly the linked work orders and shows the selection chip", async () => { + renderWithProviders(, { + route: "/workorders?ids=4,9,4", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("4,9"); + // No saved or default facet travels with the id set. + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + expect(params.types).toBeUndefined(); + + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + // The applied link params are consumed, like the dashboard drill-downs. + expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/); + }); + + it("returns to the weekly board when the selection chip is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: buildWorkOrderIdsHref([12]), + withAuth: false, + }); + + expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove filter" })); + + expect(searchParams()).toBe("weekly board"); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("ignores other drill-down params sent alongside the id set", async () => { + renderWithProviders(, { + route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("5"); + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + }); + + it("keeps the regular drill-down filters when no ids are given", async () => { + renderWithProviders(, { + route: "/workorders?statuses=Scheduled", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.statuses).toEqual([expect.any(Number)]); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts new file mode 100644 index 00000000..f69f87ae --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + WORK_ORDER_IDS_MAX, + buildWorkOrderIdsHref, + normalizeWorkOrderIds, + parseWorkOrderIdsParam, +} from "@/domain/work-orders/utils/work-order-ids-filter"; + +describe("work-order ids filter", () => { + it("builds the Work Orders link for an exact id set", () => { + expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318"); + }); + + it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => { + expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101"); + expect(buildWorkOrderIdsHref([])).toBe("/workorders"); + }); + + it("reads the ids param, ignoring malformed tokens", () => { + expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]); + expect(parseWorkOrderIdsParam(null)).toEqual([]); + expect(parseWorkOrderIdsParam("")).toEqual([]); + }); + + it("keeps at most the backend limit of distinct ids", () => { + const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1); + const normalized = normalizeWorkOrderIds([...ids, 1, 2]); + + expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX); + expect(normalized[0]).toBe(1); + expect(normalized.at(-1)).toBe(WORK_ORDER_IDS_MAX); + }); +}); From 5f8218b09a673c53717ef89af223f49fe9f3156a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:11:09 -0300 Subject: [PATCH 2/2] test(workorders): avoid Array.prototype.at outside the ES2020 lib --- src/test/domain/work-orders/utils/work-order-ids-filter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts index f69f87ae..638f9609 100644 --- a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts +++ b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts @@ -28,6 +28,6 @@ describe("work-order ids filter", () => { expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX); expect(normalized[0]).toBe(1); - expect(normalized.at(-1)).toBe(WORK_ORDER_IDS_MAX); + expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX); }); });