fix(admin): keep a later notification toggle when an earlier one fails on the phone

When a toggle on the phone matrix failed after a later toggle had already rendered, its catch put back the preferences from its own click and so also undid the later cell. The live preferences are now mirrored in a ref and a failure reverts only its own cell, as the desktop panel already does. The test hangs the first save, lets the second one through and then fails the first.
This commit is contained in:
Maurice
2026-10-09 10:25:45 +02:00
parent 78fa42a0e6
commit 2acef40c35
2 changed files with 65 additions and 11 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { adminApi } from '../../../api/client'
import type { TranslationFn } from '../../../types'
import type { useToast } from '../../../components/shared/Toast'
@@ -24,9 +24,20 @@ const BUILTIN_CHANNELS = ['inapp', 'email', 'webhook', 'ntfy'] as const
// AdminNotificationsPanel. Loads its own data and auto-saves each toggle.
export default function MAdminNotifyMatrix({ t, toast }: { t: TranslationFn; toast: ReturnType<typeof useToast> }) {
const [matrix, setMatrix] = useState<MatrixData | null>(null)
// Toggles fire faster than React re-renders, so the live preferences are mirrored in a
// ref. Reading state out of the render closure would let a second toggle undo the first.
const prefsRef = useRef<MatrixData['preferences'] | null>(null)
const writePrefs = (prefs: MatrixData['preferences']) => {
prefsRef.current = prefs
setMatrix((m) => (m ? { ...m, preferences: prefs } : m))
}
useEffect(() => {
adminApi.getNotificationPreferences().then((data: MatrixData) => setMatrix(data)).catch(() => {})
adminApi.getNotificationPreferences().then((data: MatrixData) => {
prefsRef.current = data.preferences
setMatrix(data)
}).catch(() => {})
}, [])
if (!matrix) {
@@ -53,16 +64,16 @@ export default function MAdminNotifyMatrix({ t, toast }: { t: TranslationFn; toa
)
const toggle = async (eventType: string, channel: string) => {
const current = matrix.preferences[eventType]?.[channel] ?? true
const updated = {
...matrix.preferences,
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
}
setMatrix((m) => (m ? { ...m, preferences: updated } : m))
const before = prefsRef.current ?? matrix.preferences
const current = before[eventType]?.[channel] ?? true
const updated = { ...before, [eventType]: { ...before[eventType], [channel]: !current } }
writePrefs(updated)
try {
await adminApi.updateNotificationPreferences(updated)
} catch {
setMatrix((m) => (m ? { ...m, preferences: matrix.preferences } : m))
// Revert this cell only: a toggle that already went through keeps its value.
const latest = prefsRef.current ?? updated
writePrefs({ ...latest, [eventType]: { ...latest[eventType], [channel]: current } })
toast.error(t('common.error'))
}
}
@@ -1,8 +1,8 @@
// FE-MOB-AMATRIX-001 to FE-MOB-AMATRIX-011
// FE-MOB-AMATRIX-001 to FE-MOB-AMATRIX-012
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '../../../helpers/render';
import { act, render, screen, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores } from '../../../helpers/store';
import { useTranslation } from '../../../../src/i18n';
@@ -201,4 +201,47 @@ describe('MAdminNotifyMatrix', () => {
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.queryByText('In-App')).not.toBeInTheDocument();
});
it('FE-MOB-AMATRIX-012: a failing earlier save reverts only its own cell, not a later one', async () => {
matrixRespondsWith(MATRIX);
const bodies: Record<string, unknown>[] = [];
let failFirst: () => void = () => {};
server.use(
http.put('/api/admin/notification-preferences', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
if (bodies.length === 1) {
await new Promise<void>((resolve) => {
failFirst = resolve;
});
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json({ success: true });
})
);
const toast = buildToast();
const user = userEvent.setup();
render(<Harness toast={toast} />);
const inappToggle = await screen.findByRole('switch', { name: 'version_available inapp' });
const emailToggle = screen.getByRole('switch', { name: 'version_available email' });
// The first save hangs while the screen renders the optimistic value.
await user.click(inappToggle); // on -> off, will fail
await waitFor(() => expect(bodies).toHaveLength(1));
expect(inappToggle).toHaveAttribute('aria-checked', 'false');
// A later tap on another cell goes through.
await user.click(emailToggle); // off -> on, succeeds
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[1]).toEqual({ version_available: { inapp: false, email: true } });
expect(emailToggle).toHaveAttribute('aria-checked', 'true');
// Now the first save fails: only its own cell goes back.
await act(async () => {
failFirst();
});
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
expect(inappToggle).toHaveAttribute('aria-checked', 'true');
expect(emailToggle).toHaveAttribute('aria-checked', 'true');
});
});