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 64e559ff..4574d86c 100644 --- a/src/catalog/CatalogPage.test.tsx +++ b/src/catalog/CatalogPage.test.tsx @@ -1,10 +1,11 @@ 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_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'; @@ -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(); @@ -75,6 +84,7 @@ describe('CatalogPage', () => { data: { ...mockCourseListSearchResponse, results: [], + total: 0, }, fetchData: jest.fn(), isFetching: false, @@ -123,7 +133,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,16 +152,402 @@ 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.queryByPlaceholderText(messages.searchPlaceholder.defaultMessage); + expect(searchField).not.toBeInTheDocument(); + }); + + it('should handle search field interactions and input 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(''); 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 handle search field interactions', () => { + 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({ + 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: jest.fn(), + fetchData: mockFetchData, isFetching: false, }); @@ -159,8 +555,32 @@ describe('CatalogPage', () => { const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); - expect(searchField).toHaveValue(''); - expect(searchField).toBeInTheDocument(); + 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 () => { @@ -476,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(); }); }); @@ -792,4 +1206,502 @@ describe('CatalogPage', () => { expect(screen.getByText(messages.noCoursesAvailable.defaultMessage)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /of/i })).not.toBeInTheDocument(); }); + + describe('SubHeader title', () => { + 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 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, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + const { rerender } = render(); + + const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + + await userEvent.type(searchField, query); + await userEvent.keyboard('{Enter}'); + + mockUseCourseListSearch.mockReturnValue({ + isLoading: false, + isError: false, + data: emptySearchResults, + fetchData: mockFetchData, + isFetching: false, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText( + 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, + data: mockCourseListSearchResponse, + fetchData: mockFetchData, + isFetching: false, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText( + messages.searchResults.defaultMessage.replace('{query}', query), + )).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(); + }); + }); +}); + +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(); + }); +}); + +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 ce1c0872..ac1d4d72 100644 --- a/src/catalog/CatalogPage.tsx +++ b/src/catalog/CatalogPage.tsx @@ -8,14 +8,15 @@ 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 { useDebouncedSearchInput } from './hooks/useDebouncedSearchInput'; import { AlertNotification, CourseCard, Loading, SubHeader, } from '../generic'; -import { useFilterState } from './hooks/useFilterState'; +import { useCatalog } from './hooks/useCatalog'; import messages from './messages'; -import { transformAggregationsToFilterChoices } from './utils'; +import { transformAggregationsToFilterChoices, getPageTitle } from './utils'; const CatalogPage = () => { const intl = useIntl(); @@ -31,13 +32,32 @@ const CatalogPage = () => { const { pageIndex, filterState, + searchString, + previousCourseData, + handleSearch, handleFetchData, resetFilterProgress, - } = useFilterState(fetchData); + } = useCatalog({ fetchData, courseData, isFetching }); - useEffect(() => { - fetchData({ pageIndex: DEFAULT_PAGE_INDEX, pageSize: DEFAULT_PAGE_SIZE }); - }, [fetchData]); + const { setSearchInput } = useDebouncedSearchInput({ + searchString, + handleSearch, + }); + + /** + * 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 + * 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 shouldShowPreviousData = hasActiveSearch && !hasSearchResults && previousCourseData; + + return shouldShowPreviousData ? previousCourseData : courseData; + }, [courseData, searchString, previousCourseData]); useEffect(() => { if (!isFetching && filterState.isFilterChangeInProgress) { @@ -46,8 +66,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,44 +90,66 @@ 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); + const hasCourses = totalCourses > 0 || (previousCourseData?.total ?? 0) > 0; return ( - {totalCourses > 0 ? ( + {hasCourses ? ( <> - + {getConfig().ENABLE_COURSE_DISCOVERY && ( + { + setSearchInput(value); + }} + onSubmit={(value: string) => { + setSearchInput(value); + handleSearch(value); + }} + submitButtonLocation="external" + /> + )} row.id }} > - + diff --git a/src/catalog/hooks/__tests__/useCatalog.test.tsx b/src/catalog/hooks/__tests__/useCatalog.test.tsx new file mode 100644 index 00000000..381090ac --- /dev/null +++ b/src/catalog/hooks/__tests__/useCatalog.test.tsx @@ -0,0 +1,295 @@ +import { MemoryRouter } from 'react-router-dom'; + +import { renderHook, act } from '@src/setupTest'; +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 = { + ...mockCourseListSearchResponse, + results: mockCourseListSearchResponse.results.map(result => ({ + ...result, + title: result.data.content.displayName, + })), +}; + +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({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { + wrapper: createWrapper(), + }); + + expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); + expect(result.current.searchString).toBe(''); + expect(result.current.previousCourseData).toBeNull(); + expect(result.current.filterState).toEqual({ + previousFilters: null, + isFilterChangeInProgress: false, + }); + }); + + it('should handle search', () => { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: 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 clear search when submitting empty value', () => { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleSearch('javascript'); + }); + + expect(result.current.searchString).toBe('javascript'); + + act(() => { + result.current.handleSearch(''); + }); + + expect(result.current.searchString).toBe(''); + expect(mockFetchData).toHaveBeenNthCalledWith(2, { + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: '', + }); + }); + + it('should handle filter changes', () => { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: 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({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: 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({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleFetchData({ + pageIndex: 2, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + }); + }); + + expect(result.current.pageIndex).toBe(2); + + 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 include current search string when fetching data', () => { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleSearch('javascript'); + }); + + 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 keep cached data unchanged while a search is active', () => { + const initialData = { ...mockCourseData }; + + 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'); + }); + + expect(result.current.searchString).toBe('python'); + + const newCourseData = { ...mockCourseData, total: 99 }; + rerender({ + courseData: newCourseData, + isFetching: false, + }); + + expect(result.current.previousCourseData).toEqual(initialData); + }); + + it('should reset filter progress', () => { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handleFetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [{ id: 'subject', value: 'math' }], + }); + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(true); + + act(() => { + result.current.resetFilterProgress(); + }); + + expect(result.current.filterState.isFilterChangeInProgress).toBe(false); + }); + + it('should initialize with course data when provided', () => { + const { result } = renderHook(() => useCatalog({ + fetchData: mockFetchData, + courseData: mockCourseData, + isFetching: false, + }), { + wrapper: createWrapper(), + }); + + expect(result.current.pageIndex).toBe(DEFAULT_PAGE_INDEX); + expect(result.current.searchString).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..9c7284bf --- /dev/null +++ b/src/catalog/hooks/__tests__/useCourseData.test.ts @@ -0,0 +1,146 @@ +import { renderHook } from '@src/setupTest'; +import { mockCourseListSearchResponse } from '@src/__mocks__'; +import { useCourseData } from '../useCourseData'; + +const mockCourseData = { + ...mockCourseListSearchResponse, + results: mockCourseListSearchResponse.results.map(result => ({ + ...result, + title: result.data.content.displayName, + })), +}; + +describe('useCourseData', () => { + it('should initialize with null previous course data', () => { + const { result } = renderHook(() => useCourseData({ + courseData: undefined, + searchString: '', + })); + + expect(result.current.previousCourseData).toBeNull(); + }); + + it('should save course data when not searching', () => { + const { result } = renderHook(() => useCourseData({ + courseData: mockCourseData, + searchString: '', + })); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + }); + + it('should not save course data when searching', () => { + const { result } = renderHook(() => useCourseData({ + courseData: mockCourseData, + searchString: 'javascript', + })); + + expect(result.current.previousCourseData).toBeNull(); + }); + + it('should keep cached data unchanged while search is active', () => { + 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); + + rerender({ + courseData: { ...mockCourseData, total: 999 }, + searchString: 'python', + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + + rerender({ + courseData: { ...mockCourseData, total: 888 }, + searchString: 'python', + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + }); + + 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 }), + { + initialProps: { + courseData: mockCourseData, + searchString: '', + }, + }, + ); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + + rerender({ + courseData: { ...mockCourseData, total: 10 }, + searchString: 'python', + }); + + expect(result.current.previousCourseData).toEqual(mockCourseData); + + rerender({ + courseData: { ...mockCourseData, total: 20 }, + searchString: '', + }); + + expect(result.current.previousCourseData).toEqual({ ...mockCourseData, total: 20 }); + }); + + it('should ignore undefined course data', () => { + const { result, rerender } = renderHook( + ({ courseData, searchString }: { + courseData: typeof mockCourseData | undefined; searchString: string, + }) => useCourseData({ courseData, searchString }), + { + initialProps: { + courseData: undefined, + searchString: '', + }, + }, + ); + + expect(result.current.previousCourseData).toBeNull(); + + rerender({ + courseData: mockCourseData, + searchString: '', + }); + + expect(result.current.previousCourseData).toEqual(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(); + + rerender({ + courseData: mockCourseData, + searchString: 'javascript', + }); + + 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/__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..63cdf5f7 --- /dev/null +++ b/src/catalog/hooks/__tests__/usePagination.test.ts @@ -0,0 +1,51 @@ +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()); + + act(() => { + result.current.handlePageChange(3); + }); + + expect(result.current.pageIndex).toBe(3); + + 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..2f1f0203 --- /dev/null +++ b/src/catalog/hooks/__tests__/useSearch.test.ts @@ -0,0 +1,125 @@ +import { useSearchParams } from 'react-router-dom'; + +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from '@src/data/course-list-search/constants'; +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', () => ({ + useSearchParams: jest.fn(), +})); + +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(withSearchQuery(null)); + }); + + it('should initialize with empty search state', () => { + const { result } = renderHook(() => useSearch({ + fetchData: mockFetchData, courseData: undefined, isFetching: false, + })); + + expect(result.current.searchString).toBe(''); + }); + + 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).toHaveBeenCalled(); + expect(mockFetchData).toHaveBeenCalledWith({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: 'javascript', + }); + }); + + 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.searchString).toBe('python'); + }); + + it('does not initialize search from URL while data is fetching', () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('python')); + + renderHook(() => useSearch({ + fetchData: mockFetchData, + courseData: mockCourseListSearchResponse as unknown as CourseListSearchResponse, + isFetching: true, + })); + + expect(mockFetchData).not.toHaveBeenCalled(); + }); + + it('does not initialize search from URL when course data is missing', () => { + (useSearchParams as jest.Mock).mockReturnValue(withSearchQuery('python')); + + renderHook(() => useSearch({ + fetchData: mockFetchData, + courseData: undefined, + isFetching: false, + })); + + expect(mockFetchData).not.toHaveBeenCalled(); + }); +}); diff --git a/src/catalog/hooks/types.ts b/src/catalog/hooks/types.ts new file mode 100644 index 00000000..6d5cb267 --- /dev/null +++ b/src/catalog/hooks/types.ts @@ -0,0 +1,24 @@ +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; +} + +export interface UseSearchProps { + fetchData: (params: DataTableParams) => void; + 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 new file mode 100644 index 00000000..e1c4ef89 --- /dev/null +++ b/src/catalog/hooks/useCatalog.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; + +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. + * + * This hook combines multiple specialized hooks to provide a complete + * catalog management solution with search, filtering, pagination, and data caching. + * + * Features: + * - Search functionality + * - Filter management with intelligent change detection + * - Pagination state management + * - Course data caching for better UX + * - Coordinated data fetching with proper state management + */ +export const useCatalog = ({ + fetchData, + courseData, + isFetching, +}: UseCatalogProps) => { + const { + searchString, + handleSearch, + } = useSearch({ fetchData, courseData, isFetching }); + + const { filterState, resetFilterProgress, handleFilterChange } = useFilter(); + + const { pageIndex, handlePageChange, resetPagination } = usePagination(); + + const { previousCourseData } = useCourseData({ + courseData, + searchString, + }); + + 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, + previousCourseData, + handleSearch, + handleFetchData, + resetFilterProgress, + }; +}; diff --git a/src/catalog/hooks/useCourseData.ts b/src/catalog/hooks/useCourseData.ts new file mode 100644 index 00000000..7a4647fb --- /dev/null +++ b/src/catalog/hooks/useCourseData.ts @@ -0,0 +1,29 @@ +import { useState, 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. + * + * This hook provides functionality to: + * - Cache previous course data when not searching + * - Manage data persistence for better UX + */ +export const useCourseData = ({ + courseData, + searchString, +}: UseCourseDataProps) => { + const [previousCourseData, setPreviousCourseData] = useState(null); + + /** + * Handles course data state changes. + */ + useEffect(() => { + if (courseData && !searchString && courseData.total > 0) { + setPreviousCourseData(courseData); + } + }, [courseData, searchString]); + + 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/hooks/useFilter.ts b/src/catalog/hooks/useFilter.ts new file mode 100644 index 00000000..71902ec4 --- /dev/null +++ b/src/catalog/hooks/useFilter.ts @@ -0,0 +1,79 @@ +import { useState, useCallback } from 'react'; + +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'; + +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: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: newFilters, + searchString, + }); + return true; + } + + return false; + }, [filterState]); + + return { + filterState, + resetFilterProgress, + handleFilterChange, + }; +}; 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, - }; -}; diff --git a/src/catalog/hooks/usePagination.ts b/src/catalog/hooks/usePagination.ts new file mode 100644 index 00000000..b8a06241 --- /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(DEFAULT_PAGE_INDEX); + }, []); + + return { + pageIndex, + handlePageChange, + resetPagination, + }; +}; diff --git a/src/catalog/hooks/useSearch.ts b/src/catalog/hooks/useSearch.ts new file mode 100644 index 00000000..c292f431 --- /dev/null +++ b/src/catalog/hooks/useSearch.ts @@ -0,0 +1,72 @@ +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 type { UseSearchProps } from './types'; + +/** + * Custom hook for managing search functionality in the catalog. + * + * This hook provides functionality to: + * - Handle search queries + * - Manage search state + * - Initialize search from URL parameters + */ +export const useSearch = ({ fetchData, courseData, isFetching }: UseSearchProps) => { + const [searchString, setSearchString] = 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); + + if (urlSearchQuery) { + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete('search_query'); + setSearchParams(newParams); + } + + fetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: query, + }); + }, [fetchData, setSearchParams, searchParams, urlSearchQuery]); + + /** + * Initializes search state from URL parameters on component mount. + */ + useEffect(() => { + if (hasInitialized) { + return; + } + + if (!courseData || isFetching) { + return; + } + + if (urlSearchQuery && !searchString) { + setSearchString(urlSearchQuery); + + fetchData({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + filters: [], + searchString: urlSearchQuery, + }); + } + + setHasInitialized(true); + }, [hasInitialized, urlSearchQuery, searchString, fetchData, courseData, isFetching]); + + return { + searchString, + handleSearch, + }; +}; 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..66f4ec85 --- /dev/null +++ b/src/catalog/types.ts @@ -0,0 +1,9 @@ +import { IntlShape } from '@edx/frontend-platform/i18n'; + +import { CourseListSearchResponse } from '@src/data/course-list-search/types'; + +export interface GetPageTitleProps { + intl: IntlShape; + searchString: string; + courseData: CourseListSearchResponse | undefined; +} diff --git a/src/catalog/utils.ts b/src/catalog/utils.ts index 13363a62..0a814068 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,22 @@ 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, + searchString, + courseData, +}: GetPageTitleProps) => { + if (!searchString) { + return intl.formatMessage(messages.exploreCourses); + } + + if ((courseData?.results?.length ?? 0) === 0) { + return intl.formatMessage(messages.noSearchResults, { query: searchString }); + } + + return intl.formatMessage(messages.searchResults, { query: searchString }); +}; 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 { diff --git a/src/generic/course-card/index.tsx b/src/generic/course-card/index.tsx index db3a2e73..bfc33bce 100644 --- a/src/generic/course-card/index.tsx +++ b/src/generic/course-card/index.tsx @@ -31,6 +31,7 @@ export const CourseCard = ({ original: courseData, isLoading }: CourseCardProps) src={getFullImageUrl(courseData?.data.imageUrl)} fallbackSrc={noCourseImg} srcAlt={`${courseData?.data.content.displayName} ${courseData?.data.number}`} + skeletonDuringImageLoad />