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):