Skip to content

Commit 88d3700

Browse files
Enable cohorts (#70)
* feat: enable cohorts * test: unit tests * refactor: add disable/enable views * fix: change endpoints to use v1 * test: add additional tests * fix: fix title colors and add onError * refactor: move cohorts data to its own folder * refactor: rename lms base url * docs: add documentation for override external urls
1 parent 5ce5a16 commit 88d3700

19 files changed

+689
-14
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
Override External URLs
2+
======================
3+
4+
What is getExternalLinkUrl?
5+
---------------------------
6+
7+
The `getExternalLinkUrl` function is a utility from `@openedx/frontend-base` that allows for centralized management of external URLs. It enables the override of external links through configuration, making it possible to customize external references without modifying the source code directly.
8+
9+
URLs wrapped with getExternalLinkUrl
10+
------------------------------------
11+
12+
Currently, the following external URLs are wrapped with `getExternalLinkUrl` in the authoring application:
13+
14+
- https://openedx.atlassian.net/wiki/spaces/ENG/pages/123456789/Cohorts+Feature+Documentation
15+
16+
17+
How to Override External URLs
18+
-----------------------------
19+
20+
To override external URLs, you can use the frontend platform's configuration system.
21+
This object should be added to the config object defined in the env.config.[js,jsx,ts,tsx], and must be named externalLinkUrlOverrides.
22+
23+
1. **Environment Configuration**
24+
Add the URL overrides to your environment configuration:
25+
26+
.. code-block:: javascript
27+
28+
const config = {
29+
// Other config options...
30+
externalLinkUrlOverrides: {
31+
'https://www.edx.org/accessibility': 'https://your-custom-domain.com/accessibility',
32+
// Add other URL overrides here
33+
}
34+
};
35+
36+
Examples
37+
--------
38+
39+
**Original URL:** Default community accessibility link
40+
**Override:** Your institution's accessibility policy page
41+
42+
.. code-block:: javascript
43+
44+
// In your app configuration
45+
getExternalLinkUrl('https://www.edx.org/accessibility')
46+
// Returns: 'https://your-custom-domain.com/accessibility'
47+
// Instead of the default Open edX community link
48+
49+
Benefits
50+
--------
51+
52+
- **Customization**: Institutions can point to their own resources
53+
- **Maintainability**: URLs can be changed without code modifications
54+
- **Consistency**: Centralized URL management across the application
55+
- **Flexibility**: Different environments can have different external links

src/cohorts/CohortsPage.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import CohortsPage from './CohortsPage';
4+
import { useCohorts, useCohortStatus, useToggleCohorts } from './data/apiHook';
5+
import { renderWithIntl } from '../testUtils';
6+
import messages from './messages';
7+
8+
jest.mock('react-router-dom', () => ({
9+
...jest.requireActual('react-router-dom'),
10+
useParams: () => ({ courseId: 'course-v1:edX+Test+2024' }),
11+
}));
12+
13+
jest.mock('./data/apiHook', () => ({
14+
useCohorts: jest.fn(),
15+
useCohortStatus: jest.fn(),
16+
useToggleCohorts: jest.fn(),
17+
}));
18+
19+
describe('CohortsPage', () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('renders cohorts list and add button when cohorts exist', () => {
25+
(useCohorts as jest.Mock).mockReturnValue({ data: [{ id: '1', name: 'Cohort 1' }] });
26+
(useCohortStatus as jest.Mock).mockReturnValue({ data: { isCohorted: true } });
27+
(useToggleCohorts as jest.Mock).mockReturnValue({ mutate: jest.fn() });
28+
29+
renderWithIntl(<CohortsPage />);
30+
expect(screen.getByText(messages.cohortsTitle.defaultMessage)).toBeInTheDocument();
31+
expect(screen.getByRole('option', { name: 'Cohort 1' })).toBeInTheDocument();
32+
expect(screen.getByText(`+ ${messages.addCohort.defaultMessage}`)).toBeInTheDocument();
33+
});
34+
35+
it('renders no cohorts message and enable button when no cohorts', () => {
36+
(useCohorts as jest.Mock).mockReturnValue({ data: [] });
37+
(useCohortStatus as jest.Mock).mockReturnValue({ data: { isCohorted: false } });
38+
(useToggleCohorts as jest.Mock).mockReturnValue({ mutate: jest.fn() });
39+
40+
renderWithIntl(<CohortsPage />);
41+
expect(screen.getByText(messages.noCohortsMessage.defaultMessage)).toBeInTheDocument();
42+
expect(screen.getByRole('button', { name: messages.enableCohorts.defaultMessage })).toBeInTheDocument();
43+
expect(screen.getByRole('link', { name: messages.learnMore.defaultMessage })).toBeInTheDocument();
44+
});
45+
46+
it('calls enableCohortsMutate when enable button is clicked', async () => {
47+
const enableMock = jest.fn();
48+
(useCohorts as jest.Mock).mockReturnValue({ data: [] });
49+
(useCohortStatus as jest.Mock).mockReturnValue({ data: { isCohorted: false } });
50+
(useToggleCohorts as jest.Mock).mockReturnValue({ mutate: enableMock });
51+
52+
renderWithIntl(<CohortsPage />);
53+
const user = userEvent.setup();
54+
await user.click(screen.getByRole('button', { name: messages.enableCohorts.defaultMessage }));
55+
expect(enableMock).toHaveBeenCalled();
56+
});
57+
58+
it('opens and closes the disable cohorts modal', async () => {
59+
(useCohorts as jest.Mock).mockReturnValue({ data: [{ id: '1', name: 'Cohort 1' }] });
60+
(useCohortStatus as jest.Mock).mockReturnValue({ data: { isCohorted: true } });
61+
(useToggleCohorts as jest.Mock).mockReturnValue({ mutate: jest.fn() });
62+
63+
renderWithIntl(<CohortsPage />);
64+
const user = userEvent.setup();
65+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
66+
await user.click(screen.getByRole('button', { name: messages.disableCohorts.defaultMessage }));
67+
expect(screen.getByRole('dialog', { name: messages.disableCohorts.defaultMessage })).toBeInTheDocument();
68+
});
69+
70+
it('calls disableCohortsMutate and closes modal on confirm', async () => {
71+
const disableMock = jest.fn();
72+
(useCohorts as jest.Mock).mockReturnValue({ data: [{ id: '1', name: 'Cohort 1' }] });
73+
(useCohortStatus as jest.Mock).mockReturnValue({ data: { isCohorted: true } });
74+
(useToggleCohorts as jest.Mock).mockReturnValue({ mutate: disableMock });
75+
76+
renderWithIntl(<CohortsPage />);
77+
const user = userEvent.setup();
78+
await user.click(screen.getByRole('button', { name: messages.disableCohorts.defaultMessage }));
79+
await user.click(screen.getByRole('button', { name: messages.disableLabel.defaultMessage }));
80+
expect(disableMock).toHaveBeenCalled();
81+
});
82+
});

