diff --git a/openspec/changes/web-app/specs/web-app/spec.md b/openspec/changes/web-app/specs/web-app/spec.md index 9101207..282843c 100644 --- a/openspec/changes/web-app/specs/web-app/spec.md +++ b/openspec/changes/web-app/specs/web-app/spec.md @@ -61,7 +61,9 @@ SHALL очищать токен и показывать экран входа. Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html` на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку», ведущий на `/app/issues/{key}`; ключ карточки по-прежнему открывает задачу в Jira. Действия «Сменить токен» в меню -доски нет — оно в верхней панели приложения. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет. +доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку +карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке +захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет. #### Scenario: Раскладка переживает переход - **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными diff --git a/web/scripts/test-hub.sh b/web/scripts/test-hub.sh index f03713b..64d854c 100644 --- a/web/scripts/test-hub.sh +++ b/web/scripts/test-hub.sh @@ -7,6 +7,11 @@ ROOT=$(cd "$WEB/.." && pwd) export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}" PORT="${PORT:-8791}" case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac +# чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем +case "$JIRA_HUB_HOME" in + "$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;; + *) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;; +esac rm -rf "$JIRA_HUB_HOME" cd "$ROOT" uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null diff --git a/web/src/board/exportJson.ts b/web/src/board/exportJson.ts index 94a9ad3..41bf797 100644 --- a/web/src/board/exportJson.ts +++ b/web/src/board/exportJson.ts @@ -4,14 +4,15 @@ import { JIRA_URL } from "./constants.ts"; import { colWidths, foldedSet, laneOrder, savedLayout } from "./storage.ts"; import type { BoardModel, Filters, Visible } from "./types.ts"; -type Saved = Record; +type Saved = Record; export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown { const savedPos = (savedLayout(false) || {}) as Saved, savedDate = (savedLayout(true) || {}) as Saved; const shown = new Set(vis.nodes.map((n) => n.key)); const fromSaved = (saved: Saved, k: string) => { const r = saved[k]; if (!r) return null; - const o: { x: number; ry: number; w: number; h?: number } = { x: r.x, ry: r.ry, w: r.w }; + const o: { x: number; ry: number; w?: number; h?: number } = { x: r.x, ry: r.ry }; + if (typeof r.w === "number") o.w = r.w; // запись без w допустима (как в эталоне) if (typeof r.h === "number") o.h = r.h; return o; }; diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts index ac258d8..fdd6184 100644 --- a/web/src/board/storage.test.ts +++ b/web/src/board/storage.test.ts @@ -74,6 +74,8 @@ describe("хранилище = links.html@3931a88", () => { it("раскладка неверной формы отбрасывается при чтении", () => { localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-1": null, "AURORA-2": { x: "1" }, "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } })); expect(savedLayout(false)).toEqual({ "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } }); + localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-9003": { x: 30, ry: 50 } })); + expect(savedLayout(false)).toEqual({ "AURORA-9003": { x: 30, ry: 50 } }); // M-1: без w запись принимается (w и h проверяет computeLayout) localStorage.setItem("graph-pos:board", "[1]"); expect(savedLayout(false)).toBeNull(); }); diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts index d2885d3..8b5558d 100644 --- a/web/src/board/storage.ts +++ b/web/src/board/storage.ts @@ -52,12 +52,12 @@ export function colWidths(): Record { } const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища -// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry/w) отбрасываются +// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry) отбрасываются; w и h проверяет computeLayout export function savedLayout(byDate: boolean): unknown { const v = store.json(layoutKey(byDate), null); if (!isObj(v)) return null; const out: Record = {}; - Object.entries(v).forEach(([k, b]) => { if (isObj(b) && num(b.x) && num(b.ry) && num(b.w) && (b.h === undefined || num(b.h))) out[k] = b; }); + Object.entries(v).forEach(([k, b]) => { if (isObj(b) && num(b.x) && num(b.ry)) out[k] = b; }); return out; } // сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места diff --git a/web/src/board/useBoardSource.test.tsx b/web/src/board/useBoardSource.test.tsx index dfd30c1..f8fc2dc 100644 --- a/web/src/board/useBoardSource.test.tsx +++ b/web/src/board/useBoardSource.test.tsx @@ -112,6 +112,20 @@ describe("useBoardSource", () => { expect(h.count("GET /batches/7")).toBe(n); }); + for (const status of [400, 422]) { + it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, refreshing сброшен`, async () => { + const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") })); + expect(result.current.refreshing).toBe(false); + const n = h.count("GET /batches/7"); + await sleep(50); + expect(h.count("GET /batches/7")).toBe(n); + }); + } + it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => { const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } }); const { result } = hook(); diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts index ce02f35..5a5ac05 100644 --- a/web/src/board/useBoardSource.ts +++ b/web/src/board/useBoardSource.ts @@ -65,7 +65,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll return () => { gone = true; }; }, [board.dataUpdatedAt, active]); - // M-2, M-4: один опрос пакета; остановка на завершении, 404, 401 и после MAX_POLL_ERRORS ошибок подряд + // M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд const batch = useQuery({ queryKey: ["batch", active?.id ?? 0], enabled: active !== null, @@ -84,7 +84,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll refetchInterval: (q) => { const e = q.state.error; if (terminal(q.state.data)) return false; - if (e instanceof AuthError || (e instanceof ApiError && e.status === 404)) return false; + if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором if (failures.current >= MAX_POLL_ERRORS) return false; return pollMs; }, @@ -114,8 +114,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false }); return; } - if (e instanceof ApiError && e.status === 404) { - setEvent({ kind: "batch-lost", id: active.id, reason: "пакета нет в хабе" }); + if (e instanceof ApiError) { + setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message }); finish(active.id); } else if (e instanceof HubDownError) { if (failures.current >= MAX_POLL_ERRORS) { diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx index 78cf08e..8cc92c5 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssuePage.test.tsx @@ -142,4 +142,40 @@ describe("карточка задачи", () => { expect(screen.getByLabelText("Заметка владельца")).toHaveValue(""); expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); }); + + it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => { + const slowPut = deferred(); + mockFetch((m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (m === "PUT") return slowPut.promise; + return json(200, issue("AURORA-9001")); + }); + open("/issues/AURORA-9001"); + await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз"); + await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); + const box = screen.getByLabelText("Заметка владельца"); + await waitFor(() => expect(box).toBeDisabled()); + slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" }))); + await waitFor(() => expect(box).toBeEnabled()); + expect(box).toHaveValue("раз"); + }); + + it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => { + const client = makeQueryClient(); + client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001")); + let down = true; + mockFetch((_m, url) => { + if (url.pathname === "/settings") return json(200, settings); + if (down) throw new TypeError("Failed to fetch"); + return json(200, issue("AURORA-9001")); + }); + open("/issues/AURORA-9001", client); + expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше + expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает"); + expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + down = false; + await userEvent.click(screen.getByRole("button", { name: "Повторить" })); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); + }); }); diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx index 8cd9466..ee81dd6 100644 --- a/web/src/issues/IssuePage.tsx +++ b/web/src/issues/IssuePage.tsx @@ -19,13 +19,13 @@ export function IssuePage() { if (issue.error instanceof ApiError && issue.error.status === 404) { return

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

