feat(web): список задач зеркала — фильтры в адресе, порции по 50, поиск (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
178606f221
commit
d8e3e8656a
88
web/src/issues/IssuesPage.test.tsx
Normal file
88
web/src/issues/IssuesPage.test.tsx
Normal file
@ -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<typeof hub>) =>
|
||||
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(<IssuesPage />, { 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(<IssuesPage />, { 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(<IssuesPage />, { 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(<IssuesPage />, { 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(<IssuesPage />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||||
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,2 +1,67 @@
|
||||
// временная страница (заменяется в Task 13)
|
||||
export function IssuesPage() { return <div data-testid="page-issues" />; }
|
||||
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<IssueFilters>(() =>
|
||||
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 (
|
||||
<div data-testid="page-issues" className="issues">
|
||||
<h1>Задачи</h1>
|
||||
<div className="filters">
|
||||
{SELECTS.map(([name, label]) => (
|
||||
<label key={name}>{label}
|
||||
<select aria-label={label} value={filters[name] ?? ""} onChange={(e) => set(name, e.target.value)}>
|
||||
<option value="">все</option>
|
||||
{opts[name].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<label className="check"><input type="checkbox" checked={filters.neighbor === "1"} onChange={(e) => set("neighbor", e.target.checked ? "1" : "")} />только соседи</label>
|
||||
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
{list.isError && (
|
||||
<p className="warn" role="alert">{(list.error as Error).message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
<table className="grid">
|
||||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Статус по факту</th><th>Сосед</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((i) => (
|
||||
<tr key={i.key}>
|
||||
<td><Link to={`/issues/${i.key}`}>{i.key}</Link></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
|
||||
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
|
||||
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.isPending && <p className="hint">загрузка…</p>}
|
||||
{!list.isPending && !rows.length && !list.isError && <p className="hint">По этим фильтрам задач нет.</p>}
|
||||
{list.hasNextPage && <button className="btn" type="button" disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
8
web/src/issues/issues.css
Normal file
8
web/src/issues/issues.css
Normal file
@ -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)}
|
||||
26
web/src/issues/options.ts
Normal file
26
web/src/issues/options.ts
Normal file
@ -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<string, string> =>
|
||||
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 })),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user