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:
parent
52f82daf3e
commit
923c59b2b0
@ -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();
|
||||
|
||||
@ -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 })));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)]),
|
||||
|
||||
@ -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 }] };
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user