/** * The board body: a kanban grouped by priority, or the same rows as a table. * * The API has no column concept — an issue is open/closed and carries a * priority — so the kanban columns *are* the priorities, which makes dragging a * card a real `PATCH …/issues/{id} {priority}` write instead of a client-side * illusion. Loading / empty / error / out-of-range paging are all rendered from * the request state, never from placeholder data. */ import { IconArrowRight as ArrowRight, IconCircle as Circle, IconCircleCheck as CircleCheck, IconInbox as Inbox, IconLoader2 as Loader2, IconFlag as MilestoneIcon, IconPlus as Plus, } from '@tabler/icons-react' import { useState } from 'react' import './board-polish.css' import { ErrorPanel, Markdown } from '@app/components' import { type Board, type BoardRef, type IssueBoardView, type IssueListItem, type IssuePriority, issueLabel, issueRef, issueStamp, type Milestone, normalizeColor, PRIORITIES, priorityOf, STATES, updateIssue, } from '@app/domains/issues/model' import { useT } from '@app/i18n' import type { BoardData } from '../model/use-board-data' import { AvatarStack, DueChip, FilterMenu, PriorityBadge, PriorityGlyph } from './parts' export interface IssueBoardBodyProps { boardRef: BoardRef data: BoardData view: IssueBoardView filtered: boolean onFilter: (updates: Partial) => void onClear: () => void /** `issue` is the board-scoped number so the URL stays readable. */ onOpenIssue: (issue: string) => void onCreate: (preset?: { priority?: IssuePriority }) => void onChanged: () => void onRetry: () => void } const PAGE_SIZE_FALLBACK = 50 export function IssueBoardBody(props: IssueBoardBodyProps) { const { t, tc } = useT() const { data, view } = props const state = data.issues const failure = state.error as { code?: number; message?: string } | undefined const page = state.data const total = page?.total ?? 0 const limit = page?.limit || PAGE_SIZE_FALLBACK const offset = page?.offset ?? (view.page - 1) * limit const loaded = page?.items ?? [] if (failure && loaded.length === 0) { return (
) } if (!page && state.loading) return // Paging past the end (a stale `?page=`, or issues deleted meanwhile) would // otherwise read as "no results" — say what it is and offer the way back. if (page && loaded.length === 0 && offset > 0) { return (

{tc('issue.board.pageOutOfRange', total, { page: view.page, total, limit })}

) } const rows = loaded if (rows.length === 0) { return props.filtered ? ( ) : ( props.onCreate() }} /> ) } return (
{view.view === 'board' ? : } {state.loading ? (

{t('issue.board.refreshing')}

) : null}
) } /* ------------------------------------------------------------- kanban --- */ function BoardColumns({ boardRef, rows, data, onOpenIssue, onCreate, onChanged, }: IssueBoardBodyProps & { rows: IssueListItem[] }) { // Optimistic priority while a PATCH is in flight, so a dropped card never // jumps back. The overrides are stamped with the row set they were painted // over: as soon as the data changes (reload, page switch) they expire on // their own, which keeps a failed write from sticking forever. const { t, tc } = useT() const stamp = rows.map((row) => row.id).join(',') const [override, setOverride] = useState<{ stamp: string moves: Record error: string | null }>({ stamp, moves: {}, error: null }) const fresh = override.stamp === stamp const moves = fresh ? override.moves : {} const error = fresh ? override.error : null const [dragging, setDragging] = useState(null) const [over, setOver] = useState(null) async function moveTo(issue: IssueListItem, priority: IssuePriority) { if (priorityOf(issue.priority).id === priority) return const base = fresh ? override.moves : {} setOverride({ stamp, moves: { ...base, [issue.id]: priority }, error: null }) try { await updateIssue(boardRef, issue.id, { priority }) onChanged() } catch (err) { const next = { ...base } delete next[issue.id] setOverride({ stamp, moves: next, error: t('issue.board.moveFailed', { priority: priorityOf(priority).label, reason: (err as Error)?.message ?? t('common.state.unknown'), }), }) } } return (
{PRIORITIES.map((meta) => { const cards = rows.filter((issue) => (moves[issue.id] ?? issue.priority) === meta.id) return (
{ event.preventDefault() setOver(meta.id) }} onDragLeave={() => setOver((current) => (current === meta.id ? null : current))} onDrop={(event) => { event.preventDefault() setOver(null) const id = event.dataTransfer.getData('text/plain') || dragging const issue = rows.find((row) => row.id === id) if (issue) void moveTo(issue, meta.id) setDragging(null) }} className={`issue-board-column flex h-full w-[266px] shrink-0 flex-col gap-2 rounded-lg p-2.5 transition-colors ${ over === meta.id ? 'bg-shell-active' : 'bg-shell-board' }`} >
{meta.label} {cards.length}
{cards.map((issue) => ( m.id === issue.milestone_id)} draggable onDragStart={() => setDragging(issue.id)} onDragEnd={() => setDragging(null)} onOpen={() => onOpenIssue(issueRef(data.board.data, issue.number))} /> ))} {cards.length === 0 ? ( ) : null}
) })} {error ? (

{error}

) : null}
) } function IssueCard({ issue, board, milestone, onOpen, draggable, moving, onDragStart, onDragEnd, }: { issue: IssueListItem board?: Board milestone?: Milestone onOpen: () => void draggable?: boolean moving?: boolean onDragStart?: () => void onDragEnd?: () => void }) { const { t } = useT() return (
{ event.dataTransfer.setData('text/plain', issue.id) event.dataTransfer.effectAllowed = 'move' onDragStart?.() }} onDragEnd={onDragEnd} onClick={onOpen} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onOpen() } }} role="button" tabIndex={0} aria-label={t('issue.board.cardAria', { number: issueLabel(board, issue.number), title: issue.title })} className="issue-board-card cursor-pointer space-y-2 rounded-lg bg-shell-surface p-3 shadow-[0_1px_2px_rgb(28_30_34/8%)] outline-none transition-shadow hover:shadow-[0_2px_8px_rgb(28_30_34/14%)] focus-visible:ring-2 focus-visible:ring-shell-tile" >

