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, 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 }: { issueKey: string; onClose: () => void }) {
const issue = useIssue(key);
const settings = useSettings();
const closed = useClosedStatuses();
const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки
const close = } onClick={onClose} />;
if (issue.isPending) {
return
{key}{close}
загрузка {key}…
;
}
if (issue.error instanceof ApiError && issue.error.status === 404) {
return (
Закрыть}>Задачи {key} нет в зеркале хаба.
);
}
if (issue.isError && !issue.data) {
return (
>}>
{issue.error.message}
);
}
const i = issue.data!;
const due = dueOf(i);
const facts = [
i.assignee && {i.assignee},
due && срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> просрочено>},
i.sprint && {i.sprint},
i.epic_key && эпик: {i.epic_summary ?? i.epic_key},
].filter(Boolean);
const atts = i.attachments;
const wiki = wikiAttachments(atts, dl);
return (
} />
{i.description && }
{atts && atts.length > 0 && }
{i.changes && }
);
}
// ссылки на вложения в тексте: список для разбора, щелчок и вид кнопки (занята / ведёт в 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 };
},
};
}
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) {
const groups = groupLinks(issue, linkTypes);
return (
Связи
{!groups.length && связей нет
}
{groups.map(([id, title, links]) => (
{title}
{links.map((l, n) => (
-
{l.tag && <>{l.tag}{" "}>}
{" "}
{l.summary === null && l.status === null
? нет в зеркале
: <>{l.summary}{l.status && <>{" "}{l.status}>}>}
))}
))}
);
}
// переключатель блока «исходник» / «текст»; выбор живёт до размонтирования карточки (design wiki-render #7)
function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => void }) {
return ;
}
function Description({ text, projectKey, wiki }: { text: string; projectKey?: string; wiki: WikiAtt }) {
const ref = useRef(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 (
Описание
setSource(!source)} />
{source
? } className={cls}>{text}
: } className={cls}>
}
{(long || open) && }
);
}
function Comment({ c, projectKey, wiki, atts, dl }: { c: IssueComment; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) {
const [source, setSource] = useState(false);
return (
{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}
setSource(!source)} />
{source
? {c.body}
:
}
atts.filter((a) => a.attachment_id === id))} dl={dl} />
);
}
function Comments({ items, projectKey, wiki, atts, dl }: { items: IssueComment[]; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) {
const [hidden, setHidden] = useCommentsHidden();
return (
Комментарии ({items.length})
{items.length > 0 && }
{!items.length && комментариев нет
}
{items.length > 0 && !hidden && (
)}
);
}
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 (
История ({items.length})
{!items.length && изменений нет
}
{items.length > 0 && }
{!all && items.length > HISTORY_SHOWN && }
);
}
function OtherFields({ issue: i }: { issue: IssueDetail }) {
const rows: [string, string | null][] = [
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
["Сосед по связям", i.neighbor ? "да" : null],
];
return (
Прочие поля
{rows.filter(([, v]) => v).map(([k, v]) => - {k}
- {v}
)}
);
}
function Commands({ issue: i }: { issue: IssueDetail }) {
if (!i.commands.length) {
return Команды хаба
команд по задаче не было
;
}
return (
Команды хаба ({i.commands.length})
| Команда | Пакет | Тип | Статус | Завершена | Ошибка |
{i.commands.map((c) => (
| #{c.id} | #{c.batch_id} | {c.type} | {c.status} |
{c.finished_at ? fmtStamp(c.finished_at) : ""} | {c.error} |
))}
);
}