Feat shared widget componentries and layout (#107)

This commit is contained in:
Raymond Zhou
2022-09-07 12:22:27 -04:00
committed by GitHub
parent 720b41193f
commit 54d773a19a
22 changed files with 593 additions and 47 deletions

View File

@@ -41,6 +41,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
"handleSaveClicked": Object {
"dispatch": [MockFunction react-redux.dispatch],
"getContent": [MockFunction props.getContent],
"validateEntry": [MockFunction props.validateEntry],
},
}
}
@@ -86,6 +87,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
"handleSaveClicked": Object {
"dispatch": [MockFunction react-redux.dispatch],
"getContent": [MockFunction props.getContent],
"validateEntry": [MockFunction props.validateEntry],
},
}
}

View File

@@ -23,7 +23,7 @@ export const setAssetToStaticUrl = (images, getContent) => {
imgsArray.forEach(image => {
imageUrls.push({ portableUrl: image.portableUrl, displayName: image.displayName });
});
const imageSrcs = content.split('src="');
const imageSrcs = typeof content === 'string' ? content.split('src="') : [];
imageSrcs.forEach(src => {
if (src.startsWith('/asset') && imageUrls.length > 0) {
const nameFromEditorSrc = src.substring(src.lastIndexOf('@') + 1, src.indexOf('"'));
@@ -44,15 +44,16 @@ export const setAssetToStaticUrl = (images, getContent) => {
return content;
};
export const handleSaveClicked = ({ getContent, dispatch }) => {
export const handleSaveClicked = ({ dispatch, getContent, validateEntry }) => {
const destination = useSelector(selectors.app.returnUrl);
const analytics = useSelector(selectors.app.analytics);
const images = useSelector(selectors.app.images);
return () => saveBlock({
analytics,
content: setAssetToStaticUrl(images, getContent),
destination,
analytics,
dispatch,
validateEntry,
});
};
export const handleCancelClicked = ({ onClose }) => {

View File

@@ -59,12 +59,17 @@ describe('EditorContainer hooks', () => {
it('returns callback to saveBlock with dispatch and content from setAssetToStaticUrl', () => {
const getContent = () => 'myTestContentValue';
const setAssetToStaticUrl = () => 'myTestContentValue';
const validateEntry = () => 'vaLIdAteENTry';
const output = hooks.handleSaveClicked({
getContent,
images: { portableUrl: '/static/sOmEuiMAge.jpeg', displayName: 'sOmEuiMAge' },
images: {
portableUrl: '/static/sOmEuiMAge.jpeg',
displayName: 'sOmEuiMAge',
},
destination: 'testDEsTURL',
analytics: 'soMEanALytics',
dispatch,
validateEntry,
});
output();
expect(appHooks.saveBlock).toHaveBeenCalledWith({
@@ -72,6 +77,7 @@ describe('EditorContainer hooks', () => {
destination: reactRedux.useSelector(selectors.app.returnUrl),
analytics: reactRedux.useSelector(selectors.app.analytics),
dispatch,
validateEntry,
});
});
});

View File

@@ -13,6 +13,7 @@ export const EditorContainer = ({
children,
getContent,
onClose,
validateEntry,
}) => {
const dispatch = useDispatch();
const isInitialized = hooks.isInitialized();
@@ -34,7 +35,7 @@ export const EditorContainer = ({
{isInitialized && children}
<EditorFooter
onCancel={handleCancelClicked}
onSave={hooks.handleSaveClicked({ getContent, dispatch })}
onSave={hooks.handleSaveClicked({ dispatch, getContent, validateEntry })}
disableSave={!isInitialized}
saveFailed={hooks.saveFailed()}
/>
@@ -43,11 +44,13 @@ export const EditorContainer = ({
};
EditorContainer.defaultProps = {
onClose: null,
validateEntry: null,
};
EditorContainer.propTypes = {
getContent: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
getContent: PropTypes.func.isRequired,
onClose: PropTypes.func,
validateEntry: PropTypes.func,
};
export default EditorContainer;

View File

@@ -8,6 +8,7 @@ import * as hooks from './hooks';
const props = {
getContent: jest.fn().mockName('props.getContent'),
onClose: jest.fn().mockName('props.onClose'),
validateEntry: jest.fn().mockName('props.validateEntry'),
};
jest.mock('./hooks', () => ({
@@ -50,8 +51,9 @@ describe('EditorContainer component', () => {
});
test('save behavior is linked to footer onSave', () => {
const expected = hooks.handleSaveClicked({
getContent: props.getContent,
dispatch: useDispatch(),
getContent: props.getContent,
validateEntry: props.validateEntry,
});
expect(el.children().at(2)
.props().onSave).toEqual(expected);

View File

@@ -16,21 +16,31 @@ export const hooks = {
};
const VideoEditorModal = ({
isOpen,
close,
error,
isOpen,
}) => {
const dispatch = useDispatch();
module.hooks.initialize(dispatch);
return (
<VideoSettingsModal {...{ isOpen, close }} />
<VideoSettingsModal {...{ close, error, isOpen }} />
);
// TODO: add logic to show SelectVideoModal if no selection
};
VideoEditorModal.defaultProps = {
error: {
duration: {},
handout: {},
license: {},
thumbnail: {},
transcripts: {},
videoSource: {},
},
};
VideoEditorModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
error: PropTypes.node,
isOpen: PropTypes.bool.isRequired,
};
export default VideoEditorModal;

View File

@@ -1,7 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, IconButton } from '@edx/paragon';
import { ExpandLess, ExpandMore, Info } from '@edx/paragon/icons';
import messages from './messages';
/**
* Simple Wrapper for a Form Widget component in the Video Settings modal
@@ -11,16 +15,56 @@ import { Collapsible } from '@edx/paragon';
* </CollapsibleFormWidget>
*/
export const CollapsibleFormWidget = ({
title,
children,
isError,
subtitle,
title,
// injected
intl,
}) => (
<Collapsible defaultOpen title={title}>
{children}
</Collapsible>
<Collapsible.Advanced
className="collapsible-card rounded mb-3 px-3 py-2"
defaultOpen
open={isError || undefined}
>
<Collapsible.Trigger
className="collapsible-trigger d-flex border-0 align-items-center"
style={{ justifyContent: 'unset' }}
>
<Collapsible.Visible whenClosed>
<div className="d-flex flex-column flex-grow-1">
<div className="d-flex flex-grow-1 w-75">{title}</div>
{subtitle}
</div>
<div className="d-flex flex-row align-self-start">
{isError && <Icon className="alert-icon" src={Info} />}
<IconButton alt={intl.formatMessage(messages.expandAltText)} src={ExpandMore} iconAs={Icon} variant="dark" />
</div>
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<div className="d-flex flex-grow-1 w-75">{title}</div>
<div className="align-self-start">
<IconButton alt={intl.formatMessage(messages.collapseAltText)} src={ExpandLess} iconAs={Icon} variant="dark" />
</div>
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body rounded px-0">
{children}
</Collapsible.Body>
</Collapsible.Advanced>
);
CollapsibleFormWidget.propTypes = {
title: PropTypes.node.isRequired,
children: PropTypes.node.isRequired,
CollapsibleFormWidget.defaultProps = {
subtitle: null,
};
export default CollapsibleFormWidget;
CollapsibleFormWidget.propTypes = {
children: PropTypes.node.isRequired,
isError: PropTypes.bool.isRequired,
subtitle: PropTypes.node,
title: PropTypes.node.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(CollapsibleFormWidget);

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../testUtils';
import { CollapsibleFormWidget } from './CollapsibleFormWidget';
describe('CollapsibleFormWidget', () => {
const props = {
isError: false,
subtitle: 'SuBTItle',
title: 'tiTLE',
// inject
intl: { formatMessage },
};
describe('render', () => {
const testContent = (<p>Some test string</p>);
test('snapshots: renders as expected with default props', () => {
expect(
shallow(<CollapsibleFormWidget {...props}>{testContent}</CollapsibleFormWidget>),
).toMatchSnapshot();
});
test('snapshots: renders with open={true} when there is error', () => {
expect(
shallow(<CollapsibleFormWidget {...props} isError>{testContent}</CollapsibleFormWidget>),
).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';
export const ErrorSummary = ({
error,
}) => (
<Alert
icon={Info}
show={!Object.values(error).every(val => Object.keys(val).length === 0)}
variant="danger"
>
<Alert.Heading>
<FormattedMessage {...messages.validateErrorTitle} />
</Alert.Heading>
<p>
<FormattedMessage {...messages.validateErrorBody} />
</p>
</Alert>
);
ErrorSummary.defaultProps = {
error: {
duration: {},
handout: {},
license: {},
thumbnail: {},
transcripts: {},
videoSource: {},
},
};
ErrorSummary.propTypes = {
error: PropTypes.node,
};
export default ErrorSummary;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ErrorSummary } from './ErrorSummary';
describe('ErrorSummary', () => {
const props = {
error: 'eRrOr',
};
describe('render', () => {
test('snapshots: renders as expected', () => {
expect(
shallow(<ErrorSummary {...props} />),
).toMatchSnapshot();
});
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useDispatch } from 'react-redux';
// import PropTypes from 'prop-types';
import PropTypes from 'prop-types';
import hooks from './hooks';
import CollapsibleFormWidget from './CollapsibleFormWidget';
@@ -8,7 +8,9 @@ import CollapsibleFormWidget from './CollapsibleFormWidget';
/**
* Collapsible Form widget controlling video transcripts
*/
export const TranscriptWidget = () => {
export const TranscriptWidget = ({
error,
}) => {
const dispatch = useDispatch();
const values = hooks.widgetValues({
dispatch,
@@ -23,8 +25,16 @@ export const TranscriptWidget = () => {
allowTranscriptDownloads: allowDownload,
showTranscriptByDefault: showByDefault,
} = values;
// TODO: replace the following sample subtitle input with one managed by hook logic
const sampleSubtitle = <div>{transcripts.formValue.english}</div>;
return (
<CollapsibleFormWidget title="Transcript">
<CollapsibleFormWidget
isError={Object.keys(error).length !== 0}
subtitle={sampleSubtitle}
title="Transcript"
>
<b>Transcripts</b>
<p>English: {transcripts.formValue.english}</p>
<p><b>Allow downloads:</b> {allowDownload.formValue ? 'True' : 'False' }</p>
@@ -33,4 +43,11 @@ export const TranscriptWidget = () => {
);
};
TranscriptWidget.defaultProps = {
error: {},
};
TranscriptWidget.propTypes = {
error: PropTypes.node,
};
export default TranscriptWidget;

View File

@@ -34,7 +34,9 @@ export const VideoSourceWidget = () => {
});
return (
<CollapsibleFormWidget title="Video source">
<CollapsibleFormWidget
title="Video source"
>
<FormGroup size="sm">
<div className="border-primary-100 border-bottom pb-4">
<FormLabel size="sm">Video ID or URL</FormLabel>

View File

@@ -0,0 +1,135 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CollapsibleFormWidget render snapshots: renders as expected with default props 1`] = `
<Advanced
className="collapsible-card rounded mb-3 px-3 py-2"
defaultOpen={true}
>
<Trigger
className="collapsible-trigger d-flex border-0 align-items-center"
style={
Object {
"justifyContent": "unset",
}
}
>
<Visible
whenClosed={true}
>
<div
className="d-flex flex-column flex-grow-1"
>
<div
className="d-flex flex-grow-1 w-75"
>
tiTLE
</div>
SuBTItle
</div>
<div
className="d-flex flex-row align-self-start"
>
<IconButton
alt="Expand"
iconAs="Icon"
variant="dark"
/>
</div>
</Visible>
<Visible
whenOpen={true}
>
<div
className="d-flex flex-grow-1 w-75"
>
tiTLE
</div>
<div
className="align-self-start"
>
<IconButton
alt="Collapse"
iconAs="Icon"
variant="dark"
/>
</div>
</Visible>
</Trigger>
<Body
className="collapsible-body rounded px-0"
>
<p>
Some test string
</p>
</Body>
</Advanced>
`;
exports[`CollapsibleFormWidget render snapshots: renders with open={true} when there is error 1`] = `
<Advanced
className="collapsible-card rounded mb-3 px-3 py-2"
defaultOpen={true}
open={true}
>
<Trigger
className="collapsible-trigger d-flex border-0 align-items-center"
style={
Object {
"justifyContent": "unset",
}
}
>
<Visible
whenClosed={true}
>
<div
className="d-flex flex-column flex-grow-1"
>
<div
className="d-flex flex-grow-1 w-75"
>
tiTLE
</div>
SuBTItle
</div>
<div
className="d-flex flex-row align-self-start"
>
<Icon
className="alert-icon"
/>
<IconButton
alt="Expand"
iconAs="Icon"
variant="dark"
/>
</div>
</Visible>
<Visible
whenOpen={true}
>
<div
className="d-flex flex-grow-1 w-75"
>
tiTLE
</div>
<div
className="align-self-start"
>
<IconButton
alt="Collapse"
iconAs="Icon"
variant="dark"
/>
</div>
</Visible>
</Trigger>
<Body
className="collapsible-body rounded px-0"
>
<p>
Some test string
</p>
</Body>
</Advanced>
`;

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ErrorSummary render snapshots: renders as expected 1`] = `
<Alert
show={true}
variant="danger"
>
<Alert.Heading>
<FormattedMessage
defaultMessage="We couldn't add your video."
description="Title of validation error."
id="authoring.videoeditor.validate.error.title"
/>
</Alert.Heading>
<p>
<FormattedMessage
defaultMessage="Please check your entries and try again."
description="Body of validation error."
id="authoring.videoeditor.validate.error.body"
/>
</p>
</Alert>
`;

View File

@@ -205,18 +205,18 @@ export const arrayWidget = ({ dispatch, key }) => {
const widget = module.valueHooks({ dispatch, key });
return {
...widget,
onChange: handleIndexTransformEvent({
handler: onValue,
setter: widget.setLocal,
transform: module.updatedArray,
local: widget.local,
}),
onBlur: handleIndexTransformEvent({
handler: onValue,
setter: widget.setAll,
transform: module.updatedArray,
local: widget.local,
}),
onChange: handleIndexTransformEvent({
handler: onValue,
setter: widget.setLocal,
transform: module.updatedArray,
local: widget.local,
}),
onClear: (index) => () => widget.setAll(module.updatedArray(widget.local, index, '')),
};
};

View File

@@ -0,0 +1,22 @@
export const messages = {
expandAltText: {
id: 'authoring.videoeditor.expand',
defaultMessage: 'Expand',
},
collapseAltText: {
id: 'authoring.videoeditor.collapse',
defaultMessage: 'Collapse',
},
validateErrorTitle: {
id: 'authoring.videoeditor.validate.error.title',
defaultMessage: 'We couldn\'t add your video.',
description: 'Title of validation error.',
},
validateErrorBody: {
id: 'authoring.videoeditor.validate.error.body',
defaultMessage: 'Please check your entries and try again.',
description: 'Body of validation error.',
},
};
export default messages;

View File

@@ -1,7 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { thunkActions } from '../../../../data/redux';
// import VideoPreview from './components/VideoPreview';
import ErrorSummary from './components/ErrorSummary';
import DurationWidget from './components/DurationWidget';
import HandoutWidget from './components/HandoutWidget';
import LicenseWidget from './components/LicenseWidget';
@@ -18,24 +20,39 @@ export const hooks = {
},
};
export const VideoSettingsModal = () => (
export const VideoSettingsModal = ({
error,
}) => (
<div className="video-settings-modal row">
<div className="video-preview col col-4">
Video Preview goes here
{/* <VideoPreview /> */}
</div>
<div className="video-controls col col-8">
<VideoSourceWidget />
<ThumbnailWidget />
<TranscriptsWidget />
<DurationWidget />
<HandoutWidget />
<LicenseWidget />
<ErrorSummary {...{ error }} />
<h3>Settings</h3>
<VideoSourceWidget error={error.videoSource} />
<ThumbnailWidget error={error.thumbnail} />
<TranscriptsWidget error={error.transcripts} />
<DurationWidget error={error.duration} />
<HandoutWidget error={error.handout} />
<LicenseWidget error={error.license} />
</div>
</div>
);
VideoSettingsModal.defaultProps = {
error: {
duration: {},
handout: {},
license: {},
thumbnail: {},
transcripts: {},
videoSource: {},
},
};
VideoSettingsModal.propTypes = {
error: PropTypes.node,
};
export default VideoSettingsModal;

View File

@@ -0,0 +1,80 @@
import { useState } from 'react';
import { StrictDict } from '../../utils';
import * as module from './hooks';
export const state = StrictDict({
durationErrors: (val) => useState(val),
handoutErrors: (val) => useState(val),
licenseErrors: (val) => useState(val),
thumbnailErrors: (val) => useState(val),
transcriptsErrors: (val) => useState(val),
videoSourceErrors: (val) => useState(val),
});
export const errorsHook = () => {
const [durationErrors, setDurationErrors] = module.state.durationErrors({});
const [handoutErrors, setHandoutErrors] = module.state.handoutErrors({});
const [licenseErrors, setLicenseErrors] = module.state.licenseErrors({});
const [thumbnailErrors, setThumbnailErrors] = module.state.thumbnailErrors({});
const [transcriptsErrors, setTranscriptsErrors] = module.state.transcriptsErrors({});
const [videoSourceErrors, setVideoSourceErrors] = module.state.videoSourceErrors({});
return {
error: {
duration: durationErrors,
handout: handoutErrors,
license: licenseErrors,
thumbnail: thumbnailErrors,
transcripts: transcriptsErrors,
videoSource: videoSourceErrors,
},
validateEntry: () => {
let validated = true;
if (!module.validateDuration({ setDurationErrors })) { validated = false; }
if (!module.validateHandout({ setHandoutErrors })) { validated = false; }
if (!module.validateLicense({ setLicenseErrors })) { validated = false; }
if (!module.validateThumbnail({ setThumbnailErrors })) { validated = false; }
if (!module.validateTranscripts({ setTranscriptsErrors })) { validated = false; }
if (!module.validateVideoSource({ setVideoSourceErrors })) { validated = false; }
return validated;
},
};
};
export const validateDuration = ({ setDurationErrors }) => {
setDurationErrors({
fieldName: 'sample error message',
});
return false;
};
export const validateHandout = ({ setHandoutErrors }) => {
setHandoutErrors({
fieldName: 'sample error message',
});
return false;
};
export const validateLicense = ({ setLicenseErrors }) => {
setLicenseErrors({
fieldName: 'sample error message',
});
return false;
};
export const validateThumbnail = ({ setThumbnailErrors }) => {
setThumbnailErrors({
fieldName: 'sample error message',
});
return false;
};
export const validateTranscripts = ({ setTranscriptsErrors }) => {
setTranscriptsErrors({
fieldName: 'sample error message',
});
return false;
};
export const validateVideoSource = ({ setVideoSourceErrors }) => {
setVideoSourceErrors({
fieldName: 'sample error message',
});
return false;
};

View File

@@ -0,0 +1,72 @@
import { MockUseState } from '../../../testUtils';
import { keyStore } from '../../utils';
import * as module from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
}));
const state = new MockUseState(module);
const moduleKeys = keyStore(module);
let hook;
describe('VideoEditorHooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.durationErrors);
state.testGetter(state.keys.handoutErrors);
state.testGetter(state.keys.licenseErrors);
state.testGetter(state.keys.thumbnailErrors);
state.testGetter(state.keys.transcriptsErrors);
state.testGetter(state.keys.videoSourceErrors);
});
describe('errors hook', () => {
beforeEach(() => {
state.mock();
});
afterEach(() => {
state.restore();
});
const mockTrue = () => true;
const mockFalse = () => false;
test('error: state values', () => {
expect(module.errorsHook().error).toEqual({
duration: state.stateVals[state.keys.durationErrors],
handout: state.stateVals[state.keys.handoutErrors],
license: state.stateVals[state.keys.licenseErrors],
thumbnail: state.stateVals[state.keys.thumbnailErrors],
transcripts: state.stateVals[state.keys.transcriptsErrors],
videoSource: state.stateVals[state.keys.videoSourceErrors],
});
});
describe('validateEntry', () => {
beforeEach(() => {
hook = module.errorsHook();
});
test('validateEntry: returns true if all validation calls are true', () => {
jest.spyOn(module, moduleKeys.validateDuration).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateHandout).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateLicense).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateThumbnail).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateTranscripts).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateVideoSource).mockImplementationOnce(mockTrue);
expect(hook.validateEntry()).toEqual(true);
});
test('validateEntry: returns false if any validation calls are false', () => {
jest.spyOn(module, moduleKeys.validateDuration).mockImplementationOnce(mockFalse);
jest.spyOn(module, moduleKeys.validateHandout).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateLicense).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateThumbnail).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateTranscripts).mockImplementationOnce(mockTrue);
jest.spyOn(module, moduleKeys.validateVideoSource).mockImplementationOnce(mockTrue);
expect(hook.validateEntry()).toEqual(false);
});
});
});
});

View File

@@ -3,21 +3,29 @@ import PropTypes from 'prop-types';
import EditorContainer from '../EditorContainer';
import VideoEditorModal from './components/VideoEditorModal';
import * as hooks from './hooks';
export default function VideoEditor({
onClose,
}) {
const {
error,
validateEntry,
} = hooks.errorsHook();
return (
<EditorContainer
onClose={onClose}
getContent={() => ({})}
onClose={onClose}
validateEntry={validateEntry}
>
<div className="video-editor">
<VideoEditorModal />
<VideoEditorModal {...{ error }} />
</div>
</EditorContainer>
);
}
VideoEditor.defaultProps = {
onClose: null,
};

View File

@@ -29,17 +29,28 @@ export const navigateCallback = ({
export const nullMethod = () => ({});
export const saveBlock = ({
analytics,
content,
destination,
analytics,
dispatch,
validateEntry,
}) => {
dispatch(thunkActions.app.saveBlock({
returnToUnit: module.navigateCallback({
destination,
analyticsEvent: analyticsEvt.editorSaveClick,
analytics,
}),
content,
}));
let attemptSave = false;
if (validateEntry) {
if (validateEntry()) {
attemptSave = true;
}
} else {
attemptSave = true;
}
if (attemptSave) {
dispatch(thunkActions.app.saveBlock({
returnToUnit: module.navigateCallback({
destination,
analyticsEvent: analyticsEvt.editorSaveClick,
analytics,
}),
content,
}));
}
};

View File

@@ -67,6 +67,12 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Spacer: 'ActionRow.Spacer',
},
Button: 'Button',
Collapsible: {
Advanced: 'Advanced',
Body: 'Body',
Trigger: 'Trigger',
Visible: 'Visible',
},
Dropdown: {
Item: 'Dropdown.Item',
Menu: 'Dropdown.Menu',