From 6940da5a20aeed235374c3f98ae61c2b422ef979 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:21:50 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8=20=D1=86?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=BE=D0=BC=20=D1=82=D0=B5=D0=BC=D1=8B,=20?= =?UTF-8?q?=C2=AB=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B5=20=D0=BD=D0=B5=20=D0=B1?= =?UTF-8?q?=D1=8B=D0=BB=D0=BE=C2=BB,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B0=D1=83=D0=B7=20(T13.1,=20O-10,?= =?UTF-8?q?=20O-11,=20T14.2,=20T14.3,=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 | 23 ++++++++++++++++++++--- web/src/issues/IssuePage.tsx | 16 +++++++++------- web/src/issues/IssuesPage.test.tsx | 23 ++++++++++++++++++++++- web/src/issues/IssuesPage.tsx | 4 ++-- web/src/issues/NoteForm.tsx | 7 ++++--- web/src/issues/issues.css | 1 + web/src/issues/options.ts | 8 +++++++- 7 files changed, 65 insertions(+), 17 deletions(-) diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx index 8cc92c5..2f9fa35 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssuePage.test.tsx @@ -104,17 +104,18 @@ describe("карточка задачи", () => { }); it("поздний ответ прежней карточки не подменяет новую", async () => { + const client = makeQueryClient(); const slow = deferred(); mockFetch((_m, url) => { if (url.pathname === "/settings") return json(200, settings); if (url.pathname === "/issues/AURORA-9001") return slow.promise; return json(200, issue("AURORA-9002")); }); - const router = open("/issues/AURORA-9001"); + const router = open("/issues/AURORA-9001", client); await act(async () => { await router.navigate("/issues/AURORA-9002"); }); expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); slow.resolve(json(200, issue("AURORA-9001"))); - await new Promise((r) => setTimeout(r, 20)); + await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined()); expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull(); }); @@ -137,7 +138,7 @@ describe("карточка задачи", () => { await userEvent.click(await screen.findByRole("button", { name: "Уйти" })); expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" }))); - await new Promise((r) => setTimeout(r, 20)); + await waitFor(() => expect(client.isMutating()).toBe(0)); expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(screen.getByLabelText("Заметка владельца")).toHaveValue(""); expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); @@ -178,4 +179,20 @@ describe("карточка задачи", () => { await userEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); }); + + it("O-11: задача без команд — текст вместо пустой таблицы", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull(); + }); + + it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + const input = await screen.findByLabelText("Статус по факту"); + const id = input.getAttribute("list")!; + expect(id).not.toBe("fact-statuses"); + expect(document.getElementById(id)?.tagName).toBe("DATALIST"); + }); }); diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx index ee81dd6..4e2f707 100644 --- a/web/src/issues/IssuePage.tsx +++ b/web/src/issues/IssuePage.tsx @@ -52,13 +52,15 @@ export function IssuePage() { {!i.links_out.length && !i.links_in.length &&
  • связей нет
  • }

    Последние команды

    - - - {i.commands.map((c) => ( - - - ))} -
    КомандаПакетТипСтатусЗавершенаОшибка
    #{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
    + {i.commands.length ? ( + + + {i.commands.map((c) => ( + + + ))} +
    КомандаПакетТипСтатусЗавершенаОшибка
    #{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
    + ) :

    команд по задаче не было

    } diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 55ceea3..fefda5b 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -1,4 +1,4 @@ -import { act, screen, waitFor } from "@testing-library/react"; +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"; @@ -108,4 +108,25 @@ describe("список задач", () => { expect(before).toBe("POP"); expect(router.state.historyAction).toBe("REPLACE"); }); + + it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => { + hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" }); + const sel = await screen.findByLabelText("Спринт"); + await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument()); + expect(sel).toHaveValue("AWPCA_Спринт 0"); + }); + + it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => { + mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (url.pathname === "/board") return new 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).queryByRole("option", { name: /нет на доске/ })).toBeNull(); + expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); + }); }); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 28fc3ef..2015915 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts"; -import { dueOf, epicNames, filterOptions } from "./options.ts"; +import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts"; import "./issues.css"; const SELECTS = [ @@ -47,7 +47,7 @@ export function IssuesPage() { ))} diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index c8e624f..833deb4 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { useBlocker } from "react-router"; import { ApiError } from "../api/client.ts"; import { useSaveNote, useSettings } from "../api/queries.ts"; @@ -37,6 +37,7 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; const facts = Object.keys(settings.data?.status_labels ?? {}); + const listId = useId(); const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value })); const submit = () => save.mutate(changed, { @@ -56,8 +57,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { {TEXTS.map(([f, label]) => (