feat(web): карточка задачи — поля, связи, команды, правка служебных заметок (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 23:21:54 +04:00
parent d8e3e8656a
commit 9fcc041d87
4 changed files with 298 additions and 2 deletions

View File

@ -0,0 +1,145 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, RouterProvider } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { qk } from "../api/queries.ts";
import { makeQueryClient } from "../api/queryClient.ts";
import { setToken } from "../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../test/fetchMock.ts";
import { IssuePage } from "./IssuePage.tsx";
const note = (over = {}) => ({ key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема",
rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over });
const issue = (key: string, over = {}) => ({
key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
planned_end: "2026-10-20", epic_key: "AURORA-9100", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null,
jira_updated: null, fetched_at: "", neighbor: false,
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }],
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }],
note: note({ key }), commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
...over,
});
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/issues/:key", element: <IssuePage /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
return router;
}
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
it("поля, описание с переносами, связи с подписями, команды", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
expect(await screen.findByText("блокирует начало")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira
expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
});
it("заголовок страницы — h1 (бренд в оболочке — span)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 1, name: /AURORA-9001/ })).toBeInTheDocument();
});
it("задачи нет в зеркале — сообщение и ссылка на список", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
open("/issues/AURORA-1");
expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues");
});
it("сохраняются только изменённые поля, очищенное — null", async () => {
let put: unknown = null;
mockFetch((m, url, body) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
const rest = await screen.findByLabelText("Осталось");
await userEvent.clear(rest); await userEvent.type(rest, "тесты");
await userEvent.clear(screen.getByLabelText("Сделано"));
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
});
it("422 — текст ошибки у формы, введённое на месте", async () => {
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
const owner = await screen.findByLabelText("Заметка владельца");
await userEvent.type(owner, "проверить");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
expect(owner).toHaveValue("проверить");
});
it("уход с несохранёнными правками требует подтверждения", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
const router = open("/issues/AURORA-9001");
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await act(async () => { await router.navigate("/board"); });
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
it("поздний ответ прежней карточки не подменяет новую", async () => {
const slow = deferred<Response>();
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");
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));
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
});
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
return json(200, issue("AURORA-9001"));
});
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
const router = open("/issues/AURORA-9001", client);
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
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));
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
});
});

View File

@ -1,2 +1,63 @@
// временная страница (заменяется в Task 14) import { Link, useParams } from "react-router";
export function IssuePage() { return <div data-testid="page-issue" />; } import { ApiError } from "../api/client.ts";
import { useIssue, useSettings } from "../api/queries.ts";
import { JIRA_URL } from "../board/constants.ts";
import { fmtStamp } from "../board/model/util.ts";
import { NoteForm } from "./NoteForm.tsx";
import { dueOf } from "./options.ts";
import "./issues.css";
export function IssuePage() {
const { key = "" } = useParams();
const issue = useIssue(key);
const settings = useSettings();
// подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira
const typeName = (jira: string) =>
Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira;
if (issue.isPending) return <p className="hint" data-testid="page-issue">загрузка {key}…</p>;
if (issue.error instanceof ApiError && issue.error.status === 404) {
return <p data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
}
if (issue.isError) {
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 fields: [string, string | null][] = [
["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee],
["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start],
["Эпик", i.epic_key], ["Метки", i.labels.join(", ") || null], ["Сосед по связям", i.neighbor ? "да" : null],
["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
];
return (
<div data-testid="page-issue" className="issue">
<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>
<div className="cols">
<div>
<dl className="fields">{fields.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
{i.description && <pre className="description">{i.description}</pre>}
<h3>Связи</h3>
<ul className="links">
{i.links_out.map((l) => <li key={"o" + l.target_key + l.link_type}><span>{typeName(l.link_type)}</span> → <Link to={`/issues/${l.target_key}`}>{l.target_key}</Link></li>)}
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><Link to={`/issues/${l.source_key}`}>{l.source_key}</Link> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
</ul>
<h3>Последние команды</h3>
<table className="grid">
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
<tbody>{i.commands.map((c) => (
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
))}</tbody>
</table>
</div>
<NoteForm key={i.key} issue={i} />
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from "react";
import { useBlocker } from "react-router";
import { ApiError } from "../api/client.ts";
import { useSaveNote, useSettings } from "../api/queries.ts";
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts";
import { fmtStamp } from "../board/model/util.ts";
type Values = Record<NoteField, string>;
const valuesOf = (n: IssueDetail["note"]): Values =>
Object.fromEntries(NOTE_FIELDS.map((f) => [f, n?.[f] ?? ""])) as Values;
// только изменённые поля; очищенное поле — null (как у MCP notes_set)
export function changedFields(initial: Values, values: Values): NoteFields {
const out: NoteFields = {};
for (const f of NOTE_FIELDS) if (values[f] !== initial[f]) out[f] = values[f] === "" ? null : values[f];
return out;
}
const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
export function NoteForm({ issue }: { issue: IssueDetail }) {
const settings = useSettings();
const save = useSaveNote(issue.key);
const [initial, setInitial] = useState(() => valuesOf(issue.note));
const [values, setValues] = useState(initial);
const changed = useMemo(() => changedFields(initial, values), [initial, values]);
const dirty = Object.keys(changed).length > 0;
const blocker = useBlocker(({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname);
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
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 set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
const submit = () => save.mutate(changed, {
onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); },
});
const err = save.error instanceof ApiError ? save.error.detail ?? save.error.message : save.error?.message;
const saved = save.data ?? issue.note;
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>
))}
{err && <p className="err" role="alert">{err}</p>}
<div className="row">
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
{saved && <span className="hint">сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}</span>}
</div>
{blocker.state === "blocked" && (
<div className="confirm" role="dialog">
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>
</div>
)}
</form>
);
}

View File

@ -6,3 +6,15 @@
.grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top} .grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)} .grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
.grid a{color:var(--accent)} .grid a{color:var(--accent)}
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px}
@media (max-width:900px){.issue .cols{grid-template-columns:1fr}}
.issue .fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px;margin:0}
.issue .fields dt{font-size:12px;color:var(--muted)}
.issue .fields dd{margin:0}
.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 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)}
.note .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap}