feat(web): карточка задачи — поля, связи, команды, правка служебных заметок (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8e3e8656a
commit
9fcc041d87
145
web/src/issues/IssuePage.test.tsx
Normal file
145
web/src/issues/IssuePage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
78
web/src/issues/NoteForm.tsx
Normal file
78
web/src/issues/NoteForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user