Skip to content

Commit 252c218

Browse files
committed
feat: implement sidebar panel toggling with chat and settings components release:patch
1 parent e04011e commit 252c218

6 files changed

Lines changed: 574 additions & 46 deletions

File tree

‎apps/web/src/app/session/[id]/page.test.tsx‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ vi.mock('@/hooks/useWebRTC', () => ({
3030
useWebRTC: () => mockUseWebRTC,
3131
}));
3232

33+
vi.mock('@/components/chat/ChatPanel', () => ({
34+
ChatPanel: ({ sessionId }: { sessionId: string }) => (
35+
<div data-testid="chat-panel">Chat for {sessionId}</div>
36+
),
37+
}));
38+
39+
vi.mock('@/components/session/SessionSettingsPanel', () => ({
40+
SessionSettingsPanel: () => <div data-testid="session-settings-panel">Settings Panel</div>,
41+
}));
42+
3343
// Create a stable promise that resolves immediately
3444
function createResolvedParams(id: string) {
3545
return Promise.resolve({ id });
@@ -348,4 +358,122 @@ describe('SessionViewerPage', () => {
348358
expect(screen.queryByText('Left User')).not.toBeInTheDocument();
349359
});
350360
});
361+
362+
describe('Sidebar panel toggling', () => {
363+
const mockSessionData = {
364+
id: 'session-123',
365+
join_code: 'ABC123',
366+
status: 'active',
367+
settings: { quality: 'medium', allowControl: false, maxParticipants: 5 },
368+
created_at: '2024-01-01T00:00:00Z',
369+
session_participants: [
370+
{
371+
id: 'p-1',
372+
display_name: 'Host User',
373+
role: 'host',
374+
control_state: 'granted',
375+
joined_at: '2024-01-01T00:00:00Z',
376+
},
377+
],
378+
};
379+
380+
beforeEach(() => {
381+
vi.mocked(global.fetch).mockResolvedValue({
382+
ok: true,
383+
json: () => Promise.resolve({ data: mockSessionData }),
384+
} as Response);
385+
});
386+
387+
it('shows participants panel by default', async () => {
388+
const params = createResolvedParams('session-123');
389+
390+
await act(async () => {
391+
renderWithSuspense(<SessionViewerPage params={params} />);
392+
});
393+
394+
await waitFor(() => {
395+
expect(screen.getByText('Participants (1)')).toBeInTheDocument();
396+
});
397+
expect(screen.queryByTestId('chat-panel')).not.toBeInTheDocument();
398+
expect(screen.queryByTestId('session-settings-panel')).not.toBeInTheDocument();
399+
});
400+
401+
it('switches to chat panel when Chat button is clicked', async () => {
402+
const params = createResolvedParams('session-123');
403+
404+
await act(async () => {
405+
renderWithSuspense(<SessionViewerPage params={params} />);
406+
});
407+
408+
await waitFor(() => {
409+
expect(screen.getByRole('button', { name: /Chat/ })).toBeInTheDocument();
410+
});
411+
412+
fireEvent.click(screen.getByRole('button', { name: /Chat/ }));
413+
414+
expect(screen.getByTestId('chat-panel')).toBeInTheDocument();
415+
expect(screen.queryByText('Participants (1)')).not.toBeInTheDocument();
416+
});
417+
418+
it('switches to settings panel when Settings button is clicked', async () => {
419+
const params = createResolvedParams('session-123');
420+
421+
await act(async () => {
422+
renderWithSuspense(<SessionViewerPage params={params} />);
423+
});
424+
425+
await waitFor(() => {
426+
expect(screen.getByRole('button', { name: /Settings/ })).toBeInTheDocument();
427+
});
428+
429+
fireEvent.click(screen.getByRole('button', { name: /Settings/ }));
430+
431+
expect(screen.getByTestId('session-settings-panel')).toBeInTheDocument();
432+
expect(screen.queryByText('Participants (1)')).not.toBeInTheDocument();
433+
});
434+
435+
it('closes sidebar when active panel button is clicked again', async () => {
436+
const params = createResolvedParams('session-123');
437+
438+
await act(async () => {
439+
renderWithSuspense(<SessionViewerPage params={params} />);
440+
});
441+
442+
await waitFor(() => {
443+
expect(screen.getByText('Participants (1)')).toBeInTheDocument();
444+
});
445+
446+
// Click Participants button to toggle off
447+
fireEvent.click(screen.getByRole('button', { name: /Participants/ }));
448+
449+
expect(screen.queryByText('Participants (1)')).not.toBeInTheDocument();
450+
expect(screen.queryByTestId('chat-panel')).not.toBeInTheDocument();
451+
});
452+
453+
it('switches between panels without closing sidebar', async () => {
454+
const params = createResolvedParams('session-123');
455+
456+
await act(async () => {
457+
renderWithSuspense(<SessionViewerPage params={params} />);
458+
});
459+
460+
await waitFor(() => {
461+
expect(screen.getByText('Participants (1)')).toBeInTheDocument();
462+
});
463+
464+
// Switch to chat
465+
fireEvent.click(screen.getByRole('button', { name: /Chat/ }));
466+
expect(screen.getByTestId('chat-panel')).toBeInTheDocument();
467+
468+
// Switch to settings
469+
fireEvent.click(screen.getByRole('button', { name: /Settings/ }));
470+
expect(screen.getByTestId('session-settings-panel')).toBeInTheDocument();
471+
expect(screen.queryByTestId('chat-panel')).not.toBeInTheDocument();
472+
473+
// Switch back to participants
474+
fireEvent.click(screen.getByRole('button', { name: /Participants/ }));
475+
expect(screen.getByText('Participants (1)')).toBeInTheDocument();
476+
expect(screen.queryByTestId('session-settings-panel')).not.toBeInTheDocument();
477+
});
478+
});
351479
});

