From 923c59b2b0d957aa6aee030dd0b97baf180c984a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 01:23:37 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#5?= =?UTF-8?q?3=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D1=81=D0=B2=D1=91=D1=80=D1=82=D0=BA=D0=B8=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8F,=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=82=D0=BA=D0=B8,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D1=8C=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/e2e/issues.spec.ts | 23 +++++++++++++++++++++++ web/src/issues/IssueCard.test.tsx | 17 +++++++++++++++++ web/src/issues/IssueCard.tsx | 10 +++++++++- web/src/issues/wiki/inline.test.ts | 6 ++++++ web/src/issues/wiki/inline.ts | 15 ++++++++------- web/src/issues/wiki/wiki.css | 7 ++++--- 6 files changed, 67 insertions(+), 11 deletions(-) diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 0273ea2..912aa74 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -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(); diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index e27dcab..e6c25c9 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -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 }))); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 59cc31c..b719366 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -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; diff --git a/web/src/issues/wiki/inline.test.ts b/web/src/issues/wiki/inline.test.ts index 3160e56..ad03b36 100644 --- a/web/src/issues/wiki/inline.test.ts +++ b/web/src/issues/wiki/inline.test.ts @@ -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)]), diff --git a/web/src/issues/wiki/inline.ts b/web/src/issues/wiki/inline.ts index 263a748..94873d7 100644 --- a/web/src/issues/wiki/inline.ts +++ b/web/src/issues/wiki/inline.ts @@ -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 — подпись ссылки: без ссылок, адресов и ключей внутри, чтобы не было в (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 }] }; } diff --git a/web/src/issues/wiki/wiki.css b/web/src/issues/wiki/wiki.css index 3e769bf..133d5d5 100644 --- a/web/src/issues/wiki/wiki.css +++ b/web/src/issues/wiki/wiki.css @@ -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}