From ab3a69e18cd525987ba2c170eb9b3c15e2291a53 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:28:00 +0200 Subject: [PATCH] refactor(admin): share the admin notification matrix between desktop and phone useAdminNotificationMatrix now loads the matrix, picks the visible built in channels and saves each toggle with a per cell revert. The desktop panel and the phone card keep their own markup. --- .../Admin/useAdminNotificationMatrix.test.ts | 117 ++++++++++++++++++ .../Admin/useAdminNotificationMatrix.ts | 76 ++++++++++++ .../screens/admin/MAdminNotifyMatrix.tsx | 56 +-------- .../pages/admin/AdminNotificationsPanel.tsx | 57 +-------- 4 files changed, 198 insertions(+), 108 deletions(-) create mode 100644 client/src/components/Admin/useAdminNotificationMatrix.test.ts create mode 100644 client/src/components/Admin/useAdminNotificationMatrix.ts diff --git a/client/src/components/Admin/useAdminNotificationMatrix.test.ts b/client/src/components/Admin/useAdminNotificationMatrix.test.ts new file mode 100644 index 000000000..1870eb721 --- /dev/null +++ b/client/src/components/Admin/useAdminNotificationMatrix.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { adminApi } from '../../api/client'; +import { + type AdminPreferenceMatrix, + useAdminNotificationMatrix, + visibleAdminChannels, +} from './useAdminNotificationMatrix'; + +// FE-HOOK-ADMMATRIX-001 to FE-HOOK-ADMMATRIX-007 + +const t = (k: string) => k; +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; + +function matrix(over: Partial = {}): AdminPreferenceMatrix { + return { + event_types: ['version_available', 'trip_reminder'], + channels: [ + { id: 'inapp', active: true }, + { id: 'email', active: true }, + { id: 'webhook', active: false }, + { id: 'ntfy', active: true }, + ], + implemented_combos: { version_available: ['inapp', 'email'], trip_reminder: ['inapp'] }, + preferences: { version_available: { inapp: true, email: false } }, + ...over, + }; +} + +beforeEach(() => { + toast.error.mockReset(); + vi.spyOn(adminApi, 'getNotificationPreferences').mockResolvedValue(matrix()); + vi.spyOn(adminApi, 'updateNotificationPreferences').mockResolvedValue({ success: true }); +}); +afterEach(() => vi.restoreAllMocks()); + +async function loaded() { + const hook = renderHook(() => useAdminNotificationMatrix(t, toast)); + await waitFor(() => expect(hook.result.current.matrix).not.toBeNull()); + return hook; +} + +describe('useAdminNotificationMatrix', () => { + it('FE-HOOK-ADMMATRIX-001: stays empty until the matrix arrives and when the load fails', async () => { + vi.mocked(adminApi.getNotificationPreferences).mockRejectedValue(new Error('down')); + const { result } = renderHook(() => useAdminNotificationMatrix(t, toast)); + await waitFor(() => expect(adminApi.getNotificationPreferences).toHaveBeenCalledTimes(1)); + expect(result.current.matrix).toBeNull(); + expect(result.current.visibleChannels).toEqual([]); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-ADMMATRIX-002: shows only built-in channels that are active and implemented somewhere', async () => { + const { result } = await loaded(); + expect(result.current.visibleChannels).toEqual(['inapp', 'email']); + expect(visibleAdminChannels(matrix({ channels: undefined }))).toEqual([]); + }); + + it('FE-HOOK-ADMMATRIX-003: a toggle flips the cell, saves the whole map and clears saving', async () => { + const { result } = await loaded(); + await act(() => result.current.toggle('version_available', 'email')); + expect(adminApi.updateNotificationPreferences).toHaveBeenCalledWith({ + version_available: { inapp: true, email: true }, + }); + expect(result.current.matrix?.preferences.version_available.email).toBe(true); + expect(result.current.saving).toBe(false); + }); + + it('FE-HOOK-ADMMATRIX-004: a cell without a stored value counts as on', async () => { + const { result } = await loaded(); + await act(() => result.current.toggle('trip_reminder', 'inapp')); + expect(adminApi.updateNotificationPreferences).toHaveBeenCalledWith({ + version_available: { inapp: true, email: false }, + trip_reminder: { inapp: false }, + }); + }); + + it('FE-HOOK-ADMMATRIX-005: saving is set while the request runs', async () => { + let finish!: (v: unknown) => void; + vi.mocked(adminApi.updateNotificationPreferences).mockReturnValue(new Promise((r) => (finish = r))); + const { result } = await loaded(); + let pending!: Promise; + act(() => { + pending = result.current.toggle('version_available', 'inapp'); + }); + expect(result.current.saving).toBe(true); + await act(async () => { + finish({ success: true }); + await pending; + }); + expect(result.current.saving).toBe(false); + }); + + it('FE-HOOK-ADMMATRIX-006: a failed save reverts the cell and toasts', async () => { + vi.mocked(adminApi.updateNotificationPreferences).mockRejectedValue(new Error('x')); + const { result } = await loaded(); + await act(() => result.current.toggle('version_available', 'inapp')); + expect(result.current.matrix?.preferences.version_available.inapp).toBe(true); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-HOOK-ADMMATRIX-007: two toggles in one render keep each other; a failure reverts only its cell', async () => { + vi.mocked(adminApi.updateNotificationPreferences) + .mockResolvedValueOnce({ success: true }) + .mockRejectedValueOnce(new Error('x')); + const { result } = await loaded(); + const { toggle } = result.current; + await act(async () => { + await Promise.all([toggle('version_available', 'inapp'), toggle('version_available', 'email')]); + }); + expect(vi.mocked(adminApi.updateNotificationPreferences).mock.calls[1][0]).toEqual({ + version_available: { inapp: false, email: true }, + }); + expect(result.current.matrix?.preferences.version_available).toEqual({ inapp: false, email: false }); + }); +}); diff --git a/client/src/components/Admin/useAdminNotificationMatrix.ts b/client/src/components/Admin/useAdminNotificationMatrix.ts new file mode 100644 index 000000000..ff3512c06 --- /dev/null +++ b/client/src/components/Admin/useAdminNotificationMatrix.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react'; + +import { adminApi } from '../../api/client'; +import type { useToast } from '../shared/Toast'; + +export type Preferences = Record>; + +export interface AdminPreferenceMatrix { + event_types: string[]; + channels?: { id: string; active: boolean }[]; + implemented_combos: Record; + preferences: Preferences; +} + +// Admin-scoped events only ever go out over the built-in channels (plugin channels are +// user-scoped), so this list stays explicit rather than server-driven. +const BUILTIN_CHANNELS = ['inapp', 'email', 'webhook', 'ntfy'] as const; + +/** The built-in channels that are active and implemented for at least one event. */ +export function visibleAdminChannels(matrix: AdminPreferenceMatrix): string[] { + const isActive = (id: string) => matrix.channels?.some((c) => c.id === id && c.active) ?? false; + return BUILTIN_CHANNELS.filter( + (ch) => isActive(ch) && matrix.event_types.some((evt) => matrix.implemented_combos[evt]?.includes(ch)) + ); +} + +/** + * The per event and per channel admin notification matrix behind both shells: loaded once + * on mount, and each toggle saved straight away. The desktop panel and the phone card + * render their own markup over it. + */ +export function useAdminNotificationMatrix(t: (k: string) => string, toast: ReturnType) { + const [matrix, setMatrix] = useState(null); + const [saving, setSaving] = useState(false); + // Toggles fire faster than React re-renders, so the live preferences are mirrored in a + // ref. Reading state out of the render closure would let a second toggle undo the first. + const prefsRef = useRef(null); + + const writePrefs = (prefs: Preferences) => { + prefsRef.current = prefs; + setMatrix((m) => (m ? { ...m, preferences: prefs } : m)); + }; + + useEffect(() => { + adminApi + .getNotificationPreferences() + .then((data: AdminPreferenceMatrix) => { + prefsRef.current = data.preferences; + setMatrix(data); + }) + .catch(() => { + /* the shells stay on their loading state */ + }); + }, []); + + const toggle = async (eventType: string, channel: string) => { + if (!matrix) return; + const before = prefsRef.current ?? matrix.preferences; + const current = before[eventType]?.[channel] ?? true; + const updated = { ...before, [eventType]: { ...before[eventType], [channel]: !current } }; + writePrefs(updated); + setSaving(true); + try { + await adminApi.updateNotificationPreferences(updated); + } catch { + // Revert this cell only: a toggle that already went through keeps its value. + const latest = prefsRef.current ?? updated; + writePrefs({ ...latest, [eventType]: { ...latest[eventType], [channel]: current } }); + toast.error(t('common.error')); + } finally { + setSaving(false); + } + }; + + return { matrix, saving, visibleChannels: matrix ? visibleAdminChannels(matrix) : [], toggle }; +} diff --git a/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx b/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx index b9bd382c1..d122489a1 100644 --- a/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx +++ b/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx @@ -1,44 +1,14 @@ -import { useEffect, useRef, useState } from 'react' -import { adminApi } from '../../../api/client' +import { useAdminNotificationMatrix } from '../../../components/Admin/useAdminNotificationMatrix' import type { TranslationFn } from '../../../types' import type { useToast } from '../../../components/shared/Toast' import { ADMIN_EVENT_LABEL_KEYS, ADMIN_CHANNEL_LABEL_KEYS } from '../../../pages/admin/AdminPage.constants' import MToggle from '../../components/MToggle' import { MAdminCard, MAdminCardHead } from './MAdminUi' -interface ChannelInfo { - id: string - active: boolean -} - -interface MatrixData { - event_types: string[] - channels?: ChannelInfo[] - implemented_combos: Record - preferences: Record> -} - -const BUILTIN_CHANNELS = ['inapp', 'email', 'webhook', 'ntfy'] as const - // Per-event × per-channel admin notification matrix — the mobile layout of // AdminNotificationsPanel. Loads its own data and auto-saves each toggle. export default function MAdminNotifyMatrix({ t, toast }: { t: TranslationFn; toast: ReturnType }) { - const [matrix, setMatrix] = useState(null) - // Toggles fire faster than React re-renders, so the live preferences are mirrored in a - // ref. Reading state out of the render closure would let a second toggle undo the first. - const prefsRef = useRef(null) - - const writePrefs = (prefs: MatrixData['preferences']) => { - prefsRef.current = prefs - setMatrix((m) => (m ? { ...m, preferences: prefs } : m)) - } - - useEffect(() => { - adminApi.getNotificationPreferences().then((data: MatrixData) => { - prefsRef.current = data.preferences - setMatrix(data) - }).catch(() => {}) - }, []) + const { matrix, visibleChannels, toggle } = useAdminNotificationMatrix(t, toast) if (!matrix) { return ( @@ -56,28 +26,6 @@ export default function MAdminNotifyMatrix({ t, toast }: { t: TranslationFn; toa ) } - // Admin-scoped events only go out over the built-in channels (plugin - // channels are user-scoped), same rule as the desktop panel. - const isActive = (id: string) => matrix.channels?.some((c) => c.id === id && c.active) ?? false - const visibleChannels = BUILTIN_CHANNELS.filter( - (ch) => isActive(ch) && matrix.event_types.some((evt) => matrix.implemented_combos[evt]?.includes(ch)), - ) - - const toggle = async (eventType: string, channel: string) => { - const before = prefsRef.current ?? matrix.preferences - const current = before[eventType]?.[channel] ?? true - const updated = { ...before, [eventType]: { ...before[eventType], [channel]: !current } } - writePrefs(updated) - try { - await adminApi.updateNotificationPreferences(updated) - } catch { - // Revert this cell only: a toggle that already went through keeps its value. - const latest = prefsRef.current ?? updated - writePrefs({ ...latest, [eventType]: { ...latest[eventType], [channel]: current } }) - toast.error(t('common.error')) - } - } - return ( diff --git a/client/src/pages/admin/AdminNotificationsPanel.tsx b/client/src/pages/admin/AdminNotificationsPanel.tsx index 47fe4166f..24eff2bd5 100644 --- a/client/src/pages/admin/AdminNotificationsPanel.tsx +++ b/client/src/pages/admin/AdminNotificationsPanel.tsx @@ -1,43 +1,18 @@ -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' import { BellRing, Loader2 } from 'lucide-react' -import { adminApi } from '../../api/client' +import { useAdminNotificationMatrix } from '../../components/Admin/useAdminNotificationMatrix' import { useToast } from '../../components/shared/Toast' import ToggleSwitch from '../../components/Settings/ToggleSwitch' import { SettingsCard, SettingsHint } from '../../components/Settings/settingsKit' import { fs } from '../../components/shared/DialogShell' import { ADMIN_EVENT_LABEL_KEYS, ADMIN_CHANNEL_LABEL_KEYS } from './AdminPage.constants' -type Preferences = Record> - -interface AdminPreferenceMatrix { - event_types: string[] - channels?: { id: string; active: boolean }[] - implemented_combos: Record - preferences: Preferences -} - const EYEBROW = 'text-center font-geist font-bold uppercase tracking-[.08em] text-content-faint' // Per-event × per-channel admin notification preference matrix. // Loads its own data and auto-saves each toggle. export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType }) { - const [matrix, setMatrix] = useState(null) - const [saving, setSaving] = useState(false) - // Toggles fire faster than React re-renders, so the live preferences are mirrored in a - // ref. Reading state out of the render closure would let a second toggle undo the first. - const prefsRef = useRef(null) - - const writePrefs = (prefs: Preferences) => { - prefsRef.current = prefs - setMatrix(m => m ? { ...m, preferences: prefs } : m) - } - - useEffect(() => { - adminApi.getNotificationPreferences().then((data: AdminPreferenceMatrix) => { - prefsRef.current = data.preferences - setMatrix(data) - }).catch(() => {}) - }, []) + const { matrix, saving, visibleChannels, toggle } = useAdminNotificationMatrix(t, toast) const card = (children: React.ReactNode) => ( @@ -54,32 +29,6 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) = ) } - // Admin-scoped events only ever go out over the built-in channels (plugin channels - // are user-scoped), so this list stays explicit rather than server-driven. - const isActive = (id: string) => matrix.channels?.some(c => c.id === id && c.active) ?? false - const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => { - if (!isActive(ch)) return false - return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) - }) - - const toggle = async (eventType: string, channel: string) => { - const before = prefsRef.current ?? matrix.preferences - const current = before[eventType]?.[channel] ?? true - const updated = { ...before, [eventType]: { ...before[eventType], [channel]: !current } } - writePrefs(updated) - setSaving(true) - try { - await adminApi.updateNotificationPreferences(updated) - } catch { - // Revert this cell only — a toggle that already went through keeps its value. - const latest = prefsRef.current ?? updated - writePrefs({ ...latest, [eventType]: { ...latest[eventType], [channel]: current } }) - toast.error(t('common.error')) - } finally { - setSaving(false) - } - } - if (matrix.event_types.length === 0) { return card({t('settings.notificationPreferences.noChannels')}) }