Веб: страница «Kanban» (#108)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 20:21:04 +04:00
parent caf6ed43ae
commit 611ed830c4
5 changed files with 156 additions and 3 deletions

View File

@ -0,0 +1,111 @@
import { render, screen, within } from "@testing-library/react";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "../../../App.tsx";
import { setToken } from "../../../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { routes } from "../../../routes.tsx";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import type { AgileIssue, AgileResponse } from "../api/types.ts";
const K = { label: "KAN", name: "Канбан", board_id: 5120, board_type: "kanban", created_at: "", issues_count: 4, jira_synced_at: null };
const S = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
key, summary: `Тема ${key}`, issue_type: "Задача", status: "Новая", priority: null, assignee: null,
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
});
const cols = [
{ name: "Бэклог", statuses: ["Новая"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] },
];
const agile = (backlog: boolean | null): AgileResponse => ({
board: { id: 5120, type: "kanban", columns: cols, kanban_backlog: backlog, fetched_at: "2026-10-24T10:00:00+00:00" },
sprints: [],
backlog: backlog ? [iss("AURORA-1"), iss("AURORA-2")] : null,
board_issues: [iss("AURORA-1"), iss("AURORA-2"), iss("AURORA-3", { status: "В работе" }), iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })],
});
function hub(data: AgileResponse, projects = [K]) {
const urls: string[] = [];
mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
urls.push(k);
if (k === "GET /agile") return json(200, data);
if (k === "GET /projects") return json(200, { projects });
if (k === "GET /settings") return json(200, settings);
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agents") return json(200, { agents: [] });
return json(404, { error: "not_found" });
});
return urls;
}
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
const page = () => screen.findByTestId("page-kanban");
const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__name")).map((e) => e.textContent);
describe("«Kanban»", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", async () => {
hub(agile(false));
open("/app/jira/links?project=KAN");
const g = () => screen.getByRole("group", { name: "Jira" });
await within(g()).findByRole("link", { name: "Kanban" });
expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи", "Лента"]);
});
it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", async () => {
hub(agile(false));
open("/app/jira/kanban?project=KAN");
const p = await page();
await within(p).findByText("Тема AURORA-3");
expect(colNames(p)).toEqual(["Бэклог", "В работе", "Готово", "Без колонки"]);
const done = p.querySelector('[aria-label="Готово"]') as HTMLElement;
expect(within(done).getByText("за 14 дней")).toBeInTheDocument();
expect(within(done).getByText("Тема AURORA-4")).toBeInTheDocument();
expect(p.querySelectorAll(".agile-col__caption")).toHaveLength(1);
const rest = p.querySelector('[aria-label="Без колонки"]') as HTMLElement;
expect(within(rest).getByText("Тема AURORA-5")).toBeInTheDocument();
});
it("бэклог включён: «Бэклог (2)» над колонками, первая колонка не рисуется", async () => {
hub(agile(true));
open("/app/jira/kanban?project=KAN");
const p = await page();
const sum = await within(p).findByText("Бэклог (2)");
const det = sum.closest("details")!;
expect(within(det).getByText("Тема AURORA-1")).toBeInTheDocument();
expect(within(det).getByText("Тема AURORA-2")).toBeInTheDocument();
expect(colNames(p)).toEqual(["В работе", "Готово", "Без колонки"]);
expect(p.querySelector(".agile-columns")!.textContent).not.toContain("Тема AURORA-1");
expect(det.compareDocumentPosition(p.querySelector(".agile-columns")!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("бэклог выключен или неизвестен: раздела «Бэклог» нет, первая колонка на доске", async () => {
for (const flag of [false, null]) {
hub(agile(flag));
const { unmount } = render(<App router={createMemoryRouter(routes, { initialEntries: ["/app/jira/kanban?project=KAN"], basename: "/app" })} />);
const p = await page();
await within(p).findByText("Тема AURORA-3");
expect(p.querySelector("details")).toBeNull();
expect(screen.queryByText(/Бэклог \(/)).toBeNull();
expect(colNames(p)[0]).toBe("Бэклог");
expect(within(p.querySelector('[aria-label="Бэклог"]') as HTMLElement).getByText("Тема AURORA-1")).toBeInTheDocument();
unmount();
}
});
it("scrum-проект на /app/jira/kanban — «У проекта нет kanban-доски» со ссылкой «Связи»", async () => {
const urls = hub(agile(false), [S]);
open("/app/jira/kanban?project=AWPCA");
const p = within(await page());
expect(await p.findByText("У проекта нет kanban-доски")).toBeInTheDocument();
expect(p.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA");
expect(urls).not.toContain("GET /agile");
});
});

View File

@ -0,0 +1,38 @@
import type { Project } from "../../../api/types.ts";
import { PageHeader } from "../../../ui/index.ts";
import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx";
import { BoardColumns } from "./BoardColumns.tsx";
import { columnsOf } from "./columns.ts";
import { AgileIssueRow } from "./IssueRow.tsx";
import "./agile.css";
// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18)
export function KanbanPage({ project }: { project: Project }) {
return (
<div data-testid="page-kanban" className="agile agile--wide">
<PageHeader title="Kanban" actions={<RefreshButton />} />
<AgileGate project={project} kind="kanban">
{(d) => {
const withBacklog = d.board?.kanban_backlog === true;
const cols = d.board?.columns ?? null;
// задачи первой колонки уходят в «Бэклог», а не в «Без колонки»
const first = new Set(withBacklog && cols ? cols[0]?.statuses ?? [] : []);
const issues = (d.board_issues ?? []).filter((i) => i.status === null || !first.has(i.status));
const backlog = d.backlog ?? [];
return (
<>
{withBacklog && (
<details className="agile-past">
<summary>{`Бэклог (${backlog.length})`}</summary>
<ul className="agile-list">{backlog.map((i) => <AgileIssueRow key={i.key} issue={i} />)}</ul>
</details>
)}
<BoardColumns columns={columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues)} lastCaption="за 14 дней" />
</>
);
}}
</AgileGate>
</div>
);
}

View File

@ -1,8 +1,9 @@
import { Navigate, useLocation, useParams } from "react-router"; import { Navigate, useLocation, useParams } from "react-router";
import { ProjectGate } from "../../projects/ProjectGate.tsx"; import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBacklog, IconBoard, IconHistory, IconList, IconSprint } from "../../ui/index.ts"; import { IconBacklog, IconBoard, IconHistory, IconKanban, IconList, IconSprint } from "../../ui/index.ts";
import type { HubModule } from "../types.ts"; import type { HubModule } from "../types.ts";
import { BacklogPage } from "./agile/BacklogPage.tsx"; import { BacklogPage } from "./agile/BacklogPage.tsx";
import { KanbanPage } from "./agile/KanbanPage.tsx";
import { SprintPage } from "./agile/SprintPage.tsx"; import { SprintPage } from "./agile/SprintPage.tsx";
import { BoardPage } from "./board/BoardPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx";
import { FeedPage } from "./feed/FeedPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx";
@ -27,6 +28,7 @@ export const jiraModule: HubModule = {
{ path: "/jira/links", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> }, { path: "/jira/links", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/jira/backlog", element: <ProjectGate>{(p) => <BacklogPage key={p.label} project={p} />}</ProjectGate> }, { path: "/jira/backlog", element: <ProjectGate>{(p) => <BacklogPage key={p.label} project={p} />}</ProjectGate> },
{ path: "/jira/sprint", element: <ProjectGate>{(p) => <SprintPage key={p.label} project={p} />}</ProjectGate> }, { path: "/jira/sprint", element: <ProjectGate>{(p) => <SprintPage key={p.label} project={p} />}</ProjectGate> },
{ path: "/jira/kanban", element: <ProjectGate>{(p) => <KanbanPage key={p.label} project={p} />}</ProjectGate> },
{ path: "/jira/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> }, { path: "/jira/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/jira/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> }, { path: "/jira/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
// прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории
@ -40,6 +42,7 @@ export const jiraModule: HubModule = {
{ to: "/jira/links", label: "Связи", icon: <IconBoard /> }, { to: "/jira/links", label: "Связи", icon: <IconBoard /> },
{ to: "/jira/backlog", label: "Бэклог", icon: <IconBacklog />, padded: true, when: (p) => boardType(p) === "scrum" }, { to: "/jira/backlog", label: "Бэклог", icon: <IconBacklog />, padded: true, when: (p) => boardType(p) === "scrum" },
{ to: "/jira/sprint", label: "Спринт", icon: <IconSprint />, when: (p) => boardType(p) === "scrum" }, { to: "/jira/sprint", label: "Спринт", icon: <IconSprint />, when: (p) => boardType(p) === "scrum" },
{ to: "/jira/kanban", label: "Kanban", icon: <IconKanban />, when: (p) => boardType(p) === "kanban" },
{ to: "/jira/issues", label: "Задачи", icon: <IconList />, padded: true }, { to: "/jira/issues", label: "Задачи", icon: <IconList />, padded: true },
{ to: "/jira/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true }, { to: "/jira/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
], ],

View File

@ -6,12 +6,12 @@ import { MODULES } from "./index.ts";
describe("сборка оболочки из модулей", () => { describe("сборка оболочки из модулей", () => {
it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => {
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]);
}); });
it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => {
expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([
["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"],
]); ]);
}); });

