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:
Marcos
2023-10-20 11:33:41 -03:00
committed by GitHub
parent 12205de132
commit cbb419c256
16 changed files with 674 additions and 88 deletions

59
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);
};

View File

@@ -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');
});
});
});

View 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);

View File

@@ -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);
});
});

View 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;
}

View 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');
};
}, []);
}

View 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');
});
});
});

View File

@@ -1,2 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
export { default as CoursewareSearch } from './CoursewareSearch';

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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";