diff --git a/client/src/components/Vacay/VacaySharedCalendars.tsx b/client/src/components/Vacay/VacaySharedCalendars.tsx index 2dc75a02b..eed85b01c 100644 --- a/client/src/components/Vacay/VacaySharedCalendars.tsx +++ b/client/src/components/Vacay/VacaySharedCalendars.tsx @@ -2,12 +2,11 @@ import { useId, useState } from 'react' import { Share2, Eye, EyeOff, Loader2, X } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' 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 { useVacayShareActions } from './useVacayShareActions' +import { useVacayUserPicker } from './useVacayUserPicker' /** * Sidebar card for read-only calendar sharing (#444/#667). Deliberately separate @@ -16,45 +15,27 @@ import VacayBadge from './VacayBadge' */ export default function VacaySharedCalendars() { const { t } = useTranslation() - const toast = useToast() - const { outgoingShares, incomingShares, shareWith, removeShare, setShareHidden } = useVacayStore() + const { outgoingShares, incomingShares, shareWith } = useVacayStore() const [showShare, setShowShare] = useState(false) - const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([]) - const [selectedUser, setSelectedUser] = useState(null) - const [sharing, setSharing] = useState(false) + const { + available: availableUsers, selected: selectedUser, setSelected: setSelectedUser, + sending: sharing, load: loadAvailable, send, + } = useVacayUserPicker({ + endpoint: '/addons/vacay/shares/available-users', + submit: shareWith, + successKey: 'vacay.shareSent', + errorKey: 'vacay.shareFailed', + clearOnLoadError: false, + }) const shareLabelId = useId() - const loadAvailable = async () => { - try { - const data = await apiClient.get('/addons/vacay/shares/available-users').then(r => r.data) - setAvailableUsers(data.users) - } catch { /* */ } - } + const handleShare = () => send(() => { + setShowShare(false) + setSelectedUser(null) + }) - const handleShare = async () => { - if (!selectedUser) return - setSharing(true) - try { - await shareWith(selectedUser) - toast.success(t('vacay.shareSent')) - setShowShare(false) - setSelectedUser(null) - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))) - } finally { - setSharing(false) - } - } - - // The optimistic hide and the removals reject on server errors — surface them - // instead of leaving an unhandled rejection behind a silently reverted toggle. - const handleToggleHidden = (id: number, hidden: boolean) => { - setShareHidden(id, hidden).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))) - } - const handleRemove = (id: number) => { - removeShare(id).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))) - } + const { toggleHidden: handleToggleHidden, remove: handleRemove } = useVacayShareActions() const closeShare = () => setShowShare(false) diff --git a/client/src/components/Vacay/useVacayShareActions.test.ts b/client/src/components/Vacay/useVacayShareActions.test.ts new file mode 100644 index 000000000..c51e347d5 --- /dev/null +++ b/client/src/components/Vacay/useVacayShareActions.test.ts @@ -0,0 +1,45 @@ +// FE-COMP-VACAYSHARE-001 to -003: the calendar share row actions both views share. +import { renderHook, waitFor } from '@testing-library/react'; + +import { useVacayStore } from '../../store/vacayStore'; +import { useVacayShareActions } from './useVacayShareActions'; + +const toast = { success: vi.fn(), error: vi.fn() }; +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +const setShareHidden = vi.fn<(shareId: number, hidden: boolean) => Promise>(); +const removeShare = vi.fn<(shareId: number) => Promise>(); + +beforeEach(() => { + toast.error.mockReset(); + setShareHidden.mockReset().mockResolvedValue(undefined); + removeShare.mockReset().mockResolvedValue(undefined); + useVacayStore.setState({ setShareHidden, removeShare }); +}); + +describe('useVacayShareActions', () => { + it('FE-COMP-VACAYSHARE-001: toggles and removes through the store', async () => { + const { result } = renderHook(() => useVacayShareActions()); + result.current.toggleHidden(4, true); + result.current.remove(5); + expect(setShareHidden).toHaveBeenCalledWith(4, true); + expect(removeShare).toHaveBeenCalledWith(5); + await Promise.resolve(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-COMP-VACAYSHARE-002: a rejected toggle toasts the server error', async () => { + setShareHidden.mockRejectedValue({ response: { data: { error: 'Share not found' } } }); + const { result } = renderHook(() => useVacayShareActions()); + result.current.toggleHidden(4, false); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Share not found')); + }); + + it('FE-COMP-VACAYSHARE-003: a rejected removal falls back to the generic share error', async () => { + removeShare.mockRejectedValue('boom'); + const { result } = renderHook(() => useVacayShareActions()); + result.current.remove(5); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('vacay.shareFailed')); + }); +}); diff --git a/client/src/components/Vacay/useVacayShareActions.ts b/client/src/components/Vacay/useVacayShareActions.ts new file mode 100644 index 000000000..7ea47686a --- /dev/null +++ b/client/src/components/Vacay/useVacayShareActions.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; + +import { useTranslation } from '../../i18n'; +import { useVacayStore } from '../../store/vacayStore'; +import { getApiErrorMessage } from '../../types'; +import { useToast } from '../shared/Toast'; + +/** + * The read-only calendar share rows' actions. The optimistic hide and the removals + * reject on server errors: surface them instead of leaving an unhandled rejection + * behind a silently reverted toggle. + */ +export function useVacayShareActions() { + const { t } = useTranslation(); + const toast = useToast(); + const setShareHidden = useVacayStore((s) => s.setShareHidden); + const removeShare = useVacayStore((s) => s.removeShare); + + const toggleHidden = useCallback( + (shareId: number, hidden: boolean) => { + setShareHidden(shareId, hidden).catch((err: unknown) => + toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))) + ); + }, + [setShareHidden, toast, t] + ); + + const remove = useCallback( + (shareId: number) => { + removeShare(shareId).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))); + }, + [removeShare, toast, t] + ); + + return { toggleHidden, remove }; +} diff --git a/client/src/components/Vacay/useVacayUserPicker.test.ts b/client/src/components/Vacay/useVacayUserPicker.test.ts index f53b89d2c..2bffd6103 100644 --- a/client/src/components/Vacay/useVacayUserPicker.test.ts +++ b/client/src/components/Vacay/useVacayUserPicker.test.ts @@ -1,4 +1,4 @@ -// FE-COMP-VACAYPICK-001 to -006: picking a user for the fusion invite and the calendar share. +// FE-COMP-VACAYPICK-001 to -009: picking a user for the fusion invite and the calendar share. import { act, renderHook } from '@testing-library/react'; import apiClient from '../../api/client'; @@ -93,4 +93,53 @@ describe('useVacayUserPicker', () => { expect(onSent).not.toHaveBeenCalled(); expect(result.current.sending).toBe(false); }); + + it('FE-COMP-VACAYPICK-007: the desktop dialogs (no sheetOpen) never load on their own', () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { users: USERS } }); + picker(false); + expect(get).not.toHaveBeenCalled(); + }); + + it('FE-COMP-VACAYPICK-008: every opening of a phone sheet clears the pick, folds the list and reloads', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { users: USERS } }); + const { result, rerender } = renderHook( + ({ open }: { open: boolean }) => + useVacayUserPicker<{ id: number; username: string }>({ + endpoint: '/addons/vacay/available-users', + submit, + successKey: 'vacay.inviteSent', + errorKey: 'vacay.inviteError', + clearOnLoadError: true, + sheetOpen: open, + }), + { initialProps: { open: false } } + ); + expect(get).not.toHaveBeenCalled(); + await act(async () => rerender({ open: true })); + expect(get).toHaveBeenCalledTimes(1); + expect(result.current.available).toEqual(USERS); + act(() => result.current.togglePicker()); + act(() => result.current.setSelected(2)); + expect(result.current.pickerOpen).toBe(true); + await act(async () => rerender({ open: false })); + expect(get).toHaveBeenCalledTimes(1); + expect(result.current.selected).toBe(2); + await act(async () => rerender({ open: true })); + expect(get).toHaveBeenCalledTimes(2); + expect(result.current.selected).toBeNull(); + expect(result.current.pickerOpen).toBe(false); + }); + + it('FE-COMP-VACAYPICK-009: toggling unfolds the inline list and picking a user folds it again', () => { + const { result } = picker(true); + expect(result.current.pickerOpen).toBe(false); + act(() => result.current.togglePicker()); + expect(result.current.pickerOpen).toBe(true); + act(() => result.current.pick(3)); + expect(result.current.selected).toBe(3); + expect(result.current.pickerOpen).toBe(false); + act(() => result.current.togglePicker()); + act(() => result.current.togglePicker()); + expect(result.current.pickerOpen).toBe(false); + }); }); diff --git a/client/src/components/Vacay/useVacayUserPicker.ts b/client/src/components/Vacay/useVacayUserPicker.ts index 03a9df1f3..fed1b2642 100644 --- a/client/src/components/Vacay/useVacayUserPicker.ts +++ b/client/src/components/Vacay/useVacayUserPicker.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import apiClient from '../../api/client'; import { useTranslation } from '../../i18n'; @@ -17,6 +17,12 @@ interface VacayUserPickerOptions { * from the last load stays. */ clearOnLoadError: boolean; + /** + * The phone sheets pass their open flag: every opening clears the pick, folds + * the inline user list and reloads the users. The desktop dialogs leave it out + * and load on their own. + */ + sheetOpen?: boolean; } /** @@ -29,12 +35,15 @@ export function useVacayUserPicker({ successKey, errorKey, clearOnLoadError, + sheetOpen, }: VacayUserPickerOptions) { const { t } = useTranslation(); const toast = useToast(); const [available, setAvailable] = useState([]); const [selected, setSelected] = useState(null); const [sending, setSending] = useState(false); + /** Whether the inline user list of a phone sheet is unfolded. */ + const [pickerOpen, setPickerOpen] = useState(false); const load = useCallback(async () => { try { @@ -45,6 +54,21 @@ export function useVacayUserPicker({ } }, [endpoint, clearOnLoadError]); + useEffect(() => { + if (!sheetOpen) return; + setSelected(null); + setPickerOpen(false); + void load(); + }, [sheetOpen, load]); + + const togglePicker = () => setPickerOpen((o) => !o); + + /** Picks a user from the inline list and folds it. */ + const pick = (userId: number) => { + setSelected(userId); + setPickerOpen(false); + }; + /** Sends to the picked user; `onSent` runs right after the success toast. */ const send = async (onSent: () => void) => { if (!selected) return; @@ -62,5 +86,16 @@ export function useVacayUserPicker({ const selectedUser = available.find((u) => u.id === selected); - return { available, selected, setSelected, selectedUser, sending, load, send }; + return { + available, + selected, + setSelected, + selectedUser, + sending, + load, + send, + pickerOpen, + togglePicker, + pick, + }; } diff --git a/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx b/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx index 533c0f7bd..251f9f2b7 100644 --- a/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx +++ b/client/src/mobile/screens/vacay/MVacayInviteSheet.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react' import { ChevronDown, Clock, Loader2, X } from 'lucide-react' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' @@ -20,21 +19,14 @@ interface MVacayInviteSheetProps { export default function MVacayInviteSheet({ open, onClose }: MVacayInviteSheetProps) { const { t } = useTranslation() const { invite, pendingInvites, cancelInvite } = useVacayStore() - const { available, selected, setSelected, selectedUser, sending, load, send } = useVacayUserPicker({ + const { available, selected, selectedUser, sending, send, pickerOpen, togglePicker, pick } = useVacayUserPicker({ endpoint: '/addons/vacay/available-users', submit: invite, successKey: 'vacay.inviteSent', errorKey: 'vacay.inviteError', clearOnLoadError: true, + sheetOpen: open, }) - const [pickerOpen, setPickerOpen] = useState(false) - - useEffect(() => { - if (!open) return - setSelected(null) - setPickerOpen(false) - void load() - }, [open, setSelected, load]) const handleSend = () => send(onClose) @@ -59,7 +51,7 @@ export default function MVacayInviteSheet({ open, onClose }: MVacayInviteSheetPr <>