Feat: Add Request Alerts and upload file (#43)

Fixes to Upload data type, as well as adding in of two error alerts for upload and fetch.
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
Co-authored-by: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com
This commit is contained in:
connorhaugh
2022-04-04 08:33:31 -04:00
committed by GitHub
parent 28516a0389
commit fd35c1cb18
21 changed files with 422 additions and 23626 deletions

23633
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@
"homepage": "https://github.com/edx/frontend-lib-content-components#readme",
"devDependencies": {
"@edx/frontend-build": "9.1.1",
"@edx/frontend-platform": "1.15.2",
"@edx/frontend-platform": "1.15.5",
"@edx/paragon": "19.6.0",
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "12.1.1",
@@ -73,7 +73,7 @@
},
"peerDependencies": {
"@edx/paragon": ">= 7.0.0 < 20.0.0",
"@edx/frontend-platform": "1.14.0",
"@edx/frontend-platform": ">1.15.0",
"prop-types": "^15.5.10",
"react": "^16.14.0",
"react-dom": "^16.14.0"

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from '../SelectImageModal/messages';
export const hooks = {
state: {
isDismissed: (val) => React.useState(val),
},
dismissalHooks: ({ isError }) => {
const [isDismissed, setIsDismissed] = hooks.state.isDismissed(false);
React.useEffect(() => {
setIsDismissed(isDismissed && !isError);
},
[isError]);
return {
isDismissed,
dismissAlert: () => setIsDismissed(true),
};
},
};
export const ErrorAlert = ({
isError,
children,
}) => {
const { isDismissed, dismissAlert } = hooks.dismissalHooks({ isError });
if (!isError || isDismissed) {
return null;
}
return (
<Alert
variant="danger"
icon={Info}
dismissible
onClose={dismissAlert}
>
<Alert.Heading>
<FormattedMessage
{...messages.errorTitle}
/>
</Alert.Heading>
{children}
</Alert>
);
};
ErrorAlert.propTypes = {
isError: PropTypes.bool.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
export default ErrorAlert;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { shallow } from 'enzyme';
import * as module from './ErrorAlert';
import { MockUseState } from '../../../../../testUtils';
const { ErrorAlert } = module;
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn(val => ({ current: val })),
useEffect: jest.fn(),
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
const state = new MockUseState(module.hooks);
let hook;
const testValue = 'testVALUE';
describe('ErrorAlert component', () => {
describe('Hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.isDismissed);
});
describe('using state', () => {
beforeEach(() => { state.mock(); });
afterEach(() => { state.restore(); });
describe('dismissalHooks', () => {
beforeEach(() => {
hook = module.hooks.dismissalHooks({ isError: testValue });
});
it('returns isDismissed value, initialized to false', () => {
expect(state.stateVals.isDismissed).toEqual(hook.isDismissed);
});
test('dismissAlert sets isDismissed to true', () => {
hook.dismissAlert();
expect(state.setState.isDismissed).toHaveBeenCalledWith(true);
});
test('On Render, calls setIsDismissed', () => {
expect(React.useEffect.mock.calls.length).toEqual(1);
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs[0]).toEqual(testValue);
cb();
expect(state.setState.isDismissed).toHaveBeenCalledWith(state.stateVals.isDismissed && !testValue);
});
});
});
});
describe('Component', () => {
describe('Snapshots', () => {
let props;
beforeAll(() => {
props = {
isError: false,
};
jest.spyOn(module.hooks, 'dismissalHooks').mockImplementation((value) => ({ isError: value }));
});
afterAll(() => {
jest.clearAllMocks();
});
test('snapshot: is Null when no error (ErrorAlert)', () => {
expect(shallow(<ErrorAlert {...props}> <p> An Error Message </p></ErrorAlert>)).toMatchSnapshot();
});
test('snapshot: Loads children and component when error (ErrorAlert)', () => {
expect(shallow(<ErrorAlert {...props} isError> <p> An Error Message </p> </ErrorAlert>)).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from '../SelectImageModal/messages';
import ErrorAlert from './ErrorAlert';
import { selectors } from '../../../../data/redux';
import { RequestKeys } from '../../../../data/constants/requests';
export const FetchErrorAlert = ({
// redux
isFetchError,
// inject
}) => (
<ErrorAlert
isError={isFetchError}
>
<FormattedMessage
{...messages.fetchImagesError}
/>
</ErrorAlert>
);
FetchErrorAlert.propTypes = {
// redux
isFetchError: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchImages }),
});
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(FetchErrorAlert);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FetchErrorAlert, mapStateToProps } from './FetchErrorAlert';
import { selectors } from '../../../../data/redux';
import { RequestKeys } from '../../../../data/constants/requests';
jest.mock('../../../../data/redux', () => ({
selectors: {
requests: {
isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
},
},
}));
describe('FetchErrorAlert', () => {
describe('Snapshots', () => {
test('snapshot: is ErrorAlert with Message error (ErrorAlert)', () => {
expect(shallow(<FetchErrorAlert isFetchError />)).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('isFetchError from requests.isFinished', () => {
expect(
mapStateToProps(testState).isFetchError,
).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.fetchImages }));
});
});
});

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from '../SelectImageModal/messages';
import ErrorAlert from './ErrorAlert';
import { selectors } from '../../../../data/redux';
import { RequestKeys } from '../../../../data/constants/requests';
export const UploadErrorAlert = ({
// redux
isUploadError,
// inject
}) => (
<ErrorAlert
isError={isUploadError}
>
<FormattedMessage
{...messages.uploadImageError}
/>
</ErrorAlert>
);
UploadErrorAlert.propTypes = {
// redux
isUploadError: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadImage }),
});
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(UploadErrorAlert);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { UploadErrorAlert, mapStateToProps } from './UploadErrorAlert';
import { selectors } from '../../../../data/redux';
import { RequestKeys } from '../../../../data/constants/requests';
jest.mock('../../../../data/redux', () => ({
selectors: {
requests: {
isFailed: jest.fn((state, params) => ({ isFailed: { state, params } })),
},
},
}));
describe('UploadErrorAlert', () => {
describe('Snapshots', () => {
test('snapshot: is ErrorAlert with Message error (ErrorAlert)', () => {
expect(shallow(<UploadErrorAlert isUploadError />)).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('isUploadError from requests.isFinished', () => {
expect(
mapStateToProps(testState).isUploadError,
).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadImage }));
});
});
});

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ErrorAlert component Component Snapshots snapshot: is Null when no error (ErrorAlert) 1`] = `""`;
exports[`ErrorAlert component Component Snapshots snapshot: Loads children and component when error (ErrorAlert) 1`] = `
<Alert
dismissible={true}
variant="danger"
>
<Alert.Heading>
<FormattedMessage
defaultMessage="Error"
description="Title of message presented to user when something goes wrong"
id="authoring.texteditor.selectimagemodal.error.errorTitle"
/>
</Alert.Heading>
<p>
An Error Message
</p>
</Alert>
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FetchErrorAlert Snapshots snapshot: is ErrorAlert with Message error (ErrorAlert) 1`] = `
<ErrorAlert
isError={true}
>
<FormattedMessage
defaultMessage="Failed to obtain course Images. Please Try again."
description="Message presented to user when images are not found"
id="authoring.texteditor.selectimagemodal.error.fetchImagesError"
/>
</ErrorAlert>
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UploadErrorAlert Snapshots snapshot: is ErrorAlert with Message error (ErrorAlert) 1`] = `
<ErrorAlert
isError={true}
>
<FormattedMessage
defaultMessage="Failed to Upload Image. Please Try again."
description="Message presented to user when image fails to upload"
id="authoring.texteditor.selectimagemodal.error.uploadImageError"
/>
</ErrorAlert>
`;

View File

@@ -1,36 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
export const ErrorAlert = ({
error,
setError,
}) => {
if (!error) {
return null;
}
return (
<Alert
variant="danger"
icon={Info}
dismissible
onClose={() => setError(null)}
>
<Alert.Heading>
Image not uploaded
</Alert.Heading>
<p>
Something went wrong while uploading your image. Please try again.
</p>
</Alert>
);
};
ErrorAlert.propTypes = {
error: PropTypes.string.isRequired,
setError: PropTypes.func.isRequired,
};
export default ErrorAlert;

View File

@@ -30,6 +30,8 @@ exports[`SelectImageModal component snapshot 1`] = `
isOpen={true}
title="Add an image"
>
<Connect(FetchErrorAlert) />
<Connect(UploadErrorAlert) />
<Stack
gap={3}
>

