Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add allowance modal #36

Merged
merged 1 commit into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 };

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
Loading