diff --git a/src/course-home/courseware-search/CoursewareResultsFilter.jsx b/src/course-home/courseware-search/CoursewareResultsFilter.jsx index edf74a1a6e..29d410839b 100644 --- a/src/course-home/courseware-search/CoursewareResultsFilter.jsx +++ b/src/course-home/courseware-search/CoursewareResultsFilter.jsx @@ -5,6 +5,7 @@ import { Tabs, Tab } from '@edx/paragon'; import { useParams } from 'react-router'; import CoursewareSearchResults from './CoursewareSearchResults'; import messages from './messages'; +import { useCoursewareSearchParams } from './hooks'; import { useModel } from '../../generic/model-store'; const noFilterKey = 'none'; @@ -17,6 +18,7 @@ export const filteredResultsBySelection = ({ key = noFilterKey, results = [] }) export const CoursewareSearchResultsFilter = ({ intl }) => { const { courseId } = useParams(); const lastSearch = useModel('contentSearchResults', courseId); + const { filter: filterKeyword, setFilter } = useCoursewareSearchParams(); if (!lastSearch || !lastSearch?.results?.length) { return null; } @@ -31,6 +33,8 @@ export const CoursewareSearchResultsFilter = ({ intl }) => { ...lastSearch.filters, ]; + const activeKey = filters.find(({ key }) => key === filterKeyword)?.key || noFilterKey; + const getFilterTitle = (key, fallback) => { const msg = messages[`filter:${key}`]; if (!msg) { return fallback; } @@ -42,7 +46,8 @@ export const CoursewareSearchResultsFilter = ({ intl }) => { id="courseware-search-results-tabs" data-testid="courseware-search-results-tabs" variant="tabs" - defaultActiveKey={noFilterKey} + activeKey={activeKey} + onSelect={setFilter} > {filters.map(({ key, label }) => ( diff --git a/src/course-home/courseware-search/CoursewareResultsFilter.test.jsx b/src/course-home/courseware-search/CoursewareResultsFilter.test.jsx index 034c80f2c7..370ca20104 100644 --- a/src/course-home/courseware-search/CoursewareResultsFilter.test.jsx +++ b/src/course-home/courseware-search/CoursewareResultsFilter.test.jsx @@ -9,10 +9,12 @@ import { waitFor, } from '../../setupTest'; import { CoursewareSearchResultsFilter, filteredResultsBySelection } from './CoursewareResultsFilter'; +import { useCoursewareSearchParams } from './hooks'; import initializeStore from '../../store'; import { useModel } from '../../generic/model-store'; import searchResultsFactory from './test-data/search-results-factory'; +jest.mock('./hooks'); jest.mock('../../generic/model-store', () => ({ useModel: jest.fn(), })); @@ -47,6 +49,14 @@ const intl = { formatMessage: (message) => message?.defaultMessage || '', }; +const coursewareSearch = { + query: '', + filter: '', + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSearchParams: jest.fn(), +}; + function renderComponent(props = {}) { const store = initializeStore(); history.push(pathname); @@ -101,6 +111,7 @@ describe('CoursewareSearchResultsFilter', () => { }); it('should render', async () => { + useCoursewareSearchParams.mockReturnValue(coursewareSearch); useModel.mockReturnValue(searchResultsFactory()); await renderComponent(); diff --git a/src/course-home/courseware-search/CoursewareSearch.jsx b/src/course-home/courseware-search/CoursewareSearch.jsx index bb1abf92de..835d6d3235 100644 --- a/src/course-home/courseware-search/CoursewareSearch.jsx +++ b/src/course-home/courseware-search/CoursewareSearch.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router'; import { useDispatch } from 'react-redux'; import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; @@ -10,7 +10,7 @@ import { Close, } from '@edx/paragon/icons'; import { setShowSearch } from '../data/slice'; -import { useElementBoundingBox, useLockScroll } from './hooks'; +import { useCoursewareSearchParams, useElementBoundingBox, useLockScroll } from './hooks'; import messages from './messages'; import CoursewareSearchForm from './CoursewareSearchForm'; @@ -20,6 +20,7 @@ import { searchCourseContent } from '../data/thunks'; const CoursewareSearch = ({ intl, ...sectionProps }) => { const { courseId } = useParams(); + const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams(); const dispatch = useDispatch(); const { org } = useModel('courseHomeMeta', courseId); const { @@ -28,7 +29,6 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { errors, total, } = useModel('contentSearchResults', courseId); - const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword); useLockScroll(); @@ -36,6 +36,7 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { const top = info ? `${Math.floor(info.top)}px` : 0; const clearSearch = () => { + clearSearchParams(); dispatch(updateModel({ modelType: 'contentSearchResults', model: { @@ -48,8 +49,8 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { })); }; - const handleSubmit = () => { - if (!searchKeyword) { + const handleSubmit = (value) => { + if (!value) { clearSearch(); return; } @@ -58,20 +59,25 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { org_key: org, courserun_key: courseId, event_type: 'searchKeyword', - keyword: searchKeyword, + keyword: value, }); - dispatch(searchCourseContent(courseId, searchKeyword)); + dispatch(searchCourseContent(courseId, value)); + setQuery(value); }; + useEffect(() => { + handleSubmit(searchKeyword); + }, []); + const handleOnChange = (value) => { if (value === searchKeyword) { return; } + if (!value) { clearSearch(); } + }; - setSearchKeyword(value); - - if (!value) { - clearSearch(); - } + const handleSearchCloseClick = () => { + clearSearch(); + dispatch(setShowSearch(false)); }; let status = 'idle'; @@ -90,7 +96,7 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { variant="tertiary" className="p-1" aria-label={intl.formatMessage(messages.searchCloseAction)} - onClick={() => dispatch(setShowSearch(false))} + onClick={handleSearchCloseClick} data-testid="courseware-search-close-button" > diff --git a/src/course-home/courseware-search/CoursewareSearch.test.jsx b/src/course-home/courseware-search/CoursewareSearch.test.jsx index cf852c7ffb..7ebce7097b 100644 --- a/src/course-home/courseware-search/CoursewareSearch.test.jsx +++ b/src/course-home/courseware-search/CoursewareSearch.test.jsx @@ -11,11 +11,11 @@ import { fireEvent, } from '../../setupTest'; import { CoursewareSearch } from './index'; -import { useElementBoundingBox, useLockScroll } from './hooks'; +import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks'; import initializeStore from '../../store'; -import { useModel, updateModel } from '../../generic/model-store'; import { searchCourseContent } from '../data/thunks'; import { setShowSearch } from '../data/slice'; +import { updateModel, useModel } from '../../generic/model-store'; jest.mock('./hooks'); jest.mock('../../generic/model-store', () => ({ @@ -56,6 +56,14 @@ const defaultProps = { total: 0, }; +const coursewareSearch = { + query: '', + filter: '', + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSearchParams: jest.fn(), +}; + const intl = { formatMessage: (message) => message?.defaultMessage || '', }; @@ -73,11 +81,23 @@ function renderComponent(props = {}) { return container; } -const mockModels = ((props) => { +const mockModels = ((props = defaultProps) => { useModel.mockReturnValue({ ...defaultProps, ...props, }); + + updateModel.mockReturnValue({ + type: 'MOCK_ACTION', + payload: { + modelType: 'contentSearchResults', + model: defaultProps, + }, + }); +}); + +const mockSearchParams = ((props = coursewareSearch) => { + useCoursewareSearchParams.mockReturnValue(props); }); describe('CoursewareSearch', () => { @@ -94,6 +114,7 @@ describe('CoursewareSearch', () => { it('should use useElementBoundingBox() and useLockScroll() hooks', () => { mockModels(); + mockSearchParams(); renderComponent(); expect(useElementBoundingBox).toBeCalledTimes(1); @@ -102,6 +123,7 @@ describe('CoursewareSearch', () => { it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => { mockModels(); + mockSearchParams(); renderComponent(); const section = screen.getByTestId('courseware-search-section'); @@ -128,6 +150,7 @@ describe('CoursewareSearch', () => { useElementBoundingBox.mockImplementation(() => undefined); mockModels(); + mockSearchParams(); renderComponent(); const section = screen.getByTestId('courseware-search-section'); @@ -138,6 +161,7 @@ describe('CoursewareSearch', () => { describe('when passing extra props', () => { it('should pass on extra props to section element', () => { mockModels(); + mockSearchParams(); renderComponent({ foo: 'bar' }); const section = screen.getByTestId('courseware-search-section'); diff --git a/src/course-home/courseware-search/CoursewareSearchToggle.jsx b/src/course-home/courseware-search/CoursewareSearchToggle.jsx index 0a5707bfca..b3cbc96c13 100644 --- a/src/course-home/courseware-search/CoursewareSearchToggle.jsx +++ b/src/course-home/courseware-search/CoursewareSearchToggle.jsx @@ -1,17 +1,26 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Icon } from '@edx/paragon'; import { Search } from '@edx/paragon/icons'; import { useDispatch } from 'react-redux'; -import { setShowSearch } from '../data/slice'; import messages from './messages'; -import { useCoursewareSearchFeatureFlag } from './hooks'; +import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks'; +import { setShowSearch } from '../data/slice'; const CoursewareSearchToggle = ({ intl, }) => { const dispatch = useDispatch(); const enabled = useCoursewareSearchFeatureFlag(); + const { query } = useCoursewareSearchParams(); + + const handleSearchOpenClick = () => { + dispatch(setShowSearch(true)); + }; + + useEffect(() => { + if (enabled && !!query) { handleSearchOpenClick(); } + }, [enabled]); if (!enabled) { return null; } @@ -22,7 +31,7 @@ const CoursewareSearchToggle = ({ size="sm" className="p-1 mt-2 mr-2 rounded-lg" aria-label={intl.formatMessage(messages.searchOpenAction)} - onClick={() => dispatch(setShowSearch(true))} + onClick={handleSearchOpenClick} data-testid="courseware-search-open-button" > diff --git a/src/course-home/courseware-search/CoursewareSearchToggle.test.jsx b/src/course-home/courseware-search/CoursewareSearchToggle.test.jsx index f341e38fc6..24acd3f6ea 100644 --- a/src/course-home/courseware-search/CoursewareSearchToggle.test.jsx +++ b/src/course-home/courseware-search/CoursewareSearchToggle.test.jsx @@ -12,14 +12,33 @@ import { setShowSearch } from '../data/slice'; import { CoursewareSearchToggle } from './index'; const mockDispatch = jest.fn(); +const mockCoursewareSearchParams = jest.fn(); jest.mock('../data/thunks'); jest.mock('../data/slice'); + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useDispatch: () => mockDispatch, })); +jest.mock('./hooks', () => ({ + ...jest.requireActual('./hooks'), + useCoursewareSearchParams: () => mockCoursewareSearchParams, +})); + +const coursewareSearch = { + query: '', + filter: '', + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSearchParams: jest.fn(), +}; + +const mockSearchParams = ((props = coursewareSearch) => { + mockCoursewareSearchParams.mockReturnValue(props); +}); + function renderComponent() { const { container } = render(); return container; @@ -36,6 +55,7 @@ describe('CoursewareSearchToggle', () => { it('Should not render when the waffle flag is disabled', async () => { fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false })); + mockSearchParams(); await act(async () => renderComponent()); await waitFor(() => { @@ -46,6 +66,8 @@ describe('CoursewareSearchToggle', () => { it('Should render when the waffle flag is enabled', async () => { fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true })); + mockSearchParams(); + await act(async () => renderComponent()); await waitFor(() => { @@ -56,6 +78,8 @@ describe('CoursewareSearchToggle', () => { it('Should dispatch setShowSearch(true) when clicking the search button', async () => { fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true })); + mockSearchParams(); + await act(async () => renderComponent()); const button = await screen.findByTestId('courseware-search-open-button'); fireEvent.click(button); diff --git a/src/course-home/courseware-search/hooks.js b/src/course-home/courseware-search/hooks.js index 85c985021a..a7e64a4c3d 100644 --- a/src/course-home/courseware-search/hooks.js +++ b/src/course-home/courseware-search/hooks.js @@ -1,5 +1,5 @@ import { useState, useEffect, useLayoutEffect } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { debounce } from 'lodash'; import { fetchCoursewareSearchSettings } from '../data/thunks'; @@ -69,3 +69,18 @@ export function useLockScroll() { }; }, []); } + +export function useCoursewareSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + const clearSearchParams = () => setSearchParams({ q: '', f: '' }); + + const query = searchParams.get('q'); + const filter = searchParams.get('f'); + + const setQuery = (q) => setSearchParams((params) => ({ q, f: params.get('f') })); + const setFilter = (f) => setSearchParams((params) => ({ q: params.get('q'), f })); + + return { + query, filter, setQuery, setFilter, clearSearchParams, + }; +} diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index c6fc547a0c..bcb219d49e 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -23,8 +23,26 @@ import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateS import OutlineTab from './OutlineTab'; import LoadedTabPage from '../../tab-page/LoadedTabPage'; +const mockCoursewareSearchParams = jest.fn(); + initializeMockApp(); jest.mock('@edx/frontend-platform/analytics'); +jest.mock('../courseware-search/hooks', () => ({ + ...jest.requireActual('../courseware-search/hooks'), + useCoursewareSearchParams: () => mockCoursewareSearchParams, +})); + +const coursewareSearch = { + query: '', + filter: '', + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSearchParams: jest.fn(), +}; + +const mockSearchParams = ((props = coursewareSearch) => { + mockCoursewareSearchParams.mockReturnValue(props); +}); describe('Outline Tab', () => { let axiosMock; @@ -77,9 +95,16 @@ describe('Outline Tab', () => { expiration_date: null, }); + // Mock courseware search params + mockSearchParams(); + logUnhandledRequests(axiosMock); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('Course Outline', () => { it('displays link to start course', async () => { await fetchAndRender(); diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index 9f6dc3dbf5..d795a9bbf3 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -16,8 +16,26 @@ import ProgressTab from './ProgressTab'; import LoadedTabPage from '../../tab-page/LoadedTabPage'; import messages from './grades/messages'; +const mockCoursewareSearchParams = jest.fn(); + initializeMockApp(); jest.mock('@edx/frontend-platform/analytics'); +jest.mock('../courseware-search/hooks', () => ({ + ...jest.requireActual('../courseware-search/hooks'), + useCoursewareSearchParams: () => mockCoursewareSearchParams, +})); + +const coursewareSearch = { + query: '', + filter: '', + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSearchParams: jest.fn(), +}; + +const mockSearchParams = ((props = coursewareSearch) => { + mockCoursewareSearchParams.mockReturnValue(props); +}); describe('Progress Tab', () => { let axiosMock; @@ -58,9 +76,16 @@ describe('Progress Tab', () => { axiosMock.onGet(progressUrl).reply(200, defaultTabData); axiosMock.onGet(masqueradeUrl).reply(200, { success: true }); + // Mock courseware search params + mockSearchParams(); + logUnhandledRequests(axiosMock); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('Related links', () => { beforeEach(() => { sendTrackEvent.mockClear(); diff --git a/src/course-tabs/CourseTabsNavigation.test.jsx b/src/course-tabs/CourseTabsNavigation.test.jsx index 2f2c76de7c..a6d3db83ef 100644 --- a/src/course-tabs/CourseTabsNavigation.test.jsx +++ b/src/course-tabs/CourseTabsNavigation.test.jsx @@ -3,13 +3,20 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { initializeMockApp, render, screen, } from '../setupTest'; -import { useCoursewareSearchState } from '../course-home/courseware-search/hooks'; +import { useCoursewareSearchState, useCoursewareSearchParams } from '../course-home/courseware-search/hooks'; import { CourseTabsNavigation } from './index'; import initializeStore from '../store'; jest.mock('../course-home/courseware-search/hooks'); const mockDispatch = jest.fn(); +const coursewareSearch = { + query: '', + filter: '', + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSearchParams: jest.fn(), +}; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -33,6 +40,7 @@ describe('Course Tabs Navigation', () => { beforeEach(() => { useCoursewareSearchState.mockImplementation(() => ({ show: false })); + useCoursewareSearchParams.mockReturnValue(coursewareSearch); }); afterEach(() => {