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();