diff --git a/client/app/bundles/course/achievement/components/forms/AchievementForm.tsx b/client/app/bundles/course/achievement/components/forms/AchievementForm.tsx index 71ee776d9b9..c7169742d9d 100644 --- a/client/app/bundles/course/achievement/components/forms/AchievementForm.tsx +++ b/client/app/bundles/course/achievement/components/forms/AchievementForm.tsx @@ -1,5 +1,5 @@ import { FC, useEffect } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; import { Controller, useForm } from 'react-hook-form'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; @@ -19,7 +19,7 @@ import { } from 'types/course/achievements'; import { ConditionData, Conditions } from 'types/course/conditions'; -interface Props { +interface Props extends WrappedComponentProps { editing: boolean; // If the Form is in editing mode, `Add Conditions` button will be displayed. handleClose: (isDirty: boolean) => void; onSubmit: ( @@ -62,6 +62,15 @@ const translations = defineMessages({ id: 'course.achievement.form.update', defaultMessage: 'Update', }, + unlockConditions: { + id: 'course.achievement.form.unlockConditions', + defaultMessage: 'Unlock conditions', + }, + unlockConditionsHint: { + id: 'course.achievement.form.unlockConditionsHint', + defaultMessage: + 'This achievement will be unlocked if a student meets the following conditions.', + }, }); const validationSchema = yup.object({ @@ -78,6 +87,7 @@ const AchievementForm: FC = (props) => { initialValues, onSubmit, setIsDirty, + intl, } = props; const { control, @@ -112,7 +122,7 @@ const AchievementForm: FC = (props) => { key="achievement-form-update-button" type="submit" > - + {intl.formatMessage(translations.update)} ) : ( @@ -126,7 +136,7 @@ const AchievementForm: FC = (props) => { key="achievement-form-cancel-button" onClick={(): void => handleClose(isDirty)} > - + {intl.formatMessage(formTranslations.cancel)} ); @@ -157,7 +167,7 @@ const AchievementForm: FC = (props) => { field={field} fieldState={fieldState} disabled={disabled} - label={} + label={intl.formatMessage(translations.title)} // @ts-ignore: component is still written in JS fullWidth InputLabelProps={{ @@ -176,7 +186,7 @@ const AchievementForm: FC = (props) => { field={field} fieldState={fieldState} disabled={disabled} - label={} + label={intl.formatMessage(translations.description)} // @ts-ignore: component is still written in JS fullWidth InputLabelProps={{ @@ -207,17 +217,17 @@ const AchievementForm: FC = (props) => { field={field} fieldState={fieldState} disabled={disabled} - label={} + label={intl.formatMessage(translations.published)} /> )} /> {editing && conditionAttributes && ( -
- -
+ )} {actionButtons} @@ -225,4 +235,4 @@ const AchievementForm: FC = (props) => { ); }; -export default AchievementForm; +export default injectIntl(AchievementForm); diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/__test__/index.test.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/__test__/index.test.tsx new file mode 100644 index 00000000000..87245e6e2c5 --- /dev/null +++ b/client/app/bundles/course/assessment/components/AssessmentForm/__test__/index.test.tsx @@ -0,0 +1,264 @@ +import { ComponentProps } from 'react'; + +import { fireEvent, render, RenderResult } from 'utilities/test-utils'; +import ProviderWrapper from 'lib/components/ProviderWrapper'; +import storeCreator from '../../../store'; +import AssessmentForm from '..'; + +const INITIAL_VALUES = { + id: 1, + title: 'Test Assessment', + description: 'Awesome description 4', + autograded: false, + start_at: new Date(), + base_exp: 0, + time_bonus_exp: 0, + use_public: true, + use_private: true, + use_evaluation: false, + tabbed_view: false, + published: false, +}; + +let store; +let initialValues; +let props: ComponentProps; +let form: RenderResult; + +const getComponent = (): JSX.Element => ( + + + +); + +beforeEach(() => { + store = storeCreator({ assessments: {} }); + initialValues = INITIAL_VALUES; + + props = { + initialValues, + gamified: false, + modeSwitching: true, + containsCodaveri: false, + showPersonalizedTimelineFeatures: false, + randomizationAllowed: false, + conditionAttributes: { + conditions: [], + new_condition_urls: [], + }, + folderAttributes: { + folder_id: 1, + materials: [], + enable_materials_action: true, + }, + onSubmit: (): void => {}, + }; + + form = render(getComponent()); +}); + +describe('', () => { + it('renders assessment details sections options', () => { + expect(form.getByText('Assessment details')).toBeVisible(); + expect(form.getByLabelText('Starts at *')).toBeVisible(); + expect(form.getByLabelText('Ends at')).toBeVisible(); + expect(form.getByLabelText('Title *')).toHaveValue(INITIAL_VALUES.title); + expect(form.getByText('Description')).toBeVisible(); + expect(form.getByDisplayValue(INITIAL_VALUES.description)).toBeVisible(); + }); + + it('renders grading section options', () => { + expect(form.getByText('Grading')).toBeVisible(); + expect(form.getByText('Grading mode')).toBeVisible(); + + expect(form.getByText('Autograded')).toBeVisible(); + expect(form.getByDisplayValue('autograded')).not.toBeChecked(); + + expect(form.getByText('Manual')).toBeVisible(); + expect(form.getByDisplayValue('manual')).toBeChecked(); + + expect(form.getByLabelText('Public test cases')).toBeChecked(); + expect(form.getByLabelText('Private test cases')).toBeChecked(); + expect(form.getByLabelText('Evaluation test cases')).not.toBeChecked(); + + expect( + form.getByLabelText('Enable delayed grade publication'), + ).not.toBeChecked(); + }); + + it('renders answers and test cases section options', () => { + expect(form.getByText('Answers and test cases')).toBeVisible(); + expect(form.getByLabelText('Allow to skip steps')).not.toBeChecked(); + expect( + form.getByLabelText('Allow submission with incorrect answers'), + ).not.toBeChecked(); + expect(form.getByLabelText('Show private test cases')).not.toBeChecked(); + expect(form.getByLabelText('Show evaluation test cases')).not.toBeChecked(); + expect(form.getByLabelText('Show MCQ/MRQ solution(s)')).not.toBeChecked(); + }); + + it('renders organisation section options', () => { + expect(form.getByText('Organisation')).toBeVisible(); + expect(form.getByText('Single Page')).toBeVisible(); + }); + + it('renders exams and access control section options', () => { + expect(form.getByText('Exams and access control')).toBeVisible(); + expect( + form.getByLabelText('Block students from viewing finalized submissions'), + ).not.toBeChecked(); + expect(form.getByLabelText('Show MCQ submit result')).not.toBeChecked(); + expect(form.getByLabelText('Enable password protection')).not.toBeChecked(); + }); + + it('renders gamified options when course is gamified', () => { + expect(form.queryByText('Gamification')).not.toBeInTheDocument(); + expect(form.queryByLabelText('Bonus ends at')).not.toBeInTheDocument(); + expect(form.queryByLabelText('Base EXP')).not.toBeInTheDocument(); + expect(form.queryByLabelText('Time Bonus EXP')).not.toBeInTheDocument(); + + props.gamified = true; + form.rerender(getComponent()); + + expect(form.getByText('Gamification')).toBeVisible(); + expect(form.getByLabelText('Bonus ends at')).toBeVisible(); + expect(form.getByLabelText('Base EXP')).toHaveValue( + INITIAL_VALUES.base_exp, + ); + expect(form.getByLabelText('Time Bonus EXP')).toHaveValue( + INITIAL_VALUES.time_bonus_exp, + ); + }); + + it('renders editing options when rendered in edit assessment page', () => { + expect(form.queryByText('Visibility')).not.toBeInTheDocument(); + expect(form.queryByText('Published')).not.toBeInTheDocument(); + expect(form.queryByText('Draft')).not.toBeInTheDocument(); + expect(form.queryByText('Files')).not.toBeInTheDocument(); + expect(form.queryByText('Add Files')).not.toBeInTheDocument(); + expect(form.queryByText('Unlock conditions')).not.toBeInTheDocument(); + expect(form.queryByText('Add a condition')).not.toBeInTheDocument(); + + props.editing = true; + form.rerender(getComponent()); + + expect(form.getByText('Visibility')).toBeVisible(); + expect(form.getByText('Published')).toBeVisible(); + expect(form.getByDisplayValue('published')).not.toBeChecked(); + expect(form.getByText('Draft')).toBeVisible(); + expect(form.getByDisplayValue('draft')).toBeChecked(); + expect(form.getByText('Files')).toBeVisible(); + expect(form.getByText('Add Files')).toBeVisible(); + expect(form.queryByText('Unlock conditions')).not.toBeInTheDocument(); + expect(form.queryByText('Add a condition')).not.toBeInTheDocument(); + + props.gamified = true; + form.rerender(getComponent()); + expect(form.getByText('Unlock conditions')).toBeVisible(); + expect(form.getByText('Add a condition')).toBeVisible(); + }); + + it('prevents grading mode switching when there are submissions', () => { + expect(form.getByDisplayValue('autograded')).toBeEnabled(); + expect(form.getByDisplayValue('manual')).toBeEnabled(); + + props.modeSwitching = false; + form.rerender(getComponent()); + + expect(form.getByDisplayValue('autograded')).toBeDisabled(); + expect(form.getByDisplayValue('manual')).toBeDisabled(); + }); + + it('prevents grading mode switching when there are codaveri questions', () => { + expect(form.getByDisplayValue('autograded')).toBeEnabled(); + expect(form.getByDisplayValue('manual')).toBeEnabled(); + + props.containsCodaveri = true; + form.rerender(getComponent()); + + expect(form.getByDisplayValue('autograded')).toBeDisabled(); + expect(form.getByDisplayValue('manual')).toBeDisabled(); + }); + + it('disables unavailable options in autograded mode', () => { + expect(form.getByLabelText('Allow to skip steps')).toBeDisabled(); + expect( + form.getByLabelText('Allow submission with incorrect answers'), + ).toBeDisabled(); + expect( + form.getByLabelText('Enable delayed grade publication'), + ).toBeEnabled(); + expect(form.getByLabelText('Show MCQ submit result')).toBeDisabled(); + expect(form.getByLabelText('Enable password protection')).toBeEnabled(); + + const autogradedRadio = form.getByDisplayValue('autograded'); + fireEvent.click(autogradedRadio); + + expect(form.getByLabelText('Allow to skip steps')).toBeEnabled(); + expect( + form.getByLabelText('Allow submission with incorrect answers'), + ).toBeEnabled(); + expect( + form.getByLabelText('Enable delayed grade publication'), + ).toBeDisabled(); + expect(form.getByLabelText('Show MCQ submit result')).toBeEnabled(); + expect(form.getByLabelText('Enable password protection')).toBeDisabled(); + }); + + it('handles password protection options', () => { + expect( + form.queryByLabelText('Assessment password *'), + ).not.toBeInTheDocument(); + expect( + form.queryByLabelText('Enable session protection'), + ).not.toBeInTheDocument(); + expect( + form.queryByLabelText('Session unlock password *'), + ).not.toBeInTheDocument(); + + const passwordCheckbox = form.getByLabelText('Enable password protection'); + expect(passwordCheckbox).toBeEnabled(); + expect(passwordCheckbox).not.toBeChecked(); + + fireEvent.click(passwordCheckbox); + + expect(form.getByLabelText('Assessment password *')).toBeVisible(); + + const sessionProtectionCheckbox = form.getByLabelText( + 'Enable session protection', + ); + expect(sessionProtectionCheckbox).toBeEnabled(); + expect(sessionProtectionCheckbox).not.toBeChecked(); + expect( + form.queryByLabelText('Session unlock password *'), + ).not.toBeInTheDocument(); + + fireEvent.click(sessionProtectionCheckbox); + + expect(form.getByLabelText('Session unlock password *')).toBeVisible(); + }); + + it('renders personalised timelines options when enabled', () => { + expect(form.queryByLabelText('Has personal times')).not.toBeInTheDocument(); + expect( + form.queryByLabelText('Affects personal times'), + ).not.toBeInTheDocument(); + + props.showPersonalizedTimelineFeatures = true; + form.rerender(getComponent()); + + expect(form.getByLabelText('Has personal times')).toBeEnabled(); + expect(form.getByLabelText('Affects personal times')).toBeEnabled(); + }); + + it('renders randomization options when enabled', () => { + expect( + form.queryByLabelText('Enable Randomization'), + ).not.toBeInTheDocument(); + + props.randomizationAllowed = true; + form.rerender(getComponent()); + + expect(form.getByLabelText('Enable Randomization')).toBeEnabled(); + }); +}); diff --git a/client/app/bundles/course/assessment/containers/AssessmentForm/actions.js b/client/app/bundles/course/assessment/components/AssessmentForm/actions.js similarity index 100% rename from client/app/bundles/course/assessment/containers/AssessmentForm/actions.js rename to client/app/bundles/course/assessment/components/AssessmentForm/actions.js diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx new file mode 100644 index 00000000000..2e33e4d1d18 --- /dev/null +++ b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx @@ -0,0 +1,719 @@ +/* eslint-disable camelcase */ +import { useEffect } from 'react'; +import { injectIntl } from 'react-intl'; +import { Controller } from 'react-hook-form'; +import { RadioGroup, Typography, Grid } from '@mui/material'; +import { + Public as PublishedIcon, + Block as DraftIcon, + Create as ManualIcon, + CheckCircle as AutogradedIcon, +} from '@mui/icons-material'; +import useEmitterFactory from 'react-emitter-factory'; + +import FormDateTimePickerField from 'lib/components/form/fields/DateTimePickerField'; +import FormRichTextField from 'lib/components/form/fields/RichTextField'; +import FormSelectField from 'lib/components/form/fields/SelectField'; +import FormTextField from 'lib/components/form/fields/TextField'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import ErrorText from 'lib/components/ErrorText'; +import ConditionList from 'lib/components/course/ConditionList'; +import Section from 'lib/components/layouts/Section'; +import IconRadio from 'lib/components/IconRadio'; +import InfoLabel from 'lib/components/InfoLabel'; +import t from './translations.intl'; +import FileManager from '../FileManager'; +import { fetchTabs } from './actions'; +import useFormValidation from './useFormValidation'; +import { connector, AssessmentFormProps } from './types'; + +const AssessmentForm = (props: AssessmentFormProps): JSX.Element => { + const { + conditionAttributes, + containsCodaveri, + disabled, + dispatch, + editing, + gamified, + folderAttributes, + initialValues, + modeSwitching, + onSubmit, + randomizationAllowed, + showPersonalizedTimelineFeatures, + tabs, + intl, + } = props; + + const { + control, + handleSubmit, + setError, + watch, + formState: { errors, isDirty }, + } = useFormValidation(initialValues); + + const autograded = watch('autograded'); + const passwordProtected = watch('password_protected'); + const sessionProtected = watch('session_protected'); + + // Load all tabs if data is loaded, otherwise fall back to current assessment tab. + const loadedTabs = tabs ?? watch('tabs'); + + useEffect(() => { + if (!editing) return; + + const failureMessage = intl.formatMessage(t.fetchTabFailure); + + // @ts-ignore until Assessment store and a custom dispatch for thunk is fully typed + // https://redux.js.org/tutorials/typescript-quick-start#define-typed-hooks + dispatch(fetchTabs(failureMessage)); + }, [dispatch]); + + useEmitterFactory( + props, + { + isDirty, + }, + [isDirty], + ); + + const renderPasswordFields = (): JSX.Element => ( + <> + ( + + )} + /> + + ( + + )} + /> + + {sessionProtected && ( + ( + + )} + /> + )} + + ); + + const renderTabs = (): JSX.Element | null => { + if (!loadedTabs) return null; + + const options = loadedTabs.map((tab) => ({ + value: tab.tab_id, + label: tab.title, + })); + + return ( + ( + + )} + /> + ); + }; + + return ( +
onSubmit(data, setError))} + > + + +
+ ( + + )} + /> + + + + ( + + )} + /> + + + + ( + + )} + /> + + + {gamified && ( + + ( + + )} + /> + + )} + + + + {intl.formatMessage(t.description)} + + + ( + + )} + /> + + {editing && ( + <> + + {intl.formatMessage(t.visibility)} + + + ( + { + const isPublished = e.target.value === 'published'; + field.onChange(isPublished); + }} + > + + + + + )} + /> + + )} + + {editing && folderAttributes && ( + <> + + {intl.formatMessage(t.files)} + + + + + )} +
+ + {gamified && ( +
+ + + ( + event.currentTarget.blur()} + type="number" + variant="filled" + margins={false} + /> + )} + /> + + + + ( + event.currentTarget.blur()} + type="number" + variant="filled" + margins={false} + /> + )} + /> + + + + {editing && conditionAttributes && ( + + )} +
+ )} + +
+ + {intl.formatMessage(t.gradingMode)} + + + {!modeSwitching && ( + + )} + + {containsCodaveri && ( + + {intl.formatMessage(t.containsCodaveriQuestion)} + + )} + + ( + <> + { + const isAutograded = e.target.value === 'autograded'; + field.onChange(isAutograded); + }} + > + + + + + + )} + /> + + + {intl.formatMessage(t.calculateGradeWith)} + + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + ( + + } + /> + )} + /> +
+ +
+ ( + + } + /> + )} + /> + ( + + } + /> + )} + /> + + + {intl.formatMessage(t.afterSubmissionGraded)} + + + ( + + )} + /> + ( + + )} + /> + + ( + + )} + /> +
+ +
+ {editing && renderTabs()} + + ( + + )} + /> +
+ +
+ ( + + )} + /> + + {randomizationAllowed && ( + ( + + )} + /> + )} + + ( + + } + /> + )} + /> + + ( + + } + /> + )} + /> + + {!autograded && passwordProtected && renderPasswordFields()} +
+ + {showPersonalizedTimelineFeatures && ( +
+ ( + + )} + /> + + ( + + )} + /> +
+ )} + + ); +}; + +AssessmentForm.defaultProps = { + gamified: true, +}; + +export default connector(injectIntl(AssessmentForm)); diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js b/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js new file mode 100644 index 00000000000..fd046040e26 --- /dev/null +++ b/client/app/bundles/course/assessment/components/AssessmentForm/translations.intl.js @@ -0,0 +1,291 @@ +import { defineMessages } from 'react-intl'; + +const translations = defineMessages({ + title: { + id: 'course.assessment.form.title', + defaultMessage: 'Title', + }, + description: { + id: 'course.assessment.form.description', + defaultMessage: 'Description', + }, + startAt: { + id: 'course.assessment.form.startAt', + defaultMessage: 'Starts at *', + }, + endAt: { + id: 'course.assessment.form.endAt', + defaultMessage: 'Ends at', + }, + bonusEndAt: { + id: 'course.assessment.form.bonusEndAt', + defaultMessage: 'Bonus ends at', + }, + baseExp: { + id: 'course.assessment.form.baseExp', + defaultMessage: 'Base EXP', + }, + timeBonusExp: { + id: 'course.assessment.form.timeBonusExp', + defaultMessage: 'Time Bonus EXP', + }, + blockStudentViewingAfterSubmitted: { + id: 'course.assessment.form.blockStudentViewingAfterSubmitted', + defaultMessage: 'Block students from viewing finalized submissions', + }, + usePublic: { + id: 'course.assessment.form.usePublic', + defaultMessage: 'Public test cases', + }, + usePrivate: { + id: 'course.assessment.form.usePrivate', + defaultMessage: 'Private test cases', + }, + useEvaluation: { + id: 'course.assessment.form.useEvaluation', + defaultMessage: 'Evaluation test cases', + }, + allowPartialSubmission: { + id: 'course.assessment.form.allowPartialSubmission', + defaultMessage: 'Allow submission with incorrect answers', + }, + showMcqAnswer: { + id: 'course.assessment.form.showMcqAnswer', + defaultMessage: 'Show MCQ submit result', + }, + showMcqAnswerHint: { + id: 'course.assessment.form.showMcqAnswerHint', + defaultMessage: + 'When enabled, students can try to submit MCQ answers and get feedback until they get it right.', + }, + showPrivate: { + id: 'course.assessment.form.showPrivate', + defaultMessage: 'Show private test cases', + }, + showEvaluation: { + id: 'course.assessment.form.showEvaluation', + defaultMessage: 'Show evaluation test cases', + }, + forProgrammingQuestions: { + id: 'course.assessment.form.forProgrammingQuestions', + defaultMessage: 'for programming questions', + }, + hasPersonalTimes: { + id: 'course.assessment.form.hasPersonalTimes', + defaultMessage: 'Has personal times', + }, + hasPersonalTimesHint: { + id: 'course.assessment.form.hasPersonalTimesHint', + defaultMessage: + 'Timings for this item will be automatically adjusted for users based on learning rate', + }, + affectsPersonalTimes: { + id: 'course.assessment.form.affectsPersonalTimes', + defaultMessage: 'Affects personal times', + }, + affectsPersonalTimesHint: { + id: 'course.assessment.form.affectsPersonalTimesHint', + defaultMessage: + "Student's submission time for this item will be taken into account \ + when updating personal times for other items", + }, + visibility: { + id: 'course.assessment.form.visibility', + defaultMessage: 'Visibility', + }, + published: { + id: 'course.assessment.form.published', + defaultMessage: 'Published', + }, + draft: { + id: 'course.assessment.form.draft', + defaultMessage: 'Draft', + }, + publishedHint: { + id: 'course.assessment.form.publishedHint', + defaultMessage: 'Everyone can see this assessment.', + }, + draftHint: { + id: 'course.assessment.form.draftHint', + defaultMessage: 'Only you and staff can see this assessment.', + }, + gradingMode: { + id: 'course.assessment.form.gradingMode', + defaultMessage: 'Grading mode', + }, + autogradedHint: { + id: 'course.assessment.form.autogradedHint', + defaultMessage: + 'Automatically assign grade and EXP upon submission. \ + Non-autogradeable questions will always receive the maximum grade.', + }, + modeSwitchingDisabled: { + id: 'course.assessment.form.modeSwitchingHint', + defaultMessage: + 'You can no longer change the grading mode because there are already submissions \ + for this assessment.', + }, + containsCodaveriQuestion: { + id: 'course.assessment.form.containsCodaveriQuestion', + defaultMessage: + "Switch to autograded mode is not allowed as there's \ + codaveri programming question type in this assessment. \ + This question type is only supported \ + in manually-graded assessment.", + }, + calculateGradeWith: { + id: 'course.assessment.form.calculateGradeWith', + defaultMessage: 'Calculate grade and EXP with', + }, + skippable: { + id: 'course.assessment.form.skippable', + defaultMessage: 'Allow to skip steps', + }, + skippableManualHint: { + id: 'course.assessment.form.skippableManualHint', + defaultMessage: + 'Students can already move between questions in manually graded assessments.', + }, + unlockConditions: { + id: 'course.assessment.form.unlockConditions', + defaultMessage: 'Unlock conditions', + }, + unlockConditionsHint: { + id: 'course.assessment.form.unlockConditionsHint', + defaultMessage: + 'This assessment will be unlocked if a student meets the following conditions.', + }, + displayAssessmentAs: { + id: 'course.assessment.form.displayAssessmentAs', + defaultMessage: 'Display assessment as', + }, + tabbedView: { + id: 'course.assessment.form.tabbedView', + defaultMessage: 'Tabbed View', + }, + singlePage: { + id: 'course.assessment.form.singlePage', + defaultMessage: 'Single Page', + }, + delayedGradePublication: { + id: 'course.assessment.form.delayedGradePublication', + defaultMessage: 'Enable delayed grade publication', + }, + delayedGradePublicationHint: { + id: 'course.assessment.form.delayedGradePublicationHint', + defaultMessage: + 'When delayed grade publication is on, gradings will not be immediately shown to students. \ + To publish all gradings, you may click Publish Grades in the Submissions page.', + }, + showMcqMrqSolution: { + id: 'course.assessment.form.showMcqMrqSolution', + defaultMessage: 'Show MCQ/MRQ solution(s)', + }, + passwordRequired: { + id: 'course.assessment.form.passwordRequired', + defaultMessage: 'At least one password is required', + }, + passwordProtection: { + id: 'course.assessment.form.passwordProtection', + defaultMessage: 'Enable password protection', + }, + sessionProtection: { + id: 'course.assessment.form.sessionProtection', + defaultMessage: 'Enable session protection', + }, + sessionProtectionHint: { + id: 'course.assessment.form.sessionProtection', + defaultMessage: + 'When session protection is on, students can only access their attempt once. \ + Further access will require the session unlock password.', + }, + viewPasswordHint: { + id: 'course.assessment.form.viewPasswordHint', + defaultMessage: + 'Students need to input this password to View and Attempt this assessment.', + }, + viewPassword: { + id: 'course.assessment.form.viewPassword', + defaultMessage: 'Assessment password', + }, + sessionPasswordHint: { + id: 'course.assessment.form.sessionPasswordHint', + defaultMessage: 'Ideally, do NOT give this password to students.', + }, + sessionPassword: { + id: 'course.assessment.form.sessionPassword', + defaultMessage: 'Session unlock password', + }, + startEndValidationError: { + id: 'course.assessment.form.startEndValidationError', + defaultMessage: 'Must be after starting time', + }, + noTestCaseChosenError: { + id: 'course.assessment.form.noTestCaseChosenError', + defaultMessage: 'Select at least one type of test case', + }, + fetchTabFailure: { + id: 'course.assessment.form.fetchCategoryFailure', + defaultMessage: + 'Loading of Tabs failed. Please refresh the page, or try again.', + }, + tab: { + id: 'course.assessment.form.tab', + defaultMessage: 'Tab', + }, + enableRandomization: { + id: 'course.assessment.form.enable_randomization', + defaultMessage: 'Enable Randomization', + }, + enableRandomizationHint: { + id: 'course.assessment.form.enable_randomization_hint', + defaultMessage: + 'Enables randomized assignment of question bundles to students (per question group)', + }, + assessmentDetails: { + id: 'course.assessment.form.assessmentDetails', + defaultMessage: 'Assessment details', + }, + gamification: { + id: 'course.assessment.form.gamification', + defaultMessage: 'Gamification', + }, + grading: { + id: 'course.assessment.form.grading', + defaultMessage: 'Grading', + }, + answersAndTestCases: { + id: 'course.assessment.form.answersAndTestCases', + defaultMessage: 'Answers and test cases', + }, + organisation: { + id: 'course.assessment.form.organisation', + defaultMessage: 'Organisation', + }, + examsAndAccessControl: { + id: 'course.assessment.form.examsAndAccessControl', + defaultMessage: 'Exams and access control', + }, + personalisedTimelines: { + id: 'course.assessment.form.personalisedTimelines', + defaultMessage: 'Personalised timelines', + }, + unavailableInAutograded: { + id: 'course.assessment.form.unavailableInAutograded', + defaultMessage: 'Unavailable in autograded assessments.', + }, + unavailableInManuallyGraded: { + id: 'course.assessment.form.unavailableInManuallyGraded', + defaultMessage: 'Unavailable in manually graded assessments.', + }, + afterSubmissionGraded: { + id: 'course.assessment.form.afterSubmissionGraded', + defaultMessage: 'After submission is graded and published', + }, + files: { + id: 'course.assessment.form.files', + defaultMessage: 'Files', + }, +}); + +export default translations; diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/types.ts b/client/app/bundles/course/assessment/components/AssessmentForm/types.ts new file mode 100644 index 00000000000..ae94643f7d3 --- /dev/null +++ b/client/app/bundles/course/assessment/components/AssessmentForm/types.ts @@ -0,0 +1,74 @@ +import { connect, ConnectedProps } from 'react-redux'; +import { WrappedComponentProps } from 'react-intl'; +import { FieldValues, UseFormSetError } from 'react-hook-form'; +import { Emits } from 'react-emitter-factory'; + +import { Material } from '../FileManager'; + +interface Tab { + tab_id?: number; + title?: string; +} + +interface FolderAttributes { + folder_id: number; + materials?: Material[]; + + /** + * If `true`, Materials component in Course Settings is enabled + */ + enable_materials_action?: boolean; +} + +interface AchievementTypesConditionAttributes { + new_condition_urls?: { + name?: string; + url?: string; + }[]; + + conditions?: { + name?: string; + description?: string; + edit_url?: string; + delete_url?: string; + }[]; +} + +export interface AssessmentFormEmitter { + isDirty?: boolean; +} + +// @ts-ignore until Assessment store is fully typed +export const connector = connect((state) => ({ tabs: state.editPage.tabs })); + +export interface AssessmentFormProps + extends WrappedComponentProps, + ConnectedProps, + Emits { + tabs: Tab[]; + onSubmit: (data: FieldValues, setError: UseFormSetError) => void; + + initialValues?; + disabled?: boolean; + showPersonalizedTimelineFeatures?: boolean; + randomizationAllowed?: boolean; + folderAttributes?: FolderAttributes; + conditionAttributes?: AchievementTypesConditionAttributes; + + /** + * If `true`, this component is displayed on Edit Assessment page + */ + editing?: boolean; + + /** + * If `true`, this course is gamified + */ + gamified?: boolean; + + /** + * If `true`, Autograded and Manual grading modes can be changed + */ + modeSwitching?: boolean; + + containsCodaveri?: boolean; +} diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx new file mode 100644 index 00000000000..e92c45c9b3c --- /dev/null +++ b/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx @@ -0,0 +1,115 @@ +// @ts-nocheck +// Disable type-checking because as of yup 0.32.11, arguments types +// for yup.when(['a', 'b'], (a, b, schema) => ...) cannot be resolved. +// This is a known issue: https://github.com/jquense/yup/issues/1529 +// Probably fixed in yup 1.0+ with a new function signature with array destructuring +// https://github.com/jquense/yup#:~:text=isBig%27%2C%20(-,%5BisBig%5D,-%2C%20schema) + +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + FieldValues, + SubmitHandler, + useForm, + UseFormReturn, +} from 'react-hook-form'; + +import ft from 'lib/translations/form'; +import t from './translations.intl'; + +const validationSchema = yup.object({ + title: yup.string().required(ft.required), + tab_id: yup.number(), + description: yup.string(), + start_at: yup + .date() + .nullable() + .typeError(ft.invalidDate) + .required(ft.required), + end_at: yup + .date() + .nullable() + .typeError(ft.invalidDate) + .min(yup.ref('start_at'), t.startEndValidationError), + bonus_end_at: yup + .date() + .nullable() + .typeError(ft.invalidDate) + .min(yup.ref('start_at'), t.startEndValidationError), + base_exp: yup.number().typeError(ft.required).required(ft.required), + time_bonus_exp: yup + .number() + .nullable(true) + .transform((_, val) => (val === Number(val) ? val : null)), + published: yup.bool(), + autograded: yup.bool(), + block_student_viewing_after_submitted: yup.bool(), + skippable: yup.bool(), + allow_partial_submission: yup.bool(), + show_mcq_answer: yup.bool(), + tabbed_view: yup.bool().when('autograded', { + is: false, + then: yup.bool().required(ft.required), + }), + delayed_grade_publication: yup.bool(), + password_protected: yup + .bool() + .when( + ['view_password', 'session_password'], + (view_password, session_password, schema: yup.BooleanSchema) => + schema.test({ + test: (password_protected) => + // Check if there is at least 1 password type when password_protected + // is enabled. + password_protected ? session_password || view_password : true, + message: t.passwordRequired, + }), + ), + view_password: yup.string().nullable(), + session_password: yup.string().nullable(), + show_mcq_mrq_solution: yup.bool(), + use_public: yup.bool(), + use_private: yup.bool(), + use_evaluation: yup + .bool() + .when( + ['use_public', 'use_private'], + (use_public, use_private, schema: yup.BooleanSchema) => + schema.test({ + // Check if there is at least 1 selected test case. + test: (use_evaluation) => use_public || use_private || use_evaluation, + message: t.noTestCaseChosenError, + }), + ), + show_private: yup.bool(), + show_evaluation: yup.bool(), + randomization: yup.bool(), + has_personal_times: yup.bool(), + affects_personal_times: yup.bool(), +}); + +const useFormValidation = (initialValues): UseFormReturn => { + const form = useForm({ + defaultValues: { + ...initialValues, + session_protected: Boolean(initialValues?.session_password), + }, + resolver: yupResolver(validationSchema), + }); + + return { + ...form, + + handleSubmit: (onValid, onInvalid): SubmitHandler => { + const postProcessor = (rawData): SubmitHandler => { + if (!rawData.session_protected) rawData.session_password = null; + delete rawData.session_protected; + return onValid(rawData); + }; + + return form.handleSubmit(postProcessor, onInvalid); + }, + }; +}; + +export default useFormValidation; diff --git a/client/app/bundles/course/assessment/components/FileManager/Toolbar.tsx b/client/app/bundles/course/assessment/components/FileManager/Toolbar.tsx new file mode 100644 index 00000000000..d5ccbffc8fe --- /dev/null +++ b/client/app/bundles/course/assessment/components/FileManager/Toolbar.tsx @@ -0,0 +1,85 @@ +import { CSSProperties, ChangeEventHandler } from 'react'; +import { injectIntl, WrappedComponentProps } from 'react-intl'; +import { Button, Grid } from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; + +import t from './translations.intl'; + +/** + * Types are all any for now because DataTable is not fully typed. + */ +interface ToolbarProps extends WrappedComponentProps { + selectedRows; + onAddFiles: (files: File[]) => void; + onDeleteFileWithRowIndex: (index: number) => void; +} + +const styles: { [key: string]: CSSProperties } = { + uploadInput: { + cursor: 'pointer', + position: 'absolute', + top: 0, + bottom: 0, + right: 0, + left: 0, + opacity: 0, + }, +}; + +const Toolbar = (props: ToolbarProps): JSX.Element => { + const { intl } = props; + + const handleDeleteFiles = (e): void => { + e.preventDefault(); + + props.selectedRows?.data?.forEach((row) => { + props.onDeleteFileWithRowIndex?.(row.dataIndex); + }); + }; + + const handleFileInputChange: ChangeEventHandler = (e) => { + e.preventDefault(); + + const input = e.target; + const files = input.files; + if (!files) return; + + props.onAddFiles?.(Array.from(files)); + + input.value = ''; + }; + + return ( + + + + + + {props.selectedRows && ( + <> + + + + + )} + + ); +}; + +export default injectIntl(Toolbar); diff --git a/client/app/bundles/course/assessment/components/FileManager/__test__/index.test.tsx b/client/app/bundles/course/assessment/components/FileManager/__test__/index.test.tsx new file mode 100644 index 00000000000..b271be19cc6 --- /dev/null +++ b/client/app/bundles/course/assessment/components/FileManager/__test__/index.test.tsx @@ -0,0 +1,81 @@ +import MockAdapter from 'axios-mock-adapter'; + +import { + act, + fireEvent, + render, + RenderResult, + waitFor, +} from 'utilities/test-utils'; +import CourseAPI from 'api/course'; +import FileManager from '..'; + +const FOLDER_ID = 1; + +const MATERIALS = [ + { + id: 1, + name: `Material 1`, + updated_at: `2017-01-01T01:00:00.0000000Z`, + deleting: false, + }, + { + id: 2, + name: `Material 2`, + updated_at: `2017-01-01T02:00:00.0000000Z`, + deleting: false, + }, +]; + +const NEW_MATERIAL = { + id: 10, + name: 'Material 3', + updated_at: '2017-01-01T08:00:00.0000000Z', + deleting: false, +}; + +const client = CourseAPI.materialFolders.getClient(); +const mock = new MockAdapter(client); + +let fileManager: RenderResult; +beforeEach(() => { + fileManager = render( + , + ); +}); + +beforeEach(mock.reset); + +describe('', () => { + it('shows existing files', () => { + expect(fileManager.getByText('Material 1')).toBeVisible(); + expect(fileManager.getByText('Material 2')).toBeVisible(); + }); + + it('uploads a new file and shows it', async () => { + mock + .onPut( + `/courses/${global.courseId}/materials/folders/${FOLDER_ID}/upload_materials`, + ) + .reply(200, { + materials: [NEW_MATERIAL], + }); + + const uploadApi = jest.spyOn(CourseAPI.materialFolders, 'upload'); + const addFilesButton = fileManager.getByText('Add Files'); + expect(addFilesButton).toBeVisible(); + + const fileInput = fileManager.getByTestId('FileInput'); + + act(() => { + fireEvent.change(fileInput, { + target: { files: [{ name: NEW_MATERIAL.name }] }, + }); + }); + + await waitFor(() => expect(uploadApi).toHaveBeenCalled()); + + const newMaterialRow = await fileManager.findByText(NEW_MATERIAL.name); + expect(newMaterialRow).toBeVisible(); + }); +}); diff --git a/client/app/bundles/course/assessment/components/FileManager/index.tsx b/client/app/bundles/course/assessment/components/FileManager/index.tsx new file mode 100644 index 00000000000..db1d99bbc2f --- /dev/null +++ b/client/app/bundles/course/assessment/components/FileManager/index.tsx @@ -0,0 +1,214 @@ +import { useState, CSSProperties } from 'react'; +import { injectIntl, WrappedComponentProps } from 'react-intl'; +import { Checkbox, CircularProgress } from '@mui/material'; +import { toast } from 'react-toastify'; +import { AxiosError } from 'axios'; + +import CourseAPI from 'api/course'; +import { formatLongDateTime } from 'lib/moment'; +import { getWorkbinFileURL } from 'lib/helpers/url-builders'; +import { getCourseId } from 'lib/helpers/url-helpers'; +import DataTable from 'lib/components/DataTable'; +import InfoLabel from 'lib/components/InfoLabel'; +import Toolbar from './Toolbar'; +import t from './translations.intl'; + +export interface Material { + id?: number; + name?: string; + updated_at?: string; + deleting?: boolean; +} + +interface FileManagerProps extends WrappedComponentProps { + folderId: number; + disabled?: boolean; + materials?: Material[]; +} + +const styles: { [key: string]: CSSProperties } = { + uploadingIndicator: { + margin: '9px', + }, +}; + +const FileManager = (props: FileManagerProps): JSX.Element => { + const { disabled, intl } = props; + + const [materials, setMaterials] = useState(props.materials ?? []); + const [uploadingMaterials, setUploadingMaterials] = useState([]); + + const loadData = (): (string | undefined)[][] => { + const materialsData = materials?.map((file) => [ + file.name, + formatLongDateTime(file.updated_at), + ]); + + const uploadingMaterialsData = uploadingMaterials?.map((file) => [ + file.name, + intl.formatMessage(t.uploadingFile), + ]); + + return [...materialsData, ...uploadingMaterialsData]; + }; + + /** + * Remove materials from uploading list and add new materials from server response to existing + * materials list. + */ + const updateMaterials = (mat: Material[], response): void => { + setUploadingMaterials((current) => + current.filter((m) => mat.indexOf(m) === -1), + ); + + const newMaterials = response?.data?.materials; + if (!newMaterials) return; + setMaterials((current) => current.concat(newMaterials)); + }; + + /** + * Remove given materials from uploading list and display error message. + */ + const removeUploads = (mat: Material[], response): void => { + const messageFromServer = response?.data?.errors; + const failureMessage = intl.formatMessage(t.uploadFail); + + setUploadingMaterials((current) => + current.filter((m) => mat.indexOf(m) === -1), + ); + + toast.error(messageFromServer || failureMessage); + }; + + /** + * Uploads the given files to the corresponding `folderId`. + * @param files array of `File`s mapped from the file input in `Toolbar.tsx` + */ + const uploadFiles = async (files: File[]): Promise => { + const { folderId } = props; + + const newMaterials = files.map((file) => ({ name: file.name })); + setUploadingMaterials((current) => current.concat(newMaterials)); + + try { + const response = await CourseAPI.materialFolders.upload(folderId, files); + updateMaterials(newMaterials, response); + } catch (error) { + if (error instanceof AxiosError) + removeUploads(newMaterials, error.response); + } + }; + + /** + * Deletes a file on the `DataTable` asynchronously. + * @param index row index of the file selected for deletion in the `DataTable` + */ + const deleteFileWithRowIndex = async (index: number): Promise => { + const { id, name } = materials[index]; + + setMaterials((current) => + current?.map((m) => (m.id === id ? { ...m, deleting: true } : m)), + ); + + try { + await CourseAPI.materials.destroy(props.folderId, id); + setMaterials((current) => current?.filter((m) => m.id !== id)); + toast.success(intl.formatMessage(t.deleteSuccess, { name })); + } catch (error) { + setMaterials((current) => + current?.map((m) => (m.id === id ? { ...m, deleting: false } : m)), + ); + toast.error(intl.formatMessage(t.deleteFail, { name })); + } + }; + + const ToolbarComponent = (toolbarProps): JSX.Element => ( + + ); + + const DisabledMessages = injectIntl( + (messagesProps): JSX.Element => ( + <> + + + {materials.length > 0 && ( + + )} + + ), + ); + + const RowStartComponent = (rowStartProps): JSX.Element => { + const type = rowStartProps['data-description']; + const index = rowStartProps['data-index']; + + const isBodyRow = type === 'row-select'; + const isUploadingMaterial = index >= materials.length; + const isDeletingMaterial = + index < materials.length && materials[index]?.deleting; + + if (isBodyRow && (isUploadingMaterial || isDeletingMaterial)) { + return ; + } + + return ; + }; + + const renderFileNameRowContent = ( + value: string, + { rowIndex }, + ): string | JSX.Element => { + if (rowIndex >= materials.length) return value; + + const material = materials[rowIndex]; + if (!material) return value; + + const url = getWorkbinFileURL(getCourseId(), props.folderId, material.id); + + return ( + + {value} + + ); + }; + + return ( + ({ size: 'small', sx: { overflow: 'hidden' } }), + }} + components={{ + Checkbox: RowStartComponent, + TableToolbar: !disabled ? ToolbarComponent : DisabledMessages, + TableToolbarSelect: !disabled ? ToolbarComponent : DisabledMessages, + ...(materials.length <= 0 + ? { + TableBody: () => null, + TableHead: () => null, + } + : null), + }} + /> + ); +}; + +export default injectIntl(FileManager); diff --git a/client/app/bundles/course/assessment/components/FileManager/translations.intl.js b/client/app/bundles/course/assessment/components/FileManager/translations.intl.js new file mode 100644 index 00000000000..727c97b207f --- /dev/null +++ b/client/app/bundles/course/assessment/components/FileManager/translations.intl.js @@ -0,0 +1,48 @@ +import { defineMessages } from 'react-intl'; + +const translations = defineMessages({ + deleteSuccess: { + id: 'course.assessment.fileManager.deleteSuccess', + defaultMessage: '"{name}" was deleted.', + }, + deleteFail: { + id: 'course.assessment.fileManager.deleteFail', + defaultMessage: 'Failed to delete "{name}", please try again.', + }, + uploadFail: { + id: 'course.assessment.fileManager.uploadFail', + defaultMessage: 'Failed to upload materials.', + }, + addFiles: { + id: 'course.assessment.fileManager.addFiles', + defaultMessage: 'Add Files', + }, + deleteSelected: { + id: 'course.assessment.fileManager.deleteSelected', + defaultMessage: 'Delete Selected', + }, + fileName: { + id: 'course.assessment.fileManager.fileName', + defaultMessage: 'File name', + }, + dateAdded: { + id: 'course.assessment.fileManager.dateAdded', + defaultMessage: 'Date added', + }, + uploadingFile: { + id: 'course.assessment.fileManager.uploadingFile', + defaultMessage: 'Uploading file...', + }, + disableNewFile: { + id: 'course.assessment.fileManager.disableNewFile', + defaultMessage: + 'You cannot add new files because the Materials component is disabled in Course Settings.', + }, + studentCannotSeeFiles: { + id: 'course.assessment.fileManager.studentCannotSeeFiles', + defaultMessage: + 'Students cannot see these files because the Materials component is disabled in Course Settings.', + }, +}); + +export default translations; diff --git a/client/app/bundles/course/assessment/containers/AssessmentForm/index.jsx b/client/app/bundles/course/assessment/containers/AssessmentForm/index.jsx deleted file mode 100644 index 0b7f97fb0c1..00000000000 --- a/client/app/bundles/course/assessment/containers/AssessmentForm/index.jsx +++ /dev/null @@ -1,799 +0,0 @@ -/* eslint-disable camelcase */ -import { useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { Controller, useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import FormDateTimePickerField from 'lib/components/form/fields/DateTimePickerField'; -import FormRichTextField from 'lib/components/form/fields/RichTextField'; -import FormSelectField from 'lib/components/form/fields/SelectField'; -import FormTextField from 'lib/components/form/fields/TextField'; -import FormToggleField from 'lib/components/form/fields/ToggleField'; -import ErrorText from 'lib/components/ErrorText'; -import ConditionList from 'lib/components/course/ConditionList'; -import formTranslations from 'lib/translations/form'; -import { achievementTypesConditionAttributes, typeMaterial } from 'lib/types'; -import ReactTooltip from 'react-tooltip'; -import translations from './translations.intl'; -import MaterialUploader from '../MaterialUploader'; -import { fetchTabs } from './actions'; - -const styles = { - flexGroup: { - display: 'flex', - }, - flexChild: { - flex: 1, - }, - toggle: { - marginTop: 16, - }, - hint: { - fontSize: 14, - marginBottom: 12, - }, - conditions: { - marginTop: 24, - }, -}; - -const validationSchema = yup.object({ - title: yup.string().required(formTranslations.required), - tab_id: yup.number(), - description: yup.string(), - start_at: yup - .date() - .nullable() - .typeError(formTranslations.invalidDate) - .required(formTranslations.required), - end_at: yup - .date() - .nullable() - .typeError(formTranslations.invalidDate) - .min(yup.ref('start_at'), translations.startEndValidationError), - bonus_end_at: yup - .date() - .nullable() - .typeError(formTranslations.invalidDate) - .min(yup.ref('start_at'), translations.startEndValidationError), - base_exp: yup - .number() - .typeError(formTranslations.required) - .required(formTranslations.required), - time_bonus_exp: yup - .number() - .nullable(true) - .transform((_, val) => (val === Number(val) ? val : null)), - published: yup.bool(), - autograded: yup.bool(), - block_student_viewing_after_submitted: yup.bool(), - skippable: yup.bool(), - allow_partial_submission: yup.bool(), - show_mcq_answer: yup.bool(), - tabbed_view: yup.bool().when('autograded', { - is: false, - then: yup.bool().required(formTranslations.required), - }), - delayed_grade_publication: yup.bool(), - password_protected: yup - .bool() - .when( - ['view_password', 'session_password'], - (view_password, session_password, schema) => - schema.test({ - test: (password_protected) => - // Check if there is at least 1 password type when password_protectd - // is enabled. - password_protected ? session_password || view_password : true, - message: translations.passwordRequired, - }), - ), - view_password: yup.string().nullable(), - session_password: yup.string().nullable(), - show_mcq_mrq_solution: yup.bool(), - use_public: yup.bool(), - use_private: yup.bool(), - use_evaluation: yup - .bool() - .when(['use_public', 'use_private'], (use_public, use_private, schema) => - schema.test({ - // Check if there is at least 1 selected test case. - test: (use_evaluation) => use_public || use_private || use_evaluation, - message: translations.noTestCaseChosenError, - }), - ), - show_private: yup.bool(), - show_evaluation: yup.bool(), - randomization: yup.bool(), - has_personal_times: yup.bool(), - affects_personal_times: yup.bool(), -}); - -const AssessmentForm = (props) => { - const { - conditionAttributes, - containsCodaveri, - disabled, - dispatch, - editing, - gamified, - folderAttributes, - initialValues, - modeSwitching, - onSubmit, - randomizationAllowed, - showPersonalizedTimelineFeatures, - tabs, - intl, - } = props; - const { - control, - handleSubmit, - setError, - watch, - formState: { errors }, - } = useForm({ - defaultValues: initialValues, - resolver: yupResolver(validationSchema), - }); - const autograded = watch('autograded'); - const passwordProtected = watch('password_protected'); - - // Load all tabs if data is loaded, otherwise fall back to current assessment tab. - const loadedTabs = tabs || watch('tabs'); - - useEffect(() => { - if (editing) { - const failureMessage = ( - - ); - dispatch(fetchTabs(failureMessage)); - } - }, [dispatch]); - - const autogradedToggleTooltip = containsCodaveri ? ( - - ) : ( - - ); - - const renderPasswordFields = () => ( -
- ( - - )} - /> -
- -
- - ( - - )} - /> -
- -
-
- ); - - const renderExtraOptions = () => { - if (autograded) { - return ( -
- ( - } - renderIf={autograded} - style={styles.toggle} - /> - )} - /> - ( - - } - renderIf={autograded} - style={styles.toggle} - /> - )} - /> - ( - } - renderIf={autograded} - style={styles.toggle} - /> - )} - /> -
- -
-
- ); - } - const options = [ - { - value: false, - label: , - }, - { - value: true, - label: , - }, - ]; - return ( - <> - ( - } - options={options} - renderIf={!autograded} - type="boolean" - /> - )} - /> - ( - - } - renderIf={!autograded} - style={styles.toggle} - /> - )} - /> -
- -
- - ( - } - renderIf={!autograded} - style={styles.toggle} - /> - )} - /> - {passwordProtected && renderPasswordFields()} - - ); - }; - - const renderTabs = () => { - if (!loadedTabs) { - return null; - } - - const options = loadedTabs.map((tab) => ({ - value: tab.tab_id, - label: tab.title, - })); - return ( - ( - } - options={options} - /> - )} - /> - ); - }; - - return ( -
onSubmit(data, setError))} - > - -
- ( - } - fullWidth - InputLabelProps={{ - shrink: true, - }} - required - style={styles.flexChild} - variant="standard" - /> - )} - /> - {editing && renderTabs(loadedTabs, disabled)} -
- ( - } - fullWidth - InputLabelProps={{ - shrink: true, - }} - variant="standard" - /> - )} - /> -
- ( - } - style={styles.flexChild} - /> - )} - /> - ( - } - style={styles.flexChild} - /> - )} - /> - {gamified && ( - ( - } - style={styles.flexChild} - /> - )} - /> - )} -
- {gamified && ( -
- ( - } - InputLabelProps={{ - shrink: true, - }} - onWheel={(event) => event.currentTarget.blur()} - style={styles.flexChild} - type="number" - variant="standard" - /> - )} - /> - ( - } - InputLabelProps={{ - shrink: true, - }} - onWheel={(event) => event.currentTarget.blur()} - style={styles.flexChild} - type="number" - variant="standard" - /> - )} - /> -
- )} - - {editing && ( - ( - } - style={styles.toggle} - /> - )} - /> - )} - - - {autogradedToggleTooltip} - - -
- ( - } - style={styles.toggle} - /> - )} - /> -
- - {modeSwitching && !containsCodaveri && ( -
- -
- )} - - ( - - } - style={styles.toggle} - /> - )} - /> - - {renderExtraOptions()} - - ( - } - style={styles.toggle} - /> - )} - /> -
- -
- -
- -
- -
- ( - } - style={styles.flexChild} - /> - )} - /> - ( - } - style={styles.flexChild} - /> - )} - /> - ( - } - style={styles.flexChild} - /> - )} - /> -
- - ( - } - style={styles.toggle} - /> - )} - /> -
- -
- ( - } - style={styles.toggle} - /> - )} - /> -
- -
- - {randomizationAllowed && ( - <> - ( - - } - style={styles.toggle} - /> - )} - /> -
- -
- - )} - - {showPersonalizedTimelineFeatures && ( - <> - ( - } - style={styles.toggle} - /> - )} - /> -
- -
- - ( - - } - style={styles.toggle} - /> - )} - /> -
- -
- - )} - - {folderAttributes && ( - <> -
- - - )} - {editing && conditionAttributes && ( -
- -
- )} - - ); -}; - -AssessmentForm.defaultProps = { - gamified: true, -}; - -AssessmentForm.propTypes = { - disabled: PropTypes.bool, - dispatch: PropTypes.func.isRequired, - start_at: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - end_at: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - bonus_end_at: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.instanceOf(Date), - ]), - autograded: PropTypes.bool, - password_protected: PropTypes.bool, - showPersonalizedTimelineFeatures: PropTypes.bool, - tabs: PropTypes.arrayOf( - PropTypes.shape({ - tab_id: PropTypes.number, - title: PropTypes.string, - }), - ), - // If randomization is enabled for the assessment - randomization: PropTypes.bool, - - onSubmit: PropTypes.func.isRequired, - // If the Form is in editing mode, `published` button will be displayed. - editing: PropTypes.bool, - // if the EXP fields should be displayed - gamified: PropTypes.bool, - // If the personalized timeline fields should be displayed - show_personalized_timeline_features: PropTypes.bool, - // If randomization is allowed for assessments in the current course - randomizationAllowed: PropTypes.bool, - // If allow to switch between autoraded and manually graded mode. - modeSwitching: PropTypes.bool, - // If an assessment contains question of programming codaveri type - containsCodaveri: PropTypes.bool, - folderAttributes: PropTypes.shape({ - folder_id: PropTypes.number, - // If any action (upload, delete and download) of the materials - enable_materials_action: PropTypes.bool, - // See MaterialFormContainer for detailed PropTypes. - materials: typeMaterial, - }), - // Condtions will be displayed if the attributes are present. - conditionAttributes: achievementTypesConditionAttributes, - initialValues: PropTypes.object, - intl: PropTypes.object, -}; - -function mapStateToProps(state) { - return { - tabs: state.editPage.tabs, - }; -} - -export default connect(mapStateToProps)(injectIntl(AssessmentForm)); diff --git a/client/app/bundles/course/assessment/containers/AssessmentForm/translations.intl.js b/client/app/bundles/course/assessment/containers/AssessmentForm/translations.intl.js deleted file mode 100644 index 69cb6281dee..00000000000 --- a/client/app/bundles/course/assessment/containers/AssessmentForm/translations.intl.js +++ /dev/null @@ -1,224 +0,0 @@ -import { defineMessages } from 'react-intl'; - -const translations = defineMessages({ - title: { - id: 'course.assessment.form.title', - defaultMessage: 'Title', - }, - description: { - id: 'course.assessment.form.description', - defaultMessage: 'Description', - }, - startAt: { - id: 'course.assessment.form.startAt', - defaultMessage: 'Start At *', - }, - endAt: { - id: 'course.assessment.form.endAt', - defaultMessage: 'End At', - }, - bonusEndAt: { - id: 'course.assessment.form.bonusEndAt', - defaultMessage: 'Bonus End At', - }, - baseExp: { - id: 'course.assessment.form.baseExp', - defaultMessage: 'Base EXP', - }, - timeBonusExp: { - id: 'course.assessment.form.timeBonusExp', - defaultMessage: 'Time Bonus EXP', - }, - autograded: { - id: 'course.assessment.form.autograded', - defaultMessage: 'Autograded', - }, - blockStudentViewingAfterSubmitted: { - id: 'course.assessment.form.blockStudentViewingAfterSubmitted', - defaultMessage: 'Block Students from Viewing Finalized Submissions', - }, - autogradeTestCasesHint: { - id: 'course.assessment.form.autogradeTestCasesHint', - defaultMessage: 'Select test case types for grade and exp calculation:', - }, - usePublic: { - id: 'course.assessment.form.usePublic', - defaultMessage: 'Public', - }, - usePrivate: { - id: 'course.assessment.form.usePrivate', - defaultMessage: 'Private', - }, - useEvaluation: { - id: 'course.assessment.form.useEvaluation', - defaultMessage: 'Evaluation', - }, - allowPartialSubmission: { - id: 'course.assessment.form.allowPartialSubmission', - defaultMessage: 'Allow submission with incorrect answers', - }, - showMcqAnswer: { - id: 'course.assessment.form.showMcqAnswer', - defaultMessage: 'Show MCQ Submit Result', - }, - showMcqAnswerHint: { - id: 'course.assessment.form.showMcqAnswerHint', - defaultMessage: - 'Students can try to submit answer to MCQ and get feedback until they get the right answer', - }, - showPrivate: { - id: 'course.assessment.form.showPrivate', - defaultMessage: 'Show private tests', - }, - showPrivateHint: { - id: 'course.assessment.form.showPrivateHint', - defaultMessage: - 'Show private tests to students after the submission is graded and published (For programming questions)', - }, - showEvaluation: { - id: 'course.assessment.form.showEvaluation', - defaultMessage: 'Show evaluation tests', - }, - showEvaluationHint: { - id: 'course.assessment.form.showEvaluationHint', - defaultMessage: - 'Show evaluation tests to students after the submission is graded and published (For programming questions)', - }, - hasPersonalTimes: { - id: 'course.assessment.form.hasPersonalTimes', - defaultMessage: 'Has personal times', - }, - hasPersonalTimesHint: { - id: 'course.assessment.form.hasPersonalTimesHint', - defaultMessage: - 'Timings for this item will be automatically adjusted for users based on learning rate', - }, - affectsPersonalTimes: { - id: 'course.assessment.form.affectsPersonalTimes', - defaultMessage: 'Affects personal times', - }, - affectsPersonalTimesHint: { - id: 'course.assessment.form.affectsPersonalTimesHint', - defaultMessage: - "Student's submission time for this item will be taken into account \ - when updating personal times for other items", - }, - published: { - id: 'course.assessment.form.published', - defaultMessage: 'Published', - }, - autogradedHint: { - id: 'course.assessment.form.autogradedHint', - defaultMessage: - 'Automatically assign grade and experience points after assessment is \ - submitted. Answers that are not auto-gradable will always receive the maximum grade.', - }, - modeSwitchingDisabled: { - id: 'course.assessment.form.modeSwitchingHint', - defaultMessage: - 'Switch to autograded mode is not allowed as there are submissions \ - for the assessment.', - }, - containsCodaveriQuestion: { - id: 'course.assessment.form.modeSwitchingHint', - defaultMessage: - "Switch to autograded mode is not allowed as there's \ - codaveri programming question type. This question type is only supported \ - in non-autograded assessment.", - }, - skippable: { - id: 'course.assessment.form.skippable', - defaultMessage: 'Allow to skip steps', - }, - layout: { - id: 'course.assessment.form.layout', - defaultMessage: 'Layout', - }, - tabbedView: { - id: 'course.assessment.form.tabbedView', - defaultMessage: 'Tabbed View', - }, - singlePage: { - id: 'course.assessment.form.singlePage', - defaultMessage: 'Single Page', - }, - delayedGradePublication: { - id: 'course.assessment.form.delayedGradePublication', - defaultMessage: 'Delayed Grade Publication', - }, - delayedGradePublicationHint: { - id: 'course.assessment.form.delayedGradePublicationHint', - defaultMessage: - "When delayed grade publication is enabled, gradings done by course staff will \ - not be immediately shown to the student. To publish all gradings for this assessment, click \ - on the 'Publish Grades' button on the top right of the submissions listing for this assessment.", - }, - showMcqMrqSolution: { - id: 'course.assessment.form.showMcqMrqSolution', - defaultMessage: 'Show MCQ/MRQ Solution(s)', - }, - showMcqMrqSolutionHint: { - id: 'course.assessment.form.showMcqMrqSolutionHint', - defaultMessage: - 'Show MCQ/MRQ Solution(s) when grades of submissions have been published.', - }, - passwordRequired: { - id: 'course.assessment.form.passwordRequired', - defaultMessage: 'At least one password is required', - }, - passwordProtection: { - id: 'course.assessment.form.passwordProtection', - defaultMessage: 'Password Protection', - }, - viewPasswordHint: { - id: 'course.assessment.form.viewPasswordHint', - defaultMessage: - 'When assessment password is enabled, students are required to input the password in order to \ - view/attempt the assessment.', - }, - viewPassword: { - id: 'course.assessment.form.viewPassword', - defaultMessage: 'Input Assessment Password', - }, - sessionPasswordHint: { - id: 'course.assessment.form.sessionPasswordHint', - defaultMessage: - "When submission password is enabled, students are allowed to access their \ - submission once. Further attempts at editing the submission using a different session are \ - not allowed unless the password is provided by the staff. This can be used to prevent \ - students from accessing each other's submissions in exams. You should NOT give the submission password \ - to the students.", - }, - sessionPassword: { - id: 'course.assessment.form.sessionPassword', - defaultMessage: 'Input Submission Password', - }, - startEndValidationError: { - id: 'course.assessment.form.startEndValidationError', - defaultMessage: "Must be after 'Start At'", - }, - noTestCaseChosenError: { - id: 'course.assessment.form.noTestCaseChosenError', - defaultMessage: 'Select at least one type of test case', - }, - fetchTabFailure: { - id: 'course.assessment.form.fetchCategoryFailure', - defaultMessage: - 'Loading of Tabs failed. Please refresh the page, or try again.', - }, - tab: { - id: 'course.assessment.form.tab', - defaultMessage: 'Tab', - }, - enableRandomization: { - id: 'course.assessment.form.enable_randomization', - defaultMessage: 'Enable Randomization', - }, - enableRandomizationHint: { - id: 'course.assessment.form.enable_randomization_hint', - defaultMessage: - 'Enables randomized assignment of question bundles to students (per question group)', - }, -}); - -export default translations; diff --git a/client/app/bundles/course/assessment/containers/MaterialUploader/Material.jsx b/client/app/bundles/course/assessment/containers/MaterialUploader/Material.jsx deleted file mode 100644 index 0de3abe8972..00000000000 --- a/client/app/bundles/course/assessment/containers/MaterialUploader/Material.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage, defineMessages } from 'react-intl'; -import { formatLongDateTime } from 'lib/moment'; -import { - Avatar, - CircularProgress, - IconButton, - ListItem, - ListItemAvatar, - ListItemText, - ListItemSecondaryAction, -} from '@mui/material'; -import Assignment from '@mui/icons-material/Assignment'; -import Delete from '@mui/icons-material/Delete'; -import ReactTooltip from 'react-tooltip'; - -const styles = { - avatar: { - height: '32px', - width: '32px', - }, - iconButton: { - color: 'black', - width: 24, - height: 24, - padding: 4, - marginRight: 16, - }, - secondaryText: { - fontSize: 12, - }, -}; - -const translations = defineMessages({ - uploading: { - id: 'course.material.uploading', - defaultMessage: 'Uploading', - }, - disableDelete: { - id: 'course.material.disableDelete', - defaultMessage: - 'This action is unavailable as the Materials Component is disabled in the Admin Settings', - }, -}); - -const propTypes = { - id: PropTypes.number, - name: PropTypes.string.isRequired, - updatedAt: PropTypes.string, - onMaterialDelete: PropTypes.func, - deleting: PropTypes.bool, - uploading: PropTypes.bool, - disabled: PropTypes.bool, -}; - -class Material extends PureComponent { - onDelete = (e) => { - e.preventDefault(); - const { id, name, onMaterialDelete } = this.props; - if (onMaterialDelete) onMaterialDelete(id, name); - }; - - renderIcon() { - const { disabled } = this.props; - if (this.props.deleting || this.props.uploading) { - return ; - } - - return ( - - - - - - - ); - } - - renderSecondaryText() { - if (this.props.uploading) { - return ; - } - const { updatedAt } = this.props; - return ( -
{formatLongDateTime(updatedAt)}
- ); - } - - render() { - const { name } = this.props; - - return ( - - - - - - - - {this.renderIcon()} - - ); - } -} - -Material.propTypes = propTypes; - -export default Material; diff --git a/client/app/bundles/course/assessment/containers/MaterialUploader/MaterialList.jsx b/client/app/bundles/course/assessment/containers/MaterialUploader/MaterialList.jsx deleted file mode 100644 index 73864ecedc9..00000000000 --- a/client/app/bundles/course/assessment/containers/MaterialUploader/MaterialList.jsx +++ /dev/null @@ -1,155 +0,0 @@ -import PropTypes from 'prop-types'; -import { FormattedMessage, defineMessages } from 'react-intl'; -import { Button, List, ListSubheader, Divider } from '@mui/material'; -import Add from '@mui/icons-material/Add'; -import NotificationBar, { - notificationShape, -} from 'lib/components/NotificationBar'; -import ReactTooltip from 'react-tooltip'; -import Material from './Material'; - -const translations = defineMessages({ - addFiles: { - id: 'course.material.addFiles', - defaultMessage: 'Add Files', - }, - disableNewFile: { - id: 'course.material.disableNewFile', - defaultMessage: - 'This action is unavailable as the Materials Component is disabled in the Admin Settings', - }, -}); - -const propTypes = { - materials: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - updated_at: PropTypes.string, - deleting: PropTypes.bool, - }), - ), - // The popup notification message - notification: notificationShape, - onMaterialDelete: PropTypes.func.isRequired, - // The materials that are being uploading. - uploadingMaterials: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string, - uploading: PropTypes.bool, - }), - ), - onFileInputChange: PropTypes.func, - enableMaterialsAction: PropTypes.bool, -}; - -const defaultProps = { - materials: [], - uploadingMaterials: [], -}; - -const styles = { - newFileButton: { - verticalAlign: 'middle', - }, - uploadInput: { - cursor: 'pointer', - position: 'absolute', - top: 0, - bottom: 0, - right: 0, - left: 0, - opacity: 0, - }, -}; - -const MaterialList = (props) => { - const { - materials, - uploadingMaterials, - onMaterialDelete, - onFileInputChange, - enableMaterialsAction, - } = props; - const header = ( - - ); - - const materialNodes = materials.map((material) => ( - - )); - - const uploadingMaterialNodes = uploadingMaterials.map((material) => ( - - )); - - const newFileButton = ( - <> -
- -
- - - - - ); - - return ( - <> - - - {(materials.length > 0 || uploadingMaterials.length > 0) && ( - {header} - )} - {materialNodes} - {uploadingMaterialNodes} - {newFileButton} - - - - - ); -}; - -MaterialList.propTypes = propTypes; -MaterialList.defaultProps = defaultProps; - -export default MaterialList; diff --git a/client/app/bundles/course/assessment/containers/MaterialUploader/__test__/__snapshots__/index.test.js.snap b/client/app/bundles/course/assessment/containers/MaterialUploader/__test__/__snapshots__/index.test.js.snap deleted file mode 100644 index 812d7ae7162..00000000000 --- a/client/app/bundles/course/assessment/containers/MaterialUploader/__test__/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,181 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders the material 1`] = ` - - - - - - - - 01 Jan 2017, 4:00pm - - } - /> - - - - - - - - - -`; - -exports[` renders the component with materials 1`] = ` - - - - - - - - -
- } - style={ - Object { - "verticalAlign": "middle", - } - } - > - - - -
- - - -
- - -
-`; diff --git a/client/app/bundles/course/assessment/containers/MaterialUploader/__test__/index.test.js b/client/app/bundles/course/assessment/containers/MaterialUploader/__test__/index.test.js deleted file mode 100644 index 885effef222..00000000000 --- a/client/app/bundles/course/assessment/containers/MaterialUploader/__test__/index.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import { mount, shallow } from 'enzyme'; -import MockAdapter from 'axios-mock-adapter'; -import ProviderWrapper from 'lib/components/ProviderWrapper'; -import CourseAPI from 'api/course'; -import MaterialUploader from '../index'; -import MaterialList from '../MaterialList'; -import Material from '../Material'; - -const folderId = 1; -const uploadedMaterial = { - id: 10, - name: 'Uploaded Material', - updated_at: '2017-01-01T08:00:00.0000000Z', - deleting: false, -}; -const materials = [1, 2].map((id) => ({ - id, - name: `Material ${id}`, - updated_at: `2017-01-01T0${id}:00:00.0000000Z`, - deleting: false, -})); - -// Mock axios -const client = CourseAPI.materialFolders.getClient(); -const mock = new MockAdapter(client); - -beforeEach(() => { - mock.reset(); -}); - -describe('', () => { - it('renders the component with materials', () => { - const materialList = shallow( - , - ); - - expect(materialList).toMatchSnapshot(); - }); -}); - -describe('', () => { - it('renders the material', () => { - const material = shallow( - , - ); - - expect(material).toMatchSnapshot(); - }); -}); - -describe('', () => { - it('uploads the material', async () => { - mock - .onPut( - `/courses/${courseId}/materials/folders/${folderId}/upload_materials`, - ) - .reply(200, { - materials: [uploadedMaterial], - }); - - const materialUploader = mount( - - - , - ); - - expect(materialUploader.find('Material')).toHaveLength(2); - - const spyUpload = jest.spyOn(CourseAPI.materialFolders, 'upload'); - // Upload a file - materialUploader.find('input[type="file"]').simulate('change', { - target: { - files: [{ name: 'Uploading file' }], - }, - }); - - await sleep(1); - expect(spyUpload).toHaveBeenCalled(); - expect(materialUploader.find('Material')).toHaveLength(3); - }); -}); diff --git a/client/app/bundles/course/assessment/containers/MaterialUploader/index.jsx b/client/app/bundles/course/assessment/containers/MaterialUploader/index.jsx deleted file mode 100644 index 2e5e32a3fcd..00000000000 --- a/client/app/bundles/course/assessment/containers/MaterialUploader/index.jsx +++ /dev/null @@ -1,143 +0,0 @@ -import { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import CourseAPI from 'api/course'; -import { typeMaterial } from 'lib/types'; -import MaterialList from './MaterialList'; -import translations from './translations.intl'; - -const propTypes = { - folderId: PropTypes.number.isRequired, - materials: typeMaterial, - enableMaterialsAction: PropTypes.bool.isRequired, -}; - -class MaterialUploader extends PureComponent { - constructor(props) { - super(props); - this.state = { - materials: props.materials, - uploadingMaterials: [], - }; - } - - onFileInputChange = (e) => { - e.preventDefault(); - const fileInput = e.target; - const { folderId } = this.props; - const files = fileInput.files; - - const materials = []; - for (let i = 0; i < files.length; i += 1) { - materials.push({ name: files[i].name }); - } - this.setState((state) => ({ - uploadingMaterials: state.uploadingMaterials.concat(materials), - })); - - CourseAPI.materialFolders - .upload(folderId, files) - .then((response) => { - this.updateMaterials(materials, response); - }) - .catch((error) => { - this.removeUploads(materials, error.response); - // Set the value to null so that the files can be selected again. - fileInput.value = null; - }); - }; - - onMaterialDelete = (id, name) => { - this.setState((state) => { - // Update UI to show the loader. - const updatedMaterials = state.materials.map((m) => { - if (m.id === id) { - return { ...m, deleting: true }; - } - return m; - }); - - return { materials: updatedMaterials }; - }); - - CourseAPI.materials - .destroy(this.props.folderId, id) - .then(() => { - this.setState((state) => { - // Remove material from the list - const materials = state.materials.filter((m) => m.id !== id); - const successMessage = ( - - ); - - return { materials, notification: { message: successMessage } }; - }); - }) - .catch(() => { - this.setState((state) => { - // Display failure message and restore the material to not deleting state - const materials = state.materials.map((m) => { - if (m.id === id) { - return { ...m, deleting: false }; - } - return m; - }); - const failureMessage = ( - - ); - - return { materials, notification: { message: failureMessage } }; - }); - }); - }; - - // Remove given materials from uploading list and display error message. - removeUploads(materials, response) { - const messageFromServer = response && response.data && response.data.errors; - const failureMessage = ; - this.setState((state) => ({ - uploadingMaterials: state.uploadingMaterials.filter( - (m) => materials.indexOf(m) === -1, - ), - notification: { message: messageFromServer || failureMessage }, - })); - } - - // Remove materials from uploading list and add new materials from server response to existing - // materials list. - updateMaterials(materials, response) { - const uploadingMaterials = this.state.uploadingMaterials.filter( - (m) => materials.indexOf(m) === -1, - ); - const newState = { - uploadingMaterials, - }; - - const newMaterials = response && response.data && response.data.materials; - if (newMaterials) { - newState.materials = this.state.materials.concat(newMaterials); - } - this.setState(newState); - } - - render() { - return ( - - ); - } -} - -MaterialUploader.propTypes = propTypes; - -export default MaterialUploader; diff --git a/client/app/bundles/course/assessment/containers/MaterialUploader/translations.intl.js b/client/app/bundles/course/assessment/containers/MaterialUploader/translations.intl.js deleted file mode 100644 index 165ca7f19b8..00000000000 --- a/client/app/bundles/course/assessment/containers/MaterialUploader/translations.intl.js +++ /dev/null @@ -1,18 +0,0 @@ -import { defineMessages } from 'react-intl'; - -const translations = defineMessages({ - deleteSuccess: { - id: 'course.assessment.materialList.deleteSuccess', - defaultMessage: '"{name}" was deleted.', - }, - deleteFail: { - id: 'course.assessment.materialList.deleteFail', - defaultMessage: 'Failed to delete "{name}", please try again.', - }, - uploadFail: { - id: 'course.assessment.materialList.uploadFail', - defaultMessage: 'Failed to upload materials.', - }, -}); - -export default translations; diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/__test__/index.test.js b/client/app/bundles/course/assessment/pages/AssessmentEdit/__test__/index.test.js deleted file mode 100644 index 427ace4777d..00000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/__test__/index.test.js +++ /dev/null @@ -1,135 +0,0 @@ -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import ProviderWrapper from 'lib/components/ProviderWrapper'; -import CourseAPI from 'api/course'; -import storeCreator from '../../../store'; -import AssessmentEdit from '../index'; - -describe('', () => { - const store = storeCreator({}); - const id = 1; - const initialValues = { - id, - title: 'Assessement', - description: 'Awesome assessment', - autograded: false, - start_at: new Date(), - base_exp: 0, - time_bonus_exp: 0, - use_public: true, - use_private: true, - use_evaluation: true, - tabs: [{ tab_id: 0, tab_title: 'test' }], - }; - - it('renders the edit page', async () => { - const editPage = mount( - - - , - ); - - const autogradedInput = editPage.find('input[name="autograded"]'); - expect(autogradedInput.props().value).toBeFalsy(); - - // Select field for Tab and Layout - expect(editPage.find('FormSelectField')).toHaveLength(2); - expect(editPage.find('input[name="password_protected"]')).toHaveLength(1); - expect(editPage.find('input[name="skippable"]')).toHaveLength(0); - - // Enable autograded field - autogradedInput.simulate('change', { target: { value: true } }); - await sleep(0.01); - expect(editPage.find('FormSelectField')).toHaveLength(1); // Only Tab, no more Layout Field - expect(editPage.find('input[name="password_protected"]')).toHaveLength(0); - expect(editPage.find('input[name="skippable"]')).toHaveLength(1); - - // Change title - const newTitle = 'New Title'; - const titleInput = editPage.find('input[name="title"]'); - titleInput.simulate('change', { target: { value: newTitle } }); - await sleep(0.01); - - const spy = jest.spyOn(CourseAPI.assessment.assessments, 'update'); - const form = editPage.find('form'); - await act(async () => { - form.simulate('submit'); - }); - expect(spy).toHaveBeenCalledWith(id, { - assessment: { - ...initialValues, - title: newTitle, - autograded: true, - view_password: null, - session_password: null, - }, - }); - }); - - it('renders the gamified fields by default', () => { - const editPage = mount( - - - , - ); - expect(editPage.find('input[name="base_exp"]').length).toBeGreaterThan(0); - expect( - editPage.find('input[name="time_bonus_exp"]').length, - ).toBeGreaterThan(0); - }); - - it('does not render the gamified fields', () => { - const editPage = mount( - - - , - ); - - expect(editPage.find('input[name="bonus_end_at"]')).toHaveLength(0); - expect(editPage.find('input[name="base_exp"]')).toHaveLength(0); - expect(editPage.find('input[name="time_bonus_exp"]')).toHaveLength(0); - }); - - it('renders the has/affects personal time fields', () => { - const editPage = mount( - - - , - ); - - expect( - editPage.find('input[name="has_personal_times"]').length, - ).toBeGreaterThan(0); - expect( - editPage.find('input[name="affects_personal_times"]').length, - ).toBeGreaterThan(0); - }); - - it('does not render the has/affects personal time fields', () => { - const editPage = mount( - - - , - ); - - expect(editPage.find('input[name="has_personal_times"]')).toHaveLength(0); - expect(editPage.find('input[name="affects_personal_times"]')).toHaveLength( - 0, - ); - }); -}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/__test__/index.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentEdit/__test__/index.test.tsx new file mode 100644 index 00000000000..220f614fb83 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/__test__/index.test.tsx @@ -0,0 +1,98 @@ +import userEvent from '@testing-library/user-event'; + +import { fireEvent, render, RenderResult, waitFor } from 'utilities/test-utils'; +import ProviderWrapper from 'lib/components/ProviderWrapper'; +import CourseAPI from 'api/course'; +import storeCreator from '../../../store'; +import AssessmentEdit from '..'; + +const INITIAL_VALUES = { + id: 1, + title: 'Test Assessment', + description: 'Awesome description 4', + autograded: false, + start_at: new Date(), + end_at: undefined, + bonus_end_at: undefined, + base_exp: 0, + time_bonus_exp: 0, + use_public: true, + use_private: true, + use_evaluation: false, + tabbed_view: false, + published: false, + allow_partial_submission: false, + block_student_viewing_after_submitted: false, + delayed_grade_publication: false, + password_protected: false, + view_password: null, + session_password: null, + show_private: false, + show_evaluation: false, + show_mcq_answer: false, + show_mcq_mrq_solution: false, + skippable: false, +}; + +const NEW_VALUES = { + title: 'New Assessment Title', + description: 'Awesome new description 5', + published: true, + use_public: false, +}; + +let store; +let initialValues; +let form: RenderResult; +let updateApi: jest.SpyInstance; + +const getComponent = (): JSX.Element => ( + + {/* @ts-ignore until AssessmentEdit/index.jsx is fully typed */} + + +); + +beforeEach(() => { + store = storeCreator({ assessments: {} }); + initialValues = INITIAL_VALUES; + updateApi = jest.spyOn(CourseAPI.assessment.assessments, 'update'); + + form = render(getComponent()); +}); + +describe('', () => { + it('submits correct form data', async () => { + const user = userEvent.setup(); + + const title = form.getByLabelText('Title *'); + await user.type(title, '{Control>}a{/Control}{Delete}'); + await user.type(title, NEW_VALUES.title); + expect(title).toHaveValue(NEW_VALUES.title); + + const description = form.getByDisplayValue(INITIAL_VALUES.description); + await user.type(description, '{Control>}a{/Control}{Delete}'); + await user.type(description, NEW_VALUES.description); + expect(description).toHaveValue(NEW_VALUES.description); + + const published = form.getByDisplayValue('published'); + fireEvent.click(published); + + const publicTestCases = form.getByLabelText('Public test cases'); + fireEvent.click(publicTestCases); + + const saveButton = form.getByText('Save'); + expect(saveButton).toBeVisible(); + + fireEvent.click(saveButton); + + await waitFor(() => + expect(updateApi).toHaveBeenCalledWith(initialValues.id, { + assessment: { + ...initialValues, + ...NEW_VALUES, + }, + }), + ); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/index.jsx b/client/app/bundles/course/assessment/pages/AssessmentEdit/index.jsx index 36dfe330d58..790ba0846b5 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/index.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/index.jsx @@ -7,14 +7,15 @@ import NotificationBar, { notificationShape, } from 'lib/components/NotificationBar'; import { achievementTypesConditionAttributes } from 'lib/types'; -import AssessmentForm from '../../containers/AssessmentForm'; +import AssessmentForm from '../../components/AssessmentForm'; import * as actions from '../../actions'; import translations from './translations.intl'; const styles = { buttonContainer: { - marginTop: 16, - marginLeft: 16, + position: 'absolute', + marginTop: '-6rem', + right: '15px', }, }; @@ -62,6 +63,19 @@ class EditPage extends Component { return ( <> +
+ +
+ -
- -
); diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/translations.intl.js b/client/app/bundles/course/assessment/pages/AssessmentEdit/translations.intl.js index bc73c8dcffc..a2229a5486a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/translations.intl.js +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/translations.intl.js @@ -3,7 +3,7 @@ import { defineMessages } from 'react-intl'; const translations = defineMessages({ updateAssessment: { id: 'course.assessment.edit.update', - defaultMessage: 'Update', + defaultMessage: 'Save', }, updateSuccess: { id: 'course.assessment.update.success', diff --git a/client/app/bundles/course/assessment/pages/AssessmentIndex/index.jsx b/client/app/bundles/course/assessment/pages/AssessmentIndex/index.jsx index cdf31ae296e..5bb33037935 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentIndex/index.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentIndex/index.jsx @@ -14,12 +14,19 @@ import NotificationBar, { } from 'lib/components/NotificationBar'; import ConfirmationDialog from 'lib/components/ConfirmationDialog'; import formTranslations from 'lib/translations/form'; -import AssessmentForm from '../../containers/AssessmentForm'; +import AssessmentForm from '../../components/AssessmentForm'; import * as actions from '../../actions'; import translations from './translations.intl'; import actionTypes from '../../constants'; class PopupDialog extends Component { + constructor(props) { + super(props); + this.state = { + assessmentForm: undefined, + }; + } + onFormSubmit = (data, setError) => { const { categoryId, dispatch, intl, tabId } = this.props; @@ -41,9 +48,15 @@ class PopupDialog extends Component { }; handleClose = () => { - this.props.dispatch({ - type: actionTypes.ASSESSMENT_FORM_CANCEL, - }); + if (this.state.assessmentForm?.isDirty) { + this.props.dispatch({ + type: actionTypes.ASSESSMENT_FORM_CANCEL, + }); + } else { + this.props.dispatch({ + type: actionTypes.ASSESSMENT_FORM_CONFIRM_DISCARD, + }); + } }; handleOpen = () => { @@ -64,12 +77,16 @@ class PopupDialog extends Component { const formActions = [ , , ]; @@ -121,7 +138,7 @@ class PopupDialog extends Component { disabled={disabled} onClick={this.handleOpen} > - {intl.formatMessage(translations.new)} + {intl.formatMessage(translations.newAssessment)} this.setState({ assessmentForm })} /> {formActions} diff --git a/client/app/bundles/course/assessment/pages/AssessmentIndex/translations.intl.js b/client/app/bundles/course/assessment/pages/AssessmentIndex/translations.intl.js index 57ed42dd7f0..a5907467146 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentIndex/translations.intl.js +++ b/client/app/bundles/course/assessment/pages/AssessmentIndex/translations.intl.js @@ -5,10 +5,6 @@ const translations = defineMessages({ id: 'course.assessment.newAssessment', defaultMessage: 'New Assessment', }, - new: { - id: 'course.assessment.new', - defaultMessage: 'New', - }, creationSuccess: { id: 'course.assessment.create.success', defaultMessage: 'Assessment was created.', @@ -17,6 +13,10 @@ const translations = defineMessages({ id: 'course.assessment.create.fail', defaultMessage: 'Failed to create assessment.', }, + createAsDraft: { + id: 'course.assessment.create.createAsDraft', + defaultMessage: 'Create As Draft', + }, }); export default translations; diff --git a/client/app/lib/components/CKEditorRichText.tsx b/client/app/lib/components/CKEditorRichText.tsx index 9051b239771..2ef2479b4db 100644 --- a/client/app/lib/components/CKEditorRichText.tsx +++ b/client/app/lib/components/CKEditorRichText.tsx @@ -15,11 +15,21 @@ interface Props { required?: boolean | undefined; name: string; inputId: string; + disableMargins?: boolean; } const CKEditorRichText: FC = forwardRef((props: Props, ref) => { - const { label, value, onChange, disabled, field, required, name, inputId } = - props; + const { + label, + value, + onChange, + disabled, + field, + required, + name, + inputId, + disableMargins, + } = props; const [isFocused, setIsFocused] = useState(false); const testFieldLabelColor = isFocused ? cyan[500] : undefined; @@ -73,21 +83,24 @@ const CKEditorRichText: FC = forwardRef((props: Props, ref) => { backgroundColor: 'transparent', fontFamily: 'Roboto, sans-serif', paddingTop: label ? '1em' : 0, - paddingBottom: '1em', + paddingBottom: !disableMargins ? '1em' : 0, }} > - - {label} - + {label && ( + + {label} + + )} +