Skip to content

Commit 4db8fca

Browse files
authored
feat: add course info settings sidebar [FC-0123] (#2955)
Adds the Settings tab to the course info sidebar, which shows links to some course settings pages.
1 parent 448fcad commit 4db8fca

14 files changed

Lines changed: 260 additions & 86 deletions

File tree

src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,36 @@
1+
import { getConfig } from '@edx/frontend-platform';
12
import { useIntl } from '@edx/frontend-platform/i18n';
2-
import { useToggle } from '@openedx/paragon';
3+
import {
4+
Tab,
5+
Tabs,
6+
useToggle,
7+
} from '@openedx/paragon';
38
import { SchoolOutline, Tag } from '@openedx/paragon/icons';
49

10+
import { useUserPermissions } from '@src/authz/data/apiHooks';
11+
import { COURSE_PERMISSIONS } from '@src/authz/constants';
512
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
13+
import { useCourseSettings, useWaffleFlags } from '@src/data/apiHooks';
614
import { ComponentCountSnippet } from '@src/generic/block-type-utils';
15+
import { HelpSidebarLink, otherLinkURLParams, messages as helpSidebarMessages } from '@src/generic/help-sidebar';
16+
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
717
import { useGetBlockTypes } from '@src/search-manager';
818
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
919

10-
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
11-
1220
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
1321
import messages from '../messages';
1422

15-
export const CourseInfoSidebar = () => {
23+
const DetailsTab = () => {
1624
const intl = useIntl();
17-
const { courseId } = useCourseAuthoringContext();
18-
const { data: courseDetails } = useCourseDetails(courseId);
1925

26+
const { courseId } = useCourseAuthoringContext();
2027
const { data: componentData } = useGetBlockTypes(
2128
[`context_key = "${courseId}"`],
2229
);
23-
2430
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
2531

2632
return (
27-
<div>
28-
<SidebarTitle
29-
title={courseDetails?.title || ''}
30-
icon={SchoolOutline}
31-
/>
33+
<>
3234
<SidebarContent>
3335
<SidebarSection
3436
title={intl.formatMessage(messages.sidebarSectionSummary)}
@@ -54,6 +56,122 @@ export const CourseInfoSidebar = () => {
5456
onClose={closeManageTagsDrawer}
5557
showSheet={isManageTagsDrawerOpen}
5658
/>
57-
</div>
59+
</>
60+
);
61+
};
62+
63+
const SettingsTab = () => {
64+
const intl = useIntl();
65+
const { courseId } = useCourseAuthoringContext();
66+
const { data: courseSettingsData } = useCourseSettings(courseId);
67+
68+
const {
69+
grading,
70+
courseTeam,
71+
advancedSettings,
72+
scheduleAndDetails,
73+
groupConfigurations,
74+
} = otherLinkURLParams;
75+
const waffleFlags = useWaffleFlags(courseId);
76+
77+
const proctoredExamSettingsUrl = courseSettingsData?.mfeProctoredExamSettingsUrl;
78+
79+
/*
80+
AuthZ for Course Authoring
81+
If authz.enable_course_authoring flag is enabled, validate permissions using AuthZ API.
82+
*/
83+
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
84+
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
85+
canManageAdvancedSettings: {
86+
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
87+
scope: courseId,
88+
},
89+
}, isAuthzEnabled);
90+
91+
// If it's still loading, don't show the Advanced Settings link, otherwise, use the permission to decide
92+
const authzCanManageAdvancedSettings = isLoadingUserPermissions
93+
? false
94+
: !!userPermissions?.canManageAdvancedSettings;
95+
96+
// When authz is enabled, use permission, otherwise it's always allowed (legacy behavior)
97+
const canManageAdvancedSettings = isAuthzEnabled ? authzCanManageAdvancedSettings : true;
98+
99+
return (
100+
<SidebarSection
101+
title={intl.formatMessage(messages.settingsTabText)}
102+
>
103+
<HelpSidebarLink
104+
as="span"
105+
pathToPage={`/course/${courseId}/${scheduleAndDetails}`}
106+
title={intl.formatMessage(
107+
helpSidebarMessages.sidebarLinkToScheduleAndDetails,
108+
)}
109+
isNewPage
110+
/>
111+
<HelpSidebarLink
112+
as="span"
113+
pathToPage={`/course/${courseId}/${grading}`}
114+
title={intl.formatMessage(helpSidebarMessages.sidebarLinkToGrading)}
115+
isNewPage
116+
/>
117+
<HelpSidebarLink
118+
as="span"
119+
pathToPage={`/course/${courseId}/${courseTeam}`}
120+
title={intl.formatMessage(helpSidebarMessages.sidebarLinkToCourseTeam)}
121+
isNewPage
122+
/>
123+
<HelpSidebarLink
124+
as="span"
125+
pathToPage={`/course/${courseId}/${groupConfigurations}`}
126+
title={intl.formatMessage(helpSidebarMessages.sidebarLinkToGroupConfigurations)}
127+
isNewPage
128+
/>
129+
{canManageAdvancedSettings && (
130+
<HelpSidebarLink
131+
as="span"
132+
pathToPage={`/course/${courseId}/${advancedSettings}`}
133+
title={intl.formatMessage(helpSidebarMessages.sidebarLinkToAdvancedSettings)}
134+
isNewPage
135+
/>
136+
)}
137+
{proctoredExamSettingsUrl && (
138+
<HelpSidebarLink
139+
as="span"
140+
pathToPage={proctoredExamSettingsUrl}
141+
title={intl.formatMessage(
142+
helpSidebarMessages.sidebarLinkToProctoredExamSettings,
143+
)}
144+
isNewPage
145+
/>
146+
)}
147+
</SidebarSection>
148+
);
149+
};
150+
151+
export const CourseInfoSidebar = () => {
152+
const intl = useIntl();
153+
const { courseId } = useCourseAuthoringContext();
154+
const { data: courseDetails } = useCourseDetails(courseId);
155+
156+
return (
157+
<>
158+
<SidebarTitle
159+
title={courseDetails?.title || ''}
160+
icon={SchoolOutline}
161+
/>
162+
<Tabs
163+
variant="tabs"
164+
className="my-2 mx-n3.5"
165+
id="course-info-tabs"
166+
mountOnEnter
167+
>
168+
<Tab eventKey="info" title={intl.formatMessage(messages.infoTabText)}>
169+
<DetailsTab />
170+
</Tab>
171+
<Tab eventKey="settings" title={intl.formatMessage(messages.settingsTabText)}>
172+
<SettingsTab />
173+
</Tab>
174+
</Tabs>
175+
</>
58176
);
59177
};

src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { initializeMocks, render, screen } from '@src/testUtils';
2-
import { SelectionState } from '@src/data/types';
2+
import { getCourseSettingsApiUrl } from '@src/data/api';
3+
import type { SelectionState } from '@src/data/types';
34
import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
45
import { getXBlockApiUrl } from '@src/course-outline/data/api';
56
import userEvent from '@testing-library/user-event';
@@ -22,10 +23,12 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({
2223
}),
2324
}));
2425