; } - if (issue.isError) { + if (issue.isError && !issue.data) { return (

{issue.error.message}{" "}

); } - const i = issue.data; + const i = issue.data!; const fields: [string, string | null][] = [ ["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee], ["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start], @@ -34,6 +34,10 @@ export function IssuePage() { ]; return (
+ {issue.isError && ( +

{issue.error.message}{" "} +

+ )}

← задачи

{i.key} · {i.summary}

Открыть в Jira

diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 57cb719..55ceea3 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from "@testing-library/react"; +import { act, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -85,4 +85,27 @@ describe("список задач", () => { renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument(); }); + + it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => { + hub(() => [row(1)]); + const { router } = renderWithProviders(, { route: "/issues?q=свод", path: "/issues" }); + const box = await screen.findByLabelText("Поиск по ключу или теме"); + expect(box).toHaveValue("свод"); + await act(async () => { await router.navigate("/issues"); }); + await waitFor(() => expect(box).toHaveValue("")); + await new Promise((r) => setTimeout(r, 450)); + expect(router.state.location.search).toBe(""); + expect(box).toHaveValue(""); + }); + + it("T13.2: ввод поиска пишется в адрес через replace (шаг «назад» не застревает)", async () => { + hub(() => [row(1)]); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + const before = router.state.historyAction; + await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод"); + await waitFor(() => expect(router.state.location.search).toBe("?q=%D1%81%D0%B2%D0%BE%D0%B4")); + expect(before).toBe("POP"); + expect(router.state.historyAction).toBe("REPLACE"); + }); }); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 22eef1c..28fc3ef 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +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"; @@ -16,9 +16,20 @@ export function IssuesPage() { 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 [q, setQ] = useState(filters.q ?? ""); + const written = useRef(filters.q ?? ""); // последнее значение q, согласованное с адресом (записанное нами или принятое из него) + useEffect(() => { + // адрес изменён извне (назад, щелчок по «Задачи»): поле поиска следует за ним + const fromUrl = filters.q ?? ""; + if (fromUrl === written.current) return; + written.current = fromUrl; + setQ(fromUrl); + }, [filters.q]); useEffect(() => { if (q === (filters.q ?? "")) return; - const t = setTimeout(() => setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }), Q_DELAY); + const t = setTimeout(() => { + written.current = q; + setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true }); + }, Q_DELAY); return () => clearTimeout(t); }, [q, filters.q, setParams]); diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index 604a124..c8e624f 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -48,19 +48,21 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { return (
{ e.preventDefault(); submit(); }}>

Служебные заметки

- - - {TEXTS.map(([f, label]) => ( -