mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collab): keep the phone's own poll reconciliation in the shared hook
The shared polls hook had taken the desktop rules for both views. It now keeps each view's logic as it was before the move. The phone again replaces a poll with the one a live closed event carries, puts a vote answer on the poll it voted on, and stops the countdown tick once a deadline passed. The desktop panel again ticks until a poll is closed.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// FE-COLLAB-POLLS-001 to FE-COLLAB-POLLS-013: the poll logic behind the desktop Collab
|
||||
// panel (empties on a failed load, flips the closed flag itself) and the phone's polls tab
|
||||
// (keeps the list, shows the closed poll the server answers with).
|
||||
// FE-COLLAB-POLLS-001 to FE-COLLAB-POLLS-015: the poll logic behind the desktop Collab
|
||||
// panel (empties on a failed load, flips the closed flag itself, ticks until a poll is
|
||||
// closed) and the phone's polls tab (keeps the list, shows the closed poll the server
|
||||
// sends, puts a vote answer on the poll it voted on, stops ticking once a deadline passed).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -227,25 +228,46 @@ describe('useCollabPolls', () => {
|
||||
expect(renders).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-012: no tick for closed, expired or deadline free polls', async () => {
|
||||
it('FE-COLLAB-POLLS-012: no tick for closed or deadline free polls, an expired one ticks only on desktop', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const past = new Date(Date.now() - 1000).toISOString();
|
||||
const ahead = new Date(Date.now() + 3_600_000).toISOString();
|
||||
vi.mocked(collabApi.getPolls).mockResolvedValue({
|
||||
polls: [poll(1), poll(2, { deadline: past }), poll(3, { deadline: ahead, is_closed: true })],
|
||||
});
|
||||
let renders = 0;
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
const { result } = renderHook(() => {
|
||||
renders += 1;
|
||||
return useCollabPolls({ tripId: 1, t, toast });
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const before = renders;
|
||||
const renderCounted = (polls: CollabPollData[], over: Partial<CollabPollsOptions>) => {
|
||||
vi.mocked(collabApi.getPolls).mockResolvedValue({ polls });
|
||||
const counter = { renders: 0 };
|
||||
const view = renderHook(() => {
|
||||
counter.renders += 1;
|
||||
return useCollabPolls({ tripId: 1, t, toast, ...over });
|
||||
});
|
||||
return { ...view, counter };
|
||||
};
|
||||
|
||||
const quiet = renderCounted([poll(1), poll(3, { deadline: ahead, is_closed: true })], {});
|
||||
await waitFor(() => expect(quiet.result.current.loading).toBe(false));
|
||||
const quietBefore = quiet.counter.renders;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(90_000);
|
||||
});
|
||||
expect(renders).toBe(before);
|
||||
expect(quiet.counter.renders).toBe(quietBefore);
|
||||
quiet.unmount();
|
||||
|
||||
const phone = renderCounted([poll(2, { deadline: past })], { tickOnlyWhileActive: true });
|
||||
await waitFor(() => expect(phone.result.current.loading).toBe(false));
|
||||
const phoneBefore = phone.counter.renders;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(90_000);
|
||||
});
|
||||
expect(phone.counter.renders).toBe(phoneBefore);
|
||||
phone.unmount();
|
||||
|
||||
const desktop = renderCounted([poll(2, { deadline: past })], {});
|
||||
await waitFor(() => expect(desktop.result.current.loading).toBe(false));
|
||||
const desktopBefore = desktop.counter.renders;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(desktop.counter.renders).toBeGreaterThan(desktopBefore);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-013: a poll create answered with the bare poll is read too', async () => {
|
||||
@@ -256,4 +278,27 @@ describe('useCollabPolls', () => {
|
||||
});
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([11, 1, 2]);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-014: on the phone a live closed event replaces the poll with the one it carries', async () => {
|
||||
const { result } = await loaded({ replaceClosedPoll: true });
|
||||
act(() => {
|
||||
wsHandler()({ type: 'collab:poll:closed', tripId: 1, poll: { id: 2, question: 'closed live' } });
|
||||
});
|
||||
expect(result.current.polls[1]).toEqual({ id: 2, question: 'closed live' });
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-015: a vote answer lands on the answered id on desktop and on the voted poll on the phone', async () => {
|
||||
vi.mocked(collabApi.votePoll).mockResolvedValue({ poll: poll(2, { question: 'answer' }) });
|
||||
const desktop = await loaded();
|
||||
await act(async () => {
|
||||
await desktop.result.current.votePoll(1, 0);
|
||||
});
|
||||
expect(desktop.result.current.polls.map((p) => p.question)).toEqual(['Poll 1', 'answer']);
|
||||
|
||||
const phone = await loaded({ matchVoteByPollId: true });
|
||||
await act(async () => {
|
||||
await phone.result.current.votePoll(1, 0);
|
||||
});
|
||||
expect(phone.result.current.polls.map((p) => p.question)).toEqual(['answer', 'Poll 2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,15 @@ export interface CollabPollsOptions {
|
||||
toast: ReturnType<typeof useToast>;
|
||||
/** The desktop panel outlives a trip change, so a failed load there empties the list. */
|
||||
resetOnLoadError?: boolean;
|
||||
/** The phone shows the closed poll the server answers with; the desktop panel only flips its flag. */
|
||||
/**
|
||||
* The phone shows the closed poll the server sends, both as the close answer and as the
|
||||
* live event; the desktop panel flips its flag and merges the live event into the poll.
|
||||
*/
|
||||
replaceClosedPoll?: boolean;
|
||||
/** The phone puts a vote answer on the poll it voted on; the desktop panel on the id in the answer. */
|
||||
matchVoteByPollId?: boolean;
|
||||
/** The phone stops the countdown tick once a deadline passes; the desktop panel ticks until the poll is closed. */
|
||||
tickOnlyWhileActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +42,8 @@ export function useCollabPolls({
|
||||
toast,
|
||||
resetOnLoadError = false,
|
||||
replaceClosedPoll = false,
|
||||
matchVoteByPollId = false,
|
||||
tickOnlyWhileActive = false,
|
||||
}: CollabPollsOptions) {
|
||||
const [polls, setPolls] = useState<CollabPollData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -72,7 +81,12 @@ export function useCollabPolls({
|
||||
setPolls((prev) => prev.map((p) => (p.id === poll.id ? poll : p)));
|
||||
}
|
||||
if (msg.type === 'collab:poll:closed' && poll) {
|
||||
setPolls((prev) => prev.map((p) => (p.id === poll.id ? { ...p, ...poll, is_closed: true } : p)));
|
||||
setPolls((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== poll.id) return p;
|
||||
return replaceClosedPoll ? poll : { ...p, ...poll, is_closed: true };
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'collab:poll:deleted') {
|
||||
const id = (msg.pollId as number | undefined) || poll?.id;
|
||||
@@ -81,14 +95,14 @@ export function useCollabPolls({
|
||||
};
|
||||
addListener(handler);
|
||||
return () => removeListener(handler);
|
||||
}, [tripId]);
|
||||
}, [tripId, replaceClosedPoll]);
|
||||
|
||||
// Re-render every 30s while a deadline is still counting down.
|
||||
useEffect(() => {
|
||||
if (!polls.some((p) => p.deadline && isPollActive(p))) return;
|
||||
if (!polls.some((p) => p.deadline && (tickOnlyWhileActive ? isPollActive(p) : !p.is_closed))) return;
|
||||
const iv = setInterval(() => setTick((v) => v + 1), 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, [polls]);
|
||||
}, [polls, tickOnlyWhileActive]);
|
||||
|
||||
/** Rethrows after the toast, so the form that called it stays open. */
|
||||
const createPoll = useCallback(
|
||||
@@ -108,12 +122,13 @@ export function useCollabPolls({
|
||||
async (pollId: number, optionIndex: number) => {
|
||||
try {
|
||||
const updated = readPoll(await collabApi.votePoll(tripId, pollId, optionIndex));
|
||||
setPolls((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
||||
const target = matchVoteByPollId ? pollId : updated.id;
|
||||
setPolls((prev) => prev.map((p) => (p.id === target ? updated : p)));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
},
|
||||
[tripId, toast, t]
|
||||
[tripId, matchVoteByPollId, toast, t]
|
||||
);
|
||||
|
||||
const closePoll = useCallback(
|
||||
|
||||
@@ -48,7 +48,14 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
|
||||
const currentUserId = user?.id ?? null
|
||||
|
||||
const { polls, loading, createPoll, votePoll: handleVote, closePoll: handleClosePoll, deletePoll: handleDelete } =
|
||||
useCollabPolls({ tripId, t, toast, replaceClosedPoll: true })
|
||||
useCollabPolls({
|
||||
tripId,
|
||||
t,
|
||||
toast,
|
||||
replaceClosedPoll: true,
|
||||
matchVoteByPollId: true,
|
||||
tickOnlyWhileActive: true,
|
||||
})
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
|
||||
const [collapsedClosed, setCollapsedClosed] = useState(false)
|
||||
|
||||
Reference in New Issue
Block a user