View File

@@ -33,6 +33,24 @@ jest.mock('../../../../data/redux', () => ({
},
}));
jest.mock('react-redux', () => {
const dispatchFn = jest.fn();
return {
...jest.requireActual('react-redux'),
dispatch: dispatchFn,
useDispatch: jest.fn(() => dispatchFn),
};
});
jest.mock('../../../../data/redux', () => ({
thunkActions: {
app: {
fetchImages: jest.fn(),
uploadImage: jest.fn(),
},
},
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
let hook;

View File

@@ -11,6 +11,8 @@ import BaseModal from '../BaseModal';
import SearchSort from './SearchSort';
import Gallery from './Gallery';
import FileInput from './FileInput';
import FetchErrorAlert from '../ErrorAlerts/FetchErrorAlert';
import UploadErrorAlert from '../ErrorAlerts/UploadErrorAlert';
export const SelectImageModal = ({
isOpen,
@@ -25,7 +27,6 @@ export const SelectImageModal = ({
selectBtnProps,
fileInput,
} = hooks.imgHooks({ setSelection });
return (
<BaseModal
close={close}
@@ -42,6 +43,8 @@ export const SelectImageModal = ({
)}
title={intl.formatMessage(messages.titleLabel)}
>
<FetchErrorAlert />
<UploadErrorAlert />
<Stack gap={3}>
<SearchSort {...searchSortProps} />
<Gallery {...galleryProps} />

View File

@@ -49,6 +49,21 @@ export const messages = {
defaultMessage: 'loading...',
description: 'Gallery loading spinner screen-reader text',
},
uploadImageError: {
id: 'authoring.texteditor.selectimagemodal.error.uploadImageError',
defaultMessage: 'Failed to Upload Image. Please Try again.',
description: 'Message presented to user when image fails to upload',
},
fetchImagesError: {
id: 'authoring.texteditor.selectimagemodal.error.fetchImagesError',
defaultMessage: 'Failed to obtain course Images. Please Try again.',
description: 'Message presented to user when images are not found',
},
errorTitle: {
id: 'authoring.texteditor.selectimagemodal.error.errorTitle',
defaultMessage: 'Error',
description: 'Title of message presented to user when something goes wrong',
},
};
export default messages;

View File

@@ -18,10 +18,14 @@ export const apiMethods = {
courseId,
studioEndpointUrl,
image,
}) => post(
urls.courseAssets({ studioEndpointUrl, courseId }),
image,
),
}) => {
const data = new FormData();
data.append('file', image);
return post(
urls.courseAssets({ studioEndpointUrl, courseId }),
data,
);
},
normalizeContent: ({
blockId,
blockType,

View File

@@ -105,7 +105,9 @@ describe('cms api', () => {
describe('uploadImage', () => {
const image = { photo: 'dAta' };
it('should call post with urls.block and normalizeContent', () => {
it('should call post with urls.courseAssets and imgdata', () => {
const mockFormdata = new FormData();
mockFormdata.append('file', image);
apiMethods.uploadImage({
courseId,
studioEndpointUrl,
@@ -113,7 +115,7 @@ describe('cms api', () => {
});
expect(post).toHaveBeenCalledWith(
urls.courseAssets({ studioEndpointUrl, courseId }),
image,
mockFormdata,
);
});
});

View File

@@ -108,7 +108,6 @@ export const saveBlock = ({
}),
});
// eslint-disable-next-line
export const uploadImage = ({
courseId,
studioEndpointUrl,

View File

@@ -60,6 +60,9 @@ jest.mock('@edx/frontend-platform/i18n', () => {
});
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
Alert: {
Heading: 'Alert.Heading',
},
ActionRow: {
Spacer: 'ActionRow.Spacer',
},

9
www/package-lock.json generated
View File

@@ -1250,10 +1250,10 @@
"@edx/frontend-lib-content-components": {
"version": "file:..",
"requires": {
"@edx/brand": "npm:@edx/brand-edx@2.0.3",
"@reduxjs/toolkit": "^1.7.2",
"@tinymce/tinymce-react": "^3.13.0",
"babel-polyfill": "6.26.0",
"lodash-es": "^4.17.21",
"react-redux": "^7.2.6",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.2",
@@ -2420,7 +2420,7 @@
"integrity": "sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA=="
},
"@edx/brand": {
"version": "npm:@edx/brand-openedx@1.1.0",
"version": "npm:@edx/brand@1.1.0",
"resolved": "https://registry.npmjs.org/@edx/brand-openedx/-/brand-openedx-1.1.0.tgz",
"integrity": "sha512-ne2ZKF1r0akkt0rEzCAQAk4cTDTI2GiWCpc+T7ldQpw9X57OnUB16dKsFNe40C9uEjL5h3Ps/ZsFM5dm4cIkEQ=="
},
@@ -26378,6 +26378,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",