From e4179dbc11a249164eef2960ed0c8839f0bd0f9f Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:30:46 +0200 Subject: [PATCH] refactor(vacay): share the calendar sharing picker and row actions between desktop and phone The read only share reuses useVacayUserPicker for picking and sending, and useVacayShareActions holds the hide toggle and the removal with their error toasts for the sidebar card, the phone sheet and the phone chips. --- .../components/Vacay/VacaySharedCalendars.tsx | 55 ++++++----------- .../Vacay/useVacayShareActions.test.ts | 45 ++++++++++++++ .../components/Vacay/useVacayShareActions.ts | 36 +++++++++++ .../Vacay/useVacayUserPicker.test.ts | 51 +++++++++++++++- .../components/Vacay/useVacayUserPicker.ts | 39 +++++++++++- .../screens/vacay/MVacayInviteSheet.tsx | 16 ++--- .../mobile/screens/vacay/MVacayShareSheet.tsx | 59 ++++++------------- client/src/mobile/screens/vacay/useMVacay.ts | 12 ++-- 8 files changed, 212 insertions(+), 101 deletions(-) create mode 100644 client/src/components/Vacay/useVacayShareActions.test.ts create mode 100644 client/src/components/Vacay/useVacayShareActions.ts 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 <>