From f01acfe852e7ddf97eff08a331092681cb3a9230 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 22 Oct 2025 16:26:00 +0300 Subject: [PATCH 01/10] feat: Course search for catalog page --- src/catalog/CatalogPage.tsx | 52 ++++++-- src/catalog/hooks/useCatalogState.ts | 188 +++++++++++++++++++++++++++ src/catalog/messages.ts | 10 ++ src/catalog/types.ts | 10 ++ src/catalog/utils.ts | 22 ++++ src/data/course-list-search/api.ts | 5 + src/data/course-list-search/hooks.ts | 5 +- src/data/course-list-search/types.ts | 2 + 8 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 src/catalog/hooks/useCatalogState.ts create mode 100644 src/catalog/types.ts diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index ce1c0872..abdb1e16 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -13,9 +13,10 @@ import { useCourseListSearch } from '@src/data/course-list-search/hooks'; import { AlertNotification, CourseCard, Loading, SubHeader, } from '../generic'; -import { useFilterState } from './hooks/useFilterState'; +// import { useFilterState } from './hooks/useFilterState'; +import { useCatalogState } from './hooks/useCatalogState'; import messages from './messages'; -import { transformAggregationsToFilterChoices } from './utils'; +import { transformAggregationsToFilterChoices, getPageTitle } from './utils'; const CatalogPage = () => { const intl = useIntl(); @@ -31,9 +32,32 @@ const CatalogPage = () => { const { pageIndex, filterState, + lastSearchQuery, + searchString, + previousCourseData, + handleSearch, + handleClearSearch, handleFetchData, resetFilterProgress, - } = useFilterState(fetchData); + } = useCatalogState(fetchData, courseData, isFetching); + + /** + * Determines which data to display in the catalog based on search state and results. + * Shows previous course data when: + * - User has an active search but no results were found, OR + * - User previously searched, cleared the search, but no results exist + * This provides better UX by showing cached data instead of empty state. + */ + const displayData = useMemo(() => { + const hasSearchResults = (courseData?.results?.length ?? 0) > 0; + const hasActiveSearch = Boolean(searchString); + const hadPreviousSearch = Boolean(lastSearchQuery); + + const shouldShowPreviousData = (hasActiveSearch && !hasSearchResults && previousCourseData) + || (hadPreviousSearch && !hasActiveSearch && !hasSearchResults && previousCourseData); + + return shouldShowPreviousData ? previousCourseData : courseData; + }, [courseData, searchString, lastSearchQuery, previousCourseData]); useEffect(() => { fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE }); @@ -46,8 +70,8 @@ const CatalogPage = () => { }, [isFetching, filterState.isFilterChangeInProgress, resetFilterProgress]); const tableColumns = useMemo( - () => transformAggregationsToFilterChoices(courseData?.aggs, intl), - [courseData], + () => transformAggregationsToFilterChoices(displayData?.aggs, intl), + [displayData?.aggs, intl], ); if (isLoading) { @@ -70,13 +94,18 @@ const CatalogPage = () => { ); } - const totalCourses = courseData?.results?.length ?? 0; - const pageCount = Math.ceil((courseData?.total || totalCourses) / DEFAULT_PAGE_SIZE); + const totalCourses = displayData?.results?.length ?? 0; + const pageCount = Math.ceil((displayData?.total || totalCourses) / DEFAULT_PAGE_SIZE); return ( {totalCourses > 0 ? ( @@ -88,6 +117,9 @@ const CatalogPage = () => { 'mb-4 w-25': !isMedium, })} placeholder={intl.formatMessage(messages.searchPlaceholder)} + value={searchString} + onSubmit={handleSearch} + onClear={handleClearSearch} /> { manualFilters manualPagination defaultColumnValues={{ Filter: TextFilter }} - itemCount={courseData?.total || totalCourses} + itemCount={displayData?.total || totalCourses} pageSize={DEFAULT_PAGE_SIZE} pageCount={pageCount} initialState={{ pageSize: DEFAULT_PAGE_SIZE, pageIndex }} - data={courseData?.results} + data={displayData?.results} columns={tableColumns} fetchData={handleFetchData} > diff --git a/src/catalog/hooks/useCatalogState.ts b/src/catalog/hooks/useCatalogState.ts new file mode 100644 index 00000000..4126f266 --- /dev/null +++ b/src/catalog/hooks/useCatalogState.ts @@ -0,0 +1,188 @@ +import { + useState, useCallback, useEffect, useMemo, +} from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import type { DataTableFilter } from '@src/data/course-list-search/types'; +import { compareFilters } from '../utils'; + +const INITIAL_FILTER_STATE = { + previousFilters: null as any[] | Record | null, + isFilterChangeInProgress: false, +}; + +export const useCatalogState = (fetchData, courseData, isFetching) => { + const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX); + const [filterState, setFilterState] = useState(INITIAL_FILTER_STATE); + const [searchString, setSearchString] = useState(''); + const [lastSearchQuery, setLastSearchQuery] = useState(''); + const [previousCourseData, setPreviousCourseData] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + const urlSearchQuery = useMemo(() => searchParams.get('search_query'), [searchParams]); + const [hasInitialized, setHasInitialized] = useState(false); + + const handleFetchData = useCallback((params) => { + const { pageIndex: newPageIndex, filters: newFilters } = params; + + const hasFilters = Array.isArray(newFilters) && newFilters.length > 0; + const hadFilters = filterState.previousFilters && Object.keys(filterState.previousFilters).length > 0; + const filtersChanged = filterState.previousFilters !== null + && !compareFilters(newFilters as DataTableFilter[], filterState.previousFilters as DataTableFilter[]); + const isFirstFilterApplied = !hadFilters && hasFilters; + const shouldResetSearch = filtersChanged || isFirstFilterApplied || (newPageIndex !== pageIndex); + + if (shouldResetSearch) { + setLastSearchQuery(''); + } + + if (filterState.isFilterChangeInProgress) { + return; + } + + if (filtersChanged || isFirstFilterApplied) { + setFilterState(prev => ({ + ...prev, + isFilterChangeInProgress: true, + previousFilters: newFilters || [], + })); + setPageIndex(0); + fetchData({ ...params, pageIndex: 0, searchString }); + return; + } + + setPageIndex(newPageIndex); + fetchData({ ...params, searchString }); + }, [fetchData, filterState.previousFilters, filterState.isFilterChangeInProgress, searchString, pageIndex]); + + const resetFilterProgress = useCallback(() => { + setFilterState(prev => ({ + ...prev, + isFilterChangeInProgress: false, + })); + }, []); + + const savePreviousCourseData = useCallback((data) => { + if (data && !searchString) { + setPreviousCourseData(data); + } + }, [searchString]); + + const handleNoSearchResults = useCallback((searchQuery) => { + setLastSearchQuery(searchQuery); + setSearchString(''); + setSearchParams({}); + }, [setSearchParams]); + + const clearLastSearchQuery = useCallback(() => { + setLastSearchQuery(''); + }, []); + + const handleSearch = useCallback((query) => { + setSearchString(query); + setPageIndex(0); + + if (query) { + setLastSearchQuery(''); + } + + setFilterState(prev => ({ + ...prev, + isFilterChangeInProgress: true, + previousFilters: [], + })); + + setSearchParams(query ? { search_query: query } : {}); + + fetchData({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: query, + }); + }, [fetchData, setSearchParams]); + + const handleClearSearch = useCallback(() => { + setSearchString(''); + setLastSearchQuery(''); + setPageIndex(0); + + setFilterState(prev => ({ + ...prev, + isFilterChangeInProgress: true, + previousFilters: [], + })); + + setSearchParams({}); + + fetchData({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }, [fetchData, setSearchParams]); + + useEffect(() => { + if (hasInitialized) { + return; + } + + if (urlSearchQuery && !searchString) { + handleSearch(urlSearchQuery); + } else if (!urlSearchQuery && !searchString) { + fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [] }); + } + + setHasInitialized(true); + }, [hasInitialized, urlSearchQuery, searchString, handleSearch, fetchData]); + + useEffect(() => { + if (!isFetching && filterState.isFilterChangeInProgress) { + resetFilterProgress(); + } + + if (!courseData) { + return; + } + + if (!searchString) { + savePreviousCourseData(courseData); + return; + } + + const hasResults = (courseData.results?.length ?? 0) > 0; + + if (!isFetching) { + if (hasResults) { + clearLastSearchQuery(); + } else { + handleNoSearchResults(searchString); + } + } + }, [ + isFetching, + filterState.isFilterChangeInProgress, + resetFilterProgress, + courseData, + searchString, + savePreviousCourseData, + clearLastSearchQuery, + handleNoSearchResults, + ]); + + return { + pageIndex, + filterState, + searchString, + lastSearchQuery, + previousCourseData, + handleFetchData, + resetFilterProgress, + handleSearch, + handleClearSearch, + savePreviousCourseData, + handleNoSearchResults, + clearLastSearchQuery, + }; +}; diff --git a/src/catalog/messages.ts b/src/catalog/messages.ts index d46e27cb..dca87c93 100644 --- a/src/catalog/messages.ts +++ b/src/catalog/messages.ts @@ -21,6 +21,16 @@ const messages = defineMessages({ defaultMessage: 'Search for a course', description: 'Search placeholder.', }, + searchResults: { + id: 'category.catalog.search-results', + defaultMessage: 'Search results for "{query}"', + description: 'Search results heading.', + }, + noSearchResults: { + id: 'category.catalog.no-search-results', + defaultMessage: 'We couldn\'t find any results for "{query}"', + description: 'No search results.', + }, exploreCourses: { id: 'category.catalog.explore-courses', defaultMessage: 'Explore courses', diff --git a/src/catalog/types.ts b/src/catalog/types.ts new file mode 100644 index 00000000..b5726927 --- /dev/null +++ b/src/catalog/types.ts @@ -0,0 +1,10 @@ +import { IntlShape } from '@edx/frontend-platform/i18n'; + +import { CourseListSearchResponse } from '@src/data/course-list-search/types'; + +export interface GetPageTitleProps { + intl: IntlShape; + lastSearchQuery: string; + searchString: string; + courseData: CourseListSearchResponse | undefined; +} diff --git a/src/catalog/utils.ts b/src/catalog/utils.ts index 13363a62..ab941411 100644 --- a/src/catalog/utils.ts +++ b/src/catalog/utils.ts @@ -3,6 +3,7 @@ import { IntlShape } from '@edx/frontend-platform/i18n'; import capitalize from 'lodash.capitalize'; import type { Aggregations, DataTableFilter } from '@src/data/course-list-search/types'; +import type { GetPageTitleProps } from './types'; import messages from './messages'; /** @@ -82,3 +83,24 @@ export const compareFilters = ( return set1.size === set2.size && [...set1].every(key => set2.has(key)); }; + +/** + * Determines the appropriate page title based on search state and results. + */ +export const getPageTitle = ({ + intl, + lastSearchQuery, + searchString, + courseData, +}: GetPageTitleProps) => { + if (lastSearchQuery && !searchString) { + return intl.formatMessage(messages.noSearchResults, { query: lastSearchQuery }); + } + if (searchString && (courseData?.results?.length ?? 0) === 0) { + return intl.formatMessage(messages.noSearchResults, { query: searchString }); + } + if (searchString) { + return intl.formatMessage(messages.searchResults, { query: searchString }); + } + return intl.formatMessage(messages.exploreCourses); +}; diff --git a/src/data/course-list-search/api.ts b/src/data/course-list-search/api.ts index 175fcf64..43dbcac6 100644 --- a/src/data/course-list-search/api.ts +++ b/src/data/course-list-search/api.ts @@ -17,6 +17,7 @@ export const fetchCourseListSearch = async (params): Promise = {}): CourseListSearchHook => { const [params, setParams] = useState({ pageSize, pageIndex, enableCourseSortingByStartDate, filters, + searchString, }); const { @@ -35,13 +37,14 @@ export const useCourseListSearch = ({ /** * Updates query params and triggers data refetch if params have changed. */ - const fetchData = useCallback((newParams: DataTableParams) => { + const fetchData = useCallback((newParams: DataTableParams & { searchString?: string }) => { const transformedFilters = transformDataTableFilters(newParams.filters); const transformedParams: CourseListSearchParams = { pageSize: newParams.pageSize, pageIndex: newParams.pageIndex, filters: transformedFilters, + searchString: newParams.searchString || '', }; setParams(prevParams => { diff --git a/src/data/course-list-search/types.ts b/src/data/course-list-search/types.ts index 9d334719..7be2bd7d 100644 --- a/src/data/course-list-search/types.ts +++ b/src/data/course-list-search/types.ts @@ -49,6 +49,7 @@ export interface CourseListSearchParams { pageIndex?: number; filters?: Record; enableCourseSortingByStartDate?: boolean; + searchString?: string; } export interface DataTableParams { @@ -58,6 +59,7 @@ export interface DataTableParams { id: string; value: string | string[]; }>; + searchString?: string; } export interface CourseListSearchHook { From e5f48373a03fa4acd5aeb014ec5220dd69be0349 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 22 Oct 2025 19:07:19 +0300 Subject: [PATCH 02/10] refactor: some refactoring --- src/catalog/CatalogPage.tsx | 1 - src/catalog/hooks/useCatalogState.ts | 32 +++++++++---- src/catalog/hooks/useFilterState.ts | 69 ---------------------------- 3 files changed, 24 insertions(+), 78 deletions(-) delete mode 100644 src/catalog/hooks/useFilterState.ts diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index abdb1e16..5e0b7eb5 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -13,7 +13,6 @@ import { useCourseListSearch } from '@src/data/course-list-search/hooks'; import { AlertNotification, CourseCard, Loading, SubHeader, } from '../generic'; -// import { useFilterState } from './hooks/useFilterState'; import { useCatalogState } from './hooks/useCatalogState'; import messages from './messages'; import { transformAggregationsToFilterChoices, getPageTitle } from './utils'; diff --git a/src/catalog/hooks/useCatalogState.ts b/src/catalog/hooks/useCatalogState.ts index 4126f266..6344a7a4 100644 --- a/src/catalog/hooks/useCatalogState.ts +++ b/src/catalog/hooks/useCatalogState.ts @@ -1,10 +1,10 @@ import { - useState, useCallback, useEffect, useMemo, + useState, useCallback, useMemo, useEffect, } from 'react'; import { useSearchParams } from 'react-router-dom'; import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; -import type { DataTableFilter } from '@src/data/course-list-search/types'; +import type { DataTableParams, CourseListSearchResponse, DataTableFilter } from '@src/data/course-list-search/types'; import { compareFilters } from '../utils'; const INITIAL_FILTER_STATE = { @@ -12,21 +12,37 @@ const INITIAL_FILTER_STATE = { isFilterChangeInProgress: false, }; -export const useCatalogState = (fetchData, courseData, isFetching) => { +/** + * Custom hook for managing filter state and pagination logic. + */ +export const useCatalogState = ( + fetchData: (params: DataTableParams) => void, + courseData: CourseListSearchResponse | undefined, + isFetching: boolean, +) => { const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX); const [filterState, setFilterState] = useState(INITIAL_FILTER_STATE); const [searchString, setSearchString] = useState(''); const [lastSearchQuery, setLastSearchQuery] = useState(''); const [previousCourseData, setPreviousCourseData] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + const [hasInitialized, setHasInitialized] = useState(false); const urlSearchQuery = useMemo(() => searchParams.get('search_query'), [searchParams]); - const [hasInitialized, setHasInitialized] = useState(false); + /** + * Handles data fetching with intelligent filter and pagination logic. + * + * This function: + * - Compares new filters with previous filters to detect changes + * - Resets pagination to page 0 when filters change + * - Prevents duplicate calls during filter transitions + * - Handles both filter changes and pagination separately + */ const handleFetchData = useCallback((params) => { const { pageIndex: newPageIndex, filters: newFilters } = params; - const hasFilters = Array.isArray(newFilters) && newFilters.length > 0; + const hasFilters = Array.isArray(newFilters) && Object.keys(newFilters).length > 0; const hadFilters = filterState.previousFilters && Object.keys(filterState.previousFilters).length > 0; const filtersChanged = filterState.previousFilters !== null && !compareFilters(newFilters as DataTableFilter[], filterState.previousFilters as DataTableFilter[]); @@ -45,7 +61,7 @@ export const useCatalogState = (fetchData, courseData, isFetching) => { setFilterState(prev => ({ ...prev, isFilterChangeInProgress: true, - previousFilters: newFilters || [], + previousFilters: newFilters || {}, })); setPageIndex(0); fetchData({ ...params, pageIndex: 0, searchString }); @@ -174,11 +190,11 @@ export const useCatalogState = (fetchData, courseData, isFetching) => { return { pageIndex, filterState, + handleFetchData, + resetFilterProgress, searchString, lastSearchQuery, previousCourseData, - handleFetchData, - resetFilterProgress, handleSearch, handleClearSearch, savePreviousCourseData, diff --git a/src/catalog/hooks/useFilterState.ts b/src/catalog/hooks/useFilterState.ts deleted file mode 100644 index 45ab375d..00000000 --- a/src/catalog/hooks/useFilterState.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useState, useCallback } from 'react'; - -import { DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; -import type { DataTableParams } from '@src/data/course-list-search/types'; -import { compareFilters } from '../utils'; - -const INITIAL_FILTER_STATE = { - previousFilters: null, - isFilterChangeInProgress: false, -}; - -/** - * Custom hook for managing filter state and pagination logic. - */ -export const useFilterState = (fetchData: (params: DataTableParams) => void) => { - const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX); - const [filterState, setFilterState] = useState(INITIAL_FILTER_STATE); - - /** - * Handles data fetching with intelligent filter and pagination logic. - * - * This function: - * - Compares new filters with previous filters to detect changes - * - Resets pagination to page 0 when filters change - * - Prevents duplicate calls during filter transitions - * - Handles both filter changes and pagination separately - */ - const handleFetchData = useCallback((params) => { - const { pageIndex: newPageIndex, filters: newFilters } = params; - - const hasFilters = newFilters && Object.keys(newFilters).length > 0; - const hadFilters = filterState.previousFilters && Object.keys(filterState.previousFilters).length > 0; - const filtersChanged = filterState.previousFilters !== null - && !compareFilters(newFilters, filterState.previousFilters); - const isFirstFilterApplied = !hadFilters && hasFilters; - - if (filterState.isFilterChangeInProgress) { - return; - } - - if (filtersChanged || isFirstFilterApplied) { - setFilterState(prev => ({ - ...prev, - isFilterChangeInProgress: true, - previousFilters: newFilters || {}, - })); - setPageIndex(0); - fetchData({ ...params, pageIndex: 0 }); - return; - } - - setPageIndex(newPageIndex); - fetchData(params); - }, [fetchData, filterState.previousFilters, filterState.isFilterChangeInProgress]); - - const resetFilterProgress = useCallback(() => { - setFilterState(prev => ({ - ...prev, - isFilterChangeInProgress: false, - })); - }, []); - - return { - pageIndex, - filterState, - handleFetchData, - resetFilterProgress, - }; -}; From 8a81ce096271377bb5a9cfc646905a071d5d79c6 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 22 Oct 2025 22:49:02 +0300 Subject: [PATCH 03/10] refactor: hooks refactoring --- src/catalog/CatalogPage.test.tsx | 6 +- src/catalog/CatalogPage.tsx | 28 ++-- src/catalog/hooks/useCatalog.ts | 81 +++++++++++ src/catalog/hooks/useCatalogState.ts | 204 --------------------------- src/catalog/hooks/useCourseData.ts | 65 +++++++++ src/catalog/hooks/useFilter.ts | 76 ++++++++++ src/catalog/hooks/usePagination.ts | 29 ++++ src/catalog/hooks/useSearch.ts | 95 +++++++++++++ 8 files changed, 364 insertions(+), 220 deletions(-) create mode 100644 src/catalog/hooks/useCatalog.ts delete mode 100644 src/catalog/hooks/useCatalogState.ts create mode 100644 src/catalog/hooks/useCourseData.ts create mode 100644 src/catalog/hooks/useFilter.ts create mode 100644 src/catalog/hooks/usePagination.ts create mode 100644 src/catalog/hooks/useSearch.ts diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index 64e559ff..4ea774cd 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -123,7 +123,7 @@ describe('CatalogPage', () => { expect(searchField).toBeInTheDocument(); }); - it('should render DataTable without filters when course discovery is disabled', () => { + it('should render DataTable without filters and search field when course discovery is disabled', () => { mockGetConfig.mockReturnValue({ INFO_EMAIL: 'support@example.com', ENABLE_COURSE_DISCOVERY: false, @@ -142,8 +142,8 @@ describe('CatalogPage', () => { expect(screen.queryByText(messages.languages.defaultMessage)).not.toBeInTheDocument(); expect(screen.queryByText('Filters')).not.toBeInTheDocument(); expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument(); - const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); - expect(searchField).toBeInTheDocument(); + const searchField = screen.queryByPlaceholderText(messages.searchPlaceholder.defaultMessage); + expect(searchField).not.toBeInTheDocument(); }); it('should handle search field interactions', () => { diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index 5e0b7eb5..b32c885e 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -13,7 +13,7 @@ import { useCourseListSearch } from '@src/data/course-list-search/hooks'; import { AlertNotification, CourseCard, Loading, SubHeader, } from '../generic'; -import { useCatalogState } from './hooks/useCatalogState'; +import { useCatalog } from './hooks/useCatalog'; import messages from './messages'; import { transformAggregationsToFilterChoices, getPageTitle } from './utils'; @@ -38,7 +38,7 @@ const CatalogPage = () => { handleClearSearch, handleFetchData, resetFilterProgress, - } = useCatalogState(fetchData, courseData, isFetching); + } = useCatalog(fetchData, courseData, isFetching); /** * Determines which data to display in the catalog based on search state and results. @@ -109,17 +109,19 @@ const CatalogPage = () => { /> {totalCourses > 0 ? ( <> - + {getConfig().ENABLE_COURSE_DISCOVERY && ( + + )} void, + courseData: CourseListSearchResponse | undefined, + isFetching: boolean, +) => { + const { + searchString, + lastSearchQuery, + handleSearch, + handleClearSearch, + handleNoSearchResults, + clearLastSearchQuery, + } = useSearch(fetchData); + + const { filterState, resetFilterProgress, handleFilterChange } = useFilter(); + + const { pageIndex, handlePageChange, resetPagination } = usePagination(); + + const { previousCourseData, savePreviousCourseData } = useCourseData( + courseData, + searchString, + isFetching, + handleNoSearchResults, + clearLastSearchQuery, + ); + + const handleFetchData = useCallback((params: DataTableParams) => { + const { pageIndex: newPageIndex, filters: newFilters } = params; + + const filterChanged = handleFilterChange(newFilters as DataTableFilter[], fetchData, searchString); + + if (filterChanged) { + resetPagination(); + return; + } + + handlePageChange(newPageIndex ?? DEFAULT_PAGE_INDEX); + fetchData({ ...params, searchString }); + }, [handleFilterChange, fetchData, searchString, resetPagination, handlePageChange]); + + return { + pageIndex, + filterState, + searchString, + lastSearchQuery, + previousCourseData, + handleSearch, + handleClearSearch, + handleFetchData, + resetFilterProgress, + savePreviousCourseData, + handleNoSearchResults, + clearLastSearchQuery, + }; +}; diff --git a/src/catalog/hooks/useCatalogState.ts b/src/catalog/hooks/useCatalogState.ts deleted file mode 100644 index 6344a7a4..00000000 --- a/src/catalog/hooks/useCatalogState.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - useState, useCallback, useMemo, useEffect, -} from 'react'; -import { useSearchParams } from 'react-router-dom'; - -import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; -import type { DataTableParams, CourseListSearchResponse, DataTableFilter } from '@src/data/course-list-search/types'; -import { compareFilters } from '../utils'; - -const INITIAL_FILTER_STATE = { - previousFilters: null as any[] | Record | null, - isFilterChangeInProgress: false, -}; - -/** - * Custom hook for managing filter state and pagination logic. - */ -export const useCatalogState = ( - fetchData: (params: DataTableParams) => void, - courseData: CourseListSearchResponse | undefined, - isFetching: boolean, -) => { - const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX); - const [filterState, setFilterState] = useState(INITIAL_FILTER_STATE); - const [searchString, setSearchString] = useState(''); - const [lastSearchQuery, setLastSearchQuery] = useState(''); - const [previousCourseData, setPreviousCourseData] = useState(null); - const [searchParams, setSearchParams] = useSearchParams(); - const [hasInitialized, setHasInitialized] = useState(false); - - const urlSearchQuery = useMemo(() => searchParams.get('search_query'), [searchParams]); - - /** - * Handles data fetching with intelligent filter and pagination logic. - * - * This function: - * - Compares new filters with previous filters to detect changes - * - Resets pagination to page 0 when filters change - * - Prevents duplicate calls during filter transitions - * - Handles both filter changes and pagination separately - */ - const handleFetchData = useCallback((params) => { - const { pageIndex: newPageIndex, filters: newFilters } = params; - - const hasFilters = Array.isArray(newFilters) && Object.keys(newFilters).length > 0; - const hadFilters = filterState.previousFilters && Object.keys(filterState.previousFilters).length > 0; - const filtersChanged = filterState.previousFilters !== null - && !compareFilters(newFilters as DataTableFilter[], filterState.previousFilters as DataTableFilter[]); - const isFirstFilterApplied = !hadFilters && hasFilters; - const shouldResetSearch = filtersChanged || isFirstFilterApplied || (newPageIndex !== pageIndex); - - if (shouldResetSearch) { - setLastSearchQuery(''); - } - - if (filterState.isFilterChangeInProgress) { - return; - } - - if (filtersChanged || isFirstFilterApplied) { - setFilterState(prev => ({ - ...prev, - isFilterChangeInProgress: true, - previousFilters: newFilters || {}, - })); - setPageIndex(0); - fetchData({ ...params, pageIndex: 0, searchString }); - return; - } - - setPageIndex(newPageIndex); - fetchData({ ...params, searchString }); - }, [fetchData, filterState.previousFilters, filterState.isFilterChangeInProgress, searchString, pageIndex]); - - const resetFilterProgress = useCallback(() => { - setFilterState(prev => ({ - ...prev, - isFilterChangeInProgress: false, - })); - }, []); - - const savePreviousCourseData = useCallback((data) => { - if (data && !searchString) { - setPreviousCourseData(data); - } - }, [searchString]); - - const handleNoSearchResults = useCallback((searchQuery) => { - setLastSearchQuery(searchQuery); - setSearchString(''); - setSearchParams({}); - }, [setSearchParams]); - - const clearLastSearchQuery = useCallback(() => { - setLastSearchQuery(''); - }, []); - - const handleSearch = useCallback((query) => { - setSearchString(query); - setPageIndex(0); - - if (query) { - setLastSearchQuery(''); - } - - setFilterState(prev => ({ - ...prev, - isFilterChangeInProgress: true, - previousFilters: [], - })); - - setSearchParams(query ? { search_query: query } : {}); - - fetchData({ - pageIndex: 0, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - searchString: query, - }); - }, [fetchData, setSearchParams]); - - const handleClearSearch = useCallback(() => { - setSearchString(''); - setLastSearchQuery(''); - setPageIndex(0); - - setFilterState(prev => ({ - ...prev, - isFilterChangeInProgress: true, - previousFilters: [], - })); - - setSearchParams({}); - - fetchData({ - pageIndex: 0, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - }); - }, [fetchData, setSearchParams]); - - useEffect(() => { - if (hasInitialized) { - return; - } - - if (urlSearchQuery && !searchString) { - handleSearch(urlSearchQuery); - } else if (!urlSearchQuery && !searchString) { - fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [] }); - } - - setHasInitialized(true); - }, [hasInitialized, urlSearchQuery, searchString, handleSearch, fetchData]); - - useEffect(() => { - if (!isFetching && filterState.isFilterChangeInProgress) { - resetFilterProgress(); - } - - if (!courseData) { - return; - } - - if (!searchString) { - savePreviousCourseData(courseData); - return; - } - - const hasResults = (courseData.results?.length ?? 0) > 0; - - if (!isFetching) { - if (hasResults) { - clearLastSearchQuery(); - } else { - handleNoSearchResults(searchString); - } - } - }, [ - isFetching, - filterState.isFilterChangeInProgress, - resetFilterProgress, - courseData, - searchString, - savePreviousCourseData, - clearLastSearchQuery, - handleNoSearchResults, - ]); - - return { - pageIndex, - filterState, - handleFetchData, - resetFilterProgress, - searchString, - lastSearchQuery, - previousCourseData, - handleSearch, - handleClearSearch, - savePreviousCourseData, - handleNoSearchResults, - clearLastSearchQuery, - }; -}; diff --git a/src/catalog/hooks/useCourseData.ts b/src/catalog/hooks/useCourseData.ts new file mode 100644 index 00000000..cf781e5e --- /dev/null +++ b/src/catalog/hooks/useCourseData.ts @@ -0,0 +1,65 @@ +import { useState, useCallback, useEffect } from 'react'; + +import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; + +/** + * Custom hook for managing course data caching and search result handling. + * + * This hook provides functionality to: + * - Cache previous course data when not searching + * - Handle search result states (successful results vs no results) + * - Manage data persistence for better UX + * - Coordinate with search state management + */ +export const useCourseData = ( + courseData: CourseListSearchResponse | undefined, + searchString: string, + isFetching: boolean, + onNoSearchResults: (searchQuery: string) => void, + onClearLastSearchQuery: () => void, +) => { + const [previousCourseData, setPreviousCourseData] = useState(null); + + /** + * Saves course data to cache when not actively searching. + */ + const savePreviousCourseData = useCallback((data: CourseListSearchResponse) => { + if (data && !searchString) { + setPreviousCourseData(data); + } + }, [searchString]); + + /** + * Handles course data state changes and search result processing. + */ + useEffect(() => { + if (!courseData) { return; } + + if (!searchString) { + savePreviousCourseData(courseData); + return; + } + + const hasResults = (courseData.results?.length ?? 0) > 0; + + if (!isFetching) { + if (hasResults) { + onClearLastSearchQuery(); + } else { + onNoSearchResults(searchString); + } + } + }, [ + courseData, + searchString, + isFetching, + savePreviousCourseData, + onNoSearchResults, + onClearLastSearchQuery, + ]); + + return { + previousCourseData, + savePreviousCourseData, + }; +}; diff --git a/src/catalog/hooks/useFilter.ts b/src/catalog/hooks/useFilter.ts new file mode 100644 index 00000000..c04fb063 --- /dev/null +++ b/src/catalog/hooks/useFilter.ts @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react'; + +import { DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import type { DataTableFilter, DataTableParams } from '@src/data/course-list-search/types'; +import { compareFilters } from '../utils'; + +const INITIAL_FILTER_STATE = { + previousFilters: null as any[] | Record | null, + isFilterChangeInProgress: false, +}; + +/** + * Custom hook for managing filter state and handling filter changes in the catalog. + * + * This hook provides functionality to: + * - Track previous filter state to detect changes + * - Prevent duplicate API calls during filter transitions + * - Reset pagination when filters change + * - Handle filter change progress state + */ +export const useFilter = () => { + const [filterState, setFilterState] = useState(INITIAL_FILTER_STATE); + + /** + * Resets the filter change progress flag. + * + * This function should be called when the API request completes + * to allow new filter changes to be processed. + */ + const resetFilterProgress = useCallback(() => { + setFilterState(prev => ({ + ...prev, + isFilterChangeInProgress: false, + })); + }, []); + + /** + * Handles filter changes to prevent duplicate API calls. + */ + const handleFilterChange = useCallback(( + newFilters: DataTableFilter[], + fetchData: (params: DataTableParams) => void, + searchString: string, + ) => { + const hasFilters = Array.isArray(newFilters) && Object.keys(newFilters).length > 0; + const hadFilters = filterState.previousFilters && Object.keys(filterState.previousFilters).length > 0; + const filtersChanged = filterState.previousFilters !== null + && !compareFilters(newFilters, filterState.previousFilters as DataTableFilter[]); + const isFirstFilterApplied = !hadFilters && hasFilters; + + if (filterState.isFilterChangeInProgress) { + return false; + } + + if (filtersChanged || isFirstFilterApplied) { + setFilterState(prev => ({ + ...prev, + isFilterChangeInProgress: true, + previousFilters: newFilters || {}, + })); + + fetchData({ + pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE, filters: newFilters, searchString, + }); + return true; + } + + return false; + }, [filterState]); + + return { + filterState, + resetFilterProgress, + handleFilterChange, + }; +}; diff --git a/src/catalog/hooks/usePagination.ts b/src/catalog/hooks/usePagination.ts new file mode 100644 index 00000000..950a81ee --- /dev/null +++ b/src/catalog/hooks/usePagination.ts @@ -0,0 +1,29 @@ +import { useState, useCallback } from 'react'; + +import { DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; + +/** + * Custom hook for managing pagination state in the DataTable. + * + * This hook provides functionality to: + * - Track current page index + * - Handle page changes + * - Reset pagination to the first page + */ +export const usePagination = () => { + const [pageIndex, setPageIndex] = useState(DEFAULT_PAGE_INDEX); + + const handlePageChange = useCallback((newPageIndex: number) => { + setPageIndex(newPageIndex); + }, []); + + const resetPagination = useCallback(() => { + setPageIndex(0); + }, []); + + return { + pageIndex, + handlePageChange, + resetPagination, + }; +}; diff --git a/src/catalog/hooks/useSearch.ts b/src/catalog/hooks/useSearch.ts new file mode 100644 index 00000000..1b7908e5 --- /dev/null +++ b/src/catalog/hooks/useSearch.ts @@ -0,0 +1,95 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; +import { DataTableParams } from '@src/data/course-list-search/types'; + +/** + * Custom hook for managing search functionality in the catalog. + * + * This hook provides functionality to: + * - Handle search queries and URL synchronization + * - Manage search state and history + * - Initialize search from URL parameters + * - Handle search result states (no results, clearing search) + */ +export const useSearch = (fetchData: (params: DataTableParams) => void) => { + const [searchString, setSearchString] = useState(''); + const [lastSearchQuery, setLastSearchQuery] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const [hasInitialized, setHasInitialized] = useState(false); + + const urlSearchQuery = searchParams.get('search_query'); + + /** + * Handles search operations to ensure proper state management and API calls. + */ + const handleSearch = useCallback((query: string) => { + setSearchString(query); + setLastSearchQuery(query ? '' : lastSearchQuery); + setSearchParams(query ? { search_query: query } : {}); + + fetchData({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: query, + }); + }, [fetchData, setSearchParams, lastSearchQuery]); + + /** + * Clears the current search and resets to the default DataTable view. + */ + const handleClearSearch = useCallback(() => { + setSearchString(''); + setLastSearchQuery(''); + setSearchParams({}); + + fetchData({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }, [fetchData, setSearchParams]); + + /** + * Handles the case when a search returns no results. + * This is typically called when the search API returns empty results. + */ + const handleNoSearchResults = useCallback((searchQuery: string) => { + setLastSearchQuery(searchQuery); + setSearchString(''); + setSearchParams({}); + }, [setSearchParams]); + + /** + * Clears the last search query when no results are found. + */ + const clearLastSearchQuery = useCallback(() => { + setLastSearchQuery(''); + }, []); + + /** + * Initializes search state from URL parameters on component mount. + */ + useEffect(() => { + if (hasInitialized) { return; } + + if (urlSearchQuery && !searchString) { + handleSearch(urlSearchQuery); + } else if (!urlSearchQuery && !searchString) { + fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [] }); + } + + setHasInitialized(true); + }, [hasInitialized, urlSearchQuery, searchString, handleSearch, fetchData]); + + return { + searchString, + lastSearchQuery, + handleSearch, + handleClearSearch, + handleNoSearchResults, + clearLastSearchQuery, + }; +}; From a0391ebf650d39ab9f5330d0f9c4d5efe5c3e5de Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Thu, 23 Oct 2025 09:49:53 +0300 Subject: [PATCH 04/10] test: added tests --- src/catalog/CatalogPage.test.tsx | 651 +++++++++++++++++- .../hooks/__tests__/useCatalog.test.tsx | 327 +++++++++ .../hooks/__tests__/useCourseData.test.ts | 188 +++++ src/catalog/hooks/__tests__/useFilter.test.ts | 138 ++++ .../hooks/__tests__/usePagination.test.ts | 53 ++ src/catalog/hooks/__tests__/useSearch.test.ts | 129 ++++ 6 files changed, 1483 insertions(+), 3 deletions(-) create mode 100644 src/catalog/hooks/__tests__/useCatalog.test.tsx create mode 100644 src/catalog/hooks/__tests__/useCourseData.test.ts create mode 100644 src/catalog/hooks/__tests__/useFilter.test.ts create mode 100644 src/catalog/hooks/__tests__/usePagination.test.ts create mode 100644 src/catalog/hooks/__tests__/useSearch.test.ts diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index 4ea774cd..41a35370 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -4,7 +4,7 @@ import { render, within, screen, waitFor, userEvent, } from '../setupTest'; import { useCourseListSearch } from '../data/course-list-search/hooks'; -import { DEFAULT_PAGE_SIZE } from '../data/course-list-search/constants'; +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '../data/course-list-search/constants'; import { mockCourseListSearchResponse } from '../__mocks__'; import CatalogPage from './CatalogPage'; import messages from './messages'; @@ -146,12 +146,13 @@ describe('CatalogPage', () => { expect(searchField).not.toBeInTheDocument(); }); - it('should handle search field interactions', () => { + it('should handle search field interactions and input changes', async () => { + const mockFetchData = jest.fn(); mockUseCourseListSearch.mockReturnValue({ isLoading: false, isError: false, data: mockCourseListSearchResponse, - fetchData: jest.fn(), + fetchData: mockFetchData, isFetching: false, }); @@ -161,6 +162,385 @@ describe('CatalogPage', () => { expect(searchField).toHaveValue(''); expect(searchField).toBeInTheDocument(); + + await userEvent.type(searchField, 'python'); + expect(searchField).toHaveValue('python'); + }); + + it('should call fetchData with search query when search is submitted', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'python', + }), + ); + }); + }); + + it('should clear search when clear button is clicked', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await userEvent.clear(searchField); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(searchField).toHaveValue(''); + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: '', + }), + ); + }); + }); + + it('should reset page to 0 when performing search', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'machine learning'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + const lastCall = mockFetchData.mock.calls[mockFetchData.mock.calls.length - 1]; + expect(lastCall[0].pageIndex).toBe(DEFAULT_PAGE_INDEX); + }); + }); + + it('should handle empty search query submission', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.click(searchField); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: '', + }), + ); + }); + }); + + it('should display search results when search returns data', async () => { + const mockFetchData = jest.fn(); + const searchResults = { + ...mockCourseListSearchResponse, + results: [mockCourseListSearchResponse.results[0]], + total: 1, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: searchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const courseCards = screen.getAllByTestId('course-card'); + expect(courseCards).toHaveLength(searchResults.results.length); + + const rowStatus = screen.getAllByTestId('row-status')[0]; + expect(rowStatus).toHaveTextContent( + `Showing ${searchResults.results.length} - ${searchResults.results.length} of ${searchResults.total}.`, + ); + }); + + it('should show no results message when search returns empty results', async () => { + const mockFetchData = jest.fn(); + const emptySearchResults = { + ...mockCourseListSearchResponse, + results: [], + total: 0, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: emptySearchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + expect(screen.getByText(messages.noCoursesAvailable.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.noCoursesAvailableMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should preserve filters when performing search', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + // First apply a filter + const englishCheckbox = screen.getByRole('checkbox', { name: /English/i }); + await userEvent.click(englishCheckbox); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.arrayContaining([ + expect.objectContaining({ + id: 'language', + value: expect.arrayContaining(['en']), + }), + ]), + }), + ); + }); + + // Then perform a search - filters should be preserved + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'data science'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + const lastCall = mockFetchData.mock.calls[mockFetchData.mock.calls.length - 1]; + expect(lastCall[0]).toEqual( + expect.objectContaining({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + filters: expect.arrayContaining([ + expect.objectContaining({ + id: 'language', + value: expect.arrayContaining(['en']), + }), + ]), + searchString: 'data science', + }), + ); + }); + }); + + it('should handle search and filter interactions independently', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'python', + }), + ); + }); + + await userEvent.clear(searchField); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + const lastCall = mockFetchData.mock.calls[mockFetchData.mock.calls.length - 1]; + expect(lastCall[0]).toEqual( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: '', + }), + ); + }); + }); + + it('should maintain search state during pagination', async () => { + const mockFetchData = jest.fn(); + const paginatedResponse = { + ...mockCourseListSearchResponse, + total: 50, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: paginatedResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + // Perform search + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: 'python', + }), + ); + }); + + // Go to next page + const nextPageButton = screen.getByRole('button', { name: /next/i }); + await userEvent.click(nextPageButton); + + // Verify that search string is preserved during pagination + await waitFor(() => { + const lastCall = mockFetchData.mock.calls[mockFetchData.mock.calls.length - 1]; + expect(lastCall[0]).toEqual( + expect.objectContaining({ + pageIndex: 1, + searchString: 'python', + }), + ); + }); + }); + + it('should handle search with special characters', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + // Test search with special characters + await userEvent.type(searchField, 'C++ & Java'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'C++ & Java', + }), + ); + }); + }); + + it('should handle multiple consecutive searches', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: 'python', + }), + ); + }); + + await userEvent.clear(searchField); + await userEvent.type(searchField, 'javascript'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + const lastCall = mockFetchData.mock.calls[mockFetchData.mock.calls.length - 1]; + expect(lastCall[0]).toEqual( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'javascript', + }), + ); + }); }); it('should render DataTable row statuses with correct pagination info', async () => { @@ -792,4 +1172,269 @@ describe('CatalogPage', () => { expect(screen.getByText(messages.noCoursesAvailable.defaultMessage)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /of/i })).not.toBeInTheDocument(); }); + + describe('CatalogPage - SubHeader Title Tests', () => { + it('should display default title when no search is performed', () => { + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: jest.fn(), + isFetching: false, + }); + + render(); + + expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument(); + }); + + it('should display search results title when search has results', async () => { + const mockFetchData = jest.fn(); + const searchResults = { + ...mockCourseListSearchResponse, + results: [mockCourseListSearchResponse.results[0]], + total: 1, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: searchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByText( + messages.searchResults.defaultMessage.replace('{query}', 'python'), + )).toBeInTheDocument(); + }); + }); + + it('should display no search results title when search returns empty results', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + const { rerender } = render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'nonexistent'); + await userEvent.keyboard('{Enter}'); + + const emptySearchResults = { + ...mockCourseListSearchResponse, + results: [], + total: 0, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: emptySearchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText( + messages.noSearchResults.defaultMessage.replace('{query}', 'nonexistent'), + )).toBeInTheDocument(); + }); + }); + + it('should display no search results title when search is cleared after having no results', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + const { rerender } = render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'nonexistent'); + await userEvent.keyboard('{Enter}'); + + const emptySearchResults = { + ...mockCourseListSearchResponse, + results: [], + total: 0, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: emptySearchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText( + messages.noSearchResults.defaultMessage.replace('{query}', 'nonexistent'), + )).toBeInTheDocument(); + }); + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText( + messages.noSearchResults.defaultMessage.replace('{query}', 'nonexistent'), + )).toBeInTheDocument(); + }); + }); + + it('should display search results title when search is cleared after having results', async () => { + const mockFetchData = jest.fn(); + const searchResults = { + ...mockCourseListSearchResponse, + results: [mockCourseListSearchResponse.results[0]], + total: 1, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: searchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByText('Search results for "python"')).toBeInTheDocument(); + }); + + await userEvent.clear(searchField); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should display search results title with special characters in query', async () => { + const mockFetchData = jest.fn(); + const searchResults = { + ...mockCourseListSearchResponse, + results: [mockCourseListSearchResponse.results[0]], + total: 1, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: searchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'C++ & Java'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByText( + messages.searchResults.defaultMessage.replace('{query}', 'C++ & Java'), + )).toBeInTheDocument(); + }); + }); + + it('should update title when switching between different search queries', async () => { + const mockFetchData = jest.fn(); + const searchResults = { + ...mockCourseListSearchResponse, + results: [mockCourseListSearchResponse.results[0]], + total: 1, + }; + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: searchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByText( + messages.searchResults.defaultMessage.replace('{query}', 'python'), + )).toBeInTheDocument(); + }); + + await userEvent.clear(searchField); + await userEvent.type(searchField, 'javascript'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByText( + messages.searchResults.defaultMessage.replace('{query}', 'javascript'), + )).toBeInTheDocument(); + }); + }); + + it('should display default title when course discovery is disabled', () => { + mockGetConfig.mockReturnValue({ + INFO_EMAIL: process.env.INFO_EMAIL, + ENABLE_COURSE_DISCOVERY: false, + }); + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: jest.fn(), + isFetching: false, + }); + + render(); + + expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument(); + const searchField = screen.queryByPlaceholderText(messages.searchPlaceholder.defaultMessage); + expect(searchField).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/catalog/hooks/__tests__/useCatalog.test.tsx b/src/catalog/hooks/__tests__/useCatalog.test.tsx new file mode 100644 index 00000000..fd02e0e2 --- /dev/null +++ b/src/catalog/hooks/__tests__/useCatalog.test.tsx @@ -0,0 +1,327 @@ +import { MemoryRouter } from 'react-router-dom'; + +import { renderHook, act } from '@src/setupTest'; +import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import { useCatalog } from '../useCatalog'; + +const mockFetchData = jest.fn(); + +const mockCourseData: CourseListSearchResponse = { + results: [ + { + id: '1', + index: '1', + type: 'course', + title: 'Course 1', + data: { + id: '1', + course: 'Course 1', + start: '2021-01-01', + imageUrl: 'https://example.com/image.jpg', + org: 'Org 1', + orgImageUrl: 'https://example.com/org-image.jpg', + content: { + displayName: 'Course 1', + overview: 'Overview 1', + number: '1', + }, + number: '1', + modes: ['mode1', 'mode2'], + language: 'en', + catalogVisibility: 'public', + }, + }, + { + id: '2', + index: '2', + type: 'course', + title: 'Course 2', + data: { + id: '2', + course: 'Course 2', + start: '2021-01-02', + imageUrl: 'https://example.com/image.jpg', + org: 'Org 2', + orgImageUrl: 'https://example.com/org-image.jpg', + content: { + displayName: 'Course 2', + overview: 'Overview 2', + number: '2', + }, + number: '2', + modes: ['mode3', 'mode4'], + language: 'es', + catalogVisibility: 'public', + }, + }, + ], + total: 2, + aggs: {}, + took: 0, + maxScore: 0, +}; + +const createWrapper = () => function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +}; +describe('useCatalog', () => { + beforeEach(() => { + mockFetchData.mockClear(); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); + expect(result.current.searchString).toBe(''); + expect(result.current.lastSearchQuery).toBe(''); + expect(result.current.previousCourseData).toBeNull(); + expect(result.current.filterState).toEqual({ + previousFilters: null, + isFilterChangeInProgress: false, + }); + }); + + it('should handle search', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleSearch('javascript'); + }); + + expect(result.current.searchString).toBe('javascript'); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'javascript', + }); + }); + + it('should handle clear search', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleClearSearch(); + }); + + expect(result.current.searchString).toBe(''); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); + + it('should handle filter changes', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + const newFilters = [{ id: 'subject', value: 'math' }]; + + act(() => { + result.current.handleFetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: newFilters, + }); + }); + + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: newFilters, + searchString: '', + }); + }); + + it('should handle pagination changes', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleFetchData({ + pageIndex: 2, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); + + expect(result.current.pageIndex).toBe(2); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: 2, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: '', + }); + }); + + it('should reset pagination when filters change', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + // First change page + act(() => { + result.current.handleFetchData({ + pageIndex: 2, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); + + expect(result.current.pageIndex).toBe(2); + + // Then change filters (should reset pagination) + act(() => { + result.current.handleFetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [{ id: 'subject', value: 'math' }], + }); + }); + + expect(result.current.pageIndex).toBe(0); + }); + + it('should handle no search results', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleNoSearchResults('javascript'); + }); + + expect(result.current.lastSearchQuery).toBe('javascript'); + expect(result.current.searchString).toBe(''); + }); + + it('should clear last search query', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + // Set last search query + act(() => { + result.current.handleNoSearchResults('javascript'); + }); + + expect(result.current.lastSearchQuery).toBe('javascript'); + + // Clear it + act(() => { + result.current.clearLastSearchQuery(); + }); + + expect(result.current.lastSearchQuery).toBe(''); + }); + + it('should reset filter progress', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + // Apply filters to set progress + act(() => { + result.current.handleFetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [{ id: 'subject', value: 'math' }], + }); + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(true); + + // Reset progress + act(() => { + result.current.resetFilterProgress(); + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(false); + }); + + it('should save previous course data', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + undefined, + false, + ), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.savePreviousCourseData(mockCourseData); + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + }); + + it('should initialize with course data when provided', () => { + const { result } = renderHook(() => useCatalog( + mockFetchData, + mockCourseData, + false, + ), { + wrapper: createWrapper(), + }); + + expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); + expect(result.current.searchString).toBe(''); + expect(result.current.lastSearchQuery).toBe(''); + expect(result.current.previousCourseData).toEqual(mockCourseData); + expect(result.current.filterState).toEqual({ + previousFilters: null, + isFilterChangeInProgress: false, + }); + }); +}); diff --git a/src/catalog/hooks/__tests__/useCourseData.test.ts b/src/catalog/hooks/__tests__/useCourseData.test.ts new file mode 100644 index 00000000..cea2f3bb --- /dev/null +++ b/src/catalog/hooks/__tests__/useCourseData.test.ts @@ -0,0 +1,188 @@ +import { renderHook, act } from '@src/setupTest'; +import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; +import { useCourseData } from '../useCourseData'; + +const mockOnNoSearchResults = jest.fn(); +const mockOnClearLastSearchQuery = jest.fn(); + +const mockCourseData: CourseListSearchResponse = { + results: [ + { + id: '1', + index: '1', + type: 'course', + title: 'Course 1', + data: { + id: '1', + course: 'Course 1', + start: '2021-01-01', + imageUrl: 'https://example.com/image.jpg', + org: 'Org 1', + orgImageUrl: 'https://example.com/org-image.jpg', + content: { displayName: 'Course 1', overview: 'Overview 1', number: '1' }, + number: '1', + modes: ['mode1', 'mode2'], + language: 'en', + catalogVisibility: 'public', + }, + }, + { + id: '2', + index: '2', + type: 'course', + title: 'Course 2', + data: { + id: '2', + course: 'Course 2', + start: '2021-01-02', + imageUrl: 'https://example.com/image.jpg', + org: 'Org 2', + orgImageUrl: 'https://example.com/org-image.jpg', + content: { displayName: 'Course 2', overview: 'Overview 2', number: '2' }, + number: '2', + modes: ['mode3', 'mode4'], + language: 'es', + catalogVisibility: 'public', + }, + }, + ], + total: 2, + aggs: {}, + took: 0, + maxScore: 0, +}; + +const mockEmptyCourseData: CourseListSearchResponse = { + results: [], + total: 0, + aggs: {}, + took: 0, + maxScore: 0, +}; + +describe('useCourseData', () => { + beforeEach(() => { + mockOnNoSearchResults.mockClear(); + mockOnClearLastSearchQuery.mockClear(); + }); + + it('should initialize with null previous course data', () => { + const { result } = renderHook(() => useCourseData( + undefined, + '', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(result.current.previousCourseData).toBeNull(); + }); + + it('should save course data when not searching', () => { + const { result } = renderHook(() => useCourseData( + mockCourseData, + '', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + }); + + it('should not save course data when searching', () => { + const { result } = renderHook(() => useCourseData( + mockCourseData, + 'javascript', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(result.current.previousCourseData).toBeNull(); + }); + + it('should handle search results with data', () => { + renderHook(() => useCourseData( + mockCourseData, + 'javascript', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(mockOnClearLastSearchQuery).toHaveBeenCalled(); + expect(mockOnNoSearchResults).not.toHaveBeenCalled(); + }); + + it('should handle search results with no data', () => { + renderHook(() => useCourseData( + mockEmptyCourseData, + 'javascript', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(mockOnNoSearchResults).toHaveBeenCalledWith('javascript'); + expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); + }); + + it('should not process results while fetching', () => { + renderHook(() => useCourseData( + mockEmptyCourseData, + 'javascript', + true, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(mockOnNoSearchResults).not.toHaveBeenCalled(); + expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); + }); + + it('should not process when course data is undefined', () => { + renderHook(() => useCourseData( + undefined, + 'javascript', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + expect(mockOnNoSearchResults).not.toHaveBeenCalled(); + expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); + }); + + it('should allow manual saving of course data', () => { + const { result } = renderHook(() => useCourseData( + undefined, + '', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + act(() => { + result.current.savePreviousCourseData(mockCourseData); + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + }); + + it('should not save course data when searching', () => { + const { result } = renderHook(() => useCourseData( + undefined, + 'javascript', + false, + mockOnNoSearchResults, + mockOnClearLastSearchQuery, + )); + + act(() => { + result.current.savePreviousCourseData(mockCourseData); + }); + + expect(result.current.previousCourseData).toBeNull(); + }); +}); diff --git a/src/catalog/hooks/__tests__/useFilter.test.ts b/src/catalog/hooks/__tests__/useFilter.test.ts new file mode 100644 index 00000000..5d4b47de --- /dev/null +++ b/src/catalog/hooks/__tests__/useFilter.test.ts @@ -0,0 +1,138 @@ +import { renderHook, act } from '@src/setupTest'; +import type { DataTableFilter } from '@src/data/course-list-search/types'; +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import { useFilter } from '../useFilter'; + +const mockFetchData = jest.fn(); + +describe('useFilter', () => { + beforeEach(() => { + mockFetchData.mockClear(); + }); + + it('should initialize with default filter state', () => { + const { result } = renderHook(() => useFilter()); + + expect(result.current.filterState).toEqual({ + previousFilters: null, + isFilterChangeInProgress: false, + }); + }); + + it('should handle first filter application', () => { + const { result } = renderHook(() => useFilter()); + const newFilters: DataTableFilter[] = [{ id: 'subject', value: 'math' }]; + + act(() => { + const filterChanged = result.current.handleFilterChange( + newFilters, + mockFetchData, + '', + ); + expect(filterChanged).toBe(true); + }); + + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: newFilters, + searchString: '', + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(true); + expect(result.current.filterState.previousFilters).toEqual(newFilters); + }); + + it('should handle filter changes', () => { + const { result } = renderHook(() => useFilter()); + const initialFilters: DataTableFilter[] = [{ id: 'subject', value: 'math' }]; + const newFilters: DataTableFilter[] = [{ id: 'subject', value: 'science' }]; + + act(() => { + result.current.handleFilterChange(initialFilters, mockFetchData, ''); + }); + + act(() => { + result.current.resetFilterProgress(); + }); + + act(() => { + const filterChanged = result.current.handleFilterChange( + newFilters, + mockFetchData, + '', + ); + expect(filterChanged).toBe(true); + }); + + expect(mockFetchData).toHaveBeenLastCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: newFilters, + searchString: '', + }); + }); + + it('should not call fetchData when filter change is in progress', () => { + const { result } = renderHook(() => useFilter()); + const filters: DataTableFilter[] = [{ id: 'subject', value: 'math' }]; + + act(() => { + result.current.handleFilterChange(filters, mockFetchData, ''); + }); + + // Try to apply filters again while in progress + act(() => { + const filterChanged = result.current.handleFilterChange( + filters, + mockFetchData, + '', + ); + expect(filterChanged).toBe(false); + }); + + expect(mockFetchData).toHaveBeenCalledTimes(1); + }); + + it('should reset filter progress', () => { + const { result } = renderHook(() => useFilter()); + const filters: DataTableFilter[] = [{ id: 'subject', value: 'math' }]; + + act(() => { + result.current.handleFilterChange(filters, mockFetchData, ''); + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(true); + + act(() => { + result.current.resetFilterProgress(); + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(false); + }); + + it('should not trigger filter change for same filters', () => { + const { result } = renderHook(() => useFilter()); + const filters: DataTableFilter[] = [{ id: 'subject', value: 'math' }]; + + act(() => { + result.current.handleFilterChange(filters, mockFetchData, ''); + }); + + act(() => { + result.current.resetFilterProgress(); + }); + + // Apply same filters again + act(() => { + const filterChanged = result.current.handleFilterChange( + filters, + mockFetchData, + '', + ); + expect(filterChanged).toBe(false); + }); + + expect(mockFetchData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/catalog/hooks/__tests__/usePagination.test.ts b/src/catalog/hooks/__tests__/usePagination.test.ts new file mode 100644 index 00000000..7ae4ec90 --- /dev/null +++ b/src/catalog/hooks/__tests__/usePagination.test.ts @@ -0,0 +1,53 @@ +import { renderHook, act } from '@src/setupTest'; +import { DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; +import { usePagination } from '../usePagination'; + +describe('usePagination', () => { + it('should initialize with default page index', () => { + const { result } = renderHook(() => usePagination()); + + expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); + }); + + it('should handle page change', () => { + const { result } = renderHook(() => usePagination()); + + act(() => { + result.current.handlePageChange(2); + }); + + expect(result.current.pageIndex).toBe(2); + }); + + it('should reset pagination to first page', () => { + const { result } = renderHook(() => usePagination()); + + // Change to page 3 + act(() => { + result.current.handlePageChange(3); + }); + + expect(result.current.pageIndex).toBe(3); + + // Reset to first page + act(() => { + result.current.resetPagination(); + }); + + expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); + }); + + it('should handle multiple page changes', () => { + const { result } = renderHook(() => usePagination()); + + act(() => { + result.current.handlePageChange(1); + }); + expect(result.current.pageIndex).toBe(1); + + act(() => { + result.current.handlePageChange(5); + }); + expect(result.current.pageIndex).toBe(5); + }); +}); diff --git a/src/catalog/hooks/__tests__/useSearch.test.ts b/src/catalog/hooks/__tests__/useSearch.test.ts new file mode 100644 index 00000000..5521b0cf --- /dev/null +++ b/src/catalog/hooks/__tests__/useSearch.test.ts @@ -0,0 +1,129 @@ +import { useSearchParams } from 'react-router-dom'; + +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import { renderHook, act } from '@src/setupTest'; +import { useSearch } from '../useSearch'; + +jest.mock('react-router-dom', () => ({ + useSearchParams: jest.fn(), +})); + +const mockFetchData = jest.fn(); +const mockSetSearchParams = jest.fn(); + +describe('useSearch', () => { + beforeEach(() => { + mockFetchData.mockClear(); + mockSetSearchParams.mockClear(); + (useSearchParams as jest.Mock).mockReturnValue([ + { get: jest.fn().mockReturnValue(null) }, + mockSetSearchParams, + ]); + }); + + it('should initialize with empty search state', () => { + const { result } = renderHook(() => useSearch(mockFetchData)); + + expect(result.current.searchString).toBe(''); + expect(result.current.lastSearchQuery).toBe(''); + }); + + it('should handle search', () => { + const { result } = renderHook(() => useSearch(mockFetchData)); + + act(() => { + result.current.handleSearch('javascript'); + }); + + expect(result.current.searchString).toBe('javascript'); + expect(mockSetSearchParams).toHaveBeenCalledWith({ search_query: 'javascript' }); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'javascript', + }); + }); + + it('should handle clear search', () => { + const { result } = renderHook(() => useSearch(mockFetchData)); + + act(() => { + result.current.handleSearch('javascript'); + }); + + act(() => { + result.current.handleClearSearch(); + }); + + expect(result.current.searchString).toBe(''); + expect(result.current.lastSearchQuery).toBe(''); + expect(mockSetSearchParams).toHaveBeenCalledWith({}); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); + + it('should handle no search results', () => { + const { result } = renderHook(() => useSearch(mockFetchData)); + + act(() => { + result.current.handleNoSearchResults('javascript'); + }); + + expect(result.current.lastSearchQuery).toBe('javascript'); + expect(result.current.searchString).toBe(''); + expect(mockSetSearchParams).toHaveBeenCalledWith({}); + }); + + it('should clear last search query', () => { + const { result } = renderHook(() => useSearch(mockFetchData)); + + act(() => { + result.current.handleNoSearchResults('javascript'); + }); + + expect(result.current.lastSearchQuery).toBe('javascript'); + + act(() => { + result.current.clearLastSearchQuery(); + }); + + expect(result.current.lastSearchQuery).toBe(''); + }); + + it('should initialize from URL search query', () => { + const mockGet = jest.fn().mockReturnValue('react'); + (useSearchParams as jest.Mock).mockReturnValue([ + { get: mockGet }, + mockSetSearchParams, + ]); + + renderHook(() => useSearch(mockFetchData)); + + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'react', + }); + }); + + it('should fetch default data when no URL search query', () => { + const mockGet = jest.fn().mockReturnValue(null); + (useSearchParams as jest.Mock).mockReturnValue([ + { get: mockGet }, + mockSetSearchParams, + ]); + + renderHook(() => useSearch(mockFetchData)); + + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); +}); From 9ea9fac915c5951c853fa7334560f3acd28a1592 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Thu, 23 Oct 2025 10:44:49 +0300 Subject: [PATCH 05/10] refactor: some tests refactoring --- src/catalog/CatalogPage.test.tsx | 2 +- src/catalog/CatalogPage.tsx | 2 +- .../hooks/__tests__/useCatalog.test.tsx | 178 ++++++----------- .../hooks/__tests__/useCourseData.test.ts | 184 +++++++----------- .../hooks/__tests__/usePagination.test.ts | 2 - src/catalog/hooks/types.ts | 15 ++ src/catalog/hooks/useCatalog.ts | 25 ++- src/catalog/hooks/useCourseData.ts | 19 +- src/catalog/hooks/useFilter.ts | 7 +- src/catalog/hooks/usePagination.ts | 2 +- src/catalog/hooks/useSearch.ts | 4 +- 11 files changed, 182 insertions(+), 258 deletions(-) create mode 100644 src/catalog/hooks/types.ts diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index 41a35370..39605d08 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -1173,7 +1173,7 @@ describe('CatalogPage', () => { expect(screen.queryByRole('button', { name: /of/i })).not.toBeInTheDocument(); }); - describe('CatalogPage - SubHeader Title Tests', () => { + describe('SubHeader title', () => { it('should display default title when no search is performed', () => { mockUseCourseListSearch.mockReturnValue({ isLoading: false, diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index b32c885e..b0d8a696 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -38,7 +38,7 @@ const CatalogPage = () => { handleClearSearch, handleFetchData, resetFilterProgress, - } = useCatalog(fetchData, courseData, isFetching); + } = useCatalog({ fetchData, courseData, isFetching }); /** * Determines which data to display in the catalog based on search state and results. diff --git a/src/catalog/hooks/__tests__/useCatalog.test.tsx b/src/catalog/hooks/__tests__/useCatalog.test.tsx index fd02e0e2..0292a0a8 100644 --- a/src/catalog/hooks/__tests__/useCatalog.test.tsx +++ b/src/catalog/hooks/__tests__/useCatalog.test.tsx @@ -1,65 +1,18 @@ import { MemoryRouter } from 'react-router-dom'; import { renderHook, act } from '@src/setupTest'; -import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import { mockCourseListSearchResponse } from '@src/__mocks__'; import { useCatalog } from '../useCatalog'; const mockFetchData = jest.fn(); -const mockCourseData: CourseListSearchResponse = { - results: [ - { - id: '1', - index: '1', - type: 'course', - title: 'Course 1', - data: { - id: '1', - course: 'Course 1', - start: '2021-01-01', - imageUrl: 'https://example.com/image.jpg', - org: 'Org 1', - orgImageUrl: 'https://example.com/org-image.jpg', - content: { - displayName: 'Course 1', - overview: 'Overview 1', - number: '1', - }, - number: '1', - modes: ['mode1', 'mode2'], - language: 'en', - catalogVisibility: 'public', - }, - }, - { - id: '2', - index: '2', - type: 'course', - title: 'Course 2', - data: { - id: '2', - course: 'Course 2', - start: '2021-01-02', - imageUrl: 'https://example.com/image.jpg', - org: 'Org 2', - orgImageUrl: 'https://example.com/org-image.jpg', - content: { - displayName: 'Course 2', - overview: 'Overview 2', - number: '2', - }, - number: '2', - modes: ['mode3', 'mode4'], - language: 'es', - catalogVisibility: 'public', - }, - }, - ], - total: 2, - aggs: {}, - took: 0, - maxScore: 0, +const mockCourseData = { + ...mockCourseListSearchResponse, + results: mockCourseListSearchResponse.results.map(result => ({ + ...result, + title: result.data.content.displayName, + })), }; const createWrapper = () => function Wrapper({ children }: { children: React.ReactNode }) { @@ -69,17 +22,18 @@ const createWrapper = () => function Wrapper({ children }: { children: React.Rea ); }; + describe('useCatalog', () => { beforeEach(() => { mockFetchData.mockClear(); }); it('should initialize with default state', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -94,11 +48,11 @@ describe('useCatalog', () => { }); it('should handle search', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -116,11 +70,11 @@ describe('useCatalog', () => { }); it('should handle clear search', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -137,11 +91,11 @@ describe('useCatalog', () => { }); it('should handle filter changes', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -164,11 +118,11 @@ describe('useCatalog', () => { }); it('should handle pagination changes', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -190,15 +144,14 @@ describe('useCatalog', () => { }); it('should reset pagination when filters change', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); - // First change page act(() => { result.current.handleFetchData({ pageIndex: 2, @@ -209,7 +162,6 @@ describe('useCatalog', () => { expect(result.current.pageIndex).toBe(2); - // Then change filters (should reset pagination) act(() => { result.current.handleFetchData({ pageIndex: DEFAULT_PAGE_INDEX, @@ -222,11 +174,11 @@ describe('useCatalog', () => { }); it('should handle no search results', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -239,22 +191,20 @@ describe('useCatalog', () => { }); it('should clear last search query', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); - // Set last search query act(() => { result.current.handleNoSearchResults('javascript'); }); expect(result.current.lastSearchQuery).toBe('javascript'); - // Clear it act(() => { result.current.clearLastSearchQuery(); }); @@ -263,15 +213,14 @@ describe('useCatalog', () => { }); it('should reset filter progress', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); - // Apply filters to set progress act(() => { result.current.handleFetchData({ pageIndex: DEFAULT_PAGE_INDEX, @@ -282,7 +231,6 @@ describe('useCatalog', () => { expect(result.current.filterState.isFilterChangeInProgress).toBe(true); - // Reset progress act(() => { result.current.resetFilterProgress(); }); @@ -291,11 +239,11 @@ describe('useCatalog', () => { }); it('should save previous course data', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - undefined, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { wrapper: createWrapper(), }); @@ -307,11 +255,11 @@ describe('useCatalog', () => { }); it('should initialize with course data when provided', () => { - const { result } = renderHook(() => useCatalog( - mockFetchData, - mockCourseData, - false, - ), { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: mockCourseData, + isFetching: false, + }), { wrapper: createWrapper(), }); diff --git a/src/catalog/hooks/__tests__/useCourseData.test.ts b/src/catalog/hooks/__tests__/useCourseData.test.ts index cea2f3bb..445c3b0f 100644 --- a/src/catalog/hooks/__tests__/useCourseData.test.ts +++ b/src/catalog/hooks/__tests__/useCourseData.test.ts @@ -1,63 +1,21 @@ import { renderHook, act } from '@src/setupTest'; -import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; +import { mockCourseListSearchResponse } from '@src/__mocks__'; import { useCourseData } from '../useCourseData'; const mockOnNoSearchResults = jest.fn(); const mockOnClearLastSearchQuery = jest.fn(); -const mockCourseData: CourseListSearchResponse = { - results: [ - { - id: '1', - index: '1', - type: 'course', - title: 'Course 1', - data: { - id: '1', - course: 'Course 1', - start: '2021-01-01', - imageUrl: 'https://example.com/image.jpg', - org: 'Org 1', - orgImageUrl: 'https://example.com/org-image.jpg', - content: { displayName: 'Course 1', overview: 'Overview 1', number: '1' }, - number: '1', - modes: ['mode1', 'mode2'], - language: 'en', - catalogVisibility: 'public', - }, - }, - { - id: '2', - index: '2', - type: 'course', - title: 'Course 2', - data: { - id: '2', - course: 'Course 2', - start: '2021-01-02', - imageUrl: 'https://example.com/image.jpg', - org: 'Org 2', - orgImageUrl: 'https://example.com/org-image.jpg', - content: { displayName: 'Course 2', overview: 'Overview 2', number: '2' }, - number: '2', - modes: ['mode3', 'mode4'], - language: 'es', - catalogVisibility: 'public', - }, - }, - ], - total: 2, - aggs: {}, - took: 0, - maxScore: 0, +const mockCourseData = { + ...mockCourseListSearchResponse, + results: mockCourseListSearchResponse.results.map(result => ({ + ...result, + title: result.data.content.displayName, + })), }; -const mockEmptyCourseData: CourseListSearchResponse = { +const mockEmptyCourseData = { + ...mockCourseListSearchResponse, results: [], - total: 0, - aggs: {}, - took: 0, - maxScore: 0, }; describe('useCourseData', () => { @@ -67,62 +25,62 @@ describe('useCourseData', () => { }); it('should initialize with null previous course data', () => { - const { result } = renderHook(() => useCourseData( - undefined, - '', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + const { result } = renderHook(() => useCourseData({ + courseData: undefined, + searchString: '', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); expect(result.current.previousCourseData).toBeNull(); }); it('should save course data when not searching', () => { - const { result } = renderHook(() => useCourseData( - mockCourseData, - '', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + const { result } = renderHook(() => useCourseData({ + courseData: mockCourseData, + searchString: '', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); expect(result.current.previousCourseData).toEqual(mockCourseData); }); it('should not save course data when searching', () => { - const { result } = renderHook(() => useCourseData( - mockCourseData, - 'javascript', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + const { result } = renderHook(() => useCourseData({ + courseData: mockCourseData, + searchString: 'javascript', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); expect(result.current.previousCourseData).toBeNull(); }); it('should handle search results with data', () => { - renderHook(() => useCourseData( - mockCourseData, - 'javascript', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + renderHook(() => useCourseData({ + courseData: mockCourseData, + searchString: 'javascript', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); expect(mockOnClearLastSearchQuery).toHaveBeenCalled(); expect(mockOnNoSearchResults).not.toHaveBeenCalled(); }); it('should handle search results with no data', () => { - renderHook(() => useCourseData( - mockEmptyCourseData, - 'javascript', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + renderHook(() => useCourseData({ + courseData: mockEmptyCourseData, + searchString: 'javascript', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); expect(mockOnNoSearchResults).toHaveBeenCalledWith('javascript'); expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); @@ -130,11 +88,13 @@ describe('useCourseData', () => { it('should not process results while fetching', () => { renderHook(() => useCourseData( - mockEmptyCourseData, - 'javascript', - true, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, + { + courseData: mockEmptyCourseData, + searchString: 'javascript', + isFetching: true, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + }, )); expect(mockOnNoSearchResults).not.toHaveBeenCalled(); @@ -142,26 +102,26 @@ describe('useCourseData', () => { }); it('should not process when course data is undefined', () => { - renderHook(() => useCourseData( - undefined, - 'javascript', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + renderHook(() => useCourseData({ + courseData: undefined, + searchString: 'javascript', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); expect(mockOnNoSearchResults).not.toHaveBeenCalled(); expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); }); it('should allow manual saving of course data', () => { - const { result } = renderHook(() => useCourseData( - undefined, - '', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + const { result } = renderHook(() => useCourseData({ + courseData: undefined, + searchString: '', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); act(() => { result.current.savePreviousCourseData(mockCourseData); @@ -171,13 +131,13 @@ describe('useCourseData', () => { }); it('should not save course data when searching', () => { - const { result } = renderHook(() => useCourseData( - undefined, - 'javascript', - false, - mockOnNoSearchResults, - mockOnClearLastSearchQuery, - )); + const { result } = renderHook(() => useCourseData({ + courseData: undefined, + searchString: 'javascript', + isFetching: false, + onNoSearchResults: mockOnNoSearchResults, + onClearLastSearchQuery: mockOnClearLastSearchQuery, + })); act(() => { result.current.savePreviousCourseData(mockCourseData); diff --git a/src/catalog/hooks/__tests__/usePagination.test.ts b/src/catalog/hooks/__tests__/usePagination.test.ts index 7ae4ec90..63cdf5f7 100644 --- a/src/catalog/hooks/__tests__/usePagination.test.ts +++ b/src/catalog/hooks/__tests__/usePagination.test.ts @@ -22,14 +22,12 @@ describe('usePagination', () => { it('should reset pagination to first page', () => { const { result } = renderHook(() => usePagination()); - // Change to page 3 act(() => { result.current.handlePageChange(3); }); expect(result.current.pageIndex).toBe(3); - // Reset to first page act(() => { result.current.resetPagination(); }); diff --git a/src/catalog/hooks/types.ts b/src/catalog/hooks/types.ts new file mode 100644 index 00000000..9f8243d9 --- /dev/null +++ b/src/catalog/hooks/types.ts @@ -0,0 +1,15 @@ +import type { CourseListSearchResponse, DataTableParams } from '@src/data/course-list-search/types'; + +export interface UseCatalogProps { + fetchData: (params: DataTableParams) => void; + courseData: CourseListSearchResponse | undefined; + isFetching: boolean; +} + +export interface UseCourseDataProps { + courseData: CourseListSearchResponse | undefined; + searchString: string; + isFetching: boolean; + onNoSearchResults: (searchQuery: string) => void; + onClearLastSearchQuery: () => void; +} diff --git a/src/catalog/hooks/useCatalog.ts b/src/catalog/hooks/useCatalog.ts index 73ca1646..dd596d53 100644 --- a/src/catalog/hooks/useCatalog.ts +++ b/src/catalog/hooks/useCatalog.ts @@ -1,15 +1,12 @@ import { useCallback } from 'react'; -import type { - CourseListSearchResponse, - DataTableParams, - DataTableFilter, -} from '@src/data/course-list-search/types'; +import type { DataTableParams, DataTableFilter } from '@src/data/course-list-search/types'; import { DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; import { useSearch } from './useSearch'; import { useFilter } from './useFilter'; import { usePagination } from './usePagination'; import { useCourseData } from './useCourseData'; +import type { UseCatalogProps } from './types'; /** * Main catalog hook that orchestrates all catalog functionality. @@ -24,11 +21,11 @@ import { useCourseData } from './useCourseData'; * - Course data caching for better UX * - Coordinated data fetching with proper state management */ -export const useCatalog = ( - fetchData: (params: DataTableParams) => void, - courseData: CourseListSearchResponse | undefined, - isFetching: boolean, -) => { +export const useCatalog = ({ + fetchData, + courseData, + isFetching, +}: UseCatalogProps) => { const { searchString, lastSearchQuery, @@ -42,13 +39,13 @@ export const useCatalog = ( const { pageIndex, handlePageChange, resetPagination } = usePagination(); - const { previousCourseData, savePreviousCourseData } = useCourseData( + const { previousCourseData, savePreviousCourseData } = useCourseData({ courseData, searchString, isFetching, - handleNoSearchResults, - clearLastSearchQuery, - ); + onNoSearchResults: handleNoSearchResults, + onClearLastSearchQuery: clearLastSearchQuery, + }); const handleFetchData = useCallback((params: DataTableParams) => { const { pageIndex: newPageIndex, filters: newFilters } = params; diff --git a/src/catalog/hooks/useCourseData.ts b/src/catalog/hooks/useCourseData.ts index cf781e5e..6af1f9be 100644 --- a/src/catalog/hooks/useCourseData.ts +++ b/src/catalog/hooks/useCourseData.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; +import type { UseCourseDataProps } from './types'; /** * Custom hook for managing course data caching and search result handling. @@ -11,13 +12,13 @@ import type { CourseListSearchResponse } from '@src/data/course-list-search/type * - Manage data persistence for better UX * - Coordinate with search state management */ -export const useCourseData = ( - courseData: CourseListSearchResponse | undefined, - searchString: string, - isFetching: boolean, - onNoSearchResults: (searchQuery: string) => void, - onClearLastSearchQuery: () => void, -) => { +export const useCourseData = ({ + courseData, + searchString, + isFetching, + onNoSearchResults, + onClearLastSearchQuery, +}: UseCourseDataProps) => { const [previousCourseData, setPreviousCourseData] = useState(null); /** @@ -33,7 +34,9 @@ export const useCourseData = ( * Handles course data state changes and search result processing. */ useEffect(() => { - if (!courseData) { return; } + if (!courseData) { + return; + } if (!searchString) { savePreviousCourseData(courseData); diff --git a/src/catalog/hooks/useFilter.ts b/src/catalog/hooks/useFilter.ts index c04fb063..71902ec4 100644 --- a/src/catalog/hooks/useFilter.ts +++ b/src/catalog/hooks/useFilter.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; -import { DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; import type { DataTableFilter, DataTableParams } from '@src/data/course-list-search/types'; import { compareFilters } from '../utils'; @@ -60,7 +60,10 @@ export const useFilter = () => { })); fetchData({ - pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE, filters: newFilters, searchString, + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: newFilters, + searchString, }); return true; } diff --git a/src/catalog/hooks/usePagination.ts b/src/catalog/hooks/usePagination.ts index 950a81ee..b8a06241 100644 --- a/src/catalog/hooks/usePagination.ts +++ b/src/catalog/hooks/usePagination.ts @@ -18,7 +18,7 @@ export const usePagination = () => { }, []); const resetPagination = useCallback(() => { - setPageIndex(0); + setPageIndex(DEFAULT_PAGE_INDEX); }, []); return { diff --git a/src/catalog/hooks/useSearch.ts b/src/catalog/hooks/useSearch.ts index 1b7908e5..c9d37760 100644 --- a/src/catalog/hooks/useSearch.ts +++ b/src/catalog/hooks/useSearch.ts @@ -30,7 +30,7 @@ export const useSearch = (fetchData: (params: DataTableParams) => void) => { setSearchParams(query ? { search_query: query } : {}); fetchData({ - pageIndex: 0, + pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [], searchString: query, @@ -46,7 +46,7 @@ export const useSearch = (fetchData: (params: DataTableParams) => void) => { setSearchParams({}); fetchData({ - pageIndex: 0, + pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [], }); From 18f38947e18967cb8142dc66022494a9bc131355 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 5 Nov 2025 09:37:43 +0200 Subject: [PATCH 06/10] feat: added search btn for SearchField --- src/catalog/CatalogPage.test.tsx | 30 ++++++++++++++++++++++++++++++ src/catalog/CatalogPage.tsx | 1 + 2 files changed, 31 insertions(+) diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index 39605d08..ff4e2317 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -196,6 +196,36 @@ describe('CatalogPage', () => { }); }); + it('should call fetchData with search query when search button is clicked', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await userEvent.type(searchField, 'python'); + + const searchButton = screen.getByRole('button', { name: 'search submit search' }); + await userEvent.click(searchButton); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'python', + }), + ); + }); + }); + it('should clear search when clear button is clicked', async () => { const mockFetchData = jest.fn(); mockUseCourseListSearch.mockReturnValue({ diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index b0d8a696..dbb8c2de 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -120,6 +120,7 @@ const CatalogPage = () => { value={searchString} onSubmit={handleSearch} onClear={handleClearSearch} + submitButtonLocation="external" /> )} Date: Thu, 6 Nov 2025 20:25:30 +0200 Subject: [PATCH 07/10] refactor: removed last search logic --- src/catalog/CatalogPage.test.tsx | 82 ++++++++-- src/catalog/CatalogPage.tsx | 17 +- .../hooks/__tests__/useCatalog.test.tsx | 42 +++-- .../hooks/__tests__/useCourseData.test.ts | 131 ++++++++-------- src/catalog/hooks/__tests__/useSearch.test.ts | 147 +++++++++++------- src/catalog/hooks/types.ts | 7 +- src/catalog/hooks/useCatalog.ts | 13 +- src/catalog/hooks/useCourseData.ts | 29 +--- src/catalog/hooks/useSearch.ts | 71 ++++----- src/catalog/types.ts | 1 - src/catalog/utils.ts | 4 - 11 files changed, 310 insertions(+), 234 deletions(-) diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index ff4e2317..a876dedb 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -1,4 +1,5 @@ import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { render, within, screen, waitFor, userEvent, @@ -15,6 +16,7 @@ jest.mock('../data/course-list-search/hooks', () => ({ jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn(), + camelCaseObject: jest.fn(obj => obj), })); jest.mock('@edx/frontend-platform/react', () => ({ @@ -23,9 +25,16 @@ jest.mock('@edx/frontend-platform/react', () => ({ ), })); +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + const mockUseCourseListSearch = useCourseListSearch as jest.Mock; const mockGetConfig = getConfig as jest.Mock; +const actualUseCourseListSearch = jest + .requireActual('../data/course-list-search/hooks').useCourseListSearch; + describe('CatalogPage', () => { beforeEach(() => { jest.clearAllMocks(); @@ -1286,8 +1295,16 @@ describe('CatalogPage', () => { }); }); - it('should display no search results title when search is cleared after having no results', async () => { + it('should keep cached courses visible after empty results and restore the search title once data returns', async () => { const mockFetchData = jest.fn(); + const query = 'nonexistent'; + + const emptySearchResults = { + ...mockCourseListSearchResponse, + results: [], + total: 0, + }; + mockUseCourseListSearch.mockReturnValue({ isLoading: false, isError: false, @@ -1300,15 +1317,9 @@ describe('CatalogPage', () => { const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); - await userEvent.type(searchField, 'nonexistent'); + await userEvent.type(searchField, query); await userEvent.keyboard('{Enter}'); - const emptySearchResults = { - ...mockCourseListSearchResponse, - results: [], - total: 0, - }; - mockUseCourseListSearch.mockReturnValue({ isLoading: false, isError: false, @@ -1321,10 +1332,14 @@ describe('CatalogPage', () => { await waitFor(() => { expect(screen.getByText( - messages.noSearchResults.defaultMessage.replace('{query}', 'nonexistent'), + messages.noSearchResults.defaultMessage.replace('{query}', query), )).toBeInTheDocument(); }); + mockCourseListSearchResponse.results.forEach(result => { + expect(screen.getByText(result.data.content.displayName)).toBeInTheDocument(); + }); + mockUseCourseListSearch.mockReturnValue({ isLoading: false, isError: false, @@ -1337,7 +1352,7 @@ describe('CatalogPage', () => { await waitFor(() => { expect(screen.getByText( - messages.noSearchResults.defaultMessage.replace('{query}', 'nonexistent'), + messages.searchResults.defaultMessage.replace('{query}', query), )).toBeInTheDocument(); }); }); @@ -1468,3 +1483,50 @@ describe('CatalogPage', () => { }); }); }); + +describe('CatalogPage search integration', () => { + let mockPost: jest.Mock; + + beforeEach(() => { + mockPost = jest.fn().mockResolvedValue({ data: mockCourseListSearchResponse }); + + getAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + mockUseCourseListSearch.mockImplementation(params => actualUseCourseListSearch(params)); + + mockGetConfig.mockReturnValue({ + INFO_EMAIL: process.env.INFO_EMAIL, + ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY, + }); + }); + + afterEach(() => { + getAuthenticatedHttpClient.mockReset(); + mockUseCourseListSearch.mockReset(); + mockGetConfig.mockReset(); + }); + + it('sends search_string to FormData when searching', async () => { + render(); + + await waitFor(() => expect(mockPost).toHaveBeenCalled()); + + const [, initialFormData] = mockPost.mock.calls[0]; + expect((initialFormData as FormData).get('search_string')).toBeNull(); + + const searchField = await screen.findByPlaceholderText( + messages.searchPlaceholder.defaultMessage, + ); + + await userEvent.type(searchField, 'python'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => expect(mockPost.mock.calls.length).toBeGreaterThanOrEqual(2)); + + const searchCall = mockPost.mock.calls.find(([, formData]) => ( + (formData as FormData).get('search_string') === 'python' + )); + + expect(searchCall).toBeDefined(); + }); +}); diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index dbb8c2de..4c043120 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -8,7 +8,7 @@ import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import classNames from 'classnames'; -import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; +import { DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; import { useCourseListSearch } from '@src/data/course-list-search/hooks'; import { AlertNotification, CourseCard, Loading, SubHeader, @@ -31,7 +31,6 @@ const CatalogPage = () => { const { pageIndex, filterState, - lastSearchQuery, searchString, previousCourseData, handleSearch, @@ -43,24 +42,17 @@ const CatalogPage = () => { /** * Determines which data to display in the catalog based on search state and results. * Shows previous course data when: - * - User has an active search but no results were found, OR - * - User previously searched, cleared the search, but no results exist + * - User has an active search but no results were found * This provides better UX by showing cached data instead of empty state. */ const displayData = useMemo(() => { const hasSearchResults = (courseData?.results?.length ?? 0) > 0; const hasActiveSearch = Boolean(searchString); - const hadPreviousSearch = Boolean(lastSearchQuery); - const shouldShowPreviousData = (hasActiveSearch && !hasSearchResults && previousCourseData) - || (hadPreviousSearch && !hasActiveSearch && !hasSearchResults && previousCourseData); + const shouldShowPreviousData = hasActiveSearch && !hasSearchResults && previousCourseData; return shouldShowPreviousData ? previousCourseData : courseData; - }, [courseData, searchString, lastSearchQuery, previousCourseData]); - - useEffect(() => { - fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE }); - }, [fetchData]); + }, [courseData, searchString, previousCourseData]); useEffect(() => { if (!isFetching && filterState.isFilterChangeInProgress) { @@ -101,7 +93,6 @@ const CatalogPage = () => { { expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); expect(result.current.searchString).toBe(''); - expect(result.current.lastSearchQuery).toBe(''); expect(result.current.previousCourseData).toBeNull(); expect(result.current.filterState).toEqual({ previousFilters: null, @@ -173,7 +172,7 @@ describe('useCatalog', () => { expect(result.current.pageIndex).toBe(0); }); - it('should handle no search results', () => { + it('should include current search string when fetching data', () => { const { result } = renderHook(() => useCatalog({ fetchData: mockFetchData, courseData: undefined, @@ -183,14 +182,28 @@ describe('useCatalog', () => { }); act(() => { - result.current.handleNoSearchResults('javascript'); + result.current.handleSearch('javascript'); }); - expect(result.current.lastSearchQuery).toBe('javascript'); - expect(result.current.searchString).toBe(''); + mockFetchData.mockClear(); + + act(() => { + result.current.handleFetchData({ + pageIndex: 1, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); + + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: 1, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'javascript', + }); }); - it('should clear last search query', () => { + it('should keep cached data unchanged while a search is active', () => { const { result } = renderHook(() => useCatalog({ fetchData: mockFetchData, courseData: undefined, @@ -199,17 +212,25 @@ describe('useCatalog', () => { wrapper: createWrapper(), }); + const initialData = { ...mockCourseData }; + + act(() => { + result.current.savePreviousCourseData(initialData); + }); + + expect(result.current.previousCourseData).toEqual(initialData); + act(() => { - result.current.handleNoSearchResults('javascript'); + result.current.handleSearch('python'); }); - expect(result.current.lastSearchQuery).toBe('javascript'); + const newCourseData = { ...mockCourseData, total: 99 }; act(() => { - result.current.clearLastSearchQuery(); + result.current.savePreviousCourseData(newCourseData); }); - expect(result.current.lastSearchQuery).toBe(''); + expect(result.current.previousCourseData).toEqual(initialData); }); it('should reset filter progress', () => { @@ -265,7 +286,6 @@ describe('useCatalog', () => { expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); expect(result.current.searchString).toBe(''); - expect(result.current.lastSearchQuery).toBe(''); expect(result.current.previousCourseData).toEqual(mockCourseData); expect(result.current.filterState).toEqual({ previousFilters: null, diff --git a/src/catalog/hooks/__tests__/useCourseData.test.ts b/src/catalog/hooks/__tests__/useCourseData.test.ts index 445c3b0f..1a2dcf46 100644 --- a/src/catalog/hooks/__tests__/useCourseData.test.ts +++ b/src/catalog/hooks/__tests__/useCourseData.test.ts @@ -2,9 +2,6 @@ import { renderHook, act } from '@src/setupTest'; import { mockCourseListSearchResponse } from '@src/__mocks__'; import { useCourseData } from '../useCourseData'; -const mockOnNoSearchResults = jest.fn(); -const mockOnClearLastSearchQuery = jest.fn(); - const mockCourseData = { ...mockCourseListSearchResponse, results: mockCourseListSearchResponse.results.map(result => ({ @@ -13,24 +10,11 @@ const mockCourseData = { })), }; -const mockEmptyCourseData = { - ...mockCourseListSearchResponse, - results: [], -}; - describe('useCourseData', () => { - beforeEach(() => { - mockOnNoSearchResults.mockClear(); - mockOnClearLastSearchQuery.mockClear(); - }); - it('should initialize with null previous course data', () => { const { result } = renderHook(() => useCourseData({ courseData: undefined, searchString: '', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, })); expect(result.current.previousCourseData).toBeNull(); @@ -40,9 +24,6 @@ describe('useCourseData', () => { const { result } = renderHook(() => useCourseData({ courseData: mockCourseData, searchString: '', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, })); expect(result.current.previousCourseData).toEqual(mockCourseData); @@ -52,75 +33,100 @@ describe('useCourseData', () => { const { result } = renderHook(() => useCourseData({ courseData: mockCourseData, searchString: 'javascript', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, })); expect(result.current.previousCourseData).toBeNull(); }); - it('should handle search results with data', () => { - renderHook(() => useCourseData({ + it('should keep cached data unchanged while search is active', () => { + const { result } = renderHook(() => useCourseData({ courseData: mockCourseData, - searchString: 'javascript', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, + searchString: '', })); - expect(mockOnClearLastSearchQuery).toHaveBeenCalled(); - expect(mockOnNoSearchResults).not.toHaveBeenCalled(); - }); + expect(result.current.previousCourseData).toEqual(mockCourseData); - it('should handle search results with no data', () => { - renderHook(() => useCourseData({ - courseData: mockEmptyCourseData, - searchString: 'javascript', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, - })); + act(() => { + result.current.savePreviousCourseData({ + ...mockCourseData, + total: 999, + }); + }); + + expect(result.current.previousCourseData).toEqual({ + ...mockCourseData, + total: 999, + }); - expect(mockOnNoSearchResults).toHaveBeenCalledWith('javascript'); - expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); + act(() => { + result.current.savePreviousCourseData(mockCourseData); + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); }); - it('should not process results while fetching', () => { - renderHook(() => useCourseData( + it('should allow caching new data when search string becomes empty', () => { + const { result, rerender } = renderHook( + ({ courseData, searchString }: { + courseData: typeof mockCourseData | undefined; searchString: string, + }) => useCourseData({ courseData, searchString }), { - courseData: mockEmptyCourseData, - searchString: 'javascript', - isFetching: true, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, + initialProps: { + courseData: mockCourseData, + searchString: '', + }, }, - )); + ); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + + rerender({ + courseData: { ...mockCourseData, total: 10 }, + searchString: 'python', + }); - expect(mockOnNoSearchResults).not.toHaveBeenCalled(); - expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); + expect(result.current.previousCourseData).toEqual(mockCourseData); + + rerender({ + courseData: { ...mockCourseData, total: 20 }, + searchString: '', + }); + + expect(result.current.previousCourseData).toEqual({ ...mockCourseData, total: 20 }); }); - it('should not process when course data is undefined', () => { - renderHook(() => useCourseData({ + it('should ignore undefined course data', () => { + const { result } = renderHook(() => useCourseData({ + courseData: undefined, + searchString: '', + })); + + expect(result.current.previousCourseData).toBeNull(); + + act(() => { + result.current.savePreviousCourseData(mockCourseData); + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + }); + + it('should prevent manual save while search is active', () => { + const { result } = renderHook(() => useCourseData({ courseData: undefined, searchString: 'javascript', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, })); - expect(mockOnNoSearchResults).not.toHaveBeenCalled(); - expect(mockOnClearLastSearchQuery).not.toHaveBeenCalled(); + act(() => { + result.current.savePreviousCourseData(mockCourseData); + }); + + expect(result.current.previousCourseData).toBeNull(); }); it('should allow manual saving of course data', () => { const { result } = renderHook(() => useCourseData({ courseData: undefined, searchString: '', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, })); act(() => { @@ -134,9 +140,6 @@ describe('useCourseData', () => { const { result } = renderHook(() => useCourseData({ courseData: undefined, searchString: 'javascript', - isFetching: false, - onNoSearchResults: mockOnNoSearchResults, - onClearLastSearchQuery: mockOnClearLastSearchQuery, })); act(() => { diff --git a/src/catalog/hooks/__tests__/useSearch.test.ts b/src/catalog/hooks/__tests__/useSearch.test.ts index 5521b0cf..6fccfc1d 100644 --- a/src/catalog/hooks/__tests__/useSearch.test.ts +++ b/src/catalog/hooks/__tests__/useSearch.test.ts @@ -1,7 +1,9 @@ import { useSearchParams } from 'react-router-dom'; import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; -import { renderHook, act } from '@src/setupTest'; +import { renderHook, act, waitFor } from '@src/setupTest'; +import { mockCourseListSearchResponse } from '@src/__mocks__'; +import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; import { useSearch } from '../useSearch'; jest.mock('react-router-dom', () => ({ @@ -11,32 +13,63 @@ jest.mock('react-router-dom', () => ({ const mockFetchData = jest.fn(); const mockSetSearchParams = jest.fn(); +const withSearchQuery = (query: string | null) => { + const params = new URLSearchParams(); + if (query) { + params.set('search_query', query); + } + return [params, mockSetSearchParams] as const; +}; + describe('useSearch', () => { beforeEach(() => { mockFetchData.mockClear(); mockSetSearchParams.mockClear(); - (useSearchParams as jest.Mock).mockReturnValue([ - { get: jest.fn().mockReturnValue(null) }, - mockSetSearchParams, - ]); + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery(null)); }); it('should initialize with empty search state', () => { - const { result } = renderHook(() => useSearch(mockFetchData)); + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, courseData: undefined, isFetching: false, + })); expect(result.current.searchString).toBe(''); - expect(result.current.lastSearchQuery).toBe(''); }); - it('should handle search', () => { - const { result } = renderHook(() => useSearch(mockFetchData)); + it('should handle search without updating URL', () => { + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, courseData: undefined, isFetching: false, + })); + + act(() => { + result.current.handleSearch('javascript'); + }); + + expect(result.current.searchString).toBe('javascript'); + expect(mockSetSearchParams).not.toHaveBeenCalledWith( + expect.objectContaining({ search_query: 'javascript' }), + ); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'javascript', + }); + }); + + it('should remove search_query from URL if it exists when searching', () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('old-query')); + + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, courseData: undefined, isFetching: false, + })); act(() => { result.current.handleSearch('javascript'); }); expect(result.current.searchString).toBe('javascript'); - expect(mockSetSearchParams).toHaveBeenCalledWith({ search_query: 'javascript' }); + expect(mockSetSearchParams).toHaveBeenCalled(); expect(mockFetchData).toHaveBeenCalledWith({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, @@ -46,7 +79,9 @@ describe('useSearch', () => { }); it('should handle clear search', () => { - const { result } = renderHook(() => useSearch(mockFetchData)); + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, courseData: undefined, isFetching: false, + })); act(() => { result.current.handleSearch('javascript'); @@ -57,8 +92,6 @@ describe('useSearch', () => { }); expect(result.current.searchString).toBe(''); - expect(result.current.lastSearchQuery).toBe(''); - expect(mockSetSearchParams).toHaveBeenCalledWith({}); expect(mockFetchData).toHaveBeenCalledWith({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, @@ -66,64 +99,68 @@ describe('useSearch', () => { }); }); - it('should handle no search results', () => { - const { result } = renderHook(() => useSearch(mockFetchData)); + it('should remove search_query from URL when clearing search if it exists', () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('old-query')); + + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, courseData: undefined, isFetching: false, + })); act(() => { - result.current.handleNoSearchResults('javascript'); + result.current.handleClearSearch(); }); - expect(result.current.lastSearchQuery).toBe('javascript'); expect(result.current.searchString).toBe(''); - expect(mockSetSearchParams).toHaveBeenCalledWith({}); - }); - - it('should clear last search query', () => { - const { result } = renderHook(() => useSearch(mockFetchData)); - - act(() => { - result.current.handleNoSearchResults('javascript'); + expect(mockSetSearchParams).toHaveBeenCalled(); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], }); + }); - expect(result.current.lastSearchQuery).toBe('javascript'); - - act(() => { - result.current.clearLastSearchQuery(); + it('initializes search from URL query when data is available', async () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('python')); + + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, + courseData: mockCourseListSearchResponse as unknown as CourseListSearchResponse, + isFetching: false, + })); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'python', + }); }); - expect(result.current.lastSearchQuery).toBe(''); + expect(result.current.searchString).toBe('python'); }); - it('should initialize from URL search query', () => { - const mockGet = jest.fn().mockReturnValue('react'); - (useSearchParams as jest.Mock).mockReturnValue([ - { get: mockGet }, - mockSetSearchParams, - ]); + it('does not initialize search from URL while data is fetching', () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('python')); - renderHook(() => useSearch(mockFetchData)); + renderHook(() => useSearch({ + fetchData: mockFetchData, + courseData: mockCourseListSearchResponse as unknown as CourseListSearchResponse, + isFetching: true, + })); - expect(mockFetchData).toHaveBeenCalledWith({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - searchString: 'react', - }); + expect(mockFetchData).not.toHaveBeenCalled(); }); - it('should fetch default data when no URL search query', () => { - const mockGet = jest.fn().mockReturnValue(null); - (useSearchParams as jest.Mock).mockReturnValue([ - { get: mockGet }, - mockSetSearchParams, - ]); + it('does not initialize search from URL when course data is missing', () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('python')); - renderHook(() => useSearch(mockFetchData)); + renderHook(() => useSearch({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + })); - expect(mockFetchData).toHaveBeenCalledWith({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - }); + expect(mockFetchData).not.toHaveBeenCalled(); }); }); diff --git a/src/catalog/hooks/types.ts b/src/catalog/hooks/types.ts index 9f8243d9..ef6df4d1 100644 --- a/src/catalog/hooks/types.ts +++ b/src/catalog/hooks/types.ts @@ -9,7 +9,10 @@ export interface UseCatalogProps { export interface UseCourseDataProps { courseData: CourseListSearchResponse | undefined; searchString: string; +} + +export interface UseSearchProps { + fetchData: (params: DataTableParams) => void; + courseData: CourseListSearchResponse | undefined; isFetching: boolean; - onNoSearchResults: (searchQuery: string) => void; - onClearLastSearchQuery: () => void; } diff --git a/src/catalog/hooks/useCatalog.ts b/src/catalog/hooks/useCatalog.ts index dd596d53..924aa10b 100644 --- a/src/catalog/hooks/useCatalog.ts +++ b/src/catalog/hooks/useCatalog.ts @@ -15,7 +15,7 @@ import type { UseCatalogProps } from './types'; * catalog management solution with search, filtering, pagination, and data caching. * * Features: - * - Search functionality with URL synchronization + * - Search functionality * - Filter management with intelligent change detection * - Pagination state management * - Course data caching for better UX @@ -28,12 +28,9 @@ export const useCatalog = ({ }: UseCatalogProps) => { const { searchString, - lastSearchQuery, handleSearch, handleClearSearch, - handleNoSearchResults, - clearLastSearchQuery, - } = useSearch(fetchData); + } = useSearch({ fetchData, courseData, isFetching }); const { filterState, resetFilterProgress, handleFilterChange } = useFilter(); @@ -42,9 +39,6 @@ export const useCatalog = ({ const { previousCourseData, savePreviousCourseData } = useCourseData({ courseData, searchString, - isFetching, - onNoSearchResults: handleNoSearchResults, - onClearLastSearchQuery: clearLastSearchQuery, }); const handleFetchData = useCallback((params: DataTableParams) => { @@ -65,14 +59,11 @@ export const useCatalog = ({ pageIndex, filterState, searchString, - lastSearchQuery, previousCourseData, handleSearch, handleClearSearch, handleFetchData, resetFilterProgress, savePreviousCourseData, - handleNoSearchResults, - clearLastSearchQuery, }; }; diff --git a/src/catalog/hooks/useCourseData.ts b/src/catalog/hooks/useCourseData.ts index 6af1f9be..336ca079 100644 --- a/src/catalog/hooks/useCourseData.ts +++ b/src/catalog/hooks/useCourseData.ts @@ -4,20 +4,15 @@ import type { CourseListSearchResponse } from '@src/data/course-list-search/type import type { UseCourseDataProps } from './types'; /** - * Custom hook for managing course data caching and search result handling. + * Custom hook for managing course data caching. * * This hook provides functionality to: * - Cache previous course data when not searching - * - Handle search result states (successful results vs no results) * - Manage data persistence for better UX - * - Coordinate with search state management */ export const useCourseData = ({ courseData, searchString, - isFetching, - onNoSearchResults, - onClearLastSearchQuery, }: UseCourseDataProps) => { const [previousCourseData, setPreviousCourseData] = useState(null); @@ -31,7 +26,7 @@ export const useCourseData = ({ }, [searchString]); /** - * Handles course data state changes and search result processing. + * Handles course data state changes. */ useEffect(() => { if (!courseData) { @@ -40,26 +35,8 @@ export const useCourseData = ({ if (!searchString) { savePreviousCourseData(courseData); - return; } - - const hasResults = (courseData.results?.length ?? 0) > 0; - - if (!isFetching) { - if (hasResults) { - onClearLastSearchQuery(); - } else { - onNoSearchResults(searchString); - } - } - }, [ - courseData, - searchString, - isFetching, - savePreviousCourseData, - onNoSearchResults, - onClearLastSearchQuery, - ]); + }, [courseData, searchString, savePreviousCourseData]); return { previousCourseData, diff --git a/src/catalog/hooks/useSearch.ts b/src/catalog/hooks/useSearch.ts index c9d37760..e926c814 100644 --- a/src/catalog/hooks/useSearch.ts +++ b/src/catalog/hooks/useSearch.ts @@ -2,20 +2,18 @@ import { useState, useCallback, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants'; -import { DataTableParams } from '@src/data/course-list-search/types'; +import type { UseSearchProps } from './types'; /** * Custom hook for managing search functionality in the catalog. * * This hook provides functionality to: - * - Handle search queries and URL synchronization - * - Manage search state and history + * - Handle search queries + * - Manage search state * - Initialize search from URL parameters - * - Handle search result states (no results, clearing search) */ -export const useSearch = (fetchData: (params: DataTableParams) => void) => { +export const useSearch = ({ fetchData, courseData, isFetching }: UseSearchProps) => { const [searchString, setSearchString] = useState(''); - const [lastSearchQuery, setLastSearchQuery] = useState(''); const [searchParams, setSearchParams] = useSearchParams(); const [hasInitialized, setHasInitialized] = useState(false); @@ -26,8 +24,12 @@ export const useSearch = (fetchData: (params: DataTableParams) => void) => { */ const handleSearch = useCallback((query: string) => { setSearchString(query); - setLastSearchQuery(query ? '' : lastSearchQuery); - setSearchParams(query ? { search_query: query } : {}); + + if (urlSearchQuery) { + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete('search_query'); + setSearchParams(newParams); + } fetchData({ pageIndex: DEFAULT_PAGE_INDEX, @@ -35,61 +37,56 @@ export const useSearch = (fetchData: (params: DataTableParams) => void) => { filters: [], searchString: query, }); - }, [fetchData, setSearchParams, lastSearchQuery]); + }, [fetchData, setSearchParams, searchParams, urlSearchQuery]); /** * Clears the current search and resets to the default DataTable view. */ const handleClearSearch = useCallback(() => { setSearchString(''); - setLastSearchQuery(''); - setSearchParams({}); + + if (urlSearchQuery) { + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete('search_query'); + setSearchParams(newParams); + } fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [], }); - }, [fetchData, setSearchParams]); - - /** - * Handles the case when a search returns no results. - * This is typically called when the search API returns empty results. - */ - const handleNoSearchResults = useCallback((searchQuery: string) => { - setLastSearchQuery(searchQuery); - setSearchString(''); - setSearchParams({}); - }, [setSearchParams]); - - /** - * Clears the last search query when no results are found. - */ - const clearLastSearchQuery = useCallback(() => { - setLastSearchQuery(''); - }, []); + }, [fetchData, setSearchParams, searchParams, urlSearchQuery]); /** * Initializes search state from URL parameters on component mount. */ useEffect(() => { - if (hasInitialized) { return; } + if (hasInitialized) { + return; + } + + if (!courseData || isFetching) { + return; + } if (urlSearchQuery && !searchString) { - handleSearch(urlSearchQuery); - } else if (!urlSearchQuery && !searchString) { - fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [] }); + setSearchString(urlSearchQuery); + + fetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: urlSearchQuery, + }); } setHasInitialized(true); - }, [hasInitialized, urlSearchQuery, searchString, handleSearch, fetchData]); + }, [hasInitialized, urlSearchQuery, searchString, fetchData, courseData, isFetching]); return { searchString, - lastSearchQuery, handleSearch, handleClearSearch, - handleNoSearchResults, - clearLastSearchQuery, }; }; diff --git a/src/catalog/types.ts b/src/catalog/types.ts index b5726927..66f4ec85 100644 --- a/src/catalog/types.ts +++ b/src/catalog/types.ts @@ -4,7 +4,6 @@ import { CourseListSearchResponse } from '@src/data/course-list-search/types'; export interface GetPageTitleProps { intl: IntlShape; - lastSearchQuery: string; searchString: string; courseData: CourseListSearchResponse | undefined; } diff --git a/src/catalog/utils.ts b/src/catalog/utils.ts index ab941411..03abbf91 100644 --- a/src/catalog/utils.ts +++ b/src/catalog/utils.ts @@ -89,13 +89,9 @@ export const compareFilters = ( */ export const getPageTitle = ({ intl, - lastSearchQuery, searchString, courseData, }: GetPageTitleProps) => { - if (lastSearchQuery && !searchString) { - return intl.formatMessage(messages.noSearchResults, { query: lastSearchQuery }); - } if (searchString && (courseData?.results?.length ?? 0) === 0) { return intl.formatMessage(messages.noSearchResults, { query: searchString }); } From 3ca723f939c127853a8a70c0d022f9f6a8422754 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Mon, 10 Nov 2025 09:59:13 +0200 Subject: [PATCH 08/10] refactor: removed handleClearSearch --- src/catalog/CatalogPage.test.tsx | 9 +--- src/catalog/CatalogPage.tsx | 5 +-- .../hooks/__tests__/useCatalog.test.tsx | 13 ++++-- src/catalog/hooks/__tests__/useSearch.test.ts | 41 ------------------- src/catalog/hooks/useCatalog.ts | 2 - src/catalog/hooks/useCourseData.ts | 2 +- src/catalog/hooks/useSearch.ts | 20 --------- 7 files changed, 15 insertions(+), 77 deletions(-) diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index a876dedb..6748beb7 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -84,6 +84,7 @@ describe('CatalogPage', () => { data: { ...mockCourseListSearchResponse, results: [], + total: 0, }, fetchData: jest.fn(), isFetching: false, @@ -895,13 +896,7 @@ describe('CatalogPage', () => { rerender(); await waitFor(() => { - const alert = screen.getByRole('alert'); - expect(within(alert).getByText( - messages.noCoursesAvailable.defaultMessage, - )).toBeInTheDocument(); - expect(within(alert).getByText( - messages.noCoursesAvailableMessage.defaultMessage, - )).toBeInTheDocument(); + expect(screen.getByText(messages.noResultsFound.defaultMessage)).toBeInTheDocument(); }); }); diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index 4c043120..13383163 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -34,7 +34,6 @@ const CatalogPage = () => { searchString, previousCourseData, handleSearch, - handleClearSearch, handleFetchData, resetFilterProgress, } = useCatalog({ fetchData, courseData, isFetching }); @@ -87,6 +86,7 @@ const CatalogPage = () => { const totalCourses = displayData?.results?.length ?? 0; const pageCount = Math.ceil((displayData?.total || totalCourses) / DEFAULT_PAGE_SIZE); + const hasCourses = totalCourses > 0 || (previousCourseData?.total ?? 0) > 0; return ( @@ -98,7 +98,7 @@ const CatalogPage = () => { })} className={classNames({ 'mx-2.5': isMedium })} /> - {totalCourses > 0 ? ( + {hasCourses ? ( <> {getConfig().ENABLE_COURSE_DISCOVERY && ( { placeholder={intl.formatMessage(messages.searchPlaceholder)} value={searchString} onSubmit={handleSearch} - onClear={handleClearSearch} submitButtonLocation="external" /> )} diff --git a/src/catalog/hooks/__tests__/useCatalog.test.tsx b/src/catalog/hooks/__tests__/useCatalog.test.tsx index 35891541..9e45bab4 100644 --- a/src/catalog/hooks/__tests__/useCatalog.test.tsx +++ b/src/catalog/hooks/__tests__/useCatalog.test.tsx @@ -68,7 +68,7 @@ describe('useCatalog', () => { }); }); - it('should handle clear search', () => { + it('should clear search when submitting empty value', () => { const { result } = renderHook(() => useCatalog({ fetchData: mockFetchData, courseData: undefined, @@ -78,14 +78,21 @@ describe('useCatalog', () => { }); act(() => { - result.current.handleClearSearch(); + result.current.handleSearch('javascript'); + }); + + expect(result.current.searchString).toBe('javascript'); + + act(() => { + result.current.handleSearch(''); }); expect(result.current.searchString).toBe(''); - expect(mockFetchData).toHaveBeenCalledWith({ + expect(mockFetchData).toHaveBeenNthCalledWith(2, { pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE, filters: [], + searchString: '', }); }); diff --git a/src/catalog/hooks/__tests__/useSearch.test.ts b/src/catalog/hooks/__tests__/useSearch.test.ts index 6fccfc1d..2f1f0203 100644 --- a/src/catalog/hooks/__tests__/useSearch.test.ts +++ b/src/catalog/hooks/__tests__/useSearch.test.ts @@ -78,47 +78,6 @@ describe('useSearch', () => { }); }); - it('should handle clear search', () => { - const { result } = renderHook(() => useSearch({ - fetchData: mockFetchData, courseData: undefined, isFetching: false, - })); - - act(() => { - result.current.handleSearch('javascript'); - }); - - act(() => { - result.current.handleClearSearch(); - }); - - expect(result.current.searchString).toBe(''); - expect(mockFetchData).toHaveBeenCalledWith({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - }); - }); - - it('should remove search_query from URL when clearing search if it exists', () => { - (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('old-query')); - - const { result } = renderHook(() => useSearch({ - fetchData: mockFetchData, courseData: undefined, isFetching: false, - })); - - act(() => { - result.current.handleClearSearch(); - }); - - expect(result.current.searchString).toBe(''); - expect(mockSetSearchParams).toHaveBeenCalled(); - expect(mockFetchData).toHaveBeenCalledWith({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - }); - }); - it('initializes search from URL query when data is available', async () => { (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('python')); diff --git a/src/catalog/hooks/useCatalog.ts b/src/catalog/hooks/useCatalog.ts index 924aa10b..3d7e5823 100644 --- a/src/catalog/hooks/useCatalog.ts +++ b/src/catalog/hooks/useCatalog.ts @@ -29,7 +29,6 @@ export const useCatalog = ({ const { searchString, handleSearch, - handleClearSearch, } = useSearch({ fetchData, courseData, isFetching }); const { filterState, resetFilterProgress, handleFilterChange } = useFilter(); @@ -61,7 +60,6 @@ export const useCatalog = ({ searchString, previousCourseData, handleSearch, - handleClearSearch, handleFetchData, resetFilterProgress, savePreviousCourseData, diff --git a/src/catalog/hooks/useCourseData.ts b/src/catalog/hooks/useCourseData.ts index 336ca079..7b760c87 100644 --- a/src/catalog/hooks/useCourseData.ts +++ b/src/catalog/hooks/useCourseData.ts @@ -20,7 +20,7 @@ export const useCourseData = ({ * Saves course data to cache when not actively searching. */ const savePreviousCourseData = useCallback((data: CourseListSearchResponse) => { - if (data && !searchString) { + if (data && !searchString && data.total > 0) { setPreviousCourseData(data); } }, [searchString]); diff --git a/src/catalog/hooks/useSearch.ts b/src/catalog/hooks/useSearch.ts index e926c814..c292f431 100644 --- a/src/catalog/hooks/useSearch.ts +++ b/src/catalog/hooks/useSearch.ts @@ -39,25 +39,6 @@ export const useSearch = ({ fetchData, courseData, isFetching }: UseSearchProps) }); }, [fetchData, setSearchParams, searchParams, urlSearchQuery]); - /** - * Clears the current search and resets to the default DataTable view. - */ - const handleClearSearch = useCallback(() => { - setSearchString(''); - - if (urlSearchQuery) { - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete('search_query'); - setSearchParams(newParams); - } - - fetchData({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - filters: [], - }); - }, [fetchData, setSearchParams, searchParams, urlSearchQuery]); - /** * Initializes search state from URL parameters on component mount. */ @@ -87,6 +68,5 @@ export const useSearch = ({ fetchData, courseData, isFetching }: UseSearchProps) return { searchString, handleSearch, - handleClearSearch, }; }; From 4d25f175704ac6cc89c01d4cfe5c7f35626255be Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Mon, 17 Nov 2025 10:57:33 +0200 Subject: [PATCH 09/10] refactor: removed manual state update and added search on type --- package-lock.json | 2 +- package.json | 1 + src/catalog/CatalogPage.test.tsx | 182 +++++++++++++++++- src/catalog/CatalogPage.tsx | 22 ++- .../hooks/__tests__/useCatalog.test.tsx | 53 +++-- .../hooks/__tests__/useCourseData.test.ts | 99 +++++----- .../__tests__/useDebouncedSearchInput.test.ts | 63 ++++++ src/catalog/hooks/types.ts | 6 + src/catalog/hooks/useCatalog.ts | 3 +- src/catalog/hooks/useCourseData.ts | 26 +-- src/catalog/hooks/useDebouncedSearchInput.ts | 43 +++++ src/catalog/utils.ts | 12 +- 12 files changed, 396 insertions(+), 116 deletions(-) create mode 100644 src/catalog/hooks/__tests__/useDebouncedSearchInput.test.ts create mode 100644 src/catalog/hooks/useDebouncedSearchInput.ts diff --git a/package-lock.json b/package-lock.json index 0b35dd8b..12549049 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "classnames": "^2.5.1", "core-js": "3.41.0", "lodash.capitalize": "^4.2.1", + "lodash.debounce": "^4.0.8", "prop-types": "15.8.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -21714,7 +21715,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "devOptional": true, "license": "MIT" }, "node_modules/lodash.memoize": { diff --git a/package.json b/package.json index f5f67390..42046298 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "classnames": "^2.5.1", "core-js": "3.41.0", "lodash.capitalize": "^4.2.1", + "lodash.debounce": "^4.0.8", "prop-types": "15.8.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx index 6748beb7..4574d86c 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -2,7 +2,7 @@ import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { - render, within, screen, waitFor, userEvent, + render, within, screen, waitFor, userEvent, act, } from '../setupTest'; import { useCourseListSearch } from '../data/course-list-search/hooks'; import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '../data/course-list-search/constants'; @@ -1525,3 +1525,183 @@ describe('CatalogPage search integration', () => { expect(searchCall).toBeDefined(); }); }); + +describe('Debounced search', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockGetConfig.mockReturnValue({ + INFO_EMAIL: process.env.INFO_EMAIL, + ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY, + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('should debounce search calls when typing in search field', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalled(); + }); + + mockFetchData.mockClear(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + // Use real timers for userEvent, then switch back to fake timers + jest.useRealTimers(); + await userEvent.type(searchField, 'python'); + jest.useFakeTimers(); + + // Should not be called immediately after typing (before debounce) + expect(mockFetchData).not.toHaveBeenCalled(); + + // Advance timers to trigger debounce + act(() => { + jest.advanceTimersByTime(300); + }); + + jest.useRealTimers(); + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'python', + }), + ); + }); + jest.useFakeTimers(); + }); + + it('should only call fetchData once with final value when typing rapidly', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalled(); + }); + + mockFetchData.mockClear(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + jest.useRealTimers(); + await userEvent.type(searchField, 'react', { delay: 0 }); + jest.useFakeTimers(); + + act(() => { + jest.advanceTimersByTime(100); + }); + // Should not be called yet (before debounce completes) + expect(mockFetchData).not.toHaveBeenCalled(); + + // Advance timers to trigger debounce + act(() => { + jest.advanceTimersByTime(300); + }); + + jest.useRealTimers(); + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: 'react', + }), + ); + }); + jest.useFakeTimers(); + }); + + it('should call fetchData immediately on submit without waiting for debounce', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + jest.useRealTimers(); + await userEvent.type(searchField, 'javascript'); + await userEvent.keyboard('{Enter}'); + + // Should be called immediately on submit, not waiting for debounce + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: 'javascript', + }), + ); + }); + + // Switch to fake timers to verify debounce doesn't cause duplicate calls + jest.useFakeTimers(); + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockFetchData).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('should sync search input with external searchString changes', async () => { + const mockFetchData = jest.fn(); + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + expect(searchField).toHaveValue(''); + + jest.useRealTimers(); + await userEvent.type(searchField, 'python'); + expect(searchField).toHaveValue('python'); + + await userEvent.clear(searchField); + expect(searchField).toHaveValue(''); + + jest.useFakeTimers(); + act(() => { + jest.advanceTimersByTime(300); + }); + + jest.useRealTimers(); + await waitFor(() => { + expect(mockFetchData).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index 13383163..1cd03993 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -10,6 +10,7 @@ import classNames from 'classnames'; import { DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; import { useCourseListSearch } from '@src/data/course-list-search/hooks'; +import { useDebouncedSearchInput } from './hooks/useDebouncedSearchInput'; import { AlertNotification, CourseCard, Loading, SubHeader, } from '../generic'; @@ -38,6 +39,11 @@ const CatalogPage = () => { resetFilterProgress, } = useCatalog({ fetchData, courseData, isFetching }); + const { setSearchInput } = useDebouncedSearchInput({ + searchString, + handleSearch, + }); + /** * Determines which data to display in the catalog based on search state and results. * Shows previous course data when: @@ -108,14 +114,19 @@ const CatalogPage = () => { 'mb-4 w-25': !isMedium, })} placeholder={intl.formatMessage(messages.searchPlaceholder)} - value={searchString} - onSubmit={handleSearch} + onChange={(value: string) => { + setSearchInput(value); + }} + onSubmit={(value: string) => { + setSearchInput(value); + handleSearch(value); + }} submitButtonLocation="external" /> )} { fetchData={handleFetchData} > - + diff --git a/src/catalog/hooks/__tests__/useCatalog.test.tsx b/src/catalog/hooks/__tests__/useCatalog.test.tsx index 9e45bab4..381090ac 100644 --- a/src/catalog/hooks/__tests__/useCatalog.test.tsx +++ b/src/catalog/hooks/__tests__/useCatalog.test.tsx @@ -211,30 +211,39 @@ describe('useCatalog', () => { }); it('should keep cached data unchanged while a search is active', () => { - const { result } = renderHook(() => useCatalog({ - fetchData: mockFetchData, - courseData: undefined, - isFetching: false, - }), { - wrapper: createWrapper(), - }); - const initialData = { ...mockCourseData }; - act(() => { - result.current.savePreviousCourseData(initialData); - }); + const { result, rerender } = renderHook( + ({ courseData, isFetching }: { + courseData: typeof mockCourseData | undefined; + isFetching: boolean, + }) => useCatalog({ + fetchData: mockFetchData, + courseData, + isFetching, + }), + { + wrapper: createWrapper(), + initialProps: { + courseData: initialData, + isFetching: false, + }, + }, + ); expect(result.current.previousCourseData).toEqual(initialData); + expect(result.current.searchString).toBe(''); act(() => { result.current.handleSearch('python'); }); - const newCourseData = { ...mockCourseData, total: 99 }; + expect(result.current.searchString).toBe('python'); - act(() => { - result.current.savePreviousCourseData(newCourseData); + const newCourseData = { ...mockCourseData, total: 99 }; + rerender({ + courseData: newCourseData, + isFetching: false, }); expect(result.current.previousCourseData).toEqual(initialData); @@ -266,22 +275,6 @@ describe('useCatalog', () => { expect(result.current.filterState.isFilterChangeInProgress).toBe(false); }); - it('should save previous course data', () => { - const { result } = renderHook(() => useCatalog({ - fetchData: mockFetchData, - courseData: undefined, - isFetching: false, - }), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.savePreviousCourseData(mockCourseData); - }); - - expect(result.current.previousCourseData).toEqual(mockCourseData); - }); - it('should initialize with course data when provided', () => { const { result } = renderHook(() => useCatalog({ fetchData: mockFetchData, diff --git a/src/catalog/hooks/__tests__/useCourseData.test.ts b/src/catalog/hooks/__tests__/useCourseData.test.ts index 1a2dcf46..9c7284bf 100644 --- a/src/catalog/hooks/__tests__/useCourseData.test.ts +++ b/src/catalog/hooks/__tests__/useCourseData.test.ts @@ -1,4 +1,4 @@ -import { renderHook, act } from '@src/setupTest'; +import { renderHook } from '@src/setupTest'; import { mockCourseListSearchResponse } from '@src/__mocks__'; import { useCourseData } from '../useCourseData'; @@ -39,27 +39,30 @@ describe('useCourseData', () => { }); it('should keep cached data unchanged while search is active', () => { - const { result } = renderHook(() => useCourseData({ - courseData: mockCourseData, - searchString: '', - })); + const { result, rerender } = renderHook( + ({ courseData, searchString }: { + courseData: typeof mockCourseData | undefined; searchString: string, + }) => useCourseData({ courseData, searchString }), + { + initialProps: { + courseData: mockCourseData, + searchString: '', + }, + }, + ); expect(result.current.previousCourseData).toEqual(mockCourseData); - act(() => { - result.current.savePreviousCourseData({ - ...mockCourseData, - total: 999, - }); + rerender({ + courseData: { ...mockCourseData, total: 999 }, + searchString: 'python', }); - expect(result.current.previousCourseData).toEqual({ - ...mockCourseData, - total: 999, - }); + expect(result.current.previousCourseData).toEqual(mockCourseData); - act(() => { - result.current.savePreviousCourseData(mockCourseData); + rerender({ + courseData: { ...mockCourseData, total: 888 }, + searchString: 'python', }); expect(result.current.previousCourseData).toEqual(mockCourseData); @@ -96,54 +99,46 @@ describe('useCourseData', () => { }); it('should ignore undefined course data', () => { - const { result } = renderHook(() => useCourseData({ - courseData: undefined, - searchString: '', - })); + const { result, rerender } = renderHook( + ({ courseData, searchString }: { + courseData: typeof mockCourseData | undefined; searchString: string, + }) => useCourseData({ courseData, searchString }), + { + initialProps: { + courseData: undefined, + searchString: '', + }, + }, + ); expect(result.current.previousCourseData).toBeNull(); - act(() => { - result.current.savePreviousCourseData(mockCourseData); + rerender({ + courseData: mockCourseData, + searchString: '', }); expect(result.current.previousCourseData).toEqual(mockCourseData); }); - it('should prevent manual save while search is active', () => { - const { result } = renderHook(() => useCourseData({ - courseData: undefined, - searchString: 'javascript', - })); - - act(() => { - result.current.savePreviousCourseData(mockCourseData); - }); + it('should not save course data during search to keep previous data for empty results fallback', () => { + const { result, rerender } = renderHook( + ({ courseData, searchString }: { + courseData: typeof mockCourseData | undefined; searchString: string, + }) => useCourseData({ courseData, searchString }), + { + initialProps: { + courseData: undefined, + searchString: 'javascript', + }, + }, + ); expect(result.current.previousCourseData).toBeNull(); - }); - it('should allow manual saving of course data', () => { - const { result } = renderHook(() => useCourseData({ - courseData: undefined, - searchString: '', - })); - - act(() => { - result.current.savePreviousCourseData(mockCourseData); - }); - - expect(result.current.previousCourseData).toEqual(mockCourseData); - }); - - it('should not save course data when searching', () => { - const { result } = renderHook(() => useCourseData({ - courseData: undefined, + rerender({ + courseData: mockCourseData, searchString: 'javascript', - })); - - act(() => { - result.current.savePreviousCourseData(mockCourseData); }); expect(result.current.previousCourseData).toBeNull(); diff --git a/src/catalog/hooks/__tests__/useDebouncedSearchInput.test.ts b/src/catalog/hooks/__tests__/useDebouncedSearchInput.test.ts new file mode 100644 index 00000000..7d7fa623 --- /dev/null +++ b/src/catalog/hooks/__tests__/useDebouncedSearchInput.test.ts @@ -0,0 +1,63 @@ +import { renderHook, act } from '@src/setupTest'; +import { useDebouncedSearchInput } from '../useDebouncedSearchInput'; + +jest.useFakeTimers(); + +describe('useDebouncedSearchInput', () => { + const mockHandleSearch = jest.fn(); + + it('should initialize with searchString value', () => { + const { result } = renderHook(() => useDebouncedSearchInput({ + searchString: 'initial query', + handleSearch: mockHandleSearch, + })); + + expect(result.current.setSearchInput).toBeDefined(); + }); + + it('should debounce search calls', () => { + const { result } = renderHook(() => useDebouncedSearchInput({ + searchString: '', + handleSearch: mockHandleSearch, + debounceDelay: 300, + })); + + act(() => { + result.current.setSearchInput('a'); + }); + + act(() => { + result.current.setSearchInput('ab'); + }); + + act(() => { + result.current.setSearchInput('abc'); + }); + + expect(mockHandleSearch).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockHandleSearch).toHaveBeenCalledTimes(1); + expect(mockHandleSearch).toHaveBeenCalledWith('abc'); + }); + + it('should handle null searchString', () => { + const { result } = renderHook(() => useDebouncedSearchInput({ + searchString: null, + handleSearch: mockHandleSearch, + })); + + act(() => { + result.current.setSearchInput('test'); + }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockHandleSearch).toHaveBeenCalledWith('test'); + }); +}); diff --git a/src/catalog/hooks/types.ts b/src/catalog/hooks/types.ts index ef6df4d1..6d5cb267 100644 --- a/src/catalog/hooks/types.ts +++ b/src/catalog/hooks/types.ts @@ -16,3 +16,9 @@ export interface UseSearchProps { courseData: CourseListSearchResponse | undefined; isFetching: boolean; } + +export interface UseDebouncedSearchInputProps { + searchString: string | null | undefined; + handleSearch: (value: string) => void; + debounceDelay?: number; +} diff --git a/src/catalog/hooks/useCatalog.ts b/src/catalog/hooks/useCatalog.ts index 3d7e5823..e1c4ef89 100644 --- a/src/catalog/hooks/useCatalog.ts +++ b/src/catalog/hooks/useCatalog.ts @@ -35,7 +35,7 @@ export const useCatalog = ({ const { pageIndex, handlePageChange, resetPagination } = usePagination(); - const { previousCourseData, savePreviousCourseData } = useCourseData({ + const { previousCourseData } = useCourseData({ courseData, searchString, }); @@ -62,6 +62,5 @@ export const useCatalog = ({ handleSearch, handleFetchData, resetFilterProgress, - savePreviousCourseData, }; }; diff --git a/src/catalog/hooks/useCourseData.ts b/src/catalog/hooks/useCourseData.ts index 7b760c87..7a4647fb 100644 --- a/src/catalog/hooks/useCourseData.ts +++ b/src/catalog/hooks/useCourseData.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import type { CourseListSearchResponse } from '@src/data/course-list-search/types'; import type { UseCourseDataProps } from './types'; @@ -16,30 +16,14 @@ export const useCourseData = ({ }: UseCourseDataProps) => { const [previousCourseData, setPreviousCourseData] = useState(null); - /** - * Saves course data to cache when not actively searching. - */ - const savePreviousCourseData = useCallback((data: CourseListSearchResponse) => { - if (data && !searchString && data.total > 0) { - setPreviousCourseData(data); - } - }, [searchString]); - /** * Handles course data state changes. */ useEffect(() => { - if (!courseData) { - return; - } - - if (!searchString) { - savePreviousCourseData(courseData); + if (courseData && !searchString && courseData.total > 0) { + setPreviousCourseData(courseData); } - }, [courseData, searchString, savePreviousCourseData]); + }, [courseData, searchString]); - return { - previousCourseData, - savePreviousCourseData, - }; + return { previousCourseData }; }; diff --git a/src/catalog/hooks/useDebouncedSearchInput.ts b/src/catalog/hooks/useDebouncedSearchInput.ts new file mode 100644 index 00000000..0f544368 --- /dev/null +++ b/src/catalog/hooks/useDebouncedSearchInput.ts @@ -0,0 +1,43 @@ +import { + useEffect, useMemo, useState, useDeferredValue, useRef, +} from 'react'; +import debounce from 'lodash.debounce'; + +import type { UseDebouncedSearchInputProps } from './types'; + +/** + * Custom hook for managing debounced search input with deferred value optimization. + */ +export const useDebouncedSearchInput = ({ + searchString, + handleSearch, + debounceDelay = 300, +}: UseDebouncedSearchInputProps) => { + const [searchInput, setSearchInput] = useState(searchString ?? ''); + const deferredSearchInput = useDeferredValue(searchInput); + const lastQueryRef = useRef(''); + + useEffect(() => { + setSearchInput(searchString ?? ''); + }, [searchString]); + + const debouncedHandleSearch = useMemo( + () => debounce((value: string) => { + handleSearch(value); + }, debounceDelay), + [handleSearch, debounceDelay], + ); + + useEffect(() => () => debouncedHandleSearch.cancel(), [debouncedHandleSearch]); + + useEffect(() => { + if (deferredSearchInput === lastQueryRef.current) { + return; + } + + lastQueryRef.current = deferredSearchInput; + debouncedHandleSearch(deferredSearchInput); + }, [deferredSearchInput, debouncedHandleSearch]); + + return { setSearchInput }; +}; diff --git a/src/catalog/utils.ts b/src/catalog/utils.ts index 03abbf91..0a814068 100644 --- a/src/catalog/utils.ts +++ b/src/catalog/utils.ts @@ -92,11 +92,13 @@ export const getPageTitle = ({ searchString, courseData, }: GetPageTitleProps) => { - if (searchString && (courseData?.results?.length ?? 0) === 0) { - return intl.formatMessage(messages.noSearchResults, { query: searchString }); + if (!searchString) { + return intl.formatMessage(messages.exploreCourses); } - if (searchString) { - return intl.formatMessage(messages.searchResults, { query: searchString }); + + if ((courseData?.results?.length ?? 0) === 0) { + return intl.formatMessage(messages.noSearchResults, { query: searchString }); } - return intl.formatMessage(messages.exploreCourses); + + return intl.formatMessage(messages.searchResults, { query: searchString }); }; From b714e696d3e30ef20621388f42f9b7cef9b16d58 Mon Sep 17 00:00:00 2001 From: PKulkoRaccoonGang Date: Wed, 19 Nov 2025 21:23:29 +0200 Subject: [PATCH 10/10] feat: added stable row IDs to DataTable using course ID --- src/catalog/CatalogPage.tsx | 4 ++++ src/generic/course-card/index.tsx | 1 + 2 files changed, 5 insertions(+) diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx index 1cd03993..ac1d4d72 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -140,6 +140,10 @@ const CatalogPage = () => { data={displayData?.results} columns={tableColumns} fetchData={handleFetchData} + // Using course ID as a unique identifier for DataTable rows. + // This ensures stable keys for React reconciliation, preventing cards from being + // repopulated with different data when filtering, sorting, or paginating. + initialTableOptions={{ getRowId: (row) => row.id }} >