diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx new file mode 100644 index 000000000..bb7db9940 --- /dev/null +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -0,0 +1,129 @@ +// FE-COMP-LINKS-001 to FE-COMP-LINKS-008 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +import { render, screen, waitFor } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { useAuthStore } from '../../store/authStore'; +import { useTripStore } from '../../store/tripStore'; +import { usePermissionsStore } from '../../store/permissionsStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildUser, buildTrip } from '../../../tests/helpers/factories'; +import CollabLinks from './CollabLinks'; + +const currentUser = buildUser({ id: 1, username: 'testuser' }); + +const buildLink = (overrides: Record = {}) => ({ + id: 1, + title: 'Ferry timetable', + url: 'https://ferries.example/timetable', + pinned: 0, + ...overrides, +}); + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [] })), + ); + seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) }); +}); + +describe('CollabLinks', () => { + it('FE-COMP-LINKS-001: empty list shows the mascot state, not a bare line of text', async () => { + render(); + expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument(); + // The mascot carries the scene class; it is what makes this panel match its siblings. + await waitFor(() => expect(document.querySelector('.trek--links')).toBeInTheDocument()); + }); + + it('FE-COMP-LINKS-002: the header names the panel and carries the add button', async () => { + render(); + const heading = await screen.findByRole('heading', { level: 3 }); + expect(heading).toHaveTextContent(/links/i); + expect(heading).toHaveStyle({ textTransform: 'uppercase' }); + expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument(); + }); + + it('FE-COMP-LINKS-003: renders a link with its title and url', async () => { + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + ); + render(); + expect(await screen.findByText('Ferry timetable')).toBeInTheDocument(); + expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable'); + }); + + it('FE-COMP-LINKS-004: the add button opens the form in a dialog, not inside the list', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i })); + expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument(); + }); + + it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => { + const user = userEvent.setup(); + let posted: Record | null = null; + server.use( + http.post('/api/trips/1/collab/links', async ({ request }) => { + posted = (await request.json()) as Record; + return HttpResponse.json({ link: buildLink({ id: 7, title: 'Ferry timetable' }) }); + }), + ); + render(); + await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i })); + await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable'); + await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable'); + await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i })); + + await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' })); + await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument()); + expect(await screen.findByText('Ferry timetable')).toBeInTheDocument(); + }); + + it('FE-COMP-LINKS-006: a failed save keeps the form open so the input is not lost', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/trips/1/collab/links', () => new HttpResponse(null, { status: 500 })), + ); + render(); + await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i })); + await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable'); + await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable'); + await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i })); + + expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable'); + }); + + it('FE-COMP-LINKS-007: pin and delete are offered on a link', async () => { + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + ); + render(); + expect(await screen.findByRole('button', { name: /pin link|collab\.links\.pin/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument(); + }); + + it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { + // collab_edit reserved for the owner, on somebody else's trip. + seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 99 }) }); + render(); + expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /add link|collab\.links\.add/i })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index e5570cdba..564eacdde 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -1,12 +1,16 @@ -import { FormEvent, useEffect, useState } from 'react' +import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import EmptyState from '../shared/EmptyState' import { useToast } from '../shared/Toast' +const FONT = "var(--font-system)" + interface CollabLink { id: number title: string @@ -17,21 +21,91 @@ interface CollabLink { function LinkIcon({ url, title }: { url: string; title: string }) { const [failed, setFailed] = useState(false) let favicon = '' - try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' } + try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ } if (!favicon || failed) return return setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} /> } +/** + * The add form as a modal, like the poll and note panels next to it. Inline it + * pushed the list down and gave the panel a second header, which is what made + * this tab read as a different app from its four siblings. + */ +function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise }) { + const { t } = useTranslation() + const [title, setTitle] = useState('') + const [url, setUrl] = useState('') + const [busy, setBusy] = useState(false) + const valid = title.trim().length > 0 && url.trim().length > 0 + + const submit = async (event: FormEvent) => { + event.preventDefault() + if (!valid || busy) return + setBusy(true) + try { + await onCreate({ title: title.trim(), url: url.trim() }) + onClose() + } catch { + // add() already said what went wrong; the form stays open with the input in it. + } finally { + setBusy(false) + } + } + + const field: CSSProperties = { + width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit', + fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', + border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)', + } + const label: CSSProperties = { + fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, + color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, + } + + return createPortal( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

{t('collab.links.add')}

