diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index 7fa547d..0bfb060 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -73,6 +73,34 @@ describe("карточка задачи", () => { expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы }); + it("связь на ключ вне зеркала ведёт в Jira, а не в карточку (#103)", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { + links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: null, status: null }], links_in: [], + }))); + open("/jira/issues/AURORA-9001"); + const link = await screen.findByRole("link", { name: /AURORA-1999/ }); + expect(link).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-1999"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link.closest("li")).toHaveTextContent("нет в зеркале"); + }); + + it("«Прочие поля»: вид работы отдельно от меток (#42 M-5)", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { labels: ["AWPCA", "kind-dev"] }))); + open("/jira/issues/AURORA-9001"); + await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" }); + const other = screen.getByRole("region", { name: "Прочие поля" }); + expect(within(other).getByText("Вид работы в Jira").nextSibling).toHaveTextContent("разработка"); + expect(within(other).getByText("Метки").nextSibling).toHaveTextContent("AWPCA"); + expect(within(other).queryByText(/kind-dev/)).toBeNull(); + }); + + it("пустое описание (одни пробелы) — без раздела «Описание» (#56 M-4)", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: " \n\n" }))); + open("/jira/issues/AURORA-9001"); + await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" }); + expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); + }); + it("без связей — «связей нет»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); open("/jira/issues/AURORA-9001"); diff --git a/web/src/modules/jira/issues/IssueCard.tsx b/web/src/modules/jira/issues/IssueCard.tsx index 6d51e79..7bd142a 100644 --- a/web/src/modules/jira/issues/IssueCard.tsx +++ b/web/src/modules/jira/issues/IssueCard.tsx @@ -7,7 +7,7 @@ import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from ".. import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../../../ui/format.ts"; import { EventList } from "../feed/EventList.tsx"; -import { groupLinks, isOverdue, todayISO } from "./card.ts"; +import { groupLinks, isOverdue, splitKindLabels, todayISO } from "./card.ts"; import { useCommentsHidden } from "./commentsPref.ts"; import { IssueLink } from "./IssueLink.tsx"; import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../../../ui/index.ts"; @@ -22,26 +22,27 @@ import "./issues.css"; const DESC_LINES = 8; -export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { +export function IssueCard({ issueKey: key, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) { const issue = useIssue(key); const settings = useSettings(); const closed = useClosedStatuses(); const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки const close = } onClick={onClose} />; + const back = onBack && ; if (issue.isPending) { return
{key}{close}

загрузка {key}…

; } if (issue.error instanceof ApiError && issue.error.status === 404) { return (
- Закрыть}>Задачи {key} нет в зеркале хаба. + {back}}>Задачи {key} нет в зеркале хаба.
); } if (issue.isError && !issue.data) { return (
- }> + {back}}> {issue.error.message}
@@ -76,14 +77,14 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
} /> - {i.description && } + {i.description?.trim() && } {atts && atts.length > 0 && } {i.changes && }
- + | undefined} />
@@ -107,6 +108,8 @@ function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): Wik }; } +const absent = (l: { summary: string | null; status: string | null }) => l.summary === null && l.status === null; + function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) { const groups = groupLinks(issue, linkTypes); return ( @@ -120,8 +123,10 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record (
  • {l.tag && <>{l.tag}{" "}} - {" "} - {l.summary === null && l.status === null + {absent(l) + ? {l.key} + : }{" "} + {absent(l) ? нет в зеркале : <>{l.summary}{l.status && <>{" "}{l.status}}}
  • @@ -222,9 +227,11 @@ function History({ items }: { items: ChangeEvent[] }) { ); } -function OtherFields({ issue: i }: { issue: IssueDetail }) { +function OtherFields({ issue: i, kindLabels }: { issue: IssueDetail; kindLabels?: Record }) { + const { kinds, rest } = splitKindLabels(i.labels, kindLabels); const rows: [string, string | null][] = [ - ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null], + ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], + ["Вид работы в Jira", kinds.join(", ") || null], ["Метки", rest.join(", ") || null], ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], ["Сосед по связям", i.neighbor ? "да" : null], ]; diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index c646a4a..5ce2091 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -168,4 +168,63 @@ describe("карточка окном", () => { render(); expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); }); + + describe("задача не в зеркале после перехода (#103)", () => { + const mirror = () => mockFetch((_m, url) => { + const p = url.pathname; + if (p === "/issues/AURORA-1999") return json(404, {}); + 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 msg = "Задачи AURORA-1999 нет в зеркале хаба."; + + it("«Назад» возвращает прежнюю карточку; при новом переходе «Закрыть» закрывает окно", 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" })); + expect(await within(dialog()).findByText(msg)).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" })); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" })); + await within(dialog()).findByText(msg); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/jira/issues"); + }); + + it("прямой адрес 404 — только «Закрыть»", async () => { + mirror(); + render(); + expect(await within(await screen.findByRole("dialog")).findByText(msg)).toBeInTheDocument(); + expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument(); + expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull(); + }); + + it("«Назад» есть только после перехода: из списка — нет; из прямого адреса после перехода — есть", async () => { + mirror(); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull(); + await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" })); + await within(dialog()).findByText(msg); + expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument(); + }); + + it("переход из прямого адреса — «Назад» есть", async () => { + mirror(); + render(); + await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" })); + await within(dialog()).findByText(msg); + expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/modules/jira/issues/IssueModal.tsx b/web/src/modules/jira/issues/IssueModal.tsx index df19501..87d86db 100644 --- a/web/src/modules/jira/issues/IssueModal.tsx +++ b/web/src/modules/jira/issues/IssueModal.tsx @@ -7,7 +7,7 @@ import { IssueCard } from "./IssueCard.tsx"; // окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. // Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации // пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). -export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) { +export function IssueModal({ issueKey, onClose, onBack }: { issueKey: string; onClose: () => void; onBack?: () => void }) { // I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна, // даёт click на самом , но нажатие было внутри содержимого const downOnBackdrop = useRef(false); @@ -26,7 +26,7 @@ export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: ( downOnBackdrop.current = false; if (e.target === e.currentTarget && fromBackdrop) onClose(); }}> - + ); } diff --git a/web/src/modules/jira/issues/card.test.ts b/web/src/modules/jira/issues/card.test.ts index 493e712..6bcc24d 100644 --- a/web/src/modules/jira/issues/card.test.ts +++ b/web/src/modules/jira/issues/card.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import settings from "../board/__fixtures__/settings.json"; -import { groupLinks, isOverdue, todayISO } from "./card.ts"; +import { groupLinks, isOverdue, splitKindLabels, todayISO } from "./card.ts"; const types = settings.link_types as Record; const closed = new Set(["Готово", "Снято"]); @@ -50,3 +50,13 @@ describe("просрочка", () => { expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05"); }); }); + +describe("splitKindLabels", () => { + it("метки видов работ отдельно от прочих", () => { + expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" })) + .toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] }); + }); + it("без kind_labels — все метки прочие (Review Focus 4)", () => { + expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] }); + }); +}); diff --git a/web/src/modules/jira/issues/card.ts b/web/src/modules/jira/issues/card.ts index 2b5f1ad..437c987 100644 --- a/web/src/modules/jira/issues/card.ts +++ b/web/src/modules/jira/issues/card.ts @@ -31,3 +31,14 @@ export function isOverdue(due: string, status: string | null | undefined, closed export const todayISO = (d = new Date()): string => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +// метки задачи: виды работ (метка из kind_labels → название вида) отдельно от прочих меток +export function splitKindLabels(labels: string[], kindLabels: Record | undefined): { kinds: string[]; rest: string[] } { + const byLabel = new Map(Object.entries(kindLabels ?? {}).map(([kind, label]) => [label, kind])); + const kinds: string[] = [], rest: string[] = []; + for (const l of labels) { + const k = byLabel.get(l); + if (k !== undefined) kinds.push(k); else rest.push(l); + } + return { kinds, rest }; +} diff --git a/web/src/modules/jira/issues/overlay.tsx b/web/src/modules/jira/issues/overlay.tsx index ad3b173..b980c70 100644 --- a/web/src/modules/jira/issues/overlay.tsx +++ b/web/src/modules/jira/issues/overlay.tsx @@ -42,8 +42,11 @@ export function useIssueOverlay(): Overlay { const abortClose = useCallback(() => { closingDirect.current = false; }, []); + // «Назад» в окне — только если оно открыто переходом из другой карточки (из списка depth = 1; прямой адрес — direct, depth 0) + const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void navigate(-1) : undefined; + const element = issueKey - ? + ? : null; return { pageLocation, element }; }