Skip to content

Commit 70191bf

Browse files
authored
feat: implement "schedule and details" permissions (#2991)
* feat: implement schedule and details permissions * feat: set default value for isEditable prop in multiple components * refactor: streamline permission handling in ScheduleAndDetails component and tests * fix: ensure isAuthzEnabled is consistently set in permissions mock * feat: add authorization checks for viewing schedule and grading settings in menu items
1 parent adf92ef commit 70191bf

40 files changed

Lines changed: 544 additions & 47 deletions

src/authz/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ export const COURSE_PERMISSIONS = {
2020

2121
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
2222
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
23+
24+
VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details',
25+
EDIT_SCHEDULE: 'courses.edit_schedule',
26+
EDIT_DETAILS: 'courses.edit_details',
2327
};

src/authz/permissionHelpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { COURSE_PERMISSIONS } from './constants';
22

3+
export const getScheduleAndDetailsPermissions = (courseId: string) => ({
4+
canViewScheduleAndDetails: {
5+
action: COURSE_PERMISSIONS.VIEW_SCHEDULE_AND_DETAILS,
6+
scope: courseId,
7+
},
8+
canEditSchedule: {
9+
action: COURSE_PERMISSIONS.EDIT_SCHEDULE,
10+
scope: courseId,
11+
},
12+
canEditDetails: {
13+
action: COURSE_PERMISSIONS.EDIT_DETAILS,
14+
scope: courseId,
15+
},
16+
});
17+
318
export const getGradingPermissions = (courseId: string) => ({
419
canViewGradingSettings: {
520
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,

src/generic/WysiwygEditor.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const WysiwygEditor = ({
1414
editorType,
1515
onChange,
1616
minHeight,
17+
disabled = false,
1718
}) => {
1819
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
1920
const { courseId } = useCourseAuthoringContext();
@@ -64,6 +65,7 @@ export const WysiwygEditor = ({
6465
images={{}}
6566
enableImageUpload={false}
6667
onEditorChange={() => ({})}
68+
disabled={disabled}
6769
/>
6870
);
6971
};

src/generic/course-upload-image/index.jsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const CourseUploadImage = ({
2727
identifierFieldText,
2828
showImageBodyText,
2929
customInputPlaceholder,
30+
disabled = false,
3031
onChange,
3132
}) => {
3233
const { courseId } = useParams();
@@ -113,13 +114,16 @@ const CourseUploadImage = ({
113114
<Form.Label>{label}</Form.Label>
114115
<Card>
115116
<Card.Body className="image-body">
116-
<Dropzone
117-
onProcessUpload={handleProcessUpload}
118-
inputComponent={inputComponent}
119-
accept={{
120-
'image/*': ['.png', '.jpeg'],
121-
}}
122-
/>
117+
<div style={disabled ? { pointerEvents: 'none' } : undefined}>
118+
<Dropzone
119+
onProcessUpload={handleProcessUpload}
120+
inputComponent={inputComponent}
121+
accept={{
122+
'image/*': ['.png', '.jpeg'],
123+
}}
124+
disabled={disabled}
125+
/>
126+
</div>
123127
{showImageBodyText && cardImageTextBody}
124128
</Card.Body>
125129
<Card.Divider />
@@ -131,6 +135,7 @@ const CourseUploadImage = ({
131135
|| intl.formatMessage(messages.uploadImageInputPlaceholder, {
132136
identifierFieldText,
133137
})}
138+
disabled={disabled}
134139
/>
135140
</Card.Footer>
136141
</Card>

src/header/hooks.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,58 @@ describe('header utils', () => {
175175
});
176176
});
177177

178+
it('when authz flag is enabled and user has canViewScheduleAndDetails should include schedule and details option', async () => {
179+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
180+
jest.mocked(useUserPermissions).mockReturnValue({
181+
isLoading: false,
182+
data: { canViewScheduleAndDetails: true },
183+
} as any);
184+
const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() });
185+
await waitFor(() => {
186+
const actualItemsTitle = result.current.map((item) => item.title);
187+
expect(actualItemsTitle).toContain('Schedule & Details');
188+
});
189+
});
190+
191+
it('when authz flag is enabled and user lacks canViewScheduleAndDetails should not include schedule and details option', async () => {
192+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
193+
jest.mocked(useUserPermissions).mockReturnValue({
194+
isLoading: false,
195+
data: { canViewScheduleAndDetails: false },
196+
} as any);
197+
const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() });
198+
await waitFor(() => {
199+
const actualItemsTitle = result.current.map((item) => item.title);
200+
expect(actualItemsTitle).not.toContain('Schedule & Details');
201+
});
202+
});
203+
204+
it('when authz flag is enabled and user has canViewGradingSettings should include grading option', async () => {
205+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
206+
jest.mocked(useUserPermissions).mockReturnValue({
207+
isLoading: false,
208+
data: { canViewGradingSettings: true },
209+
} as any);
210+
const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() });
211+
await waitFor(() => {
212+
const actualItemsTitle = result.current.map((item) => item.title);
213+
expect(actualItemsTitle).toContain('Grading');
214+
});
215+
});
216+
217+
it('when authz flag is enabled and user lacks canViewGradingSettings should not include grading option', async () => {
218+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
219+
jest.mocked(useUserPermissions).mockReturnValue({
220+
isLoading: false,
221+
data: { canViewGradingSettings: false },
222+
} as any);
223+
const { result } = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() });
224+
await waitFor(() => {
225+
const actualItemsTitle = result.current.map((item) => item.title);
226+
expect(actualItemsTitle).not.toContain('Grading');
227+
});
228+
});
229+
178230
it('should include roles and permissions option', () => {
179231
setConfig({
180232
...getConfig(),

src/header/hooks.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export const useSettingMenuItems = (courseId: string) => {
7373
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
7474
scope: courseId,
7575
},
76+
canViewScheduleAndDetails: {
77+
action: COURSE_PERMISSIONS.VIEW_SCHEDULE_AND_DETAILS,
78+
scope: courseId,
79+
},
80+
canViewGradingSettings: {
81+
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
82+
scope: courseId,
83+
},
7684
}, isAuthzEnabled);
7785

7886
const authzCanManageAdvancedSettings = isLoadingUserPermissions
@@ -83,15 +91,27 @@ export const useSettingMenuItems = (courseId: string) => {
8391
? authzCanManageAdvancedSettings
8492
: legacyCanAccessAdvancedSettings;
8593

94+
const canViewScheduleAndDetails = isAuthzEnabled
95+
? (!isLoadingUserPermissions && (userPermissions?.canViewScheduleAndDetails || false))
96+
: true;
97+
98+
const canViewGradingSettings = isAuthzEnabled
99+
? (!isLoadingUserPermissions && (userPermissions?.canViewGradingSettings || false))
100+
: true;
101+
86102
const items = [
87-
{
88-
href: `/course/${courseId}/settings/details`,
89-
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
90-
},
91-
{
92-
href: `/course/${courseId}/settings/grading`,
93-
title: intl.formatMessage(messages['header.links.grading']),
94-
},
103+
...(canViewScheduleAndDetails
104+
? [{
105+
href: `/course/${courseId}/settings/details`,
106+
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
107+
}]
108+
: []),
109+
...(canViewGradingSettings
110+
? [{
111+
href: `/course/${courseId}/settings/grading`,
112+
title: intl.formatMessage(messages['header.links.grading']),
113+
}]
114+
: []),
95115
...(isAuthzEnabled
96116
? [{
97117
href: `${getConfig().ADMIN_CONSOLE_URL}/authz?scope=${encodeURIComponent(courseId)}`,

src/schedule-and-details/ScheduleAndDetails.test.jsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { executeThunk } from '@src/utils';
1010
import genericMessages from '@src/generic/help-sidebar/messages';
1111
import { DATE_FORMAT } from '@src/constants';
1212
import { getCourseSettingsApiUrl } from '@src/data/api';
13+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
14+
import { useCourseUserPermissions } from '@src/authz/hooks';
1315

1416
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
1517
import { courseDetailsMock, courseSettingsMock } from './__mocks__';
@@ -22,6 +24,26 @@ import scheduleMessages from './schedule-section/messages';
2224
import messages from './messages';
2325
import ScheduleAndDetails from '.';
2426

27+
jest.mock('@src/authz/hooks', () => ({
28+
useCourseUserPermissions: jest.fn().mockReturnValue({
29+
isLoading: false,
30+
isAuthzEnabled: true,
31+
canViewScheduleAndDetails: true,
32+
canEditSchedule: true,
33+
canEditDetails: true,
34+
}),
35+
}));
36+
37+
const mockPermissions = (overrides = {}) =>
38+
jest.mocked(useCourseUserPermissions).mockReturnValue({
39+
isLoading: false,
40+
isAuthzEnabled: true,
41+
canViewScheduleAndDetails: true,
42+
canEditSchedule: true,
43+
canEditDetails: true,
44+
...overrides,
45+
});
46+
2547
let axiosMock;
2648
let store;
2749
const courseId = '123';
@@ -169,3 +191,73 @@ describe('<ScheduleAndDetails />', () => {
169191
expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
170192
});
171193
});
194+
195+
describe('<ScheduleAndDetails /> permissions', () => {
196+
beforeEach(() => {
197+
jest.restoreAllMocks();
198+
const mocks = initializeMocks();
199+
axiosMock = mocks.axiosMock;
200+
store = mocks.reduxStore;
201+
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock);
202+
axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock);
203+
axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200);
204+
mockPermissions();
205+
});
206+
207+
it('renders normally when authz flag is disabled (no regression)', async () => {
208+
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
209+
const { getAllByText } = renderComponent();
210+
await waitFor(() => {
211+
expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0);
212+
});
213+
});
214+
215+
it('renders normally when user has all permissions', async () => {
216+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
217+
const { getAllByText } = renderComponent();
218+
await waitFor(() => {
219+
expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0);
220+
});
221+
});
222+
223+
it('shows PermissionDeniedAlert when user lacks view permission', async () => {
224+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
225+
mockPermissions({ canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false });
226+
const { getByTestId } = renderComponent();
227+
await waitFor(() => {
228+
expect(getByTestId('permissionDeniedAlert')).toBeInTheDocument();
229+
});
230+
});
231+
232+
it('disables schedule date inputs when user lacks edit_schedule permission', async () => {
233+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
234+
mockPermissions({ canEditSchedule: false });
235+
const { getAllByPlaceholderText } = renderComponent();
236+
await waitFor(() => {
237+
const dateInputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
238+
dateInputs.forEach((input) => expect(input).toBeDisabled());
239+
});
240+
});
241+
242+
it('disables pacing and details inputs when user lacks edit_details permission', async () => {
243+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
244+
mockPermissions({ canEditDetails: false });
245+
const { getAllByRole } = renderComponent();
246+
await waitFor(() => {
247+
const radios = getAllByRole('radio');
248+
radios.forEach((radio) => expect(radio).toBeDisabled());
249+
});
250+
});
251+
252+
it('save button cannot be triggered when user has no edit permissions', async () => {
253+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
254+
mockPermissions({ canEditSchedule: false, canEditDetails: false });
255+
const { getAllByPlaceholderText, queryByText } = renderComponent();
256+
// Wait for page to load
257+
const dateInputs = await waitFor(() => getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()));
258+
// All date inputs must be disabled (no edit_schedule permission)
259+
dateInputs.forEach((input) => expect(input).toBeDisabled());
260+
// No changes can be made so the save button never appears
261+
expect(queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
262+
});
263+
});

