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 (