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:
Kristin Aoki
2025-03-24 11:46:02 -04:00
committed by GitHub
parent 6ab0deb7b7
commit dae1d63e23
7 changed files with 152 additions and 281 deletions

View File

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

View File

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

View File

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

View File

@@ -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.
![Screenshot of Content added after the Unit Title](./images/post_unit_title.png)
![Screenshot of Content added before and after the Unit Title](./images/screenshot_custom.png)
```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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

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