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:
catlog22
2026-02-16 11:51:21 +08:00
parent 374a1e1c2c
commit 2202c2ccfd
35 changed files with 3717 additions and 145 deletions

View 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();
});
});

View 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;