mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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> = {}): 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<void>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, Record<string, boolean>>;
|
||||
|
||||
export interface AdminPreferenceMatrix {
|
||||
event_types: string[];
|
||||
channels?: { id: string; active: boolean }[];
|
||||
implemented_combos: Record<string, string[] | undefined>;
|
||||
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<typeof useToast>) {
|
||||
const [matrix, setMatrix] = useState<AdminPreferenceMatrix | null>(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<Preferences | null>(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 };
|
||||
}
|
||||
@@ -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<string, string[]>
|
||||
preferences: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
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<typeof useToast> }) {
|
||||
const [matrix, setMatrix] = useState<MatrixData | null>(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<MatrixData['preferences'] | null>(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 (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.tabs.notifications')} hint={t('admin.notifications.adminNotificationsHint')} />
|
||||
|
||||
@@ -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<string, Record<string, boolean>>
|
||||
|
||||
interface AdminPreferenceMatrix {
|
||||
event_types: string[]
|
||||
channels?: { id: string; active: boolean }[]
|
||||
implemented_combos: Record<string, string[] | undefined>
|
||||
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<typeof useToast> }) {
|
||||
const [matrix, setMatrix] = useState<AdminPreferenceMatrix | null>(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<Preferences | null>(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) => (
|
||||
<SettingsCard icon={BellRing} title={t('admin.tabs.notifications')} hint={t('admin.notifications.adminNotificationsHint')}>
|
||||
@@ -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(<SettingsHint>{t('settings.notificationPreferences.noChannels')}</SettingsHint>)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user