mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collab): share the polls logic between desktop and phone
useCollabPolls holds the poll list with its live updates, the countdown tick, and creating, voting, closing and deleting. The desktop panel reads the vote totals, winner and closed state from collabModel like the phone tab does and keeps its own countdown label. A failed load still empties the desktop list only, and closing still takes the server poll on the phone only.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useId } from 'react'
|
||||
import React, { useState, useCallback, useId } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { RoundAction } from '../Planner/bookings/bookingParts'
|
||||
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
|
||||
@@ -9,14 +9,15 @@ import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { User } from '../../types'
|
||||
import type { CollabPollCreateRequest } from '@trek/shared'
|
||||
import { useCollabPolls } from './useCollabPolls'
|
||||
import { hasUserVoted, isPollActive, pollMaxVoteCount, splitPolls, totalPollVotes, type CollabPollData } from './collabModel'
|
||||
|
||||
interface PollVoter {
|
||||
user_id: number
|
||||
@@ -24,23 +25,6 @@ interface PollVoter {
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface PollOption {
|
||||
id: number
|
||||
text: string
|
||||
voters: PollVoter[]
|
||||
}
|
||||
|
||||
interface Poll {
|
||||
id: number
|
||||
question: string
|
||||
options: PollOption[]
|
||||
multiple_choice: boolean
|
||||
is_closed: boolean
|
||||
deadline: string | null
|
||||
created_by: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
// Block styling for the markdown question (#2177). Tailwind's preflight resets
|
||||
@@ -83,15 +67,6 @@ function timeRemaining(deadline) {
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
function isExpired(deadline) {
|
||||
if (!deadline) return false
|
||||
return new Date(deadline).getTime() <= Date.now()
|
||||
}
|
||||
|
||||
function totalVotes(poll) {
|
||||
return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0)
|
||||
}
|
||||
|
||||
// ── Create Poll Modal ────────────────────────────────────────────────────────
|
||||
interface CreatePollModalProps {
|
||||
onClose: () => void
|
||||
@@ -210,7 +185,7 @@ function PollChip({ icon, tone = 'muted', children }: { icon?: React.ReactNode;
|
||||
|
||||
// ── Poll Card ────────────────────────────────────────────────────────────────
|
||||
interface PollCardProps {
|
||||
poll: Poll
|
||||
poll: CollabPollData
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
@@ -220,12 +195,12 @@ interface PollCardProps {
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const total = totalPollVotes(poll)
|
||||
const isClosed = !isPollActive(poll)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
|
||||
const hasVoted = hasUserVoted(poll, currentUser.id)
|
||||
// Highest vote count across the options; 0 for a poll without options.
|
||||
const topCount = (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0)
|
||||
const topCount = pollMaxVoteCount(poll)
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
|
||||
@@ -324,98 +299,17 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const { polls, loading, createPoll, votePoll: handleVote, closePoll: handleClose, deletePoll: handleDelete } = useCollabPolls({
|
||||
tripId, t, toast, resetOnLoadError: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getPolls(tripId).then(data => {
|
||||
if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || [])
|
||||
}).catch(() => {
|
||||
if (!cancelled) setPolls([])
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
const handleCreate = useCallback(async (data: CollabPollCreateRequest) => {
|
||||
await createPoll(data)
|
||||
setShowForm(false)
|
||||
}, [createPoll])
|
||||
|
||||
// WebSocket
|
||||
useEffect(() => {
|
||||
const handler = (msg) => {
|
||||
if (!msg?.type) return
|
||||
// The panel is not remounted on a trip change, so an event still in flight
|
||||
// from the trip we just left must not land in this list.
|
||||
if (String(msg.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:poll:created' && msg.poll) {
|
||||
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:poll:voted' && msg.poll) {
|
||||
setPolls(prev => prev.map(p => p.id === msg.poll.id ? msg.poll : p))
|
||||
}
|
||||
if (msg.type === 'collab:poll:closed' && msg.poll) {
|
||||
setPolls(prev => prev.map(p => p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p))
|
||||
}
|
||||
if (msg.type === 'collab:poll:deleted') {
|
||||
const id = msg.pollId || msg.poll?.id
|
||||
if (id) setPolls(prev => prev.filter(p => p.id !== id))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
try {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleVote = useCallback(async (pollId, optionIndex) => {
|
||||
try {
|
||||
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
||||
const updated = result.poll || result
|
||||
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleClose = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.closePoll(tripId, pollId)
|
||||
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleDelete = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId)
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
|
||||
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
|
||||
|
||||
// Deadline ticker
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!polls.some(p => p.deadline && !p.is_closed)) return
|
||||
const iv = setInterval(() => setTick(t => t + 1), 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [polls])
|
||||
const { active: activePolls, closed: closedPolls } = splitPolls(polls)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { collabApi } from '../../api/client';
|
||||
import { addListener, removeListener } from '../../api/websocket';
|
||||
import type { CollabPollData } from './collabModel';
|
||||
import { useCollabPolls, type CollabPollsOptions } from './useCollabPolls';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
function poll(id: number, over: Partial<CollabPollData> = {}): CollabPollData {
|
||||
return {
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
question: `Poll ${id}`,
|
||||
options: [
|
||||
{ text: 'A', label: 'A', voters: [] },
|
||||
{ text: 'B', label: 'B', voters: [] },
|
||||
],
|
||||
multiple_choice: false,
|
||||
is_closed: false,
|
||||
deadline: null,
|
||||
username: 'alice',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: '2026-05-01T10:00:00Z',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(over: Partial<CollabPollsOptions> = {}) {
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
const hook = renderHook(
|
||||
(props: Partial<CollabPollsOptions>) => useCollabPolls({ tripId: 1, t, toast, ...over, ...props }),
|
||||
{ initialProps: {} }
|
||||
);
|
||||
return { ...hook, toast };
|
||||
}
|
||||
|
||||
async function loaded(over: Partial<CollabPollsOptions> = {}) {
|
||||
const view = setup(over);
|
||||
await waitFor(() => expect(view.result.current.loading).toBe(false));
|
||||
return view;
|
||||
}
|
||||
|
||||
function wsHandler(): (event: Record<string, unknown>) => void {
|
||||
const calls = vi.mocked(addListener).mock.calls;
|
||||
return calls[calls.length - 1][0] as (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(addListener).mockClear();
|
||||
vi.mocked(removeListener).mockClear();
|
||||
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls: [poll(1), poll(2)] });
|
||||
vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: poll(9) });
|
||||
vi.spyOn(collabApi, 'votePoll').mockResolvedValue({ poll: poll(1, { question: 'voted' }) });
|
||||
vi.spyOn(collabApi, 'closePoll').mockResolvedValue({ poll: poll(1, { is_closed: true, question: 'server' }) });
|
||||
vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useCollabPolls', () => {
|
||||
it('FE-COLLAB-POLLS-001: loads the polls, from an object or a bare array', async () => {
|
||||
const { result } = setup();
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(collabApi.getPolls).toHaveBeenCalledWith(1);
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([1, 2]);
|
||||
|
||||
vi.mocked(collabApi.getPolls).mockResolvedValue([poll(3)]);
|
||||
const bare = await loaded();
|
||||
expect(bare.result.current.polls.map((p) => p.id)).toEqual([3]);
|
||||
|
||||
vi.mocked(collabApi.getPolls).mockResolvedValue({});
|
||||
const empty = await loaded();
|
||||
expect(empty.result.current.polls).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-002: a failed reload empties the desktop list and keeps the phone list', async () => {
|
||||
const desktop = await loaded({ resetOnLoadError: true });
|
||||
const phone = await loaded();
|
||||
vi.mocked(collabApi.getPolls).mockRejectedValue(new Error('boom'));
|
||||
|
||||
desktop.rerender({ tripId: 2 });
|
||||
phone.rerender({ tripId: 2 });
|
||||
await waitFor(() => expect(desktop.result.current.loading).toBe(false));
|
||||
await waitFor(() => expect(phone.result.current.loading).toBe(false));
|
||||
expect(desktop.result.current.polls).toEqual([]);
|
||||
expect(phone.result.current.polls.map((p) => p.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-003: live events create, vote, close and delete, only for this trip', async () => {
|
||||
const { result } = await loaded();
|
||||
const handler = wsHandler();
|
||||
act(() => {
|
||||
handler({ type: 'collab:poll:created', tripId: 2, poll: poll(7) });
|
||||
handler({ type: 'collab:poll:created', tripId: '1', poll: poll(7) });
|
||||
handler({ type: 'collab:poll:created', tripId: 1, poll: poll(7) });
|
||||
handler({ type: 'collab:poll:voted', tripId: 1, poll: poll(1, { question: 'live vote' }) });
|
||||
handler({ type: 'collab:poll:closed', tripId: 1, poll: { id: 2 } });
|
||||
});
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([7, 1, 2]);
|
||||
expect(result.current.polls[1].question).toBe('live vote');
|
||||
// A closed event folds into the poll it names and always closes it.
|
||||
expect(result.current.polls[2]).toMatchObject({ question: 'Poll 2', is_closed: true });
|
||||
|
||||
act(() => {
|
||||
handler({ type: 'collab:poll:deleted', tripId: 1, pollId: 7 });
|
||||
handler({ type: 'collab:poll:deleted', tripId: 1, poll: { id: 1 } });
|
||||
handler({ type: 'collab:poll:voted', tripId: 1 });
|
||||
handler({});
|
||||
});
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-004: unmounting removes the live listener', async () => {
|
||||
const { unmount } = await loaded();
|
||||
const handler = wsHandler();
|
||||
unmount();
|
||||
expect(removeListener).toHaveBeenCalledWith(handler);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-005: creating puts the new poll on top once', async () => {
|
||||
const { result } = await loaded();
|
||||
const data = { question: 'Q', options: ['A', 'B'], multiple_choice: true };
|
||||
await act(async () => {
|
||||
await result.current.createPoll(data);
|
||||
});
|
||||
expect(collabApi.createPoll).toHaveBeenCalledWith(1, data);
|
||||
act(() => wsHandler()({ type: 'collab:poll:created', tripId: 1, poll: poll(9) }));
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([9, 1, 2]);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-006: a failed create toasts and rethrows', async () => {
|
||||
const err = new Error('boom');
|
||||
vi.mocked(collabApi.createPoll).mockRejectedValue(err);
|
||||
const { result, toast } = await loaded();
|
||||
await act(async () => {
|
||||
await expect(result.current.createPoll({ question: 'Q', options: ['A', 'B'] })).rejects.toBe(err);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(result.current.polls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-007: voting swaps in the server poll, a failure toasts', async () => {
|
||||
const { result, toast } = await loaded();
|
||||
await act(async () => {
|
||||
await result.current.votePoll(1, 0);
|
||||
});
|
||||
expect(collabApi.votePoll).toHaveBeenCalledWith(1, 1, 0);
|
||||
expect(result.current.polls[0].question).toBe('voted');
|
||||
|
||||
vi.mocked(collabApi.votePoll).mockRejectedValue(new Error('boom'));
|
||||
await act(async () => {
|
||||
await result.current.votePoll(1, 1);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-008: closing flips the flag on desktop and swaps in the server poll on the phone', async () => {
|
||||
vi.mocked(collabApi.closePoll).mockResolvedValue({ success: true });
|
||||
const desktop = await loaded();
|
||||
await act(async () => {
|
||||
await desktop.result.current.closePoll(1);
|
||||
});
|
||||
expect(collabApi.closePoll).toHaveBeenCalledWith(1, 1);
|
||||
expect(desktop.result.current.polls[0]).toMatchObject({ question: 'Poll 1', is_closed: true });
|
||||
expect(desktop.result.current.polls[1].is_closed).toBe(false);
|
||||
|
||||
vi.mocked(collabApi.closePoll).mockResolvedValue({ poll: poll(1, { is_closed: true, question: 'server' }) });
|
||||
const phone = await loaded({ replaceClosedPoll: true });
|
||||
await act(async () => {
|
||||
await phone.result.current.closePoll(1);
|
||||
});
|
||||
expect(phone.result.current.polls[0]).toMatchObject({ question: 'server', is_closed: true });
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-009: a failed close toasts and leaves the poll open', async () => {
|
||||
vi.mocked(collabApi.closePoll).mockRejectedValue(new Error('boom'));
|
||||
const { result, toast } = await loaded();
|
||||
await act(async () => {
|
||||
await result.current.closePoll(1);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(result.current.polls[0].is_closed).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-010: deleting drops the poll, a failure toasts and keeps it', async () => {
|
||||
const { result, toast } = await loaded();
|
||||
await act(async () => {
|
||||
await result.current.deletePoll(1);
|
||||
});
|
||||
expect(collabApi.deletePoll).toHaveBeenCalledWith(1, 1);
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([2]);
|
||||
|
||||
vi.mocked(collabApi.deletePoll).mockRejectedValue(new Error('boom'));
|
||||
await act(async () => {
|
||||
await result.current.deletePoll(2);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-011: the clock ticks while an open poll still has a deadline ahead', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const ahead = new Date(Date.now() + 3_600_000).toISOString();
|
||||
vi.mocked(collabApi.getPolls).mockResolvedValue({ polls: [poll(1, { deadline: ahead })] });
|
||||
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;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(renders).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-012: no tick for closed, expired or deadline free polls', 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;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(90_000);
|
||||
});
|
||||
expect(renders).toBe(before);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-POLLS-013: a poll create answered with the bare poll is read too', async () => {
|
||||
vi.mocked(collabApi.createPoll).mockResolvedValue(poll(11));
|
||||
const { result } = await loaded();
|
||||
await act(async () => {
|
||||
await result.current.createPoll({ question: 'Q', options: ['A', 'B'] });
|
||||
});
|
||||
expect(result.current.polls.map((p) => p.id)).toEqual([11, 1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { CollabPollCreateRequest } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { collabApi } from '../../api/client';
|
||||
import { addListener, removeListener } from '../../api/websocket';
|
||||
import type { useTranslation } from '../../i18n';
|
||||
import type { useToast } from '../shared/Toast';
|
||||
import { isPollActive, type CollabPollData } from './collabModel';
|
||||
|
||||
type PollResponse = { poll?: CollabPollData } | CollabPollData;
|
||||
|
||||
function readPoll(result: PollResponse): CollabPollData {
|
||||
return ('poll' in result && result.poll) || (result as CollabPollData);
|
||||
}
|
||||
|
||||
export interface CollabPollsOptions {
|
||||
tripId: number | string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
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. */
|
||||
replaceClosedPoll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip polls behind the desktop Collab panel and the phone's polls tab, which draw
|
||||
* their own cards and create form over it: the list with its live WebSocket updates, a
|
||||
* 30 second tick while a deadline is still counting down, and creating, voting, closing
|
||||
* and deleting. Collab has no store slice, so the polls live here.
|
||||
*/
|
||||
export function useCollabPolls({
|
||||
tripId,
|
||||
t,
|
||||
toast,
|
||||
resetOnLoadError = false,
|
||||
replaceClosedPoll = false,
|
||||
}: CollabPollsOptions) {
|
||||
const [polls, setPolls] = useState<CollabPollData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
collabApi
|
||||
.getPolls(tripId)
|
||||
.then((data: CollabPollData[] | { polls?: CollabPollData[] }) => {
|
||||
if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && resetOnLoadError) setPolls([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tripId, resetOnLoadError]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (msg: Record<string, unknown>) => {
|
||||
if (!msg?.type) return;
|
||||
// An event still in flight from a trip just left must not land in this list.
|
||||
if (String(msg.tripId) !== String(tripId)) return;
|
||||
const poll = msg.poll as CollabPollData | undefined;
|
||||
if (msg.type === 'collab:poll:created' && poll) {
|
||||
setPolls((prev) => (prev.some((p) => p.id === poll.id) ? prev : [poll, ...prev]));
|
||||
}
|
||||
if (msg.type === 'collab:poll:voted' && poll) {
|
||||
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)));
|
||||
}
|
||||
if (msg.type === 'collab:poll:deleted') {
|
||||
const id = (msg.pollId as number | undefined) || poll?.id;
|
||||
if (id) setPolls((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
};
|
||||
addListener(handler);
|
||||
return () => removeListener(handler);
|
||||
}, [tripId]);
|
||||
|
||||
// Re-render every 30s while a deadline is still counting down.
|
||||
useEffect(() => {
|
||||
if (!polls.some((p) => p.deadline && isPollActive(p))) return;
|
||||
const iv = setInterval(() => setTick((v) => v + 1), 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, [polls]);
|
||||
|
||||
/** Rethrows after the toast, so the form that called it stays open. */
|
||||
const createPoll = useCallback(
|
||||
async (data: CollabPollCreateRequest) => {
|
||||
try {
|
||||
const created = readPoll(await collabApi.createPoll(tripId, data));
|
||||
setPolls((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev]));
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[tripId, toast, t]
|
||||
);
|
||||
|
||||
const votePoll = useCallback(
|
||||
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)));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
},
|
||||
[tripId, toast, t]
|
||||
);
|
||||
|
||||
const closePoll = useCallback(
|
||||
async (pollId: number) => {
|
||||
try {
|
||||
const result = await collabApi.closePoll(tripId, pollId);
|
||||
setPolls((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== pollId) return p;
|
||||
return replaceClosedPoll ? readPoll(result) : { ...p, is_closed: true };
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
},
|
||||
[tripId, replaceClosedPoll, toast, t]
|
||||
);
|
||||
|
||||
const deletePoll = useCallback(
|
||||
async (pollId: number) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId);
|
||||
setPolls((prev) => prev.filter((p) => p.id !== pollId));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
},
|
||||
[tripId, toast, t]
|
||||
);
|
||||
|
||||
return { polls, loading, createPoll, votePoll, closePoll, deletePoll };
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../../../../components/shared/markdownSanitize'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { collabApi } from '../../../../api/client'
|
||||
import { addListener, removeListener } from '../../../../api/websocket'
|
||||
import { useCollabPolls } from '../../../../components/Collab/useCollabPolls'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
@@ -28,9 +27,6 @@ interface MCollabPollsProps {
|
||||
planner: TripPlanner
|
||||
}
|
||||
|
||||
interface GetPollsResponse { polls: CollabPollData[] }
|
||||
interface PollResponse { poll: CollabPollData }
|
||||
|
||||
interface PollFormSubmitData {
|
||||
question: string
|
||||
options: string[]
|
||||
@@ -39,9 +35,9 @@ interface PollFormSubmitData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip-tab Collab / Polls. Same self-contained architecture as
|
||||
* MCollabChat/MCollabNotes (own state, own `collabApi` calls, own WebSocket
|
||||
* listener — 10-tab-databindings.md §8.5). The demo only has a placeholder,
|
||||
* Trip-tab Collab / Polls. The list, its WebSocket listener and the poll
|
||||
* actions come from useCollabPolls, the hook the desktop panel reads too
|
||||
* (10-tab-databindings.md §8.5). The demo only has a placeholder,
|
||||
* so question/options/results/deadline/close/delete below is a new design in
|
||||
* the established mobile visual language, not a port of desktop markup.
|
||||
*/
|
||||
@@ -51,96 +47,21 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
|
||||
const canEdit = planner.can('collab_edit', planner.trip)
|
||||
const currentUserId = user?.id ?? null
|
||||
|
||||
const [polls, setPolls] = useState<CollabPollData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { polls, loading, createPoll, votePoll: handleVote, closePoll: handleClosePoll, deletePoll: handleDelete } =
|
||||
useCollabPolls({ tripId, t, toast, replaceClosedPoll: true })
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
|
||||
const [collapsedClosed, setCollapsedClosed] = useState(false)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
// ── Load ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getPolls(tripId).then((data: GetPollsResponse) => {
|
||||
if (!cancelled) setPolls(data.polls || [])
|
||||
}).catch(() => { /* leave polls empty */ }).finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
|
||||
// ── WebSocket (own listener, not handleRemoteEvent) ──
|
||||
useEffect(() => {
|
||||
const handler = (event: Record<string, unknown>) => {
|
||||
if (String(event.tripId) !== String(tripId)) return
|
||||
if (event.type === 'collab:poll:created') {
|
||||
const poll = event.poll as CollabPollData
|
||||
setPolls(prev => (prev.some(p => p.id === poll.id) ? prev : [poll, ...prev]))
|
||||
}
|
||||
if (event.type === 'collab:poll:voted' || event.type === 'collab:poll:closed') {
|
||||
const poll = event.poll as CollabPollData
|
||||
setPolls(prev => prev.map(p => (p.id === poll.id ? poll : p)))
|
||||
}
|
||||
if (event.type === 'collab:poll:deleted') {
|
||||
const pollId = event.pollId as number
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
// ── Deadline countdown ticker — re-render every 30s so formatPollCountdown recomputes ──
|
||||
useEffect(() => {
|
||||
if (!polls.some(p => p.deadline && isPollActive(p))) return
|
||||
const iv = setInterval(() => setTick(v => v + 1), 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [polls])
|
||||
|
||||
const handleCreate = useCallback(async (data: PollFormSubmitData) => {
|
||||
try {
|
||||
const res = (await collabApi.createPoll(tripId, {
|
||||
question: data.question,
|
||||
options: data.options,
|
||||
// Server reads `data.multiple || data.multiple_choice`
|
||||
// (nest/collab/collab.service.ts) — both are sent so either shape lands.
|
||||
multiple: data.multipleChoice,
|
||||
multiple_choice: data.multipleChoice,
|
||||
deadline: data.deadline || undefined,
|
||||
})) as PollResponse
|
||||
setPolls(prev => (prev.some(p => p.id === res.poll.id) ? prev : [res.poll, ...prev]))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
throw new Error('create failed')
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleVote = useCallback(async (pollId: number, optionIndex: number) => {
|
||||
try {
|
||||
const res = (await collabApi.votePoll(tripId, pollId, optionIndex)) as PollResponse
|
||||
// Reconcile against the poll we asked about, like handleClosePoll does.
|
||||
setPolls(prev => prev.map(p => (p.id === pollId ? res.poll : p)))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleClosePoll = useCallback(async (pollId: number) => {
|
||||
try {
|
||||
const res = (await collabApi.closePoll(tripId, pollId)) as PollResponse
|
||||
setPolls(prev => prev.map(p => (p.id === pollId ? res.poll : p)))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleDelete = useCallback(async (pollId: number) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId)
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
const handleCreate = useCallback((data: PollFormSubmitData) => createPoll({
|
||||
question: data.question,
|
||||
options: data.options,
|
||||
// Server reads `data.multiple || data.multiple_choice`
|
||||
// (nest/collab/collab.service.ts), so both are sent and either shape lands.
|
||||
multiple: data.multipleChoice,
|
||||
multiple_choice: data.multipleChoice,
|
||||
deadline: data.deadline || undefined,
|
||||
}), [createPoll])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user