diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx
index 51a5d5e89..898fb202a 100644
--- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx
+++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx
@@ -111,7 +111,8 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled
- // Optimistic update
+ // Optimistic flip, per-row rollback on failure. Rolling the whole addons snapshot
+ // back instead would undo a toggle the user hit in parallel.
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
@@ -139,22 +140,6 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
- const handleTogglePhotoProvider = async (providerAddon: Addon) => {
- const enableProvider = !providerAddon.enabled
- const prev = addons
-
- setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
-
- try {
- await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
- refreshGlobalAddons()
- toast.success(t('admin.addons.toast.updated'))
- } catch {
- setAddons(prev)
- toast.error(t('admin.addons.toast.error'))
- }
- }
-
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const documentProviderAddons = addons.filter(a => a.type === 'document_provider')
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
@@ -165,7 +150,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
label: provider.name,
description: provider.description,
enabled: provider.enabled,
- toggle: () => handleTogglePhotoProvider(provider),
+ toggle: () => handleToggle(provider),
}))
// No credential form under these, unlike the photo providers: a document
// connection belongs to a trip and is entered there. The admin only decides
diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
index 3840c0193..8b114e362 100644
--- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
+++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
@@ -1,4 +1,4 @@
-// FE-MOB-AADD-001 to FE-MOB-AADD-033
+// FE-MOB-AADD-001 to FE-MOB-AADD-034
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -316,6 +316,33 @@ describe('MAdminAddonManager', () => {
await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false'));
});
+ it('FE-MOB-AADD-034: a failing provider toggle keeps a provider toggled while it was in flight', async () => {
+ const user = userEvent.setup();
+ server.use(
+ addonsRoute([
+ buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
+ buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
+ buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
+ ]),
+ http.put('/api/admin/addons/immich', () => HttpResponse.json({ success: true })),
+ http.put('/api/admin/addons/unsplash', async () => {
+ await delay(150);
+ return HttpResponse.error();
+ }),
+ );
+ render(<>>);
+ await screen.findByText('Immich');
+
+ // Unsplash is still saving when Immich goes on and through.
+ await user.click(screen.getByRole('switch', { name: 'Unsplash' }));
+ await user.click(screen.getByRole('switch', { name: 'Immich' }));
+ await screen.findByText('Addon updated');
+
+ await screen.findByText('Failed to update addon');
+ await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false'));
+ expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
+ });
+
it('FE-MOB-AADD-026: switching the journey addon off and on again shows the cascaded providers as off', async () => {
const user = userEvent.setup();
let loads = 0;