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(" ");
}