Skip to content

Commit bcb6d42

Browse files
Specify learner field (#147)
* feat: specify learner field * fix: change axios error, error was persistent, change value passed to query
1 parent 5b5411c commit bcb6d42

23 files changed

Lines changed: 535 additions & 84 deletions

src/cohorts/CohortsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const CohortsPageContent = () => {
3939
return (
4040
<>
4141
<div className="d-inline-flex align-items-center">
42-
<h3 className="mb-0 text-gray-700">{intl.formatMessage(messages.cohortsTitle)}</h3>
42+
<h3 className="mb-0 text-primary-700">{intl.formatMessage(messages.cohortsTitle)}</h3>
4343
{isCohorted && (
4444
<div className="small">
4545
<IconButton

src/components/SpecifyLearnerField.test.tsx

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,95 @@ import userEvent from '@testing-library/user-event';
33
import SpecifyLearnerField from './SpecifyLearnerField';
44
import messages from './messages';
55
import { renderWithIntl } from '@src/testUtils';
6+
import { useCourseInfo, useLearner } from '@src/data/apiHook';
7+
8+
jest.mock('@src/data/apiHook', () => ({
9+
useCourseInfo: jest.fn(),
10+
useLearner: jest.fn(),
11+
}));
12+
13+
const mockLearnerData = {
14+
username: 'testuser',
15+
fullName: 'Test User',
16+
email: 'test@email.com',
17+
};
618

719
describe('SpecifyLearnerField', () => {
8-
it('renders label and input', () => {
9-
renderWithIntl(<SpecifyLearnerField onChange={jest.fn()} />);
10-
expect(screen.getByText(messages.specifyLearner.defaultMessage)).toBeInTheDocument();
11-
expect(screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage)).toBeInTheDocument();
12-
});
20+
describe('when learner is found', () => {
21+
beforeEach(() => {
22+
jest.resetAllMocks();
23+
(useCourseInfo as jest.Mock).mockReturnValue({ data: { permissions: { admin: true, dataResearcher: false } } });
24+
(useLearner as jest.Mock).mockReturnValue({
25+
data: mockLearnerData,
26+
refetch: jest.fn(),
27+
error: null,
28+
});
29+
});
30+
it('renders label and input', () => {
31+
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
32+
expect(screen.getByText(messages.specifyLearner.defaultMessage)).toBeInTheDocument();
33+
expect(screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage)).toBeInTheDocument();
34+
});
1335

14-
it('renders select button', () => {
15-
renderWithIntl(<SpecifyLearnerField onChange={jest.fn()} />);
16-
expect(screen.getByText(messages.select.defaultMessage)).toBeInTheDocument();
17-
});
36+
it('renders select button', () => {
37+
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
38+
expect(screen.getByText(messages.select.defaultMessage)).toBeInTheDocument();
39+
});
1840

19-
it('calls onChange when input changes', async () => {
20-
const handleChange = jest.fn();
21-
renderWithIntl(<SpecifyLearnerField onChange={handleChange} />);
22-
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
23-
const user = userEvent.setup();
24-
await user.type(input, 'testuser');
25-
expect(handleChange).toHaveBeenCalled();
41+
it('calls onClickSelect when clicking select', async () => {
42+
const handleClick = jest.fn();
43+
renderWithIntl(<SpecifyLearnerField onClickSelect={handleClick} />);
44+
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
45+
const user = userEvent.setup();
46+
await user.type(input, mockLearnerData.username);
47+
const button = screen.getByText(messages.select.defaultMessage);
48+
await user.click(button);
49+
expect(handleClick).toHaveBeenCalledWith(mockLearnerData.username);
50+
});
51+
52+
it('input has correct name attribute', () => {
53+
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
54+
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
55+
expect(input).toHaveAttribute('name', 'emailOrUsername');
56+
});
57+
58+
it('shows learner info and change button after select', async () => {
59+
const handleClick = jest.fn();
60+
renderWithIntl(<SpecifyLearnerField onClickSelect={handleClick} />);
61+
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
62+
const user = userEvent.setup();
63+
await user.type(input, mockLearnerData.username);
64+
const button = screen.getByText(messages.select.defaultMessage);
65+
await user.click(button);
66+
expect(screen.getByText(mockLearnerData.username)).toBeInTheDocument();
67+
expect(screen.getByText(mockLearnerData.fullName)).toBeInTheDocument();
68+
expect(screen.getByText(mockLearnerData.email)).toBeInTheDocument();
69+
expect(screen.getByRole('button', { name: messages.change.defaultMessage })).toBeInTheDocument();
70+
});
2671
});
2772

28-
it('input has correct name attribute', () => {
29-
renderWithIntl(<SpecifyLearnerField onChange={jest.fn()} />);
30-
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
31-
expect(input).toHaveAttribute('name', 'emailOrUsername');
73+
describe('when learner not found', () => {
74+
beforeEach(() => {
75+
jest.resetAllMocks();
76+
(useCourseInfo as jest.Mock).mockReturnValue({ data: { permissions: { admin: true, dataResearcher: false } } });
77+
(useLearner as jest.Mock).mockReturnValue({
78+
data: {},
79+
refetch: jest.fn(),
80+
error: { isAxiosError: true, response: { status: 404 } },
81+
});
82+
});
83+
84+
it('shows error message if learner not found', async () => {
85+
renderWithIntl(<SpecifyLearnerField onClickSelect={jest.fn()} />);
86+
const input = screen.getByPlaceholderText(messages.specifyLearnerPlaceholder.defaultMessage);
87+
const user = userEvent.setup();
88+
await user.type(input, mockLearnerData.username);
89+
const button = screen.getByText(messages.select.defaultMessage);
90+
await user.click(button);
91+
const staticPart = messages.learnerNotFound.defaultMessage.split(':')[0];
92+
expect(
93+
screen.getByText(new RegExp(staticPart + ':'))
94+
).toBeInTheDocument();
95+
});
3296
});
3397
});

src/components/SpecifyLearnerField.tsx

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,89 @@
1-
import { Button, FormControl, FormGroup, FormLabel } from '@openedx/paragon';
1+
import { useState, ChangeEvent } from 'react';
2+
import { isAxiosError } from 'axios';
3+
import { useParams } from 'react-router-dom';
4+
import { Avatar, Button, FormControl, FormGroup, FormLabel, useToggle } from '@openedx/paragon';
25
import { useIntl } from '@openedx/frontend-base';
6+
import { SpinnerIcon } from '@openedx/paragon/icons';
7+
import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter';
8+
import { useCourseInfo, useLearner } from '@src/data/apiHook';
9+
import { SelectedLearner } from '@src/types';
310
import messages from './messages';
411

512
interface SpecifyLearnerFieldProps {
6-
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void,
13+
learner?: SelectedLearner,
14+
onClickSelect: (emailOrUsername: string) => void,
715
}
816

9-
const SpecifyLearnerField = ({ onChange }: SpecifyLearnerFieldProps) => {
17+
const SpecifyLearnerField = ({ learner, onClickSelect }: SpecifyLearnerFieldProps) => {
1018
const intl = useIntl();
19+
const { courseId = '' } = useParams<{ courseId: string }>();
20+
const [identifier, setIdentifier] = useState('');
21+
const [showLearner, enableShowLearner, disableShowLearner] = useToggle(false);
22+
const { data: courseInfo } = useCourseInfo(courseId);
23+
const permissions = courseInfo?.permissions || { admin: false, dataResearcher: false };
24+
const { inputValue, handleChange } = useDebouncedFilter({
25+
filterValue: identifier,
26+
setFilter: setIdentifier,
27+
});
28+
const { data = { email: '', fullName: '', username: '' }, refetch, error } = useLearner(courseId, inputValue);
29+
30+
const selectedLearner = learner || data;
31+
32+
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
33+
handleChange(event.target.value);
34+
35+
if (showLearner) {
36+
disableShowLearner();
37+
}
38+
};
39+
40+
const handleClickSelect = () => {
41+
if (inputValue) {
42+
onClickSelect(inputValue);
43+
refetch();
44+
enableShowLearner();
45+
}
46+
};
1147

1248
return (
13-
<FormGroup size="sm">
14-
<FormLabel>{intl.formatMessage(messages.specifyLearner)}</FormLabel>
15-
<div className="d-flex">
16-
<FormControl className="mr-2" name="emailOrUsername" placeholder={intl.formatMessage(messages.specifyLearnerPlaceholder)} size="md" autoResize onChange={onChange} />
17-
<Button>{intl.formatMessage(messages.select)}</Button>
49+
<FormGroup className="mb-0" size="sm">
50+
<FormLabel className="text-primary-500 d-flex">{intl.formatMessage(messages.specifyLearner)}</FormLabel>
51+
<div className="d-flex align-items-center">
52+
<FormControl
53+
className={`mr-2 ${selectedLearner.username && showLearner ? 'd-none' : ''}`}
54+
name="emailOrUsername"
55+
placeholder={intl.formatMessage(messages.specifyLearnerPlaceholder)}
56+
size="md"
57+
autoResize
58+
value={inputValue}
59+
onChange={handleInputChange}
60+
/>
61+
{selectedLearner.username && showLearner ? (
62+
<>
63+
<Avatar className="mr-2.5" size="sm" />
64+
<div className="d-flex flex-column mr-3 text-primary-500">
65+
<p className="mb-0">{selectedLearner.username}</p>
66+
{(permissions.admin || permissions.dataResearcher)
67+
&& (
68+
<div className="d-flex x-small">
69+
<p className="mr-3 mb-0">{selectedLearner.fullName}</p>
70+
<p className="mb-0">{selectedLearner.email}</p>
71+
</div>
72+
)}
73+
</div>
74+
{!learner && <Button iconBefore={SpinnerIcon} onClick={disableShowLearner}>{intl.formatMessage(messages.change)}</Button>}
75+
</>
76+
) : (
77+
<Button onClick={handleClickSelect} disabled={!inputValue}>{intl.formatMessage(messages.select)}</Button>
78+
)}
1879
</div>
80+
{showLearner && error
81+
&& isAxiosError(error)
82+
&& error.response?.status === 404 && (
83+
<p className="text-danger-500 mb-0 x-small mt-2">
84+
{intl.formatMessage(messages.learnerNotFound, { identifier })}
85+
</p>
86+
)}
1987
</FormGroup>
2088
);
2189
};

src/components/messages.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,17 @@ const messages = defineMessages({
105105
id: 'instruct.csvComponent.uploadingFileMessage',
106106
defaultMessage: 'File chosen: {fileName}',
107107
description: 'Message displayed when a file is being uploaded, with the file name included'
108-
}
108+
},
109+
change: {
110+
id: 'instruct.specifyLearner.change',
111+
defaultMessage: 'Change',
112+
description: 'Label for change button in specify learner field',
113+
},
114+
learnerNotFound: {
115+
id: 'instruct.specifyLearner.learnerNotFound',
116+
defaultMessage: 'Could not find student matching identifier: {identifier}',
117+
description: 'Error message displayed when a learner cannot be found based on the provided identifier (email or username)',
118+
},
109119
});
110120

111121
export default messages;

src/courseInfo/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export interface CourseInfoResponse {
2121
gradeCutoffs: string | null,
2222
staffCount: number,
2323
learnerCount: number,
24+
permissions: {
25+
admin: boolean,
26+
dataResearcher: boolean,
27+
[key: string]: boolean,
28+
},
2429
}
2530

2631
interface EnrollmentCounts extends Record<string, number> {

src/data/api.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCourseInfo } from './api';
1+
import { getCourseInfo, getLearner } from './api';
22
import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base';
33
import { fetchPendingTasks } from './api';
44

@@ -83,4 +83,35 @@ describe('base api', () => {
8383
expect(result).toEqual(mockTasks);
8484
});
8585
});
86+
87+
describe('getLearner', () => {
88+
const mockHttpClient = {
89+
get: jest.fn(),
90+
};
91+
const mockLearnerData = { username: 'testuser', email: 'test@example.com', full_name: 'Test User' };
92+
const mockCamelCaseData = { username: 'testuser', email: 'test@example.com', fullName: 'Test User' };
93+
94+
beforeEach(() => {
95+
mockGetAppConfig.mockReturnValue({ LMS_BASE_URL: 'https://test-lms.com' });
96+
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
97+
mockCamelCaseObject.mockReturnValue(mockCamelCaseData);
98+
mockHttpClient.get.mockResolvedValue({ data: mockLearnerData });
99+
});
100+
101+
it('fetches learner info successfully', async () => {
102+
const courseId = 'course-v1:Test+Course+2025';
103+
const emailOrUsername = 'testuser';
104+
const result = await getLearner(courseId, emailOrUsername);
105+
expect(mockGetAuthenticatedHttpClient).toHaveBeenCalled();
106+
expect(mockHttpClient.get).toHaveBeenCalledWith('https://test-lms.com/api/instructor/v2/courses/course-v1:Test+Course+2025/learners/testuser');
107+
expect(mockCamelCaseObject).toHaveBeenCalledWith(mockLearnerData);
108+
expect(result).toBe(mockCamelCaseData);
109+
});
110+
111+
it('throws error when API call fails', async () => {
112+
const error = new Error('Network error');
113+
mockHttpClient.get.mockRejectedValue(error);
114+
await expect(getLearner('course-v1:Test+Course+2025', 'testuser')).rejects.toThrow('Network error');
115+
});
116+
});
86117
});

src/data/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { camelCaseObject, getAppConfig, getAuthenticatedHttpClient } from '@openedx/frontend-base';
22
import { appId } from '@src/constants';
33
import { CourseInfoResponse } from '@src/courseInfo/types';
4+
import { SelectedLearner } from '@src/types';
45

56
export const getApiBaseUrl = () => getAppConfig(appId).LMS_BASE_URL;
67

@@ -26,3 +27,15 @@ export const fetchPendingTasks = async (courseId: string) => {
2627
);
2728
return response.data?.tasks?.map(camelCaseObject);
2829
};
30+
31+
/**
32+
* Get learner information for a course.
33+
* @param {string} courseId
34+
* @param {string} emailOrUsername
35+
* @returns {Promise<SelectedLearner>}
36+
*/
37+
export const getLearner = async (courseId: string, emailOrUsername: string): Promise<SelectedLearner> => {
38+
const { data } = await getAuthenticatedHttpClient()
39+
.get(`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/learners/${emailOrUsername}`);
40+
return camelCaseObject(data);
41+
};

0 commit comments

Comments
 (0)