From 3807a87c5ac24242ad1edef105b69594822a7904 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 30 Apr 2026 14:46:53 -0500 Subject: [PATCH 1/5] feat: implement schedule and details permissions --- src/authz/constants.ts | 4 + src/authz/permissionHelpers.ts | 15 +++ src/generic/WysiwygEditor.jsx | 4 + src/generic/course-upload-image/index.jsx | 24 ++-- .../ScheduleAndDetails.test.jsx | 108 ++++++++++++++++++ .../details-section/DetailsSection.test.jsx | 22 ++++ .../details-section/index.jsx | 7 +- src/schedule-and-details/index.jsx | 41 ++++++- .../InstructorsSection.test.jsx | 10 ++ .../instructors-section/index.jsx | 7 +- .../InstructorContainer.test.jsx | 14 +++ .../instructor-container/index.jsx | 10 +- .../IntroducingSection.test.jsx | 10 ++ .../ExtendedCourseDetails.test.jsx | 12 ++ .../extended-course-details/index.jsx | 4 + .../introducing-section/index.jsx | 14 ++- .../IntroductionVideo.test.jsx | 14 +++ .../introduction-video/index.jsx | 7 +- .../InstructorsSection.test.jsx | 14 +++ .../learning-outcomes-section/index.jsx | 8 +- .../license-section/LicenseSection.test.jsx | 12 ++ .../license-section/index.jsx | 6 +- .../LicenseCommonsOptions.test.jsx | 30 ++++- .../license-commons-options/index.jsx | 11 +- .../license-selector/LicenseSelector.test.jsx | 19 +++ .../license-selector/index.jsx | 5 +- .../pacing-section/PacingSection.test.jsx | 18 +++ .../pacing-section/index.jsx | 7 +- .../RequirementsSection.test.jsx | 13 +++ .../entrance-exam/EntranceExam.test.jsx | 12 ++ .../entrance-exam/index.jsx | 5 + .../GradeRequirements.test.jsx | 10 ++ .../grade-requirements/index.jsx | 4 + .../requirements-section/index.jsx | 11 +- .../schedule-section/ScheduleSection.test.jsx | 16 +++ .../CertificateDisplayRow.jsx | 8 +- .../CertificateDisplayRow.test.jsx | 23 ++++ .../schedule-section/index.jsx | 10 +- 38 files changed, 526 insertions(+), 43 deletions(-) diff --git a/src/authz/constants.ts b/src/authz/constants.ts index aa148ea08b..05b0a6783d 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -20,4 +20,8 @@ export const COURSE_PERMISSIONS = { VIEW_GRADING_SETTINGS: 'courses.view_grading_settings', EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings', + + VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details', + EDIT_SCHEDULE: 'courses.edit_schedule', + EDIT_DETAILS: 'courses.edit_details', }; diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts index 76585ea0bf..6423f1b0df 100644 --- a/src/authz/permissionHelpers.ts +++ b/src/authz/permissionHelpers.ts @@ -1,5 +1,20 @@ import { COURSE_PERMISSIONS } from './constants'; +export const getScheduleAndDetailsPermissions = (courseId: string) => ({ + canViewScheduleAndDetails: { + action: COURSE_PERMISSIONS.VIEW_SCHEDULE_AND_DETAILS, + scope: courseId, + }, + canEditSchedule: { + action: COURSE_PERMISSIONS.EDIT_SCHEDULE, + scope: courseId, + }, + canEditDetails: { + action: COURSE_PERMISSIONS.EDIT_DETAILS, + scope: courseId, + }, +}); + export const getGradingPermissions = (courseId: string) => ({ canViewGradingSettings: { action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, diff --git a/src/generic/WysiwygEditor.jsx b/src/generic/WysiwygEditor.jsx index cd1c0d9e2b..fbb96db889 100644 --- a/src/generic/WysiwygEditor.jsx +++ b/src/generic/WysiwygEditor.jsx @@ -14,6 +14,7 @@ export const WysiwygEditor = ({ editorType, onChange, minHeight, + disabled, }) => { const { editorRef, refReady, setEditorRef } = prepareEditorRef(); const { courseId } = useCourseAuthoringContext(); @@ -64,6 +65,7 @@ export const WysiwygEditor = ({ images={{}} enableImageUpload={false} onEditorChange={() => ({})} + disabled={disabled} /> ); }; @@ -72,6 +74,7 @@ WysiwygEditor.defaultProps = { initialValue: '', editorType: SUPPORTED_TEXT_EDITORS.text, minHeight: 200, + disabled: false, }; WysiwygEditor.propTypes = { @@ -79,4 +82,5 @@ WysiwygEditor.propTypes = { editorType: PropTypes.oneOf(Object.values(SUPPORTED_TEXT_EDITORS)), onChange: PropTypes.func.isRequired, minHeight: PropTypes.number, + disabled: PropTypes.bool, }; diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx index 387b3ae1c2..39f466aa2a 100644 --- a/src/generic/course-upload-image/index.jsx +++ b/src/generic/course-upload-image/index.jsx @@ -27,6 +27,7 @@ const CourseUploadImage = ({ identifierFieldText, showImageBodyText, customInputPlaceholder, + disabled, onChange, }) => { const { courseId } = useParams(); @@ -113,13 +114,16 @@ const CourseUploadImage = ({ {label} - +
+ +
{showImageBodyText && cardImageTextBody}
@@ -130,7 +134,9 @@ const CourseUploadImage = ({ placeholder={customInputPlaceholder || intl.formatMessage(messages.uploadImageInputPlaceholder, { identifierFieldText, - })} + }) + } + disabled={disabled} />
@@ -152,6 +158,7 @@ CourseUploadImage.defaultProps = { showImageBodyText: false, identifierFieldText: '', customInputPlaceholder: '', + disabled: false, }; CourseUploadImage.propTypes = { @@ -163,6 +170,7 @@ CourseUploadImage.propTypes = { showImageBodyText: PropTypes.bool, identifierFieldText: PropTypes.string, customInputPlaceholder: PropTypes.string, + disabled: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index f1801b53e8..02b494443f 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -10,6 +10,8 @@ import { executeThunk } from '@src/utils'; import genericMessages from '@src/generic/help-sidebar/messages'; import { DATE_FORMAT } from '@src/constants'; import { getCourseSettingsApiUrl } from '@src/data/api'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { courseDetailsMock, courseSettingsMock } from './__mocks__'; @@ -22,6 +24,18 @@ import scheduleMessages from './schedule-section/messages'; import messages from './messages'; import ScheduleAndDetails from '.'; +jest.mock('@src/authz/hooks', () => ({ + useUserPermissionsWithAuthzCourse: jest.fn().mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + permissions: { + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: true, + }, + }), +})); + let axiosMock; let store; const courseId = '123'; @@ -169,3 +183,97 @@ describe('', () => { expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument(); }); }); + +describe(' permissions', () => { + beforeEach(() => { + jest.restoreAllMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + store = mocks.reduxStore; + axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock); + axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock); + axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200); + jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + permissions: { + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: true, + }, + }); + }); + + it('renders normally when authz flag is disabled (no regression)', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + const { getAllByText } = renderComponent(); + await waitFor(() => { + expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0); + }); + }); + + it('renders normally when user has all permissions', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + const { getAllByText } = renderComponent(); + await waitFor(() => { + expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0); + }); + }); + + it('shows PermissionDeniedAlert when user lacks view permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + permissions: { canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false }, + }); + const { getByTestId } = renderComponent(); + await waitFor(() => { + expect(getByTestId('permissionDeniedAlert')).toBeInTheDocument(); + }); + }); + + it('disables schedule date inputs when user lacks edit_schedule permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + permissions: { canViewScheduleAndDetails: true, canEditSchedule: false, canEditDetails: true }, + }); + const { getAllByPlaceholderText } = renderComponent(); + await waitFor(() => { + const dateInputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()); + dateInputs.forEach((input) => expect(input).toBeDisabled()); + }); + }); + + it('disables pacing and details inputs when user lacks edit_details permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + permissions: { canViewScheduleAndDetails: true, canEditSchedule: true, canEditDetails: false }, + }); + const { getAllByRole } = renderComponent(); + await waitFor(() => { + const radios = getAllByRole('radio'); + radios.forEach((radio) => expect(radio).toBeDisabled()); + }); + }); + + it('save button cannot be triggered when user has no edit permissions', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + permissions: { canViewScheduleAndDetails: true, canEditSchedule: false, canEditDetails: false }, + }); + const { getAllByPlaceholderText, queryByText } = renderComponent(); + // Wait for page to load + const dateInputs = await waitFor(() => getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase())); + // All date inputs must be disabled (no edit_schedule permission) + dateInputs.forEach((input) => expect(input).toBeDisabled()); + // No changes can be made so the save button never appears + expect(queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument(); + }); +}); diff --git a/src/schedule-and-details/details-section/DetailsSection.test.jsx b/src/schedule-and-details/details-section/DetailsSection.test.jsx index 7f9f49b883..0883b5f46c 100644 --- a/src/schedule-and-details/details-section/DetailsSection.test.jsx +++ b/src/schedule-and-details/details-section/DetailsSection.test.jsx @@ -57,4 +57,26 @@ describe('', () => { getByRole('button', { name: messages.dropdownEmpty.defaultMessage }), ).toBeInTheDocument(); }); + + it('disables the language dropdown toggle when isEditable is false', () => { + const { getByRole } = render(); + const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] }); + expect(toggle).toBeDisabled(); + }); + + it('does not call onChange when dropdown item clicked while isEditable is false', () => { + onChangeMock.mockClear(); + const { getByRole } = render(); + // Toggle is disabled, so clicking it does not open the dropdown + const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] }); + expect(toggle).toBeDisabled(); + fireEvent.click(toggle); + expect(onChangeMock).not.toHaveBeenCalled(); + }); + + it('enables the language dropdown when isEditable is true', () => { + const { getByRole } = render(); + const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] }); + expect(toggle).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/details-section/index.jsx b/src/schedule-and-details/details-section/index.jsx index b165a3f3f6..64b86d6d0c 100644 --- a/src/schedule-and-details/details-section/index.jsx +++ b/src/schedule-and-details/details-section/index.jsx @@ -10,6 +10,7 @@ const DetailsSection = ({ language, languageOptions, onChange, + isEditable, }) => { const intl = useIntl(); const formattedLanguage = () => { @@ -26,14 +27,14 @@ const DetailsSection = ({ {intl.formatMessage(messages.dropdownLabel)} - + {formattedLanguage()} {languageOptions.map((option) => ( onChange(option[0], 'language')} + onClick={isEditable ? () => onChange(option[0], 'language') : undefined} > {option[1]} @@ -50,6 +51,7 @@ const DetailsSection = ({ DetailsSection.defaultProps = { language: '', + isEditable: true, }; DetailsSection.propTypes = { @@ -57,6 +59,7 @@ DetailsSection.propTypes = { languageOptions: PropTypes.arrayOf( PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, ).isRequired, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index 2dcc26d1f8..bdc5b4c3b1 100644 --- a/src/schedule-and-details/index.jsx +++ b/src/schedule-and-details/index.jsx @@ -20,6 +20,9 @@ import { STATEFUL_BUTTON_STATES } from '@src/constants'; import getPageHeadTitle from '@src/generic/utils'; import { useScrollToHashElement } from '@src/hooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useCourseUserPermissions } from '@src/authz/hooks'; +import { getScheduleAndDetailsPermissions } from '@src/authz/permissionHelpers'; +import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; import { fetchCourseSettingsQuery, @@ -52,12 +55,21 @@ const ScheduleAndDetails = () => { const courseDetails = useSelector(getCourseDetails); const loadingDetailsStatus = useSelector(getLoadingDetailsStatus); const loadingSettingsStatus = useSelector(getLoadingSettingsStatus); - const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS - || loadingSettingsStatus === RequestStatus.IN_PROGRESS; const { courseId, courseDetails: course } = useCourseAuthoringContext(); document.title = getPageHeadTitle(course?.name || '', intl.formatMessage(messages.headingTitle)); + const { + isLoading: isLoadingUserPermissions, + canViewScheduleAndDetails, + canEditSchedule, + canEditDetails, + } = useCourseUserPermissions(courseId, getScheduleAndDetailsPermissions(courseId)); + + const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS + || loadingSettingsStatus === RequestStatus.IN_PROGRESS + || isLoadingUserPermissions; + const { platformName, isCreditCourse, @@ -147,6 +159,10 @@ const ScheduleAndDetails = () => { const { overview: initialOverview } = courseDetails || {}; const { aboutSidebarHtml: initialAboutSidebarHtml } = courseDetails || {}; + if (!isLoadingUserPermissions && !canViewScheduleAndDetails) { + return ; + } + if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; @@ -160,6 +176,9 @@ const ScheduleAndDetails = () => { ); } + const isScheduleEditable = !isLoadingUserPermissions && canEditSchedule; + const isDetailsEditable = !isLoadingUserPermissions && canEditDetails; + const showCreditSection = creditEligibilityEnabled && isCreditCourse; const showRequirementsSection = aboutPageEditable || isPrerequisiteCoursesEnabled || isEntranceExamsEnabled; const hasErrors = !!Object.keys(errorFields).length; @@ -258,6 +277,7 @@ const ScheduleAndDetails = () => { { certificateAvailableDate={certificateAvailableDate} certificatesDisplayBehavior={certificatesDisplayBehavior} canShowCertificateAvailableDateField={canShowCertificateAvailableDateField} + isEditable={isScheduleEditable} onChange={handleValuesChange} /> {aboutPageEditable && ( )} @@ -298,16 +320,19 @@ const ScheduleAndDetails = () => { shortDescriptionEditable={shortDescriptionEditable} enableExtendedCourseDetails={enableExtendedCourseDetails} videoThumbnailImageAssetPath={videoThumbnailImageAssetPath} + isEditable={isDetailsEditable} onChange={handleValuesChange} /> {enableExtendedCourseDetails && ( <> @@ -323,12 +348,14 @@ const ScheduleAndDetails = () => { possiblePreRequisiteCourses={possiblePreRequisiteCourses} entranceExamMinimumScorePct={entranceExamMinimumScorePct} isPrerequisiteCoursesEnabled={isPrerequisiteCoursesEnabled} + isEditable={isDetailsEditable} onChange={handleValuesChange} /> )} {licensingEnabled && ( )} @@ -376,10 +403,12 @@ const ScheduleAndDetails = () => { , ].filter(Boolean)} diff --git a/src/schedule-and-details/instructors-section/InstructorsSection.test.jsx b/src/schedule-and-details/instructors-section/InstructorsSection.test.jsx index b1b02c80b2..c19943b77a 100644 --- a/src/schedule-and-details/instructors-section/InstructorsSection.test.jsx +++ b/src/schedule-and-details/instructors-section/InstructorsSection.test.jsx @@ -110,4 +110,14 @@ describe('', () => { }], }, 'instructorInfo'); }); + + it('disables add button when isEditable is false', () => { + render(); + expect(screen.getByRole('button', { name: messages.instructorAdd.defaultMessage })).toBeDisabled(); + }); + + it('enables add button when isEditable is true', () => { + render(); + expect(screen.getByRole('button', { name: messages.instructorAdd.defaultMessage })).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/instructors-section/index.jsx b/src/schedule-and-details/instructors-section/index.jsx index 99dde50d62..90cf950c87 100644 --- a/src/schedule-and-details/instructors-section/index.jsx +++ b/src/schedule-and-details/instructors-section/index.jsx @@ -9,7 +9,7 @@ import InstructorContainer from './instructor-container'; import SectionSubHeader from '../../generic/section-sub-header'; import messages from './messages'; -const InstructorsSection = ({ instructors, onChange }) => { +const InstructorsSection = ({ instructors, isEditable, onChange }) => { const intl = useIntl(); const newInstructor = { bio: '', @@ -64,12 +64,13 @@ const InstructorsSection = ({ instructors, onChange }) => { instructor={instructor} key={uuid} idx={idx} + isEditable={isEditable} onDelete={handleDelete} onChange={handleChange} /> ))} - @@ -78,6 +79,7 @@ const InstructorsSection = ({ instructors, onChange }) => { InstructorsSection.defaultProps = { instructors: [], + isEditable: true, }; InstructorsSection.propTypes = { @@ -90,6 +92,7 @@ InstructorsSection.propTypes = { title: PropTypes.string, }), ), + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/instructors-section/instructor-container/InstructorContainer.test.jsx b/src/schedule-and-details/instructors-section/instructor-container/InstructorContainer.test.jsx index e5ef3ffc0f..815a7743a2 100644 --- a/src/schedule-and-details/instructors-section/instructor-container/InstructorContainer.test.jsx +++ b/src/schedule-and-details/instructors-section/instructor-container/InstructorContainer.test.jsx @@ -123,4 +123,18 @@ describe('', () => { fireEvent.click(deleteBtn); expect(onDeleteMock).toHaveBeenCalledWith(props.idx); }); + + it('disables all inputs and delete button when isEditable is false', () => { + const { getAllByRole, getByRole } = render(); + const textboxes = getAllByRole('textbox'); + textboxes.forEach((input) => expect(input).toBeDisabled()); + expect(getByRole('button', { name: messages.instructorDelete.defaultMessage })).toBeDisabled(); + }); + + it('enables all inputs and delete button when isEditable is true', () => { + const { getAllByRole, getByRole } = render(); + const textboxes = getAllByRole('textbox'); + textboxes.forEach((input) => expect(input).not.toBeDisabled()); + expect(getByRole('button', { name: messages.instructorDelete.defaultMessage })).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/instructors-section/instructor-container/index.jsx b/src/schedule-and-details/instructors-section/instructor-container/index.jsx index af2cae049f..4bc7f3af15 100644 --- a/src/schedule-and-details/instructors-section/instructor-container/index.jsx +++ b/src/schedule-and-details/instructors-section/instructor-container/index.jsx @@ -17,6 +17,7 @@ const InstructorContainer = ({ idx, onDelete, onChange, + isEditable, }) => { const intl = useIntl(); return ( @@ -32,6 +33,7 @@ const InstructorContainer = ({ value={instructor?.name} placeholder={intl.formatMessage(messages.instructorNameInputPlaceholder)} onChange={(e) => onChange(e.target.value, idx, 'name')} + disabled={!isEditable} /> {intl.formatMessage(messages.instructorNameHelpText)} @@ -46,6 +48,7 @@ const InstructorContainer = ({ value={instructor?.title} placeholder={intl.formatMessage(messages.instructorTitleInputPlaceholder)} onChange={(e) => onChange(e.target.value, idx, 'title')} + disabled={!isEditable} /> {intl.formatMessage(messages.instructorTitleHelpText)} @@ -60,6 +63,7 @@ const InstructorContainer = ({ value={instructor?.organization} placeholder={intl.formatMessage(messages.instructorOrganizationInputPlaceholder)} onChange={(e) => onChange(e.target.value, idx, 'organization')} + disabled={!isEditable} /> {intl.formatMessage(messages.instructorOrganizationHelpText)} @@ -76,6 +80,7 @@ const InstructorContainer = ({ value={instructor?.bio} placeholder={intl.formatMessage(messages.instructorBioInputPlaceholder)} onChange={(e) => onChange(e.target.value, idx, 'bio')} + disabled={!isEditable} /> {intl.formatMessage(messages.instructorBioHelpText)} @@ -91,6 +96,7 @@ const InstructorContainer = ({ messages.instructorPhotoInputPlaceholder, )} customHelpText={intl.formatMessage(messages.instructorPhotoHelpText)} + disabled={!isEditable} onChange={(value, field) => onChange(value, idx, field)} /> @@ -98,7 +104,7 @@ const InstructorContainer = ({ - @@ -108,6 +114,7 @@ const InstructorContainer = ({ InstructorContainer.defaultProps = { instructor: {}, + isEditable: true, }; InstructorContainer.propTypes = { @@ -119,6 +126,7 @@ InstructorContainer.propTypes = { title: PropTypes.string, }), idx: PropTypes.number.isRequired, + isEditable: PropTypes.bool, onDelete: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx b/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx index e7872510cd..fcc8047b5b 100644 --- a/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx +++ b/src/schedule-and-details/introducing-section/IntroducingSection.test.jsx @@ -86,4 +86,14 @@ describe('', () => { expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0); expect(queryAllByText(messages.courseAboutSidebarLabel.defaultMessage).length).toBe(0); }); + + it('disables the short description textarea when isEditable is false', () => { + const { getByLabelText } = render(); + expect(getByLabelText(messages.courseShortDescriptionLabel.defaultMessage)).toBeDisabled(); + }); + + it('enables the short description textarea when isEditable is true', () => { + const { getByLabelText } = render(); + expect(getByLabelText(messages.courseShortDescriptionLabel.defaultMessage)).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/introducing-section/extended-course-details/ExtendedCourseDetails.test.jsx b/src/schedule-and-details/introducing-section/extended-course-details/ExtendedCourseDetails.test.jsx index 16f6cf9239..7121b764db 100644 --- a/src/schedule-and-details/introducing-section/extended-course-details/ExtendedCourseDetails.test.jsx +++ b/src/schedule-and-details/introducing-section/extended-course-details/ExtendedCourseDetails.test.jsx @@ -65,4 +65,16 @@ describe('', () => { }); expect(onChangeMock).toHaveBeenCalledWith('abc', 'title'); }); + + it('disables all inputs when isEditable is false', () => { + const { getAllByRole } = render(); + const inputs = getAllByRole('textbox'); + inputs.forEach((input) => expect(input).toBeDisabled()); + }); + + it('enables all inputs when isEditable is true', () => { + const { getAllByRole } = render(); + const inputs = getAllByRole('textbox'); + inputs.forEach((input) => expect(input).not.toBeDisabled()); + }); }); diff --git a/src/schedule-and-details/introducing-section/extended-course-details/index.jsx b/src/schedule-and-details/introducing-section/extended-course-details/index.jsx index 53f37f19dd..e9a0c54b7b 100644 --- a/src/schedule-and-details/introducing-section/extended-course-details/index.jsx +++ b/src/schedule-and-details/introducing-section/extended-course-details/index.jsx @@ -11,6 +11,7 @@ const ExtendedCourseDetails = ({ subtitle, duration, description, + isEditable, onChange, }) => { const intl = useIntl(); @@ -61,6 +62,7 @@ const ExtendedCourseDetails = ({ maxLength={param.maxLength} onChange={(e) => onChange(e.target.value, param.controlName)} aria-label={param.ariaLabel} + disabled={!isEditable} /> {param.helpText} @@ -74,6 +76,7 @@ ExtendedCourseDetails.defaultProps = { subtitle: '', duration: '', description: '', + isEditable: true, }; ExtendedCourseDetails.propTypes = { @@ -81,6 +84,7 @@ ExtendedCourseDetails.propTypes = { subtitle: PropTypes.string, duration: PropTypes.string, description: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/introducing-section/index.jsx b/src/schedule-and-details/introducing-section/index.jsx index 1eb87aff7c..ee6c28efb0 100644 --- a/src/schedule-and-details/introducing-section/index.jsx +++ b/src/schedule-and-details/introducing-section/index.jsx @@ -30,6 +30,7 @@ const IntroducingSection = ({ shortDescriptionEditable, enableExtendedCourseDetails, videoThumbnailImageAssetPath, + isEditable, onChange, }) => { const intl = useIntl(); @@ -83,6 +84,7 @@ const IntroducingSection = ({ subtitle={subtitle} duration={duration} description={description} + isEditable={isEditable} onChange={onChange} /> )} @@ -100,6 +102,7 @@ const IntroducingSection = ({ messages.courseShortDescriptionAriaLabel, )} maxLength={150} + disabled={!isEditable} /> {intl.formatMessage(messages.courseShortDescriptionHelpText)} @@ -113,6 +116,7 @@ const IntroducingSection = ({ onChange(value, 'overview')} + disabled={!isEditable} /> {overviewHelpText} @@ -122,6 +126,7 @@ const IntroducingSection = ({ onChange(value, 'aboutSidebarHtml')} + disabled={!isEditable} /> {aboutSidebarHelpText} @@ -133,6 +138,7 @@ const IntroducingSection = ({ assetImageField="courseImageAssetPath" imageNameField="courseImageName" showImageBodyText + disabled={!isEditable} onChange={onChange} /> @@ -146,6 +152,7 @@ const IntroducingSection = ({ assetImageField="bannerImageAssetPath" imageNameField="bannerImageName" showImageBodyText + disabled={!isEditable} onChange={onChange} /> )} - {aboutPageEditable && } + {aboutPageEditable && ( + + )} ); }; @@ -176,6 +186,7 @@ IntroducingSection.defaultProps = { bannerImageAssetPath: '', videoThumbnailImageAssetPath: '', overview: '', + isEditable: true, }; IntroducingSection.propTypes = { @@ -195,6 +206,7 @@ IntroducingSection.propTypes = { shortDescriptionEditable: PropTypes.bool.isRequired, enableExtendedCourseDetails: PropTypes.bool.isRequired, videoThumbnailImageAssetPath: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/introducing-section/introduction-video/IntroductionVideo.test.jsx b/src/schedule-and-details/introducing-section/introduction-video/IntroductionVideo.test.jsx index 53d234eb3c..2895942b72 100644 --- a/src/schedule-and-details/introducing-section/introduction-video/IntroductionVideo.test.jsx +++ b/src/schedule-and-details/introducing-section/introduction-video/IntroductionVideo.test.jsx @@ -65,4 +65,18 @@ describe('', () => { fireEvent.click(button); expect(onChangeMock).toHaveBeenCalledWith('', 'introVideo'); }); + + it('disables input and delete button when isEditable is false', () => { + const initialProps = { ...props, introVideo: 'BvgNgTPTkSo', isEditable: false }; + const { getByPlaceholderText, getByRole } = render(); + expect(getByPlaceholderText(messages.courseIntroductionVideoPlaceholder.defaultMessage)).toBeDisabled(); + expect(getByRole('button', { name: messages.courseIntroductionVideoDelete.defaultMessage })).toBeDisabled(); + }); + + it('enables input and delete button when isEditable is true', () => { + const initialProps = { ...props, introVideo: 'BvgNgTPTkSo', isEditable: true }; + const { getByPlaceholderText, getByRole } = render(); + expect(getByPlaceholderText(messages.courseIntroductionVideoPlaceholder.defaultMessage)).not.toBeDisabled(); + expect(getByRole('button', { name: messages.courseIntroductionVideoDelete.defaultMessage })).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/introducing-section/introduction-video/index.jsx b/src/schedule-and-details/introducing-section/introduction-video/index.jsx index 7713f743aa..afc1968009 100644 --- a/src/schedule-and-details/introducing-section/introduction-video/index.jsx +++ b/src/schedule-and-details/introducing-section/introduction-video/index.jsx @@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -const IntroductionVideo = ({ introVideo, onChange }) => { +const IntroductionVideo = ({ introVideo, isEditable, onChange }) => { const intl = useIntl(); const embedVideoUrl = introVideo ? `//www.youtube.com/embed/${introVideo}` @@ -35,11 +35,12 @@ const IntroductionVideo = ({ introVideo, onChange }) => { value={introVideo || ''} placeholder={intl.formatMessage(messages.courseIntroductionVideoPlaceholder)} onChange={(e) => onChange(e.target.value, 'introVideo')} + disabled={!isEditable} /> @@ -54,10 +55,12 @@ const IntroductionVideo = ({ introVideo, onChange }) => { IntroductionVideo.defaultProps = { introVideo: '', + isEditable: true, }; IntroductionVideo.propTypes = { introVideo: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/learning-outcomes-section/InstructorsSection.test.jsx b/src/schedule-and-details/learning-outcomes-section/InstructorsSection.test.jsx index ea728aab7b..df8f8176fc 100644 --- a/src/schedule-and-details/learning-outcomes-section/InstructorsSection.test.jsx +++ b/src/schedule-and-details/learning-outcomes-section/InstructorsSection.test.jsx @@ -66,4 +66,18 @@ describe('', () => { expect(onChangeMock).toHaveBeenCalledWith(['abc'], 'learningInfo'); }); + + it('disables input, delete button and add button when isEditable is false', () => { + const { getByPlaceholderText, getByRole } = render(); + expect(getByPlaceholderText(messages.outcomesInputPlaceholder.defaultMessage)).toBeDisabled(); + expect(getByRole('button', { name: messages.outcomesDelete.defaultMessage })).toBeDisabled(); + expect(getByRole('button', { name: messages.outcomesAdd.defaultMessage })).toBeDisabled(); + }); + + it('enables input, delete button and add button when isEditable is true', () => { + const { getByPlaceholderText, getByRole } = render(); + expect(getByPlaceholderText(messages.outcomesInputPlaceholder.defaultMessage)).not.toBeDisabled(); + expect(getByRole('button', { name: messages.outcomesDelete.defaultMessage })).not.toBeDisabled(); + expect(getByRole('button', { name: messages.outcomesAdd.defaultMessage })).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/learning-outcomes-section/index.jsx b/src/schedule-and-details/learning-outcomes-section/index.jsx index 06409fd5bd..3965b6d268 100644 --- a/src/schedule-and-details/learning-outcomes-section/index.jsx +++ b/src/schedule-and-details/learning-outcomes-section/index.jsx @@ -7,7 +7,7 @@ import { Add as AddIcon } from '@openedx/paragon/icons'; import SectionSubHeader from '../../generic/section-sub-header'; import messages from './messages'; -const LearningOutcomesSection = ({ learningInfo, onChange }) => { +const LearningOutcomesSection = ({ learningInfo, isEditable, onChange }) => { const intl = useIntl(); const handleInputChange = (value, index) => { @@ -39,10 +39,12 @@ const LearningOutcomesSection = ({ learningInfo, onChange }) => { value={text} placeholder={intl.formatMessage(messages.outcomesInputPlaceholder)} onChange={(e) => handleInputChange(e.target.value, idx)} + disabled={!isEditable} /> @@ -58,7 +60,7 @@ const LearningOutcomesSection = ({ learningInfo, onChange }) => {
    {learningInfo.map(renderLearningOutcomeItem)}
- @@ -67,10 +69,12 @@ const LearningOutcomesSection = ({ learningInfo, onChange }) => { LearningOutcomesSection.defaultProps = { learningInfo: [], + isEditable: true, }; LearningOutcomesSection.propTypes = { learningInfo: PropTypes.arrayOf(PropTypes.string), + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/license-section/LicenseSection.test.jsx b/src/schedule-and-details/license-section/LicenseSection.test.jsx index 488d3d2c14..219683f696 100644 --- a/src/schedule-and-details/license-section/LicenseSection.test.jsx +++ b/src/schedule-and-details/license-section/LicenseSection.test.jsx @@ -25,4 +25,16 @@ describe('', () => { expect(getByText(messages.licenseTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.licenseDescription.defaultMessage)).toBeInTheDocument(); }); + + it('disables license type buttons when isEditable is false', () => { + const { getAllByRole } = render(); + const buttons = getAllByRole('button'); + buttons.forEach((button) => expect(button).toBeDisabled()); + }); + + it('enables license type buttons when isEditable is true', () => { + const { getAllByRole } = render(); + const buttons = getAllByRole('button'); + buttons.forEach((button) => expect(button).not.toBeDisabled()); + }); }); diff --git a/src/schedule-and-details/license-section/index.jsx b/src/schedule-and-details/license-section/index.jsx index 08e28819ee..d6cc1b7677 100644 --- a/src/schedule-and-details/license-section/index.jsx +++ b/src/schedule-and-details/license-section/index.jsx @@ -10,7 +10,7 @@ import { LICENSE_TYPE } from './constants'; import messages from './messages'; import { useLicenseDetails } from './hooks'; -const LicenseSection = ({ license, onChange }) => { +const LicenseSection = ({ license, isEditable, onChange }) => { const intl = useIntl(); const { licenseURL, @@ -28,11 +28,13 @@ const LicenseSection = ({ license, onChange }) => { /> {licenseType === LICENSE_TYPE.creativeCommons && ( )} @@ -47,10 +49,12 @@ const LicenseSection = ({ license, onChange }) => { LicenseSection.defaultProps = { license: null, + isEditable: true, }; LicenseSection.propTypes = { license: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/license-section/license-commons-options/LicenseCommonsOptions.test.jsx b/src/schedule-and-details/license-section/license-commons-options/LicenseCommonsOptions.test.jsx index 22369e2c36..05b6ed193e 100644 --- a/src/schedule-and-details/license-section/license-commons-options/LicenseCommonsOptions.test.jsx +++ b/src/schedule-and-details/license-section/license-commons-options/LicenseCommonsOptions.test.jsx @@ -47,11 +47,29 @@ describe('', () => { expect(props.onToggleCheckbox).not.toHaveBeenCalled(); fireEvent.click(checkboxList[1]); expect(props.onToggleCheckbox).toHaveBeenCalledWith(LICENSE_COMMONS_OPTIONS.nonCommercial); - // Note: there is no point in asserting that the checkbox is now checked, - // because it is a controlled component that never changes unless the props change. - // This test should really be implemented in a higher level component/page. - // await waitFor(() => { - // expect(checkboxList[1].checked).toBeFalsy(); - // }); + }); + + it('disables all non-fixed checkboxes when isEditable is false', () => { + const { getAllByRole } = render(); + const checkboxList = getAllByRole('checkbox'); + // All checkboxes (including attribution which is always disabled) should be disabled + checkboxList.forEach((checkbox) => expect(checkbox).toBeDisabled()); + }); + + it('does not call onToggleCheckbox when clicked while isEditable is false', () => { + onToggleCheckboxMock.mockClear(); + const { getAllByRole } = render(); + const checkboxList = getAllByRole('checkbox'); + fireEvent.click(checkboxList[1]); + expect(onToggleCheckboxMock).not.toHaveBeenCalled(); + }); + + it('non-fixed checkboxes are enabled when isEditable is true', () => { + const { getAllByRole } = render(); + const checkboxList = getAllByRole('checkbox'); + // checkboxList[0] is attribution (always disabled), rest should be enabled + expect(checkboxList[1]).not.toBeDisabled(); + expect(checkboxList[2]).not.toBeDisabled(); + expect(checkboxList[3]).not.toBeDisabled(); }); }); diff --git a/src/schedule-and-details/license-section/license-commons-options/index.jsx b/src/schedule-and-details/license-section/license-commons-options/index.jsx index a454fe5cc0..ca86c6c4e8 100644 --- a/src/schedule-and-details/license-section/license-commons-options/index.jsx +++ b/src/schedule-and-details/license-section/license-commons-options/index.jsx @@ -12,7 +12,7 @@ import { import { LICENSE_COMMONS_OPTIONS } from '../constants'; import messages from './messages'; -const LicenseCommonsOptions = ({ licenseDetails, onToggleCheckbox }) => { +const LicenseCommonsOptions = ({ licenseDetails, isEditable, onToggleCheckbox }) => { const optionDetails = [ { id: LICENSE_COMMONS_OPTIONS.attribution, @@ -45,7 +45,7 @@ const LicenseCommonsOptions = ({ licenseDetails, onToggleCheckbox }) => { ]; const handleCheckboxClick = (option) => { - if (!option.disabled) { + if (!option.disabled && isEditable) { onToggleCheckbox(option.id); } }; @@ -70,7 +70,7 @@ const LicenseCommonsOptions = ({ licenseDetails, onToggleCheckbox }) => { @@ -89,6 +89,10 @@ const LicenseCommonsOptions = ({ licenseDetails, onToggleCheckbox }) => { ); }; +LicenseCommonsOptions.defaultProps = { + isEditable: true, +}; + LicenseCommonsOptions.propTypes = { licenseDetails: PropTypes.shape({ attribution: PropTypes.bool.isRequired, @@ -96,6 +100,7 @@ LicenseCommonsOptions.propTypes = { noDerivatives: PropTypes.bool.isRequired, shareAlike: PropTypes.bool.isRequired, }).isRequired, + isEditable: PropTypes.bool, onToggleCheckbox: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/license-section/license-selector/LicenseSelector.test.jsx b/src/schedule-and-details/license-section/license-selector/LicenseSelector.test.jsx index efa7d29519..7cf41400c2 100644 --- a/src/schedule-and-details/license-section/license-selector/LicenseSelector.test.jsx +++ b/src/schedule-and-details/license-section/license-selector/LicenseSelector.test.jsx @@ -58,4 +58,23 @@ describe('', () => { expect(buttonFirst).toHaveClass('btn btn-outline-primary'); expect(buttonSecond).toHaveClass('btn btn-outline-primary'); }); + + it('disables both buttons when isEditable is false', () => { + const { getByRole } = render(); + expect(getByRole('button', { name: messages.licenseChoice1.defaultMessage })).toBeDisabled(); + expect(getByRole('button', { name: messages.licenseChoice2.defaultMessage })).toBeDisabled(); + }); + + it('does not call onChangeLicenseType when clicked while isEditable is false', () => { + onChangeLicenseTypeMock.mockClear(); + const { getByRole } = render(); + fireEvent.click(getByRole('button', { name: messages.licenseChoice2.defaultMessage })); + expect(onChangeLicenseTypeMock).not.toHaveBeenCalled(); + }); + + it('enables both buttons when isEditable is true', () => { + const { getByRole } = render(); + expect(getByRole('button', { name: messages.licenseChoice1.defaultMessage })).not.toBeDisabled(); + expect(getByRole('button', { name: messages.licenseChoice2.defaultMessage })).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/license-section/license-selector/index.jsx b/src/schedule-and-details/license-section/license-selector/index.jsx index 5524298f09..e3dba91b6d 100644 --- a/src/schedule-and-details/license-section/license-selector/index.jsx +++ b/src/schedule-and-details/license-section/license-selector/index.jsx @@ -12,7 +12,7 @@ import { import { LICENSE_TYPE } from '../constants'; import messages from './messages'; -const LicenseSelector = ({ licenseType, onChangeLicenseType }) => { +const LicenseSelector = ({ licenseType, isEditable, onChangeLicenseType }) => { const LICENSE_BUTTON_GROUP_LABELS = { [LICENSE_TYPE.allRightsReserved]: { label: , @@ -35,6 +35,7 @@ const LicenseSelector = ({ licenseType, onChangeLicenseType }) => { @@ -57,10 +58,12 @@ const LicenseSelector = ({ licenseType, onChangeLicenseType }) => { LicenseSelector.defaultProps = { licenseType: null, + isEditable: true, }; LicenseSelector.propTypes = { licenseType: PropTypes.oneOf(Object.values(LICENSE_TYPE)), + isEditable: PropTypes.bool, onChangeLicenseType: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/pacing-section/PacingSection.test.jsx b/src/schedule-and-details/pacing-section/PacingSection.test.jsx index 4bbeca6baa..401ee0767c 100644 --- a/src/schedule-and-details/pacing-section/PacingSection.test.jsx +++ b/src/schedule-and-details/pacing-section/PacingSection.test.jsx @@ -66,4 +66,22 @@ describe('', () => { fireEvent.click(radioList[1]); expect(onChangeMock).toHaveBeenCalled(); }); + + it('disables radio inputs when isEditable is false', () => { + const year = new Date().getFullYear() + 1; + const futureDate = `${year}-12-31`; + const initialProps = { ...props, startDate: futureDate, isEditable: false }; + const { getAllByRole } = render(); + const radioList = getAllByRole('radio'); + radioList.forEach((radio) => expect(radio).toBeDisabled()); + }); + + it('keeps radio inputs enabled when isEditable is true and course has not started', () => { + const year = new Date().getFullYear() + 1; + const futureDate = `${year}-12-31`; + const initialProps = { ...props, startDate: futureDate, isEditable: true }; + const { getAllByRole } = render(); + const radioList = getAllByRole('radio'); + radioList.forEach((radio) => expect(radio).not.toBeDisabled()); + }); }); diff --git a/src/schedule-and-details/pacing-section/index.jsx b/src/schedule-and-details/pacing-section/index.jsx index 2bbe76bc91..a0dd5814f8 100644 --- a/src/schedule-and-details/pacing-section/index.jsx +++ b/src/schedule-and-details/pacing-section/index.jsx @@ -10,6 +10,7 @@ const PacingSection = ({ selfPaced, startDate, onChange, + isEditable, }) => { const intl = useIntl(); const canTogglePace = new Date() <= new Date(startDate); @@ -34,14 +35,14 @@ const PacingSection = ({ {intl.formatMessage(messages.pacingTypeInstructorLabel)} {intl.formatMessage(messages.pacingTypeSelfLabel)} @@ -54,11 +55,13 @@ const PacingSection = ({ PacingSection.defaultProps = { selfPaced: '', startDate: '', + isEditable: true, }; PacingSection.propTypes = { startDate: PropTypes.string, selfPaced: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx b/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx index c9a7a6feb0..1391f426cf 100644 --- a/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx +++ b/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx @@ -95,4 +95,17 @@ describe('', () => { expect(queryAllByLabelText(messages.dropdownLabel.defaultMessage).length).toBe(0); expect(queryAllByLabelText(entranceExamMessages.requirementsEntrance.defaultMessage).length).toBe(0); }); + + it('disables effort input and prerequisite dropdown when isEditable is false', () => { + const { getByDisplayValue, getByRole } = render(); + expect(getByDisplayValue(props.effort)).toBeDisabled(); + expect(getByRole('button', { name: messages.dropdownLabel.defaultMessage, hidden: true })).toBeDefined(); + const dropdown = getByRole('button', { name: new RegExp(props.preRequisiteCourses[0] || '', 'i'), hidden: true }); + expect(dropdown || getByRole('button', { name: messages.dropdownEmptyText?.defaultMessage || '' })).toBeDefined(); + }); + + it('enables effort input when isEditable is true', () => { + const { getByDisplayValue } = render(); + expect(getByDisplayValue(props.effort)).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/requirements-section/entrance-exam/EntranceExam.test.jsx b/src/schedule-and-details/requirements-section/entrance-exam/EntranceExam.test.jsx index 5f58e04b23..f3e712ba5a 100644 --- a/src/schedule-and-details/requirements-section/entrance-exam/EntranceExam.test.jsx +++ b/src/schedule-and-details/requirements-section/entrance-exam/EntranceExam.test.jsx @@ -61,4 +61,16 @@ describe('', () => { expect(screen.queryByText('Grade requirements')).not.toBeInTheDocument(); }); }); + + it('disables checkbox when isEditable is false', () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeDisabled(); + }); + + it('enables checkbox when isEditable is true', () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/requirements-section/entrance-exam/index.jsx b/src/schedule-and-details/requirements-section/entrance-exam/index.jsx index 717707f186..0fe41fbe6c 100644 --- a/src/schedule-and-details/requirements-section/entrance-exam/index.jsx +++ b/src/schedule-and-details/requirements-section/entrance-exam/index.jsx @@ -12,6 +12,7 @@ const EntranceExam = ({ errorEffort, isCheckedString, entranceExamMinimumScorePct, + isEditable, onChange, }) => { const { courseId } = useParams(); @@ -33,6 +34,7 @@ const EntranceExam = ({ @@ -62,6 +64,7 @@ const EntranceExam = ({ @@ -76,12 +79,14 @@ EntranceExam.defaultProps = { errorEffort: '', isCheckedString: '', entranceExamMinimumScorePct: '', + isEditable: true, }; EntranceExam.propTypes = { errorEffort: PropTypes.string, isCheckedString: PropTypes.string, entranceExamMinimumScorePct: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/requirements-section/grade-requirements/GradeRequirements.test.jsx b/src/schedule-and-details/requirements-section/grade-requirements/GradeRequirements.test.jsx index 0072bc5358..338193e225 100644 --- a/src/schedule-and-details/requirements-section/grade-requirements/GradeRequirements.test.jsx +++ b/src/schedule-and-details/requirements-section/grade-requirements/GradeRequirements.test.jsx @@ -47,4 +47,14 @@ describe('', () => { expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); }); + + it('disables input when isEditable is false', () => { + const { getByDisplayValue } = render(); + expect(getByDisplayValue(props.entranceExamMinimumScorePct)).toBeDisabled(); + }); + + it('enables input when isEditable is true', () => { + const { getByDisplayValue } = render(); + expect(getByDisplayValue(props.entranceExamMinimumScorePct)).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/requirements-section/grade-requirements/index.jsx b/src/schedule-and-details/requirements-section/grade-requirements/index.jsx index 91772a9d82..8a13e7c0c0 100644 --- a/src/schedule-and-details/requirements-section/grade-requirements/index.jsx +++ b/src/schedule-and-details/requirements-section/grade-requirements/index.jsx @@ -9,6 +9,7 @@ import messages from './messages'; const GradeRequirements = ({ errorEffort, entranceExamMinimumScorePct, + isEditable, onChange, }) => ( onChange(e.target.value, 'entranceExamMinimumScorePct')} trailingElement="%" + disabled={!isEditable} /> {errorEffort && ( @@ -43,11 +45,13 @@ const GradeRequirements = ({ GradeRequirements.defaultProps = { errorEffort: '', entranceExamMinimumScorePct: '', + isEditable: true, }; GradeRequirements.propTypes = { errorEffort: PropTypes.string, entranceExamMinimumScorePct: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/requirements-section/index.jsx b/src/schedule-and-details/requirements-section/index.jsx index c7635c10ca..acca798c4a 100644 --- a/src/schedule-and-details/requirements-section/index.jsx +++ b/src/schedule-and-details/requirements-section/index.jsx @@ -18,6 +18,7 @@ const RequirementsSection = ({ possiblePreRequisiteCourses, entranceExamMinimumScorePct, isPrerequisiteCoursesEnabled, + isEditable, onChange, }) => { const intl = useIntl(); @@ -33,20 +34,20 @@ const RequirementsSection = ({ > {intl.formatMessage(messages.dropdownLabel)} - + {formattedSelectedItem} onChange([], 'preRequisiteCourses')} + onClick={isEditable ? () => onChange([], 'preRequisiteCourses') : undefined} > {intl.formatMessage(messages.dropdownEmptyText)} {possiblePreRequisiteCourses.map((course) => ( onChange([course.courseKey], 'preRequisiteCourses')} + onClick={isEditable ? () => onChange([course.courseKey], 'preRequisiteCourses') : undefined} > {course.displayName} @@ -74,6 +75,7 @@ const RequirementsSection = ({ value={effort || ''} placeholder={TIME_FORMAT.toUpperCase()} onChange={(e) => onChange(e.target.value, 'effort')} + disabled={!isEditable} /> {intl.formatMessage(messages.timepickerHelpText)} @@ -86,6 +88,7 @@ const RequirementsSection = ({ errorEffort={errorFields?.entranceExamMinimumScorePct} isCheckedString={entranceExamEnabled} entranceExamMinimumScorePct={entranceExamMinimumScorePct} + isEditable={isEditable} onChange={onChange} /> )} @@ -110,6 +113,7 @@ RequirementsSection.defaultProps = { entranceExamEnabled: '', preRequisiteCourses: [], entranceExamMinimumScorePct: '', + isEditable: true, }; RequirementsSection.propTypes = { @@ -124,6 +128,7 @@ RequirementsSection.propTypes = { ).isRequired, entranceExamMinimumScorePct: PropTypes.string, isPrerequisiteCoursesEnabled: PropTypes.bool.isRequired, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/schedule-section/ScheduleSection.test.jsx b/src/schedule-and-details/schedule-section/ScheduleSection.test.jsx index 6e9b2dc9cb..2433f3cf05 100644 --- a/src/schedule-and-details/schedule-section/ScheduleSection.test.jsx +++ b/src/schedule-and-details/schedule-section/ScheduleSection.test.jsx @@ -71,4 +71,20 @@ describe('', () => { expect(getByText(certificateMessages.certificateBehaviorLabel.defaultMessage)).toBeInTheDocument(); expect(queryAllByText(certificateMessages.certificateAvailableDateLabel.defaultMessage).length).toBe(0); }); + + it('sets all date inputs to disabled when isEditable is false', () => { + const { container } = render(); + // DatepickerControl uses disabled={readonly}, so inputs get the disabled attribute + const inputs = container.querySelectorAll('input[disabled]'); + expect(inputs.length).toBeGreaterThan(0); + }); + + it('date inputs are not readonly when isEditable is true', () => { + const { container } = render(); + // upgradeDeadline and enrollmentEnd (not editable) will still be readonly — only check start/end date + const allInputs = container.querySelectorAll('input'); + const startDateInput = Array.from(allInputs).find((i) => i.id?.includes('startDate-date')); + expect(startDateInput).not.toBeNull(); + expect(startDateInput.readOnly).toBe(false); + }); }); diff --git a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx index f8172423d6..aa1fed0a73 100644 --- a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx +++ b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx @@ -24,6 +24,7 @@ const CertificateDisplayRow = ({ availableDateErrorFeedback, certificatesDisplayBehavior, displayBehaviorErrorFeedback, + isEditable, onChange, }) => { const intl = useIntl(); @@ -129,12 +130,12 @@ const CertificateDisplayRow = ({ {intl.formatMessage(messages.certificateBehaviorLabel)} - + {certificateDisplayValue} {dropdownOptions.map(({ id, label }) => ( - handleOnChange(id)}> + handleOnChange(id) : undefined}> {label} ))} @@ -151,6 +152,7 @@ const CertificateDisplayRow = ({ onChange={(date) => onChange(date, 'certificateAvailableDate')} isInvalid={!!availableDateErrorFeedback} controlName="certificateAvailableDate" + readonly={!isEditable} /> )} {availableDateErrorFeedback && ( @@ -175,6 +177,7 @@ CertificateDisplayRow.defaultProps = { availableDateErrorFeedback: '', certificatesDisplayBehavior: '', displayBehaviorErrorFeedback: '', + isEditable: true, }; CertificateDisplayRow.propTypes = { @@ -182,6 +185,7 @@ CertificateDisplayRow.propTypes = { availableDateErrorFeedback: PropTypes.string, certificatesDisplayBehavior: PropTypes.string, displayBehaviorErrorFeedback: PropTypes.string, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.test.jsx b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.test.jsx index 7b5a510a59..7fd9e113e0 100644 --- a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.test.jsx +++ b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.test.jsx @@ -93,4 +93,27 @@ describe('', () => { getByText(messages.certificateAvailableDateLabel.defaultMessage), ).toBeInTheDocument(); }); + + it('disables the dropdown toggle when isEditable is false', () => { + const { getByRole } = render(); + // certificatesDisplayBehavior: 'end' from mock → button label is Option2 + const toggle = getByRole('button', { name: messages.certificateBehaviorDropdownOption2.defaultMessage }); + expect(toggle).toBeDisabled(); + }); + + it('does not call onChange when dropdown item clicked while isEditable is false', () => { + onChangeMock.mockClear(); + const { getByRole } = render(); + const toggle = getByRole('button', { name: messages.certificateBehaviorDropdownOption2.defaultMessage }); + // Toggle is disabled, clicking it does not open the dropdown + fireEvent.click(toggle); + expect(onChangeMock).not.toHaveBeenCalled(); + }); + + it('enables the dropdown toggle when isEditable is true', () => { + const { getByRole } = render(); + // certificatesDisplayBehavior: 'end' from mock → button label is Option2 + const toggle = getByRole('button', { name: messages.certificateBehaviorDropdownOption2.defaultMessage }); + expect(toggle).not.toBeDisabled(); + }); }); diff --git a/src/schedule-and-details/schedule-section/index.jsx b/src/schedule-and-details/schedule-section/index.jsx index e7098b3921..9d57773695 100644 --- a/src/schedule-and-details/schedule-section/index.jsx +++ b/src/schedule-and-details/schedule-section/index.jsx @@ -19,6 +19,7 @@ const ScheduleSection = ({ certificateAvailableDate, certificatesDisplayBehavior, canShowCertificateAvailableDateField, + isEditable, onChange, }) => { const intl = useIntl(); @@ -44,6 +45,7 @@ const ScheduleSection = ({ helpText: intl.formatMessage(messages.scheduleCourseStartDateHelpText), controlName: 'startDate', errorFeedback: errorFields?.startDate, + readonly: !isEditable, }, { labels: [ @@ -55,6 +57,7 @@ const ScheduleSection = ({ helpText: intl.formatMessage(messages.scheduleCourseEndDateHelpText), controlName: 'endDate', errorFeedback: errorFields?.endDate, + readonly: !isEditable, }, { skip: !canShowCertificateAvailableDateField, @@ -64,6 +67,7 @@ const ScheduleSection = ({ availableDateErrorFeedback: errorFields?.certificateAvailableDate, certificatesDisplayBehavior, displayBehaviorErrorFeedback: errorFields?.certificatesDisplayBehavior, + isEditable, }, { labels: [ @@ -75,6 +79,7 @@ const ScheduleSection = ({ helpText: intl.formatMessage(messages.scheduleEnrollmentStartDateHelpText), controlName: 'enrollmentStart', errorFeedback: errorFields?.enrollmentStart, + readonly: !isEditable, }, { labels: [ @@ -84,7 +89,7 @@ const ScheduleSection = ({ value: enrollmentEnd, rowType: SCHEDULE_ROW_TYPES.datetime, helpText: computedEnrollmentEndHelpText, - readonly: !enrollmentEndEditable, + readonly: !enrollmentEndEditable || !isEditable, controlName: 'enrollmentEnd', errorFeedback: errorFields?.enrollmentEnd, }, @@ -133,6 +138,7 @@ const ScheduleSection = ({ ); @@ -150,6 +156,7 @@ ScheduleSection.defaultProps = { enrollmentStart: '', upgradeDeadline: '', certificateAvailableDate: '', + isEditable: true, }; ScheduleSection.propTypes = { @@ -164,6 +171,7 @@ ScheduleSection.propTypes = { certificateAvailableDate: PropTypes.string, certificatesDisplayBehavior: PropTypes.string.isRequired, canShowCertificateAvailableDateField: PropTypes.bool.isRequired, + isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; From b48a9a3f16b586f749dbe999962fcb0e08adc299 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 30 Apr 2026 14:49:45 -0500 Subject: [PATCH 2/5] fix: solve lint issues --- src/generic/course-upload-image/index.jsx | 3 +-- src/schedule-and-details/index.jsx | 12 +++++------- .../introducing-section/index.jsx | 4 +--- .../CertificateDisplayRow.jsx | 7 ++++++- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx index 39f466aa2a..8504efc854 100644 --- a/src/generic/course-upload-image/index.jsx +++ b/src/generic/course-upload-image/index.jsx @@ -134,8 +134,7 @@ const CourseUploadImage = ({ placeholder={customInputPlaceholder || intl.formatMessage(messages.uploadImageInputPlaceholder, { identifierFieldText, - }) - } + })} disabled={disabled} /> diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index bdc5b4c3b1..3b797d146f 100644 --- a/src/schedule-and-details/index.jsx +++ b/src/schedule-and-details/index.jsx @@ -62,8 +62,8 @@ const ScheduleAndDetails = () => { const { isLoading: isLoadingUserPermissions, canViewScheduleAndDetails, - canEditSchedule, - canEditDetails, + canEditSchedule, + canEditDetails, } = useCourseUserPermissions(courseId, getScheduleAndDetailsPermissions(courseId)); const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS @@ -404,11 +404,9 @@ const ScheduleAndDetails = () => { key="save-button" onClick={handleUpdateValues} disabled={hasErrors || (!isScheduleEditable && !isDetailsEditable)} - state={ - isQueryPending - ? STATEFUL_BUTTON_STATES.pending - : STATEFUL_BUTTON_STATES.default - } + state={isQueryPending + ? STATEFUL_BUTTON_STATES.pending + : STATEFUL_BUTTON_STATES.default} {...updateValuesButtonState} />, ].filter(Boolean)} diff --git a/src/schedule-and-details/introducing-section/index.jsx b/src/schedule-and-details/introducing-section/index.jsx index ee6c28efb0..84e4cc529b 100644 --- a/src/schedule-and-details/introducing-section/index.jsx +++ b/src/schedule-and-details/introducing-section/index.jsx @@ -167,9 +167,7 @@ const IntroducingSection = ({ /> )} - {aboutPageEditable && ( - - )} + {aboutPageEditable && } ); }; diff --git a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx index aa1fed0a73..44117fba91 100644 --- a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx +++ b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx @@ -135,7 +135,12 @@ const CertificateDisplayRow = ({ {dropdownOptions.map(({ id, label }) => ( - handleOnChange(id) : undefined}> + handleOnChange(id) + : undefined} + > {label} ))} From 4102655c53ec5f396a458005a8d6b340dbbf5a29 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 30 Apr 2026 15:14:13 -0500 Subject: [PATCH 3/5] feat: set default value for isEditable prop in multiple components --- src/generic/WysiwygEditor.jsx | 4 +- src/generic/course-upload-image/index.jsx | 4 +- .../ScheduleAndDetails.test.jsx | 46 ++++++++++--------- .../details-section/index.jsx | 4 +- src/schedule-and-details/index.jsx | 12 ++--- .../instructors-section/index.jsx | 4 +- .../instructor-container/index.jsx | 4 +- .../extended-course-details/index.jsx | 4 +- .../introducing-section/index.jsx | 4 +- .../introduction-video/index.jsx | 4 +- .../learning-outcomes-section/index.jsx | 4 +- .../license-section/index.jsx | 4 +- .../license-commons-options/index.jsx | 7 +-- .../license-selector/index.jsx | 4 +- .../pacing-section/index.jsx | 4 +- .../entrance-exam/index.jsx | 4 +- .../grade-requirements/index.jsx | 4 +- .../requirements-section/index.jsx | 4 +- .../CertificateDisplayRow.jsx | 4 +- .../schedule-section/index.jsx | 4 +- 20 files changed, 49 insertions(+), 84 deletions(-) diff --git a/src/generic/WysiwygEditor.jsx b/src/generic/WysiwygEditor.jsx index fbb96db889..876d49f53a 100644 --- a/src/generic/WysiwygEditor.jsx +++ b/src/generic/WysiwygEditor.jsx @@ -14,7 +14,7 @@ export const WysiwygEditor = ({ editorType, onChange, minHeight, - disabled, + disabled = false, }) => { const { editorRef, refReady, setEditorRef } = prepareEditorRef(); const { courseId } = useCourseAuthoringContext(); @@ -74,7 +74,6 @@ WysiwygEditor.defaultProps = { initialValue: '', editorType: SUPPORTED_TEXT_EDITORS.text, minHeight: 200, - disabled: false, }; WysiwygEditor.propTypes = { @@ -82,5 +81,4 @@ WysiwygEditor.propTypes = { editorType: PropTypes.oneOf(Object.values(SUPPORTED_TEXT_EDITORS)), onChange: PropTypes.func.isRequired, minHeight: PropTypes.number, - disabled: PropTypes.bool, }; diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx index 8504efc854..aaf4489163 100644 --- a/src/generic/course-upload-image/index.jsx +++ b/src/generic/course-upload-image/index.jsx @@ -27,7 +27,7 @@ const CourseUploadImage = ({ identifierFieldText, showImageBodyText, customInputPlaceholder, - disabled, + disabled = false, onChange, }) => { const { courseId } = useParams(); @@ -157,7 +157,6 @@ CourseUploadImage.defaultProps = { showImageBodyText: false, identifierFieldText: '', customInputPlaceholder: '', - disabled: false, }; CourseUploadImage.propTypes = { @@ -169,7 +168,6 @@ CourseUploadImage.propTypes = { showImageBodyText: PropTypes.bool, identifierFieldText: PropTypes.string, customInputPlaceholder: PropTypes.string, - disabled: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index 02b494443f..c349c4c16d 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -11,7 +11,7 @@ import genericMessages from '@src/generic/help-sidebar/messages'; import { DATE_FORMAT } from '@src/constants'; import { getCourseSettingsApiUrl } from '@src/data/api'; import { mockWaffleFlags } from '@src/data/apiHooks.mock'; -import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { courseDetailsMock, courseSettingsMock } from './__mocks__'; @@ -25,14 +25,12 @@ import messages from './messages'; import ScheduleAndDetails from '.'; jest.mock('@src/authz/hooks', () => ({ - useUserPermissionsWithAuthzCourse: jest.fn().mockReturnValue({ + useCourseUserPermissions: jest.fn().mockReturnValue({ isLoading: false, isAuthzEnabled: true, - permissions: { - canViewScheduleAndDetails: true, - canEditSchedule: true, - canEditDetails: true, - }, + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: true, }), })); @@ -193,14 +191,12 @@ describe(' permissions', () => { axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock); axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock); axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200); - jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - permissions: { - canViewScheduleAndDetails: true, - canEditSchedule: true, - canEditDetails: true, - }, + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: true, }); }); @@ -222,10 +218,12 @@ describe(' permissions', () => { it('shows PermissionDeniedAlert when user lacks view permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - permissions: { canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false }, + canViewScheduleAndDetails: false, + canEditSchedule: false, + canEditDetails: false, }); const { getByTestId } = renderComponent(); await waitFor(() => { @@ -235,10 +233,12 @@ describe(' permissions', () => { it('disables schedule date inputs when user lacks edit_schedule permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - permissions: { canViewScheduleAndDetails: true, canEditSchedule: false, canEditDetails: true }, + canViewScheduleAndDetails: true, + canEditSchedule: false, + canEditDetails: true, }); const { getAllByPlaceholderText } = renderComponent(); await waitFor(() => { @@ -249,10 +249,12 @@ describe(' permissions', () => { it('disables pacing and details inputs when user lacks edit_details permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - permissions: { canViewScheduleAndDetails: true, canEditSchedule: true, canEditDetails: false }, + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: false, }); const { getAllByRole } = renderComponent(); await waitFor(() => { @@ -263,10 +265,12 @@ describe(' permissions', () => { it('save button cannot be triggered when user has no edit permissions', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useUserPermissionsWithAuthzCourse).mockReturnValue({ + jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - permissions: { canViewScheduleAndDetails: true, canEditSchedule: false, canEditDetails: false }, + canViewScheduleAndDetails: true, + canEditSchedule: false, + canEditDetails: false, }); const { getAllByPlaceholderText, queryByText } = renderComponent(); // Wait for page to load diff --git a/src/schedule-and-details/details-section/index.jsx b/src/schedule-and-details/details-section/index.jsx index 64b86d6d0c..f4099849da 100644 --- a/src/schedule-and-details/details-section/index.jsx +++ b/src/schedule-and-details/details-section/index.jsx @@ -10,7 +10,7 @@ const DetailsSection = ({ language, languageOptions, onChange, - isEditable, + isEditable = true, }) => { const intl = useIntl(); const formattedLanguage = () => { @@ -51,7 +51,6 @@ const DetailsSection = ({ DetailsSection.defaultProps = { language: '', - isEditable: true, }; DetailsSection.propTypes = { @@ -59,7 +58,6 @@ DetailsSection.propTypes = { languageOptions: PropTypes.arrayOf( PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, ).isRequired, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index 3b797d146f..4bbb995563 100644 --- a/src/schedule-and-details/index.jsx +++ b/src/schedule-and-details/index.jsx @@ -159,15 +159,15 @@ const ScheduleAndDetails = () => { const { overview: initialOverview } = courseDetails || {}; const { aboutSidebarHtml: initialAboutSidebarHtml } = courseDetails || {}; - if (!isLoadingUserPermissions && !canViewScheduleAndDetails) { - return ; - } - if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; } + if (!canViewScheduleAndDetails) { + return ; + } + if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) { return (
@@ -176,8 +176,8 @@ const ScheduleAndDetails = () => { ); } - const isScheduleEditable = !isLoadingUserPermissions && canEditSchedule; - const isDetailsEditable = !isLoadingUserPermissions && canEditDetails; + const isScheduleEditable = canEditSchedule; + const isDetailsEditable = canEditDetails; const showCreditSection = creditEligibilityEnabled && isCreditCourse; const showRequirementsSection = aboutPageEditable || isPrerequisiteCoursesEnabled || isEntranceExamsEnabled; diff --git a/src/schedule-and-details/instructors-section/index.jsx b/src/schedule-and-details/instructors-section/index.jsx index 90cf950c87..517a33f3e7 100644 --- a/src/schedule-and-details/instructors-section/index.jsx +++ b/src/schedule-and-details/instructors-section/index.jsx @@ -9,7 +9,7 @@ import InstructorContainer from './instructor-container'; import SectionSubHeader from '../../generic/section-sub-header'; import messages from './messages'; -const InstructorsSection = ({ instructors, isEditable, onChange }) => { +const InstructorsSection = ({ instructors, isEditable = true, onChange }) => { const intl = useIntl(); const newInstructor = { bio: '', @@ -79,7 +79,6 @@ const InstructorsSection = ({ instructors, isEditable, onChange }) => { InstructorsSection.defaultProps = { instructors: [], - isEditable: true, }; InstructorsSection.propTypes = { @@ -92,7 +91,6 @@ InstructorsSection.propTypes = { title: PropTypes.string, }), ), - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/instructors-section/instructor-container/index.jsx b/src/schedule-and-details/instructors-section/instructor-container/index.jsx index 4bc7f3af15..756dd930a5 100644 --- a/src/schedule-and-details/instructors-section/instructor-container/index.jsx +++ b/src/schedule-and-details/instructors-section/instructor-container/index.jsx @@ -17,7 +17,7 @@ const InstructorContainer = ({ idx, onDelete, onChange, - isEditable, + isEditable = true, }) => { const intl = useIntl(); return ( @@ -114,7 +114,6 @@ const InstructorContainer = ({ InstructorContainer.defaultProps = { instructor: {}, - isEditable: true, }; InstructorContainer.propTypes = { @@ -126,7 +125,6 @@ InstructorContainer.propTypes = { title: PropTypes.string, }), idx: PropTypes.number.isRequired, - isEditable: PropTypes.bool, onDelete: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/introducing-section/extended-course-details/index.jsx b/src/schedule-and-details/introducing-section/extended-course-details/index.jsx index e9a0c54b7b..d413e887aa 100644 --- a/src/schedule-and-details/introducing-section/extended-course-details/index.jsx +++ b/src/schedule-and-details/introducing-section/extended-course-details/index.jsx @@ -11,7 +11,7 @@ const ExtendedCourseDetails = ({ subtitle, duration, description, - isEditable, + isEditable = true, onChange, }) => { const intl = useIntl(); @@ -76,7 +76,6 @@ ExtendedCourseDetails.defaultProps = { subtitle: '', duration: '', description: '', - isEditable: true, }; ExtendedCourseDetails.propTypes = { @@ -84,7 +83,6 @@ ExtendedCourseDetails.propTypes = { subtitle: PropTypes.string, duration: PropTypes.string, description: PropTypes.string, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/introducing-section/index.jsx b/src/schedule-and-details/introducing-section/index.jsx index 84e4cc529b..0c19787aae 100644 --- a/src/schedule-and-details/introducing-section/index.jsx +++ b/src/schedule-and-details/introducing-section/index.jsx @@ -30,7 +30,7 @@ const IntroducingSection = ({ shortDescriptionEditable, enableExtendedCourseDetails, videoThumbnailImageAssetPath, - isEditable, + isEditable = true, onChange, }) => { const intl = useIntl(); @@ -184,7 +184,6 @@ IntroducingSection.defaultProps = { bannerImageAssetPath: '', videoThumbnailImageAssetPath: '', overview: '', - isEditable: true, }; IntroducingSection.propTypes = { @@ -204,7 +203,6 @@ IntroducingSection.propTypes = { shortDescriptionEditable: PropTypes.bool.isRequired, enableExtendedCourseDetails: PropTypes.bool.isRequired, videoThumbnailImageAssetPath: PropTypes.string, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/introducing-section/introduction-video/index.jsx b/src/schedule-and-details/introducing-section/introduction-video/index.jsx index afc1968009..dd03213a6f 100644 --- a/src/schedule-and-details/introducing-section/introduction-video/index.jsx +++ b/src/schedule-and-details/introducing-section/introduction-video/index.jsx @@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -const IntroductionVideo = ({ introVideo, isEditable, onChange }) => { +const IntroductionVideo = ({ introVideo, isEditable = true, onChange }) => { const intl = useIntl(); const embedVideoUrl = introVideo ? `//www.youtube.com/embed/${introVideo}` @@ -55,12 +55,10 @@ const IntroductionVideo = ({ introVideo, isEditable, onChange }) => { IntroductionVideo.defaultProps = { introVideo: '', - isEditable: true, }; IntroductionVideo.propTypes = { introVideo: PropTypes.string, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/learning-outcomes-section/index.jsx b/src/schedule-and-details/learning-outcomes-section/index.jsx index 3965b6d268..5f6a50c358 100644 --- a/src/schedule-and-details/learning-outcomes-section/index.jsx +++ b/src/schedule-and-details/learning-outcomes-section/index.jsx @@ -7,7 +7,7 @@ import { Add as AddIcon } from '@openedx/paragon/icons'; import SectionSubHeader from '../../generic/section-sub-header'; import messages from './messages'; -const LearningOutcomesSection = ({ learningInfo, isEditable, onChange }) => { +const LearningOutcomesSection = ({ learningInfo, isEditable = true, onChange }) => { const intl = useIntl(); const handleInputChange = (value, index) => { @@ -69,12 +69,10 @@ const LearningOutcomesSection = ({ learningInfo, isEditable, onChange }) => { LearningOutcomesSection.defaultProps = { learningInfo: [], - isEditable: true, }; LearningOutcomesSection.propTypes = { learningInfo: PropTypes.arrayOf(PropTypes.string), - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/license-section/index.jsx b/src/schedule-and-details/license-section/index.jsx index d6cc1b7677..0ae49ab9bc 100644 --- a/src/schedule-and-details/license-section/index.jsx +++ b/src/schedule-and-details/license-section/index.jsx @@ -10,7 +10,7 @@ import { LICENSE_TYPE } from './constants'; import messages from './messages'; import { useLicenseDetails } from './hooks'; -const LicenseSection = ({ license, isEditable, onChange }) => { +const LicenseSection = ({ license, isEditable = true, onChange }) => { const intl = useIntl(); const { licenseURL, @@ -49,12 +49,10 @@ const LicenseSection = ({ license, isEditable, onChange }) => { LicenseSection.defaultProps = { license: null, - isEditable: true, }; LicenseSection.propTypes = { license: PropTypes.string, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/license-section/license-commons-options/index.jsx b/src/schedule-and-details/license-section/license-commons-options/index.jsx index ca86c6c4e8..974629e60f 100644 --- a/src/schedule-and-details/license-section/license-commons-options/index.jsx +++ b/src/schedule-and-details/license-section/license-commons-options/index.jsx @@ -12,7 +12,7 @@ import { import { LICENSE_COMMONS_OPTIONS } from '../constants'; import messages from './messages'; -const LicenseCommonsOptions = ({ licenseDetails, isEditable, onToggleCheckbox }) => { +const LicenseCommonsOptions = ({ licenseDetails, isEditable = true, onToggleCheckbox }) => { const optionDetails = [ { id: LICENSE_COMMONS_OPTIONS.attribution, @@ -89,10 +89,6 @@ const LicenseCommonsOptions = ({ licenseDetails, isEditable, onToggleCheckbox }) ); }; -LicenseCommonsOptions.defaultProps = { - isEditable: true, -}; - LicenseCommonsOptions.propTypes = { licenseDetails: PropTypes.shape({ attribution: PropTypes.bool.isRequired, @@ -100,7 +96,6 @@ LicenseCommonsOptions.propTypes = { noDerivatives: PropTypes.bool.isRequired, shareAlike: PropTypes.bool.isRequired, }).isRequired, - isEditable: PropTypes.bool, onToggleCheckbox: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/license-section/license-selector/index.jsx b/src/schedule-and-details/license-section/license-selector/index.jsx index e3dba91b6d..62e713b515 100644 --- a/src/schedule-and-details/license-section/license-selector/index.jsx +++ b/src/schedule-and-details/license-section/license-selector/index.jsx @@ -12,7 +12,7 @@ import { import { LICENSE_TYPE } from '../constants'; import messages from './messages'; -const LicenseSelector = ({ licenseType, isEditable, onChangeLicenseType }) => { +const LicenseSelector = ({ licenseType, isEditable = true, onChangeLicenseType }) => { const LICENSE_BUTTON_GROUP_LABELS = { [LICENSE_TYPE.allRightsReserved]: { label: , @@ -58,12 +58,10 @@ const LicenseSelector = ({ licenseType, isEditable, onChangeLicenseType }) => { LicenseSelector.defaultProps = { licenseType: null, - isEditable: true, }; LicenseSelector.propTypes = { licenseType: PropTypes.oneOf(Object.values(LICENSE_TYPE)), - isEditable: PropTypes.bool, onChangeLicenseType: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/pacing-section/index.jsx b/src/schedule-and-details/pacing-section/index.jsx index a0dd5814f8..ed97f7cd1a 100644 --- a/src/schedule-and-details/pacing-section/index.jsx +++ b/src/schedule-and-details/pacing-section/index.jsx @@ -10,7 +10,7 @@ const PacingSection = ({ selfPaced, startDate, onChange, - isEditable, + isEditable = true, }) => { const intl = useIntl(); const canTogglePace = new Date() <= new Date(startDate); @@ -55,13 +55,11 @@ const PacingSection = ({ PacingSection.defaultProps = { selfPaced: '', startDate: '', - isEditable: true, }; PacingSection.propTypes = { startDate: PropTypes.string, selfPaced: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/requirements-section/entrance-exam/index.jsx b/src/schedule-and-details/requirements-section/entrance-exam/index.jsx index 0fe41fbe6c..7f101891c7 100644 --- a/src/schedule-and-details/requirements-section/entrance-exam/index.jsx +++ b/src/schedule-and-details/requirements-section/entrance-exam/index.jsx @@ -12,7 +12,7 @@ const EntranceExam = ({ errorEffort, isCheckedString, entranceExamMinimumScorePct, - isEditable, + isEditable = true, onChange, }) => { const { courseId } = useParams(); @@ -79,14 +79,12 @@ EntranceExam.defaultProps = { errorEffort: '', isCheckedString: '', entranceExamMinimumScorePct: '', - isEditable: true, }; EntranceExam.propTypes = { errorEffort: PropTypes.string, isCheckedString: PropTypes.string, entranceExamMinimumScorePct: PropTypes.string, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/requirements-section/grade-requirements/index.jsx b/src/schedule-and-details/requirements-section/grade-requirements/index.jsx index 8a13e7c0c0..7ab7cef1b6 100644 --- a/src/schedule-and-details/requirements-section/grade-requirements/index.jsx +++ b/src/schedule-and-details/requirements-section/grade-requirements/index.jsx @@ -9,7 +9,7 @@ import messages from './messages'; const GradeRequirements = ({ errorEffort, entranceExamMinimumScorePct, - isEditable, + isEditable = true, onChange, }) => ( { const intl = useIntl(); @@ -113,7 +113,6 @@ RequirementsSection.defaultProps = { entranceExamEnabled: '', preRequisiteCourses: [], entranceExamMinimumScorePct: '', - isEditable: true, }; RequirementsSection.propTypes = { @@ -128,7 +127,6 @@ RequirementsSection.propTypes = { ).isRequired, entranceExamMinimumScorePct: PropTypes.string, isPrerequisiteCoursesEnabled: PropTypes.bool.isRequired, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx index 44117fba91..fb9571eeaa 100644 --- a/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx +++ b/src/schedule-and-details/schedule-section/certificate-display-row/CertificateDisplayRow.jsx @@ -24,7 +24,7 @@ const CertificateDisplayRow = ({ availableDateErrorFeedback, certificatesDisplayBehavior, displayBehaviorErrorFeedback, - isEditable, + isEditable = true, onChange, }) => { const intl = useIntl(); @@ -182,7 +182,6 @@ CertificateDisplayRow.defaultProps = { availableDateErrorFeedback: '', certificatesDisplayBehavior: '', displayBehaviorErrorFeedback: '', - isEditable: true, }; CertificateDisplayRow.propTypes = { @@ -190,7 +189,6 @@ CertificateDisplayRow.propTypes = { availableDateErrorFeedback: PropTypes.string, certificatesDisplayBehavior: PropTypes.string, displayBehaviorErrorFeedback: PropTypes.string, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/schedule-section/index.jsx b/src/schedule-and-details/schedule-section/index.jsx index 9d57773695..f6cf4b7b5c 100644 --- a/src/schedule-and-details/schedule-section/index.jsx +++ b/src/schedule-and-details/schedule-section/index.jsx @@ -19,7 +19,7 @@ const ScheduleSection = ({ certificateAvailableDate, certificatesDisplayBehavior, canShowCertificateAvailableDateField, - isEditable, + isEditable = true, onChange, }) => { const intl = useIntl(); @@ -156,7 +156,6 @@ ScheduleSection.defaultProps = { enrollmentStart: '', upgradeDeadline: '', certificateAvailableDate: '', - isEditable: true, }; ScheduleSection.propTypes = { @@ -171,7 +170,6 @@ ScheduleSection.propTypes = { certificateAvailableDate: PropTypes.string, certificatesDisplayBehavior: PropTypes.string.isRequired, canShowCertificateAvailableDateField: PropTypes.bool.isRequired, - isEditable: PropTypes.bool, onChange: PropTypes.func.isRequired, }; From 3f4bc3d9eb7789477f7e08146fe1446e85adbf97 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 30 Apr 2026 15:27:01 -0500 Subject: [PATCH 4/5] refactor: streamline permission handling in ScheduleAndDetails component and tests --- .../ScheduleAndDetails.test.jsx | 50 ++++++------------- src/schedule-and-details/index.jsx | 21 ++++---- .../RequirementsSection.test.jsx | 6 +-- 3 files changed, 26 insertions(+), 51 deletions(-) diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index c349c4c16d..784141ad94 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -27,13 +27,21 @@ import ScheduleAndDetails from '.'; jest.mock('@src/authz/hooks', () => ({ useCourseUserPermissions: jest.fn().mockReturnValue({ isLoading: false, - isAuthzEnabled: true, canViewScheduleAndDetails: true, canEditSchedule: true, canEditDetails: true, }), })); +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: true, + ...overrides, + }); + let axiosMock; let store; const courseId = '123'; @@ -191,13 +199,7 @@ describe(' permissions', () => { axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock); axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock); axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200); - jest.mocked(useCourseUserPermissions).mockReturnValue({ - isLoading: false, - isAuthzEnabled: true, - canViewScheduleAndDetails: true, - canEditSchedule: true, - canEditDetails: true, - }); + mockPermissions(); }); it('renders normally when authz flag is disabled (no regression)', async () => { @@ -218,13 +220,7 @@ describe(' permissions', () => { it('shows PermissionDeniedAlert when user lacks view permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useCourseUserPermissions).mockReturnValue({ - isLoading: false, - isAuthzEnabled: true, - canViewScheduleAndDetails: false, - canEditSchedule: false, - canEditDetails: false, - }); + mockPermissions({ canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false }); const { getByTestId } = renderComponent(); await waitFor(() => { expect(getByTestId('permissionDeniedAlert')).toBeInTheDocument(); @@ -233,13 +229,7 @@ describe(' permissions', () => { it('disables schedule date inputs when user lacks edit_schedule permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useCourseUserPermissions).mockReturnValue({ - isLoading: false, - isAuthzEnabled: true, - canViewScheduleAndDetails: true, - canEditSchedule: false, - canEditDetails: true, - }); + mockPermissions({ canEditSchedule: false }); const { getAllByPlaceholderText } = renderComponent(); await waitFor(() => { const dateInputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()); @@ -249,13 +239,7 @@ describe(' permissions', () => { it('disables pacing and details inputs when user lacks edit_details permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useCourseUserPermissions).mockReturnValue({ - isLoading: false, - isAuthzEnabled: true, - canViewScheduleAndDetails: true, - canEditSchedule: true, - canEditDetails: false, - }); + mockPermissions({ canEditDetails: false }); const { getAllByRole } = renderComponent(); await waitFor(() => { const radios = getAllByRole('radio'); @@ -265,13 +249,7 @@ describe(' permissions', () => { it('save button cannot be triggered when user has no edit permissions', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); - jest.mocked(useCourseUserPermissions).mockReturnValue({ - isLoading: false, - isAuthzEnabled: true, - canViewScheduleAndDetails: true, - canEditSchedule: false, - canEditDetails: false, - }); + mockPermissions({ canEditSchedule: false, canEditDetails: false }); const { getAllByPlaceholderText, queryByText } = renderComponent(); // Wait for page to load const dateInputs = await waitFor(() => getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase())); diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index 4bbb995563..7df72729d4 100644 --- a/src/schedule-and-details/index.jsx +++ b/src/schedule-and-details/index.jsx @@ -176,8 +176,7 @@ const ScheduleAndDetails = () => { ); } - const isScheduleEditable = canEditSchedule; - const isDetailsEditable = canEditDetails; + const canEdit = canEditSchedule || canEditDetails; const showCreditSection = creditEligibilityEnabled && isCreditCourse; const showRequirementsSection = aboutPageEditable || isPrerequisiteCoursesEnabled || isEntranceExamsEnabled; @@ -277,7 +276,7 @@ const ScheduleAndDetails = () => { { certificateAvailableDate={certificateAvailableDate} certificatesDisplayBehavior={certificatesDisplayBehavior} canShowCertificateAvailableDateField={canShowCertificateAvailableDateField} - isEditable={isScheduleEditable} + isEditable={canEditSchedule} onChange={handleValuesChange} /> {aboutPageEditable && ( )} @@ -320,19 +319,19 @@ const ScheduleAndDetails = () => { shortDescriptionEditable={shortDescriptionEditable} enableExtendedCourseDetails={enableExtendedCourseDetails} videoThumbnailImageAssetPath={videoThumbnailImageAssetPath} - isEditable={isDetailsEditable} + isEditable={canEditDetails} onChange={handleValuesChange} /> {enableExtendedCourseDetails && ( <> @@ -348,14 +347,14 @@ const ScheduleAndDetails = () => { possiblePreRequisiteCourses={possiblePreRequisiteCourses} entranceExamMinimumScorePct={entranceExamMinimumScorePct} isPrerequisiteCoursesEnabled={isPrerequisiteCoursesEnabled} - isEditable={isDetailsEditable} + isEditable={canEditDetails} onChange={handleValuesChange} /> )} {licensingEnabled && ( )} @@ -403,7 +402,7 @@ const ScheduleAndDetails = () => { ', () => { }); it('disables effort input and prerequisite dropdown when isEditable is false', () => { - const { getByDisplayValue, getByRole } = render(); + const { container, getByDisplayValue } = render(); expect(getByDisplayValue(props.effort)).toBeDisabled(); - expect(getByRole('button', { name: messages.dropdownLabel.defaultMessage, hidden: true })).toBeDefined(); - const dropdown = getByRole('button', { name: new RegExp(props.preRequisiteCourses[0] || '', 'i'), hidden: true }); - expect(dropdown || getByRole('button', { name: messages.dropdownEmptyText?.defaultMessage || '' })).toBeDefined(); + expect(container.querySelector('#prerequisiteDropdown')).toBeDisabled(); }); it('enables effort input when isEditable is true', () => { From 65ff3ffdec26ff39e0ca21c239c5e3049babfb3f Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 30 Apr 2026 15:40:56 -0500 Subject: [PATCH 5/5] fix: ensure isAuthzEnabled is consistently set in permissions mock --- src/schedule-and-details/ScheduleAndDetails.test.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index 784141ad94..15711d606a 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -27,6 +27,7 @@ import ScheduleAndDetails from '.'; jest.mock('@src/authz/hooks', () => ({ useCourseUserPermissions: jest.fn().mockReturnValue({ isLoading: false, + isAuthzEnabled: true, canViewScheduleAndDetails: true, canEditSchedule: true, canEditDetails: true, @@ -36,6 +37,7 @@ jest.mock('@src/authz/hooks', () => ({ const mockPermissions = (overrides = {}) => jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, + isAuthzEnabled: true, canViewScheduleAndDetails: true, canEditSchedule: true, canEditDetails: true,