Skip to content
Clone
main
340 lines 12.8 KB
import { Alert } from '@app/components'
import { useT } from '@app/i18n'
import { ApiFailure } from '@app/lib'
import { Button, Checkbox } from '@app/ui'
import { IconLoader2 as Loader2, IconX as X } from '@tabler/icons-react'
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
import { parseList } from './form-values'
export type FormValues = Record<string, string | boolean>
interface BaseField {
key: string
label: string
optional?: boolean
initialValue?: string | boolean
}
export type FormField =
| (BaseField & {
kind: 'text'
placeholder?: string
hint?: string
type?: 'text' | 'email'
maxLength?: number
sanitize?: (v: string) => string
})
| (BaseField & {
kind: 'textarea'
placeholder?: string
maxLength?: number
hint?: string
rows?: number
/** Monospace rendering — for machine blobs (SSH keys, tokens). */
mono?: boolean
})
| (BaseField & { kind: 'number'; placeholder?: string; hint?: string })
| (BaseField & { kind: 'toggle' })
| (BaseField & { kind: 'select'; options: { value: string; label: string }[] })
/** `<input type=datetime-local>`; the value is a local date-time string. */
| (BaseField & { kind: 'datetime'; hint?: string; placeholder?: string })
/** Toggle chips. The value is the selected option values joined by commas. */
| (BaseField & {
kind: 'multiselect'
options: { value: string; label: string }[]
hint?: string
emptyLabel?: string
})
export interface FormModalProps {
title: string
description?: string
/** Free-form block between the description and the fields (tips, snippets). */
extra?: ReactNode
fields: FormField[]
open: boolean
danger?: boolean
confirmLabel?: string
/** Wider dialog (420px → 560px) for forms with long single-line payloads. */
wide?: boolean
/** Async initial values (e.g. personal preferences). */
load?: () => Promise<FormValues>
/** Return a string to keep the modal open with a success note, void to close. */
onSubmit: (values: FormValues) => Promise<string | void>
onClose: () => void
}
/**
* Config-driven modal dialog shared by the space menu's create/settings flows and
* the repo branch tools. Errors surface through `ApiFailure` (see shared/lib), so any
* slice can throw a translated message and have it shown verbatim.
*/
export function FormModal({
title,
description,
extra,
fields,
open,
danger = false,
confirmLabel,
wide = false,
load,
onSubmit,
onClose,
}: FormModalProps) {
const { t } = useT()
const [values, setValues] = useState<FormValues>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
useEffect(() => {
if (!open) return
setError(null)
setNotice(null)
setBusy(false)
const base: FormValues = {}
for (const field of fields) {
base[field.key] = field.kind === 'toggle' ? Boolean(field.initialValue) : String(field.initialValue ?? '')
}
setValues(base)
if (!load) return
let active = true
setLoading(true)
load().then(
(loaded) => {
if (!active) return
setValues((prev) => ({ ...prev, ...loaded }))
setLoading(false)
},
(err) => {
if (!active) return
setError(err instanceof ApiFailure ? err.message : t('ui.loadFailed'))
setLoading(false)
},
)
return () => {
active = false
}
// Re-initialise only when the dialog opens; fields/load are stable per dialog.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
// Capture + stop: this is the innermost dialog, so a parent modal
// (e.g. the user-settings overlay) must not close along with it.
event.stopPropagation()
onClose()
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [open, onClose])
if (!open) return null
const setField = (key: string, value: string | boolean) => {
setNotice(null)
setValues((prev) => ({ ...prev, [key]: value }))
}
async function handleSubmit(event: FormEvent) {
event.preventDefault()
if (busy || loading) return
const missing = fields.find((f) => {
if (f.optional) return false
const raw = String(values[f.key] ?? '').trim()
return raw === '' && (f.kind === 'text' || f.kind === 'datetime' || f.kind === 'multiselect')
})
if (missing) {
setError(t('ui.fieldRequired', { field: missing.label }))
return
}
const badNumber = fields.find((f) => {
if (f.kind !== 'number') return false
const raw = String(values[f.key] ?? '')
return raw !== '' && (!Number.isFinite(Number(raw)) || Number(raw) < 0)
})
if (badNumber) {
setError(t('ui.fieldNonNegativeInteger', { field: badNumber.label }))
return
}
setBusy(true)
setError(null)
try {
const message = await onSubmit(values)
if (message) {
setNotice(message)
setBusy(false)
} else {
onClose()
}
} catch (err) {
setError(err instanceof ApiFailure ? err.message : t('ui.actionFailed'))
setBusy(false)
}
}
const labelClass = 'font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted'
const inputClass =
'h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink'
return (
<div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
role="dialog"
aria-modal
aria-label={title}
className={`workspace-dialog w-full bg-shell-surface ${wide ? 'max-w-[560px]' : 'max-w-[420px]'}`}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[16px] font-semibold">{title}</h2>
<button
type="button"
aria-label={t('ui.close')}
className="dialog-close text-shell-ink-muted transition-colors hover:text-shell-ink"
onClick={onClose}
>
<X className="h-4 w-4" />
</button>
</div>
{description ? <p className="mb-4 text-[12.5px] leading-[1.5] text-shell-ink-muted">{description}</p> : null}
{extra ? <div className="mb-4">{extra}</div> : null}
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
</div>
) : (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
{fields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
{field.kind === 'toggle' ? (
<div className="flex items-center gap-2 text-[13px]">
<Checkbox
checked={Boolean(values[field.key])}
onChange={(v) => setField(field.key, v)}
aria-label={field.label}
/>
<span className="cursor-pointer" onClick={() => setField(field.key, !values[field.key])}>
{field.label}
</span>
</div>
) : (
<>
<span className={labelClass}>{field.label}</span>
{field.kind === 'text' ? (
<input
value={String(values[field.key] ?? '')}
onChange={(e) =>
setField(field.key, field.sanitize ? field.sanitize(e.target.value) : e.target.value)
}
type={field.type ?? 'text'}
maxLength={field.maxLength}
placeholder={field.placeholder}
className={inputClass}
/>
) : field.kind === 'datetime' ? (
<input
type="datetime-local"
value={String(values[field.key] ?? '')}
onChange={(e) => setField(field.key, e.target.value)}
placeholder={field.placeholder}
className={inputClass}
/>
) : field.kind === 'multiselect' ? (
<div className="flex flex-wrap gap-2 pt-0.5" role="group" aria-label={field.label}>
{field.options.length === 0 ? (
<span className="text-[12px] text-shell-ink-muted">
{field.emptyLabel ?? t('ui.noOptions')}
</span>
) : (
field.options.map((option) => {
const picked = parseList(values[field.key])
const on = picked.includes(option.value)
return (
<button
key={option.value}
type="button"
aria-pressed={on}
className={`settings-chip${on ? ' is-on' : ''}`}
onClick={() =>
setField(
field.key,
(on
? picked.filter((item) => item !== option.value)
: [...picked, option.value]
).join(','),
)
}
>
{option.label}
</button>
)
})
)}
</div>
) : field.kind === 'number' ? (
<input
type="number"
min={0}
step={1}
value={String(values[field.key] ?? '')}
onChange={(e) => setField(field.key, e.target.value)}
placeholder={field.placeholder}
className={inputClass}
/>
) : field.kind === 'textarea' ? (
<textarea
value={String(values[field.key] ?? '')}
onChange={(e) => setField(field.key, e.target.value)}
maxLength={field.maxLength}
rows={field.rows ?? 3}
placeholder={field.placeholder}
className={`w-full resize-none rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink ${
field.mono ? 'font-mono text-[12.5px] leading-5' : ''
}`}
/>
) : (
<select
value={String(values[field.key] ?? '')}
onChange={(e) => setField(field.key, e.target.value)}
className={inputClass}
>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{'hint' in field && field.hint ? (
<span className="text-[11px] text-shell-ink-muted">{field.hint}</span>
) : null}
</>
)}
</div>
))}
{notice && <Alert tone="success">{notice}</Alert>}
{error && <Alert tone="error">{error}</Alert>}
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={busy}>
{t('common.action.cancel')}
</Button>
<Button
type="submit"
disabled={busy || loading}
className={danger ? 'bg-[#f23f43] text-white hover:brightness-95' : undefined}
>
{busy ? t('common.action.working') : (confirmLabel ?? t('common.action.confirm'))}
</Button>
</div>
</form>
)}
</div>
</div>
)
}