Skip to content

Commit 80d48d2

Browse files
authored
feat: validate submission response required fields (#241)
* feat: add required property to both FileResponseConfig and TextResponseConfig types * feat: add a validation hook for required submission fields * feat: validate submission required fields * feat: remove unsupported multiple attribute from dropzone
1 parent 8185c59 commit 80d48d2

22 files changed

+390
-120
lines changed

src/components/FileUpload/__snapshots__/index.test.jsx.snap

+52-39
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ exports[`<FileUpload /> render default 1`] = `
44
<div>
55
<h3>
66
File upload
7+
78
</h3>
89
<b>
910
Uploaded files
@@ -57,20 +58,23 @@ exports[`<FileUpload /> render default 1`] = `
5758
]
5859
}
5960
/>
60-
<Dropzone
61-
accept={
62-
{
63-
"*": [
64-
".pdf",
65-
".jpg",
66-
],
61+
<Form.Group
62+
isInValid={true}
63+
>
64+
<Dropzone
65+
accept={
66+
{
67+
"*": [
68+
".pdf",
69+
".jpg",
70+
],
71+
}
6772
}
68-
}
69-
maxSize={123456}
70-
multiple={true}
71-
onProcessUpload={[MockFunction onProcessUpload]}
72-
progressVariant="bar"
73-
/>
73+
maxSize={123456}
74+
onProcessUpload={[MockFunction onProcessUpload]}
75+
progressVariant="bar"
76+
/>
77+
</Form.Group>
7478
</div>
7579
`;
7680

@@ -82,6 +86,7 @@ exports[`<FileUpload /> render extra columns when activeStepName is submission 1
8286
<div>
8387
<h3>
8488
File upload
89+
8590
</h3>
8691
<b>
8792
Uploaded files
@@ -140,27 +145,31 @@ exports[`<FileUpload /> render extra columns when activeStepName is submission 1
140145
]
141146
}
142147
/>
143-
<Dropzone
144-
accept={
145-
{
146-
"*": [
147-
".pdf",
148-
".jpg",
149-
],
148+
<Form.Group
149+
isInValid={true}
150+
>
151+
<Dropzone
152+
accept={
153+
{
154+
"*": [
155+
".pdf",
156+
".jpg",
157+
],
158+
}
150159
}
151-
}
152-
maxSize={123456}
153-
multiple={true}
154-
onProcessUpload={[MockFunction onProcessUpload]}
155-
progressVariant="bar"
156-
/>
160+
maxSize={123456}
161+
onProcessUpload={[MockFunction onProcessUpload]}
162+
progressVariant="bar"
163+
/>
164+
</Form.Group>
157165
</div>
158166
`;
159167

160168
exports[`<FileUpload /> render without dropzone and confirm modal when isReadOnly 1`] = `
161169
<div>
162170
<h3>
163171
File upload
172+
164173
</h3>
165174
<FilePreview
166175
defaultCollapsePreview={false}
@@ -224,6 +233,7 @@ exports[`<FileUpload /> render without file preview if uploadedFiles are empty a
224233
<div>
225234
<h3>
226235
File upload
236+
227237
</h3>
228238
<b>
229239
Uploaded files
@@ -312,20 +322,23 @@ exports[`<FileUpload /> render without header 1`] = `
312322
]
313323
}
314324
/>
315-
<Dropzone
316-
accept={
317-
{
318-
"*": [
319-
".pdf",
320-
".jpg",
321-
],
325+
<Form.Group
326+
isInValid={true}
327+
>
328+
<Dropzone
329+
accept={
330+
{
331+
"*": [
332+
".pdf",
333+
".jpg",
334+
],
335+
}
322336
}
323-
}
324-
maxSize={123456}
325-
multiple={true}
326-
onProcessUpload={[MockFunction onProcessUpload]}
327-
progressVariant="bar"
328-
/>
337+
maxSize={123456}
338+
onProcessUpload={[MockFunction onProcessUpload]}
339+
progressVariant="bar"
340+
/>
341+
</Form.Group>
329342
</div>
330343
`;
331344

src/components/FileUpload/index.jsx

+17-12
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import PropTypes from 'prop-types';
33
import filesize from 'filesize';
44

5-
import { DataTable, Dropzone } from '@openedx/paragon';
5+
import { DataTable, Dropzone, Form } from '@openedx/paragon';
66
import { useIntl } from '@edx/frontend-platform/i18n';
77

88
import { nullMethod } from 'utils';
@@ -35,6 +35,7 @@ const FileUpload = ({
3535
onDeletedFile,
3636
defaultCollapsePreview,
3737
hideHeader,
38+
isInValid,
3839
}) => {
3940
const { formatMessage } = useIntl();
4041
const {
@@ -47,7 +48,7 @@ const FileUpload = ({
4748
const viewStep = useViewStep();
4849
const activeStepName = useActiveStepName();
4950
const {
50-
enabled, fileUploadLimit, allowedExtensions, maxFileSize,
51+
enabled, fileUploadLimit, allowedExtensions, maxFileSize, required,
5152
} = useFileUploadConfig() || {};
5253

5354
if (!enabled || viewStep === stepNames.studentTraining) {
@@ -78,7 +79,7 @@ const FileUpload = ({
7879

7980
return (
8081
<div>
81-
{!hideHeader && <h3>{formatMessage(messages.fileUploadTitle)}</h3>}
82+
{!hideHeader && <h3>{formatMessage(messages.fileUploadTitle)} {required && <span>(required)</span>}</h3>}
8283
{uploadedFiles.length > 0 && isReadOnly && (
8384
<FilePreview defaultCollapsePreview={defaultCollapsePreview} />
8485
)}
@@ -93,15 +94,17 @@ const FileUpload = ({
9394
columns={columns}
9495
/>
9596
{!isReadOnly && fileUploadLimit > uploadedFiles.length && (
96-
<Dropzone
97-
multiple
98-
onProcessUpload={onProcessUpload}
99-
progressVariant="bar"
100-
accept={{
101-
'*': (allowedExtensions || []).map((ext) => `.${ext}`),
102-
}}
103-
maxSize={maxFileSize}
104-
/>
97+
<Form.Group isInValid>
98+
<Dropzone
99+
onProcessUpload={onProcessUpload}
100+
progressVariant="bar"
101+
accept={{
102+
'*': (allowedExtensions || []).map((ext) => `.${ext}`),
103+
}}
104+
maxSize={maxFileSize}
105+
/>
106+
{isInValid && <Form.Control.Feedback type="invalid">{formatMessage(messages.required)}</Form.Control.Feedback>}
107+
</Form.Group>
105108
)}
106109
{!isReadOnly && isModalOpen && (
107110
<UploadConfirmModal
@@ -122,6 +125,7 @@ FileUpload.defaultProps = {
122125
onDeletedFile: nullMethod,
123126
defaultCollapsePreview: false,
124127
hideHeader: false,
128+
isInValid: false,
125129
};
126130
FileUpload.propTypes = {
127131
isReadOnly: PropTypes.bool,
@@ -137,6 +141,7 @@ FileUpload.propTypes = {
137141
onDeletedFile: PropTypes.func,
138142
defaultCollapsePreview: PropTypes.bool,
139143
hideHeader: PropTypes.bool,
144+
isInValid: PropTypes.bool,
140145
};
141146

142147
export default FileUpload;

src/components/FileUpload/messages.js

+5
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ const messages = defineMessages({
9696
defaultMessage: 'File description',
9797
description: 'Popover title for file description',
9898
},
99+
required: {
100+
id: 'frontend-app-ora.FileUpload.required',
101+
defaultMessage: 'File Upload is required',
102+
description: 'Indicating file upload is required',
103+
},
99104
});
100105

101106
export default messages;

src/data/services/lms/types/blockInfo.ts

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface TextResponseConfig {
1717
optional: boolean,
1818
editorType: 'text' | 'tinymce',
1919
allowLatexPreview: boolean,
20+
required: boolean,
2021
}
2122

2223
export interface FileResponseConfig {
@@ -27,6 +28,7 @@ export interface FileResponseConfig {
2728
allowedExtensions: string[],
2829
blockedExtensions: string[],
2930
fileTypeDescription: string,
31+
required: boolean,
3032
}
3133

3234
export interface SubmissionConfig {

src/hooks/actions/useConfirmAction.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ export const assessmentSteps = [
1313
stepNames.peer,
1414
];
1515

16-
const useConfirmAction = () => {
16+
const useConfirmAction = (validateBeforeOpen = () => true) => {
1717
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
1818
const close = React.useCallback(() => setIsOpen(false), [setIsOpen]);
19-
const open = React.useCallback(() => setIsOpen(true), [setIsOpen]);
19+
const open = React.useCallback(() => {
20+
if (validateBeforeOpen()) {
21+
setIsOpen(true);
22+
}
23+
}, [setIsOpen, validateBeforeOpen]);
2024
return React.useCallback((config) => {
2125
const { description, title } = config;
2226
const action = config.action.action ? config.action.action : config.action;

src/hooks/actions/useConfirmAction.test.js

+8-6
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ const nestedActionConfig = {
2323
action: { action: config.action },
2424
};
2525

26+
const validateBeforeOpen = jest.fn(() => true);
27+
2628
let out;
2729
describe('useConfirmAction', () => {
2830
beforeEach(() => {
2931
jest.clearAllMocks();
3032
state.mock();
31-
out = useConfirmAction();
33+
out = useConfirmAction(validateBeforeOpen);
3234
});
3335
afterEach(() => { state.resetVals(); });
3436
describe('behavior', () => {
@@ -44,7 +46,7 @@ describe('useConfirmAction', () => {
4446
state.expectSetStateCalledWith(stateKeys.isOpen, false);
4547
};
4648
const testOpen = (openFn) => {
47-
expect(openFn.useCallback.prereqs).toEqual([state.setState[stateKeys.isOpen]]);
49+
expect(openFn.useCallback.prereqs).toEqual([state.setState[stateKeys.isOpen], validateBeforeOpen]);
4850
openFn.useCallback.cb();
4951
state.expectSetStateCalledWith(stateKeys.isOpen, true);
5052
};
@@ -62,13 +64,13 @@ describe('useConfirmAction', () => {
6264
expect(out.useCallback.prereqs[1]).toEqual(true);
6365
});
6466
test('open callback', () => {
65-
out = useConfirmAction();
67+
out = useConfirmAction(validateBeforeOpen);
6668
testOpen(prereqs[2]);
6769
});
6870
});
6971
describe('callback', () => {
7072
it('returns action with labels from config action', () => {
71-
out = useConfirmAction().useCallback.cb(config);
73+
out = useConfirmAction(validateBeforeOpen).useCallback.cb(config);
7274
testOpen(out.action.onClick);
7375
expect(out.action.children).toEqual(config.action.labels.default);
7476
expect(out.confirmProps.isOpen).toEqual(false);
@@ -78,7 +80,7 @@ describe('useConfirmAction', () => {
7880
testClose(out.confirmProps.close);
7981
});
8082
it('returns nested action from config action', () => {
81-
out = useConfirmAction().useCallback.cb(nestedActionConfig);
83+
out = useConfirmAction(validateBeforeOpen).useCallback.cb(nestedActionConfig);
8284
testOpen(out.action.onClick);
8385
expect(out.action.children).toEqual(nestedActionConfig.action.action.labels.default);
8486
expect(out.confirmProps.isOpen).toEqual(false);
@@ -89,7 +91,7 @@ describe('useConfirmAction', () => {
8991
});
9092
it('returns action with children from config action', () => {
9193
state.mockVals({ [stateKeys.isOpen]: true });
92-
out = useConfirmAction().useCallback.cb(noLabelConfig);
94+
out = useConfirmAction(validateBeforeOpen).useCallback.cb(noLabelConfig);
9395
testOpen(out.action.onClick);
9496
expect(out.action.children).toEqual(noLabelConfig.action.children);
9597
expect(out.confirmProps.isOpen).toEqual(true);

src/hooks/actions/useSubmitResponseAction.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import messages, { confirmDescriptions, confirmTitles } from './messages';
1313
*/
1414
const useSubmitResponseAction = ({ options }) => {
1515
const { formatMessage } = useIntl();
16-
const { submit, submitStatus } = options;
17-
const confirmAction = useConfirmAction();
16+
const { submit, submitStatus, validateBeforeConfirmation } = options;
17+
const confirmAction = useConfirmAction(validateBeforeConfirmation);
1818
return confirmAction({
1919
action: {
2020
onClick: submit,

src/hooks/actions/useSubmitResponseAction.test.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ jest.mock('./useConfirmAction', () => ({
1212
default: jest.fn(),
1313
}));
1414

15-
const mockConfirmAction = jest.fn(args => ({ confirmAction: args }));
16-
when(useConfirmAction).calledWith().mockReturnValue(mockConfirmAction);
17-
1815
const options = {
1916
submit: jest.fn(),
2017
submitStatus: 'test-submit-status',
18+
validateBeforeConfirmation: jest.fn(),
2119
};
2220

21+
const mockConfirmAction = jest.fn(args => ({ confirmAction: args }));
22+
when(useConfirmAction).calledWith(options.validateBeforeConfirmation).mockReturnValue(mockConfirmAction);
23+
2324
let out;
2425
describe('useSubmitResponseAction', () => {
2526
beforeEach(() => {
@@ -28,7 +29,7 @@ describe('useSubmitResponseAction', () => {
2829
describe('behavior', () => {
2930
it('loads internatioonalization and confirm action from hooks', () => {
3031
expect(useIntl).toHaveBeenCalledWith();
31-
expect(useConfirmAction).toHaveBeenCalledWith();
32+
expect(useConfirmAction).toHaveBeenCalledWith(options.validateBeforeConfirmation);
3233
});
3334
});
3435
describe('output confirmAction', () => {

0 commit comments

Comments
 (0)