+ +
+
+
+ + setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} /> +
+
+ + setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} /> +
+ +
+
+
, + document.body, + ) +} + export default function CollabLinks({ tripId }: { tripId: number }) { const { t } = useTranslation() const trip = useTripStore(s => s.trip) const canEdit = useCanDo()('collab_edit', trip) const toast = useToast() const [links, setLinks] = useState([]) - const [title, setTitle] = useState('') - const [url, setUrl] = useState('') - const [busy, setBusy] = useState(false) - const [expanded, setExpanded] = useState(false) + const [showForm, setShowForm] = useState(false) + + // Both are new objects on every render, so depending on them would reload the + // list after each keystroke and drop a link that was just added back out of it. + const toastRef = useRef(toast) + toastRef.current = toast + const tRef = useRef(t) + tRef.current = t useEffect(() => { let cancelled = false @@ -40,10 +114,10 @@ export default function CollabLinks({ tripId }: { tripId: number }) { .catch(err => { if (cancelled) return console.error('Failed to load collab links:', err) - toast.error(t('common.error')) + toastRef.current.error(tRef.current('common.error')) }) return () => { cancelled = true } - }, [tripId, toast, t]) + }, [tripId]) // Live sync, the same shape the notes panel uses. Without it the three // collab:link events the server broadcasts would arrive nowhere and a link @@ -73,19 +147,14 @@ export default function CollabLinks({ tripId }: { tripId: number }) { // Every write says something when it fails. Silence left the pin looking // unchanged and the delete looking ignored, with an unhandled rejection in // the console as the only trace. - const add = async (event?: FormEvent) => { - event?.preventDefault() - if (!title.trim() || !url.trim() || busy) return - setBusy(true) + const add = async (data: { title: string; url: string }) => { try { - const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() }) + const d = await collabApi.createLink(tripId, data) setLinks(v => [d.link, ...v]) - setTitle(''); setUrl(''); setExpanded(false) } catch (err) { console.error('Failed to add collab link:', err) toast.error(t('common.error')) - } finally { - setBusy(false) + throw err } } @@ -108,16 +177,56 @@ export default function CollabLinks({ tripId }: { tripId: number }) { toast.error(t('common.error')) } } - return
-
- {canEdit &&
- - {expanded && } -
} -
{links.length === 0 ?
{t('collab.links.empty')}
: links.map(link =>
{link.title}
{link.url}
)}
-
+ + return ( +
+ {/* Header */} +
+

+ + {t('collab.tabs.links')} +

+ {canEdit && ( + + )} +
+ + {/* Content */} +
+ {links.length === 0 ? ( + + ) : ( +
+ {links.map(link => ( +
+ + +
{link.title}
+
{link.url}
+
+
+ ))} +
+ )} +
+ + {/* Create Modal */} + {showForm && setShowForm(false)} onCreate={add} />} +
+ ) } diff --git a/client/src/mobile/components/MDancingTrek.tsx b/client/src/mobile/components/MDancingTrek.tsx index c4f4a64b1..7e0d99aa6 100644 --- a/client/src/mobile/components/MDancingTrek.tsx +++ b/client/src/mobile/components/MDancingTrek.tsx @@ -27,6 +27,7 @@ export type TrekScene = | 'packing' | 'files' | 'notes' + | 'links' | 'polls' | 'journey' | 'collections' @@ -212,6 +213,21 @@ function SceneBack({ scene }: { scene: TrekScene }) { ) + case 'links': + // two chain links clicking into each other beside the body. The lower one + // carries a surface-coloured under-stroke so it reads as passing through + // the upper one rather than lying flat on top of it. + return ( + + + + + + + + + + ) case 'polls': // vote bars gently rising and settling like live tallies return ( diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index 784b1d914..c5ef96044 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -233,6 +233,10 @@ @keyframes trek-handle { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } } /* collections map pin bobbing */ @keyframes trek-pin { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } } +/* Chain: the pair sways, and the near link slides out along its own axis and + clicks back in. Moving only the near one keeps the joint readable at 88px. */ +@keyframes trek-chain { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-6deg); } } +@keyframes trek-link { 0%, 62%, 100% { transform: translateX(0); } 30% { transform: translateX(2.6px); } } /* atlas globe meridian drifting to fake a slow spin */ @keyframes trek-globe { 0%, 100% { transform: translateX(-2px); } 50% { transform: translateX(2px); } } /* costs top coin bouncing on */ @@ -274,6 +278,8 @@ .trek-suitcase { animation: trek-suitcase 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; } .trek-handle { animation: trek-handle 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; } .trek-pin { animation: trek-pin 2.2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; } +.trek-chain { animation: trek-chain 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +.trek-link { animation: trek-link 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } .trek-globe { animation: trek-globe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } .trek-coin { animation: trek-coin 2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; } .trek-page { animation: trek-page 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; } @@ -323,7 +329,7 @@ .trek-bounce--idle, .trek-bounce--polls, .trek-bounce--transport, .trek-board, .trek-wheel, .trek-flag, .trek-ticket, .trek-note, .trek-pencil, .trek-write, .trek-bar, .trek-suitcase, .trek-handle, .trek-pin, .trek-globe, .trek-coin, .trek-page, .trek-plane, - .trek-magnifier, .trek-z, .trek-check { animation: none; } + .trek-magnifier, .trek-z, .trek-check, .trek-chain, .trek-link { animation: none; } /* Zzz would sit invisible at opacity 0 without their animation — pin them visible */ .trek-z { opacity: 1; } }