diff --git a/e2e/work-orders/type-filter.spec.ts b/e2e/work-orders/type-filter.spec.ts new file mode 100644 index 00000000..8c0cef3f --- /dev/null +++ b/e2e/work-orders/type-filter.spec.ts @@ -0,0 +1,93 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-type-filter-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function boardRow(id: number, woNumber: string, scheduledDate: string | null) { + return { + id, + woNumber, + workOrderType: 2, + siteCode: woNumber, + lifecycleStatusLabel: "Scheduled", + scheduledDate, + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", + }; +} + +async function mockWorkOrderBoard(page: Page, boardUrls: string[]) { + await page.route("**/api/**", (route) => { + const pathname = new URL(route.request().url()).pathname; + if (!pathname.startsWith("/api/")) { + return route.fallback(); + } + if (pathname.includes("/workorders/board")) { + return route.fallback(); + } + return fulfillJson(route, { data: [] }); + }); + await page.route("**/api/workorders/board*", (route) => { + if (route.request().url().includes("board/search")) { + return route.fallback(); + } + boardUrls.push(route.request().url()); + return fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [boardRow(1, "TYPE-WO", "2026-08-19")], + }); + }); + await page.route("**/api/workorders/board/search*", (route) => + fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }), + ); +} + +test.describe("Work Orders type filter", () => { + test("sends union types on the board request and omits them when cleared", async ({ page }) => { + const boardUrls: string[] = []; + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript( + (user) => localStorage.setItem("auth", JSON.stringify(user)), + TEST_USER, + ); + await mockWorkOrderBoard(page, boardUrls); + + await page.goto("/workorders"); + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 15_000, + }); + const trigger = page.getByTestId("wo-type-filter-trigger"); + await expect(trigger).toBeVisible(); + await trigger.click(); + const typeMenu = page.getByTestId("wo-type-filter-menu"); + await expect(typeMenu.getByRole("menuitemcheckbox", { name: "PM" })).toBeVisible(); + await typeMenu.getByRole("menuitemcheckbox", { name: "PM" }).click(); + await typeMenu.getByRole("menuitemcheckbox", { name: "Emergency" }).click(); + await expect(trigger).toContainText("2 types selected"); + + await expect + .poll(() => boardUrls.some((url) => url.includes("types=2") && url.includes("types=3"))) + .toBe(true); + + await typeMenu.getByRole("menuitemcheckbox", { name: "All types" }).click(); + await expect(trigger).toContainText("All types"); + }); +}); diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx new file mode 100644 index 00000000..bd9c66c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx @@ -0,0 +1,124 @@ +import { useMemo } from "react"; +import { Menu, MenuItem } from "@mui/material"; +import { ChevronDown, Tag } from "lucide-react"; +import { CheckboxIndicator } from "@/components/ui/checkbox-indicator"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; +import { cn } from "@/lib/utils"; + +type WoTypeFilterProps = { + selected: Set; + onChange: (types: Set) => void; + disabled?: boolean; +}; + +const TYPE_FILTER_MENU_LIST_PROPS = { + "aria-label": "Work order types", + "data-testid": "wo-type-filter-menu", +}; + +function typeFilterLabel(selected: Set): string { + if (selected.size === 0) return "All types"; + if (selected.size === 1) { + const [only] = selected; + return only ?? "All types"; + } + return `${selected.size} types selected`; +} + +function TypeFilterMenuItem({ + checked, + label, + onClick, +}: { + checked: boolean; + label: string; + onClick: () => void; +}) { + return ( + + + {label} + + ); +} + +export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFilterProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + const allSelected = selected.size === 0; + const label = useMemo(() => typeFilterLabel(selected), [selected]); + + const toggle = (type: WOType) => { + const next = new Set(selected); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + onChange(next); + }; + + return ( + <> + + + {label} + + + + { + if (reason === "backdropClick" || reason === "escapeKeyDown" || reason === "tabKeyDown") { + close(); + } + }} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + slotProps={{ + paper: { + className: "mt-1 w-[180px] rounded-lg border border-border p-1.5 shadow-md", + }, + list: TYPE_FILTER_MENU_LIST_PROPS, + }} + > + onChange(new Set())} + /> + {WO_TYPES.map((type) => ( + toggle(type)} + /> + ))} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx deleted file mode 100644 index 03d89520..00000000 --- a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control"; - -import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; - -import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; - -type WoTypeSegmentProps = { - value: WorkOrderTypeFilter; - - onChange: (value: WorkOrderTypeFilter) => void; - - disabled?: boolean; -}; - -const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES]; - -export function WoTypeSegment({ value, onChange, disabled = false }: WoTypeSegmentProps) { - return ( - - {OPTIONS.map((option) => ( - onChange(option)} - > - {option} - - ))} - - ); -} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx index db264bb6..e43ccad3 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx @@ -1,7 +1,7 @@ -import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs"; import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector"; -import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment"; +import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter"; import { Text } from "@/components/ui/text"; type WorkOrdersFilterBarProps = { @@ -9,8 +9,8 @@ type WorkOrdersFilterBarProps = { onWeekChange: (mondayIso: string) => void; selectedDayIso: string; onSelectedDayChange: (dayIso: string) => void; - typeFilter: WorkOrderTypeFilter; - onTypeChange: (value: WorkOrderTypeFilter) => void; + types: Set; + onTypesChange: (value: Set) => void; filteredCount: number; scopeTotalCount: number; disabled?: boolean; @@ -21,8 +21,8 @@ export function WorkOrdersFilterBar({ onWeekChange, selectedDayIso, onSelectedDayChange, - typeFilter, - onTypeChange, + types, + onTypesChange, filteredCount, scopeTotalCount, disabled = false, @@ -34,9 +34,12 @@ export function WorkOrdersFilterBar({ className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)" aria-disabled={disabled} > -
-
- +
+ +
{resultLabel} -
-
- +
); diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts index 581d1b0f..25e9ccf7 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts @@ -1,10 +1,8 @@ import { useMemo, useState } from "react"; import { useDebounce } from "@/hooks/use-debounce"; import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; -import type { - WorkOrderBarFilters, - WorkOrderTypeFilter, -} from "@/domain/work-orders/types/work-order-list-filters"; +import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board"; import { clearAdvancedFilterGroup, @@ -37,7 +35,7 @@ export function useWorkOrdersListFilters() { initialSelectedDay(currentWeekMonday()), ); const [dispatcherIds, setDispatcherIdsState] = useState>(() => new Set()); - const [typeFilter, setTypeFilterState] = useState("All"); + const [types, setTypesState] = useState>(() => new Set()); const debouncedSearch = useDebounce(search, 300); @@ -45,9 +43,9 @@ export function useWorkOrdersListFilters() { () => ({ weekMonday, dispatcherIds, - type: typeFilter, + types, }), - [weekMonday, dispatcherIds, typeFilter], + [weekMonday, dispatcherIds, types], ); const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]); @@ -85,8 +83,8 @@ export function useWorkOrdersListFilters() { setPage(0); }; - const setTypeFilter = (value: WorkOrderTypeFilter) => { - setTypeFilterState(value); + const setTypes = (value: Set) => { + setTypesState(new Set(value)); setPage(0); }; @@ -148,7 +146,7 @@ export function useWorkOrdersListFilters() { selectedDayIso, weekDays, dispatcherIds, - typeFilter, + types, barFilters, weekLabel, boardParams, @@ -166,7 +164,7 @@ export function useWorkOrdersListFilters() { setSelectedDayIso, navigateToDay, setDispatcherIds, - setTypeFilter, + setTypes, setSortBy, setSortDir, }; diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 5707a4ef..a48da809 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -58,8 +58,8 @@ export default function WorkOrdersListPage() { onWeekChange={filters.setWeekMonday} selectedDayIso={filters.selectedDayIso} onSelectedDayChange={filters.setSelectedDayIso} - typeFilter={filters.typeFilter} - onTypeChange={filters.setTypeFilter} + types={filters.types} + onTypesChange={filters.setTypes} filteredCount={tableData.totalCount} scopeTotalCount={tableData.scopeTotalCount} disabled={filters.advApplied != null} diff --git a/src/domain/work-orders/types/work-order-list-filters.ts b/src/domain/work-orders/types/work-order-list-filters.ts index aac7217a..b20db35d 100644 --- a/src/domain/work-orders/types/work-order-list-filters.ts +++ b/src/domain/work-orders/types/work-order-list-filters.ts @@ -1,9 +1,7 @@ import type { WOType } from "./work-order-wizard"; -export type WorkOrderTypeFilter = WOType | "All"; - export interface WorkOrderBarFilters { weekMonday: string; dispatcherIds: Set; - type: WorkOrderTypeFilter; + types: Set; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 8c1e403e..4f8b771c 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -140,7 +140,7 @@ export function toBoardQueryParams( search: string, ): WorkOrderBoardQueryParams { const [, weekTo] = weekRange(bar.weekMonday); - const typeFilters = bar.type !== "All" ? mapWoTypeFilters([bar.type]) : {}; + const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {}; return { weekStart: bar.weekMonday, diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 0bf9879c..dcda43c5 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -1,6 +1,7 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderDayGroup, WorkOrderTableRow, @@ -59,6 +60,15 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { return haystack.includes(q); } +function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set): boolean { + if (types.size === 0) { + return true; + } + const overdueMatch = types.has("Overdue") && row.isPastDue; + const typeMatch = types.has(row.type as WOType); + return overdueMatch || typeMatch; +} + export function filterByBarAndSearch( rows: WorkOrderTableRow[], bar: WorkOrderBarFilters, @@ -71,11 +81,7 @@ export function filterByBarAndSearch( return false; } - if (bar.type === "Overdue") { - if (!row.isPastDue) { - return false; - } - } else if (bar.type !== "All" && row.type !== bar.type) { + if (!rowMatchesBarTypes(row, bar.types)) { return false; } diff --git a/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx b/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx index d186543a..ee92488d 100644 --- a/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx +++ b/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx @@ -122,4 +122,28 @@ describe("DispatcherFilter (SH-242)", () => { await user.click(within(menu).getByRole("button", { name: "All dispatchers" })); expect(onChange).toHaveBeenLastCalledWith(new Set()); }); + + it("does not put aria-checked on ordinary dispatcher buttons", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + { withAuth: false }, + ); + + await user.click(screen.getByRole("button", { name: "All dispatchers" })); + const popover = screen.getByRole("presentation"); + const items = [ + within(popover).getByRole("button", { name: "All dispatchers" }), + within(popover).getByRole("button", { name: "Unassigned" }), + within(popover).getByRole("button", { name: "Blair Cho" }), + ]; + for (const item of items) { + expect(item).not.toHaveAttribute("aria-checked"); + } + }); }); diff --git a/src/test/app/(protected)/workorders/week-day-nav.test.tsx b/src/test/app/(protected)/workorders/week-day-nav.test.tsx index 935df45f..17e66cdd 100644 --- a/src/test/app/(protected)/workorders/week-day-nav.test.tsx +++ b/src/test/app/(protected)/workorders/week-day-nav.test.tsx @@ -6,6 +6,7 @@ import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-ba import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector"; import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import { firstWeekday } from "@/domain/work-orders/utils/week-day-nav-utils"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; vi.mock("@/domain/work-orders/utils/wizard-date-utils", async (importOriginal) => { const actual = @@ -110,4 +111,22 @@ describe("useWorkOrdersListFilters day state (SH-240)", () => { expect(result.current.weekMonday).toBe("2026-05-18"); expect(result.current.selectedDayIso).toBe("2026-05-20"); }); + + it("maps selected types onto board params and resets the page", () => { + const { result } = renderHook(() => useWorkOrdersListFilters()); + + act(() => { + result.current.setPage(2); + }); + act(() => { + result.current.setTypes(new Set(["PM", "Emergency"])); + }); + + expect(result.current.page).toBe(0); + expect(result.current.boardParams.types).toEqual([ + WORK_ORDER_TYPE_API.PM, + WORK_ORDER_TYPE_API.Emergency, + ]); + expect(result.current.boardParams.overdue).toBeUndefined(); + }); }); diff --git a/src/test/app/(protected)/workorders/wo-type-filter.test.tsx b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx new file mode 100644 index 00000000..82fb7a2f --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx @@ -0,0 +1,220 @@ +import { useState } from "react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { renderWithProviders } from "@/test/test-utils"; +import { screen, waitFor, within } from "@testing-library/react"; + +function TypeFilterHarness({ + initial = new Set(), + disabled = false, +}: { + initial?: Set; + disabled?: boolean; +}) { + const [selected, setSelected] = useState(initial); + return ; +} + +async function openTypeMenu( + user: ReturnType, + triggerName: string | RegExp, +) { + await user.click(screen.getByRole("button", { name: triggerName })); + return screen.getByRole("menu", { name: "Work order types" }); +} + +describe("WoTypeFilter", () => { + it("exposes a menu trigger, not a listbox", () => { + renderWithProviders(, { withAuth: false }); + + const trigger = screen.getByRole("button", { name: /all types/i }); + expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + expect(trigger).not.toHaveAttribute("aria-haspopup", "listbox"); + }); + + it("shows All types on the trigger and lists the four types in the menu", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("button", { name: /all types/i })).toBeEnabled(); + const menu = await openTypeMenu(user, /all types/i); + + expect(menu).toHaveAttribute("data-testid", "wo-type-filter-menu"); + expect(within(menu).getByRole("menuitemcheckbox", { name: "PM" })).toBeInTheDocument(); + expect(within(menu).getByRole("menuitemcheckbox", { name: "Reactive" })).toBeInTheDocument(); + expect(within(menu).getByRole("menuitemcheckbox", { name: "Emergency" })).toBeInTheDocument(); + expect(within(menu).getByRole("menuitemcheckbox", { name: "Overdue" })).toBeInTheDocument(); + }); + + it("exposes selected and unselected type state on the menu", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + expect(within(menu).getByRole("menuitemcheckbox", { name: "All types" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(within(menu).getByRole("menuitemcheckbox", { name: "PM" })).toHaveAttribute( + "aria-checked", + "false", + ); + + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "PM" })); + expect(within(menu).getByRole("menuitemcheckbox", { name: "All types" })).toHaveAttribute( + "aria-checked", + "false", + ); + expect(within(menu).getByRole("menuitemcheckbox", { name: "PM" })).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("labels a single type and multiple types on the trigger", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "PM" })); + expect( + screen.getByRole("button", { name: /work order types, pm/i, hidden: true, expanded: true }), + ).toBeInTheDocument(); + + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "Emergency" })); + expect( + screen.getByRole("button", { + name: /2 types selected/i, + hidden: true, + expanded: true, + }), + ).toBeInTheDocument(); + }); + + it("clears the selection when All types is chosen", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("button", { name: /work order types, pm/i })).toBeInTheDocument(); + const menu = await openTypeMenu(user, /work order types, pm/i); + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "All types" })); + expect( + screen.getByRole("button", { + name: /work order types, all types/i, + hidden: true, + expanded: true, + }), + ).toBeInTheDocument(); + }); + + it("returns to All types after deselecting the last type", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /work order types, pm/i); + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "PM" })); + expect( + screen.getByRole("button", { + name: /work order types, all types/i, + hidden: true, + expanded: true, + }), + ).toBeInTheDocument(); + }); + + it("does not open when disabled", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const trigger = screen.getByRole("button", { name: /all types/i }); + expect(trigger).toBeDisabled(); + await user.click(trigger); + expect(screen.queryByRole("menu", { name: "Work order types" })).not.toBeInTheDocument(); + expect(screen.queryByRole("menuitemcheckbox", { name: "PM" })).not.toBeInTheDocument(); + }); + + it("lists All types first, then the four WO types", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + const labels = within(menu) + .getAllByRole("menuitemcheckbox") + .map((item) => item.textContent?.trim()); + expect(labels).toEqual(["All types", "PM", "Reactive", "Emergency", "Overdue"]); + }); + + it("moves focus into the menu when opened", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + await waitFor(() => { + expect(within(menu).getByRole("menuitemcheckbox", { name: "All types" })).toHaveFocus(); + }); + }); + + it("navigates with Arrow, Home, and End keys", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + const allTypes = within(menu).getByRole("menuitemcheckbox", { name: "All types" }); + const pm = within(menu).getByRole("menuitemcheckbox", { name: "PM" }); + const overdue = within(menu).getByRole("menuitemcheckbox", { name: "Overdue" }); + + await waitFor(() => { + expect(allTypes).toHaveFocus(); + }); + await user.keyboard("{ArrowDown}"); + expect(pm).toHaveFocus(); + await user.keyboard("{End}"); + expect(overdue).toHaveFocus(); + await user.keyboard("{Home}"); + expect(allTypes).toHaveFocus(); + await user.keyboard("{ArrowUp}"); + expect(overdue).toHaveFocus(); + }); + + it("keeps inactive items out of the Tab sequence", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + const items = within(menu).getAllByRole("menuitemcheckbox"); + for (const item of items) { + expect(item).toHaveAttribute("tabindex", "-1"); + } + }); + + it("toggles the focused type from the keyboard without closing the menu", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + await waitFor(() => { + expect(within(menu).getByRole("menuitemcheckbox", { name: "All types" })).toHaveFocus(); + }); + await user.keyboard("{ArrowDown}"); + await user.keyboard(" "); + expect(within(menu).getByRole("menuitemcheckbox", { name: "PM" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(screen.getByRole("menu", { name: "Work order types" })).toBeInTheDocument(); + }); + + it("closes the menu on Tab instead of walking each item", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await openTypeMenu(user, /all types/i); + await waitFor(() => { + expect(screen.getByRole("menuitemcheckbox", { name: "All types" })).toHaveFocus(); + }); + await user.keyboard("{Tab}"); + expect(screen.queryByRole("menu", { name: "Work order types" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 5e604ee8..80ffa03a 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -54,8 +54,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = navigateToDay: vi.fn(), dispatcherIds: new Set(), setDispatcherIds: vi.fn(), - typeFilter: "All", - setTypeFilter: vi.fn(), + types: new Set(), + setTypes: vi.fn(), }), })); @@ -324,8 +324,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Week 7/20–7/24" })).toBeDisabled(); expect(screen.getByRole("tab", { name: "Mon · 07/20" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "All" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "PM" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeDisabled(); expect(screen.getByText("42 work orders")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /clear all/i })); @@ -335,7 +334,6 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(screen.queryByText("42 work orders")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled(); - expect(screen.getByRole("button", { name: "All" })).toBeEnabled(); - expect(screen.getByRole("button", { name: "PM" })).toBeEnabled(); + expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled(); }); }); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index 42074864..e8f8ce8e 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -50,8 +50,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = navigateToDay: vi.fn(), dispatcherIds: new Set(), setDispatcherIds: vi.fn(), - typeFilter: "All", - setTypeFilter: vi.fn(), + types: new Set(), + setTypes: vi.fn(), }), })); diff --git a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx index 2ab48cfe..1724e2d9 100644 --- a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx @@ -35,8 +35,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = navigateToDay: vi.fn(), dispatcherIds: new Set(), setDispatcherIds: vi.fn(), - typeFilter: "All", - setTypeFilter: vi.fn(), + types: new Set(), + setTypes: vi.fn(), }), })); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index f2e8238c..45d28cab 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -6,6 +6,7 @@ import { LIFECYCLE_STATUS_API, WORK_ORDER_TYPE_API, } from "@/domain/work-orders/types/work-order-board"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { BOARD_SEARCH_MAX_PAGE_SIZE, buildBoardSearchParamsRecord, @@ -20,7 +21,7 @@ describe("toBoardQueryParams", () => { const baseBar = { weekMonday: "2026-07-13", dispatcherIds: new Set(), - type: "All" as const, + types: new Set(), }; it("omits search when shorter than 2 characters", () => { @@ -47,17 +48,29 @@ describe("toBoardQueryParams", () => { }); it("maps PM type filter to API types", () => { - const params = toBoardQueryParams({ ...baseBar, type: "PM" }, ""); + const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM"]) }, ""); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); expect(params.overdue).toBeUndefined(); }); + it("maps multiple types as union query params", () => { + const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Emergency"]) }, ""); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Emergency]); + expect(params.overdue).toBeUndefined(); + }); + it("maps Overdue bar filter to overdue=true without types", () => { - const params = toBoardQueryParams({ ...baseBar, type: "Overdue" }, ""); + const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, ""); expect(params.overdue).toBe(true); expect(params.types).toBeUndefined(); }); + it("maps Overdue with real types as OR (types + overdue)", () => { + const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, ""); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); + expect(params.overdue).toBe(true); + }); + it("omits types when filter is All", () => { const params = toBoardQueryParams(baseBar, ""); expect(params.types).toBeUndefined(); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index a8b09c3a..35071265 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -3,6 +3,7 @@ import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-upli import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { buildDayGroups, composeBoardSections, @@ -83,7 +84,7 @@ function bar( return { weekMonday, dispatcherIds: new Set(), - type: "All", + types: new Set(), ...overrides, }; } @@ -135,6 +136,49 @@ describe("filterByBarAndSearch", () => { const ids = filterByBarAndSearch([inWeek, outWeek], bar(weekMonday), "").map((row) => row.id); expect(ids).toEqual(["in"]); }); + + it("keeps the union of selected types (OR)", () => { + const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" }); + const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" }); + const reactive = makeRow({ id: "rx", type: "Reactive", scheduledOn: "2026-06-03" }); + + const ids = filterByBarAndSearch( + [pm, emergency, reactive], + bar(weekMonday, { types: new Set(["Emergency", "Reactive"]) }), + "", + ).map((row) => row.id); + expect(ids).toEqual(["em", "rx"]); + }); + + it("matches Overdue via isPastDue even when row.type is not Overdue", () => { + const pastDuePm = makeRow({ + id: "pd", + type: "PM", + isPastDue: true, + scheduledOn: "2026-06-03", + }); + const currentPm = makeRow({ + id: "now", + type: "PM", + isPastDue: false, + scheduledOn: "2026-06-03", + }); + + const ids = filterByBarAndSearch( + [pastDuePm, currentPm], + bar(weekMonday, { types: new Set(["Overdue"]) }), + "", + ).map((row) => row.id); + expect(ids).toEqual(["pd"]); + }); + + it("does not filter by type when the set is empty", () => { + const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" }); + const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" }); + + const ids = filterByBarAndSearch([pm, emergency], bar(weekMonday), "").map((row) => row.id); + expect(ids).toEqual(["pm", "em"]); + }); }); describe("isUnassignedBoardRow", () => {