From fd7f6f3cf9bcfefb28a59cd9731518e841eeebcd Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:27:10 +0200 Subject: [PATCH] refactor(vacay): share the fusion invite picker between desktop and phone Loading the users that can be invited, the picked user and sending the invite with its toasts now live in useVacayUserPicker. The dialog still resets and closes on success, the sheet still closes and resets on the next open and empties the list when loading fails. --- client/src/components/Vacay/VacayPersons.tsx | 45 ++++----- .../Vacay/useVacayUserPicker.test.ts | 96 +++++++++++++++++++ .../components/Vacay/useVacayUserPicker.ts | 66 +++++++++++++ .../screens/vacay/MVacayInviteSheet.tsx | 38 +++----- 4 files changed, 190 insertions(+), 55 deletions(-) create mode 100644 client/src/components/Vacay/useVacayUserPicker.test.ts create mode 100644 client/src/components/Vacay/useVacayUserPicker.ts diff --git a/client/src/components/Vacay/VacayPersons.tsx b/client/src/components/Vacay/VacayPersons.tsx index e7719285c..d78c41aac 100644 --- a/client/src/components/Vacay/VacayPersons.tsx +++ b/client/src/components/Vacay/VacayPersons.tsx @@ -3,13 +3,11 @@ import { UserPlus, Check, Loader2, Clock, Palette } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' -import { getApiErrorMessage } from '../../types' -import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' -import apiClient from '../../api/client' import VacayBadge from './VacayBadge' import { useDefaultVacayPerson } from './useVacayCalendarLogic' +import { useVacayUserPicker } from './useVacayUserPicker' const PRESET_COLORS = [ '#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', @@ -19,7 +17,6 @@ const PRESET_COLORS = [ export default function VacayPersons() { const { t } = useTranslation() - const toast = useToast() const { users, pendingInvites, invite, cancelInvite, updateColor, selectedUserId, setSelectedUserId, isFused } = useVacayStore() const { user: currentUser } = useAuthStore() @@ -28,33 +25,23 @@ export default function VacayPersons() { const [showInvite, setShowInvite] = useState(false) const [showColorPicker, setShowColorPicker] = useState(false) const [colorEditUserId, setColorEditUserId] = useState(null) - const [availableUsers, setAvailableUsers] = useState([]) - const [selectedInviteUser, setSelectedInviteUser] = useState(null) - const [inviting, setInviting] = useState(false) + const { + available: availableUsers, selected: selectedInviteUser, setSelected: setSelectedInviteUser, + sending: inviting, load: loadAvailable, send, + } = useVacayUserPicker<{ id: number; username: string; email: string }>({ + endpoint: '/addons/vacay/available-users', + submit: invite, + successKey: 'vacay.inviteSent', + errorKey: 'vacay.inviteError', + clearOnLoadError: false, + }) const inviteLabelId = useId() const colorLabelId = useId() - const loadAvailable = async () => { - try { - const data = await apiClient.get('/addons/vacay/available-users').then(r => r.data) - setAvailableUsers(data.users) - } catch { /* */ } - } - - const handleInvite = async () => { - if (!selectedInviteUser) return - setInviting(true) - try { - await invite(selectedInviteUser) - toast.success(t('vacay.inviteSent')) - setShowInvite(false) - setSelectedInviteUser(null) - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, t('vacay.inviteError'))) - } finally { - setInviting(false) - } - } + const handleInvite = () => send(() => { + setShowInvite(false) + setSelectedInviteUser(null) + }) const handleColorChange = async (color: string) => { await updateColor(color, colorEditUserId) @@ -173,7 +160,7 @@ export default function VacayPersons() { ) : ( setSelectedInviteUser(Number(v))} options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))} placeholder={t('vacay.selectUser')} searchable diff --git a/client/src/components/Vacay/useVacayUserPicker.test.ts b/client/src/components/Vacay/useVacayUserPicker.test.ts new file mode 100644 index 000000000..f53b89d2c --- /dev/null +++ b/client/src/components/Vacay/useVacayUserPicker.test.ts @@ -0,0 +1,96 @@ +// FE-COMP-VACAYPICK-001 to -006: picking a user for the fusion invite and the calendar share. +import { act, renderHook } from '@testing-library/react'; + +import apiClient from '../../api/client'; +import { useVacayUserPicker } from './useVacayUserPicker'; + +const toast = { success: vi.fn(), error: vi.fn() }; +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +const USERS = [ + { id: 2, username: 'bob' }, + { id: 3, username: 'carol' }, +]; + +const submit = vi.fn<(userId: number) => Promise>(); + +function picker(clearOnLoadError: boolean) { + return renderHook(() => + useVacayUserPicker<{ id: number; username: string }>({ + endpoint: '/addons/vacay/shares/available-users', + submit, + successKey: 'vacay.shareSent', + errorKey: 'vacay.shareFailed', + clearOnLoadError, + }) + ); +} + +beforeEach(() => { + submit.mockReset().mockResolvedValue(undefined); + toast.success.mockReset(); + toast.error.mockReset(); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('useVacayUserPicker', () => { + it('FE-COMP-VACAYPICK-001: loads the users from the endpoint and resolves the picked one', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { users: USERS } }); + const { result } = picker(false); + await act(() => result.current.load()); + expect(get).toHaveBeenCalledWith('/addons/vacay/shares/available-users'); + expect(result.current.available).toEqual(USERS); + act(() => result.current.setSelected(3)); + expect(result.current.selectedUser).toEqual(USERS[1]); + }); + + it('FE-COMP-VACAYPICK-002: a failed load keeps the last list on desktop', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValueOnce({ data: { users: USERS } }); + const { result } = picker(false); + await act(() => result.current.load()); + get.mockRejectedValueOnce(new Error('down')); + await act(() => result.current.load()); + expect(result.current.available).toEqual(USERS); + }); + + it('FE-COMP-VACAYPICK-003: a failed load empties the list on the phone', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValueOnce({ data: { users: USERS } }); + const { result } = picker(true); + await act(() => result.current.load()); + get.mockRejectedValueOnce(new Error('down')); + await act(() => result.current.load()); + expect(result.current.available).toEqual([]); + }); + + it('FE-COMP-VACAYPICK-004: sending without a pick does nothing', async () => { + const onSent = vi.fn(); + const { result } = picker(false); + await act(() => result.current.send(onSent)); + expect(submit).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + + it('FE-COMP-VACAYPICK-005: a sent pick toasts success, then runs onSent', async () => { + const order: string[] = []; + toast.success.mockImplementation(() => order.push('toast')); + const { result } = picker(false); + act(() => result.current.setSelected(2)); + await act(() => result.current.send(() => order.push('sent'))); + expect(submit).toHaveBeenCalledWith(2); + expect(toast.success).toHaveBeenCalledWith('vacay.shareSent'); + expect(order).toEqual(['toast', 'sent']); + expect(result.current.sending).toBe(false); + }); + + it('FE-COMP-VACAYPICK-006: a failed send toasts the server error and skips onSent', async () => { + submit.mockRejectedValue(new Error('already shared')); + const onSent = vi.fn(); + const { result } = picker(false); + act(() => result.current.setSelected(2)); + await act(() => result.current.send(onSent)); + expect(toast.error).toHaveBeenCalledWith('already shared'); + expect(onSent).not.toHaveBeenCalled(); + expect(result.current.sending).toBe(false); + }); +}); diff --git a/client/src/components/Vacay/useVacayUserPicker.ts b/client/src/components/Vacay/useVacayUserPicker.ts new file mode 100644 index 000000000..03a9df1f3 --- /dev/null +++ b/client/src/components/Vacay/useVacayUserPicker.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react'; + +import apiClient from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { getApiErrorMessage } from '../../types'; +import { useToast } from '../shared/Toast'; + +interface VacayUserPickerOptions { + /** Where the users that can be picked come from. */ + endpoint: '/addons/vacay/available-users' | '/addons/vacay/shares/available-users'; + /** What sending does with the picked user. */ + submit: (userId: number) => Promise; + successKey: string; + errorKey: string; + /** + * Empty the list when loading it fails (the phone sheets). Otherwise the list + * from the last load stays. + */ + clearOnLoadError: boolean; +} + +/** + * Picking another TREK user for a vacay action and sending it: the fusion invite + * and the read-only calendar share. Each view keeps its own dialog or sheet. + */ +export function useVacayUserPicker({ + endpoint, + submit, + successKey, + errorKey, + clearOnLoadError, +}: VacayUserPickerOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const [available, setAvailable] = useState([]); + const [selected, setSelected] = useState(null); + const [sending, setSending] = useState(false); + + const load = useCallback(async () => { + try { + const data = await apiClient.get(endpoint).then((r) => r.data); + setAvailable(data.users); + } catch { + if (clearOnLoadError) setAvailable([]); + } + }, [endpoint, clearOnLoadError]); + + /** Sends to the picked user; `onSent` runs right after the success toast. */ + const send = async (onSent: () => void) => { + if (!selected) return; + setSending(true); + try { + await submit(selected); + toast.success(t(successKey)); + onSent(); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t(errorKey))); + } finally { + setSending(false); + } + }; + + const selectedUser = available.find((u) => u.id === selected); + + return { available, selected, setSelected, selectedUser, sending, load, send }; +} diff --git a/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx b/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx index 07a39f3cd..533c0f7bd 100644 --- a/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx +++ b/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx @@ -4,9 +4,8 @@ import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' import { useVacayStore } from '../../../store/vacayStore' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' -import { getApiErrorMessage, type VacayUser } from '../../../types' -import apiClient from '../../../api/client' +import { type VacayUser } from '../../../types' +import { useVacayUserPicker } from '../../../components/Vacay/useVacayUserPicker' interface MVacayInviteSheetProps { open: boolean @@ -20,37 +19,24 @@ interface MVacayInviteSheetProps { */ export default function MVacayInviteSheet({ open, onClose }: MVacayInviteSheetProps) { const { t } = useTranslation() - const toast = useToast() const { invite, pendingInvites, cancelInvite } = useVacayStore() - const [available, setAvailable] = useState([]) - const [selected, setSelected] = useState(null) + const { available, selected, setSelected, selectedUser, sending, load, send } = useVacayUserPicker({ + endpoint: '/addons/vacay/available-users', + submit: invite, + successKey: 'vacay.inviteSent', + errorKey: 'vacay.inviteError', + clearOnLoadError: true, + }) const [pickerOpen, setPickerOpen] = useState(false) - const [sending, setSending] = useState(false) useEffect(() => { if (!open) return setSelected(null) setPickerOpen(false) - apiClient.get('/addons/vacay/available-users') - .then(r => setAvailable(r.data.users)) - .catch(() => setAvailable([])) - }, [open]) + void load() + }, [open, setSelected, load]) - const selectedUser = available.find(u => u.id === selected) - - const handleSend = async () => { - if (!selected) return - setSending(true) - try { - await invite(selected) - toast.success(t('vacay.inviteSent')) - onClose() - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, t('vacay.inviteError'))) - } finally { - setSending(false) - } - } + const handleSend = () => send(onClose) return (