mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collections): share the list sharing logic between desktop and phone
useCollectionSharing holds the invitable users lookup, the roster order, invite, role, cancel, remove and leave for both the share dialog and the share sheet. The dialog keeps one busy member per kind of row action, the sheet one for all.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
import React, { useId, type ReactNode } from 'react'
|
||||||
import { avatarSrc } from '../../utils/avatarSrc'
|
import { avatarSrc } from '../../utils/avatarSrc'
|
||||||
import { UserPlus, UserMinus, UserX, Loader2, Clock, Crown, LogOut, Share2 } from 'lucide-react'
|
import { UserPlus, UserMinus, UserX, Loader2, Clock, Crown, LogOut, Share2 } from 'lucide-react'
|
||||||
import type { CollectionMember, CollectionRole } from '@trek/shared'
|
import type { CollectionMember, CollectionRole } from '@trek/shared'
|
||||||
@@ -6,13 +6,9 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
|
|||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { TripMemberAvatar } from '../Trips/TripMemberAvatar'
|
import { TripMemberAvatar } from '../Trips/TripMemberAvatar'
|
||||||
import { useCollectionStore } from '../../store/collectionStore'
|
|
||||||
import { useAuthStore } from '../../store/authStore'
|
|
||||||
import { collectionsApi } from '../../api/collections'
|
|
||||||
import { getApiErrorMessage } from '../../utils/apiError'
|
|
||||||
import type { TranslationFn } from '../../types'
|
import type { TranslationFn } from '../../types'
|
||||||
|
import { useCollectionSharing } from './useCollectionSharing'
|
||||||
|
|
||||||
interface ShareCollectionModalProps {
|
interface ShareCollectionModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -64,114 +60,17 @@ export default function ShareCollectionModal({
|
|||||||
onAfterLeave,
|
onAfterLeave,
|
||||||
t,
|
t,
|
||||||
}: ShareCollectionModalProps): React.ReactElement | null {
|
}: ShareCollectionModalProps): React.ReactElement | null {
|
||||||
const toast = useToast()
|
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
const currentUserId = useAuthStore(s => s.user?.id)
|
const {
|
||||||
const invite = useCollectionStore(s => s.invite)
|
currentUserId, availableUsers, sortedMembers, selectedUserId, setSelectedUserId, inviteRole, setInviteRole,
|
||||||
const cancelInvite = useCollectionStore(s => s.cancelInvite)
|
inviting, confirmLeave, setConfirmLeave, leaving, busyUserId, setRole, cancel, remove, handleInvite, handleLeave,
|
||||||
const removeMember = useCollectionStore(s => s.removeMember)
|
} = useCollectionSharing({ open: isOpen, collectionId, isOwner, members, onAfterLeave, t })
|
||||||
const setMemberRole = useCollectionStore(s => s.setMemberRole)
|
const settingRoleId = busyUserId('role')
|
||||||
const leave = useCollectionStore(s => s.leave)
|
const cancellingId = busyUserId('cancel')
|
||||||
|
const removingId = busyUserId('remove')
|
||||||
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
|
|
||||||
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor')
|
|
||||||
const [settingRoleId, setSettingRoleId] = useState<number | null>(null)
|
|
||||||
const [inviting, setInviting] = useState(false)
|
|
||||||
const [cancellingId, setCancellingId] = useState<number | null>(null)
|
|
||||||
const [removingId, setRemovingId] = useState<number | null>(null)
|
|
||||||
const [confirmLeave, setConfirmLeave] = useState(false)
|
|
||||||
const [leaving, setLeaving] = useState(false)
|
|
||||||
|
|
||||||
// Load the invitable users whenever an owner opens the modal.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !isOwner) return
|
|
||||||
let cancelled = false
|
|
||||||
collectionsApi.availableUsers(collectionId)
|
|
||||||
.then(data => { if (!cancelled) setAvailableUsers(data.users) })
|
|
||||||
.catch(() => { if (!cancelled) setAvailableUsers([]) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [isOpen, isOwner, collectionId, members.length])
|
|
||||||
|
|
||||||
// Reset transient state on close.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setSelectedUserId('')
|
|
||||||
setConfirmLeave(false)
|
|
||||||
}
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const sortedMembers = useMemo(() => {
|
|
||||||
// Owner first, then accepted, then pending — alphabetised within each band.
|
|
||||||
const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2)
|
|
||||||
return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username))
|
|
||||||
}, [members])
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
const handleInvite = async () => {
|
|
||||||
if (selectedUserId === '' || inviting) return
|
|
||||||
setInviting(true)
|
|
||||||
try {
|
|
||||||
await invite(collectionId, Number(selectedUserId), inviteRole)
|
|
||||||
toast.success(t('collections.invite.sent'))
|
|
||||||
setSelectedUserId('')
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('collections.invite.error')))
|
|
||||||
} finally {
|
|
||||||
setInviting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSetRole = async (userId: number, role: CollectionRole) => {
|
|
||||||
if (settingRoleId != null) return
|
|
||||||
setSettingRoleId(userId)
|
|
||||||
try {
|
|
||||||
await setMemberRole(collectionId, userId, role)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setSettingRoleId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = async (userId: number) => {
|
|
||||||
if (cancellingId != null) return
|
|
||||||
setCancellingId(userId)
|
|
||||||
try {
|
|
||||||
await cancelInvite(collectionId, userId)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setCancellingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemove = async (userId: number) => {
|
|
||||||
if (removingId != null) return
|
|
||||||
setRemovingId(userId)
|
|
||||||
try {
|
|
||||||
await removeMember(collectionId, userId)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setRemovingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLeave = async () => {
|
|
||||||
setLeaving(true)
|
|
||||||
try {
|
|
||||||
await leave(collectionId)
|
|
||||||
toast.success(t('collections.share.left'))
|
|
||||||
onAfterLeave()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setLeaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleOptions = ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))
|
const roleOptions = ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -214,8 +113,8 @@ export default function ShareCollectionModal({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={selectedUserId}
|
value={selectedUserId ?? ''}
|
||||||
onChange={v => setSelectedUserId(v === '' ? '' : Number(v))}
|
onChange={v => setSelectedUserId(v === '' ? null : Number(v))}
|
||||||
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
|
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
|
||||||
placeholder={t('collections.share.inviteUser')}
|
placeholder={t('collections.share.inviteUser')}
|
||||||
searchable
|
searchable
|
||||||
@@ -228,7 +127,7 @@ export default function ShareCollectionModal({
|
|||||||
<DialogButton
|
<DialogButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void handleInvite()}
|
onClick={() => void handleInvite()}
|
||||||
disabled={selectedUserId === '' || inviting}
|
disabled={selectedUserId == null || inviting}
|
||||||
icon={inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} strokeWidth={2.2} />}
|
icon={inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} strokeWidth={2.2} />}
|
||||||
>
|
>
|
||||||
{t('collections.share.sendInvite')}
|
{t('collections.share.sendInvite')}
|
||||||
@@ -259,7 +158,7 @@ export default function ShareCollectionModal({
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
size="sm"
|
size="sm"
|
||||||
value={member.role ?? 'editor'}
|
value={member.role ?? 'editor'}
|
||||||
onChange={v => handleSetRole(member.user_id, v as CollectionRole)}
|
onChange={v => setRole(member.user_id, v as CollectionRole)}
|
||||||
options={roleOptions}
|
options={roleOptions}
|
||||||
disabled={settingRoleId === member.user_id}
|
disabled={settingRoleId === member.user_id}
|
||||||
/>
|
/>
|
||||||
@@ -282,12 +181,12 @@ export default function ShareCollectionModal({
|
|||||||
</div>
|
</div>
|
||||||
{standing}
|
{standing}
|
||||||
{isOwner && pending && (
|
{isOwner && pending && (
|
||||||
<RowAction label={t('collections.share.cancel')} onClick={() => handleCancel(member.user_id)} disabled={cancellingId === member.user_id}>
|
<RowAction label={t('collections.share.cancel')} onClick={() => cancel(member.user_id)} disabled={cancellingId === member.user_id}>
|
||||||
{cancellingId === member.user_id ? <Loader2 size={14} className="animate-spin" /> : <UserX size={15} />}
|
{cancellingId === member.user_id ? <Loader2 size={14} className="animate-spin" /> : <UserX size={15} />}
|
||||||
</RowAction>
|
</RowAction>
|
||||||
)}
|
)}
|
||||||
{isOwner && !member.is_owner && member.status === 'accepted' && (
|
{isOwner && !member.is_owner && member.status === 'accepted' && (
|
||||||
<RowAction label={t('collections.share.remove')} onClick={() => handleRemove(member.user_id)} disabled={removingId === member.user_id}>
|
<RowAction label={t('collections.share.remove')} onClick={() => remove(member.user_id)} disabled={removingId === member.user_id}>
|
||||||
{removingId === member.user_id ? <Loader2 size={14} className="animate-spin" /> : <UserMinus size={15} />}
|
{removingId === member.user_id ? <Loader2 size={14} className="animate-spin" /> : <UserMinus size={15} />}
|
||||||
</RowAction>
|
</RowAction>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// FE-COMP-COLSHAREHOOK-001 to FE-COMP-COLSHAREHOOK-012: the list sharing logic behind
|
||||||
|
// both the desktop dialog and the phone sheet.
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import type { CollectionMember } from '@trek/shared';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest';
|
||||||
|
|
||||||
|
import { buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { collectionsApi } from '../../api/collections';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useCollectionStore } from '../../store/collectionStore';
|
||||||
|
import {
|
||||||
|
nextCollectionRole,
|
||||||
|
sortCollectionMembers,
|
||||||
|
useCollectionSharing,
|
||||||
|
type CollectionSharingOptions,
|
||||||
|
} from './useCollectionSharing';
|
||||||
|
|
||||||
|
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k);
|
||||||
|
|
||||||
|
const OWNER: CollectionMember = { user_id: 1, username: 'maurice', status: 'accepted', is_owner: true };
|
||||||
|
const EDITOR: CollectionMember = { user_id: 2, username: 'julien', status: 'accepted', role: 'editor' };
|
||||||
|
const VIEWER: CollectionMember = { user_id: 4, username: 'ada', status: 'accepted', role: 'viewer' };
|
||||||
|
const PENDING: CollectionMember = { user_id: 3, username: 'zoe', status: 'pending' };
|
||||||
|
|
||||||
|
type CollectionState = ReturnType<typeof useCollectionStore.getState>;
|
||||||
|
const initialCollectionState = useCollectionStore.getState();
|
||||||
|
let actions: {
|
||||||
|
invite: Mock<CollectionState['invite']>;
|
||||||
|
cancelInvite: Mock<CollectionState['cancelInvite']>;
|
||||||
|
removeMember: Mock<CollectionState['removeMember']>;
|
||||||
|
setMemberRole: Mock<CollectionState['setMemberRole']>;
|
||||||
|
leave: Mock<CollectionState['leave']>;
|
||||||
|
};
|
||||||
|
let addToast: Mock<NonNullable<Window['__addToast']>>;
|
||||||
|
let availableUsers: MockInstance<typeof collectionsApi.availableUsers>;
|
||||||
|
|
||||||
|
function setup(over: Partial<CollectionSharingOptions> = {}) {
|
||||||
|
const props: CollectionSharingOptions = {
|
||||||
|
open: true,
|
||||||
|
collectionId: 7,
|
||||||
|
isOwner: true,
|
||||||
|
members: [PENDING, EDITOR, OWNER],
|
||||||
|
onAfterLeave: vi.fn(),
|
||||||
|
t,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
const hook = renderHook((p: CollectionSharingOptions) => useCollectionSharing(p), { initialProps: props });
|
||||||
|
return { ...hook, props };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A promise the test settles by hand, to look at the hook while a write is in flight. */
|
||||||
|
function deferred() {
|
||||||
|
let resolve: () => void = () => {};
|
||||||
|
const promise = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
useCollectionStore.setState(initialCollectionState, true);
|
||||||
|
actions = {
|
||||||
|
invite: vi.fn<CollectionState['invite']>().mockResolvedValue(undefined),
|
||||||
|
cancelInvite: vi.fn<CollectionState['cancelInvite']>().mockResolvedValue(undefined),
|
||||||
|
removeMember: vi.fn<CollectionState['removeMember']>().mockResolvedValue(undefined),
|
||||||
|
setMemberRole: vi.fn<CollectionState['setMemberRole']>().mockResolvedValue(undefined),
|
||||||
|
leave: vi.fn<CollectionState['leave']>().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
useCollectionStore.setState(actions);
|
||||||
|
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'maurice' }) });
|
||||||
|
addToast = vi.fn<NonNullable<Window['__addToast']>>();
|
||||||
|
window.__addToast = addToast;
|
||||||
|
availableUsers = vi
|
||||||
|
.spyOn(collectionsApi, 'availableUsers')
|
||||||
|
.mockResolvedValue({ users: [{ id: 9, username: 'nina' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete window.__addToast;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collection sharing helpers', () => {
|
||||||
|
it('FE-COMP-COLSHAREHOOK-001: sorts owner, then accepted, then pending, by name within a band', () => {
|
||||||
|
expect(sortCollectionMembers([PENDING, EDITOR, VIEWER, OWNER]).map((m) => m.user_id)).toEqual([1, 4, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-002: cycles viewer, editor, admin and back', () => {
|
||||||
|
expect(nextCollectionRole('viewer')).toBe('editor');
|
||||||
|
expect(nextCollectionRole('editor')).toBe('admin');
|
||||||
|
expect(nextCollectionRole('admin')).toBe('viewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCollectionSharing', () => {
|
||||||
|
it('FE-COMP-COLSHAREHOOK-003: loads the invitable users for an owner and reloads when the roster changes size', async () => {
|
||||||
|
const { result, rerender, props } = setup();
|
||||||
|
await waitFor(() => expect(result.current.availableUsers).toEqual([{ id: 9, username: 'nina' }]));
|
||||||
|
expect(availableUsers).toHaveBeenCalledWith(7);
|
||||||
|
expect(result.current.currentUserId).toBe(1);
|
||||||
|
rerender({ ...props, members: [OWNER] });
|
||||||
|
await waitFor(() => expect(availableUsers).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-004: loads nothing for a member, while closed, or without a list', () => {
|
||||||
|
setup({ isOwner: false });
|
||||||
|
setup({ open: false });
|
||||||
|
setup({ collectionId: null });
|
||||||
|
expect(availableUsers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-005: an empty invitee list when the lookup fails', async () => {
|
||||||
|
availableUsers.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
const { result } = setup();
|
||||||
|
await waitFor(() => expect(availableUsers).toHaveBeenCalled());
|
||||||
|
expect(result.current.availableUsers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-006: invites the picked user with the picked role, toasts and clears the pick', async () => {
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleInvite();
|
||||||
|
});
|
||||||
|
expect(actions.invite).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSelectedUserId(9);
|
||||||
|
result.current.setInviteRole('admin');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleInvite();
|
||||||
|
});
|
||||||
|
expect(actions.invite).toHaveBeenCalledWith(7, 9, 'admin');
|
||||||
|
expect(addToast).toHaveBeenCalledWith('collections.invite.sent', 'success', undefined);
|
||||||
|
expect(result.current.selectedUserId).toBeNull();
|
||||||
|
expect(result.current.inviting).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-007: a failed invite keeps the pick and shows the server message', async () => {
|
||||||
|
actions.invite.mockRejectedValueOnce({ response: { data: { error: 'Already invited' } } });
|
||||||
|
const { result } = setup();
|
||||||
|
act(() => result.current.setSelectedUserId(9));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleInvite();
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith('Already invited', 'error', undefined);
|
||||||
|
expect(result.current.selectedUserId).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-008: closing drops the pick and the leave question', () => {
|
||||||
|
const { result, rerender, props } = setup();
|
||||||
|
act(() => {
|
||||||
|
result.current.setSelectedUserId(9);
|
||||||
|
result.current.setConfirmLeave(true);
|
||||||
|
});
|
||||||
|
rerender({ ...props, open: false });
|
||||||
|
expect(result.current.selectedUserId).toBeNull();
|
||||||
|
expect(result.current.confirmLeave).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-009: one busy member per kind of row action on desktop', async () => {
|
||||||
|
const role = deferred();
|
||||||
|
actions.setMemberRole.mockReturnValueOnce(role.promise);
|
||||||
|
const { result } = setup();
|
||||||
|
act(() => {
|
||||||
|
void result.current.setRole(2, 'viewer');
|
||||||
|
});
|
||||||
|
expect(result.current.busyUserId('role')).toBe(2);
|
||||||
|
expect(result.current.busyUserId('cancel')).toBeNull();
|
||||||
|
|
||||||
|
// A cancel on another row runs alongside, a second role change does not.
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.cancel(3);
|
||||||
|
await result.current.setRole(4, 'admin');
|
||||||
|
});
|
||||||
|
expect(actions.cancelInvite).toHaveBeenCalledWith(7, 3);
|
||||||
|
expect(actions.setMemberRole).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => role.resolve());
|
||||||
|
await waitFor(() => expect(result.current.busyUserId('role')).toBeNull());
|
||||||
|
expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'viewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-010: one busy member for every row action on the phone', async () => {
|
||||||
|
const role = deferred();
|
||||||
|
actions.setMemberRole.mockReturnValueOnce(role.promise);
|
||||||
|
const { result } = setup({ singleBusySlot: true });
|
||||||
|
act(() => result.current.cycleRole(EDITOR));
|
||||||
|
expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'admin');
|
||||||
|
expect(result.current.busyUserId('remove')).toBe(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.cancel(3);
|
||||||
|
await result.current.remove(4);
|
||||||
|
});
|
||||||
|
expect(actions.cancelInvite).not.toHaveBeenCalled();
|
||||||
|
expect(actions.removeMember).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => role.resolve());
|
||||||
|
await waitFor(() => expect(result.current.busyUserId('remove')).toBeNull());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.remove(4);
|
||||||
|
});
|
||||||
|
expect(actions.removeMember).toHaveBeenCalledWith(7, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-011: a failed row action toasts the generic error and frees the row', async () => {
|
||||||
|
actions.removeMember.mockRejectedValueOnce(new Error('nope'));
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.remove(2);
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith('common.error', 'error', undefined);
|
||||||
|
expect(result.current.busyUserId('remove')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-012: leaving toasts and hands over, a failure only toasts', async () => {
|
||||||
|
const onAfterLeave = vi.fn();
|
||||||
|
const { result } = setup({ isOwner: false, onAfterLeave });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleLeave();
|
||||||
|
});
|
||||||
|
expect(actions.leave).toHaveBeenCalledWith(7);
|
||||||
|
expect(addToast).toHaveBeenCalledWith('collections.share.left', 'success', undefined);
|
||||||
|
expect(onAfterLeave).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
actions.leave.mockRejectedValueOnce(new Error('nope'));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleLeave();
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenLastCalledWith('common.error', 'error', undefined);
|
||||||
|
expect(onAfterLeave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.leaving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLSHAREHOOK-013: only the phone ignores a second leave while one is in flight', async () => {
|
||||||
|
const first = deferred();
|
||||||
|
actions.leave.mockReturnValueOnce(first.promise);
|
||||||
|
const phone = setup({ isOwner: false, singleBusySlot: true });
|
||||||
|
act(() => {
|
||||||
|
void phone.result.current.handleLeave();
|
||||||
|
});
|
||||||
|
expect(phone.result.current.leaving).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
await phone.result.current.handleLeave();
|
||||||
|
});
|
||||||
|
expect(actions.leave).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => first.resolve());
|
||||||
|
phone.unmount();
|
||||||
|
|
||||||
|
const second = deferred();
|
||||||
|
actions.leave.mockReturnValueOnce(second.promise);
|
||||||
|
const desktop = setup({ isOwner: false });
|
||||||
|
act(() => {
|
||||||
|
void desktop.result.current.handleLeave();
|
||||||
|
});
|
||||||
|
expect(desktop.result.current.leaving).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
await desktop.result.current.handleLeave();
|
||||||
|
});
|
||||||
|
expect(actions.leave).toHaveBeenCalledTimes(3);
|
||||||
|
await act(async () => second.resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { CollectionMember, CollectionRole } from '@trek/shared';
|
||||||
|
import { COLLECTION_ROLES } from '@trek/shared';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { collectionsApi } from '../../api/collections';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useCollectionStore } from '../../store/collectionStore';
|
||||||
|
import type { TranslationFn } from '../../types';
|
||||||
|
import { getApiErrorMessage } from '../../utils/apiError';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
|
||||||
|
/** A per-member write: change the role, cancel a pending invite, remove an accepted member. */
|
||||||
|
export type MemberAction = 'role' | 'cancel' | 'remove';
|
||||||
|
|
||||||
|
export interface CollectionSharingOptions {
|
||||||
|
open: boolean;
|
||||||
|
collectionId: number | null;
|
||||||
|
isOwner: boolean;
|
||||||
|
members: CollectionMember[];
|
||||||
|
/** Called after the current (member) user successfully leaves the list. */
|
||||||
|
onAfterLeave: () => void;
|
||||||
|
t: TranslationFn;
|
||||||
|
/**
|
||||||
|
* One busy member for every kind of row action (the phone sheet), instead of one
|
||||||
|
* busy member per kind (the desktop dialog, where a role change and a cancel on
|
||||||
|
* two different rows can run side by side). The phone sheet also ignores a second
|
||||||
|
* leave while one is in flight.
|
||||||
|
*/
|
||||||
|
singleBusySlot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner first, then accepted, then pending, alphabetised within each band. */
|
||||||
|
export function sortCollectionMembers(members: CollectionMember[]): CollectionMember[] {
|
||||||
|
const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2);
|
||||||
|
return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The role after `current` in the Viewer, Editor, Admin cycle. */
|
||||||
|
export function nextCollectionRole(current: CollectionRole): CollectionRole {
|
||||||
|
return COLLECTION_ROLES[(COLLECTION_ROLES.indexOf(current) + 1) % COLLECTION_ROLES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusion sharing for one list: the logic behind both the desktop dialog and the phone
|
||||||
|
* sheet, which render their own markup over it. An owner gets the invitable users on
|
||||||
|
* every opening (and after the roster changes size), invites, changes roles, cancels
|
||||||
|
* pending invites and removes members; a member can leave.
|
||||||
|
*/
|
||||||
|
export function useCollectionSharing({
|
||||||
|
open,
|
||||||
|
collectionId,
|
||||||
|
isOwner,
|
||||||
|
members,
|
||||||
|
onAfterLeave,
|
||||||
|
t,
|
||||||
|
singleBusySlot = false,
|
||||||
|
}: CollectionSharingOptions) {
|
||||||
|
const toast = useToast();
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
const invite = useCollectionStore((s) => s.invite);
|
||||||
|
const cancelInvite = useCollectionStore((s) => s.cancelInvite);
|
||||||
|
const removeMember = useCollectionStore((s) => s.removeMember);
|
||||||
|
const setMemberRole = useCollectionStore((s) => s.setMemberRole);
|
||||||
|
const leave = useCollectionStore((s) => s.leave);
|
||||||
|
|
||||||
|
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([]);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor');
|
||||||
|
const [busy, setBusy] = useState<Partial<Record<MemberAction, number>>>({});
|
||||||
|
const [inviting, setInviting] = useState(false);
|
||||||
|
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
|
// Load the invitable users whenever an owner opens the surface.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !isOwner || collectionId == null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
collectionsApi
|
||||||
|
.availableUsers(collectionId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setAvailableUsers(data.users);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAvailableUsers([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, isOwner, collectionId, members.length]);
|
||||||
|
|
||||||
|
// Reset transient state on close.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setConfirmLeave(false);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const sortedMembers = useMemo(() => sortCollectionMembers(members), [members]);
|
||||||
|
|
||||||
|
const slotOf = (kind: MemberAction): MemberAction => (singleBusySlot ? 'role' : kind);
|
||||||
|
const busyUserId = (kind: MemberAction): number | null => busy[slotOf(kind)] ?? null;
|
||||||
|
|
||||||
|
const runMemberAction = async (kind: MemberAction, userId: number, action: () => Promise<void>) => {
|
||||||
|
const slot = slotOf(kind);
|
||||||
|
if (busy[slot] != null) return;
|
||||||
|
setBusy((b) => ({ ...b, [slot]: userId }));
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
} finally {
|
||||||
|
setBusy((b) => {
|
||||||
|
const rest = { ...b };
|
||||||
|
delete rest[slot];
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRole = (userId: number, role: CollectionRole) => {
|
||||||
|
if (collectionId == null) return Promise.resolve();
|
||||||
|
return runMemberAction('role', userId, () => setMemberRole(collectionId, userId, role));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleRole = (member: CollectionMember) => {
|
||||||
|
if (collectionId == null) return;
|
||||||
|
void setRole(member.user_id, nextCollectionRole(member.role ?? 'editor'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (userId: number) => {
|
||||||
|
if (collectionId == null) return Promise.resolve();
|
||||||
|
return runMemberAction('cancel', userId, () => cancelInvite(collectionId, userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (userId: number) => {
|
||||||
|
if (collectionId == null) return Promise.resolve();
|
||||||
|
return runMemberAction('remove', userId, () => removeMember(collectionId, userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (collectionId == null || selectedUserId == null || inviting) return;
|
||||||
|
setInviting(true);
|
||||||
|
try {
|
||||||
|
await invite(collectionId, selectedUserId, inviteRole);
|
||||||
|
toast.success(t('collections.invite.sent'));
|
||||||
|
setSelectedUserId(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('collections.invite.error')));
|
||||||
|
} finally {
|
||||||
|
setInviting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = async () => {
|
||||||
|
// Only the phone sheet ignores a second leave while one is in flight.
|
||||||
|
if (collectionId == null || (singleBusySlot && leaving)) return;
|
||||||
|
setLeaving(true);
|
||||||
|
try {
|
||||||
|
await leave(collectionId);
|
||||||
|
toast.success(t('collections.share.left'));
|
||||||
|
onAfterLeave();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
} finally {
|
||||||
|
setLeaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUserId,
|
||||||
|
availableUsers,
|
||||||
|
sortedMembers,
|
||||||
|
selectedUserId,
|
||||||
|
setSelectedUserId,
|
||||||
|
inviteRole,
|
||||||
|
setInviteRole,
|
||||||
|
inviting,
|
||||||
|
confirmLeave,
|
||||||
|
setConfirmLeave,
|
||||||
|
leaving,
|
||||||
|
busyUserId,
|
||||||
|
setRole,
|
||||||
|
cycleRole,
|
||||||
|
cancel,
|
||||||
|
remove,
|
||||||
|
handleInvite,
|
||||||
|
handleLeave,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react'
|
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react'
|
||||||
import type { CollectionMember, CollectionRole } from '@trek/shared'
|
import type { CollectionMember } from '@trek/shared'
|
||||||
import { COLLECTION_ROLES } from '@trek/shared'
|
import { COLLECTION_ROLES } from '@trek/shared'
|
||||||
import type { TranslationFn } from '../../../types'
|
import type { TranslationFn } from '../../../types'
|
||||||
import { collectionsApi } from '../../../api/collections'
|
|
||||||
import { useCollectionStore } from '../../../store/collectionStore'
|
|
||||||
import { useAuthStore } from '../../../store/authStore'
|
|
||||||
import { useToast } from '../../../components/shared/Toast'
|
|
||||||
import { getApiErrorMessage } from '../../../utils/apiError'
|
|
||||||
import { avatarSrc } from '../../../utils/avatarSrc'
|
import { avatarSrc } from '../../../utils/avatarSrc'
|
||||||
|
import { useCollectionSharing } from '../../../components/Collections/useCollectionSharing'
|
||||||
import MSheet from '../../components/MSheet'
|
import MSheet from '../../components/MSheet'
|
||||||
import { CancelPill, Eyebrow, PrimaryPill, SheetHeader } from './MCollSheetKit'
|
import { CancelPill, Eyebrow, PrimaryPill, SheetHeader } from './MCollSheetKit'
|
||||||
|
|
||||||
@@ -44,91 +40,16 @@ function MemberAvatar({ member }: { member: CollectionMember }) {
|
|||||||
export default function MCollShareSheet({
|
export default function MCollShareSheet({
|
||||||
open, collectionId, collectionName, isOwner, members, onClose, onAfterLeave, t,
|
open, collectionId, collectionName, isOwner, members, onClose, onAfterLeave, t,
|
||||||
}: MCollShareSheetProps) {
|
}: MCollShareSheetProps) {
|
||||||
const toast = useToast()
|
const {
|
||||||
const currentUserId = useAuthStore(s => s.user?.id)
|
currentUserId, availableUsers, sortedMembers, selectedUserId, setSelectedUserId, inviteRole, setInviteRole,
|
||||||
const invite = useCollectionStore(s => s.invite)
|
inviting, confirmLeave, setConfirmLeave, leaving, busyUserId, cycleRole, cancel, remove, handleInvite, handleLeave,
|
||||||
const cancelInvite = useCollectionStore(s => s.cancelInvite)
|
} = useCollectionSharing({ open, collectionId, isOwner, members, onAfterLeave, t, singleBusySlot: true })
|
||||||
const removeMember = useCollectionStore(s => s.removeMember)
|
|
||||||
const setMemberRole = useCollectionStore(s => s.setMemberRole)
|
|
||||||
const leave = useCollectionStore(s => s.leave)
|
|
||||||
|
|
||||||
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
|
|
||||||
const [userPickerOpen, setUserPickerOpen] = useState(false)
|
const [userPickerOpen, setUserPickerOpen] = useState(false)
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
|
||||||
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor')
|
|
||||||
const [busyUserId, setBusyUserId] = useState<number | null>(null)
|
|
||||||
const [inviting, setInviting] = useState(false)
|
|
||||||
const [confirmLeave, setConfirmLeave] = useState(false)
|
|
||||||
const [leaving, setLeaving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isOwner || collectionId == null) return
|
if (!open) setUserPickerOpen(false)
|
||||||
let cancelled = false
|
|
||||||
collectionsApi.availableUsers(collectionId)
|
|
||||||
.then(data => { if (!cancelled) setAvailableUsers(data.users) })
|
|
||||||
.catch(() => { if (!cancelled) setAvailableUsers([]) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [open, isOwner, collectionId, members.length])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) return
|
|
||||||
setSelectedUserId(null)
|
|
||||||
setUserPickerOpen(false)
|
|
||||||
setConfirmLeave(false)
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const sortedMembers = useMemo(() => {
|
|
||||||
const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2)
|
|
||||||
return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username))
|
|
||||||
}, [members])
|
|
||||||
|
|
||||||
const run = async (userId: number, action: () => Promise<void>) => {
|
|
||||||
if (busyUserId != null) return
|
|
||||||
setBusyUserId(userId)
|
|
||||||
try {
|
|
||||||
await action()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setBusyUserId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cycleRole = (member: CollectionMember) => {
|
|
||||||
if (collectionId == null) return
|
|
||||||
const current = member.role ?? 'editor'
|
|
||||||
const next = COLLECTION_ROLES[(COLLECTION_ROLES.indexOf(current) + 1) % COLLECTION_ROLES.length]
|
|
||||||
void run(member.user_id, () => setMemberRole(collectionId, member.user_id, next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInvite = async () => {
|
|
||||||
if (collectionId == null || selectedUserId == null || inviting) return
|
|
||||||
setInviting(true)
|
|
||||||
try {
|
|
||||||
await invite(collectionId, selectedUserId, inviteRole)
|
|
||||||
toast.success(t('collections.invite.sent'))
|
|
||||||
setSelectedUserId(null)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('collections.invite.error')))
|
|
||||||
} finally {
|
|
||||||
setInviting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLeave = async () => {
|
|
||||||
if (collectionId == null || leaving) return
|
|
||||||
setLeaving(true)
|
|
||||||
try {
|
|
||||||
await leave(collectionId)
|
|
||||||
toast.success(t('collections.share.left'))
|
|
||||||
onAfterLeave()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setLeaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedUser = availableUsers.find(u => u.id === selectedUserId) ?? null
|
const selectedUser = availableUsers.find(u => u.id === selectedUserId) ?? null
|
||||||
const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold'
|
const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold'
|
||||||
|
|
||||||
@@ -145,7 +66,7 @@ export default function MCollShareSheet({
|
|||||||
{sortedMembers.map(member => {
|
{sortedMembers.map(member => {
|
||||||
const isSelf = member.user_id === currentUserId
|
const isSelf = member.user_id === currentUserId
|
||||||
const pending = member.status === 'pending'
|
const pending = member.status === 'pending'
|
||||||
const busy = busyUserId === member.user_id
|
const busy = busyUserId('role') === member.user_id
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.user_id}
|
key={member.user_id}
|
||||||
@@ -184,9 +105,7 @@ export default function MCollShareSheet({
|
|||||||
{isOwner && !member.is_owner && collectionId != null && (
|
{isOwner && !member.is_owner && collectionId != null && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => run(member.user_id, () =>
|
onClick={() => (pending ? cancel(member.user_id) : remove(member.user_id))}
|
||||||
pending ? cancelInvite(collectionId, member.user_id) : removeMember(collectionId, member.user_id),
|
|
||||||
)}
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={pending ? t('collections.share.cancel') : t('collections.share.remove')}
|
aria-label={pending ? t('collections.share.cancel') : t('collections.share.remove')}
|
||||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint active:bg-[color:var(--m-ic)] disabled:opacity-50"
|
className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint active:bg-[color:var(--m-ic)] disabled:opacity-50"
|
||||||
|
|||||||
Reference in New Issue
Block a user