Merge remote-tracking branch 'origin/feat/ab/wo-ids-filter' into feat/ab/sh-313-completion-templates-page

This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:08:26 -03:00
commit 0a64f2dec7
9 changed files with 207 additions and 0 deletions

View file

@ -46,6 +46,8 @@ export interface WorkOrderAdvancedFilters {
internalOnly: boolean; internalOnly: boolean;
hasUplift: boolean; hasUplift: boolean;
upliftStatuses: Set<UpliftFilterStatus>; upliftStatuses: Set<UpliftFilterStatus>;
/** Exact work orders to show; when non-empty the server ignores every other filter. */
workOrderIds: Set<number>;
} }
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
@ -70,6 +72,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
internalOnly: false, internalOnly: false,
hasUplift: false, hasUplift: false,
upliftStatuses: new Set(), upliftStatuses: new Set(),
workOrderIds: new Set(),
}; };
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [

View file

@ -326,6 +326,8 @@ export interface WorkOrderBoardSearchQueryParams {
hasUplift?: boolean; hasUplift?: boolean;
upliftStatuses?: string[]; upliftStatuses?: string[];
myWorkOrders?: boolean; myWorkOrders?: boolean;
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
ids?: string;
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
sortDir?: "asc" | "desc" | string; sortDir?: "asc" | "desc" | string;
} }

View file

@ -306,3 +306,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
clear: clearUplift, 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<number>() }),
});
}

View file

@ -12,6 +12,7 @@ import {
appendSeverityChip, appendSeverityChip,
appendStatusPmVendorDocChips, appendStatusPmVendorDocChips,
appendUpliftChip, appendUpliftChip,
appendWorkOrderIdsChip,
type AdvancedFilterChip, type AdvancedFilterChip,
} from "@/domain/work-orders/utils/advanced-filter-chip-builders"; } from "@/domain/work-orders/utils/advanced-filter-chip-builders";
import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; 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), docs: new Set(filters.docs),
flagColors: new Set(filters.flagColors ?? []), flagColors: new Set(filters.flagColors ?? []),
upliftStatuses: new Set(filters.upliftStatuses ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []),
workOrderIds: new Set(filters.workOrderIds ?? []),
unscheduledOnly: Boolean(filters.unscheduledOnly), unscheduledOnly: Boolean(filters.unscheduledOnly),
}; };
} }
@ -56,6 +58,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
filters.internalOnly, filters.internalOnly,
filters.hasUplift, filters.hasUplift,
filters.unscheduledOnly, filters.unscheduledOnly,
Boolean(filters.workOrderIds?.size),
]; ];
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
@ -120,6 +123,7 @@ export function buildAdvancedFilterChips(
appendIndicatorChips(chips, filters); appendIndicatorChips(chips, filters);
appendFlagAndInternalChips(chips, filters); appendFlagAndInternalChips(chips, filters);
appendUpliftChip(chips, filters); appendUpliftChip(chips, filters);
appendWorkOrderIdsChip(chips, filters);
return chips; return chips;
} }

View file

@ -133,6 +133,11 @@ function mapSearchFacetParams(
}; };
} }
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
return ids?.size ? [...ids].join(",") : undefined;
}
/** Build GET /board query from weekly filter bar. */ /** Build GET /board query from weekly filter bar. */
export function toBoardQueryParams( export function toBoardQueryParams(
bar: WorkOrderBarFilters, bar: WorkOrderBarFilters,
@ -226,6 +231,7 @@ export function toBoardSearchQueryParams(input: {
docStatuses: mapDocStatuses(filters.docs), docStatuses: mapDocStatuses(filters.docs),
unscheduledOnly: filters.unscheduledOnly || undefined, unscheduledOnly: filters.unscheduledOnly || undefined,
...mapSearchFacetParams(filters), ...mapSearchFacetParams(filters),
ids: mapWorkOrderIds(filters.workOrderIds),
sortBy: input.sortBy || "scheduledDate", sortBy: input.sortBy || "scheduledDate",
sortDir: input.sortDir === "desc" ? "desc" : "asc", sortDir: input.sortDir === "desc" ? "desc" : "asc",
}; };

View file

@ -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 { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter";
import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; 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}$/; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@ -63,6 +64,12 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
export function parseDashboardDrilldownParams( export function parseDashboardDrilldownParams(
params: URLSearchParams, params: URLSearchParams,
): WorkOrderAdvancedFilters | null { ): 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 dateFrom = (params.get("dateFrom") ?? "").trim();
const dateTo = (params.get("dateTo") ?? "").trim(); const dateTo = (params.get("dateTo") ?? "").trim();
const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo);

View file

@ -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 | string>): number[] {
const ids = new Set<number>();
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";
}

View file

@ -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 (
<>
<output aria-label="Board search params">
{params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))}
</output>
<output aria-label="Current search">{location.search}</output>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={2}
sites={[]}
users={[]}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
)}
</>
);
}
function searchParams(): Record<string, unknown> | "weekly board" {
const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? "";
return text === "weekly board" ? text : (JSON.parse(text) as Record<string, unknown>);
}
describe("Work Orders exact id filter", () => {
it("opens the board on exactly the linked work orders and shows the selection chip", async () => {
renderWithProviders(<BoardFilterHarness />, {
route: "/workorders?ids=4,9,4",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
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(<BoardFilterHarness />, {
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(<BoardFilterHarness />, {
route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
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(<BoardFilterHarness />, {
route: "/workorders?statuses=Scheduled",
withAuth: false,
});
await waitFor(() => expect(searchParams()).not.toBe("weekly board"));
const params = searchParams() as Record<string, unknown>;
expect(params.ids).toBeUndefined();
expect(params.statuses).toEqual([expect.any(Number)]);
});
});

View file

@ -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);
});
});