import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useClosedStatuses } from "../api/closed.ts"; import { useIssue, useSettings } from "../api/queries.ts"; import type { IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../board/model/util.ts"; 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 { 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 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); 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 && }
); } 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}{" "}} {" "} {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 }: { text: string; projectKey?: string }) { 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 }: { c: IssueComment; projectKey?: string }) { const [source, setSource] = useState(false); return (
  • {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""} setSource(!source)} />
    {source ?
    {c.body}
    :
    }
  • ); } function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) { const [hidden, setHidden] = useCommentsHidden(); return (

    Комментарии ({items.length})

    {items.length > 0 && }
    {!items.length &&

    комментариев нет

    } {items.length > 0 && !hidden && (
      {items.map((c) => )}
    )}
    ); } 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}
    ); }