diff --git a/src/cohorts/components/ManageLearners.test.tsx b/src/cohorts/components/ManageLearners.test.tsx
index c5fb89b3..57ca087a 100644
--- a/src/cohorts/components/ManageLearners.test.tsx
+++ b/src/cohorts/components/ManageLearners.test.tsx
@@ -5,6 +5,7 @@ import { useCohortContext } from '@src/cohorts/components/CohortContext';
import ManageLearners from '@src/cohorts/components/ManageLearners';
import messages from '@src/cohorts/messages';
import { renderWithIntl } from '@src/testUtils';
+import { AlertProvider } from '@src/components/AlertContext';
jest.mock('react-router-dom', () => ({
useParams: jest.fn(),
@@ -18,6 +19,8 @@ jest.mock('@src/cohorts/components/CohortContext', () => ({
useCohortContext: jest.fn(),
}));
+const renderWithAlertProvider = () => renderWithIntl();
+
describe('ManageLearners', () => {
const mutateMock = jest.fn();
@@ -29,7 +32,7 @@ describe('ManageLearners', () => {
});
it('render all static texts', () => {
- renderWithIntl();
+ renderWithAlertProvider();
expect(screen.getByRole('heading', { name: messages.addLearnersTitle.defaultMessage })).toBeInTheDocument();
expect(screen.getByText(messages.addLearnersSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.addLearnersInstructions.defaultMessage)).toBeInTheDocument();
@@ -39,7 +42,7 @@ describe('ManageLearners', () => {
});
it('updates textarea value and calls mutate on button click', () => {
- renderWithIntl();
+ renderWithAlertProvider();
const textarea = screen.getByPlaceholderText(messages.learnersExample.defaultMessage);
fireEvent.change(textarea, { target: { value: 'user1@example.com,user2@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
@@ -53,10 +56,10 @@ describe('ManageLearners', () => {
});
it('handles empty input gracefully', () => {
- renderWithIntl();
+ renderWithAlertProvider();
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
expect(mutateMock).toHaveBeenCalledWith(
- [''],
+ [],
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
@@ -65,7 +68,7 @@ describe('ManageLearners', () => {
});
it('calls onError if mutate fails', () => {
- renderWithIntl();
+ renderWithAlertProvider();
const textarea = screen.getByPlaceholderText(messages.learnersExample.defaultMessage);
fireEvent.change(textarea, { target: { value: 'user@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
@@ -79,7 +82,7 @@ describe('ManageLearners', () => {
it('uses default cohort id 0 if selectedCohort is missing', () => {
(useCohortContext as jest.Mock).mockReturnValue({ selectedCohort: undefined });
- renderWithIntl();
+ renderWithAlertProvider();
fireEvent.click(screen.getByRole('button', { name: /\+ Add Learners/i }));
expect(mutateMock).toHaveBeenCalled();
});
diff --git a/src/cohorts/components/ManageLearners.tsx b/src/cohorts/components/ManageLearners.tsx
index 644e5182..3e052401 100644
--- a/src/cohorts/components/ManageLearners.tsx
+++ b/src/cohorts/components/ManageLearners.tsx
@@ -5,6 +5,15 @@ import { Button, FormControl } from '@openedx/paragon';
import { useCohortContext } from '@src/cohorts/components/CohortContext';
import { useAddLearnersToCohort } from '@src/cohorts/data/apiHook';
import messages from '@src/cohorts/messages';
+import { useAlert } from '@src/components/AlertContext';
+
+interface AddLearnersResponse {
+ added: string[],
+ changed: string[],
+ preassigned: string[],
+ present: string[],
+ unknown: string[],
+}
const ManageLearners = () => {
const { courseId = '' } = useParams();
@@ -12,13 +21,62 @@ const ManageLearners = () => {
const { selectedCohort } = useCohortContext();
const { mutate: addLearnersToCohort } = useAddLearnersToCohort(courseId, selectedCohort?.id ? Number(selectedCohort.id) : 0);
const [users, setUsers] = useState('');
+ const { addAlert, clearAlerts } = useAlert();
+
+ const handleAlertMessages = (response: AddLearnersResponse) => {
+ const { added, changed, preassigned, present, unknown } = response;
+ if (preassigned.length > 0) {
+ addAlert({
+ type: 'warning',
+ message: intl.formatMessage(messages.addLearnersWarningMessage, {
+ countLearners: preassigned.length,
+ }),
+ extraContent: (
+ preassigned.map((learner: string) => (
+ • {learner}
+ ))
+ )
+ });
+ }
+ if (present.length > 0 || added.length > 0 || changed.length > 0) {
+ addAlert({
+ type: 'success',
+ message: intl.formatMessage(messages.addLearnersSuccessMessage, {
+ countLearners: added.length + changed.length,
+ }),
+ extraContent: (
+ present.length > 0 && (
+ present.map((learner: string) => (
+ • {intl.formatMessage(messages.existingLearner, { learner })}
+ ))
+ ))
+ });
+ }
+ if (unknown.length > 0) {
+ addAlert({
+ type: 'error',
+ message: intl.formatMessage(messages.addLearnersErrorMessage, {
+ countLearners: unknown.length,
+ }),
+ extraContent: (
+ unknown.map((learner: string) => (
+ • {intl.formatMessage(messages.unknownLearner, { learner })}
+ ))
+ )
+ });
+ }
+ };
const handleAddLearners = () => {
- addLearnersToCohort(users.split(','), {
- onSuccess: () => {
- // Handle success (e.g., show a success message)
- },
+ clearAlerts();
+ const usersArray = users.split(/[\n,]+/).map(u => u.trim()).filter(Boolean);
+ addLearnersToCohort(usersArray, {
+ onSuccess: handleAlertMessages,
onError: (error) => {
+ addAlert({
+ type: 'error',
+ message: intl.formatMessage(messages.addLearnersErrorMessage)
+ });
console.error(error);
}
});
diff --git a/src/cohorts/components/SelectedCohortInfo.test.tsx b/src/cohorts/components/SelectedCohortInfo.test.tsx
new file mode 100644
index 00000000..52463e7f
--- /dev/null
+++ b/src/cohorts/components/SelectedCohortInfo.test.tsx
@@ -0,0 +1,85 @@
+import { screen } from '@testing-library/react';
+import SelectedCohortInfo from './SelectedCohortInfo';
+import messages from '../messages';
+import dataDownloadsMessages from '@src/dataDownloads/messages';
+import { renderWithIntl } from '@src/testUtils';
+import * as CohortContextModule from '@src/cohorts/components/CohortContext';
+import { CohortProvider } from './CohortContext';
+import { AlertProvider } from '@src/components/AlertContext';
+import { useCohorts, useContentGroupsData } from '../data/apiHook';
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ courseId: 'course-v1:edX+DemoX+Demo_Course' }),
+}));
+
+const mockCohorts = [
+ {
+ id: 1,
+ name: 'Initial Cohort',
+ assignmentType: 'manual',
+ groupId: 2,
+ userPartitionId: 3,
+ userCount: 0
+ },
+ { id: 2, name: 'Cohort 2',
+ assignmentType: 'automatic',
+ groupId: null,
+ userPartitionId: null,
+ userCount: 5
+ },
+];
+
+const mockContentGroups = [
+ { id: '2', name: 'Group 1' },
+ { id: '3', name: 'Group 2' },
+];
+
+jest.mock('@src/cohorts/data/apiHook', () => ({
+ useCohorts: jest.fn(),
+ useContentGroupsData: jest.fn(),
+ useCreateCohort: () => ({ mutate: jest.fn() }),
+ usePatchCohort: () => ({ mutate: jest.fn() }),
+ useAddLearnersToCohort: () => ({ mutate: jest.fn() }),
+}));
+
+function renderWithProviders() {
+ return renderWithIntl(
+
+
+
+
+
+ );
+}
+
+describe('SelectedCohortInfo', () => {
+ beforeEach(() => {
+ (useCohorts as jest.Mock).mockReturnValue({ data: mockCohorts });
+ (useContentGroupsData as jest.Mock).mockReturnValue({ data: { allGroupConfigurations: [{ groups: mockContentGroups }] } });
+ jest.spyOn(CohortContextModule, 'useCohortContext').mockReturnValue({
+ selectedCohort: mockCohorts[0],
+ setSelectedCohort: jest.fn(),
+ clearSelectedCohort: jest.fn(),
+ updateCohortField: jest.fn(),
+ });
+ });
+
+ it('if a cohort is selected renders CohortCard', () => {
+ renderWithProviders();
+ expect(screen.getByRole('tablist')).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: messages.manageLearners.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('renders cohort disclaimer message', () => {
+ renderWithProviders();
+ expect(screen.getByText(new RegExp(messages.cohortDisclaimer.defaultMessage))).toBeInTheDocument();
+ });
+
+ it('renders data downloads hyperlink with correct destination', () => {
+ renderWithProviders();
+ const link = screen.getByRole('link', { name: dataDownloadsMessages.pageTitle.defaultMessage });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', '/instructor/course-v1:edX+DemoX+Demo_Course/data_downloads');
+ });
+});
diff --git a/src/cohorts/components/SelectedCohortInfo.tsx b/src/cohorts/components/SelectedCohortInfo.tsx
index 2dbbb9bf..43006727 100644
--- a/src/cohorts/components/SelectedCohortInfo.tsx
+++ b/src/cohorts/components/SelectedCohortInfo.tsx
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
import CohortCard from './CohortCard';
import messages from '../messages';
import dataDownloadsMessages from '@src/dataDownloads/messages';
+import { Hyperlink } from '@openedx/paragon';
const SelectedCohortInfo = () => {
const intl = useIntl();
@@ -12,7 +13,7 @@ const SelectedCohortInfo = () => {
<>
- {intl.formatMessage(messages.cohortDisclaimer)} {intl.formatMessage(dataDownloadsMessages.pageTitle)} {intl.formatMessage(messages.page)}
+ {intl.formatMessage(messages.cohortDisclaimer)} {intl.formatMessage(dataDownloadsMessages.pageTitle)} {intl.formatMessage(messages.page)}
>
);
diff --git a/src/cohorts/data/api.ts b/src/cohorts/data/api.ts
index 7c00e164..5378c31d 100644
--- a/src/cohorts/data/api.ts
+++ b/src/cohorts/data/api.ts
@@ -28,7 +28,7 @@ export const createCohort = async (courseId: string, cohortDetails: BasicCohortD
};
export const getContentGroups = async (courseId: string) => {
- const url = `${getApiBaseUrl()}/api/instructor/v1/courses/${courseId}/group_configurations`;
+ const url = `${getApiBaseUrl()}/api/cohorts/v2/courses/${courseId}/group_configurations`;
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
};
diff --git a/src/cohorts/data/apiHook.ts b/src/cohorts/data/apiHook.ts
index a061d1dc..c4bd2e79 100644
--- a/src/cohorts/data/apiHook.ts
+++ b/src/cohorts/data/apiHook.ts
@@ -43,6 +43,7 @@ export const useContentGroupsData = (courseId: string) => (
useQuery({
queryKey: cohortsQueryKeys.contentGroups(courseId),
queryFn: () => getContentGroups(courseId),
+ enabled: !!courseId,
})
);
diff --git a/src/cohorts/messages.ts b/src/cohorts/messages.ts
index f8457bc0..8bfd2ee7 100644
--- a/src/cohorts/messages.ts
+++ b/src/cohorts/messages.ts
@@ -186,6 +186,36 @@ const messages = defineMessages({
defaultMessage: 'Add Learners',
description: 'Label for the add learners button'
},
+ manualAssignmentDisabledTooltip: {
+ id: 'instruct.cohorts.manualAssignmentDisabledTooltip',
+ defaultMessage: 'There must be one cohort to which students can automatically be assigned.',
+ description: 'Tooltip message when manual assignment is disabled'
+ },
+ addLearnersSuccessMessage: {
+ id: 'instruct.cohorts.addLearnersSuccessMessage',
+ defaultMessage: '{countLearners} learners have been added to this cohort.',
+ description: 'Success message displayed when learners are added to a cohort'
+ },
+ addLearnersErrorMessage: {
+ id: 'instruct.cohorts.addLearnersErrorMessage',
+ defaultMessage: '{countLearners} learners could not be added to this cohort.',
+ description: 'Error message displayed when there is an issue adding learners to a cohort'
+ },
+ addLearnersWarningMessage: {
+ id: 'instruct.cohorts.addLearnersWarningMessage',
+ defaultMessage: '{countLearners} were pre-assigned for this cohort. This learner will automatically be added to the cohort when they enroll in the course.',
+ description: 'Warning message displayed when some learners could not be added to a cohort'
+ },
+ existingLearner: {
+ id: 'instruct.cohorts.existingLearner',
+ defaultMessage: '{learner} learner was already in the cohort.',
+ description: 'Message indicating that a learner is already assigned to a cohort'
+ },
+ unknownLearner: {
+ id: 'instruct.cohorts.unknownLearner',
+ defaultMessage: 'Unknown username or email: {learner}',
+ description: 'Message indicating that a learner is not recognized in the course'
+ },
});
export default messages;
diff --git a/src/components/AlertContext.test.tsx b/src/components/AlertContext.test.tsx
new file mode 100644
index 00000000..d6c10c84
--- /dev/null
+++ b/src/components/AlertContext.test.tsx
@@ -0,0 +1,100 @@
+import { ReactElement } from 'react';
+import { render, screen } from '@testing-library/react';
+import { AlertProvider, useAlert } from '@src/components/AlertContext';
+import userEvent from '@testing-library/user-event';
+
+const TestComponent = () => {
+ const { alerts, addAlert, removeAlert, clearAlerts } = useAlert();
+
+ return (
+
+
+
+
+
+
+ {alerts.map(alert => (
+ -
+ {alert.type}: {alert.message}
+
+ ))}
+
+
+ );
+};
+
+const renderWithProvider = (ui: ReactElement) =>
+ render({ui});
+
+describe('AlertContext', () => {
+ it('throws error when used outside provider', () => {
+ const BrokenComponent = () => {
+ useAlert();
+ return <>>;
+ };
+ expect(() => render()).toThrow(
+ 'useAlert must be used within a AlertProvider'
+ );
+ });
+
+ it('adds an alert', async () => {
+ renderWithProvider();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: /Add Success/i }));
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
+ });
+
+ it('adds multiple alerts of different types', async () => {
+ renderWithProvider();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: /Add Success/i }));
+ await user.click(screen.getByRole('button', { name: /Add Error/i }));
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
+ expect(screen.getByText('success: Success!')).toBeInTheDocument();
+ expect(screen.getByText('error: Error!')).toBeInTheDocument();
+ });
+
+ it('removes an alert by id', async () => {
+ renderWithProvider();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: /Add Success/i }));
+ await user.click(screen.getByRole('button', { name: /Remove First/i }));
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0);
+ });
+
+ it('clears all alerts', async () => {
+ renderWithProvider();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: /Add Success/i }));
+ await user.click(screen.getByRole('button', { name: /Add Error/i }));
+ await user.click(screen.getByRole('button', { name: /Clear All/i }));
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0);
+ });
+
+ it('alerts have unique ids', async () => {
+ renderWithProvider();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: /Add Success/i }));
+ await user.click(screen.getByRole('button', { name: /Add Success/i }));
+ const alerts = screen.getAllByRole('listitem');
+ expect(alerts.length).toBe(2);
+ expect(alerts[0].textContent).toBe('success: Success!');
+ expect(alerts[1].textContent).toBe('success: Success!');
+ });
+});
diff --git a/src/components/AlertContext.tsx b/src/components/AlertContext.tsx
new file mode 100644
index 00000000..11ab757f
--- /dev/null
+++ b/src/components/AlertContext.tsx
@@ -0,0 +1,48 @@
+import { createContext, useContext, useState, ReactNode } from 'react';
+
+export type AlertType = 'success' | 'error' | 'info' | 'warning';
+
+export interface AlertProps {
+ id: string,
+ type: AlertType,
+ message: string,
+ extraContent?: ReactNode,
+}
+
+interface AlertContextProps {
+ alerts: AlertProps[],
+ addAlert: (alert: Omit) => void,
+ removeAlert: (id: string) => void,
+ clearAlerts: () => void,
+}
+
+const AlertContext = createContext(undefined);
+
+export const useAlert = () => {
+ const context = useContext(AlertContext);
+ if (!context) {
+ throw new Error('useAlert must be used within a AlertProvider');
+ }
+ return context;
+};
+
+export const AlertProvider = ({ children }: { children: ReactNode }) => {
+ const [alerts, setAlerts] = useState([]);
+
+ const addAlert = (alert: Omit) => {
+ const id = `${Date.now()}-${Math.random()}`;
+ setAlerts(prev => [...prev, { ...alert, id }]);
+ };
+
+ const removeAlert = (id: string) => {
+ setAlerts(prev => prev.filter(alert => alert.id !== id));
+ };
+
+ const clearAlerts = () => setAlerts([]);
+
+ return (
+
+ {children}
+
+ );
+};