diff --git a/web/src/modules/jira/feed/events.test.ts b/web/src/modules/jira/feed/events.test.ts index b0014e3..bfd61e6 100644 --- a/web/src/modules/jira/feed/events.test.ts +++ b/web/src/modules/jira/feed/events.test.ts @@ -60,6 +60,11 @@ describe("текст события", () => { .toBe("комментарий: Итог Готово, см. протокол и @ivanov"); }); + it("вложения в комментарии — именем, даже без списка вложений (I-1)", () => { + expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "см. !скрин.png|thumbnail! и [^отчёт.pdf]" }))) + .toBe("комментарий: см. скрин.png и отчёт.pdf"); + }); + it("пустой комментарий — по-прежнему «—»", () => { expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: " " }))).toBe("комментарий: —"); }); diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index ed6ff1b..3cbe92f 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -224,7 +224,7 @@ describe("карточка окном", () => { expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); }); - describe("задача не в зеркале после перехода (#103)", () => { + describe("задача не в зеркале после перехода (#42 M-5)", () => { const mirror = () => mockFetch((_m, url) => { const p = url.pathname; if (p === "/issues/AURORA-1999") return json(404, {}); @@ -263,7 +263,7 @@ describe("карточка окном", () => { expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull(); }); - it("«Назад» есть только после перехода: из списка — нет; из прямого адреса после перехода — есть", async () => { + it("«Назад» есть только после перехода: из списка — нет, после перехода из списка — есть", async () => { mirror(); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); @@ -274,6 +274,54 @@ describe("карточка окном", () => { expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument(); }); + it("двойной щелчок по «Назад» — один шаг, окно остаётся (M-1)", async () => { + mirror(); + const router = at(["/app/jira/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); + await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" })); + await within(dialog()).findByText(msg); + const back = within(dialog()).getByRole("button", { name: "Назад" }); + await act(async () => { fireEvent.click(back); fireEvent.click(back); }); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); + }); + + it("двойной «Назад» из прямого адреса не уходит со страницы приложения (M-1)", async () => { + mirror(); + const router = at(["/app/jira/issues/AURORA-9002"]); + render(); + await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" })); + await within(dialog()).findByText(msg); + const back = within(dialog()).getByRole("button", { name: "Назад" }); + await act(async () => { fireEvent.click(back); fireEvent.click(back); }); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); + }); + + it("ошибка загрузки после перехода: «Повторить», «Назад», «Закрыть» (T3.2)", async () => { + mockFetch((_m, url) => { + const p = url.pathname; + if (p === "/issues/AURORA-1999") return json(500, { detail: "сбой" }); + if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", { + links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [], + })); + if (p === "/settings") return json(200, settings); + if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] }); + if (p === "/issues") return json(200, { issues: rows }); + if (p === "/batches") return json(200, { batches: [] }); + return json(200, {}); + }); + const router = at(["/app/jira/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); + await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" })); + expect(await within(dialog()).findByRole("button", { name: "Повторить" })).toBeInTheDocument(); + expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" })); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + }); + it("переход из прямого адреса — «Назад» есть", async () => { mirror(); render(); diff --git a/web/src/modules/jira/issues/overlay.tsx b/web/src/modules/jira/issues/overlay.tsx index 4ece74d..f028ad0 100644 --- a/web/src/modules/jira/issues/overlay.tsx +++ b/web/src/modules/jira/issues/overlay.tsx @@ -48,7 +48,14 @@ export function useIssueOverlay(): Overlay { const abortClose = useCallback(() => { closingDirect.current = false; closingKey.current = null; }, []); // «Назад» в окне — только если оно открыто переходом из другой карточки (из списка depth = 1; прямой адрес — direct, depth 0) - const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void navigate(-1) : undefined; + // повторный щелчок на той же записи истории игнорируется тем же closingKey (M-1) + const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) + ? () => { + if (closingKey.current === location.key) return; + closingKey.current = location.key; + void navigate(-1); + } + : undefined; const element = issueKey ? diff --git a/web/src/modules/jira/issues/wiki/inline.ts b/web/src/modules/jira/issues/wiki/inline.ts index 0ce6ab0..3296d7b 100644 --- a/web/src/modules/jira/issues/wiki/inline.ts +++ b/web/src/modules/jira/issues/wiki/inline.ts @@ -1,9 +1,10 @@ // Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач. // Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть. -// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое) +// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — первое в порядке хаба, новые сверху) export interface WikiAttachment { id: string; filename: string; created: string | null } -export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] } +// anyAttachment — только для плоского текста (wikiPlain): `!имя!` и `[^имя]` — вложение, даже если имени нет в списке +export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[]; anyAttachment?: boolean } type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub"; @@ -34,7 +35,8 @@ function safeHref(raw: string): string | null { // вложение по имени: несколько с одним именем — первое в порядке хаба function findAttachment(opts: WikiOptions, name: string): string | null { - return (opts.attachments ?? []).find((a) => a.filename === name)?.id ?? null; + const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id; + return found ?? (opts.anyAttachment ? "" : null); } function keyPattern(projectKey: string | undefined): RegExp | null { @@ -81,11 +83,11 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false) text(c); i += 1; continue; } - if (c === "!" && !label && opts.attachments?.length) { + if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) { IMAGE.lastIndex = i; const m = IMAGE.exec(line); const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null; - if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; } + if (m && id !== null) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; } } if (c === "h" && !label && prevOk(i)) { @@ -146,7 +148,7 @@ function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean) const ref = ATT_LINK.exec(body); if (ref) { const id = findAttachment(opts, ref[2]); - return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null; + return id !== null ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null; } const bar = body.indexOf("|"); const label = bar >= 0 ? body.slice(0, bar) : ""; diff --git a/web/src/modules/jira/issues/wiki/parse.test.ts b/web/src/modules/jira/issues/wiki/parse.test.ts index 22e799b..3d85b2e 100644 --- a/web/src/modules/jira/issues/wiki/parse.test.ts +++ b/web/src/modules/jira/issues/wiki/parse.test.ts @@ -92,6 +92,17 @@ describe("parseWiki: блоки-макросы", () => { expect(table).toMatchObject({ t: "table" }); expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]); }); + it("непарная [ не берёт чужую ] из ссылки в следующей ячейке (T1.1)", () => { + const [table] = parseWiki("|[draft|итог|[док|https://x.ru]|"); + const cells = (table as { rows: { children: Inline[] }[][] }).rows[0]; + expect(cells).toHaveLength(3); + expect(cells[0].children[0]).toEqual({ t: "text", text: "[draft" }); + expect(cells[1].children[0]).toEqual({ t: "text", text: "итог" }); + expect(cells[2].children[0]).toMatchObject({ t: "link", href: "https://x.ru" }); + }); + it("{code} {code} в одной строке — пустой блок, не pre из пробелов (T1.2)", () => { + expect(parseWiki("{code} {code}")).toEqual([{ t: "pre", text: "" }]); + }); it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => { const [table] = parseWiki("|[док|https://x.ru]|b|"); expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2); diff --git a/web/src/modules/jira/issues/wiki/parse.ts b/web/src/modules/jira/issues/wiki/parse.ts index 635b1bd..a731a90 100644 --- a/web/src/modules/jira/issues/wiki/parse.ts +++ b/web/src/modules/jira/issues/wiki/parse.ts @@ -109,7 +109,8 @@ function closeMacro(lines: string[], start: number, open: RegExpExecArray): { na const text = k === start ? rest : lines[k]; const at = text.indexOf(tag); if (at < 0) { if (k !== start || text.trim()) body.push(text); continue; } - if (text.slice(0, at)) body.push(text.slice(0, at)); + const head = text.slice(0, at); + if (head && (k !== start || head.trim())) body.push(head); return { name, body, end: k, after: text.slice(at + tag.length) }; } return null; @@ -126,7 +127,7 @@ function row(line: string, opts: WikiOptions): Cell[] { let depth = 0; // `|` внутри [ссылки|адрес] ячейку не режет while (j < s.length && (s[j] !== "|" || depth > 0)) { - if (s[j] === "[") { if (s.indexOf("]", j + 1) >= 0) depth++; } + if (s[j] === "[") { if (/^[^[\]]*\]/.test(s.slice(j + 1))) depth++; } else if (s[j] === "]" && depth > 0) depth--; j++; } diff --git a/web/src/modules/jira/issues/wiki/plain.test.ts b/web/src/modules/jira/issues/wiki/plain.test.ts index 9b9b2d6..38712b9 100644 --- a/web/src/modules/jira/issues/wiki/plain.test.ts +++ b/web/src/modules/jira/issues/wiki/plain.test.ts @@ -14,6 +14,9 @@ describe("wikiPlain", () => { const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] }; expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png"); }); + it("вложение, которого нет в списке, — именем (I-1)", () => { + expect(wikiPlain("!скрин.png|width=50! [текст|^отчёт.pdf] [^а.txt]").trim()).toBe("скрин.png текст а.txt"); + }); it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => { expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца"); expect(wikiPlain("")).toBe(""); diff --git a/web/src/modules/jira/issues/wiki/plain.ts b/web/src/modules/jira/issues/wiki/plain.ts index 693bd44..682a5f3 100644 --- a/web/src/modules/jira/issues/wiki/plain.ts +++ b/web/src/modules/jira/issues/wiki/plain.ts @@ -31,5 +31,5 @@ function block(b: Block): string[] { } export function wikiPlain(text: string, opts: WikiOptions = {}): string { - return parseWiki(text, opts).flatMap(block).join(" "); + return parseWiki(text, { ...opts, anyAttachment: true }).flatMap(block).join(" "); }