From dbda1ad77cc560e700c2ec49a8353f208499291a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:37:14 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87:=20=C2=AB=D1=81=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5?= =?UTF-8?q?=C2=BB=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8E,=20=D0=B1=D0=B5=D0=B7=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=B0=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/issues.spec.ts | 14 ++++- web/src/api/closed.test.ts | 25 +++++++++ web/src/api/closed.ts | 14 +++++ web/src/api/queries.ts | 5 +- web/src/api/types.ts | 4 +- web/src/board/__fixtures__/settings.json | 3 +- web/src/issues/IssuesPage.test.tsx | 68 ++++++++++++++++++++++++ web/src/issues/IssuesPage.tsx | 17 ++++-- web/src/issues/options.ts | 1 - 9 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 web/src/api/closed.test.ts create mode 100644 web/src/api/closed.ts diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 912aa74..eca317d 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -23,7 +23,7 @@ test("фильтр → карточка окном → заметка сохра test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 320 }); await login(page, "/app/issues"); - await expect(page.locator("table.grid tbody tr")).toHaveCount(10); + await expect(page.locator("table.grid 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(); @@ -173,3 +173,15 @@ test("узкий экран: окно на всю ширину, заметки await expect(d.getByRole("columnheader", { name: "Источник" })).toBeAttached(); expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); }); + +test("закрытые скрыты по умолчанию, снятый флажок показывает их", async ({ page }) => { + await login(page, "/app/issues"); + await expect(page.locator("table.grid 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 expect(page).toHaveURL(/closed=1/); + await expect(hide).not.toBeChecked(); + await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible(); + await expect(page.locator("table.grid tbody tr")).toHaveCount(10); +}); diff --git a/web/src/api/closed.test.ts b/web/src/api/closed.test.ts new file mode 100644 index 0000000..0943f10 --- /dev/null +++ b/web/src/api/closed.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { closedStatuses, isClosed } from "./closed.ts"; + +describe("закрытые статусы", () => { + const set = closedStatuses({ closed_statuses: ["Готово", "Снято"] }); + + it("набор — из closed_statuses настроек", () => { + expect([...set]).toEqual(["Готово", "Снято"]); + }); + + it("нет настройки или она не список — ничего не закрыто", () => { + expect(closedStatuses({}).size).toBe(0); + expect(closedStatuses(undefined).size).toBe(0); + expect(closedStatuses({ closed_statuses: "Готово" }).size).toBe(0); + expect([...closedStatuses({ closed_statuses: ["Готово", 5] })]).toEqual(["Готово"]); + }); + + it("закрыта — только задача со статусом из набора", () => { + expect(isClosed("Снято", set)).toBe(true); + expect(isClosed("В работе", set)).toBe(false); + expect(isClosed("", set)).toBe(false); + expect(isClosed(null, set)).toBe(false); + expect(isClosed(undefined, set)).toBe(false); + }); +}); diff --git a/web/src/api/closed.ts b/web/src/api/closed.ts new file mode 100644 index 0000000..3a8cde1 --- /dev/null +++ b/web/src/api/closed.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; +import { useSettings } from "./queries.ts"; +import type { HubSettings } from "./types.ts"; + +// закрытые задачи (hide-closed): статус Jira из настройки closed_statuses; нет настройки или она не список — закрытых нет +export const closedStatuses = (s: HubSettings | undefined): Set => + new Set(Array.isArray(s?.closed_statuses) ? s.closed_statuses.filter((x): x is string => typeof x === "string") : []); + +export const isClosed = (status: string | null | undefined, closed: Set): boolean => !!status && closed.has(status); + +export function useClosedStatuses(): Set { + const settings = useSettings(); + return useMemo(() => closedStatuses(settings.data), [settings.data]); +} diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 77a9ff6..c38aa2f 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -2,8 +2,9 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta import { api } from "./client.ts"; import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; -export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const; -export type IssueFilters = Partial>; +// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) +export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; +export type IssueFilters = Partial>; export const ISSUE_PAGE = 50; export const qk = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 544932a..c727781 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -11,11 +11,11 @@ export interface SyncRun { started_at: string; finished_at: string; issues_count export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] } // словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira, -// status_labels — статус по факту → метка Jira +// closed_statuses — статусы Jira закрытых задач export interface HubSettings { kind_labels?: Record; link_types?: Record; - status_labels?: Record; + closed_statuses?: unknown; project_key?: string; [key: string]: unknown; } diff --git a/web/src/board/__fixtures__/settings.json b/web/src/board/__fixtures__/settings.json index 15d12bc..a66c22d 100644 --- a/web/src/board/__fixtures__/settings.json +++ b/web/src/board/__fixtures__/settings.json @@ -1,5 +1,6 @@ { "kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"}, "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"}, - "status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"} + "status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}, + "closed_statuses": ["Готово", "Снято"] } diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 76f404d..32c03dd 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -150,4 +150,72 @@ describe("список задач", () => { expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); }); + + const hideBox = () => screen.getByRole("checkbox", { name: "скрыть закрытые" }); + + 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(); + 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 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 waitFor(() => expect(router.state.location.search).toBe("")); + expect(hideBox()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть + }); + + 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(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true); + }); + + it("выбран закрытый статус Jira — hide_closed не отправляется", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?status=Готово", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + const q = issuesCalls(fetch).at(-1)!.searchParams; + expect(q.get("status")).toBe("Готово"); + expect(q.has("hide_closed")).toBe(false); + expect(hideBox()).toBeChecked(); + }); + + it("выбран незакрытый статус Jira — hide_closed отправляется", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?status=В работе", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1"); + }); + + it("настройки пришли позже — запрос по закрытому статусу без hide_closed", async () => { + const st = deferred(); + const fetch = mockFetch((_m, url) => { + if (url.pathname === "/settings") return st.promise; + if (url.pathname === "/board") return json(200, SNAP); + return json(200, { issues: [row(1)] }); + }); + renderWithProviders(, { route: "/issues?status=Снято", path: "/issues" }); + st.resolve(json(200, settings)); + await screen.findByRole("link", { name: "AURORA-9001" }); + const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues"); + await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false)); + }); + + it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(screen.queryByLabelText("Статус по факту")).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull(); + expect(issuesCalls(fetch).every((u) => !u.searchParams.has("fact_status"))).toBe(true); + }); }); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 44da18b..7157572 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -1,13 +1,14 @@ 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 "./issues.css"; const SELECTS = [ - ["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], - ["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"], + ["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["status", "Статус Jira"], + ["label", "Метка"], ["epic", "Эпик"], ] as const; const Q_DELAY = 300; // поиск — после паузы ввода (R-7) @@ -37,7 +38,12 @@ export function IssuesPage() { const board = useBoard(), settings = useSettings(); const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); const names = useMemo(() => epicNames(board.data), [board.data]); - const list = useIssues(filters); + // закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет + const showClosed = params.get("closed") === "1"; + const closed = useClosedStatuses(); + const query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }), + [filters, showClosed, closed]); + const list = useIssues(query); const rows = list.data?.pages.flat() ?? []; return ( @@ -53,6 +59,7 @@ export function IssuesPage() { ))} + setQ(e.target.value)} /> {list.isError && ( @@ -60,13 +67,13 @@ export function IssuesPage() {

)} - + {rows.map((i) => ( - + ))} diff --git a/web/src/issues/options.ts b/web/src/issues/options.ts index aa151d7..1be05d4 100644 --- a/web/src/issues/options.ts +++ b/web/src/issues/options.ts @@ -24,7 +24,6 @@ export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | sprint: opt(uniq(issues.map((i) => i.sprint))), assignee: opt(uniq(issues.map((i) => i.assignee))), work_kind: opt(uniq([...Object.keys(s?.kind_labels ?? {}), ...issues.map((i) => i.work_kind)])), - fact_status: opt(uniq([...Object.keys(s?.status_labels ?? {}), ...issues.map((i) => i.fact_status)])), status: opt(uniq(issues.map((i) => i.status))), label: opt(uniq(issues.flatMap((i) => i.labels))), epic: uniq(issues.map((i) => i.epic_key)).map((k) => ({ value: k, label: names[k] ?? k })),
КлючТемаТипСтатус JiraИсполнительСпринтСрокЭпикВид работыСтатус по фактуСосед
КлючТемаТипСтатус 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.fact_status}{i.neighbor ? "да" : ""}{i.work_kind}{i.neighbor ? "да" : ""}