diff --git a/src/course-updates/CourseUpdates.test.tsx b/src/course-updates/CourseUpdates.test.tsx index 559ccc088e..143b83e199 100644 --- a/src/course-updates/CourseUpdates.test.tsx +++ b/src/course-updates/CourseUpdates.test.tsx @@ -413,6 +413,38 @@ 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 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(); + + 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 +487,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 01727fb836..6b4a944357 100644 --- a/src/course-updates/CourseUpdates.tsx +++ b/src/course-updates/CourseUpdates.tsx @@ -12,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'; @@ -155,7 +156,9 @@ const CourseUpdates = () => { ) : null} + hideBorder={!canManageCourseUpdates} /> + {!canManageCourseUpdates && }
{isMainFormOpen && ( { {intl.formatMessage(messages.noCourseUpdates)} - + {canManageCourseUpdates && ( + + )} )} diff --git a/src/generic/ViewOnlyPermissionsAlert.test.tsx b/src/generic/ViewOnlyPermissionsAlert.test.tsx new file mode 100644 index 0000000000..769c573494 --- /dev/null +++ b/src/generic/ViewOnlyPermissionsAlert.test.tsx @@ -0,0 +1,30 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; + +import ViewOnlyPermissionsAlert from './ViewOnlyPermissionsAlert'; + +describe('', () => { + 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..8579967dbc --- /dev/null +++ b/src/generic/ViewOnlyPermissionsAlert.tsx @@ -0,0 +1,32 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert } from '@openedx/paragon'; +import { Lock } from '@openedx/paragon/icons'; + +interface Props { + className?: string; + children?: React.ReactNode; +} + +/** + * 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. + * + * 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, children }: Props) => ( + + {children ?? ( + + )} + +); + +export default ViewOnlyPermissionsAlert; 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..0d4d453dbe 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -18,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'; @@ -179,6 +180,7 @@ const GradingSettings = () => { subtitle={intl.formatMessage(messages.headingSubtitle)} contentTitle={intl.formatMessage(messages.policy)} description={intl.formatMessage(messages.policiesDescription)} + banner={!isEditable ? : null} />
{ setShowSuccessAlert={setShowSuccessAlert} isEditable={isEditable} /> - + {isEditable && ( + + )}
diff --git a/src/grading-settings/assignment-section/AssignmentSection.test.jsx b/src/grading-settings/assignment-section/AssignmentSection.test.jsx index 1cc22778b9..c1ce8bbc68 100644 --- a/src/grading-settings/assignment-section/AssignmentSection.test.jsx +++ b/src/grading-settings/assignment-section/AssignmentSection.test.jsx @@ -110,13 +110,21 @@ describe('', () => { expect(getByText(messages.totalNumberErrorMessage.defaultMessage)).toBeInTheDocument(); }); }); - it('should disable all inputs and delete button when isEditable is false', async () => { - const { getAllByRole, getByText } = render(); + 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(() => { 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/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/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/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/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 7c1403c34f..f5b805c92d 100644 --- a/src/schedule-and-details/index.tsx +++ b/src/schedule-and-details/index.tsx @@ -22,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'; @@ -243,6 +244,7 @@ const ScheduleAndDetails = () => { {intl.formatMessage(messages.headingTitle)}
+ {!canEdit && }
{ lmsLinkForAboutPage={lmsLinkForAboutPage} courseDisplayName={courseDisplayName} platformName={platformName} + isEditable={canEdit} /> {showCreditSection && ( { certificatesDisplayBehavior={certificatesDisplayBehavior} canShowCertificateAvailableDateField={canShowCertificateAvailableDateField} isEditable={canEditSchedule} + showReadOnlyAlert={!canEditSchedule && canEditDetails} onChange={handleValuesChange} /> {aboutPageEditable && ( diff --git a/src/schedule-and-details/schedule-section/index.jsx b/src/schedule-and-details/schedule-section/index.jsx index f6cf4b7b5c..f5eb0a4558 100644 --- a/src/schedule-and-details/schedule-section/index.jsx +++ b/src/schedule-and-details/schedule-section/index.jsx @@ -2,6 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; + import SectionSubHeader from '../../generic/section-sub-header'; import { ScheduleRow, SCHEDULE_ROW_TYPES } from './schedule-row'; import { CertificateDisplayRow } from './certificate-display-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;