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