src/schedule-and-details/details-section/DetailsSection.test.jsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,26 @@ describe('<DetailsSection />', () => {
5757
getByRole('button', { name: messages.dropdownEmpty.defaultMessage }),
5858
).toBeInTheDocument();
5959
});
60+
61+
it('disables the language dropdown toggle when isEditable is false', () => {
62+
const { getByRole } = render(<RootWrapper {...props} isEditable={false} />);
63+
const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
64+
expect(toggle).toBeDisabled();
65+
});
66+
67+
it('does not call onChange when dropdown item clicked while isEditable is false', () => {
68+
onChangeMock.mockClear();
69+
const { getByRole } = render(<RootWrapper {...props} isEditable={false} />);
70+
// Toggle is disabled, so clicking it does not open the dropdown
71+
const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
72+
expect(toggle).toBeDisabled();
73+
fireEvent.click(toggle);
74+
expect(onChangeMock).not.toHaveBeenCalled();
75+
});
76+
77+
it('enables the language dropdown when isEditable is true', () => {
78+
const { getByRole } = render(<RootWrapper {...props} isEditable />);
79+
const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
80+
expect(toggle).not.toBeDisabled();
81+
});
6082
});

src/schedule-and-details/details-section/index.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const DetailsSection = ({
1010
language,
1111
languageOptions,
1212
onChange,
13+
isEditable = true,
1314
}) => {
1415
const intl = useIntl();
1516
const formattedLanguage = () => {
@@ -26,14 +27,14 @@ const DetailsSection = ({
2627
<Form.Group className="form-group-custom dropdown-language">
2728
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
2829
<Dropdown className="bg-white">
29-
<Dropdown.Toggle variant="outline-primary" id="languageDropdown">
30+
<Dropdown.Toggle variant="outline-primary" id="languageDropdown" disabled={!isEditable}>
3031
{formattedLanguage()}
3132
</Dropdown.Toggle>
3233
<Dropdown.Menu>
3334
{languageOptions.map((option) => (
3435
<Dropdown.Item
3536
key={option[0]}
36-
onClick={() => onChange(option[0], 'language')}
37+
onClick={isEditable ? () => onChange(option[0], 'language') : undefined}
3738
>
3839
{option[1]}
3940
</Dropdown.Item>

0 commit comments

Comments
 (0)