From 2a242f29fb1bbfc4711b5f9b3347da3107047bef Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:33:34 +0200 Subject: [PATCH] fix(admin): keep a parallel addon toggle when a photo provider toggle fails on the phone The phone rolled a failed photo provider toggle back by restoring the whole addon list it saw at the click, which also undid any switch flipped while the request was in flight. Photo providers now go through the same toggle as every other addon, with a per row rollback, as on desktop. --- .../screens/admin/MAdminAddonManager.tsx | 21 ++------------ .../mobile/admin/MAdminAddonManager.test.tsx | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 19 deletions(-) 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;