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 = } onClick={onClose} />; const back = onBack && ; if (issue.isPending) { return
{key}{close}

загрузка {key}…

; } if (issue.error instanceof ApiError && issue.error.status === 404) { return (
{back}}>Задачи {key} нет в зеркале хаба.
); } if (issue.isError && !issue.data) { return (
{back}}> {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.key} {i.status && {i.status}} Открыть в Jira {close}

{i.summary || i.key}

{facts.length > 0 &&

{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}

} {issue.isError && ( void issue.refetch()}>Повторить}>{issue.error.message} )}
} /> {i.description?.trim() && } {atts && atts.length > 0 && } {i.changes && }
| undefined} />
); } // ссылки на вложения в тексте: список для разбора, щелчок и вид кнопки (занята / ведёт в 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 }) { const groups = groupLinks(issue, linkTypes); return (

Связи

{!groups.length &&

связей нет

} {groups.map(([id, title, links]) => (
    {links.map((l, n) => (
  • {l.tag && <>{l.tag}{" "}} {absent(l) ? {l.key} : }{" "} {absent(l) ? нет в зеркале : <>{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 && (
      {items.map((c) => )}
    )}
    ); } 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, kindLabels }: { issue: IssueDetail; kindLabels?: Record }) { 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 (

    Прочие поля

    {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}
    ); }