From fdd41007da898f90646d65b375ecb93a888d7a0e Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 25 Jul 2026 16:36:47 +0100 Subject: [PATCH 1/5] fix(#986): debounce IntelligentAutoComplete suggestion computation --- .../search/IntelligentAutoComplete.tsx | 14 ++-- .../IntelligentAutoComplete.test.tsx | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/components/search/__tests__/IntelligentAutoComplete.test.tsx diff --git a/src/components/search/IntelligentAutoComplete.tsx b/src/components/search/IntelligentAutoComplete.tsx index 58c7f392..af5186b5 100644 --- a/src/components/search/IntelligentAutoComplete.tsx +++ b/src/components/search/IntelligentAutoComplete.tsx @@ -1,23 +1,27 @@ 'use client'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; +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( - ({ value, onChange, onSearch, history = [] }) => { + ({ value, onChange, onSearch, history = [], debounceMs = 300 }) => { const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const dropdownRef = useRef(null); const inputRef = useRef(null); - const suggestions = getSearchSuggestions(value); + + const debouncedValue = useDebounce(value, debounceMs); + const suggestions = useMemo(() => getSearchSuggestions(debouncedValue), [debouncedValue]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -154,11 +158,11 @@ export const IntelligentAutoComplete = React.memo( {getSuggestionIcon(suggestion)} - {highlightMatch(suggestion, value).map((part, index) => ( + {highlightMatch(suggestion, debouncedValue).map((part, index) => ( { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders input with given initial value', () => { + const onChange = vi.fn(); + const onSearch = vi.fn(); + + render(); + + 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( + , + ); + + const initialCalls = getSearchSuggestionsSpy.mock.calls.length; + + // Simulate typing multiple characters sequentially + rerender(); + rerender(); + rerender(); + rerender(); + rerender(); + + // 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(); + + const input = screen.getByPlaceholderText('Search for knowledge, authors, or topics...'); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSearch).toHaveBeenCalledWith('starknet'); + }); +}); From 1248cf21c36390790b187e6c25f7a4a838f683e6 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Wed, 29 Jul 2026 23:51:06 +0100 Subject: [PATCH 2/5] fix(search): remove duplicate suggestions variable declaration --- src/components/search/IntelligentAutoComplete.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/search/IntelligentAutoComplete.tsx b/src/components/search/IntelligentAutoComplete.tsx index 7cdbcfd5..af5186b5 100644 --- a/src/components/search/IntelligentAutoComplete.tsx +++ b/src/components/search/IntelligentAutoComplete.tsx @@ -22,7 +22,6 @@ export const IntelligentAutoComplete = React.memo( const debouncedValue = useDebounce(value, debounceMs); const suggestions = useMemo(() => getSearchSuggestions(debouncedValue), [debouncedValue]); - const suggestions = useMemo(() => getSearchSuggestions(value), [value]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { From 5f7297e8e85168a545da8201e1da639856cbd9f3 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Wed, 29 Jul 2026 23:57:39 +0100 Subject: [PATCH 3/5] fix(types): add avatarUrl to ProfileUser and export Topic from @/types/api --- src/app/profile/profile-data.ts | 1 + src/types/api.ts | 2 ++ src/types/index.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/src/app/profile/profile-data.ts b/src/app/profile/profile-data.ts index b60621c5..78ec7a34 100644 --- a/src/app/profile/profile-data.ts +++ b/src/app/profile/profile-data.ts @@ -7,6 +7,7 @@ export interface ProfileUser { bio: string; learningGoal: string; dailyLearningTime: string; + avatarUrl?: string; } export interface SelectOption { diff --git a/src/types/api.ts b/src/types/api.ts index 5fbc7886..54334133 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -133,3 +133,5 @@ export interface ReviewApprovalRequest { status: ReviewDecision; reviewNote?: string; } + +export type { Topic } from '@/utils/socialUtils'; diff --git a/src/types/index.ts b/src/types/index.ts index 28c14543..4d1e3b5e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,7 @@ export type { VideoNote, UserProgress, AnalyticsEventPayload, + Topic, } from './api'; export type { From 988e6cfcfbc5bba1787bd5032195be0e1556d549 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Thu, 30 Jul 2026 00:01:57 +0100 Subject: [PATCH 4/5] fix(profile): make avatarUrl required string with default fallback --- src/app/profile/profile-data.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/profile/profile-data.ts b/src/app/profile/profile-data.ts index 78ec7a34..1a8c8b78 100644 --- a/src/app/profile/profile-data.ts +++ b/src/app/profile/profile-data.ts @@ -7,7 +7,7 @@ export interface ProfileUser { bio: string; learningGoal: string; dailyLearningTime: string; - avatarUrl?: string; + avatarUrl: string; } export interface SelectOption { @@ -37,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 }> = [ From 21b9938a8aac268355442a9f0c195d2e529df133 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Thu, 30 Jul 2026 00:18:03 +0100 Subject: [PATCH 5/5] fix(export): add distinct error state and empty template guard to ExportButton (#990) --- src/components/ExportButton.tsx | 44 ++++++++-- .../__tests__/ExportButton.test.tsx | 82 +++++++++++++++++++ src/lib/export/types.ts | 2 +- 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 src/components/__tests__/ExportButton.test.tsx diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index 57472742..1709140f 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -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({ @@ -29,13 +30,20 @@ export function ExportButton({ sort, columns, onComplete, + onError, }: ExportButtonProps) { const [isRunning, setIsRunning] = useState(false); const [progress, setProgress] = useState(null); const [message, setMessage] = useState(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', @@ -54,6 +62,10 @@ 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, @@ -61,6 +73,7 @@ export function ExportButton({ }; setProgress(finalProgress); + setIsError(false); setMessage( `${response.result.fileName} ready (${response.result.rowCount} rows, ${( response.result.fileSize / 1024 @@ -68,26 +81,35 @@ export function ExportButton({ ); 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 (
- {progress && (
-
+
{progress.message} {progress.percent}%
@@ -97,14 +119,20 @@ export function ExportButton({ aria-valuenow={progress.percent} >
)} - {message &&

{message}

} + {message && ( +

+ {message} +

+ )}
); } diff --git a/src/components/__tests__/ExportButton.test.tsx b/src/components/__tests__/ExportButton.test.tsx new file mode 100644 index 00000000..82060051 --- /dev/null +++ b/src/components/__tests__/ExportButton.test.tsx @@ -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(); + const button = screen.getByRole('button', { name: 'Export Data' }); + expect(button).toBeDisabled(); + + rerender(); + expect(button).toBeDisabled(); + }); + + it('enables the button when valid templateId is provided', () => { + render(); + 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).mockRejectedValueOnce( + new Error('Server Error: Failed to execute export'), + ); + + render(); + + 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).mockResolvedValueOnce({ + result: { + success: true, + fileName: 'report.csv', + fileSize: 2048, + contentType: 'text/csv', + rowCount: 50, + progress: [{ stage: 'completed', percent: 100, message: 'Done' }], + }, + }); + + render(); + + 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, + }), + ); + }); +}); diff --git a/src/lib/export/types.ts b/src/lib/export/types.ts index f067cf17..fe4d15b6 100644 --- a/src/lib/export/types.ts +++ b/src/lib/export/types.ts @@ -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; }