Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use state params to keep query and filter while searching #1249

Merged
merged 5 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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; }

Expand All @@ -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; }
Expand All @@ -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 }) => (
<Tab key={key} eventKey={key} title={getFilterTitle(key, label)}>
Expand Down
11 changes: 11 additions & 0 deletions src/course-home/courseware-search/CoursewareResultsFilter.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -101,6 +111,7 @@ describe('CoursewareSearchResultsFilter', () => {
});

it('should render', async () => {
useCoursewareSearchParams.mockReturnValue(coursewareSearch);
useModel.mockReturnValue(searchResultsFactory());

await renderComponent();
Expand Down
32 changes: 19 additions & 13 deletions src/course-home/courseware-search/CoursewareSearch.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 {
Expand All @@ -28,14 +29,14 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
errors,
total,
} = useModel('contentSearchResults', courseId);
const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword);

useLockScroll();

const info = useElementBoundingBox('courseTabsNavigation');
const top = info ? `${Math.floor(info.top)}px` : 0;

const clearSearch = () => {
clearSearchParams();
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
Expand All @@ -48,8 +49,8 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
}));
};

const handleSubmit = () => {
if (!searchKeyword) {
const handleSubmit = (value) => {
if (!value) {
clearSearch();
return;
}
Expand All @@ -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';
Expand All @@ -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"
><Icon src={Close} />
</Button>
Expand Down
30 changes: 27 additions & 3 deletions src/course-home/courseware-search/CoursewareSearch.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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 || '',
};
Expand All @@ -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', () => {
Expand All @@ -94,6 +114,7 @@ describe('CoursewareSearch', () => {

it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
mockModels();
mockSearchParams();
renderComponent();

expect(useElementBoundingBox).toBeCalledTimes(1);
Expand All @@ -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');
Expand All @@ -128,6 +150,7 @@ describe('CoursewareSearch', () => {
useElementBoundingBox.mockImplementation(() => undefined);

mockModels();
mockSearchParams();
renderComponent();

const section = screen.getByTestId('courseware-search-section');
Expand All @@ -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');
Expand Down
17 changes: 13 additions & 4 deletions src/course-home/courseware-search/CoursewareSearchToggle.jsx
Original file line number Diff line number Diff line change
@@ -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; }

Expand All @@ -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"
>
<Icon src={Search} />
Expand Down
24 changes: 24 additions & 0 deletions src/course-home/courseware-search/CoursewareSearchToggle.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CoursewareSearchToggle />);
return container;
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand All @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion src/course-home/courseware-search/hooks.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
};
}
Loading