From 6e02433b3042ae5b7ad0c5cd7b6b8848596ec533 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 20 Aug 2026 19:11:31 +0200 Subject: [PATCH] feat(client): BackendForm mirror-targets composer; raw mirror type hidden --- .../Admin/storage/BackendForm.test.tsx | 49 ++++++++++---- .../components/Admin/storage/BackendForm.tsx | 67 +++++++++++++++++-- 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/client/src/components/Admin/storage/BackendForm.test.tsx b/client/src/components/Admin/storage/BackendForm.test.tsx index 1ae59f505..d85d97120 100644 --- a/client/src/components/Admin/storage/BackendForm.test.tsx +++ b/client/src/components/Admin/storage/BackendForm.test.tsx @@ -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' }), []); + }); }); diff --git a/client/src/components/Admin/storage/BackendForm.tsx b/client/src/components/Admin/storage/BackendForm.tsx index 54b20e156..52fa38a96 100644 --- a/client/src/components/Admin/storage/BackendForm.tsx +++ b/client/src/components/Admin/storage/BackendForm.tsx @@ -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(initial?.type ?? 'local') const [name, setName] = useState(initial?.name ?? '') const [values, setValues] = useState(() => valuesOf(initial)) + const [targets, setTargets] = useState(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 && ( +
+ {t('storage.mirror.targets')} +

{t('storage.mirror.targetsHelp')}

+
+ {mirror.candidates + .filter((candidate) => candidate !== name.trim()) + .map((candidate) => ( + + ))} +
+ {targets.length > 0 && ( +

+ {t('storage.mirror.latencyNote')} +

+ )} +
+ )} +
{blocked ? (