feat: update unit title plugin (#1643)
This PR updates the unit title plugin to include all the elements that are part of the unit title, which includes the unit title, bookmark button, and navigation buttons (if left sidebar navigation is enabled).
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Unit component output BookmarkButton props bookmarked, bookmark update pending snapshot 1`] = `
|
||||
<BookmarkButton
|
||||
isBookmarked={true}
|
||||
isProcessing={false}
|
||||
unitId="unit-id"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Unit component output BookmarkButton props not bookmarked, bookmark update loading snapshot 1`] = `
|
||||
<BookmarkButton
|
||||
isBookmarked={false}
|
||||
isProcessing={true}
|
||||
unitId="unit-id"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Unit component output snapshot: not bookmarked, do not show content 1`] = `
|
||||
<div
|
||||
className="unit"
|
||||
>
|
||||
<div
|
||||
className="d-flex justify-content-between"
|
||||
>
|
||||
<div
|
||||
className="mb-0"
|
||||
>
|
||||
<h3
|
||||
className="h3"
|
||||
>
|
||||
unit-title
|
||||
</h3>
|
||||
<UnitTitleSlot
|
||||
courseId="test-course-id"
|
||||
unitId="test-props-id"
|
||||
unitTitle="unit-title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="sr-only"
|
||||
>
|
||||
Level 2 headings may be created by course providers in the future.
|
||||
</p>
|
||||
<BookmarkButton
|
||||
isBookmarked={false}
|
||||
isProcessing={false}
|
||||
unitId="unit-id"
|
||||
/>
|
||||
<UnitSuspense
|
||||
courseId="test-course-id"
|
||||
id="test-props-id"
|
||||
/>
|
||||
<ContentIFrame
|
||||
courseId="test-course-id"
|
||||
elementId="unit-iframe"
|
||||
id="test-props-id"
|
||||
loadingMessage="Loading learning sequence..."
|
||||
onLoaded={[MockFunction props.onLoaded]}
|
||||
shouldShowContent={true}
|
||||
title="unit-title"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -8,7 +8,6 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { usePluginsCallback } from '@src/generic/plugin-store';
|
||||
|
||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
||||
import messages from '../messages';
|
||||
import ContentIFrame from './ContentIFrame';
|
||||
import UnitSuspense from './UnitSuspense';
|
||||
@@ -33,7 +32,6 @@ const Unit = ({
|
||||
const examAccess = useExamAccess({ id });
|
||||
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
|
||||
const unit = useModel(modelKeys.units, id);
|
||||
const isProcessing = unit.bookmarkedUpdateState === 'loading';
|
||||
const view = authenticatedUser ? views.student : views.public;
|
||||
const shouldDisplayUnitPreview = pathname.startsWith('/preview') && isOriginalUserStaff;
|
||||
|
||||
@@ -50,19 +48,7 @@ const Unit = ({
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<div className="d-flex justify-content-between">
|
||||
<div className="mb-0">
|
||||
<h3 className="h3">{unit.title}</h3>
|
||||
<UnitTitleSlot courseId={courseId} unitId={id} unitTitle={unit.title} />
|
||||
</div>
|
||||
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
|
||||
</div>
|
||||
<p className="sr-only">{formatMessage(messages.headerPlaceholder)}</p>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
<UnitTitleSlot unitId={id} {...{ unit, isEnabledOutlineSidebar, renderUnitNavigation }} />
|
||||
<UnitSuspense {...{ courseId, id }} />
|
||||
<ContentIFrame
|
||||
elementId="unit-iframe"
|
||||
|
||||
@@ -1,207 +1,122 @@
|
||||
import React from 'react';
|
||||
import { when } from 'jest-when';
|
||||
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
|
||||
import { useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
|
||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
||||
import UnitSuspense from './UnitSuspense';
|
||||
import ContentIFrame from './ContentIFrame';
|
||||
import Unit from '.';
|
||||
import messages from '../messages';
|
||||
import {
|
||||
initializeMockApp, initializeTestStore, render, screen,
|
||||
} from '../../../../setupTest';
|
||||
import { getIFrameUrl } from './urls';
|
||||
import { modelKeys, views } from './constants';
|
||||
import * as hooks from './hooks';
|
||||
import { views } from './constants';
|
||||
import Unit from '.';
|
||||
|
||||
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
|
||||
jest.mock('react-router-dom');
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
|
||||
return {
|
||||
useIntl: () => ({ formatMessage: utils.formatMessage }),
|
||||
defineMessages: m => m,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
|
||||
jest.mock('../../bookmark/BookmarkButton', () => 'BookmarkButton');
|
||||
jest.mock('./ContentIFrame', () => 'ContentIFrame');
|
||||
jest.mock('./UnitSuspense', () => 'UnitSuspense');
|
||||
jest.mock('../honor-code', () => 'HonorCode');
|
||||
jest.mock('../lock-paywall', () => 'LockPaywall');
|
||||
|
||||
jest.mock('@src/generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useContext: jest.fn(v => v),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useExamAccess: jest.fn(),
|
||||
useShouldDisplayHonorCode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./urls', () => ({
|
||||
getIFrameUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const props = {
|
||||
const defaultProps = {
|
||||
courseId: 'test-course-id',
|
||||
format: 'test-format',
|
||||
onLoaded: jest.fn().mockName('props.onLoaded'),
|
||||
id: 'test-props-id',
|
||||
isStaff: false,
|
||||
id: 'unit-id',
|
||||
isOriginalUserStaff: false,
|
||||
isEnabledOutlineSidebar: false,
|
||||
renderUnitNavigation: jest.fn(enabled => enabled && 'UnitNaviagtion'),
|
||||
};
|
||||
|
||||
const context = { authenticatedUser: { test: 'user' } };
|
||||
React.useContext.mockReturnValue(context);
|
||||
|
||||
const examAccess = {
|
||||
accessToken: 'test-token',
|
||||
blockAccess: false,
|
||||
};
|
||||
hooks.useExamAccess.mockReturnValue(examAccess);
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValue(false);
|
||||
|
||||
const unit = {
|
||||
id: 'unit-id',
|
||||
title: 'unit-title',
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'pending',
|
||||
};
|
||||
const mockCoursewareMetaFn = jest.fn(() => ({ wholeCourseTranslationEnabled: false }));
|
||||
const mockUnitsFn = jest.fn(() => unit);
|
||||
|
||||
when(useModel)
|
||||
.calledWith('courseHomeMeta', props.courseId)
|
||||
.mockImplementation(mockCoursewareMetaFn)
|
||||
.calledWith(modelKeys.units, props.id)
|
||||
.mockImplementation(mockUnitsFn);
|
||||
let store;
|
||||
|
||||
let el;
|
||||
describe('Unit component', () => {
|
||||
const searchParams = { get: (prop) => prop };
|
||||
const setSearchParams = jest.fn();
|
||||
const renderComponent = (props) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[{ pathname: `/course/${props.courseID}` }]}>
|
||||
<Unit {...props} />
|
||||
</MemoryRouter>,
|
||||
{ store, wrapWithRouter: false },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
|
||||
useLocation.mockImplementation(() => ({ pathname: `/course/${props.courseId}` }));
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<Unit {...props} />);
|
||||
initializeMockApp();
|
||||
|
||||
async function setupStoreState() {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const unitBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical', ...unit },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
|
||||
store = await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
}
|
||||
|
||||
describe('<Unit />', () => {
|
||||
beforeEach(async () => {
|
||||
await setupStoreState();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
expect(hooks.useShouldDisplayHonorCode).toHaveBeenCalledWith({
|
||||
courseId: props.courseId,
|
||||
id: props.id,
|
||||
});
|
||||
|
||||
describe('unit title', () => {
|
||||
it('has two children', () => {
|
||||
renderComponent(defaultProps);
|
||||
const unitTitleWrapper = screen.getByTestId('unit_title_slot').children[0];
|
||||
|
||||
expect(unitTitleWrapper.children).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders bookmark button', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
expect(screen.getByText('Bookmark this page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render unit navigation buttons', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const nextButton = screen.queryByText('UnitNaviagtion');
|
||||
|
||||
expect(nextButton).toBeNull();
|
||||
});
|
||||
|
||||
it('renders unit navigation buttons when isEnabledOutlineSidebar is true', () => {
|
||||
const props = { ...defaultProps, isEnabledOutlineSidebar: true };
|
||||
renderComponent(props);
|
||||
|
||||
const nextButton = screen.getByText('UnitNaviagtion');
|
||||
|
||||
expect(nextButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
let component;
|
||||
test('snapshot: not bookmarked, do not show content', () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
expect(el.snapshot).toMatchSnapshot();
|
||||
|
||||
describe('UnitSuspense', () => {
|
||||
it('renders loading message', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
expect(screen.getByText('Loading', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
describe('BookmarkButton props', () => {
|
||||
const renderComponent = () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
[component] = el.instance.findByType(BookmarkButton);
|
||||
};
|
||||
describe('not bookmarked, bookmark update loading', () => {
|
||||
beforeEach(() => {
|
||||
useModel.mockReturnValueOnce({ ...unit, bookmarkedUpdateState: 'loading' });
|
||||
renderComponent();
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(component.snapshot).toMatchSnapshot();
|
||||
});
|
||||
test('props', () => {
|
||||
expect(component.props.isBookmarked).toEqual(false);
|
||||
expect(component.props.isProcessing).toEqual(true);
|
||||
expect(component.props.unitId).toEqual(unit.id);
|
||||
});
|
||||
});
|
||||
describe('bookmarked, bookmark update pending', () => {
|
||||
beforeEach(() => {
|
||||
mockUnitsFn.mockReturnValueOnce({ ...unit, bookmarked: true });
|
||||
renderComponent();
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(component.snapshot).toMatchSnapshot();
|
||||
});
|
||||
test('props', () => {
|
||||
expect(component.props.isBookmarked).toEqual(true);
|
||||
expect(component.props.isProcessing).toEqual(false);
|
||||
expect(component.props.unitId).toEqual(unit.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentIFrame', () => {
|
||||
let iframe;
|
||||
beforeEach(() => {
|
||||
renderComponent(defaultProps);
|
||||
iframe = screen.getByTestId('content-iframe-test-id');
|
||||
});
|
||||
test('UnitSuspense props', () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
[component] = el.instance.findByType(UnitSuspense);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
expect(component.props.id).toEqual(props.id);
|
||||
|
||||
it('renders content iframe', () => {
|
||||
expect(iframe).toBeVisible();
|
||||
});
|
||||
describe('ContentIFrame props', () => {
|
||||
const testComponentProps = () => {
|
||||
expect(component.props.elementId).toEqual('unit-iframe');
|
||||
expect(component.props.id).toEqual(props.id);
|
||||
expect(component.props.loadingMessage).toEqual(formatMessage(messages.loadingSequence));
|
||||
expect(component.props.onLoaded).toEqual(props.onLoaded);
|
||||
expect(component.props.title).toEqual(unit.title);
|
||||
};
|
||||
const loadComponent = () => {
|
||||
el = shallow(<Unit {...props} />);
|
||||
[component] = el.instance.findByType(ContentIFrame);
|
||||
};
|
||||
describe('shouldShowContent', () => {
|
||||
test('do not show content if displaying honor code', () => {
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.shouldShowContent).toEqual(false);
|
||||
});
|
||||
test('do not show content if examAccess is blocked', () => {
|
||||
hooks.useExamAccess.mockReturnValueOnce({ ...examAccess, blockAccess: true });
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.shouldShowContent).toEqual(false);
|
||||
});
|
||||
test('show content if not displaying honor code or blocked by exam access', () => {
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.shouldShowContent).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('iframeUrl', () => {
|
||||
test('loads iframe url with student view if authenticated user', () => {
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: views.student,
|
||||
format: props.format,
|
||||
examAccess,
|
||||
}));
|
||||
});
|
||||
test('loads iframe url with public view if no authenticated user', () => {
|
||||
React.useContext.mockReturnValueOnce({});
|
||||
loadComponent();
|
||||
testComponentProps();
|
||||
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: views.public,
|
||||
format: props.format,
|
||||
examAccess,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('generates correct iframeUrl', () => {
|
||||
expect(iframe.getAttribute('src')).toEqual(getIFrameUrl({
|
||||
id: defaultProps.id,
|
||||
view: views.student,
|
||||
format: defaultProps.format,
|
||||
examAccess: {
|
||||
accessToken: '',
|
||||
blockAccess: false,
|
||||
},
|
||||
jumpToId: null,
|
||||
preview: 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
### Slot ID: `unit_title_slot`
|
||||
### Props:
|
||||
* `courseId`
|
||||
* `unitId`
|
||||
* `unitTitle`
|
||||
* `unit`
|
||||
* `isEnabledOutlineSidebar`
|
||||
* `renderUnitNavigation`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used for adding content after the Unit title.
|
||||
This slot is used for adding content before or after the Unit title.
|
||||
|
||||
## Example
|
||||
|
||||
The following `env.config.jsx` will render the `course_id`, `unit_id` and `unitTitle` of the course as `<p>` elements.
|
||||
The following `env.config.jsx` will render `unit_id` and `unitTitle` of the course as `<p>` elements.
|
||||
|
||||

|
||||

|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
@@ -29,11 +30,11 @@ const config = {
|
||||
widget: {
|
||||
id: 'custom_unit_title_content',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: ({courseId, unitId, unitTitle}) => (
|
||||
RenderWidget: ({ unitId, unit, isEnabledOutlineSidebar, renderUnitNavigation }) => (
|
||||
<>
|
||||
<p>📚: {courseId}</p>
|
||||
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
|
||||
<p>📙: {unit.title}</p>
|
||||
<p>📙: {unitId}</p>
|
||||
<p>📙: {unitTitle}</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
BIN
src/plugin-slots/UnitTitleSlot/images/screenshot_custom.png
Normal file
BIN
src/plugin-slots/UnitTitleSlot/images/screenshot_custom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -1,21 +1,55 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const UnitTitleSlot = ({ courseId, unitId, unitTitle }) => (
|
||||
<PluginSlot
|
||||
id="unit_title_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
unitId,
|
||||
unitTitle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
import { BookmarkButton } from '@src/courseware/course/bookmark';
|
||||
import messages from '@src/courseware/course/sequence/messages';
|
||||
|
||||
const UnitTitleSlot = ({
|
||||
unitId,
|
||||
unit,
|
||||
isEnabledOutlineSidebar,
|
||||
renderUnitNavigation,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const isProcessing = unit.bookmarkedUpdateState === 'loading';
|
||||
|
||||
return (
|
||||
<PluginSlot
|
||||
id="unit_title_slot"
|
||||
pluginProps={{
|
||||
unitId,
|
||||
unit,
|
||||
isEnabledOutlineSidebar,
|
||||
renderUnitNavigation,
|
||||
}}
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<div className="mb-0">
|
||||
<h3 className="h3">{unit.title}</h3>
|
||||
</div>
|
||||
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
|
||||
</div>
|
||||
<p className="sr-only">{formatMessage(messages.headerPlaceholder)}</p>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
</PluginSlot>
|
||||
);
|
||||
};
|
||||
|
||||
UnitTitleSlot.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitTitle: PropTypes.string.isRequired,
|
||||
unit: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
bookmarked: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
bookmarkedUpdateState: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
isEnabledOutlineSidebar: PropTypes.bool.isRequired,
|
||||
renderUnitNavigation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default UnitTitleSlot;
|
||||
|
||||
Reference in New Issue
Block a user