fix(web): ревью #53 — перемер свёртки описания, стили таблиц разметки, подпись ссылки без вложенных ссылок

C-1: Description перемеряет переполнение через ResizeObserver: карточка из
кэша монтируется до showModal, и «развернуть» пропадала.
I-1: таблицы разметки не наследуют глобальные table/th из board.css
(min-width 520px, прописные заголовки); слова в ячейках не рвутся (M-3).
I-2 (M-1): в подписи [подпись|адрес] ключи, адреса и ссылки — текст.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 01:23:37 +04:00
parent 52f82daf3e
commit 923c59b2b0
6 changed files with 67 additions and 11 deletions

View File

@ -120,6 +120,29 @@ test("разметка Jira: описание и комментарии отре
await expect(d.locator("pre.body, pre.description")).toHaveCount(0); // после перехода всё снова отрендерено
});
test("C-1: повторное открытие из кэша — длинное описание свёрнуто с кнопкой «развернуть»", async ({ page }) => {
await login(page, "/app/issues");
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
const d = page.getByRole("dialog");
await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
});
test("I-1: телефон 375 px — таблица описания прокручивается внутри, заголовки таблицы без чужого стиля", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await login(page, "/app/issues/AURORA-9001");
const d = page.getByRole("dialog");
await d.getByRole("button", { name: "развернуть" }).click();
const th = d.getByRole("columnheader", { name: "Поле", exact: true });
await expect(th).toBeAttached();
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
expect(await th.evaluate((el) => getComputedStyle(el).textTransform)).toBe("none");
expect(await d.getByRole("cell", { name: "data_type" }).evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); // слово не рвётся (M-3)
});
test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
await login(page, "/app/issues");
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();

View File

@ -96,6 +96,23 @@ describe("карточка задачи", () => {
restore();
});
it("C-1: описание смонтировано скрытым (диалог ещё закрыт) — «развернуть» появляется, когда размеры изменились", async () => {
const observers: (() => void)[] = [];
vi.stubGlobal("ResizeObserver", class { constructor(cb: () => void) { observers.push(cb); } observe() {} disconnect() {} });
let restore = overflow(0, 0);
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");
await screen.findByText("абзац 1");
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
restore();
restore = overflow(500, 180);
act(() => observers.forEach((cb) => cb()));
expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
restore();
vi.unstubAllGlobals();
});
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 })));

View File

@ -114,7 +114,15 @@ function Description({ text, projectKey }: { text: string; projectKey?: string }
const [overflow, setOverflow] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
if (!el || open) return;
const measure = () => setOverflow(el.scrollHeight > el.clientHeight + 1);
measure();
// карточка из кэша монтируется до showModal (размеры нулевые), окно меняет ширину, догружаются шрифты — перемер (C-1)
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
return () => ro.disconnect();
}, [text, open, source]);
// в рендере «длинное» — только по измерению: пустые строки исходника высоты не дают (design #8)
const long = (source && text.split("\n").length > DESC_LINES) || overflow;

View File

@ -47,6 +47,12 @@ describe("parseInline: ссылки", () => {
["[~ivanov]", [{ t: "mention", login: "ivanov" }]],
])("%s", (src, tree) => expect(parseInline(src, P)).toEqual(tree));
it("в подписи ссылки ключи и адреса — текст (I-2: без вложенных ссылок)", () => {
expect(parseInline("[AURORA-1445|https://j.x/browse/AURORA-1445]", P)).toEqual([link("https://j.x/browse/AURORA-1445", t("AURORA-1445"))]);
expect(parseInline("[см. https://a.ru|https://b.ru]", P)).toEqual([link("https://b.ru", t("см. https://a.ru"))]);
expect(parseInline("[*AURORA-1*|AURORA-2]", P)).toEqual([issue("AURORA-2", wrap("strong", t("AURORA-1")))]);
});
it.each(["[нажми|javascript:alert(1)]", "[x|ftp://h]", "[javascript:alert(1)]", "[просто текст]"])(
"опасная или неизвестная цель — текст как есть: %s",
(src) => expect(parseInline(src, P)).toEqual([t(src)]),

View File

@ -33,8 +33,9 @@ function keyPattern(projectKey: string | undefined): RegExp | null {
return new RegExp(`${escaped}-\\d+(?![\\p{L}\\p{N}_])`, "uy");
}
export function parseInline(line: string, opts: WikiOptions = {}): Inline[] {
const key = keyPattern(opts.projectKey);
// label — подпись ссылки: без ссылок, адресов и ключей внутри, чтобы не было <a> в <a> (I-2)
export function parseInline(line: string, opts: WikiOptions = {}, label = false): Inline[] {
const key = label ? null : keyPattern(opts.projectKey);
const isKey = (s: string) => !!key && new RegExp(`^${key.source}$`, "u").test(s);
const out: Inline[] = [];
const text = (s: string) => {
@ -62,7 +63,7 @@ export function parseInline(line: string, opts: WikiOptions = {}): Inline[] {
text(c); i += 1; continue;
}
if (c === "[") {
if (c === "[" && !label) {
const end = line.indexOf("]", i + 1);
const node = end > i + 1 ? bracket(line.slice(i + 1, end), opts, isKey) : null;
if (node) { out.push(node); i = end + 1; continue; }
@ -70,7 +71,7 @@ export function parseInline(line: string, opts: WikiOptions = {}): Inline[] {
text(c); i += 1; continue;
}
if ((c === "h" || c === "H") && prevOk(i)) {
if (c === "h" && !label && prevOk(i)) {
URL.lastIndex = i;
const m = URL.exec(line);
if (m) {
@ -89,7 +90,7 @@ export function parseInline(line: string, opts: WikiOptions = {}): Inline[] {
if (mark) {
const close = closing(line, i);
if (close > 0) {
out.push({ t: mark, children: parseInline(line.slice(i + 1, close), opts) });
out.push({ t: mark, children: parseInline(line.slice(i + 1, close), opts, label) });
i = close + 1;
continue;
}
@ -128,8 +129,8 @@ function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean)
const bar = body.indexOf("|");
const label = bar >= 0 ? body.slice(0, bar) : "";
const target = (bar >= 0 ? body.slice(bar + 1) : body).trim();
if (isKey(target)) return { t: "issue", key: target, children: label ? parseInline(label, opts) : [] };
if (isKey(target)) return { t: "issue", key: target, children: label ? parseInline(label, opts, true) : [] };
const href = safeHref(target);
if (!href) return null;
return { t: "link", href, children: label ? parseInline(label, opts) : [{ t: "text", text: target }] };
return { t: "link", href, children: label ? parseInline(label, opts, true) : [{ t: "text", text: target }] };
}

View File

@ -9,9 +9,10 @@ div.wiki :is(h3,h4){font-size:14px}
div.wiki :is(h5,h6){font-size:13px}
div.wiki :is(ul,ol){margin:0 0 6px;padding-left:22px}
div.wiki li>:is(ul,ol){margin:2px 0 0}
div.wiki table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px}
div.wiki :is(th,td){border:1px solid var(--line);padding:3px 8px;text-align:left;vertical-align:top}
div.wiki th{background:var(--soft);font-weight:600}
div.wiki table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px}
div.wiki :is(th,td){border:1px solid var(--line);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word}
/* глобальные table/th из board.css (min-width 520px, прописные 11px) здесь не действуют (I-1) */
div.wiki th{background:var(--soft);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit}
div.wiki code,div.wiki pre{font-family:var(--font-mono);font-size:12.5px}
div.wiki code{background:var(--soft);border-radius:3px;padding:0 3px}
div.wiki pre{background:var(--soft);border-radius:4px;padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal}