Skip to content

Commit 3807a87

Browse files
committed
feat: implement schedule and details permissions
1 parent da4e81b commit 3807a87

38 files changed

Lines changed: 526 additions & 43 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: 4 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,
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
};
@@ -72,11 +74,13 @@ WysiwygEditor.defaultProps = {
7274
initialValue: '',
7375
editorType: SUPPORTED_TEXT_EDITORS.text,
7476
minHeight: 200,
77+
disabled: false,
7578
};
7679

7780
WysiwygEditor.propTypes = {
7881
initialValue: PropTypes.string,
7982
editorType: PropTypes.oneOf(Object.values(SUPPORTED_TEXT_EDITORS)),
8083
onChange: PropTypes.func.isRequired,
8184
minHeight: PropTypes.number,
85+
disabled: PropTypes.bool,
8286
};

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const CourseUploadImage = ({
2727
identifierFieldText,
2828
showImageBodyText,
2929
customInputPlaceholder,
30+
disabled,
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 />
@@ -130,7 +134,9 @@ const CourseUploadImage = ({
130134
placeholder={customInputPlaceholder
131135
|| intl.formatMessage(messages.uploadImageInputPlaceholder, {
132136
identifierFieldText,
133-
})}
137+
})
138+
}
139+
disabled={disabled}
134140
/>
135141
</Card.Footer>
136142
</Card>
@@ -152,6 +158,7 @@ CourseUploadImage.defaultProps = {
152158
showImageBodyText: false,
153159
identifierFieldText: '',
154160
customInputPlaceholder: '',
161+
disabled: false,
155162
};
156163

157164
CourseUploadImage.propTypes = {
@@ -163,6 +170,7 @@ CourseUploadImage.propTypes = {
163170
showImageBodyText: PropTypes.bool,
164171
identifierFieldText: PropTypes.string,
165172
customInputPlaceholder: PropTypes.string,
173+
disabled: PropTypes.bool,
166174
onChange: PropTypes.func.isRequired,
167175
};
168176

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

Lines changed: 108 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 { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
1315

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

27+
jest.mock('@src/authz/hooks', () => ({
28+
useUserPermissionsWithAuthzCourse: jest.fn().mockReturnValue({
29+
isLoading: false,
30+
isAuthzEnabled: true,
31+
permissions: {
32+
canViewScheduleAndDetails: true,
33+
canEditSchedule: true,
34+
canEditDetails: true,
35+
},
36+
}),
37+
}));
38+
2539
let axiosMock;
2640
let store;
2741
const courseId = '123';
@@ -169,3 +183,97 @@ describe('<ScheduleAndDetails />', () => {
169183
expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
170184
});
171185
});
186+
187+
describe('<ScheduleAndDetails /> permissions', () => {
188+
beforeEach(() => {
189+
jest.restoreAllMocks();
190+
const mocks = initializeMocks();
191+
axiosMock = mocks.axiosMock;
192+
store = mocks.reduxStore;
193+
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock);
194+
axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock);
195+
axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200);
196+
jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({
197+
isLoading: false,
198+
isAuthzEnabled: true,
199+
permissions: {
200+
canViewScheduleAndDetails: true,
201+
canEditSchedule: true,
202+
canEditDetails: true,
203+
},
204+
});
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+
jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({
226+
isLoading: false,
227+
isAuthzEnabled: true,
228+
permissions: { canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false },
229+
});
230+
const { getByTestId } = renderComponent();
231+
await waitFor(() => {
232+
expect(getByTestId('permissionDeniedAlert')).toBeInTheDocument();
233+
});
234+
});
235+
236+
it('disables schedule date inputs when user lacks edit_schedule permission', async () => {
237+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
238+
jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({
239+
isLoading: false,
240+
isAuthzEnabled: true,
241+
permissions: { canViewScheduleAndDetails: true, canEditSchedule: false, canEditDetails: true },
242+
});
243+
const { getAllByPlaceholderText } = renderComponent();
244+
await waitFor(() => {
245+
const dateInputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
246+
dateInputs.forEach((input) => expect(input).toBeDisabled());
247+
});
248+
});
249+
250+
it('disables pacing and details inputs when user lacks edit_details permission', async () => {
251+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
252+
jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({
253+
isLoading: false,
254+
isAuthzEnabled: true,
255+
permissions: { canViewScheduleAndDetails: true, canEditSchedule: true, canEditDetails: false },
256+
});
257+
const { getAllByRole } = renderComponent();
258+
await waitFor(() => {
259+
const radios = getAllByRole('radio');
260+
radios.forEach((radio) => expect(radio).toBeDisabled());
261+
});
262+
});
263+
264+
it('save button cannot be triggered when user has no edit permissions', async () => {
265+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
266+
jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({
267+
isLoading: false,
268+
isAuthzEnabled: true,
269+
permissions: { canViewScheduleAndDetails: true, canEditSchedule: false, canEditDetails: false },
270+
});
271+
const { getAllByPlaceholderText, queryByText } = renderComponent();
272+
// Wait for page to load
273+
const dateInputs = await waitFor(() => getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()));
274+
// All date inputs must be disabled (no edit_schedule permission)
275+
dateInputs.forEach((input) => expect(input).toBeDisabled());
276+
// No changes can be made so the save button never appears
277+
expect(queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
278+
});
279+
});

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: 5 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,
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>
@@ -50,13 +51,15 @@ const DetailsSection = ({
5051

5152
DetailsSection.defaultProps = {
5253
language: '',
54+
isEditable: true,
5355
};
5456

5557
DetailsSection.propTypes = {
5658
language: PropTypes.string,
5759
languageOptions: PropTypes.arrayOf(
5860
PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
5961
).isRequired,
62+
isEditable: PropTypes.bool,
6063
onChange: PropTypes.func.isRequired,
6164
};
6265

0 commit comments

Comments
 (0)