Skip to content

Commit

Permalink
feat: add allowance modal
Browse files Browse the repository at this point in the history
  • Loading branch information
alangsto committed Aug 1, 2024
1 parent 2a35900 commit ef04d23
Show file tree
Hide file tree
Showing 17 changed files with 933 additions and 14 deletions.
1 change: 1 addition & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const RequestKeys = {
fetchExamAttempts: 'fetchExamAttempts',
deleteExamAttempt: 'deleteExamAttempt',
modifyExamAttempt: 'modifyExamAttempt',
createAllowance: 'createAllowance',
};

export const ExamAttemptActions = {
Expand Down
1 change: 1 addition & 0 deletions src/data/redux/requests/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};

Expand Down
236 changes: 236 additions & 0 deletions src/pages/ExamsPage/components/AddAllowanceModal.jsx
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

View workflow job for this annotation

GitHub Actions / test

A space is required after '{'

Check failure on line 58 in src/pages/ExamsPage/components/AddAllowanceModal.jsx

View workflow job for this annotation

GitHub Actions / test

A space is required before '}'

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;
74 changes: 74 additions & 0 deletions src/pages/ExamsPage/components/AddAllowanceModal.test.jsx
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();
});
});
19 changes: 12 additions & 7 deletions src/pages/ExamsPage/components/AllowanceList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -51,6 +52,7 @@ AllowanceListActions.propTypes = {

const AllowanceList = () => {
const { formatMessage } = useIntl();
const [isOpen, open, close] = useToggle(false);

const {
allowancesList,
Expand Down Expand Up @@ -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
};
Expand All @@ -109,7 +107,13 @@ const AllowanceList = () => {
<>
<div className="allowances-header">
<h3 data-testid="allowances">{formatMessage(messages.allowanceDashboardTabTitle)}</h3>
<Button variant="outline-primary" iconBefore={Add} size="sm" className="header-allowance-button" onClick={handleAdd}>
<Button
variant="outline-primary"
iconBefore={Add}
size="sm"
className="header-allowance-button"
onClick={open}
>
{formatMessage(messages.addAllowanceButton)}
</Button>
</div>
Expand All @@ -119,7 +123,7 @@ const AllowanceList = () => {
<div className="allowances-empty">
<h4>{formatMessage(messages.noAllowancesHeader)}</h4>
<p>{formatMessage(messages.noAllowancesBody)}</p>
<Button variant="primary" iconBefore={Add} onClick={handleAdd}>
<Button variant="primary" iconBefore={Add} onClick={open}>
{formatMessage(messages.addAllowanceButton)}
</Button>
</div>
Expand Down Expand Up @@ -171,6 +175,7 @@ const AllowanceList = () => {
</div>
)}
</div>
<AddAllowanceModal isOpen={isOpen} close={close} />
</>
);
};
Expand Down
Loading

0 comments on commit ef04d23

Please sign in to comment.