Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/profile/profile-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ProfileUser {
bio: string;
learningGoal: string;
dailyLearningTime: string;
avatarUrl: string;
}

export interface SelectOption {
Expand Down Expand Up @@ -36,6 +37,7 @@ export const profileUser: ProfileUser = {
bio: 'Passionate about Web3 technologies and decentralized learning platforms.',
learningGoal: 'Complete 1 course per month',
dailyLearningTime: '30 minutes',
avatarUrl: '/avatars/default.png',
};

export const profileTabs: Array<{ id: ProfileTabId; label: string }> = [
Expand Down
44 changes: 36 additions & 8 deletions src/components/ExportButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ interface ExportButtonResult {

interface ExportButtonProps {
templateId: string;
label?: string;
label?: string;
className?: string;
filters?: ExportFilter[];
sort?: ExportSort[];
columns?: string[];
onComplete?: (result: ExportButtonResult) => void;
onError?: (error: Error) => void;
}

export function ExportButton({
Expand All @@ -29,13 +30,20 @@ export function ExportButton({
sort,
columns,
onComplete,
onError,
}: ExportButtonProps) {
const [isRunning, setIsRunning] = useState(false);
const [progress, setProgress] = useState<ExportProgressState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);

const isDisabled = isRunning || !templateId || templateId.trim() === '';

const handleClick = async () => {
if (isDisabled) return;

setIsRunning(true);
setIsError(false);
setMessage(null);
setProgress({
stage: 'preparing',
Expand All @@ -54,40 +62,54 @@ export function ExportButton({
},
);

if (!response?.result?.success) {
throw new Error('Export failed');
}

const finalProgress = response.result.progress?.[response.result.progress.length - 1] ?? {
stage: 'completed' as const,
percent: 100,
message: 'Export completed',
};

setProgress(finalProgress);
setIsError(false);
setMessage(
`${response.result.fileName} ready (${response.result.rowCount} rows, ${(
response.result.fileSize / 1024
).toFixed(2)} KB)`,
);
onComplete?.(response.result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error) || 'Export failed');
setProgress({
stage: 'completed',
stage: 'failed',
percent: 100,
message: 'Export failed',
message: err.message,
});
setMessage(error instanceof Error ? error.message : 'Export failed');
setIsError(true);
setMessage(err.message);
onError?.(err);
} finally {
setIsRunning(false);
}
};

const isFailedStage = progress?.stage === 'failed' || isError;

return (
<div className="space-y-2">
<button type="button" onClick={handleClick} disabled={isRunning} className={className}>
<button type="button" onClick={handleClick} disabled={isDisabled} className={className}>
{isRunning ? 'Exporting...' : label}
</button>

{progress && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-gray-500">
<div
className={`flex items-center justify-between text-xs ${
isFailedStage ? 'text-red-600 font-medium' : 'text-gray-500'
}`}
>
<span>{progress.message}</span>
<span>{progress.percent}%</span>
</div>
Expand All @@ -97,14 +119,20 @@ export function ExportButton({
aria-valuenow={progress.percent}
>
<div
className="h-2 rounded-full bg-blue-600 transition-all"
className={`h-2 rounded-full transition-all ${
isFailedStage ? 'bg-red-600' : 'bg-blue-600'
}`}
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}

{message && <p className="text-xs text-gray-600">{message}</p>}
{message && (
<p className={`text-xs ${isFailedStage ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
{message}
</p>
)}
</div>
);
}
Expand Down
82 changes: 82 additions & 0 deletions src/components/__tests__/ExportButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExportButton } from '../ExportButton';
import { apiClient } from '@/lib/api';

vi.mock('@/lib/api', () => ({
apiClient: {
post: vi.fn(),
},
}));

describe('ExportButton Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('disables the button when templateId is empty or whitespace', () => {
const { rerender } = render(<ExportButton templateId="" label="Export Data" />);
const button = screen.getByRole('button', { name: 'Export Data' });
expect(button).toBeDisabled();

rerender(<ExportButton templateId=" " label="Export Data" />);
expect(button).toBeDisabled();
});

it('enables the button when valid templateId is provided', () => {
render(<ExportButton templateId="template-123" label="Export Data" />);
const button = screen.getByRole('button', { name: 'Export Data' });
expect(button).not.toBeDisabled();
});

it('handles error state properly and displays red error text and failed stage', async () => {
const onError = vi.fn();
(apiClient.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error('Server Error: Failed to execute export'),
);

render(<ExportButton templateId="template-123" onError={onError} />);

const button = screen.getByRole('button', { name: 'Run Export' });
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByText('Server Error: Failed to execute export')).toBeInTheDocument();
});

const errorMessage = screen.getByText('Server Error: Failed to execute export');
expect(errorMessage).toHaveClass('text-red-600');
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});

it('handles successful export flow', async () => {
const onComplete = vi.fn();
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
result: {
success: true,
fileName: 'report.csv',
fileSize: 2048,
contentType: 'text/csv',
rowCount: 50,
progress: [{ stage: 'completed', percent: 100, message: 'Done' }],
},
});

render(<ExportButton templateId="template-123" onComplete={onComplete} />);

const button = screen.getByRole('button', { name: 'Run Export' });
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByText(/report\.csv ready/i)).toBeInTheDocument();
});

expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'report.csv',
rowCount: 50,
}),
);
});
});
12 changes: 8 additions & 4 deletions src/components/search/IntelligentAutoComplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Search, X, Clock, ChevronRight, Sparkles, User, Tag, Type } from 'lucide-react';
import { getSearchSuggestions, highlightMatch } from '../../utils/searchUtils';
import { useDebounce } from '../../hooks/useDebounce';

interface IntelligentAutoCompleteProps {
value: string;
onChange: (value: string) => void;
onSearch: (value: string) => void;
history?: string[];
debounceMs?: number;
}

export const IntelligentAutoComplete = React.memo<IntelligentAutoCompleteProps>(
({ value, onChange, onSearch, history = [] }) => {
({ value, onChange, onSearch, history = [], debounceMs = 300 }) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const suggestions = useMemo(() => getSearchSuggestions(value), [value]);

const debouncedValue = useDebounce(value, debounceMs);
const suggestions = useMemo(() => getSearchSuggestions(debouncedValue), [debouncedValue]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
Expand Down Expand Up @@ -154,11 +158,11 @@ export const IntelligentAutoComplete = React.memo<IntelligentAutoCompleteProps>(
{getSuggestionIcon(suggestion)}
</div>
<span className="flex-1 truncate">
{highlightMatch(suggestion, value).map((part, index) => (
{highlightMatch(suggestion, debouncedValue).map((part, index) => (
<span
key={index}
className={
part.toLowerCase() === value.toLowerCase()
part.toLowerCase() === debouncedValue.toLowerCase()
? 'font-bold text-primary'
: ''
}
Expand Down
69 changes: 69 additions & 0 deletions src/components/search/__tests__/IntelligentAutoComplete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { IntelligentAutoComplete } from '../IntelligentAutoComplete';
import * as searchUtils from '../../../utils/searchUtils';

describe('IntelligentAutoComplete Component - Debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('renders input with given initial value', () => {
const onChange = vi.fn();
const onSearch = vi.fn();

render(<IntelligentAutoComplete value="react" onChange={onChange} onSearch={onSearch} />);

const input = screen.getByPlaceholderText('Search for knowledge, authors, or topics...');
expect(input).toHaveValue('react');
});

it('debounces calls to getSearchSuggestions when typing', () => {
const getSearchSuggestionsSpy = vi.spyOn(searchUtils, 'getSearchSuggestions');
const onChange = vi.fn();
const onSearch = vi.fn();

const { rerender } = render(
<IntelligentAutoComplete value="" onChange={onChange} onSearch={onSearch} debounceMs={300} />,
);

const initialCalls = getSearchSuggestionsSpy.mock.calls.length;

// Simulate typing multiple characters sequentially
rerender(<IntelligentAutoComplete value="c" onChange={onChange} onSearch={onSearch} debounceMs={300} />);
rerender(<IntelligentAutoComplete value="ca" onChange={onChange} onSearch={onSearch} debounceMs={300} />);
rerender(<IntelligentAutoComplete value="cai" onChange={onChange} onSearch={onSearch} debounceMs={300} />);
rerender(<IntelligentAutoComplete value="cair" onChange={onChange} onSearch={onSearch} debounceMs={300} />);
rerender(<IntelligentAutoComplete value="cairo" onChange={onChange} onSearch={onSearch} debounceMs={300} />);

// Before timer advances, getSearchSuggestions should not have been called for intermediate keystrokes
expect(getSearchSuggestionsSpy.mock.calls.length).toBe(initialCalls);

// Advance time by 300ms
act(() => {
vi.advanceTimersByTime(300);
});

// Now getSearchSuggestions should be called once with the final debounced value
expect(getSearchSuggestionsSpy.mock.calls.length).toBe(initialCalls + 1);
expect(getSearchSuggestionsSpy).toHaveBeenLastCalledWith('cairo');
});

it('triggers onSearch when Enter is pressed without active dropdown selection', () => {
const onChange = vi.fn();
const onSearch = vi.fn();

render(<IntelligentAutoComplete value="starknet" onChange={onChange} onSearch={onSearch} />);

const input = screen.getByPlaceholderText('Search for knowledge, authors, or topics...');
fireEvent.keyDown(input, { key: 'Enter' });

expect(onSearch).toHaveBeenCalledWith('starknet');
});
});
2 changes: 1 addition & 1 deletion src/lib/export/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface ExportSort {
}

export interface ExportProgressState {
stage: 'preparing' | 'filtering' | 'formatting' | 'completed';
stage: 'preparing' | 'filtering' | 'formatting' | 'completed' | 'failed';
percent: number;
message: string;
}
Expand Down
2 changes: 2 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,5 @@ export interface ReviewApprovalRequest {
status: ReviewDecision;
reviewNote?: string;
}

export type { Topic } from '@/utils/socialUtils';
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
VideoNote,
UserProgress,
AnalyticsEventPayload,
Topic,
} from './api';

export type {
Expand Down
Loading