diff --git a/client/src/components/Admin/DevNotificationsPanel.tsx b/client/src/components/Admin/DevNotificationsPanel.tsx index 61d967651..7e44749bf 100644 --- a/client/src/components/Admin/DevNotificationsPanel.tsx +++ b/client/src/components/Admin/DevNotificationsPanel.tsx @@ -1,27 +1,13 @@ -import React, { useState, useEffect, useId } from 'react' -import { adminApi, tripsApi } from '../../api/client' -import { getApiErrorMessage } from '../../utils/apiError' -import { useAuthStore } from '../../store/authStore' -import { useToast } from '../shared/Toast' +import React, { useId } from 'react' import { fs } from '../shared/DialogShell' import { EditorField, INPUT } from '../shared/dialogParts' import { SettingsCard, StatusPill } from '../Settings/settingsKit' +import { useDevNotifications } from './useDevNotifications' import { Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus, Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck, } from 'lucide-react' -interface Trip { - id: number - title: string -} - -interface AppUser { - id: number - username: string - email: string -} - /** A native select in the editors' box look, with the chevron the CustomSelect draws. */ const SELECT = `${INPUT} appearance-none pe-9` @@ -40,44 +26,11 @@ function ButtonGrid({ children }: { children: React.ReactNode }): React.ReactEle } export default function DevNotificationsPanel(): React.ReactElement { - const toast = useToast() const ids = useId() - const user = useAuthStore(s => s.user) - const [sending, setSending] = useState(null) - const [trips, setTrips] = useState([]) - const [selectedTripId, setSelectedTripId] = useState(null) - const [users, setUsers] = useState([]) - const [selectedUserId, setSelectedUserId] = useState(null) - - useEffect(() => { - tripsApi.list().then(data => { - const list = (data.trips || data || []) as Trip[] - setTrips(list) - if (list.length > 0) setSelectedTripId(list[0].id) - }).catch(() => {}) - adminApi.users().then(data => { - const list = (data.users || data || []) as AppUser[] - setUsers(list) - if (list.length > 0) setSelectedUserId(list[0].id) - }).catch(() => {}) - }, []) - - const fire = async (label: string, payload: Record) => { - setSending(label) - try { - await adminApi.sendTestNotification(payload) - toast.success(`Sent: ${label}`) - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, 'Failed')) - } finally { - setSending(null) - } - } - - const selectedTrip = trips.find(t => t.id === selectedTripId) - const selectedUser = users.find(u => u.id === selectedUserId) - const username = user?.username || 'Admin' - const tripTitle = selectedTrip?.title || 'Test Trip' + const { + sending, trips, selectedTripId, setSelectedTripId, users, selectedUserId, setSelectedUserId, + tripInviteId, vacayInviteId, send, + } = useDevNotifications() // ── Helpers ────────────────────────────────────────────────────────────── @@ -114,41 +67,16 @@ export default function DevNotificationsPanel(): React.ReactElement { > fire('simple-me', { - event: 'test_simple', - scope: 'user', - targetId: user?.id, - params: {}, - })} + onClick={send.simpleMe} /> fire('boolean-me', { - event: 'test_boolean', - scope: 'user', - targetId: user?.id, - params: {}, - inApp: { - type: 'boolean', - positiveCallback: { action: 'test_approve', payload: {} }, - negativeCallback: { action: 'test_deny', payload: {} }, - }, - })} + onClick={send.booleanMe} /> fire('navigate-me', { - event: 'test_navigate', - scope: 'user', - targetId: user?.id, - params: {}, - })} + onClick={send.navigateMe} /> fire('simple-admins', { - event: 'test_simple', - scope: 'admin', - targetId: 0, - params: {}, - })} + onClick={send.simpleAdmins} /> @@ -167,46 +95,19 @@ export default function DevNotificationsPanel(): React.ReactElement { selectedTripId && fire('booking_change', { - event: 'booking_change', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) }, - })} + onClick={send.bookingChange} /> selectedTripId && fire('trip_reminder', { - event: 'trip_reminder', - scope: 'trip', - targetId: selectedTripId, - params: { trip: tripTitle, tripId: String(selectedTripId) }, - })} + onClick={send.tripReminder} /> selectedTripId && fire('photos_shared', { - event: 'photos_shared', - scope: 'trip', - targetId: selectedTripId, - // A string on purpose: notification params are Record end to end, the server - // sends count: String(added), and resolveTemplate reads a numeric string as the plural count. - params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) }, - })} + onClick={send.photosShared} /> selectedTripId && fire('collab_message', { - event: 'collab_message', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) }, - })} + onClick={send.collabMessage} /> selectedTripId && fire('packing_tagged', { - event: 'packing_tagged', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) }, - })} + onClick={send.packingTagged} /> @@ -226,28 +127,18 @@ export default function DevNotificationsPanel(): React.ReactElement { selectedUserId && fire(`trip_invite-${selectedUserId}`, { - event: 'trip_invite', - scope: 'user', - targetId: selectedUserId, - params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) }, - })} + onClick={send.tripInvite} /> selectedUserId && fire(`vacay_invite-${selectedUserId}`, { - event: 'vacay_invite', - scope: 'user', - targetId: selectedUserId, - params: { actor: username, planId: '1' }, - })} + onClick={send.vacayInvite} /> @@ -257,12 +148,7 @@ export default function DevNotificationsPanel(): React.ReactElement { fire('version_available', { - event: 'version_available', - scope: 'admin', - targetId: 0, - params: { version: '9.9.9-test' }, - })} + onClick={send.versionAvailable} /> diff --git a/client/src/components/Admin/useDevNotifications.test.ts b/client/src/components/Admin/useDevNotifications.test.ts new file mode 100644 index 000000000..1a2affe4c --- /dev/null +++ b/client/src/components/Admin/useDevNotifications.test.ts @@ -0,0 +1,144 @@ +// FE-COMP-DEVNOTIF-HOOK-001 to -008: the shared dev notification tester behind both admin shells. +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { buildUser } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { adminApi, tripsApi } from '../../api/client'; +import { useAuthStore } from '../../store/authStore'; +import { useDevNotifications } from './useDevNotifications'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); + +const ME = buildUser({ id: 7, username: 'tester' }); +const TRIPS = [ + { id: 11, title: 'Paris' }, + { id: 12, title: 'Tokyo' }, +]; +const USERS = [ + { id: 21, username: 'alice', email: 'alice@example.com' }, + { id: 22, username: 'bob', email: 'bob@example.com' }, +]; + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: ME, isAuthenticated: true }); + vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS }); + vi.spyOn(adminApi, 'users').mockResolvedValue({ users: USERS }); + vi.spyOn(adminApi, 'sendTestNotification').mockResolvedValue({}); + toast.success.mockReset(); + toast.error.mockReset(); +}); +afterEach(() => vi.restoreAllMocks()); + +async function loaded(options?: Parameters[0]) { + const hook = renderHook(() => useDevNotifications(options)); + await waitFor(() => expect(hook.result.current.selectedUserId).toBe(21)); + await waitFor(() => expect(hook.result.current.selectedTripId).toBe(11)); + return hook; +} + +describe('useDevNotifications', () => { + it('FE-COMP-DEVNOTIF-HOOK-001: loads trips and users and preselects the first of each', async () => { + const { result } = await loaded(); + expect(result.current.trips).toEqual(TRIPS); + expect(result.current.users).toEqual(USERS); + expect(result.current.tripInviteId).toBe('trip_invite-21'); + expect(result.current.vacayInviteId).toBe('vacay_invite-21'); + }); + + it('FE-COMP-DEVNOTIF-HOOK-002: a failed load leaves the lists empty and nothing selected', async () => { + vi.mocked(tripsApi.list).mockRejectedValue(new Error('down')); + vi.mocked(adminApi.users).mockRejectedValue(new Error('down')); + const { result } = renderHook(() => useDevNotifications()); + await waitFor(() => expect(adminApi.users).toHaveBeenCalled()); + expect(result.current.trips).toEqual([]); + expect(result.current.selectedTripId).toBeNull(); + expect(result.current.selectedUserId).toBeNull(); + }); + + it('FE-COMP-DEVNOTIF-HOOK-003: a self-addressed send targets the signed-in user and toasts the label', async () => { + const { result } = await loaded(); + await act(async () => { + await result.current.send.booleanMe(); + }); + expect(adminApi.sendTestNotification).toHaveBeenCalledWith({ + event: 'test_boolean', + scope: 'user', + targetId: 7, + params: {}, + inApp: { + type: 'boolean', + positiveCallback: { action: 'test_approve', payload: {} }, + negativeCallback: { action: 'test_deny', payload: {} }, + }, + }); + expect(toast.success).toHaveBeenCalledWith('Sent: boolean-me'); + expect(result.current.sending).toBeNull(); + }); + + it('FE-COMP-DEVNOTIF-HOOK-004: trip events carry the selected trip, its title and the actor', async () => { + const { result } = await loaded(); + act(() => result.current.setSelectedTripId(12)); + await act(async () => { + await result.current.send.photosShared(); + }); + expect(adminApi.sendTestNotification).toHaveBeenCalledWith({ + event: 'photos_shared', + scope: 'trip', + targetId: 12, + params: { actor: 'tester', trip: 'Tokyo', count: '5', tripId: '12' }, + }); + }); + + it('FE-COMP-DEVNOTIF-HOOK-005: user events go to the selected recipient under an id naming them', async () => { + const { result } = await loaded(); + act(() => result.current.setSelectedUserId(22)); + await act(async () => { + await result.current.send.tripInvite(); + }); + expect(adminApi.sendTestNotification).toHaveBeenCalledWith({ + event: 'trip_invite', + scope: 'user', + targetId: 22, + params: { actor: 'tester', trip: 'Paris', invitee: 'bob@example.com', tripId: '11' }, + }); + expect(toast.success).toHaveBeenCalledWith('Sent: trip_invite-22'); + }); + + it('FE-COMP-DEVNOTIF-HOOK-006: trip and user events do nothing while nothing is selected', async () => { + vi.mocked(tripsApi.list).mockResolvedValue({ trips: [] }); + vi.mocked(adminApi.users).mockResolvedValue({ users: [] }); + const { result } = renderHook(() => useDevNotifications()); + await waitFor(() => expect(adminApi.users).toHaveBeenCalled()); + await act(async () => { + await result.current.send.bookingChange(); + await result.current.send.vacayInvite(); + }); + expect(adminApi.sendTestNotification).not.toHaveBeenCalled(); + }); + + it('FE-COMP-DEVNOTIF-HOOK-007: by default a failure toasts the server error field, else Failed', async () => { + const { result } = await loaded(); + vi.mocked(adminApi.sendTestNotification).mockRejectedValueOnce({ response: { data: { error: 'No channel' } } }); + await act(async () => { + await result.current.send.versionAvailable(); + }); + expect(toast.error).toHaveBeenLastCalledWith('No channel'); + vi.mocked(adminApi.sendTestNotification).mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await result.current.send.versionAvailable(); + }); + expect(toast.error).toHaveBeenLastCalledWith('Failed'); + expect(result.current.sending).toBeNull(); + }); + + it('FE-COMP-DEVNOTIF-HOOK-008: a shell can word the failure toast itself', async () => { + const { result } = await loaded({ errorMessage: (err) => (err instanceof Error ? err.message : 'Failed') }); + vi.mocked(adminApi.sendTestNotification).mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await result.current.send.simpleAdmins(); + }); + expect(toast.error).toHaveBeenCalledWith('boom'); + }); +}); diff --git a/client/src/components/Admin/useDevNotifications.ts b/client/src/components/Admin/useDevNotifications.ts new file mode 100644 index 000000000..1230660c0 --- /dev/null +++ b/client/src/components/Admin/useDevNotifications.ts @@ -0,0 +1,208 @@ +import { useEffect, useState } from 'react'; + +import { adminApi, tripsApi } from '../../api/client'; +import { useAuthStore } from '../../store/authStore'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { useToast } from '../shared/Toast'; + +interface DevTrip { + id: number; + title: string; +} + +interface DevAppUser { + id: number; + username: string; + email: string; +} + +interface UseDevNotificationsOptions { + /** The error toast text for a failed send. Defaults to the server's `error` field, else 'Failed'. */ + errorMessage?: (err: unknown) => string; +} + +const serverErrorMessage = (err: unknown) => getApiErrorMessage(err, 'Failed'); + +/** + * The dev-only notification tester behind both admin shells (the desktop panel and the + * phone panel render their own buttons over it). Loads the trips and users to pick a + * target from, and builds and sends each test event. Every handler is named after the + * button that calls it; `sending` holds the id of the send in flight. + */ +export function useDevNotifications({ errorMessage = serverErrorMessage }: UseDevNotificationsOptions = {}) { + const toast = useToast(); + const user = useAuthStore((s) => s.user); + const [sending, setSending] = useState(null); + const [trips, setTrips] = useState([]); + const [selectedTripId, setSelectedTripId] = useState(null); + const [users, setUsers] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + + useEffect(() => { + tripsApi + .list() + .then((data) => { + const list = (data.trips || data || []) as DevTrip[]; + setTrips(list); + if (list.length > 0) setSelectedTripId(list[0].id); + }) + .catch(() => {}); + adminApi + .users() + .then((data) => { + const list = (data.users || data || []) as DevAppUser[]; + setUsers(list); + if (list.length > 0) setSelectedUserId(list[0].id); + }) + .catch(() => {}); + }, []); + + const fire = async (label: string, payload: Record) => { + setSending(label); + try { + await adminApi.sendTestNotification(payload); + toast.success(`Sent: ${label}`); + } catch (err: unknown) { + toast.error(errorMessage(err)); + } finally { + setSending(null); + } + }; + + const selectedTrip = trips.find((t) => t.id === selectedTripId); + const selectedUser = users.find((u) => u.id === selectedUserId); + const username = user?.username || 'Admin'; + const tripTitle = selectedTrip?.title || 'Test Trip'; + const tripInviteId = `trip_invite-${selectedUserId}`; + const vacayInviteId = `vacay_invite-${selectedUserId}`; + + const send = { + simpleMe: () => + fire('simple-me', { + event: 'test_simple', + scope: 'user', + targetId: user?.id, + params: {}, + }), + booleanMe: () => + fire('boolean-me', { + event: 'test_boolean', + scope: 'user', + targetId: user?.id, + params: {}, + inApp: { + type: 'boolean', + positiveCallback: { action: 'test_approve', payload: {} }, + negativeCallback: { action: 'test_deny', payload: {} }, + }, + }), + navigateMe: () => + fire('navigate-me', { + event: 'test_navigate', + scope: 'user', + targetId: user?.id, + params: {}, + }), + simpleAdmins: () => + fire('simple-admins', { + event: 'test_simple', + scope: 'admin', + targetId: 0, + params: {}, + }), + bookingChange: () => + selectedTripId && + fire('booking_change', { + event: 'booking_change', + scope: 'trip', + targetId: selectedTripId, + params: { + actor: username, + trip: tripTitle, + booking: 'Test Hotel', + type: 'hotel', + tripId: String(selectedTripId), + }, + }), + tripReminder: () => + selectedTripId && + fire('trip_reminder', { + event: 'trip_reminder', + scope: 'trip', + targetId: selectedTripId, + params: { trip: tripTitle, tripId: String(selectedTripId) }, + }), + photosShared: () => + selectedTripId && + fire('photos_shared', { + event: 'photos_shared', + scope: 'trip', + targetId: selectedTripId, + // A string on purpose: notification params are Record end to end, the server + // sends count: String(added), and resolveTemplate reads a numeric string as the plural count. + params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) }, + }), + collabMessage: () => + selectedTripId && + fire('collab_message', { + event: 'collab_message', + scope: 'trip', + targetId: selectedTripId, + params: { + actor: username, + trip: tripTitle, + preview: 'This is a test message preview.', + tripId: String(selectedTripId), + }, + }), + packingTagged: () => + selectedTripId && + fire('packing_tagged', { + event: 'packing_tagged', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) }, + }), + tripInvite: () => + selectedUserId && + fire(tripInviteId, { + event: 'trip_invite', + scope: 'user', + targetId: selectedUserId, + params: { + actor: username, + trip: tripTitle, + invitee: selectedUser?.email || '', + tripId: String(selectedTripId ?? 0), + }, + }), + vacayInvite: () => + selectedUserId && + fire(vacayInviteId, { + event: 'vacay_invite', + scope: 'user', + targetId: selectedUserId, + params: { actor: username, planId: '1' }, + }), + versionAvailable: () => + fire('version_available', { + event: 'version_available', + scope: 'admin', + targetId: 0, + params: { version: '9.9.9-test' }, + }), + }; + + return { + sending, + trips, + selectedTripId, + setSelectedTripId, + users, + selectedUserId, + setSelectedUserId, + tripInviteId, + vacayInviteId, + send, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx b/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx index 8e897fabb..ae55f49c5 100644 --- a/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx @@ -1,24 +1,14 @@ -import React, { useState, useEffect } from 'react' +import React from 'react' import { Bell, Zap, CheckCircle, Navigation, Calendar, Clock, Image, MessageSquare, Tag, UserPlus, Download, MapPin, Loader2, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import { adminApi, tripsApi } from '../../../api/client' -import { useAuthStore } from '../../../store/authStore' -import { useToast } from '../../../components/shared/Toast' +import { useDevNotifications } from '../../../components/Admin/useDevNotifications' import { MAdminCard, MAdminCardHead } from './MAdminUi' -interface Trip { - id: number - title: string -} - -interface AppUser { - id: number - username: string - email: string -} +/** The phone panel toasts the thrown error's own message rather than the server's error field. */ +const errorMessage = (err: unknown) => (err instanceof Error ? err.message : 'Failed') const SELECT_CLASS = 'mb-2 h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]' @@ -57,46 +47,13 @@ function Btn({ } // Dev-only notification testing panel, re-skinned to the mobile admin system. -// All state, fetches and fire() payloads are ported verbatim from the desktop -// DevNotificationsPanel — only the presentation layer changes. +// All state, fetches and payloads come from useDevNotifications, shared +// with the desktop DevNotificationsPanel; only the presentation layer differs. export default function MAdminDevNotificationsPanel(): React.ReactElement { - const toast = useToast() - const user = useAuthStore(s => s.user) - const [sending, setSending] = useState(null) - const [trips, setTrips] = useState([]) - const [selectedTripId, setSelectedTripId] = useState(null) - const [users, setUsers] = useState([]) - const [selectedUserId, setSelectedUserId] = useState(null) - - useEffect(() => { - tripsApi.list().then(data => { - const list = (data.trips || data || []) as Trip[] - setTrips(list) - if (list.length > 0) setSelectedTripId(list[0].id) - }).catch(() => {}) - adminApi.users().then(data => { - const list = (data.users || data || []) as AppUser[] - setUsers(list) - if (list.length > 0) setSelectedUserId(list[0].id) - }).catch(() => {}) - }, []) - - const fire = async (label: string, payload: Record) => { - setSending(label) - try { - await adminApi.sendTestNotification(payload) - toast.success(`Sent: ${label}`) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : 'Failed') - } finally { - setSending(null) - } - } - - const selectedTrip = trips.find(t => t.id === selectedTripId) - const selectedUser = users.find(u => u.id === selectedUserId) - const username = user?.username || 'Admin' - const tripTitle = selectedTrip?.title || 'Test Trip' + const { + sending, trips, selectedTripId, setSelectedTripId, users, selectedUserId, setSelectedUserId, + tripInviteId, vacayInviteId, send, + } = useDevNotifications({ errorMessage }) return (
@@ -118,41 +75,16 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement { />
fire('simple-me', { - event: 'test_simple', - scope: 'user', - targetId: user?.id, - params: {}, - })} + onClick={send.simpleMe} /> fire('boolean-me', { - event: 'test_boolean', - scope: 'user', - targetId: user?.id, - params: {}, - inApp: { - type: 'boolean', - positiveCallback: { action: 'test_approve', payload: {} }, - negativeCallback: { action: 'test_deny', payload: {} }, - }, - })} + onClick={send.booleanMe} /> fire('navigate-me', { - event: 'test_navigate', - scope: 'user', - targetId: user?.id, - params: {}, - })} + onClick={send.navigateMe} /> fire('simple-admins', { - event: 'test_simple', - scope: 'admin', - targetId: 0, - params: {}, - })} + onClick={send.simpleAdmins} />
@@ -174,44 +106,19 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement {
selectedTripId && fire('booking_change', { - event: 'booking_change', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) }, - })} + onClick={send.bookingChange} /> selectedTripId && fire('trip_reminder', { - event: 'trip_reminder', - scope: 'trip', - targetId: selectedTripId, - params: { trip: tripTitle, tripId: String(selectedTripId) }, - })} + onClick={send.tripReminder} /> selectedTripId && fire('photos_shared', { - event: 'photos_shared', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) }, - })} + onClick={send.photosShared} /> selectedTripId && fire('collab_message', { - event: 'collab_message', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) }, - })} + onClick={send.collabMessage} /> selectedTripId && fire('packing_tagged', { - event: 'packing_tagged', - scope: 'trip', - targetId: selectedTripId, - params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) }, - })} + onClick={send.packingTagged} />
@@ -236,31 +143,21 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement {
selectedUserId && fire(`trip_invite-${selectedUserId}`, { - event: 'trip_invite', - scope: 'user', - targetId: selectedUserId, - params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) }, - })} + onClick={send.tripInvite} /> selectedUserId && fire(`vacay_invite-${selectedUserId}`, { - event: 'vacay_invite', - scope: 'user', - targetId: selectedUserId, - params: { actor: username, planId: '1' }, - })} + onClick={send.vacayInvite} />
@@ -275,12 +172,7 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement { />
fire('version_available', { - event: 'version_available', - scope: 'admin', - targetId: 0, - params: { version: '9.9.9-test' }, - })} + onClick={send.versionAvailable} />