feat(web): список задач — поиск на виду, фильтры панелью с плашками, строка открывает карточку (#65)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3a711ff2ea
commit
7c76c178a4
@ -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);
|
||||
});
|
||||
|
||||
61
web/src/issues/IssueFilters.tsx
Normal file
61
web/src/issues/IssueFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
44
web/src/issues/IssueTable.tsx
Normal file
44
web/src/issues/IssueTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(); });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user