{issue.title}

{issueLabel(board, issue.number)} {milestone ? ( {milestone.name} ) : null} {issue.state === 'closed' ? ( ) : null}
) } /* --------------------------------------------------------------- list --- */ /** * Linear-style rows: priority mark, state mark, key, title, then the trailing * facts (milestone, assignees, updated). No header, no cell borders, no card — * hover is the only container, so the page reads as one list of issues. */ function IssueList({ boardRef, rows, data, onOpenIssue, onChanged }: IssueBoardBodyProps & { rows: IssueListItem[] }) { const { t } = useT() // One shared width for the key column: a long prefix (APACHE-17) must not // wrap, and every row still starts its title at the same x. const keyChars = Math.max(4, ...rows.map((issue) => issueLabel(data.board.data, issue.number).length)) return (
{rows.map((issue) => { const milestone = data.milestones.data?.items.find((item) => item.id === issue.milestone_id) const open = () => onOpenIssue(issueRef(data.board.data, issue.number)) return (
{/* The priority mark is a control inside a clickable row: its own clicks must not fall through and open the issue. */} event.stopPropagation()}> } value={issue.priority} options={PRIORITIES.map((meta) => ({ value: meta.id, label: meta.label, color: meta.color }))} onChange={async (value) => { await updateIssue(boardRef, issue.id, { priority: value as IssuePriority }).catch(() => undefined) onChanged() }} /> {issue.state === 'closed' ? ( ) : ( )} {STATES[issue.state]?.label} {issueLabel(data.board.data, issue.number)} {(issue.labels ?? []).slice(0, 3).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 3 ? ( +{(issue.labels ?? []).length - 3} ) : null} {milestone ? ( {milestone.name} ) : null} {issueStamp(issue.updated_at ?? issue.created_at)}
) })}
) } /* --------------------------------------------------------------- misc --- */ function Pager({ view, total, limit, onFilter, }: { view: IssueBoardView total: number limit: number onFilter: (updates: Partial) => void }) { const { t, tc } = useT() const pages = Math.max(1, Math.ceil(total / limit)) if (pages <= 1) return null const page = Math.min(view.page, pages) return (
{tc('issue.board.pager', total, { page, pages, total })}
) } /** Public so the route layer can hold the frame while a preference decides the layout. */ export function BoardSkeleton({ columns }: { columns: number }) { const { t } = useT() return (
{Array.from({ length: columns }, (_, index) => (
{Array.from({ length: 3 }, (_, card) => (
))}
))}
) } function EmptyNote({ title, hint, action, }: { title: string hint: string action?: { label: string; onClick: () => void } }) { return (

{title}

{hint}

{action ? ( ) : null}
) }