feat: add a button to return to studio (#322)

This commit is contained in:
Kristin Aoki
2023-05-03 10:35:02 -04:00
committed by GitHub
parent 6aaedfc500
commit c49779a293
12 changed files with 453 additions and 84 deletions

View File

@@ -14,8 +14,13 @@ export const EditorPage = ({
studioEndpointUrl,
onClose,
}) => (
<ErrorBoundary>
<Provider store={store}>
<Provider store={store}>
<ErrorBoundary
{...{
learningContextId: courseId,
studioEndpointUrl,
}}
>
<Editor
{...{
onClose,
@@ -26,8 +31,8 @@ export const EditorPage = ({
studioEndpointUrl,
}}
/>
</Provider>
</ErrorBoundary>
</ErrorBoundary>
</Provider>
);
EditorPage.defaultProps = {
blockId: null,

View File

@@ -12,6 +12,11 @@ const props = {
};
jest.mock('react-redux', () => ({
Provider: 'Provider',
connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
mapStateToProps,
mapDispatchToProps,
component,
}),
}));
jest.mock('./Editor', () => 'Editor');

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import ErrorBoundary from './sharedComponents/ErrorBoundary';
import { VideoSelector } from './VideoSelector';
import VideoSelector from './VideoSelector';
import store from './data/store';
const VideoSelectorPage = ({
@@ -10,8 +10,13 @@ const VideoSelectorPage = ({
lmsEndpointUrl,
studioEndpointUrl,
}) => (
<ErrorBoundary>
<Provider store={store}>
<Provider store={store}>
<ErrorBoundary
{...{
learningContextId: courseId,
studioEndpointUrl,
}}
>
<VideoSelector
{...{
learningContextId: courseId,
@@ -19,8 +24,8 @@ const VideoSelectorPage = ({
studioEndpointUrl,
}}
/>
</Provider>
</ErrorBoundary>
</ErrorBoundary>
</Provider>
);
VideoSelectorPage.defaultProps = {

View File

@@ -10,6 +10,11 @@ const props = {
jest.mock('react-redux', () => ({
Provider: 'Provider',
connect: (mapStateToProps, mapDispatchToProps) => (component) => ({
mapStateToProps,
mapDispatchToProps,
component,
}),
}));
jest.mock('./VideoSelector', () => 'VideoSelector');

View File

@@ -1,17 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor Page snapshots props besides blockType default to null 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId={null}
studioEndpointUrl={null}
>
<Editor
blockId={null}
@@ -21,22 +24,25 @@ exports[`Editor Page snapshots props besides blockType default to null 1`] = `
onClose={null}
studioEndpointUrl={null}
/>
</Provider>
</ErrorBoundary>
</ErrorBoundary>
</Provider>
`;
exports[`Editor Page snapshots rendering correctly with expected Input 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId="course-v1:edX+DemoX+Demo_Course"
studioEndpointUrl="fakeurl.com"
>
<Editor
blockId="block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
@@ -46,6 +52,6 @@ exports[`Editor Page snapshots rendering correctly with expected Input 1`] = `
onClose={[MockFunction props.onClose]}
studioEndpointUrl="fakeurl.com"
/>
</Provider>
</ErrorBoundary>
</ErrorBoundary>
</Provider>
`;

View File

@@ -1,45 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Video Selector Page snapshots rendering correctly with expected Input 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId="course-v1:edX+DemoX+Demo_Course"
studioEndpointUrl="fakeurl.com"
>
<Component
<VideoSelector
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
studioEndpointUrl="fakeurl.com"
/>
</Provider>
</ErrorBoundary>
</ErrorBoundary>
</Provider>
`;
exports[`Video Selector Page snapshots rendering with props to null 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<ErrorBoundary
learningContextId={null}
studioEndpointUrl={null}
>
<Component
<VideoSelector
learningContextId={null}
lmsEndpointUrl={null}
studioEndpointUrl={null}
/>
</Provider>
</ErrorBoundary>
</ErrorBoundary>
</Provider>
`;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import {
Button, Container, Row, Col,
@@ -6,6 +7,8 @@ import {
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { navigateTo } from '../../hooks';
import { selectors } from '../../data/redux';
/**
* An error page that displays a generic message for unexpected errors. Also contains a "Try
@@ -13,36 +16,74 @@ import messages from './messages';
*/
export const ErrorPage = ({
message,
studioEndpointUrl,
learningContextId,
// redux
unitData,
// injected
intl,
}) => (
<Container fluid className="py-5 justify-content-center align-items-start text-center">
<Row>
<Col>
<p className="text-muted">
{intl.formatMessage(messages.unexpectedError)}
</p>
{message && (
<div role="alert" className="my-4">
<p>{message}</p>
</div>
)}
<Button onClick={global.location.reload()}>
{intl.formatMessage(messages.unexpectedErrorButtonLabel)}
</Button>
</Col>
</Row>
</Container>
);
}) => {
const outlineType = learningContextId?.startsWith('library-v1') ? 'library' : 'course';
const outlineUrl = `${studioEndpointUrl}/${outlineType}/${learningContextId}`;
const unitUrl = unitData?.data ? `${studioEndpointUrl}/container/${unitData?.data.ancestors[0].id}` : null;
return (
<Container fluid className="py-5 justify-content-center align-items-start text-center">
<Row>
<Col>
<p className="text-muted">
{intl.formatMessage(messages.unexpectedError)}
</p>
{message && (
<div role="alert" className="my-4">
<p>{message}</p>
</div>
)}
<Row className="justify-content-center">
{learningContextId && (unitUrl && outlineType !== 'library' ? (
<Button className="mr-2" variant="outline-primary" onClick={() => navigateTo(unitUrl)}>
{intl.formatMessage(messages.returnToUnitPageLabel)}
</Button>
) : (
<Button className="mr-2" variant="outline-primary" onClick={() => navigateTo(outlineUrl)}>
{intl.formatMessage(messages.returnToOutlineLabel, { outlineType })}
</Button>
))}
<Button className="ml-2" onClick={() => global.location.reload()}>
{intl.formatMessage(messages.unexpectedErrorButtonLabel)}
</Button>
</Row>
</Col>
</Row>
</Container>
);
};
ErrorPage.propTypes = {
message: PropTypes.string,
learningContextId: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,
// redux
unitData: PropTypes.shape({
data: PropTypes.shape({
ancestors: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
}),
),
}),
}),
// injected
intl: intlShape.isRequired,
};
ErrorPage.defaultProps = {
message: null,
unitData: null,
};
export default injectIntl(ErrorPage);
export const mapStateToProps = (state) => ({
unitData: selectors.app.unitUrl(state),
});
export default injectIntl(connect(mapStateToProps)(ErrorPage));

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { shallow } from 'enzyme';
import { selectors } from '../../data/redux';
import { formatMessage } from '../../../testUtils';
import { ErrorPage, mapStateToProps } from './ErrorPage';
jest.mock('../../data/redux', () => ({
selectors: {
app: {
unitUrl: jest.fn(state => ({ unitUrl: state })),
},
},
}));
describe('Editor Page', () => {
const emptyProps = {
learningContextId: null,
studioEndpointUrl: null,
intl: { formatMessage },
};
const passedProps = {
learningContextId: 'course-v1:edX+DemoX+Demo_Course',
studioEndpointUrl: 'fakeurl.com',
message: 'cUStomMEssagE',
intl: { formatMessage },
};
const unitData = {
data: {
ancestors: [{ id: 'SomeID' }],
},
};
describe('rendered with empty props', () => {
it('should only have one button (try again)', () => {
const wrapper = shallow(<ErrorPage {...emptyProps} />);
const buttonText = wrapper.find('Button').text();
expect(wrapper).toMatchSnapshot();
expect(buttonText).toEqual('Try again');
});
});
describe('rendered with pass through props defined', () => {
const wrapper = shallow(<ErrorPage {...passedProps} />);
describe('shows two buttons', () => {
it('the first button should correspond to returning to the course outline', () => {
const firstButtonText = wrapper.find('Button').at(0).text();
const secondButtonText = wrapper.find('Button').at(1).text();
expect(wrapper).toMatchSnapshot();
expect(firstButtonText).toEqual('Return to course outline');
expect(secondButtonText).toEqual('Try again');
});
it('the first button should correspond to returning to the unit page', () => {
const returnToUnitPageWrapper = shallow(<ErrorPage {...passedProps} unitData={unitData} />);
expect(returnToUnitPageWrapper).toMatchSnapshot();
const firstButtonText = returnToUnitPageWrapper.find('Button').at(0).text();
const secondButtonText = returnToUnitPageWrapper.find('Button').at(1).text();
expect(returnToUnitPageWrapper).toMatchSnapshot();
expect(firstButtonText).toEqual('Return to unit page');
expect(secondButtonText).toEqual('Try again');
});
});
it('should have custom message', () => {
const customMessageText = wrapper.find('div').children().at(0).text();
expect(wrapper).toMatchSnapshot();
expect(customMessageText).toEqual('cUStomMEssagE');
});
});
describe('mapStateToProps() function', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('unitData should equal unitUrl from app.unitUrl', () => {
expect(
mapStateToProps(testState).unitData,
).toEqual(selectors.app.unitUrl(testState));
});
});
});

View File

@@ -0,0 +1,196 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor Page rendered with empty props should only have one button (try again) 1`] = `
<Container
className="py-5 justify-content-center align-items-start text-center"
fluid={true}
>
<Row>
<Col>
<p
className="text-muted"
>
An unexpected error occurred. Please click the button below to refresh the page.
</p>
<Row
className="justify-content-center"
>
<Button
className="ml-2"
onClick={[Function]}
>
Try again
</Button>
</Row>
</Col>
</Row>
</Container>
`;
exports[`Editor Page rendered with pass through props defined should have custom message 1`] = `
<Container
className="py-5 justify-content-center align-items-start text-center"
fluid={true}
>
<Row>
<Col>
<p
className="text-muted"
>
An unexpected error occurred. Please click the button below to refresh the page.
</p>
<div
className="my-4"
role="alert"
>
<p>
cUStomMEssagE
</p>
</div>
<Row
className="justify-content-center"
>
<Button
className="mr-2"
onClick={[Function]}
variant="outline-primary"
>
Return to course outline
</Button>
<Button
className="ml-2"
onClick={[Function]}
>
Try again
</Button>
</Row>
</Col>
</Row>
</Container>
`;
exports[`Editor Page rendered with pass through props defined shows two buttons the first button should correspond to returning to the course outline 1`] = `
<Container
className="py-5 justify-content-center align-items-start text-center"
fluid={true}
>
<Row>
<Col>
<p
className="text-muted"
>
An unexpected error occurred. Please click the button below to refresh the page.
</p>
<div
className="my-4"
role="alert"
>
<p>
cUStomMEssagE
</p>
</div>
<Row
className="justify-content-center"
>
<Button
className="mr-2"
onClick={[Function]}
variant="outline-primary"
>
Return to course outline
</Button>
<Button
className="ml-2"
onClick={[Function]}
>
Try again
</Button>
</Row>
</Col>
</Row>
</Container>
`;
exports[`Editor Page rendered with pass through props defined shows two buttons the first button should correspond to returning to the unit page 1`] = `
<Container
className="py-5 justify-content-center align-items-start text-center"
fluid={true}
>
<Row>
<Col>
<p
className="text-muted"
>
An unexpected error occurred. Please click the button below to refresh the page.
</p>
<div
className="my-4"
role="alert"
>
<p>
cUStomMEssagE
</p>
</div>
<Row
className="justify-content-center"
>
<Button
className="mr-2"
onClick={[Function]}
variant="outline-primary"
>
Return to unit page
</Button>
<Button
className="ml-2"
onClick={[Function]}
>
Try again
</Button>
</Row>
</Col>
</Row>
</Container>
`;
exports[`Editor Page rendered with pass through props defined shows two buttons the first button should correspond to returning to the unit page 2`] = `
<Container
className="py-5 justify-content-center align-items-start text-center"
fluid={true}
>
<Row>
<Col>
<p
className="text-muted"
>
An unexpected error occurred. Please click the button below to refresh the page.
</p>
<div
className="my-4"
role="alert"
>
<p>
cUStomMEssagE
</p>
</div>
<Row
className="justify-content-center"
>
<Button
className="mr-2"
onClick={[Function]}
variant="outline-primary"
>
Return to unit page
</Button>
<Button
className="ml-2"
onClick={[Function]}
>
Try again
</Button>
</Row>
</Col>
</Row>
</Container>
`;

View File

@@ -16,7 +16,9 @@ import ErrorPage from './ErrorPage';
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
@@ -30,7 +32,12 @@ export default class ErrorBoundary extends Component {
render() {
if (this.state.hasError) {
return <ErrorPage />;
return (
<ErrorPage
learningContextId={this.props.learningContextId}
studioEndpointUrl={this.props.studioEndpointUrl}
/>
);
}
return this.props.children;
@@ -39,8 +46,12 @@ export default class ErrorBoundary extends Component {
ErrorBoundary.propTypes = {
children: PropTypes.node,
learningContextId: PropTypes.string,
studioEndpointUrl: PropTypes.string,
};
ErrorBoundary.defaultProps = {
children: null,
learningContextId: null,
studioEndpointUrl: null,
};

View File

@@ -11,7 +11,7 @@ jest.mock('@edx/frontend-platform/logging', () => ({
}));
// stubbing this to avoid needing to inject a stubbed intl into an internal component
jest.mock('./ErrorPage', () => () => <div />);
jest.mock('./ErrorPage', () => () => <p>Error Page</p>);
describe('ErrorBoundary', () => {
it('should render children if no error', () => {
@@ -21,8 +21,9 @@ describe('ErrorBoundary', () => {
</ErrorBoundary>
);
const wrapper = mount(component);
const element = wrapper.find('div');
expect(logError).toHaveBeenCalledTimes(0);
expect(element.text()).toEqual('Yay');
});
@@ -35,8 +36,10 @@ describe('ErrorBoundary', () => {
<ExplodingComponent />
</ErrorBoundary>
);
mount(component);
const wrapper = mount(component);
const element = wrapper.find('p');
expect(logError).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalledWith(new Error('booyah'), { stack: '\n in ExplodingComponent\n in ErrorBoundary (created by WrapperComponent)\n in WrapperComponent' });
expect(element.text()).toEqual('Error Page');
});
});

View File

@@ -11,6 +11,16 @@ const messages = defineMessages({
defaultMessage: 'Try again',
description: 'text for button that tries to reload the app by refreshing the page',
},
returnToUnitPageLabel: {
id: 'unexpected.error.returnToUnitPage.button.text',
defaultMessage: 'Return to unit page',
description: 'Text for button that navigates back to the unit page',
},
returnToOutlineLabel: {
id: 'unexpected.error.returnToCourseOutline.button.text',
defaultMessage: 'Return to {outlineType} outline',
description: 'Text for button that navigates back to the course outline',
},
});
export default messages;