Skip to content
Clone
main
517 lines 19.3 KB
/**
* 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<IssueBoardView>) => 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 (
<div className="w-full rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
<ErrorPanel
status={typeof failure.code === 'number' ? failure.code : 0}
what={t('issue.board.errorList')}
note={failure.message}
onRetry={props.onRetry}
/>
</div>
)
}
if (!page && state.loading) return <BoardSkeleton columns={view.view === 'board' ? 5 : 1} />
// 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 (
<div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-center">
<p className="text-[13px]">{tc('issue.board.pageOutOfRange', total, { page: view.page, total, limit })}</p>
<button
type="button"
onClick={() => props.onFilter({ page: 1 })}
className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
>
{t('issue.board.backToFirst')}
</button>
</div>
)
}
const rows = loaded
if (rows.length === 0) {
return props.filtered ? (
<EmptyNote
title={t('issue.board.emptyFilteredTitle')}
hint={t('issue.board.emptyFilteredHint')}
action={{ label: t('issue.filter.clear'), onClick: props.onClear }}
/>
) : (
<EmptyNote
title={t('issue.board.emptyTitle')}
hint={t('issue.board.emptyHint')}
action={{ label: t('issue.board.new'), onClick: () => props.onCreate() }}
/>
)
}
return (
<div className="issue-board-body flex min-h-0 w-full flex-col gap-2">
{view.view === 'board' ? <BoardColumns {...props} rows={rows} /> : <IssueList {...props} rows={rows} />}
<Pager view={view} total={total} limit={limit} onFilter={props.onFilter} />
{state.loading ? (
<p className="flex items-center gap-1.5 text-[11.5px] text-shell-ink-muted">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden /> {t('issue.board.refreshing')}
</p>
) : null}
</div>
)
}
/* ------------------------------------------------------------- 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<string, IssuePriority>
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<string | null>(null)
const [over, setOver] = useState<IssuePriority | null>(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 (
<div className="issue-board-columns flex min-h-0 flex-1 gap-3">
{PRIORITIES.map((meta) => {
const cards = rows.filter((issue) => (moves[issue.id] ?? issue.priority) === meta.id)
return (
<section
key={meta.id}
aria-label={tc('issue.board.columnAria', cards.length, { priority: meta.label, count: cards.length })}
data-priority={meta.id}
data-drag-over={over === meta.id || undefined}
onDragOver={(event) => {
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'
}`}
>
<header className="issue-board-column-header flex h-6 shrink-0 items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: meta.color }} aria-hidden />
<span className="text-[14px] font-semibold">{meta.label}</span>
<span className="issue-board-count text-[12px] text-shell-ink-muted">{cards.length}</span>
<button
type="button"
aria-label={t('issue.board.addInColumn', { column: meta.label })}
title={t('issue.board.addInColumn', { column: meta.label })}
onClick={() => onCreate({ priority: meta.id })}
className="issue-board-add ml-auto rounded px-1 text-[15px] leading-none text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
>
<Plus className="h-4 w-4" aria-hidden />
</button>
</header>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pb-1">
{cards.map((issue) => (
<IssueCard
key={issue.id}
issue={{ ...issue, priority: moves[issue.id] ?? issue.priority }}
board={data.board.data}
moving={dragging === issue.id}
milestone={data.milestones.data?.items.find((m) => m.id === issue.milestone_id)}
draggable
onDragStart={() => setDragging(issue.id)}
onDragEnd={() => setDragging(null)}
onOpen={() => onOpenIssue(issueRef(data.board.data, issue.number))}
/>
))}
{cards.length === 0 ? (
<button
type="button"
onClick={() => onCreate({ priority: meta.id })}
className="issue-board-column-empty"
>
<Plus className="h-5 w-5" aria-hidden />
<span>{t('issue.board.addCard')}</span>
<small>{t('issue.board.addCardHint')}</small>
</button>
) : null}
</div>
</section>
)
})}
{error ? (
<p
role="alert"
className="fixed bottom-6 left-1/2 z-40 -translate-x-1/2 rounded-md bg-[#e5484d] px-3 py-2 text-[12.5px] font-medium text-white shadow-lg"
>
{error}
</p>
) : null}
</div>
)
}
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 (
<article
draggable={draggable}
data-moving={moving || undefined}
onDragStart={(event) => {
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"
>
<div className="flex items-start gap-2">
<p className="issue-board-card-title min-w-0 flex-1 text-[13px] font-medium leading-5 break-words">
<Markdown inline>{issue.title}</Markdown>
</p>
<PriorityBadge priority={issue.priority} compact />
</div>
<div className="issue-board-card-meta flex items-center gap-2">
<span className="font-mono text-[11px] text-shell-ink-muted">{issueLabel(board, issue.number)}</span>
{milestone ? (
<span
className="truncate text-[11px] text-shell-ink-muted"
title={t('issue.board.milestoneTitle', { name: milestone.name })}
>
{milestone.name}
</span>
) : null}
<DueChip due={issue.due_at} state={issue.state} />
<span className="ml-auto flex items-center gap-1.5">
<AvatarStack users={issue.assignees} size={23} />
{issue.state === 'closed' ? (
<CircleCheck className="h-3.5 w-3.5 text-shell-ink-muted" aria-label={t('label.issueState.closed')} />
) : null}
</span>
</div>
</article>
)
}
/* --------------------------------------------------------------- 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 (
<div className="issue-list" role="list" aria-label={t('issue.board.tableCaption')}>
{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 (
<div key={issue.id} role="listitem" className="issue-list-row" onClick={open}>
{/* The priority mark is a control inside a clickable row: its own
clicks must not fall through and open the issue. */}
<span className="issue-list-priority" onClick={(event) => event.stopPropagation()}>
<FilterMenu
label={t('issue.board.filter.priority')}
iconOnly
leading={<PriorityGlyph priority={issue.priority} />}
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()
}}
/>
</span>
<span className="issue-list-state" data-state={issue.state} title={STATES[issue.state]?.label}>
{issue.state === 'closed' ? (
<CircleCheck className="h-3.5 w-3.5" aria-hidden />
) : (
<Circle className="h-3.5 w-3.5" aria-hidden />
)}
<span className="sr-only">{STATES[issue.state]?.label}</span>
</span>
<span className="issue-list-key" style={{ minWidth: keyChars + 'ch' }}>
{issueLabel(data.board.data, issue.number)}
</span>
<button
type="button"
className="issue-list-title"
title={issue.title}
onClick={(event) => {
event.stopPropagation()
open()
}}
>
<Markdown inline>{issue.title}</Markdown>
</button>
<span className="issue-list-meta">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span key={label.id} className="issue-list-chip" title={label.name}>
<span
className="issue-list-dot"
style={{ backgroundColor: normalizeColor(label.color) }}
aria-hidden
/>
<span className="issue-list-chip-text">{label.name}</span>
</span>
))}
{(issue.labels ?? []).length > 3 ? (
<span className="issue-list-chip">+{(issue.labels ?? []).length - 3}</span>
) : null}
{milestone ? (
<span className="issue-list-chip" title={t('issue.board.milestoneTitle', { name: milestone.name })}>
<MilestoneIcon className="h-3 w-3 shrink-0" aria-hidden />
<span className="issue-list-chip-text">{milestone.name}</span>
</span>
) : null}
<AvatarStack users={issue.assignees} max={2} size={20} />
<span className="issue-list-date" title={issue.updated_at ?? issue.created_at}>
{issueStamp(issue.updated_at ?? issue.created_at)}
</span>
</span>
</div>
)
})}
</div>
)
}
/* --------------------------------------------------------------- misc --- */
function Pager({
view,
total,
limit,
onFilter,
}: {
view: IssueBoardView
total: number
limit: number
onFilter: (updates: Partial<IssueBoardView>) => 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 (
<div className="flex shrink-0 items-center justify-end gap-2 text-[12.5px] text-shell-ink-muted">
<span>{tc('issue.board.pager', total, { page, pages, total })}</span>
<button
type="button"
disabled={page <= 1}
onClick={() => onFilter({ page: page - 1 })}
className="rounded-md border-2 border-shell-hairline bg-shell-field px-2 py-1 transition-colors hover:border-shell-ink disabled:opacity-40"
>
{t('issue.board.prev')}
</button>
<button
type="button"
disabled={page >= pages}
onClick={() => onFilter({ page: page + 1 })}
className="rounded-md border-2 border-shell-hairline bg-shell-field px-2 py-1 transition-colors hover:border-shell-ink disabled:opacity-40"
>
{t('issue.board.next')}
</button>
</div>
)
}
/** 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 (
<div
className="issue-board-columns issue-board-skeleton flex min-h-0 flex-1 gap-3"
role="status"
aria-label={t('issue.board.loading')}
aria-busy
>
{Array.from({ length: columns }, (_, index) => (
<section
key={index}
className="issue-board-column flex h-full w-[266px] shrink-0 flex-col gap-2 rounded-lg bg-shell-board p-2.5"
>
<div className="h-6 w-24 rounded-full bg-shell-active" />
{Array.from({ length: 3 }, (_, card) => (
<div key={card} className="space-y-2 rounded-lg bg-shell-surface p-3">
<div className="h-4 rounded-full bg-shell-active" />
<div className="h-4 w-2/3 rounded-full bg-shell-active" />
</div>
))}
</section>
))}
</div>
)
}
function EmptyNote({
title,
hint,
action,
}: {
title: string
hint: string
action?: { label: string; onClick: () => void }
}) {
return (
<div className="issue-board-empty flex w-full flex-col items-center gap-2 rounded-lg border-2 border-dashed border-shell-hairline bg-shell-board px-4 py-16 text-center">
<span className="issue-board-empty-icon">
<Inbox className="h-7 w-7" strokeWidth={1.6} aria-hidden />
</span>
<p className="text-[14px] font-semibold">{title}</p>
<p className="max-w-sm text-[12.5px] text-shell-ink-muted">{hint}</p>
{action ? (
<button
type="button"
onClick={action.onClick}
className="mt-2 inline-flex items-center gap-2 rounded-md bg-shell-action px-3.5 py-1.5 text-[13px] font-semibold text-white transition-[filter] hover:brightness-95"
>
{action.label}
<ArrowRight className="h-4 w-4" aria-hidden />
</button>
) : null}
</div>
)
}