Move tabs
This commit is contained in:
parent
9ef8f7343d
commit
3122748bae
3 changed files with 118 additions and 68 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react';
|
import { ArrowLeft, Save, Plus, Play, AlertTriangle, List, Settings } from 'lucide-react';
|
||||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types';
|
import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types';
|
||||||
|
|
@ -40,6 +40,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
|
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
|
||||||
);
|
);
|
||||||
const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config);
|
const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config);
|
||||||
|
const [activeTab, setActiveTab] = useState<'questions' | 'settings'>('questions');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) {
|
if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) {
|
||||||
|
|
@ -215,60 +216,96 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
|
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4 flex-1 min-h-0 overflow-y-auto">
|
<div className="flex bg-white border-b border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<button
|
||||||
<h2 className="text-lg font-bold text-gray-700">Questions</h2>
|
onClick={() => setActiveTab('questions')}
|
||||||
<button
|
className={`flex-1 py-3 font-bold text-center border-b-2 transition-all ${
|
||||||
onClick={handleAddQuestion}
|
activeTab === 'questions'
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-theme-primary text-white rounded-xl font-bold hover:bg-theme-primary/90 transition shadow-md"
|
? 'border-theme-primary text-theme-primary bg-theme-primary/5'
|
||||||
>
|
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||||
<Plus size={18} /> Add Question
|
}`}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
>
|
||||||
<SortableContext
|
<div className="flex items-center justify-center gap-2">
|
||||||
items={quiz.questions.map(q => q.id)}
|
<List size={18} />
|
||||||
strategy={verticalListSortingStrategy}
|
<span>Questions</span>
|
||||||
>
|
</div>
|
||||||
<div className="space-y-3">
|
</button>
|
||||||
<AnimatePresence>
|
<button
|
||||||
{quiz.questions.map((question, index) => (
|
onClick={() => setActiveTab('settings')}
|
||||||
<SortableQuestionCard
|
className={`flex-1 py-3 font-bold text-center border-b-2 transition-all ${
|
||||||
key={question.id}
|
activeTab === 'settings'
|
||||||
question={question}
|
? 'border-theme-primary text-theme-primary bg-theme-primary/5'
|
||||||
index={index}
|
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||||
isExpanded={expandedId === question.id}
|
}`}
|
||||||
onToggleExpand={() => setExpandedId(expandedId === question.id ? null : question.id)}
|
>
|
||||||
onEdit={() => setEditingQuestion(question)}
|
<div className="flex items-center justify-center gap-2">
|
||||||
onDelete={() => quiz.questions.length > 1 ? setShowDeleteConfirm(question.id) : null}
|
<Settings size={18} />
|
||||||
/>
|
<span>Settings</span>
|
||||||
))}
|
</div>
|
||||||
</AnimatePresence>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{quiz.questions.length === 0 && (
|
<div className="p-6 space-y-4 flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="text-center py-12 text-gray-400">
|
{activeTab === 'questions' ? (
|
||||||
<p className="font-bold text-lg">No questions yet</p>
|
<>
|
||||||
<p className="text-sm">Click "Add Question" to get started</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-gray-700">Questions</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleAddQuestion}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-theme-primary text-white rounded-xl font-bold hover:bg-theme-primary/90 transition shadow-md"
|
||||||
|
>
|
||||||
|
<Plus size={18} /> Add Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={quiz.questions.map(q => q.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{quiz.questions.map((question, index) => (
|
||||||
|
<SortableQuestionCard
|
||||||
|
key={question.id}
|
||||||
|
question={question}
|
||||||
|
index={index}
|
||||||
|
isExpanded={expandedId === question.id}
|
||||||
|
onToggleExpand={() => setExpandedId(expandedId === question.id ? null : question.id)}
|
||||||
|
onEdit={() => setEditingQuestion(question)}
|
||||||
|
onDelete={() => quiz.questions.length > 1 ? setShowDeleteConfirm(question.id) : null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{quiz.questions.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="font-bold text-lg">No questions yet</p>
|
||||||
|
<p className="text-sm">Click "Add Question" to get started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-700">Game Settings</h2>
|
||||||
|
<GameConfigPanel
|
||||||
|
config={config}
|
||||||
|
onChange={handleConfigChange}
|
||||||
|
questionCount={quiz.questions.length}
|
||||||
|
compact={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
|
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
|
||||||
<GameConfigPanel
|
|
||||||
config={config}
|
|
||||||
onChange={handleConfigChange}
|
|
||||||
questionCount={quiz.questions.length}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStartGame}
|
onClick={handleStartGame}
|
||||||
disabled={!canStartGame}
|
disabled={!canStartGame}
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,7 @@ describe('QuizEditor - Async Default Config Loading', () => {
|
||||||
defaultConfig: DEFAULT_GAME_CONFIG,
|
defaultConfig: DEFAULT_GAME_CONFIG,
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(screen.getByRole('button', { name: /Settings/i }));
|
||||||
await user.click(screen.getByText('Host Participates'));
|
await user.click(screen.getByText('Host Participates'));
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,22 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const findTab = (name: 'Questions' | 'Settings') => {
|
||||||
|
const tabButtons = screen.getAllByRole('button');
|
||||||
|
return tabButtons.find(btn => {
|
||||||
|
const text = btn.textContent || '';
|
||||||
|
if (name === 'Questions') {
|
||||||
|
return text.includes('Questions') && !text.includes('Add');
|
||||||
|
}
|
||||||
|
return text.includes('Settings');
|
||||||
|
})!;
|
||||||
|
};
|
||||||
|
|
||||||
describe('config initialization', () => {
|
describe('config initialization', () => {
|
||||||
it('uses DEFAULT_GAME_CONFIG when quiz has no config', () => {
|
it('uses DEFAULT_GAME_CONFIG when quiz has no config', () => {
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByText('Game Settings')).toBeInTheDocument();
|
expect(findTab('Settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses quiz config when provided', async () => {
|
it('uses quiz config when provided', async () => {
|
||||||
|
|
@ -67,7 +78,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
|
|
||||||
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
|
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
|
|
||||||
const getCheckbox = (labelText: string) => {
|
const getCheckbox = (labelText: string) => {
|
||||||
const label = screen.getByText(labelText);
|
const label = screen.getByText(labelText);
|
||||||
|
|
@ -88,7 +99,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
|
|
||||||
render(<QuizEditor {...defaultProps} defaultConfig={customDefault} />);
|
render(<QuizEditor {...defaultProps} defaultConfig={customDefault} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
|
|
||||||
const getCheckbox = (labelText: string) => {
|
const getCheckbox = (labelText: string) => {
|
||||||
const label = screen.getByText(labelText);
|
const label = screen.getByText(labelText);
|
||||||
|
|
@ -116,7 +127,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
|
|
||||||
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} defaultConfig={customDefault} />);
|
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} defaultConfig={customDefault} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
|
|
||||||
const getCheckbox = (labelText: string) => {
|
const getCheckbox = (labelText: string) => {
|
||||||
const label = screen.getByText(labelText);
|
const label = screen.getByText(labelText);
|
||||||
|
|
@ -130,31 +141,32 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('config panel interactions', () => {
|
describe('config panel interactions', () => {
|
||||||
it('renders collapsed Game Settings by default (compact mode)', () => {
|
it('shows Questions tab by default', () => {
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
expect(screen.getByText('Game Settings')).toBeInTheDocument();
|
expect(findTab('Questions')).toBeInTheDocument();
|
||||||
|
expect(findTab('Settings')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
|
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands config panel when Game Settings is clicked', async () => {
|
it('shows config panel when Settings tab is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
|
|
||||||
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Host Participates')).toBeInTheDocument();
|
expect(screen.getByText('Host Participates')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapses config panel when Game Settings is clicked again', async () => {
|
it('switches back to Questions tab when clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Questions'));
|
||||||
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
|
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -162,7 +174,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Shuffle Questions'));
|
await user.click(screen.getByText('Shuffle Questions'));
|
||||||
|
|
||||||
expect(mockOnConfigChange).toHaveBeenCalledWith({
|
expect(mockOnConfigChange).toHaveBeenCalledWith({
|
||||||
|
|
@ -175,7 +187,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Shuffle Questions'));
|
await user.click(screen.getByText('Shuffle Questions'));
|
||||||
await user.click(screen.getByText('Shuffle Answers'));
|
await user.click(screen.getByText('Shuffle Answers'));
|
||||||
|
|
||||||
|
|
@ -203,7 +215,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Shuffle Questions'));
|
await user.click(screen.getByText('Shuffle Questions'));
|
||||||
|
|
||||||
await user.click(screen.getByText(/Start Game/));
|
await user.click(screen.getByText(/Start Game/));
|
||||||
|
|
@ -278,7 +290,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Shuffle Questions'));
|
await user.click(screen.getByText('Shuffle Questions'));
|
||||||
|
|
||||||
await user.click(screen.getByText('Test Quiz'));
|
await user.click(screen.getByText('Test Quiz'));
|
||||||
|
|
@ -303,9 +315,10 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Host Participates'));
|
await user.click(screen.getByText('Host Participates'));
|
||||||
|
|
||||||
|
await user.click(findTab('Questions'));
|
||||||
await user.click(screen.getByText('Add Question'));
|
await user.click(screen.getByText('Add Question'));
|
||||||
|
|
||||||
expect(mockOnConfigChange).toHaveBeenCalledWith(
|
expect(mockOnConfigChange).toHaveBeenCalledWith(
|
||||||
|
|
@ -359,7 +372,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} onConfigChange={undefined} />);
|
render(<QuizEditor {...defaultProps} onConfigChange={undefined} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Shuffle Questions'));
|
await user.click(screen.getByText('Shuffle Questions'));
|
||||||
|
|
||||||
await user.click(screen.getByText(/Start Game/));
|
await user.click(screen.getByText(/Start Game/));
|
||||||
|
|
@ -397,7 +410,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuizEditor {...defaultProps} />);
|
render(<QuizEditor {...defaultProps} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
await user.click(screen.getByText('Shuffle Questions'));
|
await user.click(screen.getByText('Shuffle Questions'));
|
||||||
|
|
||||||
expect(mockOnConfigChange).toHaveBeenCalledTimes(1);
|
expect(mockOnConfigChange).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -411,7 +424,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
|
|
||||||
render(<QuizEditor {...defaultProps} quiz={quizWithStreak} />);
|
render(<QuizEditor {...defaultProps} quiz={quizWithStreak} />);
|
||||||
|
|
||||||
await user.click(screen.getByText('Game Settings'));
|
await user.click(findTab('Settings'));
|
||||||
|
|
||||||
const thresholdInput = screen.getByDisplayValue('3');
|
const thresholdInput = screen.getByDisplayValue('3');
|
||||||
await user.clear(thresholdInput);
|
await user.clear(thresholdInput);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue