feat(web): карточка — описание и комментарии в разметке Jira, кнопка «исходник» (#53)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 01:08:06 +04:00
parent 3fe820d195
commit 1d7a82a5c0
4 changed files with 144 additions and 31 deletions

View File

@ -16,6 +16,7 @@ export interface HubSettings {
kind_labels?: Record<string, string>;
link_types?: Record<string, string>;
status_labels?: Record<string, string>;
project_key?: string;
[key: string]: unknown;
}

View File

@ -6,17 +6,19 @@ import { beforeEach, describe, expect, it, vi } 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 baseSettings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../test/fetchMock.ts";
import { issue, note } from "../test/issueFixtures.ts";
import { IssueCard } from "./IssueCard.tsx";
const settings = { ...baseSettings, project_key: "AURORA" };
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function CardAt() {
const { key = "" } = useParams();
const navigate = useNavigate();
return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
// key — как в IssueModal: другая задача монтирует карточку заново
return <IssueCard key={key} issueKey={key} onClose={() => void navigate("/board")} />;
}
function open(path: string, client = makeQueryClient()) {
@ -28,6 +30,13 @@ function open(path: string, client = makeQueryClient()) {
return router;
}
// высоты элементов в jsdom нулевые: для проверок свёртки по измерению — подмена
function overflow(scroll: number, client: number) {
const s = vi.spyOn(Element.prototype, "scrollHeight", "get").mockReturnValue(scroll);
const c = vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(client);
return () => { s.mockRestore(); c.mockRestore(); };
}
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
@ -68,23 +77,96 @@ describe("карточка задачи", () => {
expect(await screen.findByText("связей нет")).toBeInTheDocument();
});
it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => {
it("длинное описание в рендере свёрнуто по высоте, «развернуть»/«свернуть»; «исходник» не сбрасывает «развёрнуто»", async () => {
const restore = overflow(500, 180);
const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n");
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
open("/issues/AURORA-9001");
const body = (await screen.findByText("абзац 1")).closest(".description")!;
expect(body.tagName).toBe("DIV");
expect(body).toHaveClass("clamped");
await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
expect(screen.getByText("абзац 1").closest(".description")).not.toHaveClass("clamped");
await userEvent.click(screen.getByRole("button", { name: "исходник" }));
const pre = document.querySelector("pre.description")!;
expect(pre).not.toHaveClass("clamped");
expect(screen.getByRole("button", { name: "свернуть" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
expect(document.querySelector("pre.description")).toHaveClass("clamped");
restore();
});
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 });
await userEvent.click(await screen.findByRole("button", { name: "исходник" }));
const pre = screen.getByText(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");
expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
});
it("список через пустые строки, который помещается, — в рендере без «развернуть» (design #8)", async () => {
const restore = overflow(120, 180);
const d = " * один\n\n * два\n\n * три\n\n * четыре\n\n * пять";
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
open("/issues/AURORA-9001");
expect(await screen.findByText("пять")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
restore();
});
it("описание отрендерено; «исходник» — исходник только у описания, «текст» — обратно", async () => {
const d = "h3. Контекст\n# Загрузить ОСВ\n# Открыть документ";
const comments = [{ comment_id: "1", author: "Иванов Иван", created: null, updated: null, body: "*важно*" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d, comments })));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
const section = screen.getByRole("heading", { name: "Описание" }).closest("section")!;
await userEvent.click(within(section).getByRole("button", { name: "исходник" }));
expect(section.querySelector("pre.description")).toHaveTextContent("h3. Контекст");
expect(screen.queryByRole("heading", { level: 3, name: "Контекст" })).toBeNull();
expect(screen.getByText("важно").tagName).toBe("STRONG");
await userEvent.click(within(section).getByRole("button", { name: "текст" }));
expect(screen.getByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
});
it("сценарий «Исходник у одного комментария»: переключается только он, после перехода всё отрендерено", async () => {
const comments = [
{ comment_id: "2", author: "Петров", created: null, updated: null, body: "*b*" },
{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "*a*" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments })));
const router = open("/issues/AURORA-9001");
await screen.findByRole("heading", { name: "Комментарии (2)" });
const items = () => within(screen.getByTestId("comments")).getAllByRole("listitem");
await userEvent.click(within(items()[1]).getByRole("button", { name: "исходник" }));
expect(items()[1].querySelector("pre.body")).toHaveTextContent("*a*");
expect(within(items()[0]).getByText("b").tagName).toBe("STRONG");
await act(() => router.navigate("/issues/AURORA-9002"));
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9002" });
await act(() => router.navigate(-1));
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
await waitFor(() => expect(within(items()[1]).getByText("a").tagName).toBe("STRONG"));
expect(document.querySelector("pre.body")).toBeNull();
});
it("ключ задачи проекта в комментарии — ссылка на карточку", async () => {
const comments = [{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "П. 1 ведётся в AURORA-9002, не UTF-8" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments })));
const router = open("/issues/AURORA-9001");
const comment = within(await screen.findByTestId("comments"));
expect(comment.queryByRole("link", { name: "UTF-8" })).toBeNull();
await userEvent.click(comment.getByRole("link", { name: "AURORA-9002" }));
expect(router.state.location.pathname).toBe("/issues/AURORA-9002");
});
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");
const body = (await screen.findByText(/строка 1/)).closest(".description")!;
expect(body).toHaveTextContent("строка 1строка 2");
expect(body.querySelector("br")).not.toBeNull();
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
d = null;
cleanup();
@ -102,7 +184,8 @@ describe("карточка задачи", () => {
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();
expect(items[0].querySelector(".body")).toHaveTextContent("новыйвторая строка");
expect(items[0].querySelector(".body br")).not.toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
expect(screen.queryByText("старый")).toBeNull();
expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { type RefObject, 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";
@ -9,6 +9,7 @@ import { useCommentsHidden } from "./commentsPref.ts";
import { IssueLink } from "./IssueLink.tsx";
import { Notes } from "./Notes.tsx";
import { dueOf } from "./options.ts";
import { WikiText } from "./wiki/WikiText.tsx";
import "./issues.css";
const DESC_LINES = 8;
@ -62,8 +63,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
<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 ?? []} />
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
</div>
<div className="side">
<Notes key={i.key} issue={i} />
@ -101,25 +102,53 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
);
}
function Description({ text }: { text: string }) {
const ref = useRef<HTMLPreElement>(null);
// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7)
function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) {
return <button className="link" type="button" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</button>;
}
function Description({ text, projectKey }: { text: string; projectKey?: string }) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const [source, setSource] = 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;
}, [text, open, source]);
// в рендере «длинное» — только по измерению: пустые строки исходника высоты не дают (design #8)
const long = (source && text.split("\n").length > DESC_LINES) || overflow;
const cls = "description" + (open ? "" : " clamped");
return (
<section>
<h3>Описание</h3>
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
<div className="sec-head">
<h3>Описание</h3>
<SourceToggle source={source} onToggle={() => setSource(!source)} />
</div>
{source
? <pre ref={ref as RefObject<HTMLPreElement>} className={cls}>{text}</pre>
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} /></div>}
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
</section>
);
}
function Comments({ items }: { items: IssueComment[] }) {
function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
const [source, setSource] = useState(false);
return (
<li>
<div className="who">
<span>{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</span>
<SourceToggle source={source} onToggle={() => setSource(!source)} />
</div>
{source
? <pre className="body">{c.body}</pre>
: <div className="body"><WikiText text={c.body} projectKey={projectKey} /></div>}
</li>
);
}
function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) {
const [hidden, setHidden] = useCommentsHidden();
return (
<section className="comments" data-testid="comments">
@ -130,12 +159,7 @@ function Comments({ items }: { items: IssueComment[] }) {
{!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>
))}
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} />)}
</ul>
)}
</section>

View File

@ -40,13 +40,18 @@
.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 .description{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
.issue pre.description{white-space:pre-wrap;font:inherit}
.issue pre.description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden;
/* нижний отступ — рамкой цвета фона: overflow обрезает по краю padding, и в нижнем padding была видна 9-я строка */
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
.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 .comments>ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
.issue div.description.clamped{max-height:12em;overflow:hidden;
/* рендер: блочное содержимое line-clamp не режет — ограничение высотой ≈ 8 строк (line-height 1.5, design #8, R-5) */
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px;display:flex;justify-content:space-between;gap:8px}
.issue .comments .who .link{font-size:12px}
.issue .comments pre.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;white-space:pre-wrap}