generated from openedx/frontend-template-application
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
17 changed files
with
933 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}; | ||
Check failure on line 58 in src/pages/ExamsPage/components/AddAllowanceModal.jsx GitHub Actions / test
|
||
|
||
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 ( | ||
<ModalDialog | ||
title={formatMessage(messages.addAllowanceModalTitle)} | ||
isOpen={isOpen} | ||
onClose={close} | ||
size="md" | ||
hasCloseButton | ||
isFullscreenOnMobile | ||
> | ||
<ModalDialog.Header> | ||
<ModalDialog.Title> | ||
{ formatMessage(messages.addAllowanceModalTitle) } | ||
</ModalDialog.Title> | ||
</ModalDialog.Header> | ||
<ModalDialog.Body> | ||
<Form id="add-allowance-form" onSubmit={onSubmit}> | ||
<Form.Group isInvalid={learnerFieldError}> | ||
<Form.Label>{ formatMessage(messages.addAllowanceLearnerField) }</Form.Label> | ||
<Form.Control name="users" value={form.users || ''} onChange={handleChange} data-testid="users" /> | ||
{ !learnerFieldError | ||
? ( | ||
<Form.Control.Feedback> | ||
{ formatMessage(messages.addAllowanceLearnerFieldFeedback) } | ||
</Form.Control.Feedback> | ||
) | ||
: ( | ||
<Form.Control.Feedback type="invalid"> | ||
{ formatMessage(messages.addAllowanceLearnerFieldErrorFeedback) } | ||
</Form.Control.Feedback> | ||
)} | ||
</Form.Group> | ||
<Form.Group controlId="form-exam-type"> | ||
<Form.Label>{ formatMessage(messages.addAllowanceExamTypeField) }</Form.Label> | ||
<Form.Control | ||
as="select" | ||
onChange={handleChange} | ||
name="exam-type" | ||
value={form['exam-type']} | ||
data-testid="exam-type" | ||
> | ||
{ | ||
proctoredExams.length > 0 | ||
&& <option value="proctored">{ formatMessage(messages.addAllowanceProctoredExamOption) }</option> | ||
} | ||
{ | ||
timedExams.length > 0 | ||
&& <option value="timed">{ formatMessage(messages.addAllowanceTimedExamOption) }</option> | ||
} | ||
</Form.Control> | ||
</Form.Group> | ||
{ displayExams.length > 0 ? ( | ||
<Form.Group isInvalid={examFieldError}> | ||
<Form.Label>{ formatMessage(messages.addAllowanceExamField) }</Form.Label> | ||
<Form.CheckboxSet | ||
name="exams" | ||
onChange={handleCheckboxChange} | ||
> | ||
{ | ||
displayExams.map((exam) => ( | ||
<Form.Checkbox | ||
key={exam.id} | ||
data-key={exam.id} | ||
value={exam.timeLimitMins} | ||
> | ||
{exam.name} | ||
</Form.Checkbox> | ||
)) | ||
} | ||
</Form.CheckboxSet> | ||
{ examFieldError && <Form.Control.Feedback type="invalid">{ formatMessage(messages.addAllowanceExamErrorFeedback) }</Form.Control.Feedback>} | ||
</Form.Group> | ||
) : null } | ||
<Form.Group controlId="form-allowance-type"> | ||
<Form.Label>{ formatMessage(messages.addAllowanceAllowanceTypeField) }</Form.Label> | ||
<Form.Control | ||
as="select" | ||
name="allowance-type" | ||
onChange={handleChange} | ||
value={form['allowance-type']} | ||
data-testid="allowance-type" | ||
> | ||
<option value="additional-minutes">{ formatMessage(messages.addAllowanceAdditionalMinutesOption) }</option> | ||
<option value="time-multiplier">{ formatMessage(messages.addAllowanceTimeMultiplierOption) }</option> | ||
</Form.Control> | ||
</Form.Group> | ||
{ form['allowance-type'] === 'additional-minutes' | ||
? ( | ||
<Form.Group controlId="form-allowance-value-minutes" isInvalid={additionalTimeError}> | ||
<Form.Label>{ formatMessage(messages.addAllowanceMinutesField) }</Form.Label> | ||
<Form.Control | ||
name="additional-time-minutes" | ||
value={form['additional-time-minutes'] || ''} | ||
onChange={handleChange} | ||
data-testid="additional-time-minutes" | ||
/> | ||
{ additionalTimeError && <Form.Control.Feedback type="invalid">{ formatMessage(messages.addAllowanceMinutesErrorFeedback) }</Form.Control.Feedback> } | ||
</Form.Group> | ||
) | ||
: ( | ||
<Form.Group controlId="form-allowance-value-multiplier" isInvalid={additionalTimeError}> | ||
<Form.Label>{ formatMessage(messages.addAllowanceMultiplierField) }</Form.Label> | ||
<Form.Control | ||
name="additional-time-multiplier" | ||
value={form['additional-time-multiplier'] || ''} | ||
onChange={handleChange} | ||
/> | ||
{ | ||
additionalTimeError | ||
? ( | ||
<Form.Control.Feedback type="invalid"> | ||
{ formatMessage(messages.addAllowanceMultiplierFeedback) } | ||
</Form.Control.Feedback> | ||
) | ||
: ( | ||
<Form.Control.Feedback> | ||
{ formatMessage(messages.addAllowanceMultiplierErrorFeedback) } | ||
</Form.Control.Feedback> | ||
) | ||
} | ||
</Form.Group> | ||
)} | ||
</Form> | ||
</ModalDialog.Body> | ||
|
||
<ModalDialog.Footer> | ||
<ActionRow> | ||
<ModalDialog.CloseButton variant="tertiary"> | ||
{ formatMessage(messages.addAllowanceCloseButton) } | ||
</ModalDialog.CloseButton> | ||
<StatefulButton | ||
data-testid="create-allowance-stateful-button" | ||
state={createAllowanceRequestStatus()} | ||
labels={{ | ||
default: formatMessage(messages.addAllowanceButtonDefaultLabel), | ||
pending: formatMessage(messages.addAllowanceButtonPendingLabel), | ||
complete: formatMessage(messages.addAllowanceButtonCompleteLabel), | ||
error: formatMessage(messages.addAllowanceButtonErrorLabel), | ||
}} | ||
type="submit" | ||
form="add-allowance-form" | ||
/> | ||
</ActionRow> | ||
</ModalDialog.Footer> | ||
</ModalDialog> | ||
); | ||
}; | ||
|
||
AddAllowanceModal.propTypes = { | ||
isOpen: PropTypes.bool.isRequired, | ||
close: PropTypes.func.isRequired, | ||
}; | ||
|
||
export default AddAllowanceModal; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(<AddAllowanceModal isOpen close={jest.fn()} />)).toMatchSnapshot(); | ||
}); | ||
|
||
it('should display timed exam choices', () => { | ||
render(<AddAllowanceModal isOpen close={jest.fn()} />); | ||
fireEvent.change(screen.getByTestId('exam-type'), { target: { value: 'timed' } }); | ||
expect(screen.getByText('exam2')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should update allowance input', () => { | ||
render(<AddAllowanceModal isOpen close={jest.fn()} />); | ||
fireEvent.change(screen.getByTestId('allowance-type'), { target: { value: 'time-multiplier' } }); | ||
expect(screen.getByText('Multiplier')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should submit with default values', () => { | ||
render(<AddAllowanceModal isOpen close={jest.fn()} />); | ||
fireEvent.change(screen.getByTestId('users'), { target: { value: 'edx, [email protected]' } }); | ||
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, [email protected]', | ||
'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(<AddAllowanceModal isOpen close={jest.fn()} />); | ||
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.