265 lines
14 KiB
TypeScript
265 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|