From 0fedd803957360d2d57b94e6ad1269581b36a5f8 Mon Sep 17 00:00:00 2001 From: Alie Langston Date: Tue, 30 Jul 2024 10:43:46 -0400 Subject: [PATCH] feat: add allowance modal --- src/data/constants.js | 1 + src/data/redux/requests/reducer.js | 1 + .../components/AddAllowanceModal.jsx | 236 +++++++++++ .../components/AddAllowanceModal.test.jsx | 74 ++++ .../ExamsPage/components/AllowanceList.jsx | 19 +- .../components/AllowanceList.test.jsx | 26 +- .../AddAllowanceModal.test.jsx.snap | 365 ++++++++++++++++++ .../__snapshots__/AllowanceList.test.jsx.snap | 7 +- src/pages/ExamsPage/data/api.js | 6 + src/pages/ExamsPage/data/reducer.js | 2 + src/pages/ExamsPage/data/reducer.test.js | 6 + src/pages/ExamsPage/hooks.js | 27 ++ src/pages/ExamsPage/hooks.test.js | 34 ++ src/pages/ExamsPage/index.test.jsx | 3 + src/pages/ExamsPage/messages.js | 112 ++++++ src/pages/ExamsPage/utils.js | 21 + src/testUtils.js | 7 +- 17 files changed, 933 insertions(+), 14 deletions(-) create mode 100644 src/pages/ExamsPage/components/AddAllowanceModal.jsx create mode 100644 src/pages/ExamsPage/components/AddAllowanceModal.test.jsx create mode 100644 src/pages/ExamsPage/components/__snapshots__/AddAllowanceModal.test.jsx.snap diff --git a/src/data/constants.js b/src/data/constants.js index 70772e3..ceb08fc 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -11,6 +11,7 @@ export const RequestKeys = { fetchExamAttempts: 'fetchExamAttempts', deleteExamAttempt: 'deleteExamAttempt', modifyExamAttempt: 'modifyExamAttempt', + createAllowance: 'createAllowance', }; export const ExamAttemptActions = { diff --git a/src/data/redux/requests/reducer.js b/src/data/redux/requests/reducer.js index c332556..d1e0490 100644 --- a/src/data/redux/requests/reducer.js +++ b/src/data/redux/requests/reducer.js @@ -13,6 +13,7 @@ const initialState = { [RequestKeys.fetchExamAttempts]: { status: RequestStates.inactive }, [RequestKeys.deleteExamAttempt]: { status: RequestStates.inactive }, [RequestKeys.modifyExamAttempt]: { status: RequestStates.inactive }, + [RequestKeys.createAllowance]: { status: RequestStates.inactive }, [undefined]: { status: RequestStates.inactive }, }; diff --git a/src/pages/ExamsPage/components/AddAllowanceModal.jsx b/src/pages/ExamsPage/components/AddAllowanceModal.jsx new file mode 100644 index 0000000..e110235 --- /dev/null +++ b/src/pages/ExamsPage/components/AddAllowanceModal.jsx @@ -0,0 +1,236 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + ModalDialog, + Form, + ActionRow, + StatefulButton, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import * as constants from '../../../data/constants'; +import { useFilteredExamsData, useCreateAllowance, useButtonStateFromRequestStatus } from '../hooks'; +import messages from '../messages'; + +const AddAllowanceModal = ({ isOpen, close }) => { + const { proctoredExams, timedExams } = useFilteredExamsData(); + + const defaultExamType = proctoredExams.length > 0 ? 'proctored' : 'timed'; + const defaultExamsList = defaultExamType === 'proctored' ? proctoredExams : timedExams; + + const initialFormState = { + 'allowance-type': 'additional-minutes', + 'exam-type': defaultExamType, + exams: {}, + }; + + const [displayExams, setDisplayExams] = useState(defaultExamsList); + const [form, setForm] = useState(initialFormState); + const [learnerFieldError, setLearnerFieldError] = useState(false); + const [examFieldError, setExamFieldError] = useState(false); + const [additionalTimeError, setAdditionalTimeError] = useState(false); + const createAllowanceRequestStatus = useButtonStateFromRequestStatus(constants.RequestKeys.createAllowance); + const createAllowance = useCreateAllowance(); + const { formatMessage } = useIntl(); + + const handleExamTypeChange = (examType) => { + if (examType === 'proctored') { + setDisplayExams(proctoredExams); + } else { + setDisplayExams(timedExams); + } + // reset exam selection state + setForm(prev => ({ ...prev, exams: {} })); + }; + + const handleChange = (event) => { + const { name, value } = event.target; + setForm(prev => ({ ...prev, [name]: value })); + if (name === 'exam-type') { + handleExamTypeChange(value); + } + }; + + const handleCheckboxChange = (event) => { + const el = event.target; + const id = el.getAttribute('data-key'); + const timeLimitMins = el.value; + const selectedExams = { ...form.exams }; + + if (el.checked) { + selectedExams[id] = timeLimitMins; + } else { + delete selectedExams[id]; + } + setForm(prev => ({ ...prev, exams: selectedExams })); + }; + + const onSubmit = (e) => { + e.preventDefault(); + setLearnerFieldError(!form.users); + setExamFieldError(Object.keys(form.exams).length === 0); + setAdditionalTimeError(!form['additional-time-minutes'] && !form['additional-time-multiplier']); + const valid = ( + form.users + && Object.keys(form.exams).length > 0 + && (form['additional-time-minutes'] || form['additional-time-multiplier']) + ); + if (valid) { + createAllowance(form, close); + setForm(initialFormState); + } + }; + + return ( + + + + { formatMessage(messages.addAllowanceModalTitle) } + + + +
+ + { formatMessage(messages.addAllowanceLearnerField) } + + { !learnerFieldError + ? ( + + { formatMessage(messages.addAllowanceLearnerFieldFeedback) } + + ) + : ( + + { formatMessage(messages.addAllowanceLearnerFieldErrorFeedback) } + + )} + + + { formatMessage(messages.addAllowanceExamTypeField) } + + { + proctoredExams.length > 0 + && + } + { + timedExams.length > 0 + && + } + + + { displayExams.length > 0 ? ( + + { formatMessage(messages.addAllowanceExamField) } + + { + displayExams.map((exam) => ( + + {exam.name} + + )) + } + + { examFieldError && { formatMessage(messages.addAllowanceExamErrorFeedback) }} + + ) : null } + + { formatMessage(messages.addAllowanceAllowanceTypeField) } + + + + + + { form['allowance-type'] === 'additional-minutes' + ? ( + + { formatMessage(messages.addAllowanceMinutesField) } + + { additionalTimeError && { formatMessage(messages.addAllowanceMinutesErrorFeedback) } } + + ) + : ( + + { formatMessage(messages.addAllowanceMultiplierField) } + + { + additionalTimeError + ? ( + + { formatMessage(messages.addAllowanceMultiplierFeedback) } + + ) + : ( + + { formatMessage(messages.addAllowanceMultiplierErrorFeedback) } + + ) + } + + )} +
+
+ + + + + { formatMessage(messages.addAllowanceCloseButton) } + + + + +
+ ); +}; + +AddAllowanceModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, +}; + +export default AddAllowanceModal; diff --git a/src/pages/ExamsPage/components/AddAllowanceModal.test.jsx b/src/pages/ExamsPage/components/AddAllowanceModal.test.jsx new file mode 100644 index 0000000..b99b715 --- /dev/null +++ b/src/pages/ExamsPage/components/AddAllowanceModal.test.jsx @@ -0,0 +1,74 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import * as hooks from '../hooks'; +import AddAllowanceModal from './AddAllowanceModal'; + +const mockMakeNetworkRequest = jest.fn(); +const mockCreateAllowance = jest.fn(); + +const proctoredExams = [ + { id: 1, name: 'exam1', examType: 'proctored', timeLimitMins: 60 }, // eslint-disable-line object-curly-newline + { id: 3, name: 'exam3', examType: 'proctored', timeLimitMins: 45 }, // eslint-disable-line object-curly-newline +]; + +const timedExams = [{ id: 2, name: 'exam2', examType: 'timed', timeLimitMins: 30 }]; // eslint-disable-line object-curly-newline + +// normally mocked for unit tests but required for rendering/snapshots +jest.unmock('react'); + +jest.mock('../hooks', () => ({ + useExamsData: jest.fn(), + useFilteredExamsData: jest.fn(), + useButtonStateFromRequestStatus: jest.fn(), + useCreateAllowance: jest.fn(), +})); + +describe('AddAllowanceModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + hooks.useFilteredExamsData.mockReturnValue({ proctoredExams, timedExams }); + hooks.useButtonStateFromRequestStatus.mockReturnValue(mockMakeNetworkRequest); + hooks.useCreateAllowance.mockReturnValue(mockCreateAllowance); + }); + + it('should match snapshot', () => { + expect(render()).toMatchSnapshot(); + }); + + it('should display timed exam choices', () => { + render(); + fireEvent.change(screen.getByTestId('exam-type'), { target: { value: 'timed' } }); + expect(screen.getByText('exam2')).toBeInTheDocument(); + }); + + it('should update allowance input', () => { + render(); + fireEvent.change(screen.getByTestId('allowance-type'), { target: { value: 'time-multiplier' } }); + expect(screen.getByText('Multiplier')).toBeInTheDocument(); + }); + + it('should submit with default values', () => { + render(); + fireEvent.change(screen.getByTestId('users'), { target: { value: 'edx, edx@example.com' } }); + fireEvent.click(screen.getByText('exam1')); + fireEvent.click(screen.getByText('exam3')); + fireEvent.change(screen.getByTestId('additional-time-minutes'), { target: { value: '60' } }); + fireEvent.click(screen.getByTestId('create-allowance-stateful-button')); + const expectedData = { + users: 'edx, edx@example.com', + 'exam-type': 'proctored', + exams: { 1: '60', 3: '45' }, + 'additional-time-minutes': '60', + 'allowance-type': 'additional-minutes', + }; + expect(mockCreateAllowance).toHaveBeenCalledWith(expectedData, expect.any(Function)); + }); + + it('should display field errors', () => { + render(); + fireEvent.click(screen.getByTestId('create-allowance-stateful-button')); + expect(screen.getByText('Enter learners')).toBeInTheDocument(); + expect(screen.getByText('Select exams')).toBeInTheDocument(); + expect(screen.getByText('Enter minutes')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/ExamsPage/components/AllowanceList.jsx b/src/pages/ExamsPage/components/AllowanceList.jsx index 53ba1d7..0a7aeab 100644 --- a/src/pages/ExamsPage/components/AllowanceList.jsx +++ b/src/pages/ExamsPage/components/AllowanceList.jsx @@ -5,13 +5,14 @@ import { DataTable, Icon, IconButtonWithTooltip, + useToggle, } from '@openedx/paragon'; import { Add, DeleteOutline, EditOutline } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import { useAllowancesData } from '../hooks'; - +import AddAllowanceModal from './AddAllowanceModal'; import './AllowanceList.scss'; const AllowanceListActions = ({ onEdit, onDelete }) => { @@ -51,6 +52,7 @@ AllowanceListActions.propTypes = { const AllowanceList = () => { const { formatMessage } = useIntl(); + const [isOpen, open, close] = useToggle(false); const { allowancesList, @@ -93,10 +95,6 @@ const AllowanceList = () => { return acc; }, []); - const handleAdd = () => { - console.log('Add'); // eslint-disable-line no-console - }; - const handleEdit = (row) => { console.log('Edit', row); // eslint-disable-line no-console }; @@ -109,7 +107,13 @@ const AllowanceList = () => { <>

{formatMessage(messages.allowanceDashboardTabTitle)}

-
@@ -119,7 +123,7 @@ const AllowanceList = () => {

{formatMessage(messages.noAllowancesHeader)}

{formatMessage(messages.noAllowancesBody)}

-
@@ -171,6 +175,7 @@ const AllowanceList = () => { )} + ); }; diff --git a/src/pages/ExamsPage/components/AllowanceList.test.jsx b/src/pages/ExamsPage/components/AllowanceList.test.jsx index f1661ef..b857e29 100644 --- a/src/pages/ExamsPage/components/AllowanceList.test.jsx +++ b/src/pages/ExamsPage/components/AllowanceList.test.jsx @@ -1,11 +1,14 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import AllowanceList from './AllowanceList'; -import { useAllowancesData } from '../hooks'; +import * as hooks from '../hooks'; +import * as testUtils from '../../../testUtils'; -// nomally mocked for unit tests but required for rendering/snapshots +// normally mocked for unit tests but required for rendering/snapshots jest.unmock('react'); +const mockMakeNetworkRequest = jest.fn(); + const mockedAllowancesData = { allowancesList: [ { @@ -31,26 +34,39 @@ const mockedAllowancesData = { jest.mock('../hooks', () => ({ useAllowancesData: jest.fn(), + useExamsData: jest.fn(), + useButtonStateFromRequestStatus: jest.fn(), + useCreateAllowance: jest.fn(), + useFilteredExamsData: jest.fn(), })); describe('AllowanceList', () => { beforeEach(() => { jest.resetAllMocks(); + hooks.useExamsData.mockReturnValue(testUtils.defaultExamsData); + hooks.useButtonStateFromRequestStatus.mockReturnValue(mockMakeNetworkRequest); + hooks.useFilteredExamsData.mockReturnValue({ proctoredExams: {}, timedExams: {} }); }); describe('when listing allowances', () => { beforeEach(() => { - useAllowancesData.mockReturnValue(mockedAllowancesData); + hooks.useAllowancesData.mockReturnValue(mockedAllowancesData); }); it('should match snapshot', () => { expect(render()).toMatchSnapshot(); }); + + it('should open allowance modal', () => { + render(); + screen.getByText('Add allowance').click(); + expect(screen.getByText('Add a new allowance')).toBeInTheDocument(); + }); }); describe('when there are no allowances', () => { beforeEach(() => { - useAllowancesData.mockReturnValue({ allowancesList: [] }); + hooks.useAllowancesData.mockReturnValue({ allowancesList: [] }); }); it('should match snapshot', () => { diff --git a/src/pages/ExamsPage/components/__snapshots__/AddAllowanceModal.test.jsx.snap b/src/pages/ExamsPage/components/__snapshots__/AddAllowanceModal.test.jsx.snap new file mode 100644 index 0000000..afb4169 --- /dev/null +++ b/src/pages/ExamsPage/components/__snapshots__/AddAllowanceModal.test.jsx.snap @@ -0,0 +1,365 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddAllowanceModal should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +