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..876d49f53a 100644 --- a/src/generic/WysiwygEditor.jsx +++ b/src/generic/WysiwygEditor.jsx @@ -14,6 +14,7 @@ export const WysiwygEditor = ({ editorType, onChange, minHeight, + disabled = false, }) => { const { editorRef, refReady, setEditorRef } = prepareEditorRef(); const { courseId } = useCourseAuthoringContext(); @@ -64,6 +65,7 @@ export const WysiwygEditor = ({ images={{}} enableImageUpload={false} onEditorChange={() => ({})} + disabled={disabled} /> ); }; diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx index 387b3ae1c2..aaf4489163 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 = false, onChange, }) => { const { courseId } = useParams(); @@ -113,13 +114,16 @@ const CourseUploadImage = ({ {label} - +
+ +
{showImageBodyText && cardImageTextBody}
@@ -131,6 +135,7 @@ const CourseUploadImage = ({ || intl.formatMessage(messages.uploadImageInputPlaceholder, { identifierFieldText, })} + disabled={disabled} />
diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index f1801b53e8..15711d606a 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 { useCourseUserPermissions } from '@src/authz/hooks'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { courseDetailsMock, courseSettingsMock } from './__mocks__'; @@ -22,6 +24,26 @@ import scheduleMessages from './schedule-section/messages'; import messages from './messages'; 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, + isAuthzEnabled: true, + canViewScheduleAndDetails: true, + canEditSchedule: true, + canEditDetails: true, + ...overrides, + }); + let axiosMock; let store; const courseId = '123'; @@ -169,3 +191,73 @@ 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); + mockPermissions(); + }); + + 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 }); + mockPermissions({ 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 }); + mockPermissions({ canEditSchedule: false }); + 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 }); + mockPermissions({ 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 }); + mockPermissions({ 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..f4099849da 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 = true, }) => { 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]} diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx index 2dcc26d1f8..7df72729d4 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, @@ -152,6 +164,10 @@ const ScheduleAndDetails = () => { return <>; } + if (!canViewScheduleAndDetails) { + return ; + } + if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) { return (
@@ -160,6 +176,8 @@ const ScheduleAndDetails = () => { ); } + const canEdit = canEditSchedule || canEditDetails; + const showCreditSection = creditEligibilityEnabled && isCreditCourse; const showRequirementsSection = aboutPageEditable || isPrerequisiteCoursesEnabled || isEntranceExamsEnabled; const hasErrors = !!Object.keys(errorFields).length; @@ -258,6 +276,7 @@ const ScheduleAndDetails = () => { { certificateAvailableDate={certificateAvailableDate} certificatesDisplayBehavior={certificatesDisplayBehavior} canShowCertificateAvailableDateField={canShowCertificateAvailableDateField} + isEditable={canEditSchedule} onChange={handleValuesChange} /> {aboutPageEditable && ( )} @@ -298,16 +319,19 @@ const ScheduleAndDetails = () => { shortDescriptionEditable={shortDescriptionEditable} enableExtendedCourseDetails={enableExtendedCourseDetails} videoThumbnailImageAssetPath={videoThumbnailImageAssetPath} + isEditable={canEditDetails} onChange={handleValuesChange} /> {enableExtendedCourseDetails && ( <> @@ -323,12 +347,14 @@ const ScheduleAndDetails = () => { possiblePreRequisiteCourses={possiblePreRequisiteCourses} entranceExamMinimumScorePct={entranceExamMinimumScorePct} isPrerequisiteCoursesEnabled={isPrerequisiteCoursesEnabled} + isEditable={canEditDetails} onChange={handleValuesChange} /> )} {licensingEnabled && ( )} @@ -376,7 +402,7 @@ const ScheduleAndDetails = () => { ', () => { }], }, '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..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, onChange }) => { +const InstructorsSection = ({ instructors, isEditable = true, 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} /> ))} - 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..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,6 +17,7 @@ const InstructorContainer = ({ idx, onDelete, onChange, + isEditable = true, }) => { 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 = ({ - 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..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,6 +11,7 @@ const ExtendedCourseDetails = ({ subtitle, duration, description, + isEditable = true, 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} diff --git a/src/schedule-and-details/introducing-section/index.jsx b/src/schedule-and-details/introducing-section/index.jsx index 1eb87aff7c..0c19787aae 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 = true, 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 && } ); }; 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..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, onChange }) => { +const IntroductionVideo = ({ introVideo, isEditable = true, 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} /> 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..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, onChange }) => { +const LearningOutcomesSection = ({ learningInfo, isEditable = true, 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)}
- 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..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, onChange }) => { +const LicenseSection = ({ license, isEditable = true, onChange }) => { const intl = useIntl(); const { licenseURL, @@ -28,11 +28,13 @@ const LicenseSection = ({ license, onChange }) => { /> {licenseType === LICENSE_TYPE.creativeCommons && ( )} 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..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, onToggleCheckbox }) => { +const LicenseCommonsOptions = ({ licenseDetails, isEditable = true, 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 }) => { 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..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, onChangeLicenseType }) => { +const LicenseSelector = ({ licenseType, isEditable = true, onChangeLicenseType }) => { const LICENSE_BUTTON_GROUP_LABELS = { [LICENSE_TYPE.allRightsReserved]: { label: , @@ -35,6 +35,7 @@ const LicenseSelector = ({ licenseType, onChangeLicenseType }) => { 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..ed97f7cd1a 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 = true, }) => { const intl = useIntl(); const canTogglePace = new Date() <= new Date(startDate); @@ -34,14 +35,14 @@ const PacingSection = ({ {intl.formatMessage(messages.pacingTypeInstructorLabel)} {intl.formatMessage(messages.pacingTypeSelfLabel)} diff --git a/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx b/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx index c9a7a6feb0..4534b4ae61 100644 --- a/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx +++ b/src/schedule-and-details/requirements-section/RequirementsSection.test.jsx @@ -95,4 +95,15 @@ 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 { container, getByDisplayValue } = render(); + expect(getByDisplayValue(props.effort)).toBeDisabled(); + expect(container.querySelector('#prerequisiteDropdown')).toBeDisabled(); + }); + + 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..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,6 +12,7 @@ const EntranceExam = ({ errorEffort, isCheckedString, entranceExamMinimumScorePct, + isEditable = true, onChange, }) => { const { courseId } = useParams(); @@ -33,6 +34,7 @@ const EntranceExam = ({ @@ -62,6 +64,7 @@ const EntranceExam = ({ 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..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,6 +9,7 @@ import messages from './messages'; const GradeRequirements = ({ errorEffort, entranceExamMinimumScorePct, + isEditable = true, onChange, }) => ( onChange(e.target.value, 'entranceExamMinimumScorePct')} trailingElement="%" + disabled={!isEditable} /> {errorEffort && ( diff --git a/src/schedule-and-details/requirements-section/index.jsx b/src/schedule-and-details/requirements-section/index.jsx index c7635c10ca..3fe8dc46a7 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 = true, 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} /> )} 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..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,6 +24,7 @@ const CertificateDisplayRow = ({ availableDateErrorFeedback, certificatesDisplayBehavior, displayBehaviorErrorFeedback, + isEditable = true, onChange, }) => { const intl = useIntl(); @@ -129,12 +130,17 @@ const CertificateDisplayRow = ({ {intl.formatMessage(messages.certificateBehaviorLabel)} - + {certificateDisplayValue} {dropdownOptions.map(({ id, label }) => ( - handleOnChange(id)}> + handleOnChange(id) + : undefined} + > {label} ))} @@ -151,6 +157,7 @@ const CertificateDisplayRow = ({ onChange={(date) => onChange(date, 'certificateAvailableDate')} isInvalid={!!availableDateErrorFeedback} controlName="certificateAvailableDate" + readonly={!isEditable} /> )} {availableDateErrorFeedback && ( 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..f6cf4b7b5c 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 = true, 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 = ({ );