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.
This commit is contained in:
Maurice
2026-10-09 10:30:46 +02:00
parent fd7f6f3cf9
commit e4179dbc11
8 changed files with 212 additions and 101 deletions
@@ -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<number | null>(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)
@@ -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<void>>();
const removeShare = vi.fn<(shareId: number) => Promise<void>>();
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'));
});
});
@@ -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 };
}
@@ -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);
});
});
@@ -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<U extends { id: number; username: string }>({
successKey,
errorKey,
clearOnLoadError,
sheetOpen,
}: VacayUserPickerOptions) {
const { t } = useTranslation();
const toast = useToast();
const [available, setAvailable] = useState<U[]>([]);
const [selected, setSelected] = useState<number | null>(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<U extends { id: number; username: string }>({
}
}, [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<U extends { id: number; username: string }>({
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,
};
}
@@ -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<VacayUser>({
const { available, selected, selectedUser, sending, send, pickerOpen, togglePicker, pick } = useVacayUserPicker<VacayUser>({
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
<>
<button
type="button"
onClick={() => setPickerOpen(o => !o)}
onClick={togglePicker}
className="flex w-full items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3 text-[0.8125rem] font-semibold"
>
<span className={`min-w-0 flex-1 truncate text-start ${selectedUser ? '' : 'text-m-muted'}`}>
@@ -73,7 +65,7 @@ export default function MVacayInviteSheet({ open, onClose }: MVacayInviteSheetPr
<button
key={u.id}
type="button"
onClick={() => { setSelected(u.id); setPickerOpen(false) }}
onClick={() => pick(u.id)}
className={`flex w-full items-center gap-[9px] rounded-[10px] px-[10px] py-[9px] text-start text-[0.8125rem] font-semibold ${
u.id === selected ? 'bg-[color:var(--m-ic)]' : ''
}`}
@@ -1,12 +1,10 @@
import { useEffect, useState } from 'react'
import { ChevronDown, Eye, EyeOff, Loader2, Share2, X } from 'lucide-react'
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 } from '../../../types'
import apiClient from '../../../api/client'
import { useVacayShareActions } from '../../../components/Vacay/useVacayShareActions'
import { useVacayUserPicker } from '../../../components/Vacay/useVacayUserPicker'
interface MVacayShareSheetProps {
open: boolean
@@ -20,39 +18,18 @@ interface MVacayShareSheetProps {
*/
export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProps) {
const { t } = useTranslation()
const toast = useToast()
const { incomingShares, outgoingShares, shareWith, removeShare, setShareHidden } = useVacayStore()
const [available, setAvailable] = useState<{ id: number; username: string }[]>([])
const [selected, setSelected] = useState<number | null>(null)
const [pickerOpen, setPickerOpen] = useState(false)
const [sending, setSending] = useState(false)
const { incomingShares, outgoingShares, shareWith } = useVacayStore()
const { available, selected, setSelected, selectedUser, sending, send, pickerOpen, togglePicker, pick } = useVacayUserPicker({
endpoint: '/addons/vacay/shares/available-users',
submit: shareWith,
successKey: 'vacay.shareSent',
errorKey: 'vacay.shareFailed',
clearOnLoadError: true,
sheetOpen: open,
})
const { toggleHidden, remove } = useVacayShareActions()
const showError = (err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))
useEffect(() => {
if (!open) return
setSelected(null)
setPickerOpen(false)
apiClient.get('/addons/vacay/shares/available-users')
.then(r => setAvailable(r.data.users))
.catch(() => setAvailable([]))
}, [open])
const selectedUser = available.find(u => u.id === selected)
const handleShare = async () => {
if (!selected) return
setSending(true)
try {
await shareWith(selected)
toast.success(t('vacay.shareSent'))
setSelected(null)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))
} finally {
setSending(false)
}
}
const handleShare = () => send(() => setSelected(null))
return (
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('vacay.sharedCalendars')}>
@@ -76,7 +53,7 @@ export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProp
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPickerOpen(o => !o)}
onClick={togglePicker}
className="flex min-w-0 flex-1 items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3 text-[0.8125rem] font-semibold"
>
<span className={`min-w-0 flex-1 truncate text-start ${selectedUser ? '' : 'text-m-muted'}`}>
@@ -100,7 +77,7 @@ export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProp
<button
key={u.id}
type="button"
onClick={() => { setSelected(u.id); setPickerOpen(false) }}
onClick={() => pick(u.id)}
className={`flex w-full items-center gap-[9px] rounded-[10px] px-[10px] py-[9px] text-start text-[0.8125rem] font-semibold ${
u.id === selected ? 'bg-[color:var(--m-ic)]' : ''
}`}
@@ -126,7 +103,7 @@ export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProp
</span>
<button
type="button"
onClick={() => setShareHidden(s.id, !s.hidden).catch(showError)}
onClick={() => toggleHidden(s.id, !s.hidden)}
aria-label={s.hidden ? t('vacay.showInCalendar') : t('vacay.hideFromCalendar')}
className="flex h-[28px] w-[28px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)]"
>
@@ -136,7 +113,7 @@ export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProp
</button>
<button
type="button"
onClick={() => removeShare(s.id).catch(showError)}
onClick={() => remove(s.id)}
className="flex-none rounded-full px-[8px] py-1 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
{t('vacay.remove')}
@@ -157,7 +134,7 @@ export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProp
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{s.username}</span>
<button
type="button"
onClick={() => removeShare(s.id).catch(showError)}
onClick={() => remove(s.id)}
className="flex-none rounded-full px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
{t('vacay.stopSharing')}
+4 -8
View File
@@ -4,10 +4,9 @@ import { useVacay } from '../../../pages/vacay/useVacay'
import { useVacayStore } from '../../../store/vacayStore'
import { useAuthStore } from '../../../store/authStore'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import { currentPeriodYear, windowMonths } from '../../../vacay/yearWindow'
import { FALLBACK_PERSON_COLOR, localDateStr, type DayVisualContext } from './vacayDayModel'
import { getApiErrorMessage } from '../../../types'
import { useVacayShareActions } from '../../../components/Vacay/useVacayShareActions'
import {
useDefaultVacayPerson, useVacayCalendarLogic, vacayWindowShape, type VacayMode,
} from '../../../components/Vacay/useVacayCalendarLogic'
@@ -23,8 +22,7 @@ export type MVacaySheet = 'invite' | 'settings' | 'share' | null
* mode, sheets) plus the derived per-day render context.
*/
export function useMVacay() {
const { t, locale } = useTranslation()
const toast = useToast()
const { locale } = useTranslation()
const navigate = useNavigate()
const {
years, selectedYear, setSelectedYear, loading,
@@ -35,7 +33,7 @@ export function useMVacay() {
stats, users, holidays,
selectedUserId, setSelectedUserId, isFused,
updateVacationDays,
incomingShares, setShareHidden, yearSettings,
incomingShares, yearSettings,
} = useVacayStore()
const currentUser = useAuthStore(s => s.user)
@@ -103,9 +101,7 @@ export function useMVacay() {
// Shared-chip tap: the optimistic hide toggle rolls back on server errors —
// tell the user instead of letting the chip snap back silently.
const toggleShareHidden = useCallback((shareId: number, hidden: boolean) => {
setShareHidden(shareId, hidden).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))))
}, [setShareHidden, toast, t])
const { toggleHidden: toggleShareHidden } = useVacayShareActions()
// Fusion: logging for each other — any fused member is selectable.
const selectPerson = useCallback((id: number) => {