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 <[email protected]>
This commit is contained in:
@@ -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" />);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user