From 4c0cba4da51f38b2bf14686204aa41fc0e0afb8d Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Mon, 19 Oct 2026 12:29:06 -0600 Subject: [PATCH] frontend: find bar project filter is an All projects / All units select pair (#40) The find bar reused the New Packet two-step picker, which put a project search and a list of project cards inside the bar. It now has two selects like the old build: All projects or one active project, then All units or one of its units. A saved pick no longer in the active list still shows. Closes #40 Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/FindBar.test.tsx | 53 ++++++++- frontend/src/components/FindBar.tsx | 8 +- frontend/src/components/ProjectUnitSelect.tsx | 110 ++++++++++++++++++ .../screens/clerical/ClericalHome.test.tsx | 4 +- frontend/src/theme/theme.css | 2 + tests/e2e/tests/pmhelpers.ts | 25 ++-- 6 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/ProjectUnitSelect.tsx diff --git a/frontend/src/components/FindBar.test.tsx b/frontend/src/components/FindBar.test.tsx index 9e23aff..6481dea 100644 --- a/frontend/src/components/FindBar.test.tsx +++ b/frontend/src/components/FindBar.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import type { Facets } from "../api/types"; @@ -22,9 +22,21 @@ const FACETS: Facets = { }; /** The find bar with its own state, as a screen holds it. */ -function Harness({ screen: scr, initial = {}, facets = FACETS, onView }: { screen: Screen; initial?: Partial; facets?: Facets | null; onView?: boolean }) { +function Harness({ + screen: scr, + initial = {}, + facets = FACETS, + onView, + initialPick = { project: null, unit: null }, +}: { + screen: Screen; + initial?: Partial; + facets?: Facets | null; + onView?: boolean; + initialPick?: UnitPick; +}) { const [f, setF] = useState({ ...FLT_DEFAULT, ...initial }); - const [pick, setPick] = useState({ project: null, unit: null }); + const [pick, setPick] = useState(initialPick); const [view, setView] = useState<"board" | "list">("board"); return ( <> @@ -44,6 +56,7 @@ function Harness({ screen: scr, initial = {}, facets = FACETS, onView }: { scree /> {JSON.stringify(f)} {view} + {pick.unit ? `${pick.project?.number}/${pick.unit.number}` : (pick.project?.number ?? "all")} ); } @@ -147,6 +160,40 @@ describe("FindBar", () => { expect(screen.queryByRole("button", { name: "List" })).not.toBeInTheDocument(); }); + it("Project is an All projects select, then an All units select for the picked project", async () => { + mockFetch({ + "/api/projects": { body: [{ id: 7, number: "L670", name: "Tank farm", status: "open" }, { id: 8, number: "L671", name: null, status: "hold" }] }, + "/api/units": { body: [{ id: 70, project_id: 7, number: "052", name: "North", status: "open" }, { id: 71, project_id: 7, number: "053", name: null, status: "closed" }] }, + }); + render(); + const proj = screen.getByRole("combobox", { name: "Project" }); + await waitFor(() => expect(within(proj).getAllByRole("option")).toHaveLength(3)); + expect(within(proj).getAllByRole("option").map((o) => o.textContent)).toEqual(["All projects", "L670 · Tank farm", "L671 (on hold)"]); + // No project search and no list of project cards in the bar. + expect(screen.queryByRole("searchbox", { name: "Project" })).not.toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Projects found" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Unit" })).not.toBeInTheDocument(); + + fireEvent.change(proj, { target: { value: "7" } }); + expect(screen.getByTestId("pick")).toHaveTextContent("L670"); + const unit = await screen.findByRole("combobox", { name: "Unit" }); + expect(within(unit).getAllByRole("option").map((o) => o.textContent)).toEqual(["All units (2)", "052 · North", "053 (closed)"]); + fireEvent.change(unit, { target: { value: "70" } }); + expect(screen.getByTestId("pick")).toHaveTextContent("L670/052"); + + fireEvent.change(proj, { target: { value: "" } }); + expect(screen.getByTestId("pick")).toHaveTextContent("all"); + expect(screen.queryByRole("combobox", { name: "Unit" })).not.toBeInTheDocument(); + }); + + it("a saved pick that is no longer active still shows in the select", async () => { + mockFetch({ "/api/projects": { body: [] }, "/api/units": { body: [] } }); + render(); + const proj = screen.getByRole("combobox", { name: "Project" }); + await waitFor(() => expect(proj).toHaveValue("9")); + expect(within(proj).getAllByRole("option").map((o) => o.textContent)).toEqual(["All projects", "L600 · Old job"]); + }); + it("Board | List switches the view", () => { mockFetch({ "/api/projects": { body: [] } }); render(); diff --git a/frontend/src/components/FindBar.tsx b/frontend/src/components/FindBar.tsx index 008cfc7..9edccf7 100644 --- a/frontend/src/components/FindBar.tsx +++ b/frontend/src/components/FindBar.tsx @@ -4,7 +4,8 @@ import type { Department } from "../auth/types"; import type { Screen } from "../auth/roles"; import { ALL, countActive, countAdvanced, FLT_FLAGKIND, FLT_FLAGS, FLT_STATUS, partsFor, type Filters, type ViewKind } from "../lib/filters"; import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets"; -import { PickUnit, type UnitPick } from "./PickUnit"; +import type { UnitPick } from "./PickUnit"; +import { ProjectUnitSelect } from "./ProjectUnitSelect"; export type BoardView = ViewKind; @@ -83,7 +84,7 @@ function PickSummary({ pick }: { pick: UnitPick }) { } /** - * Find a job (the old fltBar). Row one: the search box, the project then unit picker, Filters and Board | List. + * Find a job (the old fltBar). Row one: the search box, the project then unit selects, Filters and Board | List. * Row two, while Filters is open or any of its selects is set: Department, Status, Station, Worker, Flag. * Then the project summary and the chips row with the count and Clear filters. */ @@ -128,7 +129,8 @@ export function FindBar({ f, onChange, onClear, pick, onPick, view = "board", on {parts.project ? (
- + Project +
) : null}
diff --git a/frontend/src/components/ProjectUnitSelect.tsx b/frontend/src/components/ProjectUnitSelect.tsx new file mode 100644 index 0000000..b32d3e9 --- /dev/null +++ b/frontend/src/components/ProjectUnitSelect.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { projectsApi } from "../api/projects"; +import type { Project, Unit } from "../api/types"; +import { errorText } from "../lib/format"; +import type { UnitPick } from "./PickUnit"; + +interface ProjectUnitSelectProps { + value: UnitPick; + onChange: (v: UnitPick) => void; +} + +/** + * The find bar's project filter (the old projSel and unitSel): All projects or one active project, + * then All units or one of its units. A saved pick the list no longer holds still shows. + */ +export function ProjectUnitSelect({ value, onChange }: ProjectUnitSelectProps) { + const [projects, setProjects] = useState(null); + const [units, setUnits] = useState(null); + const [error, setError] = useState(null); + const projectId = value.project?.id ?? null; + + useEffect(() => { + const ctl = new AbortController(); + projectsApi + .list("", "active", ctl.signal) + .then((list) => { + if (!ctl.signal.aborted) setProjects(list); + }) + .catch((err: unknown) => { + if (!ctl.signal.aborted) setError(errorText(err)); + }); + return () => ctl.abort(); + }, []); + + useEffect(() => { + setUnits(null); + if (projectId === null) return; + const ctl = new AbortController(); + projectsApi + .units(projectId, "", ctl.signal) + .then((list) => { + if (!ctl.signal.aborted) setUnits(list); + }) + .catch((err: unknown) => { + if (!ctl.signal.aborted) setError(errorText(err)); + }); + return () => ctl.abort(); + }, [projectId]); + + const list = projects ?? []; + const stale = value.project && !list.some((p) => p.id === value.project?.id) ? value.project : null; + const unitList = units ?? []; + const staleUnit = value.unit && !unitList.some((u) => u.id === value.unit?.id) ? value.unit : null; + + const chooseProject = (raw: string) => { + setError(null); + if (!raw) return onChange({ project: null, unit: null }); + if (stale && String(stale.id) === raw) return; + const p = list.find((x) => String(x.id) === raw); + if (p) onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null }); + }; + + const chooseUnit = (raw: string) => { + if (!value.project) return; + if (staleUnit && String(staleUnit.id) === raw) return; + onChange({ project: value.project, unit: unitList.find((u) => String(u.id) === raw) ?? null }); + }; + + return ( + <> +
+ + {value.project && (unitList.length > 0 || staleUnit) ? ( + + ) : null} +
+ {error ?
{error}
: null} + + ); +} diff --git a/frontend/src/screens/clerical/ClericalHome.test.tsx b/frontend/src/screens/clerical/ClericalHome.test.tsx index 7d28b55..fd896d3 100644 --- a/frontend/src/screens/clerical/ClericalHome.test.tsx +++ b/frontend/src/screens/clerical/ClericalHome.test.tsx @@ -120,7 +120,9 @@ describe("ClericalHome", () => { renderApp("/clerical"); const bar = await screen.findByRole("region", { name: "Find jobs" }); expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0); - fireEvent.click(await within(bar).findByRole("button", { name: /L999/ })); + const proj = within(bar).getByRole("combobox", { name: "Project" }); + await within(proj).findByRole("option", { name: /^L999/ }); + fireEvent.change(proj, { target: { value: "7" } }); const unit = await within(bar).findByLabelText("Unit"); await within(unit).findByRole("option", { name: "L999002" }); diff --git a/frontend/src/theme/theme.css b/frontend/src/theme/theme.css index f84cdf1..e12cdde 100644 --- a/frontend/src/theme/theme.css +++ b/frontend/src/theme/theme.css @@ -684,6 +684,8 @@ a.itpcard { flex-direction: row; text-align: left; } .fltn { display: inline-grid; place-items: center; min-width: 20px; height: 20px; border-radius: 10px; background: var(--cyan); color: #06222b; font-size: 12px; padding: 0 5px; } .fltadv { padding-top: 2px; } .fltrow2 .chip { min-height: 36px; padding: 5px 12px; font-size: 13px; } +.projsel2 { display: flex; gap: 8px; } +.fltrow .projsel2 select { flex: 1 1 0; min-width: 0; } .fltbar .projsum { padding: 8px 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 4px; } .joblist-sec .sec-h { flex-wrap: wrap; align-items: flex-end; } .jlsort { display: flex; flex-direction: column; gap: 4px; min-width: 200px; } diff --git a/tests/e2e/tests/pmhelpers.ts b/tests/e2e/tests/pmhelpers.ts index 5c71f45..7581cd4 100644 --- a/tests/e2e/tests/pmhelpers.ts +++ b/tests/e2e/tests/pmhelpers.ts @@ -64,9 +64,8 @@ export const L = { find: { bar: "findbar", // data-testid of the bar (a region named "Find jobs") search: "Find a job", - project: "Project", // the project search box of PickUnit - projectsFound: "Projects found", - change: "Change", + project: "Project", // the project select (ProjectUnitSelect) + allProjects: "All projects", unit: "Unit", filters: "Filters", board: "Board", @@ -293,24 +292,18 @@ export function searchBox(page: Page): Locator { return findBar(page).getByRole("searchbox", { name: L.find.search }); } -/** Pick a project in the find bar (PickUnit: search, then the hit). Unpicks the current one first. */ +/** Pick a project in the find bar: the Project select, by its number. */ export async function pickProject(page: Page, number: string): Promise { - const bar = findBar(page); - const change = bar.getByRole("button", { name: L.find.change, exact: true }); - if (await change.count()) await change.first().click(); - await bar.getByRole("searchbox", { name: L.find.project }).fill(number); - await bar - .getByRole("group", { name: L.find.projectsFound }) - .getByRole("button", { name: new RegExp(`^${number}\\b`) }) - .first() - .click(); - await expect(bar.getByText(number, { exact: true }).first()).toBeVisible(); + const sel = findBar(page).getByRole("combobox", { name: L.find.project, exact: true }); + const opt = sel.locator("option").filter({ hasText: new RegExp(`^${number}( · |$)`) }).first(); + await expect(opt).toBeAttached(); + await sel.selectOption((await opt.getAttribute("value")) ?? ""); + await expect(sel).not.toHaveValue(""); } /** Back to every project. */ export async function unpickProject(page: Page): Promise { - const change = findBar(page).getByRole("button", { name: L.find.change, exact: true }); - if (await change.count()) await change.first().click(); + await findBar(page).getByRole("combobox", { name: L.find.project, exact: true }).selectOption({ label: L.find.allProjects }); } /** The n of "Showing n packets". */