sdlc-hub/web/src/modules/jira/issues/IssueCard.tsx

265 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type RefObject, useLayoutEffect, useRef, useState } from "react";
import { ApiError } from "../../../api/client.ts";
import { useClosedStatuses } from "../api/closed.ts";
import { useSettings } from "../../../api/queries.ts";
import { useIssue } from "../api/queries.ts";
import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "../api/types.ts";
import { JIRA_URL } from "../board/constants.ts";
import { fmtDate, fmtStamp } from "../../../ui/format.ts";
import { EventList } from "../feed/EventList.tsx";
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";
import { Notes } from "./Notes.tsx";
import { dueOf } from "./options.ts";
import { AttachmentChips } from "./attachments/AttachmentChips.tsx";
import { AttachmentList, type Download } from "./attachments/AttachmentList.tsx";
import { isTooBig, jiraAttachmentUrl } from "./attachments/format.ts";
import { useAttachmentDownload } from "./attachments/useAttachmentDownload.ts";
import { WikiText } from "./wiki/WikiText.tsx";
import "./issues.css";
const DESC_LINES = 8;
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={<>{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>{back}<Button onClick={onClose}>Закрыть</Button></>}>
{issue.error.message}
</Notice>
</div>
);
}
const i = issue.data!;
const due = dueOf(i);
const facts = [
i.assignee && <span key="who">{i.assignee}</span>,
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <Badge tone="error">просрочено</Badge></>}</span>,
i.sprint && <span key="sprint">{i.sprint}</span>,
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
].filter(Boolean);
const atts = i.attachments;
const wiki = wikiAttachments(atts, dl);
return (
<div className="issue" data-testid="issue-card">
<header className="issue-head" data-testid="issue-head">
<div className="row1">
<span className="key">{i.key}</span>
{i.status && <Badge>{i.status}</Badge>}
<span className="spacer" />
<a className="ui-btn ui-btn--secondary ui-btn--md" href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener"><IconExternal size={16} />Открыть в Jira</a>
{close}
</div>
<h2 id="issue-modal-title">{i.summary || i.key}</h2>
{facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
{issue.isError && (
<Notice tone="error" action={<Button onClick={() => void issue.refetch()}>Повторить</Button>}>{issue.error.message}</Notice>
)}
</header>
<div className="cols">
<div className="main">
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{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} kindLabels={settings.data?.kind_labels as Record<string, string> | undefined} />
<Commands issue={i} />
</Panel>
</div>
</div>
);
}
// ссылки на вложения в тексте: список для разбора, щелчок и вид кнопки (занята / ведёт в Jira)
interface WikiAtt { attachments?: { id: string; filename: string; created: string | null }[]; onAttachment?: (id: string) => void; attachmentView?: (id: string) => { busy: boolean; href?: string } }
function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): WikiAtt {
if (!atts?.length) return {};
const byId = new Map(atts.map((a) => [a.attachment_id, a]));
return {
attachments: atts.map((a) => ({ id: a.attachment_id, filename: a.filename, created: a.created })),
onAttachment: (id) => { const a = byId.get(id); if (a) dl.start(a); },
attachmentView: (id) => {
const a = byId.get(id);
if (a && isTooBig(a.size)) return { busy: false, href: jiraAttachmentUrl(a.attachment_id, a.filename) };
return { busy: dl.status(id).busy };
},
};
}
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 (
<section className="links-sec">
<h3>Связи</h3>
{!groups.length && <p className="hint">связей нет</p>}
{groups.map(([id, title, links]) => (
<div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
<h4 id={`links-${id}`}>{title}</h4>
<ul className="links">
{links.map((l, n) => (
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
{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>
))}
</ul>
</div>
))}
</section>
);
}
// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7)
function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) {
return <Button variant="ghost" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</Button>;
}
function Description({ text, projectKey, wiki }: { text: string; projectKey?: string; wiki: WikiAtt }) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const [source, setSource] = useState(false);
const [overflow, setOverflow] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el || open) return;
const measure = () => setOverflow(el.scrollHeight > el.clientHeight + 1);
measure();
// карточка из кэша монтируется до showModal (размеры нулевые), окно меняет ширину, догружаются шрифты — перемер (C-1)
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
return () => ro.disconnect();
}, [text, open, source]);
// в рендере «длинное» — только по измерению: пустые строки исходника высоты не дают (design #8)
const long = (source && text.split("\n").length > DESC_LINES) || overflow;
const cls = "description" + (open ? "" : " clamped");
return (
<section>
<div className="sec-head">
<h3>Описание</h3>
<SourceToggle source={source} onToggle={() => setSource(!source)} />
</div>
{source
? <pre ref={ref as RefObject<HTMLPreElement>} className={cls}>{text}</pre>
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} {...wiki} /></div>}
{(long || open) && <Button variant="ghost" className="more" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</Button>}
</section>
);
}
function Comment({ c, projectKey, wiki, atts, dl }: { c: IssueComment; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) {
const [source, setSource] = useState(false);
return (
<li>
<div className="c-head">
<span className="c-head__name">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</span>
<SourceToggle source={source} onToggle={() => setSource(!source)} />
</div>
{source
? <pre className="body">{c.body}</pre>
: <div className="body"><WikiText text={c.body} projectKey={projectKey} {...wiki} /></div>}
<AttachmentChips items={(c.attachment_ids ?? []).flatMap((id) => atts.filter((a) => a.attachment_id === id))} dl={dl} />
</li>
);
}
function Comments({ items, projectKey, wiki, atts, dl }: { items: IssueComment[]; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) {
const [hidden, setHidden] = useCommentsHidden();
return (
<section className="comments" data-testid="comments">
<div className="sec-head">
<h3>Комментарии ({items.length})</h3>
{items.length > 0 && <Button variant="ghost" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</Button>}
</div>
{!items.length && <p className="hint">комментариев нет</p>}
{items.length > 0 && !hidden && (
<ul>
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} wiki={wiki} atts={atts} dl={dl} />)}
</ul>
)}
</section>
);
}
const HISTORY_SHOWN = 10;
// журнал изменений задачи: 10 последних, остальное — по «Показать все» (spec web-app «История в карточке задачи»)
function History({ items }: { items: ChangeEvent[] }) {
const [all, setAll] = useState(false);
const shown = all ? items : items.slice(0, HISTORY_SHOWN);
return (
<section className="history" data-testid="history">
<div className="sec-head"><h3>История ({items.length})</h3></div>
{!items.length && <p className="hint">изменений нет</p>}
{items.length > 0 && <EventList events={shown} now={new Date()} alwaysDate />}
{!all && items.length > HISTORY_SHOWN && <Button variant="ghost" className="more" onClick={() => setAll(true)}>{`Показать все (${items.length})`}</Button>}
</section>
);
}
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],
["Вид работы в Jira", kinds.join(", ") || null], ["Метки", rest.join(", ") || null],
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
["Сосед по связям", i.neighbor ? "да" : null],
];
return (
<section className="other" aria-labelledby="other-title">
<h3 id="other-title">Прочие поля</h3>
<dl className="fields">{rows.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
</section>
);
}
function Commands({ issue: i }: { issue: IssueDetail }) {
if (!i.commands.length) {
return <section className="commands"><h3>Команды хаба</h3><p className="hint">команд по задаче не было</p></section>;
}
return (
<section className="commands">
<details>
<summary>Команды хаба ({i.commands.length})</summary>
<div className="cmd-wrap"><table className="grid">
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
<tbody>{i.commands.map((c) => (
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
))}</tbody>
</table></div>
</details>
</section>
);
}