mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points
test: implement DialogStyleContext tests for preference management and style recommendations test: create tests for useAutoSelection hook, including countdown and pause functionality feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications feat: create Zustand store for managing issue submission wizard state feat: add Zod validation schemas for issue-related API requests feat: implement issue service for CRUD operations and validation handling feat: define TypeScript types for issue submission and management
This commit is contained in:
212
ccw/frontend/src/hooks/__tests__/useAutoSelection.test.ts
Normal file
212
ccw/frontend/src/hooks/__tests__/useAutoSelection.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// ========================================
|
||||
// useAutoSelection Hook Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAutoSelection, useInteractionPause } from '../useAutoSelection';
|
||||
|
||||
// Mock DialogStyleContext
|
||||
vi.mock('@/contexts/DialogStyleContext', () => ({
|
||||
useDialogStyleContext: () => ({
|
||||
preferences: {
|
||||
autoSelectionDuration: 30,
|
||||
pauseOnInteraction: true,
|
||||
autoSelectionSoundEnabled: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useAutoSelection', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return null remaining when no timeout provided', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.remaining).toBeNull();
|
||||
expect(result.current.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should count down from timeout', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.remaining).toBe(10);
|
||||
expect(result.current.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should pause and resume countdown', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.pause();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.resume();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle pause state', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.togglePause();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.togglePause();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('should calculate progress percentage', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
// At 10 seconds remaining out of 30, progress should be ~67%
|
||||
// But we use the calculated duration from timeout, so it starts at 0
|
||||
expect(result.current.progress).toBeGreaterThanOrEqual(0);
|
||||
expect(result.current.progress).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('should call onAutoSelect when countdown reaches 0', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(onAutoSelect).toHaveBeenCalledWith('Yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useInteractionPause', () => {
|
||||
it('should return event handlers', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, true)
|
||||
);
|
||||
|
||||
expect(result.current.onMouseEnter).toBeInstanceOf(Function);
|
||||
expect(result.current.onMouseLeave).toBeInstanceOf(Function);
|
||||
expect(result.current.onFocus).toBeInstanceOf(Function);
|
||||
expect(result.current.onBlur).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should call pause on mouse enter when enabled', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, true)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onMouseEnter();
|
||||
});
|
||||
|
||||
expect(pause).toHaveBeenCalled();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call resume on mouse leave when enabled', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, true)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onMouseLeave();
|
||||
});
|
||||
|
||||
expect(resume).toHaveBeenCalled();
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call handlers when disabled', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, false)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onMouseEnter();
|
||||
result.current.onMouseLeave();
|
||||
});
|
||||
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
202
ccw/frontend/src/hooks/useAutoSelection.ts
Normal file
202
ccw/frontend/src/hooks/useAutoSelection.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// ========================================
|
||||
// useAutoSelection Hook
|
||||
// ========================================
|
||||
// Enhanced auto-selection with pause and sound notification
|
||||
// Supports configurable duration and interaction pause
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDialogStyleContext } from '@/contexts/DialogStyleContext';
|
||||
|
||||
export interface AutoSelectionOptions {
|
||||
/** Timeout timestamp from backend (ISO string) */
|
||||
timeoutAt?: string;
|
||||
/** Default label to auto-select */
|
||||
defaultLabel?: string;
|
||||
/** Question ID for the action */
|
||||
questionId?: string;
|
||||
/** Callback when auto-selection triggers */
|
||||
onAutoSelect: (defaultLabel: string) => void;
|
||||
}
|
||||
|
||||
export interface AutoSelectionState {
|
||||
/** Remaining seconds until auto-selection */
|
||||
remaining: number | null;
|
||||
/** Whether countdown is paused */
|
||||
isPaused: boolean;
|
||||
/** Whether auto-selection is enabled */
|
||||
isEnabled: boolean;
|
||||
/** Progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Pause the countdown */
|
||||
pause: () => void;
|
||||
/** Resume the countdown */
|
||||
resume: () => void;
|
||||
/** Toggle pause state */
|
||||
togglePause: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing auto-selection countdown with pause support
|
||||
*/
|
||||
export function useAutoSelection(options: AutoSelectionOptions): AutoSelectionState {
|
||||
const { timeoutAt, defaultLabel, onAutoSelect } = options;
|
||||
const { preferences } = useDialogStyleContext();
|
||||
|
||||
const [remaining, setRemaining] = useState<number | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [totalDuration, setTotalDuration] = useState<number>(preferences.autoSelectionDuration);
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const soundPlayedRef = useRef(false);
|
||||
|
||||
// Calculate total duration from timeout or preferences
|
||||
useEffect(() => {
|
||||
if (timeoutAt) {
|
||||
const target = new Date(timeoutAt).getTime();
|
||||
const now = Date.now();
|
||||
const calculated = Math.ceil((target - now) / 1000);
|
||||
if (calculated > 0) {
|
||||
setTotalDuration(calculated);
|
||||
}
|
||||
} else {
|
||||
setTotalDuration(preferences.autoSelectionDuration);
|
||||
}
|
||||
}, [timeoutAt, preferences.autoSelectionDuration]);
|
||||
|
||||
// Countdown logic
|
||||
useEffect(() => {
|
||||
if (!timeoutAt || !defaultLabel) {
|
||||
setRemaining(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = new Date(timeoutAt).getTime();
|
||||
soundPlayedRef.current = false;
|
||||
|
||||
const tick = () => {
|
||||
if (isPaused) return;
|
||||
|
||||
const secs = Math.max(0, Math.ceil((target - Date.now()) / 1000));
|
||||
setRemaining(secs);
|
||||
|
||||
// Play sound notification 3 seconds before
|
||||
if (secs <= 3 && secs > 0 && !soundPlayedRef.current && preferences.autoSelectionSoundEnabled) {
|
||||
playNotificationSound();
|
||||
soundPlayedRef.current = true;
|
||||
}
|
||||
|
||||
// Auto-select when countdown reaches 0
|
||||
if (secs === 0) {
|
||||
onAutoSelect(defaultLabel);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [timeoutAt, defaultLabel, isPaused, onAutoSelect, preferences.autoSelectionSoundEnabled]);
|
||||
|
||||
// Pause/Resume handlers
|
||||
const pause = useCallback(() => {
|
||||
if (preferences.pauseOnInteraction) {
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, [preferences.pauseOnInteraction]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
setIsPaused((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Calculate progress
|
||||
const progress = remaining !== null && totalDuration > 0
|
||||
? Math.round(((totalDuration - remaining) / totalDuration) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
remaining,
|
||||
isPaused,
|
||||
isEnabled: !!timeoutAt && !!defaultLabel,
|
||||
progress,
|
||||
pause,
|
||||
resume,
|
||||
togglePause,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a short notification sound
|
||||
*/
|
||||
function playNotificationSound(): void {
|
||||
try {
|
||||
// Create a simple beep using Web Audio API
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 880; // A5 note
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
} catch (error) {
|
||||
console.warn('[useAutoSelection] Could not play notification sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling interaction pause
|
||||
* Automatically pauses countdown when user interacts with the dialog
|
||||
*/
|
||||
export function useInteractionPause(
|
||||
pause: () => void,
|
||||
resume: () => void,
|
||||
enabled: boolean
|
||||
) {
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (enabled) {
|
||||
pause();
|
||||
}
|
||||
}, [pause, enabled]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (enabled) {
|
||||
resume();
|
||||
}
|
||||
}, [resume, enabled]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (enabled) {
|
||||
pause();
|
||||
}
|
||||
}, [pause, enabled]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (enabled) {
|
||||
resume();
|
||||
}
|
||||
}, [resume, enabled]);
|
||||
|
||||
return {
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAutoSelection;
|
||||
Reference in New Issue
Block a user