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 { useState } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { Facets } from "../api/types";
|
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. */
|
/** 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 [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");
|
const [view, setView] = useState<"board" | "list">("board");
|
||||||
return (
|
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="state">{JSON.stringify(f)}</output>
|
||||||
<output data-testid="view">{view}</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();
|
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", () => {
|
it("Board | List switches the view", () => {
|
||||||
mockFetch({ "/api/projects": { body: [] } });
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
render(<Harness screen="qc" />);
|
render(<Harness screen="qc" />);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { Department } from "../auth/types";
|
|||||||
import type { Screen } from "../auth/roles";
|
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 { 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 { 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;
|
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.
|
* 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.
|
* 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>
|
</label>
|
||||||
{parts.project ? (
|
{parts.project ? (
|
||||||
<div className="f fltp">
|
<div className="f fltp">
|
||||||
<PickUnit value={pick} onChange={onPick} />
|
<span>Project</span>
|
||||||
|
<ProjectUnitSelect value={pick} onChange={onPick} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="fltbtns">
|
<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");
|
renderApp("/clerical");
|
||||||
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
||||||
expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0);
|
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");
|
const unit = await within(bar).findByLabelText("Unit");
|
||||||
await within(unit).findByRole("option", { name: "L999002" });
|
await within(unit).findByRole("option", { name: "L999002" });
|
||||||
|
|||||||
@@ -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; }
|
.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; }
|
.fltadv { padding-top: 2px; }
|
||||||
.fltrow2 .chip { min-height: 36px; padding: 5px 12px; font-size: 13px; }
|
.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; }
|
.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; }
|
.joblist-sec .sec-h { flex-wrap: wrap; align-items: flex-end; }
|
||||||
.jlsort { display: flex; flex-direction: column; gap: 4px; min-width: 200px; }
|
.jlsort { display: flex; flex-direction: column; gap: 4px; min-width: 200px; }
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ export const L = {
|
|||||||
find: {
|
find: {
|
||||||
bar: "findbar", // data-testid of the bar (a region named "Find jobs")
|
bar: "findbar", // data-testid of the bar (a region named "Find jobs")
|
||||||
search: "Find a job",
|
search: "Find a job",
|
||||||
project: "Project", // the project search box of PickUnit
|
project: "Project", // the project select (ProjectUnitSelect)
|
||||||
projectsFound: "Projects found",
|
allProjects: "All projects",
|
||||||
change: "Change",
|
|
||||||
unit: "Unit",
|
unit: "Unit",
|
||||||
filters: "Filters",
|
filters: "Filters",
|
||||||
board: "Board",
|
board: "Board",
|
||||||
@@ -293,24 +292,18 @@ export function searchBox(page: Page): Locator {
|
|||||||
return findBar(page).getByRole("searchbox", { name: L.find.search });
|
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<void> {
|
export async function pickProject(page: Page, number: string): Promise<void> {
|
||||||
const bar = findBar(page);
|
const sel = findBar(page).getByRole("combobox", { name: L.find.project, exact: true });
|
||||||
const change = bar.getByRole("button", { name: L.find.change, exact: true });
|
const opt = sel.locator("option").filter({ hasText: new RegExp(`^${number}( · |$)`) }).first();
|
||||||
if (await change.count()) await change.first().click();
|
await expect(opt).toBeAttached();
|
||||||
await bar.getByRole("searchbox", { name: L.find.project }).fill(number);
|
await sel.selectOption((await opt.getAttribute("value")) ?? "");
|
||||||
await bar
|
await expect(sel).not.toHaveValue("");
|
||||||
.getByRole("group", { name: L.find.projectsFound })
|
|
||||||
.getByRole("button", { name: new RegExp(`^${number}\\b`) })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(bar.getByText(number, { exact: true }).first()).toBeVisible();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Back to every project. */
|
/** Back to every project. */
|
||||||
export async function unpickProject(page: Page): Promise<void> {
|
export async function unpickProject(page: Page): Promise<void> {
|
||||||
const change = findBar(page).getByRole("button", { name: L.find.change, exact: true });
|
await findBar(page).getByRole("combobox", { name: L.find.project, exact: true }).selectOption({ label: L.find.allProjects });
|
||||||
if (await change.count()) await change.first().click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The n of "Showing n packets". */
|
/** The n of "Showing n packets". */
|
||||||
|
|||||||
Reference in New Issue
Block a user