mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import React, { useId, useState } from 'react'
|
|
import { HardDrive } from 'lucide-react'
|
|
import {
|
|
STORAGE_BACKEND_TYPES,
|
|
STORAGE_BACKEND_TYPE_IDS,
|
|
type StorageBackend,
|
|
type StorageBackendFieldDef,
|
|
type StorageBackendTypeId,
|
|
} from '@trek/shared'
|
|
import { useTranslation } from '../../../i18n'
|
|
import CustomSelect from '../../shared/CustomSelect'
|
|
import {
|
|
DialogButton,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogSection,
|
|
DialogShell,
|
|
DialogTile,
|
|
FooterSpacer,
|
|
NEUTRAL_TINT,
|
|
fs,
|
|
} from '../../shared/DialogShell'
|
|
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts'
|
|
|
|
type FieldValues = Record<string, string | string[]>
|
|
|
|
function valuesOf(backend: StorageBackend | null): FieldValues {
|
|
if (!backend) return {}
|
|
const values: FieldValues = {}
|
|
for (const [key, value] of Object.entries(backend.options)) {
|
|
values[key] = Array.isArray(value) ? value : String(value)
|
|
}
|
|
return values
|
|
}
|
|
|
|
export interface BackendFormMirrorProps {
|
|
/** Selectable replica targets (the form excludes its own name at render). */
|
|
candidates: string[]
|
|
initialTargets: string[]
|
|
}
|
|
|
|
interface BackendFormProps {
|
|
/** null = new backend */
|
|
initial: StorageBackend | null
|
|
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
|
|
backendNames: string[]
|
|
/**
|
|
* Present on non-mirror backends: renders the Mirror-targets composer
|
|
* (replicas-on-primary — panel-supplied chrome, never a registry field).
|
|
* When present, onCommit's second argument is always an array.
|
|
*/
|
|
mirror?: BackendFormMirrorProps
|
|
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
|
|
onCancel: () => void
|
|
}
|
|
|
|
/** A box of checkbox rows, the white list the settings cards use. */
|
|
const CHECK_ROWS = 'divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card'
|
|
const CHECK_ROW = 'flex cursor-pointer items-center gap-3 px-3.5 py-2.5 text-content hover:bg-surface-secondary'
|
|
const CHECKBOX = 'h-4 w-4 flex-none cursor-pointer accent-accent'
|
|
|
|
/**
|
|
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
|
* `mirror` type is hidden from the type select — mirrors are composed via the
|
|
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
|
* renderers below stay for future registry types). Drawn as the planner's
|
|
* editor dialog: head band, fields under eyebrows, the answers in the footer.
|
|
*/
|
|
export default function BackendForm({
|
|
initial,
|
|
backendNames,
|
|
mirror,
|
|
onCommit,
|
|
onCancel,
|
|
}: BackendFormProps): React.ReactElement {
|
|
const { t } = useTranslation()
|
|
const labelId = useId()
|
|
const fieldId = (key: string) => `${labelId}-${key}`
|
|
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
|
const [name, setName] = useState(initial?.name ?? '')
|
|
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
|
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
|
|
|
|
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
|
|
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
|
|
const setValue = (key: string, value: string | string[]) =>
|
|
setValues((prev) => ({ ...prev, [key]: value }))
|
|
|
|
const filled = (field: StorageBackendFieldDef): boolean => {
|
|
const value = values[field.key]
|
|
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
|
|
return typeof value === 'string' && value.trim() !== ''
|
|
}
|
|
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
|
|
const canApply =
|
|
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
|
|
|
|
const apply = () => {
|
|
const options: Record<string, unknown> = {}
|
|
for (const field of fields) {
|
|
const value = values[field.key]
|
|
if (field.kind === 'backend-ref-list') {
|
|
options[field.key] = Array.isArray(value) ? value : []
|
|
continue
|
|
}
|
|
const text = typeof value === 'string' ? value : ''
|
|
if (text === '' && !field.required) continue // omitted → the shared schema default applies
|
|
options[field.key] = field.kind === 'number' ? Number(text) : text
|
|
}
|
|
// The options were built from the same field defs the schema is generated
|
|
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
|
|
const payload = { name: name.trim(), type, options } as StorageBackend
|
|
// Arity matters: the landed tests pin single-argument calls when no mirror
|
|
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
|
|
// mismatch), so the second argument exists only when the composer does.
|
|
if (mirror) onCommit(payload, targets)
|
|
else onCommit(payload)
|
|
}
|
|
|
|
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
|
|
const value = values[field.key]
|
|
if (field.kind === 'backend-ref') {
|
|
return (
|
|
<EditorField key={field.key} label={t(field.labelKey)}>
|
|
<CustomSelect
|
|
value={typeof value === 'string' ? value : ''}
|
|
onChange={(next) => setValue(field.key, String(next))}
|
|
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
|
|
placeholder={t(field.labelKey)}
|
|
size="sm"
|
|
/>
|
|
</EditorField>
|
|
)
|
|
}
|
|
if (field.kind === 'backend-ref-list') {
|
|
const selected = Array.isArray(value) ? value : []
|
|
return (
|
|
<EditorField key={field.key} label={t(field.labelKey)} className="col-span-full">
|
|
<div className={CHECK_ROWS}>
|
|
{refOptions.map((candidate) => (
|
|
<label key={candidate} className={CHECK_ROW} style={fs(13, 'body')}>
|
|
<input
|
|
type="checkbox"
|
|
className={CHECKBOX}
|
|
checked={selected.includes(candidate)}
|
|
onChange={(e) =>
|
|
setValue(
|
|
field.key,
|
|
e.target.checked
|
|
? [...selected, candidate]
|
|
: selected.filter((existing) => existing !== candidate),
|
|
)
|
|
}
|
|
/>
|
|
<span className="min-w-0 truncate font-geist">{candidate}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</EditorField>
|
|
)
|
|
}
|
|
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
|
return (
|
|
<EditorField
|
|
key={field.key}
|
|
label={t(field.labelKey)}
|
|
htmlFor={fieldId(field.key)}
|
|
hint={field.helpKey ? t(field.helpKey) : undefined}
|
|
>
|
|
<input
|
|
id={fieldId(field.key)}
|
|
type={inputType}
|
|
value={typeof value === 'string' ? value : ''}
|
|
onChange={(e) => setValue(field.key, e.target.value)}
|
|
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
className={`${INPUT} ${inputType === 'number' ? 'tabular-nums' : ''}`}
|
|
/>
|
|
</EditorField>
|
|
)
|
|
}
|
|
|
|
const visibleCandidates = mirror ? mirror.candidates.filter((candidate) => candidate !== name.trim()) : []
|
|
|
|
return (
|
|
<DialogShell
|
|
onClose={onCancel}
|
|
labelledBy={labelId}
|
|
width="editor"
|
|
align="top"
|
|
discardGuard={{ name, type, values, targets }}
|
|
header={
|
|
<DialogHeader
|
|
tile={<DialogTile><HardDrive size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
|
tint={NEUTRAL_TINT}
|
|
labelId={labelId}
|
|
onClose={onCancel}
|
|
title={initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
|
/>
|
|
}
|
|
footer={
|
|
<DialogFooter>
|
|
<FooterSpacer />
|
|
<DialogButton onClick={onCancel}>{t('storage.form.cancel')}</DialogButton>
|
|
<DialogButton variant="primary" onClick={apply} disabled={!canApply}>
|
|
{t('storage.form.apply')}
|
|
</DialogButton>
|
|
</DialogFooter>
|
|
}
|
|
>
|
|
<div className={GRID_2}>
|
|
<div className="min-w-0">
|
|
<EditorField label={t('storage.form.name')} htmlFor={fieldId('name')}>
|
|
<input
|
|
id={fieldId('name')}
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
className={`${INPUT} font-geist`}
|
|
/>
|
|
</EditorField>
|
|
{duplicate && (
|
|
<p className="m-0 mt-1 text-danger" style={fs(11)} role="alert">
|
|
{t('storage.form.duplicateName', { name: name.trim() })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<EditorField label={t('storage.form.type')}>
|
|
<CustomSelect
|
|
value={type}
|
|
onChange={(next) => {
|
|
setType(next as StorageBackendTypeId)
|
|
setValues({}) // a different type has different fields
|
|
}}
|
|
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
|
value: id,
|
|
label: t(`storage.type.${id}`),
|
|
}))}
|
|
size="sm"
|
|
disabled={initial !== null}
|
|
/>
|
|
</EditorField>
|
|
</div>
|
|
|
|
{fields.length > 0 && <div className={GRID_2}>{fields.map(renderField)}</div>}
|
|
|
|
{mirror && (
|
|
<DialogSection label={t('storage.mirror.targets')}>
|
|
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('storage.mirror.targetsHelp')}</p>
|
|
{visibleCandidates.length > 0 && (
|
|
<div className={CHECK_ROWS}>
|
|
{visibleCandidates.map((candidate) => (
|
|
<label key={candidate} className={CHECK_ROW} style={fs(13, 'body')}>
|
|
<input
|
|
type="checkbox"
|
|
className={CHECKBOX}
|
|
checked={targets.includes(candidate)}
|
|
onChange={(e) =>
|
|
setTargets(
|
|
e.target.checked
|
|
? [...targets, candidate]
|
|
: targets.filter((existing) => existing !== candidate),
|
|
)
|
|
}
|
|
/>
|
|
<span className="min-w-0 truncate font-geist">{candidate}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
{targets.length > 0 && (
|
|
<p className="m-0 mt-2.5 rounded-[12px] bg-warning-soft px-3 py-2 leading-normal text-warning" style={fs(11.5, 'body')} role="note">
|
|
{t('storage.mirror.latencyNote')}
|
|
</p>
|
|
)}
|
|
</DialogSection>
|
|
)}
|
|
</DialogShell>
|
|
)
|
|
}
|