feat(web): список задач — поиск на виду, фильтры панелью с плашками, строка открывает карточку (#65)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 00:51:58 +04:00
parent 3a711ff2ea
commit 7c76c178a4
9 changed files with 327 additions and 73 deletions

View File

@ -3,8 +3,10 @@ import { login } from "./hub.ts";
test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
await login(page, "/app/issues");
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
await page.getByRole("button", { name: /Фильтры/ }).click();
await page.getByRole("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/);
await expect(page.getByText("Исполнитель: Петров Пётр Петрович")).toBeVisible(); // плашка
await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
@ -17,16 +19,37 @@ test("фильтр → карточка окном → заметка сохра
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA&assignee=/);
await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
await expect(page.getByRole("combobox", { name: "Исполнитель" })).toHaveValue("Петров Пётр Петрович"); // панель осталась раскрытой (sessionStorage)
});
test("фильтр в адресе в новой вкладке: панель свёрнута, плашка и счётчик видны, плашка снимает фильтр", async ({ page }) => {
await login(page, "/app/issues?project=AWPCA&assignee=" + encodeURIComponent("Петров Пётр Петрович"));
await page.evaluate(() => sessionStorage.clear());
await page.reload();
const btn = page.getByRole("button", { name: /Фильтры/ });
await expect(btn).toHaveAttribute("aria-expanded", "false");
await expect(btn).toContainText("1");
await expect(page.getByRole("combobox", { name: "Исполнитель" })).toHaveCount(0);
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(2);
await page.getByRole("button", { name: "Снять фильтр Исполнитель" }).click();
await expect(page).not.toHaveURL(/assignee=/);
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9);
});
test("щелчок по ячейке строки открывает карточку, по выделенному тексту — нет", async ({ page }) => {
await login(page, "/app/issues");
await page.getByRole("cell", { name: "Схема свода" }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001/);
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
});
test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 320 });
await login(page, "/app/issues");
await expect(page.locator("table.grid tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(50);
await page.locator("table.grid tbody tr").last().getByRole("link").click();
await page.locator("table.issue-table tbody tr").last().getByRole("link").click();
await expect(page.getByRole("dialog")).toBeVisible();
expect(await page.evaluate(() => window.scrollY)).toBe(y);
await page.keyboard.press("Escape");
@ -174,14 +197,13 @@ test("узкий экран: окно на всю ширину, заметки
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
});
test("закрытые скрыты по умолчанию, снятый флажок показывает их", async ({ page }) => {
test("закрытые скрыты по умолчанию, «Все» показывает их", async ({ page }) => {
await login(page, "/app/issues");
await expect(page.locator("table.grid tbody tr")).toHaveCount(9);
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9);
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0);
const hide = page.getByRole("checkbox", { name: "скрыть закрытые" });
await hide.click(); // не uncheck(): адрес меняется навигацией, флажок следует за ним не сразу
await page.getByRole("radio", { name: "Все" }).click();
await expect(page).toHaveURL(/closed=1/);
await expect(hide).not.toBeChecked();
await expect(page.getByRole("radio", { name: "Все" })).toBeChecked();
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible();
await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(10);
});

View File

@ -0,0 +1,61 @@
import { useState } from "react";
import { Badge, Button, Chip, Field, IconFilter, Panel, SearchInput, Segmented, Select } from "../ui/index.ts";
import type { IssueFilters as Filters } from "../api/queries.ts";
import { withCurrent, type Opt } from "./options.ts";
export const SELECTS = [
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["status", "Статус Jira"],
["label", "Метка"], ["epic", "Эпик"],
] as const;
type Name = (typeof SELECTS)[number][0];
// раскрыта ли панель «Фильтры» — на время сессии вкладки; ошибки хранилища глотаем
const KEY = "jira-hub-issue-filters";
const readOpen = () => { try { return sessionStorage.getItem(KEY) === "1"; } catch { return false; } };
const saveOpen = (open: boolean) => { try { sessionStorage.setItem(KEY, open ? "1" : "0"); } catch { /* только в памяти */ } };
export function IssueFilters({ filters, set, onReset, q, setQ, showClosed, opts, hasBoard }: {
filters: Filters; set(name: string, value: string): void; onReset(): void;
q: string; setQ(v: string): void; showClosed: boolean; opts: Record<Name, Opt[]>; hasBoard: boolean;
}) {
const [open, setOpen] = useState(readOpen);
const toggle = () => setOpen((o) => { saveOpen(!o); return !o; });
const labelOf = (name: Name, value: string) => opts[name].find((o) => o.value === value)?.label ?? value;
const chosen = SELECTS.filter(([n]) => filters[n]);
return (
<Panel className="issue-filters">
<div className="issue-filters__row">
<div className="issue-filters__search">
<SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема" value={q} onChange={setQ} />
</div>
<Segmented label="Закрытые задачи" value={showClosed ? "all" : "open"}
options={[{ value: "open", label: "Открытые" }, { value: "all", label: "Все" }]}
onChange={(v) => set("closed", v === "all" ? "1" : "")} />
<Button aria-pressed={filters.neighbor === "1"} onClick={() => set("neighbor", filters.neighbor === "1" ? "" : "1")}>Только соседи</Button>
<Button icon={<IconFilter />} aria-expanded={open} aria-controls="issue-filters-grid" onClick={toggle}>
Фильтры{chosen.length > 0 && <Badge tone="info">{chosen.length}</Badge>}
</Button>
</div>
{chosen.length > 0 && (
<div className="issue-filters__chips">
{chosen.map(([n, label]) => (
<Chip key={n} removeLabel={`Снять фильтр ${label}`} onRemove={() => set(n, "")}>{label}: {labelOf(n, filters[n]!)}</Chip>
))}
<Button variant="ghost" onClick={onReset}>Сбросить</Button>
</div>
)}
{open && (
<div className="issue-filters__grid" id="issue-filters-grid">
{SELECTS.map(([name, label]) => (
<Field key={name} label={label}>
<Select aria-label={label} value={filters[name] ?? ""} onChange={(e) => set(name, e.target.value)}>
<option value="">все</option>
{withCurrent(opts[name], filters[name], hasBoard).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
</Field>
))}
</div>
)}
</Panel>
);
}

View File

@ -1,9 +1,18 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "react-router";
import { Link, useLocation, useNavigate } from "react-router";
import { openCardState } from "./modal.ts";
const cardPath = (key: string) => `/issues/${encodeURIComponent(key)}`;
// переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа
export function useOpenIssue(): (key: string) => void {
const loc = useLocation();
const navigate = useNavigate();
return (key) => void navigate(cardPath(key), { state: openCardState(loc) });
}
// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) {
const loc = useLocation();
return <Link className={className} to={`/issues/${encodeURIComponent(issueKey)}`} state={openCardState(loc)}>{children ?? issueKey}</Link>;
return <Link className={className} to={cardPath(issueKey)} state={openCardState(loc)}>{children ?? issueKey}</Link>;
}

View File

@ -32,6 +32,7 @@ describe("карточка окном", () => {
beforeEach(() => { setToken("tok"); hub(); });
it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
sessionStorage.setItem("jira-hub-issue-filters", "1"); // панель «Фильтры» раскрыта
const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]);
render(<App router={router} />);
const table = await screen.findByRole("table");

View File

@ -0,0 +1,44 @@
import type { MouseEvent } from "react";
import { Badge, Panel } from "../ui/index.ts";
import type { IssueRow } from "../api/types.ts";
import { isOverdue, todayISO } from "./card.ts";
import { IssueLink } from "./IssueLink.tsx";
import { dueOf } from "./options.ts";
// щелчок по строке открывает карточку, если это не ссылка/кнопка и не выделение текста
function rowClick(e: MouseEvent<HTMLTableRowElement>, key: string, onOpen: (key: string) => void) {
if ((e.target as HTMLElement).closest("a, button")) return;
if (window.getSelection()?.toString()) return;
onOpen(key);
}
export function IssueTable({ rows, epicNames, closed, onOpen }: {
rows: IssueRow[]; epicNames: Record<string, string>; closed: Set<string>; onOpen(key: string): void;
}) {
const today = todayISO();
return (
<Panel padding="none" className="issue-table-wrap">
<table className="issue-table">
<thead><tr><th>Ключ</th><th>Тема</th><th>Статус Jira</th><th>Исполнитель</th><th>Срок</th><th>Спринт</th><th>Эпик</th><th>Вид работы</th><th>Тип</th></tr></thead>
<tbody>
{rows.map((i) => {
const due = dueOf(i);
return (
<tr key={i.key} onClick={(e) => rowClick(e, i.key, onOpen)}>
<td className="issue-table__key"><IssueLink issueKey={i.key} />{i.neighbor && <Badge tone="neutral">сосед</Badge>}</td>
<td className="issue-table__sum">{i.summary}</td>
<td>{i.status && <Badge tone="info">{i.status}</Badge>}</td>
<td>{i.assignee}</td>
<td className={isOverdue(due, i.status, closed, today) ? "overdue" : undefined}>{due}</td>
<td>{i.sprint}</td>
<td>{i.epic_key ? epicNames[i.epic_key] ?? i.epic_key : ""}</td>
<td>{i.work_kind}</td>
<td>{i.issue_type}</td>
</tr>
);
})}
</tbody>
</table>
</Panel>
);
}

View File

@ -2,7 +2,7 @@ import { act, screen, waitFor, within } 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 { beforeEach, describe, expect, it, vi } from "vitest";
import { qk } from "../api/queries.ts";
import { setToken } from "../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
@ -26,7 +26,8 @@ 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"));
beforeEach(() => { setToken("tok"); sessionStorage.clear(); });
const expanded = () => sessionStorage.setItem("jira-hub-issue-filters", "1");
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
const fetch = hub(() => [row(1)]);
@ -41,6 +42,7 @@ describe("список задач", () => {
});
it("выбор исполнителя пишет его в адрес и в запрос", async () => {
expanded();
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
@ -113,6 +115,7 @@ describe("список задач", () => {
});
it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => {
expanded();
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
const sel = await screen.findByLabelText("Спринт");
@ -121,6 +124,7 @@ describe("список задач", () => {
});
it("значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => {
expanded();
const board = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
@ -140,6 +144,7 @@ describe("список задач", () => {
});
it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => {
expanded();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return new Promise<Response>(() => {}); // снимок не приходит до конца теста
@ -152,31 +157,32 @@ describe("список задач", () => {
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
});
const hideBox = () => screen.getByRole("checkbox", { name: "скрыть закрытые" });
const openedRadio = () => screen.getByRole("radio", { name: "Открытые" });
const allRadio = () => screen.getByRole("radio", { name: "Все" });
it("закрытые скрыты по умолчанию; снятый флажок — closed=1 и запрос без hide_closed", async () => {
it("закрытые скрыты по умолчанию; «Все» — closed=1 и запрос без hide_closed", async () => {
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(hideBox()).toBeChecked();
expect(openedRadio()).toBeChecked();
let q = issuesCalls(fetch).at(-1)!.searchParams;
expect(q.get("hide_closed")).toBe("1");
expect(q.has("closed")).toBe(false);
await userEvent.click(hideBox());
await userEvent.click(allRadio());
await waitFor(() => expect(router.state.location.search).toBe("?closed=1"));
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("hide_closed")).toBe(false));
q = issuesCalls(fetch).at(-1)!.searchParams;
expect(q.has("closed")).toBe(false);
await userEvent.click(hideBox());
await userEvent.click(openedRadio());
await waitFor(() => expect(router.state.location.search).toBe(""));
expect(hideBox()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть
expect(openedRadio()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть
});
it("адрес closed=1 — флажок снят, запрос без hide_closed", async () => {
it("адрес closed=1 — выбрано «Все», запрос без hide_closed", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?closed=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(hideBox()).not.toBeChecked();
expect(allRadio()).toBeChecked();
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true);
});
@ -187,7 +193,7 @@ describe("список задач", () => {
const q = issuesCalls(fetch).at(-1)!.searchParams;
expect(q.get("status")).toBe("Готово");
expect(q.has("hide_closed")).toBe(false);
expect(hideBox()).toBeChecked();
expect(openedRadio()).toBeChecked();
});
it("выбран незакрытый статус Jira — hide_closed отправляется", async () => {
@ -219,4 +225,111 @@ describe("список задач", () => {
expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull();
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("fact_status"))).toBe(true);
});
describe("панель фильтров", () => {
const URL2 = "/issues?sprint=AWPCA_Спринт 2&assignee=Петров Пётр Петрович";
it("Фильтр в адресе: счётчик 2, плашки, панель свёрнута", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: URL2, path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const btn = screen.getByRole("button", { name: /Фильтры/ });
expect(btn).toHaveTextContent("2");
expect(btn).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Спринт: AWPCA_Спринт 2")).toBeInTheDocument();
expect(screen.getByText("Исполнитель: Петров Пётр Петрович")).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Спринт" })).toBeNull();
});
it("кнопка «Фильтры» раскрывает панель и запоминает это в sessionStorage", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.click(screen.getByRole("button", { name: /Фильтры/ }));
expect(screen.getByRole("button", { name: /Фильтры/ })).toHaveAttribute("aria-expanded", "true");
for (const n of ["Спринт", "Исполнитель", "Вид работы", "Статус Jira", "Метка", "Эпик"]) expect(screen.getByLabelText(n)).toBeInTheDocument();
expect(sessionStorage.getItem("jira-hub-issue-filters")).toBe("1");
});
it("панель раскрыта по sessionStorage", async () => {
sessionStorage.setItem("jira-hub-issue-filters", "1");
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByLabelText("Спринт")).toBeInTheDocument();
});
it("Снять фильтр плашкой: sprint уходит из адреса, assignee остаётся", async () => {
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: URL2, path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.click(screen.getByRole("button", { name: "Снять фильтр Спринт" }));
await waitFor(() => expect(router.state.location.search).not.toContain("sprint="));
expect(router.state.location.search).toContain("assignee=");
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("sprint")).toBe(false));
});
it("«Сбросить» снимает шесть списков, поиск и «Только соседи» остаются", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />,
{ route: "/issues?sprint=AWPCA_Спринт 2&assignee=Петров Пётр Петрович&status=В работе&q=свод&neighbor=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(router.state.location.search).toBe("?q=%D1%81%D0%B2%D0%BE%D0%B4&neighbor=1"));
expect(screen.queryByRole("button", { name: "Сбросить" })).toBeNull();
});
it("«Только соседи» — кнопка с aria-pressed, пишет neighbor=1", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const b = screen.getByRole("button", { name: "Только соседи" });
expect(b).toHaveAttribute("aria-pressed", "false");
await userEvent.click(b);
await waitFor(() => expect(router.state.location.search).toBe("?neighbor=1"));
expect(screen.getByRole("button", { name: "Только соседи" })).toHaveAttribute("aria-pressed", "true");
});
});
it("подзаголовок: показано N", async () => {
hub(() => [row(1), row(2)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(screen.getByText("показано 2")).toBeInTheDocument();
});
it("пустая выдача — сведения «По этим фильтрам задач нет»", async () => {
hub(() => []);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByText("По этим фильтрам задач нет")).toBeInTheDocument();
});
it("Щелчок по строке: ячейка темы открывает карточку", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await userEvent.click(await screen.findByRole("cell", { name: "Задача 1" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/issues/AURORA-9001"));
});
it("выделенный текст в строке — без перехода", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
const cell = await screen.findByRole("cell", { name: "Задача 1" });
const sel = vi.spyOn(window, "getSelection").mockReturnValue({ toString: () => "Задач" } as unknown as Selection);
await userEvent.click(cell);
sel.mockRestore();
expect(router.state.location.pathname).toBe("/issues");
});
it("просрочено: класс overdue у срока незакрытой задачи с прошедшим сроком, у закрытой нет", async () => {
hub(() => [
{ ...row(1), planned_end: "2000-01-01", status: "В работе" },
{ ...row(2), planned_end: "2000-01-01", status: "Готово" },
{ ...row(3), planned_end: "2999-01-01", status: "В работе" },
]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?closed=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const dueCell = (n: number) => within(screen.getByRole("row", { name: new RegExp(`AURORA-${9000 + n}`) })).getByText(/^(2000|2999)-01-01$/);
await waitFor(() => expect(dueCell(1)).toHaveClass("overdue"));
expect(dueCell(2)).not.toHaveClass("overdue");
expect(dueCell(3)).not.toHaveClass("overdue");
});
});

View File

@ -1,22 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { isClosed, useClosedStatuses } from "../api/closed.ts";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
import { IssueLink } from "./IssueLink.tsx";
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters as Filters } from "../api/queries.ts";
import { Button, Notice, PageHeader } from "../ui/index.ts";
import { IssueFilters, SELECTS } from "./IssueFilters.tsx";
import { useOpenIssue } from "./IssueLink.tsx";
import { IssueTable } from "./IssueTable.tsx";
import { epicNames, filterOptions } from "./options.ts";
import "./issues.css";
const SELECTS = [
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["status", "Статус Jira"],
["label", "Метка"], ["epic", "Эпик"],
] as const;
const Q_DELAY = 300; // поиск — после паузы ввода (R-7)
export function IssuesPage({ project }: { project: string }) {
const [params, setParams] = useSearchParams();
const filters = useMemo<IssueFilters>(() =>
const filters = useMemo<Filters>(() =>
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 reset = () => setParams((p) => { const n = new URLSearchParams(p); SELECTS.forEach(([name]) => n.delete(name)); return n; });
const [q, setQ] = useState(filters.q ?? "");
const written = useRef(filters.q ?? ""); // последнее значение q, согласованное с адресом (записанное нами или принятое из него)
useEffect(() => {
@ -38,49 +38,30 @@ export function IssuesPage({ project }: { project: string }) {
const board = useBoard(project), settings = useSettings();
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
const names = useMemo(() => epicNames(board.data), [board.data]);
// закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет
// закрытые скрыты, пока не выбрано «Все» (closed=1); выбранный закрытый статус Jira скрытие отменяет
const showClosed = params.get("closed") === "1";
const closed = useClosedStatuses();
const query = useMemo<IssueFilters>(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }),
const query = useMemo<Filters>(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }),
[filters, project, showClosed, closed]);
const list = useIssues(query);
const rows = list.data?.pages.flat() ?? [];
const open = useOpenIssue();
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>
{withCurrent(opts[name], filters[name], !!board.data).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>
<label className="check"><input type="checkbox" checked={!showClosed} onChange={(e) => set("closed", e.target.checked ? "" : "1")} />скрыть закрытые</label>
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<PageHeader title="Задачи" subtitle={list.isPending ? undefined : `показано ${rows.length}${list.hasNextPage ? " · есть ещё" : ""}`} />
<IssueFilters filters={filters} set={set} onReset={reset} q={q} setQ={setQ} showClosed={showClosed} opts={opts} hasBoard={!!board.data} />
{list.isError && (
<p className="warn" role="alert">{(list.error as Error).message}{" "}
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
<Notice tone="error" action={<Button size="md" onClick={() => void list.refetch()}>Повторить</Button>}>{(list.error as Error).message}</Notice>
)}
{rows.length > 0 && <IssueTable rows={rows} epicNames={names} closed={closed} onOpen={open} />}
{list.isPending && <p className="issues__hint">Загрузка…</p>}
{!list.isPending && !rows.length && !list.isError && <Notice tone="info">По этим фильтрам задач нет</Notice>}
{list.hasNextPage && (
<div className="issues__more">
<Button disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</Button>
</div>
)}
<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></tr></thead>
<tbody>
{rows.map((i) => (
<tr key={i.key}>
<td><IssueLink issueKey={i.key} /></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.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>
);
}

View File

@ -1,11 +1,34 @@
.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)}
.issues{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)}
.issues .ui-page-header{margin-bottom:0}
.issue-filters{display:flex;flex-direction:column;gap:var(--space-3)}
.issue-filters__row{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
.issue-filters__search{flex:1 1 280px;min-width:0;max-width:480px}
.issue-filters__row .ui-btn .ui-badge{margin-left:var(--space-2)}
.issue-filters__row .ui-btn[aria-pressed="true"],.issue-filters__row .ui-btn[aria-expanded="true"]{border-color:var(--color-border-selected);color:var(--color-action-primary)}
.issue-filters__chips{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
.issue-filters__grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-3) var(--space-4)}
@media (max-width:899.98px){.issue-filters__grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:639.98px){.issue-filters__grid{grid-template-columns:minmax(0,1fr)}.issue-filters__search{max-width:none}}
/* прокрутка внутри панели — только на узких экранах: overflow на предке отключает липкую шапку страницы */
@media (max-width:1099.98px){.issue-table-wrap{overflow-x:auto}}
.issue-table th:first-child{border-top-left-radius:calc(var(--radius-xl) - 1px)}
.issue-table th:last-child{border-top-right-radius:calc(var(--radius-xl) - 1px)}
.issue-table tbody tr:last-child td:first-child{border-bottom-left-radius:calc(var(--radius-xl) - 1px)}
.issue-table tbody tr:last-child td:last-child{border-bottom-right-radius:calc(var(--radius-xl) - 1px)}
.issue-table{width:100%;border-collapse:separate;border-spacing:0;font-size:var(--fs-body);line-height:var(--lh-body)}
.issue-table th,.issue-table td{height:44px;padding:0 var(--space-3);text-align:left;vertical-align:middle;border-bottom:1px solid var(--color-border-default)}
.issue-table tbody tr:last-child td{border-bottom:0}
.issue-table th{position:sticky;top:0;z-index:1;background:var(--color-bg-surface);color:var(--color-text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;white-space:nowrap}
.issue-table td{color:var(--color-text-primary)}
.issue-table tbody tr{cursor:pointer}
.issue-table tbody tr:hover td{background:var(--color-bg-hover)}
.issue-table__key{white-space:nowrap}
.issue-table__key a{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none;margin-right:var(--space-2)}
.issue-table__key a:hover{text-decoration:underline}
.issue-table__sum{min-width:240px}
.issue-table td.overdue{color:var(--color-status-error);font-weight:600}
.issues__hint{margin:0;color:var(--color-text-secondary)}
.issues__more{display:flex;justify-content:center}
.issue a{color:var(--accent)}
.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}

View File

@ -38,7 +38,7 @@ const open = (path: string) => {
render(<App router={router} />);
return router;
};
const bar = () => screen.getByRole("banner");
const bar = () => screen.getAllByRole("banner")[0]; // верхняя панель — первая; PageHeader страницы без <main> тоже даёт banner
describe("верхняя панель", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });