Пакет веб-багов: карточка, лента, вики-разметка, состояние модулей (#124) #125
@ -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");
|
||||
|
||||
@ -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 = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
|
||||
const back = onBack && <Button onClick={onBack}>Назад</Button>;
|
||||
if (issue.isPending) {
|
||||
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
|
||||
}
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
return (
|
||||
<div className="issue issue--state" data-testid="issue-card">
|
||||
<Notice tone="info" action={<Button onClick={onClose}>Закрыть</Button>}>Задачи {key} нет в зеркале хаба.</Notice>
|
||||
<Notice tone="info" action={<>{back}<Button onClick={onClose}>Закрыть</Button></>}>Задачи {key} нет в зеркале хаба.</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (issue.isError && !issue.data) {
|
||||
return (
|
||||
<div className="issue issue--state" data-testid="issue-card">
|
||||
<Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button><Button onClick={onClose}>Закрыть</Button></>}>
|
||||
<Notice tone="error" action={<><Button onClick={() => void issue.refetch()}>Повторить</Button>{back}<Button onClick={onClose}>Закрыть</Button></>}>
|
||||
{issue.error.message}
|
||||
</Notice>
|
||||
</div>
|
||||
@ -76,14 +77,14 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
<div className="cols">
|
||||
<div className="main">
|
||||
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
||||
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
|
||||
{i.description?.trim() && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
|
||||
{atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />}
|
||||
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} />
|
||||
{i.changes && <History items={i.changes} />}
|
||||
</div>
|
||||
<Panel className="side">
|
||||
<Notes key={i.key} issue={i} />
|
||||
<OtherFields issue={i} />
|
||||
<OtherFields issue={i} kindLabels={settings.data?.kind_labels as Record<string, string> | undefined} />
|
||||
<Commands issue={i} />
|
||||
</Panel>
|
||||
</div>
|
||||
@ -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<string, string> }) {
|
||||
const groups = groupLinks(issue, linkTypes);
|
||||
return (
|
||||
@ -120,8 +123,10 @@ function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<str
|
||||
{links.map((l, n) => (
|
||||
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
|
||||
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
|
||||
<IssueLink issueKey={l.key} />{" "}
|
||||
{l.summary === null && l.status === null
|
||||
{absent(l)
|
||||
? <a href={`${JIRA_URL}/browse/${l.key}`} target="_blank" rel="noopener">{l.key}<IconExternal size={12} /></a>
|
||||
: <IssueLink issueKey={l.key} />}{" "}
|
||||
{absent(l)
|
||||
? <span className="sum absent">нет в зеркале</span>
|
||||
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<Badge>{l.status}</Badge></>}</>}
|
||||
</li>
|
||||
@ -222,9 +227,11 @@ function History({ items }: { items: ChangeEvent[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFields({ issue: i }: { issue: IssueDetail }) {
|
||||
function OtherFields({ issue: i, kindLabels }: { issue: IssueDetail; kindLabels?: Record<string, string> }) {
|
||||
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],
|
||||
];
|
||||
|
||||
@ -168,4 +168,63 @@ describe("карточка окном", () => {
|
||||
render(<App router={at(["/app/jira/issues/AURORA-2"])} />);
|
||||
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(<App router={router} />);
|
||||
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(<App router={at(["/app/jira/issues/AURORA-1999"])} />);
|
||||
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(<App router={at(["/app/jira/issues"])} />);
|
||||
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(<App router={at(["/app/jira/issues/AURORA-9002"])} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,7 +7,7 @@ import { IssueCard } from "./IssueCard.tsx";
|
||||
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; 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 на самом <dialog>, но нажатие было внутри содержимого
|
||||
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();
|
||||
}}>
|
||||
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
|
||||
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} onBack={onBack} /></ErrorBoundary>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string>;
|
||||
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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, string> | 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 };
|
||||
}
|
||||
|
||||
@ -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
|
||||
? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>
|
||||
? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} onBack={onBack} /></CloseAbortContext>
|
||||
: null;
|
||||
return { pageLocation, element };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user