Skip to content

Commit a8a02ec

Browse files
Special Exams allowances (#146)
* feat: allowances * fix: add examtype to querykey
1 parent f1e6fec commit a8a02ec

25 files changed

Lines changed: 2304 additions & 74 deletions

src/components/SpecifyLearnerField.test.tsx

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,51 @@ const mockLearnerData = {
1515
fullName: 'Test User',
1616
email: 'test@email.com',
1717
isEnrolled: true,
18+
progressUrl: 'http://example.com/progress',
1819
};
1920

2021
describe('SpecifyLearnerField', () => {
21-
describe('when learner is found', () => {
22+
beforeEach(() => {
23+
jest.resetAllMocks();
24+
(useCourseInfo as jest.Mock).mockReturnValue({ data: { permissions: { admin: true, dataResearcher: false } } });
25+
});
26+
27+
describe('when learner is provided', () => {
2228
beforeEach(() => {
23-
jest.resetAllMocks();
24-
(useCourseInfo as jest.Mock).mockReturnValue({ data: { permissions: { admin: true, dataResearcher: false } } });
2529
(useLearner as jest.Mock).mockReturnValue({
2630
data: mockLearnerData,
2731
refetch: jest.fn().mockResolvedValue({ data: mockLearnerData }),
2832
error: null,
2933
});
3034
});
31-
it('renders label and input', () => {
32-
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
33-
expect(screen.getByText(messages.specifyLearner.defaultMessage)).toBeInTheDocument();
34-
expect(screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage)).toBeInTheDocument();
35+
36+
it('renders selected learner label when learner is provided', () => {
37+
renderWithIntl(<SpecifyLearnerField learner={mockLearnerData} onClickSelect={jest.fn()} />);
38+
expect(screen.getByText(messages.selectedLearner.defaultMessage)).toBeInTheDocument();
39+
expect(screen.queryByText(messages.specifyLearner.defaultMessage)).not.toBeInTheDocument();
40+
});
41+
42+
it('shows learner details when learner is provided', () => {
43+
renderWithIntl(<SpecifyLearnerField learner={mockLearnerData} onClickSelect={jest.fn()} />);
44+
expect(screen.getByText(mockLearnerData.username)).toBeInTheDocument();
45+
expect(screen.getByText(mockLearnerData.fullName)).toBeInTheDocument();
46+
expect(screen.getByText(mockLearnerData.email)).toBeInTheDocument();
47+
});
48+
49+
it('hides input field when learner is shown', () => {
50+
renderWithIntl(<SpecifyLearnerField learner={mockLearnerData} onClickSelect={jest.fn()} />);
51+
const input = screen.queryByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
52+
expect(input?.parentNode).toHaveClass('d-none');
53+
});
54+
});
55+
56+
describe('when learner is found after search', () => {
57+
beforeEach(() => {
58+
(useLearner as jest.Mock).mockReturnValue({
59+
data: null,
60+
refetch: jest.fn().mockResolvedValue({ data: mockLearnerData }),
61+
error: null,
62+
});
3563
});
3664

3765
it('renders select button', () => {
@@ -50,6 +78,43 @@ describe('SpecifyLearnerField', () => {
5078
expect(handleClick).toHaveBeenCalledWith(mockLearnerData.username);
5179
});
5280

81+
it('changes to selected learner label after selection', async () => {
82+
const handleClick = jest.fn();
83+
renderWithIntl(<SpecifyLearnerField onClickSelect={handleClick} />);
84+
85+
// Initially shows default label
86+
expect(screen.getByText(messages.specifyLearner.defaultMessage)).toBeInTheDocument();
87+
88+
// After learner is found and has username, should show selected label
89+
renderWithIntl(<SpecifyLearnerField learner={mockLearnerData} onClickSelect={handleClick} />);
90+
expect(screen.getByText(messages.selectedLearner.defaultMessage)).toBeInTheDocument();
91+
});
92+
});
93+
94+
describe('when no learner is provided', () => {
95+
beforeEach(() => {
96+
(useLearner as jest.Mock).mockReturnValue({
97+
data: mockLearnerData,
98+
refetch: jest.fn().mockResolvedValue({ data: mockLearnerData }),
99+
error: null,
100+
});
101+
});
102+
103+
it('renders default label', () => {
104+
(useLearner as jest.Mock).mockReturnValue({
105+
data: null,
106+
refetch: jest.fn().mockResolvedValue({ data: mockLearnerData }),
107+
error: null,
108+
});
109+
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
110+
expect(screen.getByText(messages.specifyLearner.defaultMessage)).toBeInTheDocument();
111+
});
112+
113+
it('renders input field and placeholder', () => {
114+
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
115+
expect(screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage)).toBeInTheDocument();
116+
});
117+
53118
it('input has correct name attribute', () => {
54119
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
55120
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);

src/components/SpecifyLearnerField.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,25 @@ interface SpecifyLearnerFieldRef {
1818
reset: () => void,
1919
}
2020

21+
const initialLearnerState = {
22+
username: '',
23+
fullName: '',
24+
email: '',
25+
isEnrolled: false,
26+
};
27+
2128
const SpecifyLearnerField = forwardRef<SpecifyLearnerFieldRef, SpecifyLearnerFieldProps>(({ learner, onClickSelect }, ref) => {
2229
const intl = useIntl();
2330
const { courseId = '' } = useParams<{ courseId: string }>();
2431
const [identifier, setIdentifier] = useState('');
25-
const [showLearner, enableShowLearner, disableShowLearner] = useToggle(false);
32+
const [showLearner, enableShowLearner, disableShowLearner] = useToggle(!!learner);
2633
const { data: courseInfo } = useCourseInfo(courseId);
2734
const permissions = courseInfo?.permissions || { admin: false, dataResearcher: false };
2835
const { inputValue, handleChange, resetFilter } = useDebouncedFilter({
2936
filterValue: identifier,
3037
setFilter: setIdentifier,
3138
});
32-
const { data = { email: '', fullName: '', username: '', isEnrolled: false }, refetch, error } = useLearner(courseId, inputValue);
39+
const { data, refetch, error } = useLearner(courseId, inputValue);
3340

3441
const resetState = () => {
3542
resetFilter();
@@ -41,7 +48,7 @@ const SpecifyLearnerField = forwardRef<SpecifyLearnerFieldRef, SpecifyLearnerFie
4148
reset: resetState,
4249
}));
4350

44-
const selectedLearner = learner || data;
51+
const selectedLearner = learner || data || initialLearnerState;
4552

4653
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
4754
handleChange(event.target.value);
@@ -65,7 +72,7 @@ const SpecifyLearnerField = forwardRef<SpecifyLearnerFieldRef, SpecifyLearnerFie
6572

6673
return (
6774
<FormGroup className="mb-0" size="sm">
68-
<FormLabel className="text-primary-500 d-flex">{intl.formatMessage(messages.specifyLearner)}</FormLabel>
75+
<FormLabel className="text-primary-500 d-flex">{selectedLearner.username ? intl.formatMessage(messages.selectedLearner) : intl.formatMessage(messages.specifyLearner)}</FormLabel>
6976
<div className="d-flex align-items-center">
7077
<FormControl
7178
className={`mr-2 ${selectedLearner.username && showLearner ? 'd-none' : ''}`}

src/components/messages.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,12 @@ const messages = defineMessages({
150150
id: 'instruct.specifyLearner.learnerNotEnrolled',
151151
defaultMessage: '{identifier} is not enrolled in this course',
152152
description: 'Error message displayed when a learner is found based on the provided identifier (email or username) but is not enrolled in the course',
153-
}
153+
},
154+
selectedLearner: {
155+
id: 'instruct.specifyLearner.selectedLearner',
156+
defaultMessage: 'Selected Learner:',
157+
description: 'Label for specify learner field when a learner has been selected',
158+
},
154159
});
155160

156161
export default messages;

src/dateExtensions/components/AddExtensionModal.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('AddExtensionModal', () => {
3737
beforeEach(() => {
3838
jest.clearAllMocks();
3939
(useLearner as jest.Mock).mockReturnValue({
40-
data: mockLearnerData,
40+
data: null,
4141
refetch: jest.fn().mockResolvedValue({ data: mockLearnerData }),
4242
error: null,
4343
});
@@ -75,6 +75,13 @@ describe('AddExtensionModal', () => {
7575
const selectButton = screen.getByRole('button', { name: /select/i });
7676
await waitFor(() => expect(selectButton).not.toBeDisabled());
7777
await user.click(selectButton);
78+
79+
(useLearner as jest.Mock).mockReturnValue({
80+
data: mockLearnerData,
81+
refetch: jest.fn().mockResolvedValue({ data: mockLearnerData }),
82+
error: null,
83+
});
84+
7885
await user.selectOptions(blockInput, 'sub1');
7986
await user.type(dueDateInput, '2024-12-31');
8087
await user.type(dueTimeInput, '23:59');

src/specialExams/SpecialExamsPage.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,33 @@ import messages from './messages';
55
import Allowances from './components/Allowances';
66
import AttemptsList from './components/AttemptsList';
77

8+
const SPECIAL_EXAMS_TAB = {
9+
ATTEMPTS: 'attempts',
10+
ALLOWANCES: 'allowances',
11+
};
12+
813
const SpecialExamsPage = () => {
914
const intl = useIntl();
10-
const [selectedTab, setSelectedTab] = useState<'attempts' | 'allowances'>('attempts');
15+
const [selectedTab, setSelectedTab] = useState<(typeof SPECIAL_EXAMS_TAB)[keyof typeof SPECIAL_EXAMS_TAB]>(SPECIAL_EXAMS_TAB.ATTEMPTS);
1116

1217
return (
1318
<>
1419
<h3 className="text-primary-700">{intl.formatMessage(messages.specialExamsTitle)}</h3>
1520
<Card className="bg-light-200 mt-4.5">
1621
<ButtonGroup className="d-block mx-4 mt-4">
17-
<Button variant={selectedTab === 'attempts' ? 'primary' : 'outline-primary'} onClick={() => setSelectedTab('attempts')}>{intl.formatMessage(messages.examAttempts)}</Button>
18-
<Button variant={selectedTab === 'allowances' ? 'primary' : 'outline-primary'} onClick={() => setSelectedTab('allowances')}>{intl.formatMessage(messages.allowances)}</Button>
22+
<Button
23+
variant={selectedTab === SPECIAL_EXAMS_TAB.ATTEMPTS ? 'primary' : 'outline-primary'}
24+
onClick={() => setSelectedTab(SPECIAL_EXAMS_TAB.ATTEMPTS)}
25+
>{intl.formatMessage(messages.examAttempts)}
26+
</Button>
27+
<Button
28+
variant={selectedTab === SPECIAL_EXAMS_TAB.ALLOWANCES ? 'primary' : 'outline-primary'}
29+
onClick={() => setSelectedTab(SPECIAL_EXAMS_TAB.ALLOWANCES)}
30+
>{intl.formatMessage(messages.allowances)}
31+
</Button>
1932
</ButtonGroup>
2033
{
21-
selectedTab === 'attempts' ? <AttemptsList /> : <Allowances />
34+
selectedTab === SPECIAL_EXAMS_TAB.ATTEMPTS ? <AttemptsList /> : <Allowances />
2235
}
2336
</Card>
2437
</>

0 commit comments

Comments
 (0)