feat(web): карточка — описание и комментарии в разметке Jira, кнопка «исходник» (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3fe820d195
commit
1d7a82a5c0
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user