From 1d7a82a5c087e67a4beab3885f17a11a27820742 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 01:08:06 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20=E2=80=94=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B2=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=82=D0=BA=D0=B5=20Jira,=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=B8=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=C2=BB=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/types.ts | 1 + web/src/issues/IssueCard.test.tsx | 103 +++++++++++++++++++++++++++--- web/src/issues/IssueCard.tsx | 56 +++++++++++----- web/src/issues/issues.css | 15 +++-- 4 files changed, 144 insertions(+), 31 deletions(-) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index da86f18..544932a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -16,6 +16,7 @@ export interface HubSettings { kind_labels?: Record; link_types?: Record; status_labels?: Record; + project_key?: string; [key: string]: unknown; } diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index af13998..e27dcab 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -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 = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; function CardAt() { const { key = "" } = useParams(); const navigate = useNavigate(); - return void navigate("/board")} />; + // key — как в IssueModal: другая задача монтирует карточку заново + return 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"); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 8296dac..59cc31c 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -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
} /> - {i.description && } - + {i.description && } +
@@ -101,25 +102,53 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record(null); +// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7) +function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) { + return ; +} + +function Description({ text, projectKey }: { text: string; projectKey?: string }) { + const ref = useRef(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 (
-

Описание

-
{text}
+
+

Описание

+ setSource(!source)} /> +
+ {source + ?
} className={cls}>{text}
+ :
} className={cls}>
} {(long || open) && }
); } -function Comments({ items }: { items: IssueComment[] }) { +function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) { + const [source, setSource] = useState(false); + return ( +
  • +
    + {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""} + setSource(!source)} /> +
    + {source + ?
    {c.body}
    + :
    } +
  • + ); +} + +function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) { const [hidden, setHidden] = useCommentsHidden(); return (
    @@ -130,12 +159,7 @@ function Comments({ items }: { items: IssueComment[] }) { {!items.length &&

    комментариев нет

    } {items.length > 0 && !hidden && (
      - {items.map((c) => ( -
    • -
      {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}
      -
      {c.body}
      -
    • - ))} + {items.map((c) => )}
    )}
    diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index 8b1acda..f768b07 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -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}