From 4cd25d30476a2caa175839c422358e16be6bd62c Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 2 Sep 2026 19:01:03 +0200 Subject: [PATCH] fix(admin): the addons page re-reads after a journey toggle instead of trusting itself (#2156) Disabling Journey cascades the photo providers off on the server, but the panel kept its local rows, so after off and on the providers showed active while being disabled. The desktop and the mobile panel re-read the list after a journey toggle; the write itself still rolls back a single row on failure. --- .../components/Admin/AddonManager.test.tsx | 57 +++++++++++++++++++ client/src/components/Admin/AddonManager.tsx | 14 +++-- .../screens/admin/MAdminAddonManager.tsx | 14 +++-- .../mobile/admin/MAdminAddonManager.test.tsx | 57 +++++++++++++++++++ 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index a602574f2..6be9a96ad 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -361,6 +361,63 @@ describe('AddonManager', () => { expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument(); }); + it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let journeyOn = true; + let immichOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }), + ], + }); + }), + http.put('/api/admin/addons/journey', async ({ request }) => { + journeyOn = (await request.json() as { enabled: boolean }).enabled; + // Journey off takes its providers with it, see admin.service.ts. + if (!journeyOn) immichOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Immich'); + + await user.click(addonToggle('Journey')); + // The toast closes the whole toggle, re-read included. + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Immich')).not.toBeInTheDocument(); + + await user.click(addonToggle('Journey')); + + await screen.findByText('Immich'); + expect(isOn(subToggle('Immich'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => { + const user = userEvent.setup(); + let loads = 0; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }); + }), + http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })), + ); + render(<>); + await screen.findByText('Todo List'); + + await user.click(addonToggle('Todo List')); + + await screen.findByText('Addon updated'); + expect(loads).toBe(1); + expect(isOn(addonToggle('Todo List'))).toBe(true); + }); + it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { const user = userEvent.setup(); const onToggleCollabFeature = vi.fn(); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 41a88fbf5..7a4ed5130 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -92,18 +92,15 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, const [loading, setLoading] = useState(true) useEffect(() => { - loadAddons() + loadAddons().finally(() => setLoading(false)) }, []) const loadAddons = async () => { - setLoading(true) try { const data = await adminApi.addons() setAddons(data.addons) } catch (err: unknown) { toast.error(t('admin.addons.toast.error')) - } finally { - setLoading(false) } } @@ -114,12 +111,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - refreshGlobalAddons() - toast.success(t('admin.addons.toast.updated')) } catch (err: unknown) { setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) toast.error(t('admin.addons.toast.error')) + return } + refreshGlobalAddons() + // Journey off disables every photo provider with it, and the response carries + // only Journey. Without re-reading, switching Journey back on brings the shelf + // up with providers the database has long since turned off. + if (addon.id === 'journey') await loadAddons() + toast.success(t('admin.addons.toast.updated')) } const isPhotoProviderAddon = (addon: Addon) => { diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 47770b326..f5f763dfd 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -83,18 +83,15 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac const [loading, setLoading] = useState(true) useEffect(() => { - loadAddons() + loadAddons().finally(() => setLoading(false)) }, []) const loadAddons = async () => { - setLoading(true) try { const data = await adminApi.addons() setAddons(data.addons) } catch (err: unknown) { toast.error(t('admin.addons.toast.error')) - } finally { - setLoading(false) } } @@ -104,13 +101,18 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - refreshGlobalAddons() - toast.success(t('admin.addons.toast.updated')) } catch (err: unknown) { // Rollback setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) toast.error(t('admin.addons.toast.error')) + return } + refreshGlobalAddons() + // Journey off disables every photo provider with it, and the response carries + // only Journey. Without re-reading, switching Journey back on brings the shelf + // up with providers the database has long since turned off. + if (addon.id === 'journey') await loadAddons() + toast.success(t('admin.addons.toast.updated')) } const isPhotoProviderAddon = (addon: Addon) => { diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index f4c8b28f5..ab2cc5eaa 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -299,6 +299,63 @@ describe('MAdminAddonManager', () => { await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false')); }); + 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; + let journeyOn = true; + let immichOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }), + ], + }); + }), + http.put('/api/admin/addons/journey', async ({ request }) => { + journeyOn = (await request.json() as { enabled: boolean }).enabled; + // Journey off takes its providers with it, see admin.service.ts. + if (!journeyOn) immichOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Immich'); + + await user.click(screen.getByRole('switch', { name: 'Journey' })); + // The toast closes the whole toggle, re-read included. + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Immich')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('switch', { name: 'Journey' })); + + await screen.findByText('Immich'); + expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-AADD-027: a toggle the server does not cascade keeps the snapshot it has', async () => { + const user = userEvent.setup(); + let loads = 0; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }); + }), + http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })), + ); + render(<>); + await screen.findByText('Todo List'); + + await user.click(screen.getByRole('switch', { name: 'Todo List' })); + + await screen.findByText('Addon updated'); + expect(loads).toBe(1); + expect(screen.getByRole('switch', { name: 'Todo List' })).toHaveAttribute('aria-checked', 'true'); + }); + it('FE-MOB-AADD-016: a disabled AI-parsing addon renders the integration row without its config', async () => { server.use(addonsRoute([llmAddon({ provider: 'local' })].map(a => ({ ...a, enabled: false })))); render();