View File

@ -46,3 +46,4 @@ export const IconHome = make(<><path d="M4 11l8-7 8 7"/><path d="M6 10v9a1 1 0 0
export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H19v-3"/><path d="M9 7.5h6M9 11h6"/></>); export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H19v-3"/><path d="M9 7.5h6M9 11h6"/></>);
export const IconSprint = make(<><circle cx="12" cy="12" r="8"/><path d="M12 7.5V12l3 2"/></>); export const IconSprint = make(<><circle cx="12" cy="12" r="8"/><path d="M12 7.5V12l3 2"/></>);
export const IconBacklog = make(<><rect x="4" y="4" width="16" height="4.5" rx="1.5"/><rect x="4" y="10" width="16" height="4.5" rx="1.5"/><rect x="4" y="16" width="10" height="4" rx="1.5"/></>); export const IconBacklog = make(<><rect x="4" y="4" width="16" height="4.5" rx="1.5"/><rect x="4" y="10" width="16" height="4.5" rx="1.5"/><rect x="4" y="16" width="10" height="4" rx="1.5"/></>);
export const IconKanban = make(<><rect x="4" y="4" width="4.5" height="16" rx="1.5"/><rect x="9.75" y="4" width="4.5" height="10" rx="1.5"/><rect x="15.5" y="4" width="4.5" height="13" rx="1.5"/></>);