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>;
|
kind_labels?: Record<string, string>;
|
||||||
link_types?: Record<string, string>;
|
link_types?: Record<string, string>;
|
||||||
status_labels?: Record<string, string>;
|
status_labels?: Record<string, string>;
|
||||||
|
project_key?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,17 +6,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { qk } from "../api/queries.ts";
|
import { qk } from "../api/queries.ts";
|
||||||
import { makeQueryClient } from "../api/queryClient.ts";
|
import { makeQueryClient } from "../api/queryClient.ts";
|
||||||
import { setToken } from "../auth/token.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 { json, mockFetch } from "../test/fetchMock.ts";
|
||||||
import { issue, note } from "../test/issueFixtures.ts";
|
import { issue, note } from "../test/issueFixtures.ts";
|
||||||
import { IssueCard } from "./IssueCard.tsx";
|
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 }; };
|
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||||
|
|
||||||
function CardAt() {
|
function CardAt() {
|
||||||
const { key = "" } = useParams();
|
const { key = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
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()) {
|
function open(path: string, client = makeQueryClient()) {
|
||||||
@ -28,6 +30,13 @@ function open(path: string, client = makeQueryClient()) {
|
|||||||
return router;
|
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("карточка задачи", () => {
|
describe("карточка задачи", () => {
|
||||||
beforeEach(() => setToken("tok"));
|
beforeEach(() => setToken("tok"));
|
||||||
|
|
||||||
@ -68,23 +77,96 @@ describe("карточка задачи", () => {
|
|||||||
expect(await screen.findByText("связей нет")).toBeInTheDocument();
|
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");
|
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 })));
|
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
|
||||||
open("/issues/AURORA-9001");
|
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");
|
expect(pre).toHaveClass("description", "clamped");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
|
expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
|
||||||
expect(pre).not.toHaveClass("clamped");
|
});
|
||||||
await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
|
|
||||||
expect(pre).toHaveClass("clamped");
|
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 () => {
|
it("короткое описание — без кнопки; нет описания — нет раздела", async () => {
|
||||||
let d: string | null = "строка 1\nстрока 2";
|
let d: string | null = "строка 1\nстрока 2";
|
||||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
|
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
|
||||||
open("/issues/AURORA-9001");
|
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();
|
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
|
||||||
d = null;
|
d = null;
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -102,7 +184,8 @@ describe("карточка задачи", () => {
|
|||||||
expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
|
||||||
const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
|
const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
|
||||||
expect(items[0]).toHaveTextContent("Петров Пётр");
|
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: "Скрыть" }));
|
await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
|
||||||
expect(screen.queryByText("старый")).toBeNull();
|
expect(screen.queryByText("старый")).toBeNull();
|
||||||
expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");
|
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 { ApiError } from "../api/client.ts";
|
||||||
import { useIssue, useSettings } from "../api/queries.ts";
|
import { useIssue, useSettings } from "../api/queries.ts";
|
||||||
import type { IssueComment, IssueDetail } from "../api/types.ts";
|
import type { IssueComment, IssueDetail } from "../api/types.ts";
|
||||||
@ -9,6 +9,7 @@ import { useCommentsHidden } from "./commentsPref.ts";
|
|||||||
import { IssueLink } from "./IssueLink.tsx";
|
import { IssueLink } from "./IssueLink.tsx";
|
||||||
import { Notes } from "./Notes.tsx";
|
import { Notes } from "./Notes.tsx";
|
||||||
import { dueOf } from "./options.ts";
|
import { dueOf } from "./options.ts";
|
||||||
|
import { WikiText } from "./wiki/WikiText.tsx";
|
||||||
import "./issues.css";
|
import "./issues.css";
|
||||||
|
|
||||||
const DESC_LINES = 8;
|
const DESC_LINES = 8;
|
||||||
@ -62,8 +63,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
|||||||
<div className="cols">
|
<div className="cols">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
||||||
{i.description && <Description text={i.description} />}
|
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
|
||||||
<Comments items={i.comments ?? []} />
|
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
|
||||||
</div>
|
</div>
|
||||||
<div className="side">
|
<div className="side">
|
||||||
<Notes key={i.key} issue={i} />
|
<Notes key={i.key} issue={i} />
|
||||||
@ -101,25 +102,53 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Description({ text }: { text: string }) {
|
// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7)
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
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 [open, setOpen] = useState(false);
|
||||||
|
const [source, setSource] = useState(false);
|
||||||
const [overflow, setOverflow] = useState(false);
|
const [overflow, setOverflow] = useState(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
|
if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
|
||||||
}, [text, open]);
|
}, [text, open, source]);
|
||||||
const long = text.split("\n").length > DESC_LINES || overflow;
|
// в рендере «длинное» — только по измерению: пустые строки исходника высоты не дают (design #8)
|
||||||
|
const long = (source && text.split("\n").length > DESC_LINES) || overflow;
|
||||||
|
const cls = "description" + (open ? "" : " clamped");
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h3>Описание</h3>
|
<div className="sec-head">
|
||||||
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
|
<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>}
|
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
|
||||||
</section>
|
</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();
|
const [hidden, setHidden] = useCommentsHidden();
|
||||||
return (
|
return (
|
||||||
<section className="comments" data-testid="comments">
|
<section className="comments" data-testid="comments">
|
||||||
@ -130,12 +159,7 @@ function Comments({ items }: { items: IssueComment[] }) {
|
|||||||
{!items.length && <p className="hint">комментариев нет</p>}
|
{!items.length && <p className="hint">комментариев нет</p>}
|
||||||
{items.length > 0 && !hidden && (
|
{items.length > 0 && !hidden && (
|
||||||
<ul>
|
<ul>
|
||||||
{items.map((c) => (
|
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} />)}
|
||||||
<li key={c.comment_id}>
|
|
||||||
<div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
|
|
||||||
<pre className="body">{c.body}</pre>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -40,13 +40,18 @@
|
|||||||
.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
|
.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
|
||||||
.issue .links .sum{flex:1 1 200px;min-width:0}
|
.issue .links .sum{flex:1 1 200px;min-width:0}
|
||||||
.issue .links .absent{color:var(--muted);font-style:italic}
|
.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{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 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-я строка */
|
/* нижний отступ — рамкой цвета фона: overflow обрезает по краю padding, и в нижнем padding была видна 9-я строка */
|
||||||
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
|
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>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 div.description.clamped{max-height:12em;overflow:hidden;
|
||||||
.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0}
|
/* рендер: блочное содержимое 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{display:grid;grid-template-columns:1fr;gap:6px;margin:0}
|
||||||
.issue .fields dt{font-size:12px;color:var(--muted)}
|
.issue .fields dt{font-size:12px;color:var(--muted)}
|
||||||
.issue .fields dd{margin:0;white-space:pre-wrap}
|
.issue .fields dd{margin:0;white-space:pre-wrap}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user