mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Merge remote-tracking branch 'origin/feat/ab/wo-ids-filter' into feat/ab/sh-313-completion-templates-page
This commit is contained in:
commit
0a64f2dec7
9 changed files with 207 additions and 0 deletions
|
|
@ -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 }[] = [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
26
src/domain/work-orders/utils/work-order-ids-filter.ts
Normal file
26
src/domain/work-orders/utils/work-order-ids-filter.ts
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -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)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue