Fix sharing
This commit is contained in:
parent
1bfe7e3437
commit
3e9a988748
6 changed files with 88 additions and 13 deletions
2
App.tsx
2
App.tsx
|
|
@ -147,7 +147,7 @@ function App() {
|
||||||
<div className="h-screen text-white relative overflow-hidden">
|
<div className="h-screen text-white relative overflow-hidden">
|
||||||
<FloatingShapes />
|
<FloatingShapes />
|
||||||
<div className="relative z-10 h-full">
|
<div className="relative z-10 h-full">
|
||||||
<SharedQuizView onHostQuiz={(sharedQuiz) => loadSavedQuiz(sharedQuiz)} />
|
<SharedQuizView onHostQuiz={(sharedQuiz) => loadSavedQuiz(sharedQuiz)} shareToken={sharedMatch![1]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { BrainCircuit, Play, Loader2, BookmarkPlus, ChevronDown, ChevronUp, AlertCircle, LogIn } from 'lucide-react';
|
import { BrainCircuit, Play, Loader2, BookmarkPlus, ChevronDown, ChevronUp, AlertCircle, LogIn } from 'lucide-react';
|
||||||
|
|
@ -20,10 +20,11 @@ interface SharedQuizData {
|
||||||
|
|
||||||
interface SharedQuizViewProps {
|
interface SharedQuizViewProps {
|
||||||
onHostQuiz: (quiz: Quiz) => void;
|
onHostQuiz: (quiz: Quiz) => void;
|
||||||
|
shareToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SharedQuizView: React.FC<SharedQuizViewProps> = ({ onHostQuiz }) => {
|
export const SharedQuizView: React.FC<SharedQuizViewProps> = ({ onHostQuiz, shareToken }) => {
|
||||||
const { token } = useParams<{ token: string }>();
|
const token = shareToken;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const { authFetch } = useAuthenticatedFetch();
|
const { authFetch } = useAuthenticatedFetch();
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ export const useGame = () => {
|
||||||
if (auth.isLoading) return;
|
if (auth.isLoading) return;
|
||||||
|
|
||||||
const getTargetPath = () => {
|
const getTargetPath = () => {
|
||||||
if (location.pathname === '/callback') {
|
if (location.pathname === '/callback' || location.pathname.startsWith('/shared/')) {
|
||||||
return '/';
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (gameState) {
|
switch (gameState) {
|
||||||
|
|
@ -162,7 +162,7 @@ export const useGame = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetPath = getTargetPath();
|
const targetPath = getTargetPath();
|
||||||
if (location.pathname !== targetPath) {
|
if (targetPath !== null && location.pathname !== targetPath) {
|
||||||
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
|
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
|
||||||
navigate(targetPath + location.search, { replace: useReplace });
|
navigate(targetPath + location.search, { replace: useReplace });
|
||||||
}
|
}
|
||||||
|
|
@ -519,7 +519,7 @@ export const useGame = () => {
|
||||||
const initializeFromUrl = async () => {
|
const initializeFromUrl = async () => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
|
|
||||||
if (path === '/callback') {
|
if (path === '/callback' || path.startsWith('/shared/')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ if (!rootElement) {
|
||||||
throw new Error("Could not find root element to mount to");
|
throw new Error("Could not find root element to mount to");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldSkipSigninCallback = !window.location.pathname.startsWith('/callback');
|
||||||
|
|
||||||
const onSigninCallback = () => {
|
const onSigninCallback = () => {
|
||||||
window.history.replaceState({}, document.title, '/');
|
window.history.replaceState({}, document.title, '/');
|
||||||
};
|
};
|
||||||
|
|
@ -41,6 +43,7 @@ root.render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider
|
<AuthProvider
|
||||||
{...oidcConfig}
|
{...oidcConfig}
|
||||||
|
skipSigninCallback={shouldSkipSigninCallback}
|
||||||
onSigninCallback={onSigninCallback}
|
onSigninCallback={onSigninCallback}
|
||||||
onRemoveUser={() => {
|
onRemoveUser={() => {
|
||||||
localStorage.removeItem('kaboot_session');
|
localStorage.removeItem('kaboot_session');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { SharedQuizView } from '../../components/SharedQuizView';
|
import { SharedQuizView } from '../../components/SharedQuizView';
|
||||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock('react-router-dom', async () => {
|
||||||
|
|
@ -71,9 +71,7 @@ const renderWithRouter = (token: string = 'valid-token') => {
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[`/shared/${token}`]}>
|
<MemoryRouter initialEntries={[`/shared/${token}`]}>
|
||||||
<Routes>
|
<SharedQuizView onHostQuiz={onHostQuiz} shareToken={token} />
|
||||||
<Route path="/shared/:token" element={<SharedQuizView onHostQuiz={onHostQuiz} />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ describe('URL Routing and Navigation', () => {
|
||||||
role: 'HOST' | 'CLIENT',
|
role: 'HOST' | 'CLIENT',
|
||||||
currentPath: string,
|
currentPath: string,
|
||||||
sourceQuizId: string | null = null
|
sourceQuizId: string | null = null
|
||||||
): string => {
|
): string | null => {
|
||||||
|
if (currentPath === '/callback' || currentPath.startsWith('/shared/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
switch (gameState) {
|
switch (gameState) {
|
||||||
case 'LANDING':
|
case 'LANDING':
|
||||||
if (gamePin && currentPath.startsWith('/play/')) {
|
if (gamePin && currentPath.startsWith('/play/')) {
|
||||||
|
|
@ -42,6 +46,26 @@ describe('URL Routing and Navigation', () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('Shared quiz routes', () => {
|
||||||
|
it('should return null for /shared/:token routes to skip navigation', () => {
|
||||||
|
expect(getTargetPath('LANDING', null, 'HOST', '/shared/abc123')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for /shared/:token with complex token', () => {
|
||||||
|
expect(getTargetPath('LANDING', null, 'HOST', '/shared/6yC50NixzVuA-E_Z0JhEUA')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for /shared/:token regardless of gameState', () => {
|
||||||
|
expect(getTargetPath('LOBBY', '123456', 'HOST', '/shared/test-token')).toBeNull();
|
||||||
|
expect(getTargetPath('EDITING', null, 'HOST', '/shared/test-token')).toBeNull();
|
||||||
|
expect(getTargetPath('PODIUM', '123456', 'HOST', '/shared/test-token')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for /callback route', () => {
|
||||||
|
expect(getTargetPath('LANDING', null, 'HOST', '/callback')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('LANDING state', () => {
|
describe('LANDING state', () => {
|
||||||
it('should return "/" for LANDING state without gamePin', () => {
|
it('should return "/" for LANDING state without gamePin', () => {
|
||||||
expect(getTargetPath('LANDING', null, 'HOST', '/')).toBe('/');
|
expect(getTargetPath('LANDING', null, 'HOST', '/')).toBe('/');
|
||||||
|
|
@ -139,6 +163,10 @@ describe('URL Routing and Navigation', () => {
|
||||||
path: string,
|
path: string,
|
||||||
storedSession: StoredSession | null
|
storedSession: StoredSession | null
|
||||||
): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => {
|
): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => {
|
||||||
|
if (path === '/callback' || path.startsWith('/shared/')) {
|
||||||
|
return { action: 'skipInit' };
|
||||||
|
}
|
||||||
|
|
||||||
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
||||||
const playMatch = path.match(/^\/play\/(\d+)$/);
|
const playMatch = path.match(/^\/play\/(\d+)$/);
|
||||||
|
|
||||||
|
|
@ -182,6 +210,29 @@ describe('URL Routing and Navigation', () => {
|
||||||
return { action: 'showLanding' };
|
return { action: 'showLanding' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('/shared/:token URL', () => {
|
||||||
|
it('should skip initialization for shared quiz routes', () => {
|
||||||
|
const result = simulateUrlInit('/shared/abc123', null);
|
||||||
|
expect(result.action).toBe('skipInit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip initialization for shared routes with complex tokens', () => {
|
||||||
|
const result = simulateUrlInit('/shared/6yC50NixzVuA-E_Z0JhEUA', null);
|
||||||
|
expect(result.action).toBe('skipInit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip initialization for shared routes even with existing session', () => {
|
||||||
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
||||||
|
const result = simulateUrlInit('/shared/test-token', session);
|
||||||
|
expect(result.action).toBe('skipInit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip initialization for /callback route', () => {
|
||||||
|
const result = simulateUrlInit('/callback', null);
|
||||||
|
expect(result.action).toBe('skipInit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('/host/:pin URL', () => {
|
describe('/host/:pin URL', () => {
|
||||||
it('should reconnect as host when session matches', () => {
|
it('should reconnect as host when session matches', () => {
|
||||||
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
||||||
|
|
@ -551,6 +602,28 @@ describe('URL Routing and Navigation', () => {
|
||||||
expect(navigatedTo).toBe('/');
|
expect(navigatedTo).toBe('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT redirect /shared/:token to "/" (regression test)', () => {
|
||||||
|
const currentPath = '/shared/6yC50NixzVuA-E_Z0JhEUA';
|
||||||
|
const gameState = 'LANDING';
|
||||||
|
const gamePin: string | null = null;
|
||||||
|
|
||||||
|
const getTargetPath = () => {
|
||||||
|
if (currentPath.startsWith('/shared/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (gameState === 'LANDING' && !gamePin) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return currentPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPath = getTargetPath();
|
||||||
|
const shouldNavigate = targetPath !== null && currentPath !== targetPath;
|
||||||
|
|
||||||
|
expect(targetPath).toBeNull();
|
||||||
|
expect(shouldNavigate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should NOT get stuck on /play/:pin after goHomeFromDisconnected (regression test)', () => {
|
it('should NOT get stuck on /play/:pin after goHomeFromDisconnected (regression test)', () => {
|
||||||
let gameState = 'DISCONNECTED';
|
let gameState = 'DISCONNECTED';
|
||||||
let gamePin: string | null = '123456';
|
let gamePin: string | null = '123456';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue