Финальные фиксы ревью: поиск следует за адресом, остановка опроса на 4xx, блокировка заметки, защита очистки e2e-хаба, раскладка без w, ошибка карточки с данными, спека R-10/R-18 (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
51564854d6
commit
d608a16a30
@ -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` с теми же данными
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string, { x: number; ry: number; w: number; h?: number }>;
|
||||
type Saved = Record<string, { x: number; ry: number; w?: number; h?: number }>;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -52,12 +52,12 @@ export function colWidths(): Record<string, number> {
|
||||
}
|
||||
|
||||
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<unknown>(layoutKey(byDate), null);
|
||||
if (!isObj(v)) return null;
|
||||
const out: Record<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
// сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -142,4 +142,40 @@ describe("карточка задачи", () => {
|
||||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
|
||||
const slowPut = deferred<Response>();
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,13 +19,13 @@ export function IssuePage() {
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
return <p data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
|
||||
}
|
||||
if (issue.isError) {
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
<p className="warn" role="alert" data-testid="page-issue">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div data-testid="page-issue" className="issue">
|
||||
{issue.isError && (
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
<p><Link to="/issues">← задачи</Link></p>
|
||||
<h1>{i.key} · {i.summary}</h1>
|
||||
<p><a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira</a></p>
|
||||
|
||||
@ -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(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||||
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => {
|
||||
hub(() => [row(1)]);
|
||||
const { router } = renderWithProviders(<IssuesPage />, { 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(<IssuesPage />, { 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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -48,19 +48,21 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
return (
|
||||
<form className="note" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<h3>Служебные заметки</h3>
|
||||
<label>Вид работы
|
||||
<select value={values.work_kind} onChange={set("work_kind")}>
|
||||
<option value="">—</option>
|
||||
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Статус по факту
|
||||
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
|
||||
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||||
</label>
|
||||
{TEXTS.map(([f, label]) => (
|
||||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||||
))}
|
||||
<fieldset className="note-fields" disabled={save.isPending}>
|
||||
<label>Вид работы
|
||||
<select value={values.work_kind} onChange={set("work_kind")}>
|
||||
<option value="">—</option>
|
||||
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Статус по факту
|
||||
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
|
||||
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||||
</label>
|
||||
{TEXTS.map(([f, label]) => (
|
||||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||||
))}
|
||||
</fieldset>
|
||||
{err && <p className="err" role="alert">{err}</p>}
|
||||
<div className="row">
|
||||
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px}
|
||||
.issue .links{padding-left:18px}
|
||||
.note{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;align-self:start}
|
||||
.note fieldset{display:contents;border:0;margin:0;padding:0;min-width:0}
|
||||
.note label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
|
||||
.note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px}
|
||||
.note .err{color:var(--p-crit)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user