26+
const courseId = '5';
27+
2528
const openPublishModal = jest.fn();
2629
jest.mock('@src/CourseAuthoringContext', () => ({
2730
useCourseAuthoringContext: () => ({
28-
courseId: 5,
31+
courseId,
2932
setCurrentSelection: jest.fn(),
3033
openPublishModal,
3134
getUnitUrl: jest.fn(),
@@ -50,6 +53,32 @@ describe('InfoSidebar component', () => {
5053
expect(await screen.findByText('Course name')).toBeInTheDocument();
5154
});
5255

56+
it('shows the settings link for the course', async () => {
57+
const user = userEvent.setup();
58+
renderComponent();
59+
await user.click((await screen.findByRole('tab', { name: 'Settings' })));
60+
const links = await screen.findAllByRole('link');
61+
expect(links).toHaveLength(5);
62+
expect(links[0]).toHaveTextContent('Schedule & details');
63+
expect(links[1]).toHaveTextContent('Grading');
64+
expect(links[2]).toHaveTextContent('Course team');
65+
expect(links[3]).toHaveTextContent('Group configurations');
66+
expect(links[4]).toHaveTextContent('Advanced settings');
67+
});
68+
69+
it('shows the proctored exam settings link for the course if it exists', async () => {
70+
const user = userEvent.setup();
71+
const courseSettingsData = {
72+
mfeProctoredExamSettingsUrl: 'https://example.com/proctored-exam-settings',
73+
};
74+
axiosMock
75+
.onGet(getCourseSettingsApiUrl(courseId))
76+
.reply(200, courseSettingsData);
77+
renderComponent();
78+
await user.click(await screen.findByRole('tab', { name: 'Settings' }));
79+
expect(await screen.findByRole('link', { name: 'Proctored exam settings' })).toBeInTheDocument();
80+
});
81+
5382
it('renders InfoSidebar with section info', async () => {
5483
const user = userEvent.setup();
5584
selectedContainerState = {

src/course-unit/CourseUnit.test.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -713,15 +713,13 @@ describe('<CourseUnit />', () => {
713713

714714
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
715715

716-
await waitFor(async () => {
717-
const problemButton = screen.getByRole('button', {
718-
name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'),
719-
hidden: true,
720-
});
721-
722-
await user.click(problemButton);
716+
const problemButton = await screen.findByRole('button', {
717+
name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'),
718+
hidden: true,
723719
});
724720

721+
await user.click(problemButton);
722+
725723
axiosMock
726724
.onGet(getCourseSectionVerticalApiUrl(blockId))
727725
.reply(200, courseSectionVerticalMock);

src/data/api.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export const bulkModulestoreMigrateUrl = () => `${getStudioBaseUrl()}/api/module
4848
*/
4949
export const getPreviewModulestoreMigrationUrl = () => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/migration_preview/`;
5050

51+
export const getCourseSettingsApiUrl = (courseId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`;
52+
5153
export const getApiWaffleFlagsUrl = (courseId?: string): string => {
5254
const baseUrl = getStudioBaseUrl();
5355
const apiPath = '/api/contentstore/v1/course_waffle_flags';
@@ -225,3 +227,44 @@ export async function getUserAgreement(agreementType: string) {
225227
const { data } = await client.get(getUserAgreementApi(agreementType));
226228
return camelCaseObject(data);
227229
}
230+
231+
export interface CourseSettingsData {
232+
aboutPageEditable: boolean;
233+
canShowCertificateAvailableDateField: boolean;
234+
courseDisplayName: string;
235+
courseDisplayNameWithDefault: string;
236+
creditEligibilityEnabled: boolean;
237+
enableExtendedCourseDetails: boolean;
238+
enrollmentEndEditable: boolean;
239+
isCreditCourse: boolean;
240+
isEntranceExamsEnabled: boolean;
241+
isPrerequisiteCoursesEnabled: boolean;
242+
languageOptions: [string, string][];
243+
lmsLinkForAboutPage: string;
244+
licensingEnabled: boolean;
245+
marketingEnabled: boolean;
246+
mfeProctoredExamSettingsUrl: string;
247+
platformName: string;
248+
possiblePreRequisiteCourses: {
249+
courseKey: string;
250+
displayName: string;
251+
lmsLink: string;
252+
number: string;
253+
org: string;
254+
rerunLink: string;
255+
run: string;
256+
url: string;
257+
}
258+
shortDescriptionEditable: boolean;
259+
showMinGradeWarning: boolean;
260+
sidebarHtmlEnabled: boolean;
261+
upgradeDeadline: string | null;
262+
}
263+
264+
/**
265+
* Get course settings.
266+
*/
267+
export async function getCourseSettings(courseId: string): Promise<CourseSettingsData> {
268+
const { data } = await getAuthenticatedHttpClient().get(getCourseSettingsApiUrl(courseId));
269+
return camelCaseObject(data);
270+
}

src/data/apiHooks.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getUserAgreementRecord,
1515
getWaffleFlags, updateUserAgreementRecord,
1616
waffleFlagDefaults,
17+
getCourseSettings,
1718
} from './api';
1819
import { RequestStatus, RequestStatusType } from './constants';
1920

@@ -212,3 +213,13 @@ export const useUserAgreement = (agreementType:string) => (
212213
retry: false,
213214
})
214215
);
216+
217+
/**
218+
* Get the course settings
219+
*/
220+
export const useCourseSettings = (courseId: string) => (
221+
useQuery({
222+
queryKey: ['courseSettings', courseId],
223+
queryFn: () => getCourseSettings(courseId),
224+
})
225+
);

src/generic/help-sidebar/HelpSidebarLink.jsx renamed to src/generic/help-sidebar/HelpSidebarLink.tsx

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
import React from 'react';
2+
13
import { Link } from 'react-router-dom';
2-
import PropTypes from 'prop-types';
34
import { Hyperlink } from '@openedx/paragon';
45

6+
interface HelpSidebarLinkProps {
7+
as?: React.ElementType;
8+
isNewPage?: boolean;
9+
pathToPage: string;
10+
title: string;
11+
}
12+
513
const HelpSidebarLink = ({
6-
as, pathToPage, title, isNewPage,
7-
}) => {
14+
as = 'li',
15+
isNewPage = true,
16+
pathToPage,
17+
title,
18+
}: HelpSidebarLinkProps) => {
819
const TagElement = as;
920
if (isNewPage) {
1021
return (
@@ -29,16 +40,4 @@ const HelpSidebarLink = ({
2940
);
3041
};
3142

32-
HelpSidebarLink.propTypes = {
33-
isNewPage: PropTypes.bool,
34-
pathToPage: PropTypes.string.isRequired,
35-
title: PropTypes.string.isRequired,
36-
as: PropTypes.string,
37-
};
38-
39-
HelpSidebarLink.defaultProps = {
40-
as: 'li',
41-
isNewPage: true,
42-
};
43-
4443
export default HelpSidebarLink;

src/generic/help-sidebar/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { default as HelpSidebar } from './HelpSidebar';
22
export { default as HelpSidebarLink } from './HelpSidebarLink';
3+
export { otherLinkURLParams } from './constants';
4+
export { default as messages } from './messages';

0 commit comments

Comments
 (0)