feat(web): раскладка карточки — шапка состояния, связи по смыслу, комментарии, заметки в режиме чтения (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
87db9720f9
commit
102cef4125
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 <p className="hint" data-testid="issue-card">загрузка {key}…</p>;
|
||||
const close = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
|
||||
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
|
||||
}
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
<p className="warn" role="alert" data-testid="issue-card">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
<div className="issue" data-testid="issue-card">
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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],
|
||||
];
|
||||
const labels = settings.data?.status_labels as Record<string, string> | undefined;
|
||||
const fact = i.note?.fact_status ?? null;
|
||||
const cls = factClass(fact, labels);
|
||||
const due = dueOf(i);
|
||||
const facts = [
|
||||
i.assignee && <span key="who">{i.assignee}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
|
||||
i.sprint && <span key="sprint">{i.sprint}</span>,
|
||||
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div data-testid="issue-card" 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><button className="btn sm" type="button" aria-label="Закрыть" onClick={onClose}>✕</button></p>
|
||||
<h2 id="issue-modal-title">{i.key} · {i.summary}</h2>
|
||||
<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> → <IssueLink issueKey={l.target_key} /></li>)}
|
||||
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><IssueLink issueKey={l.source_key} /> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
|
||||
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
|
||||
</ul>
|
||||
<h3>Последние команды</h3>
|
||||
{i.commands.length ? (
|
||||
<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>
|
||||
) : <p className="hint">команд по задаче не было</p>}
|
||||
<div className="issue" data-testid="issue-card">
|
||||
<header className="issue-head" data-testid="issue-head">
|
||||
<div className="row1">
|
||||
<span className="key">{i.key}</span>
|
||||
{i.status && <span className="badge status">{i.status}</span>}
|
||||
{fact && <span className={"badge fact" + (cls ? " " + cls : "")}>{fact}</span>}
|
||||
<span className="spacer" />
|
||||
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
|
||||
{close}
|
||||
</div>
|
||||
<h2 id="issue-modal-title">{i.summary || i.key}</h2>
|
||||
{facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
|
||||
{issue.isError && (
|
||||
<p className="warn" role="alert">{issue.error.message}{" "}
|
||||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
</header>
|
||||
<div className="cols">
|
||||
<div className="main">
|
||||
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
||||
{i.description && <Description text={i.description} />}
|
||||
<Comments items={i.comments ?? []} />
|
||||
</div>
|
||||
<div className="side">
|
||||
<Notes key={i.key} issue={i} />
|
||||
<OtherFields issue={i} />
|
||||
<Commands issue={i} />
|
||||
</div>
|
||||
<NoteForm key={i.key} issue={i} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
|
||||
const groups = groupLinks(issue, linkTypes);
|
||||
return (
|
||||
<section className="links-sec">
|
||||
<h3>Связи</h3>
|
||||
{!groups.length && <p className="hint">связей нет</p>}
|
||||
{groups.map(([id, title, links]) => (
|
||||
<div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
|
||||
<h4 id={`links-${id}`}>{title}</h4>
|
||||
<ul className="links">
|
||||
{links.map((l) => (
|
||||
<li key={id + l.key + (l.tag ?? "")}>
|
||||
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
|
||||
<IssueLink issueKey={l.key} />{" "}
|
||||
{l.summary === null && l.status === null
|
||||
? <span className="sum absent">нет в зеркале</span>
|
||||
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<span className="badge status">{l.status}</span></>}</>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Description({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
|
||||
}, [text, open]);
|
||||
const long = text.split("\n").length > DESC_LINES || overflow;
|
||||
return (
|
||||
<section>
|
||||
<h3>Описание</h3>
|
||||
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
|
||||
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Comments({ items }: { items: IssueComment[] }) {
|
||||
const [hidden, setHidden] = useCommentsHidden();
|
||||
return (
|
||||
<section className="comments" data-testid="comments">
|
||||
<div className="sec-head">
|
||||
<h3>Комментарии ({items.length})</h3>
|
||||
{items.length > 0 && <button className="link" type="button" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</button>}
|
||||
</div>
|
||||
{!items.length && <p className="hint">комментариев нет</p>}
|
||||
{items.length > 0 && !hidden && (
|
||||
<ul>
|
||||
{items.map((c) => (
|
||||
<li key={c.comment_id}>
|
||||
<div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
|
||||
<pre className="body">{c.body}</pre>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFields({ issue: i }: { issue: IssueDetail }) {
|
||||
const rows: [string, string | null][] = [
|
||||
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
|
||||
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
|
||||
["Сосед по связям", i.neighbor ? "да" : null],
|
||||
];
|
||||
return (
|
||||
<section className="other" aria-labelledby="other-title">
|
||||
<h3 id="other-title">Прочие поля</h3>
|
||||
<dl className="fields">{rows.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Commands({ issue: i }: { issue: IssueDetail }) {
|
||||
if (!i.commands.length) {
|
||||
return <section className="commands"><h3>Команды моста</h3><p className="hint">команд по задаче не было</p></section>;
|
||||
}
|
||||
return (
|
||||
<section className="commands">
|
||||
<details>
|
||||
<summary>Команды моста ({i.commands.length})</summary>
|
||||
<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>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,6 +115,7 @@ describe("карточка окном", () => {
|
||||
const router = at(["/app/issues"]);
|
||||
render(<App router={router} />);
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
|
||||
await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
|
||||
const box = await within(dialog()).findByLabelText("Заметка владельца");
|
||||
await userEvent.type(box, "черновик");
|
||||
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, 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 =>
|
||||
@ -18,9 +17,11 @@ export function changedFields(initial: Values, values: Values): NoteFields {
|
||||
|
||||
const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||||
|
||||
export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) {
|
||||
const settings = useSettings();
|
||||
const save = useSaveNote(issue.key);
|
||||
const [askCancel, setAskCancel] = useState(false);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
const [initial, setInitial] = useState(() => valuesOf(issue.note));
|
||||
const [values, setValues] = useState(initial);
|
||||
const changed = useMemo(() => changedFields(initial, values), [initial, values]);
|
||||
@ -33,6 +34,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
// подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
|
||||
useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]);
|
||||
|
||||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||||
@ -41,14 +44,13 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
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); },
|
||||
onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); },
|
||||
});
|
||||
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>
|
||||
<form className="note" aria-labelledby="notes-title" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<h3 id="notes-title">Служебные заметки</h3>
|
||||
<fieldset className="note-fields" disabled={save.isPending}>
|
||||
<label>Вид работы
|
||||
<select value={values.work_kind} onChange={set("work_kind")}>
|
||||
@ -67,10 +69,17 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||||
{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>}
|
||||
<button className="btn" type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</button>
|
||||
</div>
|
||||
{askCancel && (
|
||||
<div className="confirm" role="alertdialog" aria-label="Отменить правки заметки?">
|
||||
<span>Отменить правки заметки?</span>
|
||||
<button className="btn" type="button" onClick={onDone}>Отменить правки</button>
|
||||
<button className="btn" type="button" onClick={() => setAskCancel(false)}>Продолжить правку</button>
|
||||
</div>
|
||||
)}
|
||||
{blocker.state === "blocked" && (
|
||||
<div className="confirm" role="alertdialog">
|
||||
<div className="confirm" role="alertdialog" ref={confirmRef}>
|
||||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||||
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
|
||||
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>
|
||||
|
||||
26
web/src/issues/Notes.tsx
Normal file
26
web/src/issues/Notes.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import type { IssueDetail, NoteField } from "../api/types.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { NoteForm } from "./NoteForm.tsx";
|
||||
|
||||
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"],
|
||||
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||||
|
||||
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение
|
||||
export function Notes({ issue }: { issue: IssueDetail }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (editing) return <NoteForm issue={issue} onDone={() => setEditing(false)} />;
|
||||
const n = issue.note;
|
||||
return (
|
||||
<section className="note view" aria-labelledby="notes-title">
|
||||
<div className="sec-head">
|
||||
<h3 id="notes-title">Служебные заметки</h3>
|
||||
<button className="btn sm" type="button" onClick={() => setEditing(true)}>Править</button>
|
||||
</div>
|
||||
<dl className="fields">
|
||||
{ROWS.map(([f, label]) => <div key={f}><dt>{label}</dt><dd>{n?.[f] || "—"}</dd></div>)}
|
||||
</dl>
|
||||
{n && <p className="hint">изменено {fmtStamp(n.updated_at)}, {n.updated_by}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -7,13 +7,48 @@
|
||||
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
|
||||
.grid a{color:var(--accent)}
|
||||
.issue 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-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
|
||||
.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)}
|
||||
.issue-head .spacer{flex:1 1 auto}
|
||||
.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3}
|
||||
.issue-head .facts{margin:0;color:var(--muted);font-size:14px}
|
||||
.issue .overdue{color:var(--p-crit);font-weight:600}
|
||||
.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
|
||||
.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
|
||||
.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
|
||||
.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
|
||||
.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
|
||||
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.issue .main{min-width:0}
|
||||
@media (max-width:900px){
|
||||
.issue .cols{display:flex;flex-direction:column;gap:16px}
|
||||
.issue .side{display:contents}
|
||||
.issue .side .note{order:-1}
|
||||
.issue .side .other{order:1}
|
||||
.issue .side .commands{order:2}
|
||||
}
|
||||
.issue section{margin-bottom:18px}
|
||||
.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
|
||||
.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.issue .group{margin:0 0 10px}
|
||||
.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px}
|
||||
.issue .links{list-style:none;margin:0;padding:0}
|
||||
.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0}
|
||||
.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
|
||||
.issue .links .sum{flex:1 1 200px;min-width:0}
|
||||
.issue .links .absent{color:var(--muted);font-style:italic}
|
||||
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
|
||||
.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px}
|
||||
.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0}
|
||||
.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;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}
|
||||
.issue .fields dd{margin:0;white-space:pre-wrap}
|
||||
.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
||||
.issue .commands table{margin-top:8px;font-size:13px}
|
||||
.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)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user