mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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() {
|
||||
) : (
|
||||
<CustomSelect
|
||||
value={selectedInviteUser}
|
||||
onChange={setSelectedInviteUser}
|
||||
onChange={v => setSelectedInviteUser(Number(v))}
|
||||
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
|
||||
placeholder={t('vacay.selectUser')}
|
||||
searchable
|
||||
|
||||
@@ -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<void>>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
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<U extends { id: number; username: string }>({
|
||||
endpoint,
|
||||
submit,
|
||||
successKey,
|
||||
errorKey,
|
||||
clearOnLoadError,
|
||||
}: VacayUserPickerOptions) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [available, setAvailable] = useState<U[]>([]);
|
||||
const [selected, setSelected] = useState<number | null>(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 };
|
||||
}
|
||||
@@ -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<VacayUser[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const { available, selected, setSelected, selectedUser, sending, load, send } = useVacayUserPicker<VacayUser>({
|
||||
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 (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('vacay.inviteUser')}>
|
||||
|
||||
Reference in New Issue
Block a user