diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx new file mode 100644 index 0000000..78cf08e --- /dev/null +++ b/web/src/issues/IssuePage.test.tsx @@ -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 = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; + +function open(path: string, client = makeQueryClient()) { + const router = createMemoryRouter([ + { path: "/issues/:key", element: }, + { path: "/board", element:

доска

}, + ], { initialEntries: [path] }); + render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx index e04fb22..8cd9466 100644 --- a/web/src/issues/IssuePage.tsx +++ b/web/src/issues/IssuePage.tsx @@ -1,2 +1,63 @@ -// временная страница (заменяется в Task 14) -export function IssuePage() { return
; } +import { Link, useParams } from "react-router"; +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

загрузка {key}…

; + if (issue.error instanceof ApiError && issue.error.status === 404) { + return

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

; + } + if (issue.isError) { + return ( +

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

+ ); + } + 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 ( +
+

← задачи

+

{i.key} · {i.summary}

+

Открыть в Jira

+
+
+
{fields.filter(([, v]) => v).map(([k, v]) =>
{k}
{v}
)}
+ {i.description &&
{i.description}
} +

Связи

+
    + {i.links_out.map((l) =>
  • {typeName(l.link_type)} → {l.target_key}
  • )} + {i.links_in.map((l) =>
  • {l.source_key} → {typeName(l.link_type)} → эта задача
  • )} + {!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}
+
+ +
+
+ ); +} diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx new file mode 100644 index 0000000..604a124 --- /dev/null +++ b/web/src/issues/NoteForm.tsx @@ -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; +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 ( +
{ e.preventDefault(); submit(); }}> +

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

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