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;