diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx new file mode 100644 index 0000000..57cb719 --- /dev/null +++ b/web/src/issues/IssuesPage.test.tsx @@ -0,0 +1,88 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setToken } from "../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { renderWithProviders } from "../test/render.tsx"; +import { IssuesPage } from "./IssuesPage.tsx"; + +const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); +const row = (n: number) => ({ ...SNAP.issues[1], key: `AURORA-${9000 + n}`, summary: `Задача ${n}`, fetched_at: "" }); + +function hub(pages: (params: URLSearchParams) => unknown[]) { + return mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/board") return json(200, SNAP); + if (url.pathname === "/issues") return json(200, { issues: pages(url.searchParams) }); + return json(404, {}); + }); +} +const issuesCalls = (fetch: ReturnType) => + fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues"); + +describe("список задач", () => { + beforeEach(() => setToken("tok")); + + it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); + expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); + const q = issuesCalls(fetch)[0].searchParams; + expect(q.get("sprint")).toBe("AWPCA_Спринт 2"); + expect(q.get("neighbor")).toBe("1"); + expect(q.get("limit")).toBe("50"); + expect(q.get("offset")).toBe("0"); + }); + + it("выбор исполнителя пишет его в адрес и в запрос", async () => { + const fetch = hub(() => [row(1)]); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Петров Пётр Петрович"); + await waitFor(() => expect(router.state.location.search).toContain("assignee=")); + await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("assignee")).toBe("Петров Пётр Петрович")); + }); + + it("«Показать ещё» до неполной порции", async () => { + const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51)))); + renderWithProviders(, { route: "/issues", path: "/issues" }); + await userEvent.click(await screen.findByRole("button", { name: "Показать ещё" })); + await waitFor(() => expect(screen.getAllByRole("row")).toHaveLength(1 + 67)); + expect(screen.queryByRole("button", { name: "Показать ещё" })).toBeNull(); + expect(issuesCalls(fetch).at(-1)!.searchParams.get("offset")).toBe("50"); + }); + + it("поиск — один запрос после паузы ввода", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + const before = issuesCalls(fetch).length; + await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод"); + await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("q")).toBe("свод")); + expect(issuesCalls(fetch).length).toBe(before + 1); + }); + + it("хаб не отвечает — сообщение и «Повторить»", async () => { + let down = true; + mockFetch((_m, url) => { + if (url.pathname === "/issues" && down) throw new TypeError("Failed to fetch"); + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/board") return json(200, SNAP); + return json(200, { issues: [row(1)] }); + }); + renderWithProviders(, { route: "/issues", path: "/issues" }); + expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает"); + down = false; + await userEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(await screen.findByRole("link", { name: "AURORA-9001" })).toBeInTheDocument(); + }); + + it("эпик показывается названием из снимка", async () => { + hub(() => [row(1)]); + renderWithProviders(, { route: "/issues", path: "/issues" }); + expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument(); + }); +}); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 2826f58..22eef1c 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -1,2 +1,67 @@ -// временная страница (заменяется в Task 13) -export function IssuesPage() { return
; } +import { useEffect, useMemo, useState } from "react"; +import { Link, useSearchParams } from "react-router"; +import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts"; +import { dueOf, epicNames, filterOptions } from "./options.ts"; +import "./issues.css"; + +const SELECTS = [ + ["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], + ["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"], +] as const; +const Q_DELAY = 300; // поиск — после паузы ввода (R-7) + +export function IssuesPage() { + const [params, setParams] = useSearchParams(); + const filters = useMemo(() => + Object.fromEntries(ISSUE_FILTER_NAMES.map((n) => [n, params.get(n) ?? ""]).filter(([, v]) => v)), [params]); + const set = (name: string, value: string) => setParams((p) => { const n = new URLSearchParams(p); if (value) n.set(name, value); else n.delete(name); return n; }); + const [q, setQ] = useState(filters.q ?? ""); + useEffect(() => { + if (q === (filters.q ?? "")) return; + const t = setTimeout(() => setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }), Q_DELAY); + return () => clearTimeout(t); + }, [q, filters.q, setParams]); + + const board = useBoard(), settings = useSettings(); + const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); + const names = useMemo(() => epicNames(board.data), [board.data]); + const list = useIssues(filters); + const rows = list.data?.pages.flat() ?? []; + + return ( +
+

Задачи

+
+ {SELECTS.map(([name, label]) => ( + + ))} + + setQ(e.target.value)} /> +
+ {list.isError && ( +

{(list.error as Error).message}{" "} +

+ )} + + + + {rows.map((i) => ( + + + + + + ))} + +
КлючТемаТипСтатус JiraИсполнительСпринтСрокЭпикВид работыСтатус по фактуСосед
{i.key}{i.summary}{i.issue_type}{i.status}{i.assignee}{i.sprint}{dueOf(i)}{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}{i.work_kind}{i.fact_status}{i.neighbor ? "да" : ""}
+ {list.isPending &&

загрузка…

} + {!list.isPending && !rows.length && !list.isError &&

По этим фильтрам задач нет.

} + {list.hasNextPage && } +
+ ); +} diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css new file mode 100644 index 0000000..67790f2 --- /dev/null +++ b/web/src/issues/issues.css @@ -0,0 +1,8 @@ +.issues .filters{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:end;margin:12px 0} +.issues .filters label{display:flex;flex-direction:column;font-size:13px;color:var(--muted);gap:2px} +.issues .filters label.check{flex-direction:row;align-items:center;gap:6px;color:var(--fg)} +.issues select,.issues input[type=search]{font:inherit;font-size:14px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px} +.grid{width:100%;border-collapse:collapse;font-size:14px} +.grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top} +.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)} +.grid a{color:var(--accent)} diff --git a/web/src/issues/options.ts b/web/src/issues/options.ts new file mode 100644 index 0000000..5805eed --- /dev/null +++ b/web/src/issues/options.ts @@ -0,0 +1,26 @@ +import type { BoardSnapshot, HubSettings } from "../api/types.ts"; + +type Opt = { value: string; label: string }; +const uniq = (xs: (string | null | undefined)[]) => [...new Set(xs.filter((x): x is string => !!x))].sort((a, b) => a.localeCompare(b, "ru")); +const isEpic = (t: string | null) => /^(Фича|Epic|Эпик)$/i.test(t ?? ""); + +export const epicNames = (snap: BoardSnapshot | undefined): Record => + Object.fromEntries((snap?.issues ?? []).filter((i) => isEpic(i.issue_type)).map((i) => [i.key, i.summary || i.key])); + +export const dueOf = (i: { planned_end: string | null; duedate: string | null }) => (i.planned_end || i.duedate || "").slice(0, 10); + +// варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API +export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | undefined) { + const issues = snap?.issues ?? []; + const names = epicNames(snap); + const opt = (xs: string[]): Opt[] => xs.map((v) => ({ value: v, label: v })); + return { + sprint: opt(uniq(issues.map((i) => i.sprint))), + assignee: opt(uniq(issues.map((i) => i.assignee))), + work_kind: opt(uniq([...Object.keys(s?.kind_labels ?? {}), ...issues.map((i) => i.work_kind)])), + fact_status: opt(uniq([...Object.keys(s?.status_labels ?? {}), ...issues.map((i) => i.fact_status)])), + status: opt(uniq(issues.map((i) => i.status))), + label: opt(uniq(issues.flatMap((i) => i.labels))), + epic: uniq(issues.map((i) => i.epic_key)).map((k) => ({ value: k, label: names[k] ?? k })), + }; +}