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 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 }[] }) /** ``; 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 /** Return a string to keep the modal open with a success note, void to close. */ onSubmit: (values: FormValues) => Promise 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({}) const [loading, setLoading] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [notice, setNotice] = useState(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 (
e.stopPropagation()} >

{title}

{description ?

{description}

: null} {extra ?
{extra}
: null} {loading ? (
) : (
{fields.map((field) => (
{field.kind === 'toggle' ? (
setField(field.key, v)} aria-label={field.label} /> setField(field.key, !values[field.key])}> {field.label}
) : ( <> {field.label} {field.kind === 'text' ? ( 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' ? ( setField(field.key, e.target.value)} placeholder={field.placeholder} className={inputClass} /> ) : field.kind === 'multiselect' ? (
{field.options.length === 0 ? ( {field.emptyLabel ?? t('ui.noOptions')} ) : ( field.options.map((option) => { const picked = parseList(values[field.key]) const on = picked.includes(option.value) return ( ) }) )}
) : field.kind === 'number' ? ( setField(field.key, e.target.value)} placeholder={field.placeholder} className={inputClass} /> ) : field.kind === 'textarea' ? (