Skip to content

Commit a5ac230

Browse files
feat: manage cohorts
1 parent a0de404 commit a5ac230

File tree

7 files changed

+174
-7
lines changed

7 files changed

+174
-7
lines changed

src/cohorts/components/EnabledCohortsView.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jest.mock('../data/apiHook', () => ({
1616
useCohorts: jest.fn(),
1717
useContentGroupsData: jest.fn(),
1818
useCreateCohort: () => ({ mutate: jest.fn() }),
19+
useAddLearnersToCohort: () => ({ mutate: jest.fn() }),
1920
}));
2021

2122
const mockCohorts = [
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { screen, fireEvent } from '@testing-library/react';
2+
import ManageLearners from './ManageLearners';
3+
import { useParams } from 'react-router-dom';
4+
import { useAddLearnersToCohort } from '../data/apiHook';
5+
import { useCohortContext } from './CohortContext';
6+
import messages from '../messages';
7+
import { renderWithIntl } from '../../testUtils';
8+
9+
jest.mock('react-router-dom', () => ({
10+
useParams: jest.fn(),
11+
}));
12+
13+
jest.mock('../data/apiHook', () => ({
14+
useAddLearnersToCohort: jest.fn(),
15+
}));
16+
17+
jest.mock('./CohortContext', () => ({
18+
useCohortContext: jest.fn(),
19+
}));
20+
21+
describe('ManageLearners', () => {
22+
const mutateMock = jest.fn();
23+
24+
beforeEach(() => {
25+
(useParams as jest.Mock).mockReturnValue({ courseId: 'course-v1:edX+Test+2024' });
26+
(useCohortContext as jest.Mock).mockReturnValue({ selectedCohort: { id: 123 } });
27+
(useAddLearnersToCohort as jest.Mock).mockReturnValue({ mutate: mutateMock });
28+
mutateMock.mockReset();
29+
});
30+
31+
it('render all static texts', () => {
32+
renderWithIntl(<ManageLearners />);
33+
expect(screen.getByRole('heading', { name: messages.addLearnersTitle.defaultMessage })).toBeInTheDocument();
34+
expect(screen.getByText(messages.addLearnersSubtitle.defaultMessage)).toBeInTheDocument();
35+
expect(screen.getByText(messages.addLearnersInstructions.defaultMessage)).toBeInTheDocument();
36+
expect(screen.getByPlaceholderText(messages.learnersExample.defaultMessage)).toBeInTheDocument();
37+
expect(screen.getByText(messages.addLearnersFootnote.defaultMessage)).toBeInTheDocument();
38+
expect(screen.getByRole('button', { name: /\+ Add Learners/i })).toBeInTheDocument();
39+
});
40+
41+
it('updates textarea value and calls mutate on button click', () => {
42+
renderWithIntl(<ManageLearners />);
43+
const textarea = screen.getByPlaceholderText(messages.learnersExample.defaultMessage);
44+
fireEvent.change(textarea, { target: { value: '[email protected],[email protected]' } });
45+
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
46+
expect(mutateMock).toHaveBeenCalledWith(
47+
48+
expect.objectContaining({
49+
onSuccess: expect.any(Function),
50+
onError: expect.any(Function),
51+
})
52+
);
53+
});
54+
55+
it('handles empty input gracefully', () => {
56+
renderWithIntl(<ManageLearners />);
57+
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
58+
expect(mutateMock).toHaveBeenCalledWith(
59+
[''],
60+
expect.objectContaining({
61+
onSuccess: expect.any(Function),
62+
onError: expect.any(Function),
63+
})
64+
);
65+
});
66+
67+
it('calls onError if mutate fails', () => {
68+
renderWithIntl(<ManageLearners />);
69+
const textarea = screen.getByPlaceholderText(messages.learnersExample.defaultMessage);
70+
fireEvent.change(textarea, { target: { value: '[email protected]' } });
71+
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
72+
73+
const callArgs = mutateMock.mock.calls[0][1];
74+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
75+
callArgs.onError('error!');
76+
expect(consoleErrorSpy).toHaveBeenCalledWith('error!');
77+
consoleErrorSpy.mockRestore();
78+
});
79+
80+
it('uses default cohort id 0 if selectedCohort is missing', () => {
81+
(useCohortContext as jest.Mock).mockReturnValue({ selectedCohort: undefined });
82+
renderWithIntl(<ManageLearners />);
83+
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
84+
expect(mutateMock).toHaveBeenCalled();
85+
});
86+
});
Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
1+
import { useState } from 'react';
2+
import { useIntl } from '@openedx/frontend-base';
3+
import { Button, FormControl } from '@openedx/paragon';
4+
import { useParams } from 'react-router-dom';
5+
import { useAddLearnersToCohort } from '../data/apiHook';
6+
import messages from '../messages';
7+
import { useCohortContext } from './CohortContext';
8+
19
const ManageLearners = () => {
2-
return <div>Manage Learners Component</div>;
10+
const { courseId = '' } = useParams();
11+
const intl = useIntl();
12+
const { selectedCohort } = useCohortContext();
13+
const { mutate: addLearnersToCohort } = useAddLearnersToCohort(courseId, selectedCohort?.id ? Number(selectedCohort.id) : 0);
14+
const [users, setUsers] = useState('');
15+
16+
const handleAddLearners = () => {
17+
addLearnersToCohort(users.split(','), {
18+
onSuccess: () => {
19+
// Handle success (e.g., show a success message)
20+
},
21+
onError: (error) => {
22+
console.error(error);
23+
}
24+
});
25+
};
26+
27+
return (
28+
<div className="mx-4 my-3.5">
29+
<h3 className="text-primary-700">{intl.formatMessage(messages.addLearnersTitle)}</h3>
30+
<p className="x-small mb-2.5">{intl.formatMessage(messages.addLearnersSubtitle)}</p>
31+
<p className="mb-2 text-primary-500">{intl.formatMessage(messages.addLearnersInstructions)}</p>
32+
<FormControl as="textarea" className="mb-2" placeholder={intl.formatMessage(messages.learnersExample)} onChange={(e) => setUsers(e.target.value)} />
33+
<p className="x-small mb-2.5">{intl.formatMessage(messages.addLearnersFootnote)}</p>
34+
<Button variant="primary" className="mt-2" onClick={handleAddLearners}>+ {intl.formatMessage(messages.addLearnersLabel)}</Button>
35+
</div>
36+
);
337
};
438

539
export default ManageLearners;

src/cohorts/data/api.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe('getCohorts', () => {
5959

6060
expect(getAppConfig).toHaveBeenCalledWith(appId);
6161
expect(mockHttpClient.get).toHaveBeenCalledWith(
62-
`${mockBaseUrl}/api/cohorts/v1/courses/${courseId}/cohorts/`
62+
`${mockBaseUrl}/api/cohorts/v1/courses/${courseId}/cohorts`
6363
);
6464
expect(camelCaseObject).toHaveBeenCalledWith(mockData);
6565
expect(result).toEqual(mockData);

src/cohorts/data/api.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const getCohortStatus = async (courseId: string) => {
99
};
1010

1111
export const getCohorts = async (courseId: string) => {
12-
const url = `${getApiBaseUrl()}/api/cohorts/v1/courses/${courseId}/cohorts/`;
12+
const url = `${getApiBaseUrl()}/api/cohorts/v1/courses/${courseId}/cohorts`;
1313
const { data } = await getAuthenticatedHttpClient().get(url);
1414
return camelCaseObject(data);
1515
};
@@ -21,14 +21,20 @@ export const toggleCohorts = async (courseId: string, isCohorted: boolean) => {
2121
};
2222

2323
export const createCohort = async (courseId: string, cohortDetails: Partial<CohortData>) => {
24-
const url = `${getApiBaseUrl()}/api/cohorts/v1/courses/${courseId}/cohorts/`;
24+
const url = `${getApiBaseUrl()}/api/cohorts/v1/courses/${courseId}/cohorts`;
2525
const cohortDetailsSnakeCase = snakeCaseObject(cohortDetails);
2626
const { data } = await getAuthenticatedHttpClient().post(url, cohortDetailsSnakeCase);
2727
return camelCaseObject(data);
2828
};
2929

3030
export const getContentGroups = async (courseId: string) => {
31-
const url = `${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}/content_groups/`;
31+
const url = `${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}/content_groups`;
3232
const { data } = await getAuthenticatedHttpClient().get(url);
3333
return camelCaseObject(data);
3434
};
35+
36+
export const addLearnersToCohort = async (courseId: string, cohortId: number, users: string[]) => {
37+
const url = `${getApiBaseUrl()}/api/cohorts/v1/courses/${courseId}/cohorts/${cohortId}/users`;
38+
const { data } = await getAuthenticatedHttpClient().post(url, { users });
39+
return camelCaseObject(data);
40+
};

src/cohorts/data/apiHook.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2-
import { getCohorts, getCohortStatus, getContentGroups, toggleCohorts, createCohort } from './api';
2+
import { getCohorts, getCohortStatus, getContentGroups, toggleCohorts, createCohort, addLearnersToCohort } from './api';
33
import { cohortsQueryKeys } from './queryKeys';
44
import { CohortData } from '../components/CohortContext';
55

@@ -45,3 +45,13 @@ export const useContentGroupsData = (courseId: string) => (
4545
queryFn: () => getContentGroups(courseId),
4646
})
4747
);
48+
49+
export const useAddLearnersToCohort = (courseId: string, cohortId: number) => {
50+
const queryClient = useQueryClient();
51+
return useMutation({
52+
mutationFn: (users: string[]) => addLearnersToCohort(courseId, cohortId, users),
53+
onSuccess: () => {
54+
queryClient.invalidateQueries({ queryKey: cohortsQueryKeys.list(courseId) });
55+
},
56+
});
57+
};

src/cohorts/messages.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,37 @@ const messages = defineMessages({
135135
id: 'instruct.cohorts.studentsOnCohort',
136136
defaultMessage: '(contains {users} students)',
137137
description: 'Label showing the number of students on this cohort'
138-
}
138+
},
139+
addLearnersTitle: {
140+
id: 'instruct.cohorts.addLearnersTitle',
141+
defaultMessage: 'Add Learners to this cohort',
142+
description: 'Title for the add learners section'
143+
},
144+
addLearnersSubtitle: {
145+
id: 'instruct.cohorts.addLearnersSubtitle',
146+
defaultMessage: 'Note: Learners can be in only one cohort. Adding learners to this group overrides any previous group assignment.',
147+
description: 'Subtitle for the add learners section'
148+
},
149+
addLearnersInstructions: {
150+
id: 'instruct.cohorts.addLearnersInstructions',
151+
defaultMessage: 'Enter email addresses and/or usernames, separated by new lines or commas, for the learners you want to add.*',
152+
description: 'Instructions for adding learners to a cohort'
153+
},
154+
addLearnersFootnote: {
155+
id: 'instruct.cohorts.addLearnersFootnote',
156+
defaultMessage: 'You will not receive notification for emails that bounce, so double-check your spelling.',
157+
description: 'Footnote for adding learners to a cohort'
158+
},
159+
learnersExample: {
160+
id: 'instruct.cohorts.learnersExample',
161+
defaultMessage: 'e.g. [email protected], JaneDoe, [email protected]',
162+
description: 'Placeholder for the learners example'
163+
},
164+
addLearnersLabel: {
165+
id: 'instruct.cohorts.addLearnersLabel',
166+
defaultMessage: 'Add Learners',
167+
description: 'Label for the add learners button'
168+
},
139169
});
140170

141171
export default messages;

0 commit comments

Comments
 (0)