eeff292
/**
* Board management: rename the 问题集, maintain its labels and milestones, and
* delete it. These are the only write paths for `PATCH/DELETE …/boards/{id}`,
* `…/labels` and `…/milestones`.
*
* Rows save individually (not on modal close) so a partial failure cannot lose
* the rest of the edits, and every success bumps the page's reload key so the
* toolbar filters and the issue pickers see the new option immediately.
*/
import { Alert } from '@app/components'
import {
type Board,
type BoardRef,
createLabel,
createMilestone,
dateApiValue,
dateInputValue,
deleteBoard,
deleteLabel,
deleteMilestone,
type IssueLabel,
isValidBoardPrefix,
LABEL_COLORS,
MAX_BOARD_PREFIX_LEN,
type Milestone,
normalizeColor,
updateBoard,
updateLabel,
updateMilestone,
} from '@app/domains/issues/model'
import type { MessageKey } from '@app/i18n'
import { useT } from '@app/i18n'
import { ApiFailure } from '@app/lib'
import { Button } from '@app/ui'
import { IconChevronRight as ChevronRight, IconLoader2 as Loader2 } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import type { BoardData } from '../model/use-board-data'
import { Dialog, smallInputClass } from './dialog'
import { DangerButton } from './parts'
type Pane = 'general' | 'labels' | 'milestones'
const PANE_KEYS: Record<Pane, MessageKey> = {
general: 'issue.board.manage.general',
labels: 'issue.board.manage.labels',
milestones: 'issue.board.manage.milestones',
}
const PANES: Pane[] = ['general', 'labels', 'milestones']
export interface BoardManageModalProps {
open: boolean
boardRef: BoardRef
board?: Board
data: BoardData
onClose: () => void
onChanged: () => void
/** Board was deleted: the page leaves the board route. */
onDeleted: () => void
}
export function BoardManageModal({
open,
boardRef,
board,
data,
onClose,
onChanged,
onDeleted,
}: BoardManageModalProps) {
const { t } = useT()
const [pane, setPane] = useState<Pane>('general')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
// Prefer the freshly fetched board: the page loads it, so a rename elsewhere
// is reflected here instead of the (possibly stale) sidebar row.
const current = data.board.data ?? board
const boardId = current?.id
// Drafts live on the modal (not the pane) because the footer owns the save
// button. They reset per board so a half-typed rename never leaks across.
const [name, setName] = useState('')
const [prefix, setPrefix] = useState('')
const [description, setDescription] = useState('')
useEffect(() => {
if (!open) return
setName(current?.name ?? '')
setPrefix(current?.prefix ?? '')
setDescription(current?.description ?? '')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, boardId])
const trimmedName = name.trim()
const trimmedPrefix = prefix.trim().toUpperCase()
const prefixInvalid = trimmedPrefix.length > 0 && !isValidBoardPrefix(trimmedPrefix)
const dirty =
Boolean(current) &&
(trimmedName !== (current?.name ?? '') ||
(trimmedPrefix || null) !== (current?.prefix ?? null) ||
(description.trim() || null) !== (current?.description ?? null))
async function run(key: string, action: () => Promise<unknown>, keepOpen = true) {
setBusy(key)
setError(null)
try {
await action()
onChanged()
if (!keepOpen) onDeleted()
return true
} catch (err) {
setError(err instanceof ApiFailure ? err.message : t('issue.board.manage.runFailed'))
return false
} finally {
setBusy(null)
}
}
const save = () =>
void run('save-board', () =>
updateBoard(boardRef, {
name: trimmedName,
prefix: trimmedPrefix || null,
description: description.trim() || null,
}),
)
return (
<Dialog
open={open}
onClose={onClose}
title={t('issue.board.manage.title')}
width="max-w-[640px]"
panelClassName="issue-manage-dialog"
header={
<div className="issue-form-head">
{current?.name ? <span className="issue-form-team">{current.name}</span> : null}
<ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="issue-form-kicker">{t('issue.board.manage.title')}</span>
</div>
}
footer={
<div className="issue-manage-footer">
{pane === 'general' && current ? (
<DangerButton
label={t('issue.board.manage.deleteBoard')}
busy={busy === 'delete-board'}
onConfirm={() => void run('delete-board', () => deleteBoard(boardRef), false)}
/>
) : null}
<div className="issue-manage-footer-actions">
<Button variant="secondary" onClick={onClose}>
{t('common.action.close')}
</Button>
{pane === 'general' ? (
<Button disabled={busy !== null || !dirty || !trimmedName || prefixInvalid || !current} onClick={save}>
{busy === 'save-board' ? t('common.state.busy') : t('common.action.save')}
</Button>
) : null}
</div>
</div>
}
>
<div className="issue-manage-tabs" role="tablist">
{PANES.map((id) => (
<button
key={id}
type="button"
role="tab"
aria-selected={pane === id}
data-active={pane === id || undefined}
onClick={() => {
setPane(id)
setError(null)
}}
className="issue-manage-tab"
>
{t(PANE_KEYS[id])}
</button>
))}
</div>
{error ? <Alert>{error}</Alert> : null}
{busy ? (
<p className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> {t('issue.board.manage.processing')}
</p>
) : null}
{pane === 'general' ? (
<div className="issue-manage-body">
<label className="issue-manage-field">
<span className="issue-manage-label">{t('issue.board.manage.name')}</span>
<input
className="issue-manage-input"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={64}
disabled={!current}
/>
</label>
<label className="issue-manage-field">
<span className="issue-manage-label">{t('issue.board.manage.prefix')}</span>
<input
className={`issue-manage-input issue-manage-prefix${prefixInvalid ? ' is-invalid' : ''}`}
value={prefix}
onChange={(event) => setPrefix(event.target.value.toUpperCase())}
placeholder={t('issue.board.manage.prefixPlaceholder')}
maxLength={MAX_BOARD_PREFIX_LEN}
spellCheck={false}
autoComplete="off"
disabled={!current}
/>
{prefixInvalid ? (
<span className="issue-manage-error">{t('issue.board.manage.prefixInvalid')}</span>
) : (
<span className="issue-manage-hint">{t('issue.board.manage.prefixHint')}</span>
)}
</label>
<label className="issue-manage-field">
<span className="issue-manage-label">{t('issue.board.manage.descriptionField')}</span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
disabled={!current}
className="issue-manage-input issue-manage-textarea"
/>
</label>
<p className="issue-manage-stats">
{t('issue.board.manage.stats', {
open: current?.open_issues_count ?? 0,
closed: current?.closed_issues_count ?? 0,
})}
</p>
</div>
) : null}
{pane === 'labels' ? (
<LabelsPane
boardRef={boardRef}
labels={data.labels.data?.items ?? []}
busy={busy}
onRun={run}
failed={Boolean(data.labels.error)}
/>
) : null}
{pane === 'milestones' ? (
<MilestonesPane
boardRef={boardRef}
milestones={data.milestones.data?.items ?? []}
busy={busy}
onRun={run}
failed={Boolean(data.milestones.error)}
/>
) : null}
</Dialog>
)
}
type Runner = (key: string, action: () => Promise<unknown>, keepOpen?: boolean) => Promise<boolean>
function LabelsPane({
boardRef,
labels,
busy,
onRun,
failed,
}: {
boardRef: BoardRef
labels: IssueLabel[]
busy: string | null
onRun: Runner
failed: boolean
}) {
const [draftName, setDraftName] = useState('')
const { t } = useT()
const [draftColor, setDraftColor] = useState<string>(LABEL_COLORS[5])
async function add() {
const trimmed = draftName.trim()
if (!trimmed) return
const ok = await onRun('new-label', () => createLabel(boardRef, { name: trimmed, color: draftColor }))
if (ok) setDraftName('')
}
return (
<div className="space-y-2">
{failed ? <p className="text-[12px] text-[#b42318]">{t('issue.board.manage.labelsFailed')}</p> : null}
{labels.length === 0 && !failed ? (
<p className="text-[12.5px] text-shell-ink-muted">{t('issue.board.manage.noLabels')}</p>
) : null}
{labels.map((label) => (
<LabelRow key={label.id} boardRef={boardRef} label={label} busy={busy === `label-${label.id}`} onRun={onRun} />
))}
<div className="flex items-center gap-2 border-t-2 border-shell-hairline pt-3">
<input
className={smallInputClass}
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void add()
}}
placeholder={t('issue.board.manage.newLabel')}
aria-label={t('issue.board.manage.newLabel')}
name="new-label-name"
maxLength={48}
/>
<div className="flex shrink-0 items-center gap-1">
{LABEL_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={t('issue.board.manage.useColor', { color })}
aria-pressed={draftColor === color}
onClick={() => setDraftColor(color)}
className={`h-5 w-5 rounded-full transition-transform ${draftColor === color ? 'scale-110 ring-2 ring-shell-ink' : ''}`}
style={{ backgroundColor: normalizeColor(color) }}
/>
))}
</div>
<button
type="button"
disabled={!draftName.trim() || busy !== null}
onClick={() => void add()}
className="ml-auto shrink-0 rounded-md bg-shell-action px-3 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
>
{t('issue.board.manage.add')}
</button>
</div>
</div>
)
}
function LabelRow({
boardRef,
label,
busy,
onRun,
}: {
boardRef: BoardRef
label: IssueLabel
busy: boolean
onRun: Runner
}) {
const { t } = useT()
const [name, setName] = useState(label.name)
const [color, setColor] = useState(normalizeColor(label.color))
const dirty = name.trim() !== label.name || color !== normalizeColor(label.color)
return (
<div className="flex items-center gap-2">
<button
type="button"
aria-label={t('issue.board.manage.selectColor')}
onClick={() =>
setColor(LABEL_COLORS[(LABEL_COLORS.indexOf(color as never) + 1) % LABEL_COLORS.length] ?? LABEL_COLORS[0])
}
className="h-5 w-5 shrink-0 rounded-full ring-1 ring-shell-hairline"
style={{ backgroundColor: color }}
/>
<input
value={name}
onChange={(event) => setName(event.target.value)}
aria-label={t('issue.board.manage.labelName')}
className={smallInputClass}
maxLength={48}
/>
<button
type="button"
disabled={!dirty || busy}
onClick={() =>
void onRun(`label-${label.id}`, () => updateLabel(boardRef, label.id, { name: name.trim(), color }))
}
className="shrink-0 rounded px-2 py-1 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink disabled:opacity-40"
>
{busy ? t('issue.board.manage.saving') : dirty ? t('common.action.save') : t('issue.board.manage.saved')}
</button>
<DangerButton
label={t('common.action.delete')}
busy={busy}
onConfirm={() => void onRun(`label-${label.id}`, () => deleteLabel(boardRef, label.id))}
/>
</div>
)
}
function MilestonesPane({
boardRef,
milestones,
busy,
onRun,
failed,
}: {
boardRef: BoardRef
milestones: Milestone[]
busy: string | null
onRun: Runner
failed: boolean
}) {
const { t } = useT()
const [name, setName] = useState('')
const [due, setDue] = useState('')
async function add() {
const trimmed = name.trim()
if (!trimmed) return
const ok = await onRun('new-milestone', () =>
createMilestone(boardRef, { name: trimmed, due_at: dateApiValue(due) }),
)
if (ok) {
setName('')
setDue('')
}
}
return (
<div className="space-y-2">
{failed ? <p className="text-[12px] text-[#b42318]">{t('issue.board.manage.milestonesFailed')}</p> : null}
{milestones.length === 0 && !failed ? (
<p className="text-[12.5px] text-shell-ink-muted">{t('issue.board.manage.noMilestones')}</p>
) : null}
{milestones.map((milestone) => (
<div key={milestone.id} className={`flex items-center gap-2 ${milestone.is_closed ? 'opacity-60' : ''}`}>
<input
value={milestone.name}
onChange={(event) =>
void onRun(`milestone-${milestone.id}`, () =>
updateMilestone(boardRef, milestone.id, { name: event.target.value }),
)
}
aria-label={t('issue.board.manage.milestoneName')}
name="milestone-name"
className={smallInputClass}
maxLength={64}
/>
<label className="flex shrink-0 items-center gap-1 text-[11.5px] text-shell-ink-muted">
{t('issue.board.manage.due')}
<input
type="date"
value={dateInputValue(milestone.due_at)}
onChange={(event) =>
void onRun(`milestone-${milestone.id}`, () =>
updateMilestone(boardRef, milestone.id, { due_at: dateApiValue(event.target.value) }),
)
}
className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-1 text-[12px] outline-none"
/>
</label>
<span className="shrink-0 text-[11.5px] text-shell-ink-muted">
{milestone.issues_count - milestone.closed_issues_count}/{milestone.issues_count}
</span>
<button
type="button"
disabled={busy !== null}
onClick={() =>
void onRun(`milestone-${milestone.id}`, () =>
updateMilestone(boardRef, milestone.id, { is_closed: !milestone.is_closed }),
)
}
className="shrink-0 rounded px-2 py-1 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink"
>
{milestone.is_closed ? t('issue.board.reopen') : t('issue.board.manage.close')}
</button>
<DangerButton
label={t('common.action.delete')}
busy={busy === `milestone-${milestone.id}`}
onConfirm={() => void onRun(`milestone-${milestone.id}`, () => deleteMilestone(boardRef, milestone.id))}
/>
</div>
))}
<div className="flex items-center gap-2 border-t-2 border-shell-hairline pt-3">
<input
className={smallInputClass}
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void add()
}}
placeholder={t('issue.board.manage.newMilestone')}
aria-label={t('issue.board.manage.newMilestone')}
maxLength={64}
/>
<input
type="date"
value={due}
onChange={(event) => setDue(event.target.value)}
aria-label={t('issue.board.manage.milestoneDue')}
className="h-7 shrink-0 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12px] outline-none"
/>
<button
type="button"
disabled={!name.trim() || busy !== null}
onClick={() => void add()}
className="ml-auto shrink-0 rounded-md bg-shell-action px-3 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
>
{t('issue.board.manage.add')}
</button>
</div>
</div>
)
}