‎apps/web/src/app/session/[id]/page.tsx‎

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import {
2222
InputCapture,
2323
CursorOverlay,
2424
} from '@/components/control';
25+
import { ChatPanel } from '@/components/chat/ChatPanel';
26+
import { SessionSettingsPanel } from '@/components/session/SessionSettingsPanel';
27+
28+
type SidebarPanel = 'participants' | 'chat' | 'settings' | null;
2529

2630
interface Participant {
2731
id: string;
@@ -56,8 +60,13 @@ export default function SessionViewerPage({ params }: { params: Promise<{ id: st
5660
const [loading, setLoading] = useState(true);
5761
const [error, setError] = useState('');
5862
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorPositionMessage>>(new Map());
63+
const [activePanel, setActivePanel] = useState<SidebarPanel>('participants');
5964
const videoContainerRef = useRef<HTMLDivElement>(null);
6065

66+
const togglePanel = useCallback((panel: SidebarPanel) => {
67+
setActivePanel((current) => (current === panel ? null : panel));
68+
}, []);
69+
6170
// Handle cursor updates from other participants
6271
const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => {
6372
setRemoteCursors((prev) => {
@@ -265,48 +274,104 @@ export default function SessionViewerPage({ params }: { params: Promise<{ id: st
265274
</button>
266275
<button
267276
type="button"
268-
className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-700"
277+
onClick={() => {
278+
togglePanel('participants');
279+
}}
280+
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
281+
activePanel === 'participants'
282+
? 'bg-blue-700 text-white hover:bg-blue-600'
283+
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
284+
}`}
285+
>
286+
<Users className="h-4 w-4" />
287+
Participants
288+
</button>
289+
<button
290+
type="button"
291+
onClick={() => {
292+
togglePanel('chat');
293+
}}
294+
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
295+
activePanel === 'chat'
296+
? 'bg-blue-700 text-white hover:bg-blue-600'
297+
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
298+
}`}
269299
>
270300
<MessageSquare className="h-4 w-4" />
271301
Chat
272302
</button>
273303
<button
274304
type="button"
275-
className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-700"
305+
onClick={() => {
306+
togglePanel('settings');
307+
}}
308+
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
309+
activePanel === 'settings'
310+
? 'bg-blue-700 text-white hover:bg-blue-600'
311+
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
312+
}`}
276313
>
277314
<Settings className="h-4 w-4" />
278315
Settings
279316
</button>
280317
</div>
281318
</main>
282319

283-
{/* Participants sidebar */}
284-
<aside className="hidden w-64 flex-shrink-0 border-l border-gray-800 bg-gray-900 lg:block">
285-
<div className="p-4">
286-
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
287-
<Users className="h-4 w-4" />
288-
Participants ({activeParticipants.length})
289-
</h3>
290-
<ul className="mt-4 space-y-2">
291-
{activeParticipants.map((participant) => (
292-
<li
293-
key={participant.id}
294-
className="flex items-center justify-between rounded-lg bg-gray-800 px-3 py-2"
295-
>
296-
<div className="flex items-center gap-2">
297-
<div className="bg-primary-600 flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold text-white">
298-
{participant.display_name.charAt(0).toUpperCase()}
299-
</div>
300-
<div>
301-
<p className="text-sm font-medium text-white">{participant.display_name}</p>
302-
<p className="text-xs text-gray-500">{participant.role}</p>
303-
</div>
304-
</div>
305-
</li>
306-
))}
307-
</ul>
308-
</div>
309-
</aside>
320+
{/* Right sidebar — toggled by control bar buttons */}
321+
{activePanel && (
322+
<aside className="hidden w-72 flex-shrink-0 border-l border-gray-800 bg-gray-900 lg:block">
323+
{activePanel === 'participants' && (
324+
<div className="p-4">
325+
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
326+
<Users className="h-4 w-4" />
327+
Participants ({activeParticipants.length})
328+
</h3>
329+
<ul className="mt-4 space-y-2">
330+
{activeParticipants.map((participant) => (
331+
<li
332+
key={participant.id}
333+
className="flex items-center justify-between rounded-lg bg-gray-800 px-3 py-2"
334+
>
335+
<div className="flex items-center gap-2">
336+
<div className="bg-primary-600 flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold text-white">
337+
{participant.display_name.charAt(0).toUpperCase()}
338+
</div>
339+
<div>
340+
<p className="text-sm font-medium text-white">
341+
{participant.display_name}
342+
</p>
343+
<p className="text-xs text-gray-500">{participant.role}</p>
344+
</div>
345+
</div>
346+
</li>
347+
))}
348+
</ul>
349+
</div>
350+
)}
351+
352+
{activePanel === 'chat' && (
353+
<ChatPanel
354+
sessionId={sessionId}
355+
participantId={participantId}
356+
isCollapsed={false}
357+
onToggleCollapse={() => {
358+
setActivePanel(null);
359+
}}
360+
className="h-full !w-full !border-0"
361+
/>
362+
)}
363+
364+
{activePanel === 'settings' && (
365+
<SessionSettingsPanel
366+
session={session}
367+
connectionState={connectionState}
368+
qualityMetrics={qualityMetrics}
369+
networkQuality={networkQuality}
370+
participantCount={activeParticipants.length}
371+
/>
372+
)}
373+
</aside>
374+
)}
310375
</div>
311376
</div>
312377
);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { SessionSettingsPanel } from './SessionSettingsPanel';
4+
5+
// Mock lucide-react icons
6+
vi.mock('lucide-react', () => ({
7+
Settings: () => <span data-testid="settings-icon" />,
8+
Info: () => <span data-testid="info-icon" />,
9+
Wifi: () => <span data-testid="wifi-icon" />,
10+
Palette: () => <span data-testid="palette-icon" />,
11+
}));
12+
13+
const SETTINGS_KEY = 'pairux-web-settings';
14+
15+
describe('SessionSettingsPanel', () => {
16+
const defaultProps = {
17+
session: {
18+
id: 'session-123',
19+
join_code: 'ABC123',
20+
status: 'active',
21+
settings: { quality: 'medium', maxParticipants: 5 },
22+
created_at: '2024-01-01T00:00:00Z',
23+
session_participants: [],
24+
},
25+
connectionState: 'connected' as const,
26+
qualityMetrics: {
27+
bitrate: 1000000,
28+
frameRate: 30,
29+
packetLoss: 0.5,
30+
roundTripTime: 45,
31+
},
32+
networkQuality: 'good' as const,
33+
participantCount: 3,
34+
};
35+
36+
beforeEach(() => {
37+
localStorage.clear();
38+
});
39+
40+
it('renders session info', () => {
41+
render(<SessionSettingsPanel {...defaultProps} />);
42+
expect(screen.getByText('ABC123')).toBeInTheDocument();
43+
expect(screen.getByText('active')).toBeInTheDocument();
44+
expect(screen.getByText('3 / 5')).toBeInTheDocument();
45+
});
46+
47+
it('renders session settings heading', () => {
48+
render(<SessionSettingsPanel {...defaultProps} />);
49+
expect(screen.getByText('Session Settings')).toBeInTheDocument();
50+
});
51+
52+
it('displays connection quality metrics', () => {
53+
render(<SessionSettingsPanel {...defaultProps} />);
54+
expect(screen.getByText('Good')).toBeInTheDocument();
55+
expect(screen.getByText('30 fps')).toBeInTheDocument();
56+
expect(screen.getByText('0.5%')).toBeInTheDocument();
57+
expect(screen.getByText('45 ms')).toBeInTheDocument();
58+
});
59+
60+
it('renders without quality metrics', () => {
61+
render(<SessionSettingsPanel {...defaultProps} qualityMetrics={null} />);
62+
expect(screen.getByText('connected')).toBeInTheDocument();
63+
expect(screen.queryByText(/fps/)).not.toBeInTheDocument();
64+
});
65+
66+
it('shows participant count without max when not set', () => {
67+
const propsNoMax = {
68+
...defaultProps,
69+
session: { ...defaultProps.session, settings: { quality: 'medium' } },
70+
};
71+
render(<SessionSettingsPanel {...propsNoMax} />);
72+
expect(screen.getByText('3')).toBeInTheDocument();
73+
expect(screen.queryByText(/\//)).not.toBeInTheDocument();
74+
});
75+
76+
it('toggles theme and saves to localStorage', () => {
77+
render(<SessionSettingsPanel {...defaultProps} />);
78+
fireEvent.click(screen.getByRole('button', { name: /dark/i }));
79+
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as {
80+
appearance?: { theme?: string };
81+
};
82+
expect(saved.appearance?.theme).toBe('dark');
83+
});
84+
85+
it('loads saved theme from localStorage', () => {
86+
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ appearance: { theme: 'dark' } }));
87+
render(<SessionSettingsPanel {...defaultProps} />);
88+
const darkButton = screen.getByRole('button', { name: /dark/i });
89+
expect(darkButton.className).toContain('bg-blue-600');
90+
});
91+
92+
it('has data-testid for integration testing', () => {
93+
render(<SessionSettingsPanel {...defaultProps} />);
94+
expect(screen.getByTestId('session-settings-panel')).toBeInTheDocument();
95+
});
96+
});

0 commit comments

Comments
 (0)