diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 4b1479b..60bd895 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -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); }); diff --git a/web/src/issues/IssueFilters.tsx b/web/src/issues/IssueFilters.tsx new file mode 100644 index 0000000..d1a9594 --- /dev/null +++ b/web/src/issues/IssueFilters.tsx @@ -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; 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 ( + +
+
+ +
+ set("closed", v === "all" ? "1" : "")} /> + + +
+ {chosen.length > 0 && ( +
+ {chosen.map(([n, label]) => ( + set(n, "")}>{label}: {labelOf(n, filters[n]!)} + ))} + +
+ )} + {open && ( +
+ {SELECTS.map(([name, label]) => ( + + + + ))} +
+ )} +
+ ); +} diff --git a/web/src/issues/IssueLink.tsx b/web/src/issues/IssueLink.tsx index dff0d22..fd484ac 100644 --- a/web/src/issues/IssueLink.tsx +++ b/web/src/issues/IssueLink.tsx @@ -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 {children ?? issueKey}; + return {children ?? issueKey}; } diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx index 1259ac0..d31a9e4 100644 --- a/web/src/issues/IssueModal.test.tsx +++ b/web/src/issues/IssueModal.test.tsx @@ -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(); const table = await screen.findByRole("table"); diff --git a/web/src/issues/IssueTable.tsx b/web/src/issues/IssueTable.tsx new file mode 100644 index 0000000..1a026f1 --- /dev/null +++ b/web/src/issues/IssueTable.tsx @@ -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, 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; closed: Set; onOpen(key: string): void; +}) { + const today = todayISO(); + return ( + + + + + {rows.map((i) => { + const due = dueOf(i); + return ( + rowClick(e, i.key, onOpen)}> + + + + + + + + + + + ); + })} + +
КлючТемаСтатус JiraИсполнительСрокСпринтЭпикВид работыТип
{i.neighbor && сосед}{i.summary}{i.status && {i.status}}{i.assignee}{due}{i.sprint}{i.epic_key ? epicNames[i.epic_key] ?? i.epic_key : ""}{i.work_kind}{i.issue_type}
+
+ ); +} diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 76d885a..b4d6d25 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -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) => 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(, { 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(, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" }); const sel = await screen.findByLabelText("Спринт"); @@ -121,6 +124,7 @@ describe("список задач", () => { }); it("значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => { + expanded(); const board = deferred(); 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(() => {}); // снимок не приходит до конца теста @@ -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(, { 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(, { 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(, { 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(, { 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(, { route: "/issues", path: "/issues" }); + expect(await screen.findByLabelText("Спринт")).toBeInTheDocument(); + }); + + it("Снять фильтр плашкой: sprint уходит из адреса, assignee остаётся", async () => { + const fetch = hub(() => [row(1)]); + const { router } = renderWithProviders(, { 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(, + { 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(, { 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(, { route: "/issues", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(screen.getByText("показано 2")).toBeInTheDocument(); + }); + + it("пустая выдача — сведения «По этим фильтрам задач нет»", async () => { + hub(() => []); + renderWithProviders(, { route: "/issues", path: "/issues" }); + expect(await screen.findByText("По этим фильтрам задач нет")).toBeInTheDocument(); + }); + + it("Щелчок по строке: ячейка темы открывает карточку", async () => { + hub(() => [row(1)]); + const { router } = renderWithProviders(, { 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(, { 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(, { 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"); + }); }); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 4419b92..efdae09 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -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(() => + 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 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(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }), + const query = useMemo(() => (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 (
-

Задачи

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

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

+ void list.refetch()}>Повторить}>{(list.error as Error).message} + )} + {rows.length > 0 && } + {list.isPending &&

Загрузка…

} + {!list.isPending && !rows.length && !list.isError && По этим фильтрам задач нет} + {list.hasNextPage && ( +
+ +
)} - - - - {rows.map((i) => ( - - - - - - ))} - -
КлючТемаТипСтатус JiraИсполнительСпринтСрокЭпикВид работыСосед
{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.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 index d334333..04af075 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -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} diff --git a/web/src/shell/AppBar.test.tsx b/web/src/shell/AppBar.test.tsx index 392f599..2e3ef54 100644 --- a/web/src/shell/AppBar.test.tsx +++ b/web/src/shell/AppBar.test.tsx @@ -38,7 +38,7 @@ const open = (path: string) => { render(); return router; }; -const bar = () => screen.getByRole("banner"); +const bar = () => screen.getAllByRole("banner")[0]; // верхняя панель — первая; PageHeader страницы без
тоже даёт banner describe("верхняя панель", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); });