mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(client): phone storage panel folds mirrors into their primaries
This commit is contained in:
@@ -13,13 +13,19 @@ import { useTranslation } from '../../../i18n'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import {
|
||||
asWireBackend,
|
||||
categoriesPointingAt,
|
||||
CACHE_CATEGORIES,
|
||||
adoptedMirrorFor,
|
||||
effectiveCategoryMap,
|
||||
foldBackends,
|
||||
hasPlaintextSecret,
|
||||
mirrorsReferencing,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaOfPrimaries,
|
||||
setMirrorTargets,
|
||||
upsertBackend,
|
||||
type StateBackend,
|
||||
type FoldedBackendRow,
|
||||
} from '../../../components/Admin/storage/storageModel'
|
||||
import { useStorageAdmin } from '../../../components/Admin/storage/useStorageAdmin'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -44,19 +50,22 @@ function MBackendForm({
|
||||
initial,
|
||||
backendNames,
|
||||
encryptionReady,
|
||||
mirror,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: StorageBackend | null
|
||||
backendNames: string[]
|
||||
encryptionReady: boolean
|
||||
onCommit: (backend: StorageBackend) => void
|
||||
mirror: { candidates: string[]; initialTargets: string[] }
|
||||
onCommit: (backend: StorageBackend, mirrorTargets: string[]) => void
|
||||
onCancel: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
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 [picker, setPicker] = useState<string | null>(null)
|
||||
|
||||
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
|
||||
@@ -84,7 +93,7 @@ function MBackendForm({
|
||||
if (text === '' && !field.required) continue
|
||||
options[field.key] = field.kind === 'number' ? Number(text) : text
|
||||
}
|
||||
onCommit({ name: name.trim(), type, options } as StorageBackend)
|
||||
onCommit({ name: name.trim(), type, options } as StorageBackend, targets)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -109,7 +118,10 @@ function MBackendForm({
|
||||
open={picker === 'type'}
|
||||
onClose={() => setPicker(null)}
|
||||
title={t('storage.form.type')}
|
||||
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}`),
|
||||
}))}
|
||||
value={type}
|
||||
onSelect={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
@@ -184,6 +196,30 @@ function MBackendForm({
|
||||
)
|
||||
})}
|
||||
|
||||
<MAdminField label={t('storage.mirror.targets')} hint={t('storage.mirror.targetsHelp')}>
|
||||
<div className="space-y-2">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<div key={candidate} className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.8125rem] font-semibold text-m-ink">{candidate}</span>
|
||||
<MToggle
|
||||
checked={targets.includes(candidate)}
|
||||
ariaLabel={candidate}
|
||||
onChange={(checked) =>
|
||||
setTargets(checked ? [...targets, candidate] : targets.filter((existing) => existing !== candidate))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{targets.length > 0 && (
|
||||
<p role="note" className="mt-1 font-geist text-[0.625rem] leading-relaxed text-m-muted">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</MAdminField>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{blocked ? (
|
||||
<p role="alert" className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
|
||||
@@ -206,8 +242,12 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const admin = useStorageAdmin(t('common.error'))
|
||||
const [editing, setEditing] = useState<{ initial: StorageBackend | null; originalName: string | null } | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<{
|
||||
initial: StorageBackend | null
|
||||
originalName: string | null
|
||||
mirror: { candidates: string[]; initialTargets: string[] }
|
||||
} | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
|
||||
const [categoryPicker, setCategoryPicker] = useState<StorageCategory | null>(null)
|
||||
|
||||
if (admin.loading) {
|
||||
@@ -226,60 +266,55 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
}
|
||||
const { state, draft } = admin
|
||||
|
||||
const draftNames = draft.backends.map((b) => b.name)
|
||||
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draftNames])]
|
||||
const stateByName = new Map(state.backends.map((b) => [b.name, b]))
|
||||
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
|
||||
const { rows, degenerate } = foldBackends(state, draft)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
|
||||
interface Row {
|
||||
name: string
|
||||
type: StorageBackend['type']
|
||||
source: 'built-in' | 'env' | 'settings'
|
||||
backend: StorageBackend
|
||||
categories: readonly string[]
|
||||
}
|
||||
const rows: Row[] = []
|
||||
for (const b of state.backends) {
|
||||
if (draftNames.includes(b.name)) continue
|
||||
if (b.source === 'settings') continue
|
||||
rows.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b), categories: b.categories })
|
||||
}
|
||||
for (const b of draft.backends) {
|
||||
const inState = stateByName.get(b.name) as StateBackend | undefined
|
||||
rows.push({
|
||||
name: b.name,
|
||||
type: b.type,
|
||||
source: 'settings',
|
||||
backend: b,
|
||||
categories: inState?.categories ?? categoriesPointingAt(draft, b.name),
|
||||
const startEdit = (row: FoldedBackendRow) => {
|
||||
setEditing({
|
||||
initial: row.backend,
|
||||
originalName: row.name,
|
||||
mirror: {
|
||||
candidates: rows.filter((r) => r.name !== row.name).map((r) => r.name),
|
||||
initialTargets: row.mirrorTargets,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const commitBackend = (backend: StorageBackend) => {
|
||||
const commitBackend = (backend: StorageBackend, mirrorTargets: string[]) => {
|
||||
const renamedFrom = editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
|
||||
const base = renamedFrom ? removeBackend(draft, renamedFrom) : draft
|
||||
admin.setDraft(upsertBackend(base, backend))
|
||||
let next = draft
|
||||
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
|
||||
next = upsertBackend(next, backend)
|
||||
next = setMirrorTargets(state, next, backend.name, mirrorTargets)
|
||||
admin.setDraft(next)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const removeMessage = (name: string): string => {
|
||||
const assigned = categoriesPointingAt(draft, name)
|
||||
const mirrors = mirrorsReferencing(draft, name)
|
||||
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
const assigned = isDegenerate
|
||||
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
|
||||
: row?.categories ?? []
|
||||
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
|
||||
return [
|
||||
t('storage.remove.body', { name }),
|
||||
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: assigned.join(', ') }) : '',
|
||||
mirrors.length > 0 ? t('storage.remove.referencedBy', { backends: mirrors.join(', ') }) : '',
|
||||
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const setCategory = (category: StorageCategory, backendName: string) => {
|
||||
const effective = state.categories[category]
|
||||
const setCategory = (category: StorageCategory, primaryName: string) => {
|
||||
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const categories = { ...draft.categories }
|
||||
if (effective.source === 'default' && backendName === effective.backend) {
|
||||
if (stateEntry.source === 'default' && target === stateEntry.backend) {
|
||||
delete categories[category]
|
||||
} else {
|
||||
categories[category] = backendName
|
||||
categories[category] = target
|
||||
}
|
||||
admin.setDraft({ ...draft, categories })
|
||||
}
|
||||
@@ -318,7 +353,11 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
<MAdminCardHead title={t('storage.backends.title')} hint={t('storage.description')} />
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => {
|
||||
const result = admin.testResults[row.name]
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = admin.testResults[resultKey]
|
||||
const testCandidate = row.mirrorName
|
||||
? draft.backends.find((b) => b.name === row.mirrorName)!
|
||||
: row.backend
|
||||
return (
|
||||
<div
|
||||
key={row.name}
|
||||
@@ -339,20 +378,30 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
? t('storage.backends.usedBy', { categories: row.categories.join(', ') })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">{t('storage.backends.envReadOnly')}</p>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<MAdminButton variant="ghost" onClick={() => admin.test(row.backend)}>
|
||||
<MAdminButton variant="ghost" onClick={() => admin.test(testCandidate)}>
|
||||
{t('storage.actions.test')}
|
||||
</MAdminButton>
|
||||
{row.source !== 'env' && (
|
||||
<MAdminButton variant="ghost" onClick={() => setEditing({ initial: row.backend, originalName: row.name })}>
|
||||
<MAdminButton variant="ghost" onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<MAdminButton variant="danger" onClick={() => setConfirmRemove(row.name)}>
|
||||
<MAdminButton variant="danger" onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
@@ -377,10 +426,65 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{degenerate.map(({ backend, reason }) => {
|
||||
const result = admin.testResults[backend.name]
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div
|
||||
key={backend.name}
|
||||
data-testid={`m-storage-backend-${backend.name}`}
|
||||
className="rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[0.8125rem] font-bold text-m-ink">{backend.name}</span>
|
||||
<span className="rounded-full border border-[color:var(--m-rowbr)] px-2 py-[1px] font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.type.mirror')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t(`storage.mirror.degenerate.${reason}`, { primary })}
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<MAdminButton variant="ghost" onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</MAdminButton>
|
||||
<MAdminButton variant="danger" onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
{result === 'running' ? (
|
||||
<p className="mt-2 font-geist text-[0.625rem] text-m-muted">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
result && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-[0.75rem] font-bold text-m-ink">
|
||||
{result.ok ? t('storage.test.ok') : t('storage.test.failed')}
|
||||
</p>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="font-geist text-[0.625rem] text-m-muted">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{editing === null && (
|
||||
<div className="mt-3">
|
||||
<MAdminButton onClick={() => setEditing({ initial: null, originalName: null })}>
|
||||
<MAdminButton
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: rows.map((r) => r.name), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
@@ -392,6 +496,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
encryptionReady={state.encryptionReady}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
@@ -401,17 +506,19 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
<MAdminCardHead title={t('storage.categories.title')} />
|
||||
<div className="space-y-2">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
const effective = state.categories[category]
|
||||
const selected = draft.categories[category] ?? effective.backend
|
||||
const changed = selected !== effective.backend
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const selectedPrimary = primaryNameOf(state, draft, effective[category])
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<MAdminField key={category} label={t(`storage.category.${category}`)}>
|
||||
<div data-testid={`m-storage-category-${category}`} className="contents">
|
||||
<MSetSelectRow
|
||||
label={
|
||||
effective.source === 'default' && selected === effective.backend
|
||||
? `${selected} (${t('storage.categories.default')})`
|
||||
: selected
|
||||
stateEntry.source === 'default' && selectedPrimary === stateEntry.backend
|
||||
? `${selectedPrimary} (${t('storage.categories.default')})`
|
||||
: selectedPrimary
|
||||
}
|
||||
trailing={<ChevronDown size={14} className="text-m-faint" />}
|
||||
onClick={() => setCategoryPicker(category)}
|
||||
@@ -422,6 +529,11 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
</MAdminField>
|
||||
)
|
||||
})}
|
||||
@@ -430,8 +542,8 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
open={categoryPicker !== null}
|
||||
onClose={() => setCategoryPicker(null)}
|
||||
title={categoryPicker ? t(`storage.category.${categoryPicker}`) : ''}
|
||||
options={backendNames.map((name) => ({ value: name, label: name }))}
|
||||
value={categoryPicker ? (draft.categories[categoryPicker] ?? state.categories[categoryPicker].backend) : ''}
|
||||
options={rows.map((row) => ({ value: row.name, label: row.name }))}
|
||||
value={categoryPicker ? primaryNameOf(state, draft, effective[categoryPicker]) : ''}
|
||||
onSelect={(name) => {
|
||||
if (categoryPicker) setCategory(categoryPicker, name)
|
||||
}}
|
||||
@@ -454,12 +566,18 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
open={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
title={t('storage.remove.title')}
|
||||
message={confirmRemove ? removeMessage(confirmRemove) : ''}
|
||||
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
|
||||
confirmLabel={t('storage.actions.remove')}
|
||||
cancelLabel={t('storage.form.cancel')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
if (confirmRemove) admin.setDraft(removeBackend(draft, confirmRemove))
|
||||
if (confirmRemove) {
|
||||
admin.setDraft(
|
||||
confirmRemove.degenerate
|
||||
? removeBackend(draft, confirmRemove.name)
|
||||
: removeBackendAndMirrors(draft, confirmRemove.name),
|
||||
)
|
||||
}
|
||||
setConfirmRemove(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,16 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
|
||||
};
|
||||
}
|
||||
|
||||
function mirroredState(): StorageAdminState {
|
||||
const state = baseState();
|
||||
state.backends.push({
|
||||
name: 'mirror', type: 'mirror', source: 'settings',
|
||||
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
|
||||
});
|
||||
state.categories.backups = { backend: 'mirror', source: 'settings' };
|
||||
return state;
|
||||
}
|
||||
|
||||
async function renderPanel(state: StorageAdminState = baseState()) {
|
||||
server.use(http.get('/api/admin/storage', () => HttpResponse.json(state)));
|
||||
render(
|
||||
@@ -99,4 +109,52 @@ describe('MAdminStoragePanel', () => {
|
||||
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-MSTOR-006: mirrors fold — no mirror card, primary and replica decorated', async () => {
|
||||
await renderPanel(mirroredState());
|
||||
expect(screen.queryByTestId('m-storage-backend-mirror')).not.toBeInTheDocument();
|
||||
const primary = screen.getByTestId('m-storage-backend-backups-local');
|
||||
expect(within(primary).getByText('Mirrored to: off-box')).toBeInTheDocument();
|
||||
const replica = screen.getByTestId('m-storage-backend-off-box');
|
||||
expect(within(replica).getByText('Replica of: backups-local')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-MSTOR-007: toggling a target on a primary synthesizes the mirror in the PUT', async () => {
|
||||
let putBody: unknown;
|
||||
await renderPanel();
|
||||
server.use(
|
||||
http.put('/api/admin/storage', async ({ request }) => {
|
||||
putBody = await request.json();
|
||||
return HttpResponse.json(baseState());
|
||||
}),
|
||||
);
|
||||
fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Edit' }));
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'off-box' }));
|
||||
expect(screen.getByText(/slows every upload/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
await screen.findByText('Storage configuration saved');
|
||||
const body = putBody as { backends: Array<{ name: string; type: string; options: unknown }>; categories: Record<string, string> };
|
||||
expect(body.backends.find((b) => b.name === 'backups-local-mirror')!.options).toEqual({
|
||||
primary: 'backups-local', replicas: ['off-box'],
|
||||
});
|
||||
expect(body.categories.backups).toBe('backups-local-mirror');
|
||||
});
|
||||
|
||||
it('FE-MOB-MSTOR-008: the category picker deals in primaries and warns on cache categories', async () => {
|
||||
let putBody: unknown;
|
||||
await renderPanel(mirroredState());
|
||||
server.use(
|
||||
http.put('/api/admin/storage', async ({ request }) => {
|
||||
putBody = await request.json();
|
||||
return HttpResponse.json(mirroredState());
|
||||
}),
|
||||
);
|
||||
fireEvent.click(within(screen.getByTestId('m-storage-category-places')).getByRole('button'));
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: 'Place images' })).getByRole('button', { name: 'backups-local' })); // picker option = primary name
|
||||
expect(screen.getByText(/re-fetchable/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
await screen.findByText('Storage configuration saved');
|
||||
expect((putBody as { categories: Record<string, string> }).categories.places).toBe('mirror');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user