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() {
)}
- | Ключ | Тема | Тип | Статус Jira | Исполнитель | Спринт | Срок | Эпик | Вид работы | Статус по факту | Сосед |
+ | Ключ | Тема | Тип | Статус Jira | Исполнитель | Спринт | Срок | Эпик | Вид работы | Сосед |
{rows.map((i) => (
| {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 ? "да" : ""} |
))}
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 })),