diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 452c79d..6d3c4f8 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router"; @@ -31,16 +31,109 @@ function open(path: string, client = makeQueryClient()) { describe("карточка задачи", () => { beforeEach(() => setToken("tok")); - it("поля, описание с переносами, связи с подписями, команды", async () => { + it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); + const head = screen.getByTestId("issue-head"); + expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); + expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); + expect(within(head).getByText("просрочено")).toHaveClass("overdue"); + expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + }); + + it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }, + { target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }], + links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" }, + { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }], + }))); + open("/issues/AURORA-9001"); + const depends = await screen.findByRole("group", { name: "Зависит от" }); + expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово"); + const blocks = screen.getByRole("group", { name: "Блокирует" }); + expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); + expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая"); + expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале"); + expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008"); + expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы + }); + + it("без связей — «связей нет»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("связей нет")).toBeInTheDocument(); + }); + + it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => { + const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n"); + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); + open("/issues/AURORA-9001"); + const pre = await screen.findByText(long, { normalizer: (s) => s }); + expect(pre).toHaveClass("description", "clamped"); + await userEvent.click(screen.getByRole("button", { name: "развернуть" })); + expect(pre).not.toHaveClass("clamped"); + await userEvent.click(screen.getByRole("button", { name: "свернуть" })); + expect(pre).toHaveClass("clamped"); + }); + + it("короткое описание — без кнопки; нет описания — нет раздела", async () => { + let d: string | null = "строка 1\nстрока 2"; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); + expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); + d = null; + cleanup(); + open("/issues/AURORA-9001"); + await screen.findByRole("heading", { level: 2 }); + expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); + }); + + it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => { + const comments = [ + { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" }, + { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument(); + const items = within(screen.getByTestId("comments")).getAllByRole("listitem"); + expect(items[0]).toHaveTextContent("Петров Пётр"); + expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Скрыть" })); + expect(screen.queryByText("старый")).toBeNull(); + expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1"); + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); + expect(screen.queryByText("старый")).toBeNull(); + }); + + it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => { + let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); + expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); + comments = []; + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByText("комментариев нет")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull(); + }); + + 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"); + const other = await screen.findByRole("region", { name: "Прочие поля" }); + expect(other).toHaveTextContent("Приоритет"); + expect(other).toHaveTextContent("Высокий"); + const details = screen.getByText("Команды моста (1)").closest("details")!; + expect(details).not.toHaveAttribute("open"); + expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); }); it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { @@ -65,13 +158,15 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); 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(); + expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull(); }); it("422 — текст ошибки у формы, введённое на месте", async () => { @@ -81,6 +176,7 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const owner = await screen.findByLabelText("Заметка владельца"); await userEvent.type(owner, "проверить"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); @@ -91,6 +187,7 @@ describe("карточка задачи", () => { it("уход с несохранёнными правками требует подтверждения", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); const router = open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await act(async () => { await router.navigate("/board"); }); expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); @@ -128,6 +225,7 @@ describe("карточка задачи", () => { const client = makeQueryClient(); client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002")); const router = open("/issues/AURORA-9001", client); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); await act(async () => { await router.navigate("/issues/AURORA-9002"); }); @@ -136,8 +234,8 @@ describe("карточка задачи", () => { slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" }))); await waitFor(() => expect(client.isMutating()).toBe(0)); expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); - expect(screen.getByLabelText("Заметка владельца")).toHaveValue(""); - expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения + expect(screen.queryByText("черновик")).toBeNull(); }); it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => { @@ -148,13 +246,14 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); 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("раз"); + await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea) + expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением }); it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => { @@ -186,9 +285,46 @@ describe("карточка задачи", () => { it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const input = await screen.findByLabelText("Статус по факту"); const id = input.getAttribute("list")!; expect(id).not.toBe("fact-statuses"); expect(document.getElementById(id)?.tagName).toBe("DATALIST"); }); + + it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(view).toHaveTextContent("Сделано"); + expect(view).toHaveTextContent("схема"); + expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—"); + expect(view).toHaveTextContent(/изменено .*chat/); + await userEvent.click(within(view).getByRole("button", { name: "Править" })); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); + + it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); + await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" })); + expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + await userEvent.click(screen.getByRole("button", { name: "Отменить правки" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByText("черновик")).toBeNull(); + }); + + it("заметки ещё нет — режим чтения с «—» и «Править»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(within(view).getAllByText("—")).toHaveLength(5); + expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); }); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 222e4da..f25bca2 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -1,68 +1,177 @@ -import { IssueLink } from "./IssueLink.tsx"; +import { useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useIssue, useSettings } from "../api/queries.ts"; +import type { IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; -import { fmtStamp } from "../board/model/util.ts"; -import { NoteForm } from "./NoteForm.tsx"; +import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; +import { useCommentsHidden } from "./commentsPref.ts"; +import { IssueLink } from "./IssueLink.tsx"; +import { Notes } from "./Notes.tsx"; import { dueOf } from "./options.ts"; import "./issues.css"; +const DESC_LINES = 8; + export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { 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}…
; + const close = ; + if (issue.isPending) returnзагрузка {key}…
{close}Задачи {key} нет в зеркале хаба.
{issue.error.message}{" "} -
+{issue.error.message}{" "} +
+ +{issue.error.message}{" "} -
- )} - -{i.description}}
- | Команда | Пакет | Тип | Статус | Завершена | Ошибка |
|---|---|---|---|---|---|
| #{c.id} | #{c.batch_id} | {c.type} | {c.status} | -{c.finished_at ? fmtStamp(c.finished_at) : ""} | {c.error} |
команд по задаче не было
} +{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}
} + {issue.isError && ( +{issue.error.message}{" "} +
+ )} +связей нет
} + {groups.map(([id, title, links]) => ( +{text}
+ {(long || open) && }
+ комментариев нет
} + {items.length > 0 && !hidden && ( +{c.body}
+ команд по задаче не было
| Команда | Пакет | Тип | Статус | Завершена | Ошибка |
|---|---|---|---|---|---|
| #{c.id} | #{c.batch_id} | {c.type} | {c.status} | +{c.finished_at ? fmtStamp(c.finished_at) : ""} | {c.error} |