From 46579552a495186f7bc387400a4a6628f6046f82 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 4 Jun 2026 09:21:59 -0500 Subject: [PATCH 1/8] feat: add view-only alert for course updates permissions --- src/course-updates/CourseUpdates.tsx | 27 ++++++++++++++++++--------- src/course-updates/messages.ts | 5 +++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/course-updates/CourseUpdates.tsx b/src/course-updates/CourseUpdates.tsx index 01727fb836..9db1d660b2 100644 --- a/src/course-updates/CourseUpdates.tsx +++ b/src/course-updates/CourseUpdates.tsx @@ -2,6 +2,7 @@ import { Helmet } from 'react-helmet'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, + Alert, Button, Container, Layout, @@ -155,7 +156,13 @@ const CourseUpdates = () => { ) : null} + hideBorder={canViewCourseUpdates &&!canManageCourseUpdates} /> + {canViewCourseUpdates && !canManageCourseUpdates && ( + + {intl.formatMessage(messages.viewOnlyAlert)} + + )}
{isMainFormOpen && ( { {intl.formatMessage(messages.noCourseUpdates)} - + {canManageCourseUpdates && ( + + )} )} diff --git a/src/course-updates/messages.ts b/src/course-updates/messages.ts index a148972aec..c118dfa630 100644 --- a/src/course-updates/messages.ts +++ b/src/course-updates/messages.ts @@ -86,6 +86,11 @@ const messages = defineMessages({ defaultMessage: 'Failed to delete selected course update. Please try again later.', description: 'Alert body message for deleting course update errors', }, + viewOnlyAlert: { + id: 'course-authoring.course-updates.alert.view-only', + defaultMessage: 'You have view-only access to this page. Contact your organization admin to request permissions.', + description: 'Alert shown to users who can view but cannot manage course updates', + }, }); export default messages; From 5330f9ae588a1ba149d745303845065131229ab8 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 7 Aug 2026 19:49:30 +1000 Subject: [PATCH 2/8] refactor: move view permission only alert to generic folder for reuse --- src/course-updates/CourseUpdates.test.tsx | 41 +++++++++++++++++++ src/course-updates/CourseUpdates.tsx | 10 ++--- src/course-updates/messages.ts | 5 --- src/generic/ViewOnlyPermissionsAlert.test.tsx | 30 ++++++++++++++ src/generic/ViewOnlyPermissionsAlert.tsx | 26 ++++++++++++ 5 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 src/generic/ViewOnlyPermissionsAlert.test.tsx create mode 100644 src/generic/ViewOnlyPermissionsAlert.tsx diff --git a/src/course-updates/CourseUpdates.test.tsx b/src/course-updates/CourseUpdates.test.tsx index 559ccc088e..c3bc141647 100644 --- a/src/course-updates/CourseUpdates.test.tsx +++ b/src/course-updates/CourseUpdates.test.tsx @@ -413,6 +413,25 @@ describe('', () => { expect(screen.getByText('Are you sure you want to delete this update?')).toBeInTheDocument(); }); + + it('should render the "Add first update" button when there are no updates', async () => { + axiosMock.resetHandlers(); + axiosMock.onGet(getCourseUpdatesApiUrl(courseId)).reply(200, []); + axiosMock.onGet(getCourseHandoutApiUrl(courseId)).reply(200, courseHandoutsMock); + + render(); + + expect( + await screen.findByRole('button', { name: messages.firstUpdateButton.defaultMessage }), + ).toBeInTheDocument(); + }); + + it('should NOT render the view-only alert', async () => { + render(); + + expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByTestId('viewOnlyPermissionsAlert')).not.toBeInTheDocument(); + }); }); describe('when user does NOT have permission to manage course updates and enableAuthzCourseAuthoring is enabled', () => { @@ -455,6 +474,28 @@ describe('', () => { expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); }); }); + + it('should NOT render the "Add first update" button when there are no updates', async () => { + axiosMock.resetHandlers(); + axiosMock.onGet(getCourseUpdatesApiUrl(courseId)).reply(200, []); + axiosMock.onGet(getCourseHandoutApiUrl(courseId)).reply(200, courseHandoutsMock); + + render(); + + expect(await screen.findByText(messages.noCourseUpdates.defaultMessage)).toBeVisible(); + expect( + screen.queryByRole('button', { name: messages.firstUpdateButton.defaultMessage }), + ).not.toBeInTheDocument(); + }); + + it('should render the view-only alert', async () => { + render(); + + expect(await screen.findByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + expect(screen.getByText( + 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + )).toBeInTheDocument(); + }); }); describe('when enableAuthzCourseAuthoring is disabled', () => { diff --git a/src/course-updates/CourseUpdates.tsx b/src/course-updates/CourseUpdates.tsx index 9db1d660b2..6b4a944357 100644 --- a/src/course-updates/CourseUpdates.tsx +++ b/src/course-updates/CourseUpdates.tsx @@ -2,7 +2,6 @@ import { Helmet } from 'react-helmet'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, - Alert, Button, Container, Layout, @@ -13,6 +12,7 @@ import InternetConnectionAlert from '@src/generic/internet-connection-alert'; import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import CourseHandouts from './course-handouts/CourseHandouts'; import CourseUpdate from './course-update/CourseUpdate'; import DeleteModal from './delete-modal/DeleteModal'; @@ -156,13 +156,9 @@ const CourseUpdates = () => { ) : null} - hideBorder={canViewCourseUpdates &&!canManageCourseUpdates} + hideBorder={!canManageCourseUpdates} /> - {canViewCourseUpdates && !canManageCourseUpdates && ( - - {intl.formatMessage(messages.viewOnlyAlert)} - - )} + {!canManageCourseUpdates && }
{isMainFormOpen && ( ', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders the view-only message', () => { + render(); + + expect(screen.getByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + expect(screen.getByText( + 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + )).toBeInTheDocument(); + }); + + it('renders a lock icon', () => { + const { container } = render(); + + expect(container.querySelector('.alert-icon svg')).toBeInTheDocument(); + }); + + it('applies a passed className', () => { + render(); + + expect(screen.getByTestId('viewOnlyPermissionsAlert')).toHaveClass('mt-4'); + }); +}); diff --git a/src/generic/ViewOnlyPermissionsAlert.tsx b/src/generic/ViewOnlyPermissionsAlert.tsx new file mode 100644 index 0000000000..84f9a80051 --- /dev/null +++ b/src/generic/ViewOnlyPermissionsAlert.tsx @@ -0,0 +1,26 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert } from '@openedx/paragon'; +import { Lock } from '@openedx/paragon/icons'; + +interface Props { + className?: string; +} + +/** + * Informational banner for users who hold a page's `view` permission but not its + * `manage`/`edit` permission, so the page renders read-only. + * + * Pair it with hiding the page's actionable controls -- this alert explains the + * absence of those controls, it does not enforce anything on its own. + */ +const ViewOnlyPermissionsAlert = ({ className }: Props) => ( + + + +); + +export default ViewOnlyPermissionsAlert; From 5a4f4606821792b721fa7946cae0a5143b65cc41 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Thu, 4 Jun 2026 11:14:59 -0500 Subject: [PATCH 3/8] feat: add view-only alerts for schedule and details permissions --- src/schedule-and-details/index.tsx | 8 ++++++++ src/schedule-and-details/messages.ts | 10 ++++++++++ src/schedule-and-details/schedule-section/index.jsx | 9 +++++++++ src/schedule-and-details/schedule-section/messages.ts | 5 +++++ 4 files changed, 32 insertions(+) diff --git a/src/schedule-and-details/index.tsx b/src/schedule-and-details/index.tsx index 7c1403c34f..02f390f71e 100644 --- a/src/schedule-and-details/index.tsx +++ b/src/schedule-and-details/index.tsx @@ -1,4 +1,5 @@ import { + Alert, Container, Button, Layout, @@ -7,6 +8,7 @@ import { import { CheckCircle as CheckCircleIcon, ErrorOutline as ErrorOutlineIcon, + Lock as LockIcon, Warning as WarningIcon, } from '@openedx/paragon/icons'; import { CourseSettingsData } from '@src/data/api'; @@ -243,6 +245,11 @@ const ScheduleAndDetails = () => { {intl.formatMessage(messages.headingTitle)} + {!canEditSchedule && !canEditDetails && ( + + {intl.formatMessage(messages.viewOnlyAlert)} + + )}
{ certificatesDisplayBehavior={certificatesDisplayBehavior} canShowCertificateAvailableDateField={canShowCertificateAvailableDateField} isEditable={canEditSchedule} + showReadOnlyAlert={!canEditSchedule && canEditDetails} onChange={handleValuesChange} /> {aboutPageEditable && ( diff --git a/src/schedule-and-details/messages.ts b/src/schedule-and-details/messages.ts index b2ba0533d4..1b66700d1d 100644 --- a/src/schedule-and-details/messages.ts +++ b/src/schedule-and-details/messages.ts @@ -81,6 +81,16 @@ const messages = defineMessages({ id: 'course-authoring.schedule.alert.fail', defaultMessage: 'We encountered an error when saving your changes.', }, + viewOnlyAlert: { + id: 'course-authoring.schedule.alert.view-only', + defaultMessage: 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + description: 'Alert shown to users who can view but cannot edit schedule or details', + }, + viewOnlyAlert: { + id: 'course-authoring.schedule.alert.view-only', + defaultMessage: 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + description: 'Alert shown to users who can view but cannot edit schedule or details', + }, errorMessage1: { id: 'course-authoring.schedule.schedule-section.error-message-1', defaultMessage: 'The certificates display behavior must be \'A date after the course end date\' if certificate available date is set.', diff --git a/src/schedule-and-details/schedule-section/index.jsx b/src/schedule-and-details/schedule-section/index.jsx index f6cf4b7b5c..681784ef26 100644 --- a/src/schedule-and-details/schedule-section/index.jsx +++ b/src/schedule-and-details/schedule-section/index.jsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { Alert } from '@openedx/paragon'; +import { Lock as LockIcon } from '@openedx/paragon/icons'; import SectionSubHeader from '../../generic/section-sub-header'; import { ScheduleRow, SCHEDULE_ROW_TYPES } from './schedule-row'; @@ -20,6 +22,7 @@ const ScheduleSection = ({ certificatesDisplayBehavior, canShowCertificateAvailableDateField, isEditable = true, + showReadOnlyAlert = false, onChange, }) => { const intl = useIntl(); @@ -119,6 +122,11 @@ const ScheduleSection = ({ title={intl.formatMessage(messages.scheduleTitle)} description={intl.formatMessage(messages.scheduleDescription)} /> + {showReadOnlyAlert && ( + + {intl.formatMessage(messages.scheduleReadOnlyAlert)} + + )}
    {propsForScheduleFields .filter((field) => !field.skip) @@ -170,6 +178,7 @@ ScheduleSection.propTypes = { certificateAvailableDate: PropTypes.string, certificatesDisplayBehavior: PropTypes.string.isRequired, canShowCertificateAvailableDateField: PropTypes.bool.isRequired, + showReadOnlyAlert: PropTypes.bool, onChange: PropTypes.func.isRequired, }; diff --git a/src/schedule-and-details/schedule-section/messages.ts b/src/schedule-and-details/schedule-section/messages.ts index aa3da3da71..1c508d2b9c 100644 --- a/src/schedule-and-details/schedule-section/messages.ts +++ b/src/schedule-and-details/schedule-section/messages.ts @@ -73,6 +73,11 @@ const messages = defineMessages({ id: 'course-authoring.schedule.schedule-section.upgrade-deadline.time.label', defaultMessage: 'Upgrade deadline time', }, + scheduleReadOnlyAlert: { + id: 'course-authoring.schedule.schedule-section.read-only-alert', + defaultMessage: 'You don\'t have permission to edit the "Course Schedule". Contact your organization admin to request access.', + description: 'Alert shown inside the Course Schedule section when the user cannot edit schedule fields', + }, }); export default messages; From 96a30ebbee4d1fdfe86ab4b8b322c1ff01fa8b4a Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 7 Aug 2026 21:09:29 +1000 Subject: [PATCH 4/8] refactor: use the general alert --- src/generic/ViewOnlyPermissionsAlert.tsx | 18 ++++++---- .../ScheduleAndDetails.test.tsx | 34 +++++++++++++++++++ src/schedule-and-details/index.tsx | 9 ++--- src/schedule-and-details/messages.ts | 10 ------ .../schedule-section/index.jsx | 8 ++--- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/generic/ViewOnlyPermissionsAlert.tsx b/src/generic/ViewOnlyPermissionsAlert.tsx index 84f9a80051..8579967dbc 100644 --- a/src/generic/ViewOnlyPermissionsAlert.tsx +++ b/src/generic/ViewOnlyPermissionsAlert.tsx @@ -4,6 +4,7 @@ import { Lock } from '@openedx/paragon/icons'; interface Props { className?: string; + children?: React.ReactNode; } /** @@ -12,14 +13,19 @@ interface Props { * * Pair it with hiding the page's actionable controls -- this alert explains the * absence of those controls, it does not enforce anything on its own. + * + * Pass `children` when the read-only scope is narrower than the whole page (a + * single section, say) and needs copy that says so. */ -const ViewOnlyPermissionsAlert = ({ className }: Props) => ( +const ViewOnlyPermissionsAlert = ({ className, children }: Props) => ( - + {children ?? ( + + )} ); diff --git a/src/schedule-and-details/ScheduleAndDetails.test.tsx b/src/schedule-and-details/ScheduleAndDetails.test.tsx index 35287ccbff..fc0acffc95 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.tsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.tsx @@ -215,6 +215,26 @@ describe(' permissions', () => { dateInputs.forEach((input) => expect(input).toBeDisabled()); }); + it('shows the schedule section alert, not the page alert, when only edit_schedule is missing', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + mockPermissions({ canEditSchedule: false }); + renderComponent(); + expect( + await screen.findByText(scheduleMessages.scheduleReadOnlyAlert.defaultMessage), + ).toBeInTheDocument(); + expect(screen.queryByText( + 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + )).not.toBeInTheDocument(); + }); + + it('shows no read-only alert when only edit_details is missing', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + mockPermissions({ canEditDetails: false }); + renderComponent(); + expect((await screen.findAllByText(messages.headingTitle.defaultMessage)).length).toBeGreaterThan(0); + expect(screen.queryByTestId('viewOnlyPermissionsAlert')).not.toBeInTheDocument(); + }); + it('disables pacing and details inputs when user lacks edit_details permission', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); mockPermissions({ canEditDetails: false }); @@ -234,4 +254,18 @@ describe(' permissions', () => { // No changes can be made so the save button never appears expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument(); }); + + it('shows the page-level view-only alert when user has no edit permissions', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + mockPermissions({ canEditSchedule: false, canEditDetails: false }); + renderComponent(); + expect(await screen.findByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + expect(screen.getByText( + 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + )).toBeInTheDocument(); + // The page-level alert stands in for the section-level one + expect( + screen.queryByText(scheduleMessages.scheduleReadOnlyAlert.defaultMessage), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/schedule-and-details/index.tsx b/src/schedule-and-details/index.tsx index 02f390f71e..bd58bd446b 100644 --- a/src/schedule-and-details/index.tsx +++ b/src/schedule-and-details/index.tsx @@ -1,5 +1,4 @@ import { - Alert, Container, Button, Layout, @@ -8,7 +7,6 @@ import { import { CheckCircle as CheckCircleIcon, ErrorOutline as ErrorOutlineIcon, - Lock as LockIcon, Warning as WarningIcon, } from '@openedx/paragon/icons'; import { CourseSettingsData } from '@src/data/api'; @@ -24,6 +22,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useCourseUserPermissions } from '@src/authz/hooks'; import { getScheduleAndDetailsPermissions } from '@src/authz/permissionHelpers'; import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import BasicSection from './basic-section'; import CreditSection from './credit-section'; @@ -245,11 +244,7 @@ const ScheduleAndDetails = () => { {intl.formatMessage(messages.headingTitle)} - {!canEditSchedule && !canEditDetails && ( - - {intl.formatMessage(messages.viewOnlyAlert)} - - )} + {!canEdit && }
    {showReadOnlyAlert && ( - + {intl.formatMessage(messages.scheduleReadOnlyAlert)} - + )}
      {propsForScheduleFields From 54498839abc42e731b63f604f1d567b078916400 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 5 Jun 2026 15:39:55 -0500 Subject: [PATCH 5/8] feat: add view-only alert and improve grading settings permissions handling --- src/generic/sub-header/SubHeader.tsx | 3 +++ src/grading-settings/GradingSettings.jsx | 25 ++++++++++++------- .../assignment-section/index.jsx | 19 +++++++------- src/grading-settings/messages.ts | 5 ++++ 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/generic/sub-header/SubHeader.tsx b/src/generic/sub-header/SubHeader.tsx index 95b1801fcc..44eae36bd4 100644 --- a/src/generic/sub-header/SubHeader.tsx +++ b/src/generic/sub-header/SubHeader.tsx @@ -10,6 +10,7 @@ interface SubHeaderProps { instruction?: ReactElement | string; headerActions?: ReactElement | ReactElement[] | null; titleActions?: ReactElement | ReactElement[] | null; + banner?: ReactElement | null; hideBorder?: boolean; withSubHeaderContent?: boolean; } @@ -23,6 +24,7 @@ const SubHeader = ({ instruction, headerActions, titleActions, + banner = null, hideBorder = false, withSubHeaderContent = true, }: SubHeaderProps) => ( @@ -44,6 +46,7 @@ const SubHeader = ({ )} + {banner} {contentTitle && withSubHeaderContent && (

      {contentTitle}

      diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 20cf200d51..0e7fa3f7d7 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -1,11 +1,12 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { + Alert, Button, Container, Layout, StatefulButton, } from '@openedx/paragon'; -import { Add as IconAdd, CheckCircle, Warning } from '@openedx/paragon/icons'; +import { Add as IconAdd, CheckCircle, Lock as LockIcon, Warning } from '@openedx/paragon/icons'; import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; @@ -179,6 +180,11 @@ const GradingSettings = () => { subtitle={intl.formatMessage(messages.headingSubtitle)} contentTitle={intl.formatMessage(messages.policy)} description={intl.formatMessage(messages.policiesDescription)} + banner={!isEditable ? ( + + {intl.formatMessage(messages.viewOnlyAlert)} + + ) : null} />
      { setShowSuccessAlert={setShowSuccessAlert} isEditable={isEditable} /> - + {isEditable && ( + + )}
      diff --git a/src/grading-settings/assignment-section/index.jsx b/src/grading-settings/assignment-section/index.jsx index 84ebb15e20..62b89a0a2d 100644 --- a/src/grading-settings/assignment-section/index.jsx +++ b/src/grading-settings/assignment-section/index.jsx @@ -188,15 +188,16 @@ const AssignmentSection = ({ aria-hidden="true" /> )} - + {isEditable && ( + + )} ); })} diff --git a/src/grading-settings/messages.ts b/src/grading-settings/messages.ts index 14488eb13b..a5b7f60cfb 100644 --- a/src/grading-settings/messages.ts +++ b/src/grading-settings/messages.ts @@ -85,6 +85,11 @@ const messages = defineMessages({ id: 'course-authoring.grading-settings.add-new-assignment-type.btn', defaultMessage: 'New assignment type', }, + viewOnlyAlert: { + id: 'course-authoring.grading.alert.view-only', + defaultMessage: 'You have view-only access to this page. Contact your organization admin to request editing permissions.', + description: 'Alert shown to users who can view but cannot edit grading settings', + }, }); export default messages; From f03a50d7bd7f542345548c67920a5eedf933a03e Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 10 Aug 2026 12:24:23 +1000 Subject: [PATCH 6/8] refactor: use the general alert in grading settings Co-Authored-By: Claude Opus 5 --- src/grading-settings/GradingSettings.jsx | 10 +++------- .../assignment-section/AssignmentSection.test.jsx | 7 +++---- src/grading-settings/messages.ts | 5 ----- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 0e7fa3f7d7..0d4d453dbe 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -1,12 +1,11 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { - Alert, Button, Container, Layout, StatefulButton, } from '@openedx/paragon'; -import { Add as IconAdd, CheckCircle, Lock as LockIcon, Warning } from '@openedx/paragon/icons'; +import { Add as IconAdd, CheckCircle, Warning } from '@openedx/paragon/icons'; import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; @@ -19,6 +18,7 @@ import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; import SectionSubHeader from '@src/generic/section-sub-header'; import SubHeader from '@src/generic/sub-header/SubHeader'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import AlertMessage from '@src/generic/alert-message'; import InternetConnectionAlert from '@src/generic/internet-connection-alert'; import getPageHeadTitle from '@src/generic/utils'; @@ -180,11 +180,7 @@ const GradingSettings = () => { subtitle={intl.formatMessage(messages.headingSubtitle)} contentTitle={intl.formatMessage(messages.policy)} description={intl.formatMessage(messages.policiesDescription)} - banner={!isEditable ? ( - - {intl.formatMessage(messages.viewOnlyAlert)} - - ) : null} + banner={!isEditable ? : null} />
      ', () => { expect(getByText(messages.totalNumberErrorMessage.defaultMessage)).toBeInTheDocument(); }); }); - it('should disable all inputs and delete button when isEditable is false', async () => { - const { getAllByRole, getByText } = render(); + it('should disable all inputs and hide delete button when isEditable is false', async () => { + const { getAllByRole, queryByText } = render(); await waitFor(() => { const inputs = getAllByRole('textbox').concat(getAllByRole('spinbutton')); inputs.forEach((input) => expect(input).toBeDisabled()); - const deleteBtn = getByText(messages.assignmentDeleteButton.defaultMessage).closest('button'); - expect(deleteBtn).toBeDisabled(); + expect(queryByText(messages.assignmentDeleteButton.defaultMessage)).not.toBeInTheDocument(); }); }); diff --git a/src/grading-settings/messages.ts b/src/grading-settings/messages.ts index a5b7f60cfb..14488eb13b 100644 --- a/src/grading-settings/messages.ts +++ b/src/grading-settings/messages.ts @@ -85,11 +85,6 @@ const messages = defineMessages({ id: 'course-authoring.grading-settings.add-new-assignment-type.btn', defaultMessage: 'New assignment type', }, - viewOnlyAlert: { - id: 'course-authoring.grading.alert.view-only', - defaultMessage: 'You have view-only access to this page. Contact your organization admin to request editing permissions.', - description: 'Alert shown to users who can view but cannot edit grading settings', - }, }); export default messages; From 1637faee94a970cadd12db9f7e3c7f5709d23d19 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 12 Aug 2026 10:41:31 +1000 Subject: [PATCH 7/8] refactor: disable invite button in CousePromotionCard for read-only users --- src/plugin-slots/PageBannerSlot/Readme.md | 10 +++-- src/plugin-slots/PageBannerSlot/index.tsx | 3 ++ .../basic-section/CoursePromotionCard.jsx | 41 +++++++++++++------ .../CoursePromotionCard.test.jsx | 21 ++++++++-- .../basic-section/constants.ts | 1 - .../basic-section/index.jsx | 3 ++ src/schedule-and-details/index.tsx | 1 + 7 files changed, 60 insertions(+), 20 deletions(-) delete mode 100644 src/schedule-and-details/basic-section/constants.ts diff --git a/src/plugin-slots/PageBannerSlot/Readme.md b/src/plugin-slots/PageBannerSlot/Readme.md index 7d154e2c1b..4496c9be66 100644 --- a/src/plugin-slots/PageBannerSlot/Readme.md +++ b/src/plugin-slots/PageBannerSlot/Readme.md @@ -17,6 +17,7 @@ This slot wraps the Paragon `PageBanner` component to allow plugins to replace, - `lmsLinkForAboutPage` - String. URL of the course about page on the LMS. - `courseDisplayName` - String. The course's display name. - `platformName` - String. The platform name configured for the site. +- `isEditable` - Boolean. Whether the current user can edit the Schedule & Details page. Pass it to `CoursePromotionCard` to disable the "Invite your students" mailto link for view-only users. ## Example @@ -69,7 +70,9 @@ mailto button. If you want to restore that experience, you can use `CoursePromotionCard` — exported from `basic-section` — via this slot. The slot passes `lmsLinkForAboutPage`, `courseDisplayName`, -and `platformName` as plugin props so the card has everything it needs. +`platformName`, and `isEditable` as plugin props so the card has everything it needs. The +`isEditable` prop disables the "Invite your students" mailto button for users with view-only +access to Schedule & Details. ```jsx import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; @@ -88,11 +91,12 @@ const config = { widget: { id: 'course_promotion_card', type: DIRECT_PLUGIN, - RenderWidget: ({ lmsLinkForAboutPage, courseDisplayName, platformName }) => ( + RenderWidget: ({ lmsLinkForAboutPage, courseDisplayName, platformName, isEditable }) => ( ), }, @@ -101,6 +105,4 @@ const config = { }, }, }; - -export default config; ``` diff --git a/src/plugin-slots/PageBannerSlot/index.tsx b/src/plugin-slots/PageBannerSlot/index.tsx index 522dca3dc6..12e5695615 100644 --- a/src/plugin-slots/PageBannerSlot/index.tsx +++ b/src/plugin-slots/PageBannerSlot/index.tsx @@ -9,6 +9,7 @@ export interface PageBannerSlotProps { lmsLinkForAboutPage?: string; courseDisplayName?: string; platformName?: string; + isEditable?: boolean; } const PageBannerSlot: React.FC = ({ @@ -18,6 +19,7 @@ const PageBannerSlot: React.FC = ({ lmsLinkForAboutPage, courseDisplayName, platformName, + isEditable, }) => ( = ({ lmsLinkForAboutPage, courseDisplayName, platformName, + isEditable, }} >
      diff --git a/src/schedule-and-details/basic-section/CoursePromotionCard.jsx b/src/schedule-and-details/basic-section/CoursePromotionCard.jsx index 4409f2d1e1..f90847f93f 100644 --- a/src/schedule-and-details/basic-section/CoursePromotionCard.jsx +++ b/src/schedule-and-details/basic-section/CoursePromotionCard.jsx @@ -9,10 +9,14 @@ import { } from '@openedx/paragon'; import { Email as EmailIcon } from '@openedx/paragon/icons'; -import { INVITE_STUDENTS_LINK_ID } from './constants'; import messages from './messages'; -const CoursePromotionCard = ({ lmsLinkForAboutPage, courseDisplayName, platformName }) => { +const CoursePromotionCard = ({ + lmsLinkForAboutPage, + courseDisplayName, + platformName, + isEditable = true, +}) => { const intl = useIntl(); const emailSubject = intl.formatMessage( @@ -42,6 +46,17 @@ const CoursePromotionCard = ({ lmsLinkForAboutPage, courseDisplayName, platformN /> ); + const inviteButton = ( + + ); + return ( - - - + {isEditable ? + ( + + {inviteButton} + + ) : + inviteButton} ); @@ -80,6 +96,7 @@ CoursePromotionCard.propTypes = { lmsLinkForAboutPage: PropTypes.string.isRequired, courseDisplayName: PropTypes.string.isRequired, platformName: PropTypes.string.isRequired, + isEditable: PropTypes.bool, }; export default CoursePromotionCard; diff --git a/src/schedule-and-details/basic-section/CoursePromotionCard.test.jsx b/src/schedule-and-details/basic-section/CoursePromotionCard.test.jsx index 89f4050ea9..ed032ff33a 100644 --- a/src/schedule-and-details/basic-section/CoursePromotionCard.test.jsx +++ b/src/schedule-and-details/basic-section/CoursePromotionCard.test.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { INVITE_STUDENTS_LINK_ID } from './constants'; import messages from './messages'; import CoursePromotionCard from './CoursePromotionCard'; @@ -34,10 +33,26 @@ describe('', () => { }); it('generates correct invite mailto link', () => { - const { getByTestId } = render(); - const inviteLink = getByTestId(INVITE_STUDENTS_LINK_ID); + const { getByRole } = render(); + const inviteLink = getByRole('link', { + name: messages.basicPromotionButton.defaultMessage, + }); expect(decodeURIComponent(inviteLink.href)).toEqual( `mailto:${process.env.INVITE_STUDENTS_EMAIL_TO}?body=The course ${props.courseDisplayName}, provided by ${props.platformName}, is open for enrollment. Please navigate to this course at ${props.lmsLinkForAboutPage} to enroll.&subject=Enroll in ${props.courseDisplayName}.`, ); }); + + it('disables the invite link when not editable', () => { + const { getByRole, queryByRole } = render( + , + ); + const inviteButton = getByRole('button', { + name: messages.basicPromotionButton.defaultMessage, + }); + + expect(inviteButton).toBeDisabled(); + expect( + queryByRole('link', { name: messages.basicPromotionButton.defaultMessage }), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/schedule-and-details/basic-section/constants.ts b/src/schedule-and-details/basic-section/constants.ts deleted file mode 100644 index 232b9f4eb9..0000000000 --- a/src/schedule-and-details/basic-section/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const INVITE_STUDENTS_LINK_ID = 'invite-students-link'; diff --git a/src/schedule-and-details/basic-section/index.jsx b/src/schedule-and-details/basic-section/index.jsx index b2142f4691..46d49e8a64 100644 --- a/src/schedule-and-details/basic-section/index.jsx +++ b/src/schedule-and-details/basic-section/index.jsx @@ -17,6 +17,7 @@ const BasicSection = ({ lmsLinkForAboutPage, courseDisplayName, platformName, + isEditable, }) => { const intl = useIntl(); const [showPageBanner, setShowPageBanner] = useState(true); @@ -53,6 +54,7 @@ const BasicSection = ({ lmsLinkForAboutPage={lmsLinkForAboutPage} courseDisplayName={courseDisplayName} platformName={platformName} + isEditable={isEditable} >

      {intl.formatMessage(messages.basicBannerTitle, { platformName })}

      @@ -82,6 +84,7 @@ BasicSection.propTypes = { lmsLinkForAboutPage: PropTypes.string.isRequired, courseDisplayName: PropTypes.string.isRequired, platformName: PropTypes.string.isRequired, + isEditable: PropTypes.bool, }; export default BasicSection; diff --git a/src/schedule-and-details/index.tsx b/src/schedule-and-details/index.tsx index bd58bd446b..f5b805c92d 100644 --- a/src/schedule-and-details/index.tsx +++ b/src/schedule-and-details/index.tsx @@ -264,6 +264,7 @@ const ScheduleAndDetails = () => { lmsLinkForAboutPage={lmsLinkForAboutPage} courseDisplayName={courseDisplayName} platformName={platformName} + isEditable={canEdit} /> {showCreditSection && ( Date: Mon, 17 Aug 2026 13:02:41 +1000 Subject: [PATCH 8/8] test: increase coverage --- src/course-updates/CourseUpdates.test.tsx | 13 +++++++++++++ .../assignment-section/AssignmentSection.test.jsx | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/src/course-updates/CourseUpdates.test.tsx b/src/course-updates/CourseUpdates.test.tsx index c3bc141647..143b83e199 100644 --- a/src/course-updates/CourseUpdates.test.tsx +++ b/src/course-updates/CourseUpdates.test.tsx @@ -426,6 +426,19 @@ describe('', () => { ).toBeInTheDocument(); }); + it('should open the update form when clicking the "Add first update" button', async () => { + axiosMock.resetHandlers(); + axiosMock.onGet(getCourseUpdatesApiUrl(courseId)).reply(200, []); + axiosMock.onGet(getCourseHandoutApiUrl(courseId)).reply(200, courseHandoutsMock); + + render(); + await userEvent.click( + await screen.findByRole('button', { name: messages.firstUpdateButton.defaultMessage }), + ); + + expect(screen.getByText('Add new update')).toBeInTheDocument(); + }); + it('should NOT render the view-only alert', async () => { render(); diff --git a/src/grading-settings/assignment-section/AssignmentSection.test.jsx b/src/grading-settings/assignment-section/AssignmentSection.test.jsx index b0ce4b240e..c1ce8bbc68 100644 --- a/src/grading-settings/assignment-section/AssignmentSection.test.jsx +++ b/src/grading-settings/assignment-section/AssignmentSection.test.jsx @@ -110,6 +110,15 @@ describe('', () => { expect(getByText(messages.totalNumberErrorMessage.defaultMessage)).toBeInTheDocument(); }); }); + it('removes the assignment when clicking the delete button', () => { + const handleRemoveAssignment = jest.fn(); + const { getByText } = render(); + + fireEvent.click(getByText(messages.assignmentDeleteButton.defaultMessage)); + + expect(handleRemoveAssignment).toHaveBeenCalledWith(defaultAssignments.id); + }); + it('should disable all inputs and hide delete button when isEditable is false', async () => { const { getAllByRole, queryByText } = render(); await waitFor(() => {