From a002812f127b44f34839a649293d86ca92da3b84 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:25:10 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B5=20=C2=AB=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BD=D0=B5=D1=82=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB=D0=B5=C2=BB=20=D0=B2=20.is?= =?UTF-8?q?sue,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0,=20wi?= =?UTF-8?q?thCurrent=20=D0=B2=D1=8B=D1=88=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F=20(#29/O-10,=20T5.1,?= =?UTF-8?q?=20T5.4,=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/issues/IssuePage.test.tsx | 7 +++++++ web/src/issues/IssuePage.tsx | 2 +- web/src/issues/IssuesPage.test.tsx | 18 ++++++++++++++++++ web/src/issues/options.ts | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx index 2f9fa35..801f37e 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssuePage.test.tsx @@ -180,6 +180,13 @@ describe("карточка задачи", () => { await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); }); + it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); + open("/issues/AURORA-1"); + const link = await screen.findByRole("link", { name: "к списку задач" }); + expect(link.closest(".issue")).not.toBeNull(); + }); + it("O-11: задача без команд — текст вместо пустой таблицы", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] }))); open("/issues/AURORA-9001"); diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx index 4e2f707..9a8b93c 100644 --- a/web/src/issues/IssuePage.tsx +++ b/web/src/issues/IssuePage.tsx @@ -17,7 +17,7 @@ export function IssuePage() { if (issue.isPending) return

загрузка {key}…

; if (issue.error instanceof ApiError && issue.error.status === 404) { - return

Задачи {key} нет в зеркале хаба. к списку задач

; + return

Задачи {key} нет в зеркале хаба. к списку задач

; } if (issue.isError && !issue.data) { return ( diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index fefda5b..f29233f 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -10,6 +10,7 @@ import { renderWithProviders } from "../test/render.tsx"; import { IssuesPage } from "./IssuesPage.tsx"; const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); +const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; const row = (n: number) => ({ ...SNAP.issues[1], key: `AURORA-${9000 + n}`, summary: `Задача ${n}`, fetched_at: "" }); function hub(pages: (params: URLSearchParams) => unknown[]) { @@ -117,6 +118,23 @@ describe("список задач", () => { expect(sel).toHaveValue("AWPCA_Спринт 0"); }); + it("T5.1: значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => { + const board = deferred(); + mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/board") return board.promise; + return json(200, { issues: [row(1)] }); + }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + const sel = await screen.findByLabelText("Спринт"); + expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); + board.resolve(json(200, SNAP)); + await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument()); + expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); + expect(sel).toHaveValue("AWPCA_Спринт 2"); + }); + it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => { mockFetch((_m, url) => { if (url.pathname === "/settings") return json(200, settings); diff --git a/web/src/issues/options.ts b/web/src/issues/options.ts index df31ca6..aa151d7 100644 --- a/web/src/issues/options.ts +++ b/web/src/issues/options.ts @@ -9,13 +9,13 @@ export const epicNames = (snap: BoardSnapshot | undefined): Record (i.planned_end || i.duedate || "").slice(0, 10); -// варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API // T13.1: значение фильтра из адреса, которого нет среди вариантов (закрытый спринт, ушедший исполнитель), видно в списке; // пока снимок доски не загружен, пометку не ставим (R-4) export const withCurrent = (opts: Opt[], value: string | undefined, loaded: boolean): Opt[] => !value || opts.some((o) => o.value === value) ? opts : [{ value, label: loaded ? `${value} (нет на доске)` : value }, ...opts]; +// варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | undefined) { const issues = snap?.issues ?? []; const names = epicNames(snap);