Added Courseware Search container popover UI (#1212)
* feat: Added Courseware Search container popover * chore: Added unit tests for CoursewareSearch and CoursewareSearchToggle * chore: Updated unit test for CourseTabsNavigation * chore: Partial coverage on Courseware Search Hooks * chore: Finished Courseware Search Hooks unit testing * fix: Fixed an overlook that caused a conditional hook * fix: Reduced bounce timeout on scroll/resize to 100ms * chore: Updated snapshots * chore: Moved @testing-library/react-hooks dep to DEV * chore: Minor adjustments on unit tests * chore: Fixed test issue
This commit is contained in:
59
package-lock.json
generated
59
package-lock.json
generated
@@ -58,6 +58,7 @@
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
@@ -3869,35 +3870,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/react-unit-test-utils/node_modules/@testing-library/react-hooks": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
|
||||
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"react-error-boundary": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.9.0 || ^17.0.0",
|
||||
"react": "^16.9.0 || ^17.0.0",
|
||||
"react-dom": "^16.9.0 || ^17.0.0",
|
||||
"react-test-renderer": "^16.9.0 || ^17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-test-renderer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/react-unit-test-utils/node_modules/axios": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
|
||||
@@ -6234,6 +6206,35 @@
|
||||
"react-dom": "<18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react-hooks": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
|
||||
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"react-error-boundary": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.9.0 || ^17.0.0",
|
||||
"react": "^16.9.0 || ^17.0.0",
|
||||
"react-dom": "^16.9.0 || ^17.0.0",
|
||||
"react-test-renderer": "^16.9.0 || ^17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-test-renderer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
|
||||
@@ -1,25 +1,90 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { Search } from '@edx/paragon/icons';
|
||||
import {
|
||||
Close,
|
||||
} from '@edx/paragon/icons';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useElementBoundingBox, useLockScroll } from './hooks';
|
||||
import messages from './messages';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
|
||||
const CoursewareSearch = ({ intl, ...rest }) => {
|
||||
const { courseId } = useParams();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const CoursewareSearch = ({ intl, ...sectionProps }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
|
||||
}, [courseId]);
|
||||
useLockScroll();
|
||||
|
||||
if (!enabled) { return null; }
|
||||
const info = useElementBoundingBox('courseTabsNavigation');
|
||||
const top = info ? `${Math.floor(info.top)}px` : 0;
|
||||
|
||||
return (
|
||||
<Button variant="tertiary" size="sm" className="p-1 mt-2 mr-2 rounded-lg" aria-label={intl.formatMessage(messages.searchOpenAction)} data-testid="courseware-search-button" {...rest}>
|
||||
<Icon src={Search} />
|
||||
</Button>
|
||||
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
|
||||
<div className="courseware-search__close">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-1"
|
||||
aria-label={intl.formatMessage(messages.searchCloseAction)}
|
||||
onClick={() => dispatch(setShowSearch(false))}
|
||||
data-testid="courseware-search-close-button"
|
||||
><Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="courseware-search__outer-content">
|
||||
<div className="courseware-search__content" style={{ height: '999px' }}>
|
||||
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis semper rutrum odio quis congue.
|
||||
Duis sodales nibh et sapien elementum fermentum. Quisque magna urna, gravida at gravida et,
|
||||
ultricies vel massa.Aliquam in vehicula dolor, id scelerisque felis.
|
||||
Morbi posuere scelerisque tincidunt. Proin et gravida tortor. Vestibulum vel orci vulputate,
|
||||
gravida justo eu, varius dolor. Etiam viverra diam sed est tincidunt, et aliquam est efficitur.
|
||||
Donec imperdiet eros quis est condimentum faucibus.
|
||||
</p>
|
||||
<p>
|
||||
In mattis, tellus ut lacinia viverra, ligula ex sagittis ex, sed mollis ex enim ut velit.
|
||||
Nunc elementum, risus eget feugiat scelerisque, sapien felis laoreet nisl, ut pharetra neque
|
||||
lorem a elit. Maecenas elementum, metus fringilla suscipit imperdiet, mi nunc efficitur elit,
|
||||
sed consequat massa magna sit amet dui. Curabitur ultrices nisi vel lorem scelerisque, pharetra
|
||||
luctus nunc pulvinar. Morbi aliquam ante eget arcu condimentum consectetur. Fusce faucibus lacus
|
||||
sed pretium ultrices. Curabitur neque lacus, elementum convallis augue placerat, gravida
|
||||
scelerisque ipsum. Donec bibendum lectus id ullamcorper sodales. Integer quis ante facilisis erat
|
||||
maximus viverra. Nunc rutrum posuere lectus, aliquam congue odio blandit nec. Phasellus placerat,
|
||||
magna non bibendum lacinia, tortor orci vulputate dui, vitae imperdiet turpis dui nec tortor.
|
||||
Praesent porttitor mollis diam ut gravida. Praesent vitae felis dignissim sem accumsan dignissim.
|
||||
Fusce ullamcorper bibendum ante ac pellentesque. Aliquam sed leo vel leo pellentesque cursus a at risus.
|
||||
Donec sollicitudin maximus diam, sit amet molestie sapien commodo at.
|
||||
</p>
|
||||
<p>
|
||||
Cras ornare pulvinar est id rhoncus. Aenean ut risus magna. Fusce cursus pulvinar dui ut egestas.
|
||||
Quisque condimentum risus non mi sagittis, eu facilisis enim hendrerit. Integer faucibus dapibus rutrum.
|
||||
Nullam vitae mollis tortor, eu lacinia mi. Nunc commodo ex id eros hendrerit, vel interdum augue tristique.
|
||||
Suspendisse ullamcorper, purus in vestibulum auctor, justo nisi finibus dolor,
|
||||
nec dignissim arcu enim a augue.
|
||||
</p>
|
||||
<p>
|
||||
Fusce vel libero odio. Orci varius natoque penatibus et magnis dis parturient montes,
|
||||
nascetur ridiculus mus. Pellentesque at varius turpis. Ut pulvinar efficitur congue. Vivamus cursus,
|
||||
purus at aliquet malesuada, felis quam blandit dolor, a interdum justo est semper augue.
|
||||
In eu lectus sit amet est pellentesque porta vel eget magna. Morbi sollicitudin turpis vitae faucibus
|
||||
pulvinar. Etiam placerat pulvinar porta.
|
||||
</p>
|
||||
<p>
|
||||
Suspendisse mattis eget felis non sagittis. Nulla facilisi. In bibendum cursus purus, non venenatis tellus
|
||||
dignissim sit amet. Phasellus volutpat ipsum turpis, non imperdiet nisi elementum a. Nunc mollis, sapien
|
||||
cursus vehicula consectetur, nunc turpis pulvinar mauris, at varius justo mi egestas nisi. Fusce semper
|
||||
sapien in orci rhoncus ornare. Donec maximus mi eu pulvinar convallis.
|
||||
</p>
|
||||
<p>
|
||||
Nullam tortor sem, hendrerit eu sapien ac, venenatis rhoncus ligula. Donec nibh leo, venenatis sed interdum
|
||||
ac, pharetra sed nibh. Orci varius natoque penatibus et magnis dis parturient montes,
|
||||
nascetur ridiculus mus. Sed congue risus eu mattis condimentum. In id nulla sit amet magna suscipit
|
||||
consectetur. Nullam vitae augue felis. In consequat tempus diam, a eleifend ante bibendum ac.
|
||||
Vivamus mi orci, fermentum ac viverra quis, tristique a ipsum. Morbi imperdiet porta sem, in sollicitudin
|
||||
risus dignissim at. Nulla dapibus iaculis vestibulum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import { CoursewareSearch } from './index';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useElementBoundingBox, useLockScroll } from './hooks';
|
||||
|
||||
jest.mock('../data/thunks');
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearch />);
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../data/slice');
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
const tabsTopPosition = 128;
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const { container } = render(<CoursewareSearch {...props} />);
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -21,26 +30,55 @@ describe('CoursewareSearch', () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should not render when the waffle flag is disabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
|
||||
describe('when rendering normally', () => {
|
||||
beforeAll(() => {
|
||||
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
|
||||
});
|
||||
|
||||
await act(async () => renderComponent());
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-button')).not.toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
|
||||
expect(useElementBoundingBox).toBeCalledTimes(1);
|
||||
expect(useLockScroll).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
|
||||
});
|
||||
|
||||
it('Should dispatch setShowSearch(true) when clicking the close button', () => {
|
||||
const button = screen.getByTestId('courseware-search-close-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render when the waffle flag is enabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
await act(async () => renderComponent());
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-button')).toBeInTheDocument();
|
||||
describe('when CourseTabsNavigation is not present', () => {
|
||||
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
|
||||
useElementBoundingBox.mockImplementation(() => undefined);
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passing extra props', () => {
|
||||
it('Should pass on extra props to section element', () => {
|
||||
renderComponent({ foo: 'bar' });
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section).toHaveAttribute('foo', 'bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
38
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
38
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React 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';
|
||||
|
||||
const CoursewareSearchToggle = ({
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="courseware-searc-toggle">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="p-1 mt-2 mr-2 rounded-lg"
|
||||
aria-label={intl.formatMessage(messages.searchOpenAction)}
|
||||
onClick={() => dispatch(setShowSearch(true))}
|
||||
data-testid="courseware-search-open-button"
|
||||
>
|
||||
<Icon src={Search} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchToggle.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoursewareSearchToggle);
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { CoursewareSearchToggle } from './index';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('../data/thunks');
|
||||
jest.mock('../data/slice');
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearchToggle />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should not render when the waffle flag is disabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
|
||||
|
||||
await act(async () => renderComponent());
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render when the waffle flag is enabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
await act(async () => renderComponent());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
await act(async () => renderComponent());
|
||||
const button = await screen.findByTestId('courseware-search-open-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
45
src/course-home/courseware-search/courseware-search.scss
Normal file
45
src/course-home/courseware-search/courseware-search.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
.courseware-search {
|
||||
background: white;
|
||||
position: fixed;
|
||||
top: var(--modal-top-position, 0);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-top: 1px solid $light-300;
|
||||
z-index: 200;
|
||||
|
||||
&__close {
|
||||
position: absolute !important; // For some reason it gets overridden
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__outer-content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 2rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
max-width: 42rem;
|
||||
margin: auto;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, 'md')) {
|
||||
.courseware-search__content {
|
||||
padding-top: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
body._search-no-scroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
71
src/course-home/courseware-search/hooks.js
Normal file
71
src/course-home/courseware-search/hooks.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useLayoutEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
|
||||
const DEBOUNCE_WAIT = 100; // ms
|
||||
|
||||
export function useCoursewareSearchFeatureFlag() {
|
||||
const { courseId } = useParams();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
|
||||
}, [courseId]);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function useCoursewareSearchState() {
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
const show = useSelector(state => state.courseHome.showSearch);
|
||||
|
||||
return { show: enabled && show };
|
||||
}
|
||||
|
||||
export function useElementBoundingBox(elementId) {
|
||||
const [info, setInfo] = useState(undefined);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (!element) {
|
||||
console.warn(`useElementBoundingBox(): Unable to find element with id='${elementId}' in the document.`); // eslint-disable-line no-console
|
||||
return undefined;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Handler to call on window resize and scroll
|
||||
function recalculate() {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
setInfo(bounds);
|
||||
}
|
||||
const debouncedRecalculate = debounce(recalculate, DEBOUNCE_WAIT, { leading: true });
|
||||
|
||||
// Add event listener
|
||||
global.addEventListener('resize', debouncedRecalculate);
|
||||
global.addEventListener('scroll', debouncedRecalculate);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
debouncedRecalculate();
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
global.removeEventListener('resize', debouncedRecalculate);
|
||||
global.removeEventListener('scroll', debouncedRecalculate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export function useLockScroll() {
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.body.classList.add('_search-no-scroll');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('_search-no-scroll');
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
187
src/course-home/courseware-search/hooks.test.jsx
Normal file
187
src/course-home/courseware-search/hooks.test.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import {
|
||||
useCoursewareSearchFeatureFlag, useCoursewareSearchState, useElementBoundingBox, useLockScroll,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('react-redux');
|
||||
jest.mock('react-router-dom');
|
||||
jest.mock('../data/thunks');
|
||||
|
||||
describe('CoursewareSearch Hooks', () => {
|
||||
const courses = {
|
||||
123: { enabled: true },
|
||||
456: { enabled: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCoursewareSearchSettings.mockImplementation((courseId) => Promise.resolve(courses[courseId]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchFeatureFlag', () => {
|
||||
const renderTestHook = async (enabled = true) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchFeatureFlag())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return true if feature is enabled', async () => {
|
||||
const hook = await renderTestHook();
|
||||
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if feature is disabled', async () => {
|
||||
const hook = await renderTestHook(false);
|
||||
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchState', () => {
|
||||
const renderTestHook = async ({ enabled, showSearch }) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
const mockedStoreState = { courseHome: { showSearch } };
|
||||
useSelector.mockImplementation(selector => selector(mockedStoreState));
|
||||
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchState())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return show: true if feature is enabled and showSearch is true', async () => {
|
||||
const hook = await renderTestHook({ enabled: true, showSearch: true });
|
||||
|
||||
expect(hook.result.current).toEqual({ show: true });
|
||||
});
|
||||
|
||||
it('should return show: false in any other case', async () => {
|
||||
let hook;
|
||||
|
||||
hook = await renderTestHook({ enabled: true, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: true });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useElementBoundingBox', () => {
|
||||
let getBoundingClientRectSpy;
|
||||
const renderTestHook = async ({ elementId, mockedInfo }) => {
|
||||
getBoundingClientRectSpy = jest.spyOn(document, 'getElementById').mockImplementation(() => (
|
||||
mockedInfo
|
||||
? { getBoundingClientRect: () => ({ ...mockedInfo }) }
|
||||
: undefined
|
||||
));
|
||||
|
||||
let hook;
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useElementBoundingBox(elementId));
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
let addEventListenerSpy;
|
||||
let removeEventListenerSpy;
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
|
||||
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
|
||||
});
|
||||
|
||||
describe('when element is present', () => {
|
||||
const mockedInfo = { top: 128 };
|
||||
|
||||
it('should bind resize and scroll events on mount', async () => {
|
||||
await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should unbindbind resize and scroll events when unmounted', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
hook.unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should return the element bounding box', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
hook.waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
|
||||
it('should call getBoundingClientRect on window resize', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
act(() => {
|
||||
// Trigger the window resize event.
|
||||
global.innerWidth = 500;
|
||||
global.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when element is NOT present', () => {
|
||||
let consoleWarnSpy;
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should log a warning and return undefined', async () => {
|
||||
await renderTestHook({ elementId: 'happiness' });
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith("useElementBoundingBox(): Unable to find element with id='happiness' in the document.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLockScroll', () => {
|
||||
const renderTestHook = () => (
|
||||
renderHook(() => useLockScroll())
|
||||
);
|
||||
|
||||
let windowScrollSpy;
|
||||
let addBodyClassSpy;
|
||||
let removeBodyClassSpy;
|
||||
let hook;
|
||||
|
||||
beforeEach(() => {
|
||||
windowScrollSpy = jest.spyOn(window, 'scrollTo');
|
||||
addBodyClassSpy = jest.spyOn(document.body.classList, 'add');
|
||||
removeBodyClassSpy = jest.spyOn(document.body.classList, 'remove');
|
||||
hook = renderTestHook();
|
||||
});
|
||||
|
||||
it('should perform a scrollTo(0, 0) on mount', () => {
|
||||
expect(windowScrollSpy).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('should append a _search-no-scroll on mount to the document body', () => {
|
||||
expect(addBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
|
||||
it('should remove the _search-no-scroll on unmount', () => {
|
||||
hook.unmount();
|
||||
|
||||
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
|
||||
export { default as CoursewareSearch } from './CoursewareSearch';
|
||||
|
||||
@@ -6,6 +6,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Search within this course',
|
||||
description: 'Aria-label for a button that will pop up Courseware Search.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSerch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSerch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -6,6 +6,7 @@ Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -327,6 +328,7 @@ Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -526,6 +528,7 @@ Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
|
||||
@@ -15,6 +15,7 @@ const slice = createSlice({
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
@@ -47,6 +48,9 @@ const slice = createSlice({
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +61,7 @@ export const {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -5,33 +5,41 @@ import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
import { CoursewareSearch } from '../course-home/courseware-search';
|
||||
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
|
||||
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
|
||||
|
||||
const CourseTabsNavigation = ({
|
||||
activeTabSlug, className, tabs, intl,
|
||||
}) => (
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="float-right">
|
||||
<CoursewareSearch />
|
||||
}) => {
|
||||
const { show } = useCoursewareSearchState();
|
||||
|
||||
return (
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="float-right">
|
||||
<CoursewareSearchToggle />
|
||||
</div>
|
||||
<div className="container-xl">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
{show ? (
|
||||
<CoursewareSearch data-testid="courseware-search" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="container-xl">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp, render, screen } from '../setupTest';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
initializeMockApp, render, screen,
|
||||
} from '../setupTest';
|
||||
import { useCoursewareSearchState } 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();
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
function renderComponent(props = { tabs: [] }) {
|
||||
const store = initializeStore();
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<CourseTabsNavigation {...props} />
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('Course Tabs Navigation', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useCoursewareSearchState.mockImplementation(() => ({ show: false }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders without tabs', () => {
|
||||
render(<CourseTabsNavigation tabs={[]} />);
|
||||
renderComponent();
|
||||
expect(screen.getByRole('button', { name: 'More...' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,7 +53,7 @@ describe('Course Tabs Navigation', () => {
|
||||
tabs,
|
||||
activeTabSlug: tabs[0].slug,
|
||||
};
|
||||
render(<CourseTabsNavigation {...mockData} />);
|
||||
renderComponent(mockData);
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveAttribute('href', tabs[0].url);
|
||||
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveClass('active');
|
||||
@@ -29,4 +61,17 @@ describe('Course Tabs Navigation', () => {
|
||||
expect(screen.getByRole('link', { name: tabs[1].title })).toHaveAttribute('href', tabs[1].url);
|
||||
expect(screen.getByRole('link', { name: tabs[1].title })).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should NOT render CoursewareSearch if the flag is off', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CoursewareSearch if the flag is on', () => {
|
||||
useCoursewareSearchState.mockImplementation(() => ({ show: true }));
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,3 +391,4 @@
|
||||
@import "course-home/progress-tab/grades/course-grade/GradeBar.scss";
|
||||
@import "courseware/course/course-exit/CourseRecommendations";
|
||||
@import "product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.scss";
|
||||
@import "course-home/courseware-search/courseware-search.scss";
|
||||
|
||||
Reference in New Issue
Block a user