feat(client): BackendForm mirror-targets composer; raw mirror type hidden

This commit is contained in:
jubnl
2026-08-20 19:11:31 +02:00
parent 0918d37df2
commit 6e02433b30
2 changed files with 97 additions and 19 deletions
@@ -49,19 +49,6 @@ describe('BackendForm', () => {
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
});
it('FE-ADMIN-STORF-003: mirror renders the composer — primary select and replica checkboxes over the other backends', () => {
renderForm({
initial: { name: 'mir', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
});
// The composer is just the two ref kinds rendering: a select and checkboxes.
expect(screen.getByText(/Primary backend/)).toBeInTheDocument();
const replicas = screen.getAllByRole('checkbox');
// Own name excluded: uploads-local, backups-local, off-box minus 'mir' (not in NAMES anyway) → 3
expect(replicas).toHaveLength(3);
expect(screen.getByRole('checkbox', { name: 'off-box' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'backups-local' })).not.toBeChecked();
});
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
const { onCommit } = renderForm({ initial: S3_INITIAL });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
@@ -131,4 +118,40 @@ describe('BackendForm', () => {
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
});
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
renderForm();
fireEvent.click(screen.getByText('Local')); // open the type select
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
expect(screen.getByText('S3')).toBeInTheDocument();
});
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
const { onCommit } = renderForm({
initial: S3_INITIAL,
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
});
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(2); // off-box (self) excluded
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
expect(screen.getByRole('note').textContent).toContain('slows every upload');
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(
expect.objectContaining({ name: 'off-box', type: 's3' }),
['backups-local', 'uploads-local'],
);
});
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
const { onCommit } = renderForm({
initial: S3_INITIAL,
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
});
});
@@ -21,13 +21,25 @@ function valuesOf(backend: StorageBackend | null): FieldValues {
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[]
encryptionReady: boolean
onCommit: (backend: StorageBackend) => void
/**
* 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
}
@@ -36,14 +48,16 @@ const INPUT_CLASS =
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
/**
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind — the whole
* mirror composer is just backend-ref/backend-ref-list rendering as selects.
* A future backend type needs no changes here.
* 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).
*/
export default function BackendForm({
initial,
backendNames,
encryptionReady,
mirror,
onCommit,
onCancel,
}: BackendFormProps): React.ReactElement {
@@ -51,6 +65,7 @@ export default function BackendForm({
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())
@@ -81,7 +96,12 @@ export default function BackendForm({
}
// The options were built from the same field defs the schema is generated
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
onCommit({ name: name.trim(), type, options } as StorageBackend)
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 => {
@@ -176,7 +196,10 @@ export default function BackendForm({
setType(next as StorageBackendTypeId)
setValues({}) // a different type has different fields
}}
options={STORAGE_BACKEND_TYPE_IDS.map((id) => ({ value: id, label: t(`storage.type.${id}`) }))}
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
value: id,
label: t(`storage.type.${id}`),
}))}
size="sm"
disabled={initial !== null}
/>
@@ -184,6 +207,38 @@ export default function BackendForm({
{fields.map(renderField)}
{mirror && (
<div>
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
<div className="space-y-1">
{mirror.candidates
.filter((candidate) => candidate !== name.trim())
.map((candidate) => (
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
<input
type="checkbox"
checked={targets.includes(candidate)}
onChange={(e) =>
setTargets(
e.target.checked
? [...targets, candidate]
: targets.filter((existing) => existing !== candidate),
)
}
/>
{candidate}
</label>
))}
</div>
{targets.length > 0 && (
<p className="text-xs mt-1 text-content-faint" role="note">
{t('storage.mirror.latencyNote')}
</p>
)}
</div>
)}
<div className="flex items-center gap-3">
{blocked ? (
<p