diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx new file mode 100644 index 0000000..31294ff --- /dev/null +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -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 => ({ + 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(); + 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(); + 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"); + }); +}); diff --git a/web/src/modules/jira/agile/KanbanPage.tsx b/web/src/modules/jira/agile/KanbanPage.tsx new file mode 100644 index 0000000..16c48d9 --- /dev/null +++ b/web/src/modules/jira/agile/KanbanPage.tsx @@ -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 ( +
+ } /> + + {(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 && ( +
+ {`Бэклог (${backlog.length})`} +
    {backlog.map((i) => )}
+
+ )} + + + ); + }} +
+
+ ); +} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 28e72f9..8d8191c 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,8 +1,9 @@ import { Navigate, useLocation, useParams } from "react-router"; 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 { BacklogPage } from "./agile/BacklogPage.tsx"; +import { KanbanPage } from "./agile/KanbanPage.tsx"; import { SprintPage } from "./agile/SprintPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx"; @@ -27,6 +28,7 @@ export const jiraModule: HubModule = { { path: "/jira/links", element: {(p) => } }, { path: "/jira/backlog", element: {(p) => } }, { path: "/jira/sprint", element: {(p) => } }, + { path: "/jira/kanban", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории @@ -40,6 +42,7 @@ export const jiraModule: HubModule = { { to: "/jira/links", label: "Связи", icon: }, { to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }, { to: "/jira/sprint", label: "Спринт", icon: , when: (p) => boardType(p) === "scrum" }, + { to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index c9a0a99..3328300 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { 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 с префиксом, плюс вики", () => { 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"], ]); }); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index 814e3bf..a154a2d 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -46,3 +46,4 @@ export const IconHome = make(<>); export const IconSprint = make(<>); export const IconBacklog = make(<>); +export const IconKanban = make(<>);