src/cohorts/CohortsPage.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useIntl } from '@openedx/frontend-base';
2+
import { IconButton } from '@openedx/paragon';
3+
import { Settings } from '@openedx/paragon/icons';
4+
import { useParams } from 'react-router-dom';
5+
import { useState } from 'react';
6+
import { useCohortStatus, useToggleCohorts } from './data/apiHook';
7+
import DisableCohortsModal from './components/DisableCohortsModal';
8+
import messages from './messages';
9+
import DisabledCohortsView from './components/DisabledCohortsView';
10+
import EnabledCohortsView from './components/EnabledCohortsView';
11+
12+
const CohortsPage = () => {
13+
const intl = useIntl();
14+
const { courseId = '' } = useParams();
15+
const { data: cohortStatus } = useCohortStatus(courseId);
16+
const { mutate: toggleCohortsMutate } = useToggleCohorts(courseId);
17+
const [isOpenDisableModal, setIsOpenDisableModal] = useState(false);
18+
const { isCohorted = false } = cohortStatus ?? {};
19+
20+
const handleEnableCohorts = () => {
21+
toggleCohortsMutate({ isCohorted: true },
22+
{
23+
onError: (error) => console.log(error)
24+
});
25+
};
26+
27+
const handleDisableCohorts = () => {
28+
toggleCohortsMutate({ isCohorted: false },
29+
{
30+
onError: (error) => console.log(error)
31+
});
32+
setIsOpenDisableModal(false);
33+
};
34+
35+
return (
36+
<div className="mt-4.5 mb-4 mx-4">
37+
<div className="d-inline-flex align-items-center">
38+
<h3 className="mb-0 text-gray-700">{intl.formatMessage(messages.cohortsTitle)}</h3>
39+
{isCohorted && (
40+
<div className="small">
41+
<IconButton
42+
alt={intl.formatMessage(messages.disableCohorts)}
43+
iconAs={Settings}
44+
iconClassNames="mb-2 text-gray-500"
45+
size="sm"
46+
variant="secondary"
47+
onClick={() => setIsOpenDisableModal(true)}
48+
/>
49+
</div>
50+
)}
51+
</div>
52+
{isCohorted ? (
53+
<EnabledCohortsView />
54+
) : (
55+
<DisabledCohortsView onEnableCohorts={handleEnableCohorts} />
56+
)}
57+
<DisableCohortsModal isOpen={isOpenDisableModal} onClose={() => setIsOpenDisableModal(false)} onConfirmDisable={handleDisableCohorts} />
58+
</div>
59+
);
60+
};
61+
62+
export default CohortsPage;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import DisableCohortsModal from './DisableCohortsModal';
4+
import { renderWithIntl } from '../../testUtils';
5+
import messages from '../messages';
6+
7+
describe('DisableCohortsModal', () => {
8+
const onClose = jest.fn();
9+
const onConfirmDisable = jest.fn();
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
});
14+
15+
it('renders modal when isOpen is true', () => {
16+
renderWithIntl(
17+
<DisableCohortsModal
18+
isOpen={true}
19+
onClose={onClose}
20+
onConfirmDisable={onConfirmDisable}
21+
/>
22+
);
23+
expect(screen.getByRole('dialog', { name: messages.disableCohorts.defaultMessage })).toBeInTheDocument();
24+
expect(screen.getByText(messages.disableMessage.defaultMessage)).toBeInTheDocument();
25+
expect(screen.getByText(messages.cancelLabel.defaultMessage)).toBeInTheDocument();
26+
expect(screen.getByText(messages.disableLabel.defaultMessage)).toBeInTheDocument();
27+
});
28+
29+
it('does not render modal when isOpen is false', () => {
30+
renderWithIntl(
31+
<DisableCohortsModal
32+
isOpen={false}
33+
onClose={onClose}
34+
onConfirmDisable={onConfirmDisable}
35+
/>
36+
);
37+
expect(screen.queryByText(messages.disableCohorts.defaultMessage)).not.toBeInTheDocument();
38+
});
39+
40+
it('calls onClose when cancel button is clicked', async () => {
41+
renderWithIntl(
42+
<DisableCohortsModal
43+
isOpen={true}
44+
onClose={onClose}
45+
onConfirmDisable={onConfirmDisable}
46+
/>
47+
);
48+
const user = userEvent.setup();
49+
const cancelButton = screen.getByRole('button', { name: messages.cancelLabel.defaultMessage });
50+
await user.click(cancelButton);
51+
expect(onClose).toHaveBeenCalled();
52+
});
53+
54+
it('calls onConfirmDisable when disable button is clicked', async () => {
55+
renderWithIntl(
56+
<DisableCohortsModal
57+
isOpen={true}
58+
onClose={onClose}
59+
onConfirmDisable={onConfirmDisable}
60+
/>
61+
);
62+
const user = userEvent.setup();
63+
const disableButton = screen.getByRole('button', { name: messages.disableLabel.defaultMessage });
64+
await user.click(disableButton);
65+
expect(onConfirmDisable).toHaveBeenCalled();
66+
});
67+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
2+
import { useIntl } from '@openedx/frontend-base';
3+
import messages from '../messages';
4+
5+
interface DisableCohortsModalProps {
6+
isOpen: boolean,
7+
onClose: () => void,
8+
onConfirmDisable: () => void,
9+
}
10+
11+
const DisableCohortsModal = ({ isOpen, onClose, onConfirmDisable }: DisableCohortsModalProps) => {
12+
const intl = useIntl();
13+
14+
return (
15+
<ModalDialog title={intl.formatMessage(messages.disableCohorts)} onClose={onClose} isOpen={isOpen} size="sm" hasCloseButton={false} isOverflowVisible={false}>
16+
<div className="mx-4 mt-4 mb-2.5">
17+
<p>{intl.formatMessage(messages.disableMessage)}</p>
18+
</div>
19+
<ModalDialog.Footer>
20+
<ActionRow>
21+
<Button variant="tertiary" onClick={onClose}>{intl.formatMessage(messages.cancelLabel)}</Button>
22+
<Button variant="primary" onClick={onConfirmDisable}>{intl.formatMessage(messages.disableLabel)}</Button>
23+
</ActionRow>
24+
</ModalDialog.Footer>
25+
</ModalDialog>
26+
);
27+
};
28+
29+
export default DisableCohortsModal;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { screen } from '@testing-library/react';
2+
import { renderWithIntl } from '../../testUtils';
3+
import messages from '../messages';
4+
import DisabledCohortsView from './DisabledCohortsView';
5+
import userEvent from '@testing-library/user-event';
6+
7+
describe('DisabledCohortsView', () => {
8+
const onEnableCohorts = jest.fn();
9+
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
it('renders the no cohorts message', () => {
15+
renderWithIntl(<DisabledCohortsView onEnableCohorts={onEnableCohorts} />);
16+
expect(screen.getByText(messages.noCohortsMessage.defaultMessage)).toBeInTheDocument();
17+
});
18+
19+
it('renders the learn more link with correct href', () => {
20+
renderWithIntl(<DisabledCohortsView onEnableCohorts={onEnableCohorts} />);
21+
const link = screen.getByRole('link', { name: messages.learnMore.defaultMessage }) as HTMLAnchorElement;
22+
expect(link).toBeInTheDocument();
23+
expect(link.href).toContain('https://openedx.atlassian.net/wiki/spaces/ENG/pages/123456789/Cohorts+Feature+Documentation');
24+
});
25+
26+
it('renders the enable cohorts button', () => {
27+
renderWithIntl(<DisabledCohortsView onEnableCohorts={onEnableCohorts} />);
28+
expect(screen.getByRole('button', { name: messages.enableCohorts.defaultMessage })).toBeInTheDocument();
29+
});
30+
31+
it('calls onEnableCohorts when button is clicked', async () => {
32+
renderWithIntl(<DisabledCohortsView onEnableCohorts={onEnableCohorts} />);
33+
const user = userEvent.setup();
34+
const enableCohortsButton = screen.getByRole('button', { name: messages.enableCohorts.defaultMessage });
35+
await user.click(enableCohortsButton);
36+
expect(onEnableCohorts).toHaveBeenCalledTimes(1);
37+
});
38+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getExternalLinkUrl, useIntl } from '@openedx/frontend-base';
2+
import { Button } from '@openedx/paragon';
3+
import messages from '../messages';
4+
5+
interface DisabledCohortsViewProps {
6+
onEnableCohorts: () => void,
7+
}
8+
9+
const DisabledCohortsView = ({ onEnableCohorts }: DisabledCohortsViewProps) => {
10+
const intl = useIntl();
11+
12+
return (
13+
<div className="d-flex bg-light-200 border border-light-400 p-5 mt-4.5 align-items-center">
14+
<p className="m-0">
15+
{intl.formatMessage(messages.noCohortsMessage)} <a href={getExternalLinkUrl('https://openedx.atlassian.net/wiki/spaces/ENG/pages/123456789/Cohorts+Feature+Documentation')}>{intl.formatMessage(messages.learnMore)}</a>
16+
</p>
17+
<Button className="ml-3 flex-shrink-0" onClick={onEnableCohorts}>{intl.formatMessage(messages.enableCohorts)}</Button>
18+
</div>
19+
);
20+
};
21+
22+
export default DisabledCohortsView;

0 commit comments

Comments
 (0)