refactor(admin): share the admin shell actions between desktop and phone

Saving the demo baseline and the bag tracking and collab feature switches now live in useAdmin. AdminPage and the phone admin screen pass them on instead of carrying their own inline copies.
This commit is contained in:
Maurice
2026-10-09 10:49:44 +02:00
parent 7738334fd6
commit 8c8069e983
4 changed files with 114 additions and 61 deletions
+5 -33
View File
@@ -24,7 +24,6 @@ import {
Users,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { adminApi } from '../../../api/client'
import { useTranslation } from '../../../i18n'
import { useCountUp } from '../../../hooks/useCountUp'
import { useAdmin } from '../../../pages/admin/useAdmin'
@@ -71,12 +70,12 @@ export default function MAdmin() {
const { t, locale } = useTranslation()
const admin = useAdmin()
const {
demoMode, mcpEnabled, devMode, managed, toast, navigate,
demoMode, mcpEnabled, devMode, managed, navigate,
activeTab, setActiveTab, stats, serverTimezone,
bagTrackingEnabled, setBagTrackingEnabled,
collabFeatures, setCollabFeatures,
bagTrackingEnabled, collabFeatures,
setShowCreateUser,
updateInfo, setShowUpdateModal,
saveDemoBaseline, toggleBagTracking, toggleCollabFeature,
} = admin
const [sectionsOpen, setSectionsOpen] = useState(false)
@@ -101,15 +100,6 @@ export default function MAdmin() {
]
const activeSection = sections.find((s) => s.id === activeTab) ?? sections[0]
const saveDemoBaseline = async () => {
try {
await adminApi.saveDemoBaseline()
toast.success('Baseline saved! Resets will restore to this state.')
} catch (e) {
toast.error(e.response?.data?.error || 'Failed to save baseline')
}
}
return (
// Flow screen: scrolls with the document (#1809), so no height and no
// scroll container of its own.
@@ -221,27 +211,9 @@ export default function MAdmin() {
{activeTab === 'addons' && (
<MAdminAddonManager
bagTrackingEnabled={bagTrackingEnabled}
onToggleBagTracking={async () => {
const next = !bagTrackingEnabled
setBagTrackingEnabled(next)
try {
await adminApi.updateBagTracking(next)
} catch {
setBagTrackingEnabled(!next)
}
}}
onToggleBagTracking={toggleBagTracking}
collabFeatures={collabFeatures}
onToggleCollabFeature={async (key: string) => {
const previous = collabFeatures[key]
setCollabFeatures({ ...collabFeatures, [key]: !previous })
try {
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
// Only this key rolls back — a slower request must not undo a toggle
// the admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}}
onToggleCollabFeature={toggleCollabFeature}
/>
)}
{activeTab === 'plugins' && <MAdminPluginsPanel />}
+5 -27
View File
@@ -1,6 +1,5 @@
import SchoolHolidayCatalog from '../components/Admin/SchoolHolidayCatalog'
import React, { Fragment } from 'react'
import { adminApi } from '../api/client'
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
import DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab'
import { useTranslation } from '../i18n'
@@ -41,12 +40,12 @@ function AdminPageDesktop(): React.ReactElement {
// each tab/section renders from a dedicated sub-component.
const admin = useAdmin()
const {
demoMode, mcpEnabled, devMode, managed, toast,
demoMode, mcpEnabled, devMode, managed,
activeTab, setActiveTab, stats,
bagTrackingEnabled, setBagTrackingEnabled,
collabFeatures, setCollabFeatures,
bagTrackingEnabled, collabFeatures,
serverTimezone,
updateInfo, setShowUpdateModal,
saveDemoBaseline, toggleBagTracking, toggleCollabFeature,
} = admin
const gUsers = t('admin.group.users')
@@ -124,14 +123,7 @@ function AdminPageDesktop(): React.ReactElement {
<p className="m-0 mt-0.5 text-content-muted" style={fs(12.5, 'body')}>Save current state as the hourly reset point. All admin trips and settings will be preserved.</p>
</div>
<button type="button"
onClick={async () => {
try {
await adminApi.saveDemoBaseline()
toast.success('Baseline saved! Resets will restore to this state.')
} catch (e) {
toast.error(e.response?.data?.error || 'Failed to save baseline')
}
}}
onClick={saveDemoBaseline}
className={`${SETTINGS_BUTTON_PRIMARY} flex-none`}
style={fs(13, 'body')}
>
@@ -164,21 +156,7 @@ function AdminPageDesktop(): React.ReactElement {
{activeTab === 'addons' && (
<div className="space-y-6">
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={async () => {
const next = !bagTrackingEnabled
setBagTrackingEnabled(next)
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
const previous = collabFeatures[key]
setCollabFeatures({ ...collabFeatures, [key]: !previous })
try {
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
// Only this key rolls back — a slower request must not undo a toggle
// the admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}} />
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={toggleBagTracking} collabFeatures={collabFeatures} onToggleCollabFeature={toggleCollabFeature} />
</div>
)}
+71 -1
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-053
// FE-ADMHOOK-001 to FE-ADMHOOK-058
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { adminApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { useAdmin } from './useAdmin';
@@ -863,4 +864,73 @@ describe('useAdmin', () => {
expect(result.current.hour12).toBe(false);
expect(result.current.currentUser?.username).toBe('admin');
});
it('FE-ADMHOOK-054: saving the demo baseline toasts the outcome, with the server message on failure', async () => {
const save = vi.spyOn(adminApi, 'saveDemoBaseline').mockResolvedValue({});
const { result } = await mountAdmin();
await act(() => result.current.saveDemoBaseline());
expect(save).toHaveBeenCalledTimes(1);
expect(toastCalls).toContainEqual({ type: 'success', message: 'Baseline saved! Resets will restore to this state.' });
save.mockRejectedValueOnce({ response: { data: { error: 'Not a demo' } } });
await act(() => result.current.saveDemoBaseline());
expect(toastCalls).toContainEqual({ type: 'error', message: 'Not a demo' });
save.mockRejectedValueOnce(new Error('offline'));
await act(() => result.current.saveDemoBaseline());
expect(toastCalls).toContainEqual({ type: 'error', message: 'Failed to save baseline' });
});
it('FE-ADMHOOK-055: bag tracking flips at once and saves the new value', async () => {
const update = vi.spyOn(adminApi, 'updateBagTracking').mockResolvedValue({});
const { result } = await mountAdmin();
const before = result.current.bagTrackingEnabled;
await act(() => result.current.toggleBagTracking());
expect(update).toHaveBeenCalledWith(!before);
expect(result.current.bagTrackingEnabled).toBe(!before);
});
it('FE-ADMHOOK-056: a failed bag tracking save flips back without a toast', async () => {
vi.spyOn(adminApi, 'updateBagTracking').mockRejectedValue(new Error('x'));
const { result } = await mountAdmin();
const before = result.current.bagTrackingEnabled;
const toastsBefore = toastCalls.length;
await act(() => result.current.toggleBagTracking());
expect(result.current.bagTrackingEnabled).toBe(before);
expect(toastCalls).toHaveLength(toastsBefore);
});
it('FE-ADMHOOK-057: a collab feature toggle saves only that key', async () => {
vi.spyOn(adminApi, 'getCollabFeatures').mockResolvedValue({ chat: true, notes: true, polls: true, whatsnext: true });
const update = vi.spyOn(adminApi, 'updateCollabFeatures').mockResolvedValue({});
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.collabFeatures.chat).toBe(true));
await act(() => result.current.toggleCollabFeature('chat'));
expect(update).toHaveBeenCalledWith({ chat: false });
expect(result.current.collabFeatures).toEqual({ chat: false, notes: true, polls: true, whatsnext: true });
});
it('FE-ADMHOOK-058: a failed collab toggle rolls back its own key and keeps a later one', async () => {
vi.spyOn(adminApi, 'getCollabFeatures').mockResolvedValue({ chat: true, notes: true, polls: true, whatsnext: true });
let fail!: (e: unknown) => void;
vi.spyOn(adminApi, 'updateCollabFeatures')
.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)))
.mockResolvedValueOnce({});
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.collabFeatures.chat).toBe(true));
let pending!: Promise<void>;
act(() => {
pending = result.current.toggleCollabFeature('chat');
});
await act(() => result.current.toggleCollabFeature('notes'));
await act(async () => {
fail(new Error('x'));
await pending;
});
expect(result.current.collabFeatures).toEqual({ chat: true, notes: false, polls: true, whatsnext: true });
});
});
+33
View File
@@ -509,6 +509,38 @@ export function useAdmin() {
}
}
// The admin shell's own actions, the same on desktop and phone.
const saveDemoBaseline = async () => {
try {
await adminApi.saveDemoBaseline()
toast.success('Baseline saved! Resets will restore to this state.')
} catch (e: unknown) {
toast.error((e as { response?: { data?: { error?: string } } }).response?.data?.error || 'Failed to save baseline')
}
}
const toggleBagTracking = async () => {
const next = !bagTrackingEnabled
setBagTrackingEnabled(next)
try {
await adminApi.updateBagTracking(next)
} catch {
setBagTrackingEnabled(!next)
}
}
const toggleCollabFeature = async (key: string) => {
const previous = (collabFeatures as Record<string, boolean>)[key]
setCollabFeatures({ ...collabFeatures, [key]: !previous })
try {
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
// Only this key rolls back: a slower request must not undo a toggle the
// admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}
return {
// store-derived
demoMode, serverTimezone, hour12, mcpEnabled, devMode, managed, currentUser,
@@ -550,5 +582,6 @@ export function useAdmin() {
toggleKey, handleSaveApiKeys, handleValidateKeys, handleValidateKey,
handleCreateUser, handleCreateInvite, handleDeleteInvite, copyInviteLink,
handleEditUser, handleSaveUser, handleDeleteUser,
saveDemoBaseline, toggleBagTracking, toggleCollabFeature,
}
}