feat(web): «Спринт» — выбор активных и будущих спринтов, фильтры (#112)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f4c3bd50a5
commit
dea051e7f9
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createMemoryRouter } from "react-router";
|
import { createMemoryRouter } from "react-router";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
@ -96,42 +96,138 @@ describe("«Спринт»", () => {
|
|||||||
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
|
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("два активных: переключатель «Спринты», выбор — ?sprint=<id> и его задачи", async () => {
|
const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 });
|
||||||
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 })]));
|
const s103 = () => sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-88")], count: 1 });
|
||||||
const router = open("/app/jira/sprint?project=AWPCA");
|
const picker = (p: HTMLElement) => p.querySelector<HTMLButtonElement>("button[aria-haspopup]")!;
|
||||||
const p = within(await page());
|
const sprintGroup = () => screen.getByRole("group", { name: "Спринты" });
|
||||||
await p.findByText("Тема AURORA-1");
|
|
||||||
const seg = p.getByRole("navigation", { name: "Спринты" });
|
it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => {
|
||||||
await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" }));
|
hub(agile([s102(), s104()]));
|
||||||
expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument();
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
expect(p.queryByText("Тема AURORA-1")).toBeNull();
|
const p = await page();
|
||||||
expect(router.state.location.search).toContain("sprint=104");
|
await within(p).findByText("Тема AURORA-1");
|
||||||
expect(router.state.location.search).toContain("project=AWPCA");
|
expect(within(p).getByText("Тема AURORA-77")).toBeInTheDocument();
|
||||||
|
expect(within(p).getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual(
|
||||||
|
expect.arrayContaining(["AWPCA_Sprint_3", "AWPCA_Sprint_5"]));
|
||||||
|
expect(picker(p)).toHaveTextContent("Спринты: 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("один активный: переключателя нет", async () => {
|
it("один активный: на кнопке «Спринты» его имя", async () => {
|
||||||
hub(agile());
|
hub(agile());
|
||||||
open("/app/jira/sprint?project=AWPCA");
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
const p = within(await page());
|
const p = await page();
|
||||||
await p.findByText("Тема AURORA-1");
|
await within(p).findByText("Тема AURORA-1");
|
||||||
expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull();
|
expect(picker(p)).toHaveTextContent("AWPCA_Sprint_3");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("устаревший ?sprint= — первый активный", async () => {
|
it("«Будущий спринт на доске»: отметить будущий — ?sprint=101,102 через replace, «осталось» только у активного", async () => {
|
||||||
|
hub(agile([sprint({ id: 101, name: "Активный", issues: [iss("AURORA-5")], count: 1 }), sprint({ id: 102, name: "Будущий", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-6")], count: 1 })]));
|
||||||
|
const router = open("/app/jira/sprint?project=AWPCA");
|
||||||
|
const p = await page();
|
||||||
|
await within(p).findByText("Тема AURORA-5");
|
||||||
|
expect(within(p).queryByText("Тема AURORA-6")).toBeNull();
|
||||||
|
await userEvent.click(picker(p));
|
||||||
|
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /Будущий/ }));
|
||||||
|
expect(await within(p).findByText("Тема AURORA-6")).toBeInTheDocument();
|
||||||
|
expect(router.state.location.search).toContain("sprint=101%2C102");
|
||||||
|
expect(router.state.location.search).toContain("project=AWPCA");
|
||||||
|
expect(router.state.historyAction).toBe("REPLACE");
|
||||||
|
expect(within(p).getAllByRole("heading", { level: 2, name: /^(Активный|Будущий)$/ })).toHaveLength(2);
|
||||||
|
expect(within(p).getAllByText(/осталось \d+ дн\./)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("в списке спринтов подписи «активный»/«будущий»; последнюю отметку снять нельзя", async () => {
|
||||||
|
hub(agile([s102(), s103()]));
|
||||||
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
|
const p = await page();
|
||||||
|
await within(p).findByText("Тема AURORA-1");
|
||||||
|
await userEvent.click(picker(p));
|
||||||
|
const g = within(sprintGroup());
|
||||||
|
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3.*активный/ })).toBeChecked();
|
||||||
|
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_4.*будущий/ })).not.toBeChecked();
|
||||||
|
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeDisabled();
|
||||||
|
await userEvent.click(g.getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
|
||||||
|
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("задача в обоих выбранных спринтах — одна карточка", async () => {
|
||||||
|
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-1", { story_points: 5 })], count: 1 })]));
|
||||||
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
|
const p = await page();
|
||||||
|
await within(p).findByText("Тема AURORA-1");
|
||||||
|
expect(within(p).getAllByText("Тема AURORA-1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?sprint=999 и ?sprint=abc — все активные", async () => {
|
||||||
|
hub(agile([s102(), s103()]));
|
||||||
|
open("/app/jira/sprint?project=AWPCA&sprint=999");
|
||||||
|
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Тема AURORA-88")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("мусорный ?sprint=abc — все активные", async () => {
|
||||||
|
hub(agile());
|
||||||
|
open("/app/jira/sprint?project=AWPCA&sprint=abc");
|
||||||
|
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?sprint=103 (будущий) — только он", async () => {
|
||||||
|
hub(agile([s102(), s103()]));
|
||||||
|
open("/app/jira/sprint?project=AWPCA&sprint=103");
|
||||||
|
const p = await page();
|
||||||
|
expect(await within(p).findByText("Тема AURORA-88")).toBeInTheDocument();
|
||||||
|
expect(within(p).queryByText("Тема AURORA-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("устаревший ?sprint= (закрытый) — все активные", async () => {
|
||||||
hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
|
hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
|
||||||
open("/app/jira/sprint?project=AWPCA&sprint=101");
|
open("/app/jira/sprint?project=AWPCA&sprint=101");
|
||||||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Тема AURORA-99")).toBeNull();
|
expect(screen.queryByText("Тема AURORA-99")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("мусорный ?sprint=abc — первый активный", async () => {
|
it("нет активного, есть будущий — «Нет активного спринта», «Бэклог», выбор доступен; выбор будущего показывает доску", async () => {
|
||||||
|
hub(agile([s103()]));
|
||||||
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
|
const p = within(await page());
|
||||||
|
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
|
||||||
|
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
|
||||||
|
await userEvent.click(picker(await page()));
|
||||||
|
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
|
||||||
|
expect(await p.findByText("Тема AURORA-88")).toBeInTheDocument();
|
||||||
|
expect(p.queryByText("Нет активного спринта")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("«Исполнитель на доске спринта»: только его карточки, числа колонок по видимым; на «Бэклоге» «все»", async () => {
|
||||||
|
const sp = s102({ issues: [iss("AURORA-1", { assignee: "ivanov" }), iss("AURORA-2", { assignee: "petrov" }), iss("AURORA-3", { assignee: "ivanov", status: "Готово", done: true })] });
|
||||||
|
hub(agile([sp]));
|
||||||
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
|
const p = await page();
|
||||||
|
await within(p).findByText("Тема AURORA-2");
|
||||||
|
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "ivanov");
|
||||||
|
await waitFor(() => expect(within(p).queryByText("Тема AURORA-2")).toBeNull());
|
||||||
|
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
|
||||||
|
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||||||
|
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1"]);
|
||||||
|
cleanup();
|
||||||
|
hub(agile([sp]));
|
||||||
|
open("/app/jira/backlog?project=AWPCA");
|
||||||
|
const b = await screen.findByTestId("page-backlog");
|
||||||
|
expect(await within(b).findByLabelText("Исполнитель")).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("«Скрыть выполненные» убирает выполненные карточки", async () => {
|
||||||
hub(agile());
|
hub(agile());
|
||||||
open("/app/jira/sprint?project=AWPCA&sprint=abc");
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
const p = await page();
|
||||||
|
await within(p).findByText("Тема AURORA-2");
|
||||||
|
await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" }));
|
||||||
|
expect(within(p).queryByText("Тема AURORA-2")).toBeNull();
|
||||||
|
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
|
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
|
||||||
hub(agile([sprint({ id: 103, name: "F", state: "future" })]));
|
hub(agile([sprint({ id: 105, name: "C", state: "closed" })]));
|
||||||
open("/app/jira/sprint?project=AWPCA");
|
open("/app/jira/sprint?project=AWPCA");
|
||||||
const p = within(await page());
|
const p = within(await page());
|
||||||
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
|
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useSearchParams } from "react-router";
|
||||||
import type { Project } from "../../../api/types.ts";
|
import type { Project } from "../../../api/types.ts";
|
||||||
import { PageHeader, Panel, Segmented } from "../../../ui/index.ts";
|
import { PageHeader, Panel } from "../../../ui/index.ts";
|
||||||
import type { AgileSprint } from "../api/types.ts";
|
import type { AgileSprint } from "../api/types.ts";
|
||||||
import { RefreshButton } from "../RefreshButton.tsx";
|
import { RefreshButton } from "../RefreshButton.tsx";
|
||||||
import { AgileGate } from "./AgileGate.tsx";
|
import { AgileGate } from "./AgileGate.tsx";
|
||||||
import { BoardColumns } from "./BoardColumns.tsx";
|
import { BoardColumns } from "./BoardColumns.tsx";
|
||||||
|
import { AgileToolbar } from "./AgileToolbar.tsx";
|
||||||
import { columnsOf } from "./columns.ts";
|
import { columnsOf } from "./columns.ts";
|
||||||
import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts";
|
import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts";
|
||||||
|
import { SprintPicker } from "./SprintPicker.tsx";
|
||||||
|
import { useAgileView } from "./useAgileView.ts";
|
||||||
|
import { daysLeft, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts";
|
||||||
import "./agile.css";
|
import "./agile.css";
|
||||||
|
|
||||||
function SprintHead({ s }: { s: AgileSprint }) {
|
function SprintHead({ s }: { s: AgileSprint }) {
|
||||||
const left = daysLeft(s.end_date, new Date());
|
const left = s.state === "active" ? daysLeft(s.end_date, new Date()) : null;
|
||||||
return (
|
return (
|
||||||
<header className="agile-section__head">
|
<header className="agile-section__head">
|
||||||
<h2 className="agile-section__title">{s.name}</h2>
|
<h2 className="agile-section__title">{s.name}</h2>
|
||||||
@ -23,35 +27,38 @@ function SprintHead({ s }: { s: AgileSprint }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// «Спринт» (spec web-app): колонки доски активного спринта; только просмотр
|
// «Спринт» (spec web-app): колонки доски выбранных спринтов (по умолчанию все активные); только просмотр
|
||||||
export function SprintPage({ project }: { project: Project }) {
|
export function SprintPage({ project }: { project: Project }) {
|
||||||
const [params] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const wanted = params.get("sprint");
|
const { view, set, setQ, filter, reset } = useAgileView(project.label, "sprint");
|
||||||
return (
|
return (
|
||||||
<div data-testid="page-sprint" className="agile agile--wide">
|
<div data-testid="page-sprint" className="agile agile--wide">
|
||||||
<PageHeader title="Спринт" actions={<RefreshButton />} />
|
<PageHeader title="Спринт" actions={<RefreshButton />} />
|
||||||
<AgileGate project={project} kind="scrum">
|
<AgileGate project={project} kind="scrum">
|
||||||
{(d) => {
|
{(d) => {
|
||||||
const active = d.sprints.filter((s) => s.state === "active");
|
const selectable = selectableSprints(d.sprints);
|
||||||
// устаревший или мусорный ?sprint= — первый активный (Review Focus 2)
|
const ids = parseSprintIds(params.get("sprint"), d.sprints);
|
||||||
const sprint = active.find((s) => String(s.id) === wanted) ?? active[0];
|
const chosen = selectable.filter((s) => ids.includes(s.id));
|
||||||
if (!sprint) {
|
const issues = sprintIssues(chosen).filter((i) => !isEpic(i));
|
||||||
return (
|
const shown = issues.filter((i) => matches(i, filter));
|
||||||
<Panel className="agile-section">
|
// выбор спринтов — в адресе, без новой записи истории
|
||||||
<p className="agile__empty">Нет активного спринта</p>
|
const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
|
||||||
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const proj = encodeURIComponent(project.label);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{active.length > 1 && (
|
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
|
||||||
<Segmented label="Спринты" value={String(sprint.id)}
|
{selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
|
||||||
options={active.map((s) => ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} />
|
</AgileToolbar>
|
||||||
|
{chosen.length === 0 ? (
|
||||||
|
<Panel className="agile-section">
|
||||||
|
<p className="agile__empty">Нет активного спринта</p>
|
||||||
|
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{chosen.map((s) => <SprintHead key={s.id} s={s} />)}
|
||||||
|
<BoardColumns columns={columnsOf(d.board?.columns ?? null, shown)} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<SprintHead s={sprint} />
|
|
||||||
<BoardColumns columns={columnsOf(d.board?.columns ?? null, sprint.issues)} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
31
web/src/modules/jira/agile/SprintPicker.tsx
Normal file
31
web/src/modules/jira/agile/SprintPicker.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// выбор спринтов доски: активные и будущие; последнюю отметку снять нельзя
|
||||||
|
import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts";
|
||||||
|
import type { AgileSprint } from "../api/types.ts";
|
||||||
|
|
||||||
|
export function SprintPicker({ sprints, selected, onChange }: {
|
||||||
|
sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void;
|
||||||
|
}) {
|
||||||
|
const chosen = sprints.filter((s) => selected.includes(s.id));
|
||||||
|
const label = chosen.length === 1 ? chosen[0].name : `Спринты: ${chosen.length}`;
|
||||||
|
const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x))));
|
||||||
|
return (
|
||||||
|
<Dropdown className="agile-sprint-picker" button={({ open, toggle }) => (
|
||||||
|
<button type="button" className="ui-input multi-btn" aria-haspopup="true" aria-expanded={open} onClick={toggle}>
|
||||||
|
<span className="lbl">{label}</span><IconChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
)}>
|
||||||
|
{() => (
|
||||||
|
<div className="dd-list" role="group" aria-label="Спринты">
|
||||||
|
{sprints.map((s) => {
|
||||||
|
const on = selected.includes(s.id);
|
||||||
|
return (
|
||||||
|
<Checkbox key={s.id} className="dd-item" checked={on} disabled={on && selected.length === 1}
|
||||||
|
onChange={(e) => flip(s.id, e.target.checked)}
|
||||||
|
label={<>{s.name} <Badge>{s.state === "active" ? "активный" : "будущий"}</Badge></>} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user