frontend: find bar project filter is an All projects / All units select pair (#40)
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 10m46s
ci / image (push) Successful in 34s
ci / e2e (push) Successful in 4m3s

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 <[email protected]>
This commit is contained in:
2026-10-19 12:29:06 -06:00
co-authored by Claude Opus 5.5
parent 77d40f6c05
commit 4c0cba4da5
6 changed files with 179 additions and 23 deletions
+50 -3
View File
@@ -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<Filters>; facets?: Facets | null; onView?: boolean }) {
function Harness({
screen: scr,
initial = {},
facets = FACETS,
onView,
initialPick = { project: null, unit: null },
}: {
screen: Screen;
initial?: Partial<Filters>;
facets?: Facets | null;
onView?: boolean;
initialPick?: UnitPick;
}) {
const [f, setF] = useState<Filters>({ ...FLT_DEFAULT, ...initial });
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
const [pick, setPick] = useState<UnitPick>(initialPick);
const [view, setView] = useState<"board" | "list">("board");
return (
<>
@@ -44,6 +56,7 @@ function Harness({ screen: scr, initial = {}, facets = FACETS, onView }: { scree
/>
<output data-testid="state">{JSON.stringify(f)}</output>
<output data-testid="view">{view}</output>
<output data-testid="pick">{pick.unit ? `${pick.project?.number}/${pick.unit.number}` : (pick.project?.number ?? "all")}</output>
</>
);
}
@@ -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(<Harness screen="clerical" />);
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(<Harness screen="clerical" initialPick={{ project: { id: 9, number: "L600", name: "Old job" }, unit: null }} />);
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(<Harness screen="qc" />);
+5 -3
View File
@@ -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
</label>
{parts.project ? (
<div className="f fltp">
<PickUnit value={pick} onChange={onPick} />
<span>Project</span>
<ProjectUnitSelect value={pick} onChange={onPick} />
</div>
) : null}
<div className="fltbtns">
@@ -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<Project[] | null>(null);
const [units, setUnits] = useState<Unit[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<>
<div className="projsel2">
<select className="in" aria-label="Project" value={projectId === null ? "" : String(projectId)} onChange={(e) => chooseProject(e.target.value)}>
<option value="">All projects</option>
{stale ? (
<option value={String(stale.id)}>
{stale.number}
{stale.name ? ` · ${stale.name}` : ""}
</option>
) : null}
{list.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.number}
{p.name ? ` · ${p.name}` : ""}
{p.status === "hold" ? " (on hold)" : ""}
</option>
))}
</select>
{value.project && (unitList.length > 0 || staleUnit) ? (
<select className="in" aria-label="Unit" value={value.unit ? String(value.unit.id) : ""} onChange={(e) => chooseUnit(e.target.value)}>
<option value="">All units ({unitList.length})</option>
{staleUnit ? (
<option value={String(staleUnit.id)}>
{staleUnit.number}
{staleUnit.name ? ` · ${staleUnit.name}` : ""}
</option>
) : null}
{unitList.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.number}
{u.name ? ` · ${u.name}` : ""}
{u.status === "hold" ? " (on hold)" : u.status === "closed" ? " (closed)" : ""}
</option>
))}
</select>
) : null}
</div>
{error ? <div className="small due over">{error}</div> : null}
</>
);
}
@@ -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" });
+2
View File
@@ -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; }