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
/>