From e8efde5fbcfbc12a51e1fd3ee005f30992ed7298 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 16:32:30 -0300 Subject: [PATCH 1/5] feat(work-orders): add multi-select All types filter (SH-243) Dispatchers can select several WO types so the board shows the union of matching rows. --- e2e/work-orders/type-filter.spec.ts | 93 ++++++++++++++++++ .../list/filter-bar/wo-type-filter.tsx | 90 ++++++++++++++++++ .../list/filter-bar/wo-type-segment.tsx | 32 ------- .../filter-bar/work-orders-filter-bar.tsx | 36 +++---- .../_hooks/use-work-orders-list-filters.ts | 20 ++-- src/app/(protected)/workorders/index.tsx | 4 +- .../types/work-order-list-filters.ts | 4 +- .../work-orders/utils/board-query-params.ts | 2 +- .../work-orders/utils/table-filter-utils.ts | 16 +++- .../workorders/week-day-nav.test.tsx | 19 ++++ .../workorders/wo-type-filter.test.tsx | 95 +++++++++++++++++++ ...ork-orders-advanced-search-wiring.test.tsx | 10 +- ...ork-orders-board-core-affordances.test.tsx | 4 +- .../utils/board-query-params.test.ts | 19 +++- .../utils/table-filter-utils.test.ts | 46 ++++++++- 15 files changed, 408 insertions(+), 82 deletions(-) create mode 100644 e2e/work-orders/type-filter.spec.ts create mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx delete mode 100644 src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx create mode 100644 src/test/app/(protected)/workorders/wo-type-filter.test.tsx diff --git a/e2e/work-orders/type-filter.spec.ts b/e2e/work-orders/type-filter.spec.ts new file mode 100644 index 00000000..06a6e3d3 --- /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 (SH-243)", () => { + 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("button", { name: "PM" })).toBeVisible(); + await typeMenu.getByRole("button", { name: "PM" }).click(); + await typeMenu.getByRole("button", { 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("button", { 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..0affe8a4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { Popover } from "@mui/material"; +import { ChevronDown, Layers } from "lucide-react"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +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; +}; + +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`; +} + +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} + + + + +
+ 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 3f0bdb13..4dd1d429 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,9 +1,9 @@ import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; -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 { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; 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 = { @@ -13,8 +13,8 @@ type WorkOrdersFilterBarProps = { onSelectedDayChange: (dayIso: string) => void; dispatcherIds: Set; onDispatcherChange: (ids: Set) => void; - typeFilter: WorkOrderTypeFilter; - onTypeChange: (value: WorkOrderTypeFilter) => void; + types: Set; + onTypesChange: (value: Set) => void; users: WorkOrderUser[]; usersLoading?: boolean; currentUserId?: string | number; @@ -30,8 +30,8 @@ export function WorkOrdersFilterBar({ onSelectedDayChange, dispatcherIds, onDispatcherChange, - typeFilter, - onTypeChange, + types, + onTypesChange, users, usersLoading = false, currentUserId, @@ -60,17 +60,21 @@ export function WorkOrdersFilterBar({ {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 8d5b1739..0ee85a17 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -49,8 +49,8 @@ export default function WorkOrdersListPage() { onSelectedDayChange={filters.setSelectedDayIso} dispatcherIds={filters.dispatcherIds} onDispatcherChange={filters.setDispatcherIds} - typeFilter={filters.typeFilter} - onTypeChange={filters.setTypeFilter} + types={filters.types} + onTypesChange={filters.setTypes} users={page.users} usersLoading={page.usersLoading} currentUserId={user?.id} 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/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..3314c986 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx @@ -0,0 +1,95 @@ +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 } 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) { + await user.click(screen.getByRole("button", { name: triggerName })); +} + +describe("WoTypeFilter (SH-243)", () => { + 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" })).toBeEnabled(); + await openTypeMenu(user, "All types"); + + expect(screen.getByRole("button", { name: "PM" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reactive" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Emergency" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Overdue" })).toBeInTheDocument(); + }); + + it("labels a single type and multiple types on the trigger", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await openTypeMenu(user, "All types"); + await user.click(screen.getByRole("button", { name: "PM" })); + expect( + screen.getByRole("button", { name: "PM", hidden: true, expanded: true }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Emergency" })); + expect( + screen.getByRole("button", { name: "2 types selected", 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: "PM" })).toBeInTheDocument(); + await openTypeMenu(user, "PM"); + await user.click(screen.getByRole("button", { name: "All types" })); + expect( + screen.getByRole("button", { name: "All types", hidden: true, expanded: true }), + ).toBeInTheDocument(); + }); + + it("returns to All types after deselecting the last type", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await openTypeMenu(user, "PM"); + await user.click(screen.getByRole("button", { name: "PM" })); + expect( + screen.getByRole("button", { name: "All types", 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" }); + expect(trigger).toBeDisabled(); + await user.click(trigger); + expect(screen.queryByRole("button", { name: "PM" })).not.toBeInTheDocument(); + }); + + it("lists All types first, then the four WO types", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await openTypeMenu(user, "All types"); + const labels = screen.getAllByRole("button").map((button) => button.textContent?.trim()); + expect(labels).toEqual(["All types", "PM", "Reactive", "Emergency", "Overdue"]); + }); +}); 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 a8e0c15e..1a18be5c 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(), }), })); @@ -256,8 +256,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: "All types" })).toBeDisabled(); expect(screen.getByText("42 work orders")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /clear all/i })); @@ -267,7 +266,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: "All types" })).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/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 5d80c1b4..531caaa7 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 @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; import { BOARD_SEARCH_MAX_PAGE_SIZE, @@ -16,7 +17,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", () => { @@ -43,17 +44,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", () => { From 919748a274894fb8879da9f909853b40f6f4404b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 17:20:10 -0300 Subject: [PATCH 2/5] fix(work-orders): expose type filter selection state to AT Menu roles and aria-checked match the multi-select. Trigger uses AAP Tag icon. --- e2e/work-orders/type-filter.spec.ts | 8 +- .../list/filter-bar/wo-type-filter.tsx | 14 ++- src/components/ui/checkbox-menu-item.tsx | 1 + .../workorders/wo-type-filter.test.tsx | 102 +++++++++++++----- ...ork-orders-advanced-search-wiring.test.tsx | 4 +- 5 files changed, 93 insertions(+), 36 deletions(-) diff --git a/e2e/work-orders/type-filter.spec.ts b/e2e/work-orders/type-filter.spec.ts index 06a6e3d3..6e829c88 100644 --- a/e2e/work-orders/type-filter.spec.ts +++ b/e2e/work-orders/type-filter.spec.ts @@ -78,16 +78,16 @@ test.describe("Work Orders type filter (SH-243)", () => { await expect(trigger).toBeVisible(); await trigger.click(); const typeMenu = page.getByTestId("wo-type-filter-menu"); - await expect(typeMenu.getByRole("button", { name: "PM" })).toBeVisible(); - await typeMenu.getByRole("button", { name: "PM" }).click(); - await typeMenu.getByRole("button", { name: "Emergency" }).click(); + 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("button", { name: "All types" }).click(); + 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 index 0affe8a4..caf77692 100644 --- 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 @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { Popover } from "@mui/material"; -import { ChevronDown, Layers } from "lucide-react"; +import { ChevronDown, Tag } from "lucide-react"; import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -43,7 +43,7 @@ export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFil onAnchor={setAnchor} open={open} disabled={disabled} - aria-haspopup="listbox" + aria-label={`Work order types, ${label}`} data-testid="wo-type-filter-trigger" className={cn( "flex h-[38px] w-auto shrink-0 items-center gap-2 rounded-md border px-3 text-[13px] font-semibold whitespace-nowrap transition-colors", @@ -52,7 +52,7 @@ export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFil : "border-[#bfe2f5] bg-(--color-primary-tint) text-primary", )} > - + {label} @@ -65,21 +65,25 @@ export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFil transformOrigin={{ vertical: "top", horizontal: "left" }} slotProps={{ paper: { - className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md", + className: "mt-1 w-[180px] rounded-lg border border-border p-1.5 shadow-md", }, }} > -
+
onChange(new Set())} /> {WO_TYPES.map((type) => ( toggle(type)} /> ))} diff --git a/src/components/ui/checkbox-menu-item.tsx b/src/components/ui/checkbox-menu-item.tsx index 473bde76..4ef1e5e5 100644 --- a/src/components/ui/checkbox-menu-item.tsx +++ b/src/components/ui/checkbox-menu-item.tsx @@ -32,6 +32,7 @@ export function CheckboxMenuItem({ className, )} {...props} + aria-checked={checked} > {left} diff --git a/src/test/app/(protected)/workorders/wo-type-filter.test.tsx b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx index 3314c986..e9cde5af 100644 --- a/src/test/app/(protected)/workorders/wo-type-filter.test.tsx +++ b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx @@ -4,7 +4,7 @@ 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 } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; function TypeFilterHarness({ initial = new Set(), @@ -17,37 +17,78 @@ function TypeFilterHarness({ return ; } -async function openTypeMenu(user: ReturnType, triggerName: string) { +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 (SH-243)", () => { +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" })).toBeEnabled(); - await openTypeMenu(user, "All types"); + expect(screen.getByRole("button", { name: /all types/i })).toBeEnabled(); + const menu = await openTypeMenu(user, /all types/i); - expect(screen.getByRole("button", { name: "PM" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Reactive" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Emergency" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Overdue" })).toBeInTheDocument(); + 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 }); - await openTypeMenu(user, "All types"); - await user.click(screen.getByRole("button", { name: "PM" })); + const menu = await openTypeMenu(user, /all types/i); + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "PM" })); expect( - screen.getByRole("button", { name: "PM", hidden: true, expanded: true }), + screen.getByRole("button", { name: /work order types, pm/i, hidden: true, expanded: true }), ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Emergency" })); + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "Emergency" })); expect( - screen.getByRole("button", { name: "2 types selected", hidden: true, expanded: true }), + screen.getByRole("button", { + name: /2 types selected/i, + hidden: true, + expanded: true, + }), ).toBeInTheDocument(); }); @@ -55,11 +96,15 @@ describe("WoTypeFilter (SH-243)", () => { const user = userEvent.setup(); renderWithProviders(, { withAuth: false }); - expect(screen.getByRole("button", { name: "PM" })).toBeInTheDocument(); - await openTypeMenu(user, "PM"); - await user.click(screen.getByRole("button", { name: "All types" })); + 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: "All types", hidden: true, expanded: true }), + screen.getByRole("button", { + name: /work order types, all types/i, + hidden: true, + expanded: true, + }), ).toBeInTheDocument(); }); @@ -67,10 +112,14 @@ describe("WoTypeFilter (SH-243)", () => { const user = userEvent.setup(); renderWithProviders(, { withAuth: false }); - await openTypeMenu(user, "PM"); - await user.click(screen.getByRole("button", { name: "PM" })); + const menu = await openTypeMenu(user, /work order types, pm/i); + await user.click(within(menu).getByRole("menuitemcheckbox", { name: "PM" })); expect( - screen.getByRole("button", { name: "All types", hidden: true, expanded: true }), + screen.getByRole("button", { + name: /work order types, all types/i, + hidden: true, + expanded: true, + }), ).toBeInTheDocument(); }); @@ -78,18 +127,21 @@ describe("WoTypeFilter (SH-243)", () => { const user = userEvent.setup(); renderWithProviders(, { withAuth: false }); - const trigger = screen.getByRole("button", { name: "All types" }); + const trigger = screen.getByRole("button", { name: /all types/i }); expect(trigger).toBeDisabled(); await user.click(trigger); - expect(screen.queryByRole("button", { name: "PM" })).not.toBeInTheDocument(); + 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 }); - await openTypeMenu(user, "All types"); - const labels = screen.getAllByRole("button").map((button) => button.textContent?.trim()); + 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"]); }); }); 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 1a18be5c..8ef733a2 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 @@ -256,7 +256,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 types" })).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 })); @@ -266,6 +266,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 types" })).toBeEnabled(); + expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled(); }); }); From efefcf9270534c76dda0dec9d714f04692d0305c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 18:47:23 -0300 Subject: [PATCH 3/5] fix(work-orders): give type filter an accessible menu keyboard contract Keep aria-checked off shared checkbox buttons so dispatcher rows stay ordinary buttons. --- .../list/filter-bar/wo-type-filter.tsx | 73 ++++++++++++------ src/components/ui/checkbox-menu-item.tsx | 1 - .../workorders/dispatcher-filter.test.tsx | 24 ++++++ .../workorders/wo-type-filter.test.tsx | 74 ++++++++++++++++++- 4 files changed, 147 insertions(+), 25 deletions(-) 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 index caf77692..10ec3a7b 100644 --- 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 @@ -1,7 +1,7 @@ import { useMemo } from "react"; -import { Popover } from "@mui/material"; +import { Menu, MenuItem } from "@mui/material"; import { ChevronDown, Tag } from "lucide-react"; -import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +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"; @@ -22,6 +22,30 @@ function typeFilterLabel(selected: Set): string { 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; @@ -57,38 +81,41 @@ export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFil - { + 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: { + "aria-label": "Work order types", + }, }} > -
- onChange(new Set())} + onChange(new Set())} + /> + {WO_TYPES.map((type) => ( + toggle(type)} /> - {WO_TYPES.map((type) => ( - toggle(type)} - /> - ))} -
-
+ ))} + ); } diff --git a/src/components/ui/checkbox-menu-item.tsx b/src/components/ui/checkbox-menu-item.tsx index 4ef1e5e5..473bde76 100644 --- a/src/components/ui/checkbox-menu-item.tsx +++ b/src/components/ui/checkbox-menu-item.tsx @@ -32,7 +32,6 @@ export function CheckboxMenuItem({ className, )} {...props} - aria-checked={checked} > {left} 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/wo-type-filter.test.tsx b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx index e9cde5af..faa20e5a 100644 --- a/src/test/app/(protected)/workorders/wo-type-filter.test.tsx +++ b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx @@ -4,7 +4,7 @@ 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, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; function TypeFilterHarness({ initial = new Set(), @@ -144,4 +144,76 @@ describe("WoTypeFilter", () => { .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(); + }); }); From 80fd30fff9d0ace5eb1f886db8c15a1c82b08ebe Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 21:54:01 -0300 Subject: [PATCH 4/5] fix(work-orders): restore type filter e2e menu test id Put wo-type-filter-menu back on the MUI Menu list so the union journey can find type rows. --- .../_components/list/filter-bar/wo-type-filter.tsx | 9 ++++++--- .../app/(protected)/workorders/wo-type-filter.test.tsx | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) 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 index 10ec3a7b..bd9c66c6 100644 --- 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 @@ -13,6 +13,11 @@ type WoTypeFilterProps = { 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) { @@ -97,9 +102,7 @@ export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFil paper: { className: "mt-1 w-[180px] rounded-lg border border-border p-1.5 shadow-md", }, - list: { - "aria-label": "Work order types", - }, + list: TYPE_FILTER_MENU_LIST_PROPS, }} > { 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(); From 1ed993a353a265bb230b17f57bdeb4eaeddb831e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 21 Aug 2026 11:47:18 -0300 Subject: [PATCH 5/5] fix: Update e2e/work-orders/type-filter.spec.ts Co-authored-by: Alexandre Brandizzi --- e2e/work-orders/type-filter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/work-orders/type-filter.spec.ts b/e2e/work-orders/type-filter.spec.ts index 6e829c88..8c0cef3f 100644 --- a/e2e/work-orders/type-filter.spec.ts +++ b/e2e/work-orders/type-filter.spec.ts @@ -59,7 +59,7 @@ async function mockWorkOrderBoard(page: Page, boardUrls: string[]) { ); } -test.describe("Work Orders type filter (SH-243)", () => { +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) });