Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/course-updates/CourseUpdates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,25 @@ describe('<CourseUpdates />', () => {

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(<RootWrapper />);

expect(
await screen.findByRole('button', { name: messages.firstUpdateButton.defaultMessage }),
).toBeInTheDocument();
});

it('should NOT render the view-only alert', async () => {
render(<RootWrapper />);

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', () => {
Expand Down Expand Up @@ -455,6 +474,28 @@ describe('<CourseUpdates />', () => {
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(<RootWrapper />);

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(<RootWrapper />);

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', () => {
Expand Down
23 changes: 14 additions & 9 deletions src/course-updates/CourseUpdates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -155,7 +156,9 @@ const CourseUpdates = () => {
</Button>
) :
null}
hideBorder={!canManageCourseUpdates}
/>
{!canManageCourseUpdates && <ViewOnlyPermissionsAlert />}
<section className="updates-section">
{isMainFormOpen && (
<UpdateForm
Expand Down Expand Up @@ -202,15 +205,17 @@ const CourseUpdates = () => {
<span className="small mr-2">
{intl.formatMessage(messages.noCourseUpdates)}
</span>
<Button
variant="primary"
iconBefore={AddIcon}
size="sm"
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
disabled={isUpdateFormOpen || errors.loadingUpdates}
>
{intl.formatMessage(messages.firstUpdateButton)}
</Button>
{canManageCourseUpdates && (
<Button
variant="primary"
iconBefore={AddIcon}
size="sm"
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
disabled={isUpdateFormOpen || errors.loadingUpdates}
>
{intl.formatMessage(messages.firstUpdateButton)}
</Button>
)}
<ActionRow.Spacer />
</ActionRow>
)}
Expand Down
30 changes: 30 additions & 0 deletions src/generic/ViewOnlyPermissionsAlert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { initializeMocks, render, screen } from '@src/testUtils';

import ViewOnlyPermissionsAlert from './ViewOnlyPermissionsAlert';

describe('<ViewOnlyPermissionsAlert />', () => {
beforeEach(() => {
initializeMocks();
});

it('renders the view-only message', () => {
render(<ViewOnlyPermissionsAlert />);

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(<ViewOnlyPermissionsAlert />);

expect(container.querySelector('.alert-icon svg')).toBeInTheDocument();
});

it('applies a passed className', () => {
render(<ViewOnlyPermissionsAlert className="mt-4" />);

expect(screen.getByTestId('viewOnlyPermissionsAlert')).toHaveClass('mt-4');
});
});
32 changes: 32 additions & 0 deletions src/generic/ViewOnlyPermissionsAlert.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Alert variant="info" icon={Lock} className={className} data-testid="viewOnlyPermissionsAlert">
{children ?? (
<FormattedMessage
id="authoring.alert.info.viewOnlyPermissions"
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 a page but do not have permission to edit it"
/>
)}
</Alert>
);

export default ViewOnlyPermissionsAlert;
3 changes: 3 additions & 0 deletions src/generic/sub-header/SubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface SubHeaderProps {
instruction?: ReactElement | string;
headerActions?: ReactElement | ReactElement[] | null;
titleActions?: ReactElement | ReactElement[] | null;
banner?: ReactElement | null;
hideBorder?: boolean;
withSubHeaderContent?: boolean;
}
Expand All @@ -23,6 +24,7 @@ const SubHeader = ({
instruction,
headerActions,
titleActions,
banner = null,
hideBorder = false,
withSubHeaderContent = true,
}: SubHeaderProps) => (
Expand All @@ -44,6 +46,7 @@ const SubHeader = ({
</ActionRow>
)}
</header>
{banner}
{contentTitle && withSubHeaderContent && (
<header className="sub-header-content">
<h2 className="sub-header-content-title">{contentTitle}</h2>
Expand Down
19 changes: 11 additions & 8 deletions src/grading-settings/GradingSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -179,6 +180,7 @@ const GradingSettings = () => {
subtitle={intl.formatMessage(messages.headingSubtitle)}
contentTitle={intl.formatMessage(messages.policy)}
description={intl.formatMessage(messages.policiesDescription)}
banner={!isEditable ? <ViewOnlyPermissionsAlert /> : null}
/>
<section>
<GradingScale
Expand Down Expand Up @@ -243,14 +245,15 @@ const GradingSettings = () => {
setShowSuccessAlert={setShowSuccessAlert}
isEditable={isEditable}
/>
<Button
variant="primary"
iconBefore={IconAdd}
onClick={handleAddAssignment}
disabled={!isEditable}
>
{intl.formatMessage(messages.addNewAssignmentTypeBtn)}
</Button>
{isEditable && (
<Button
variant="primary"
iconBefore={IconAdd}
onClick={handleAddAssignment}
>
{intl.formatMessage(messages.addNewAssignmentTypeBtn)}
</Button>
)}
</section>
</article>
</Layout.Element>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,12 @@ describe('<AssignmentSection />', () => {
expect(getByText(messages.totalNumberErrorMessage.defaultMessage)).toBeInTheDocument();
});
});
it('should disable all inputs and delete button when isEditable is false', async () => {
const { getAllByRole, getByText } = render(<RootWrapper isEditable={false} />);
it('should disable all inputs and hide delete button when isEditable is false', async () => {
const { getAllByRole, queryByText } = render(<RootWrapper isEditable={false} />);
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();
});
});

Expand Down
19 changes: 10 additions & 9 deletions src/grading-settings/assignment-section/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,16 @@ const AssignmentSection = ({
aria-hidden="true"
/>
)}
<Button
className="course-grading-assignment-delete-btn"
variant="outline-primary"
size="sm"
onClick={() => handleRemoveAssignment(gradeField.id)}
disabled={!isEditable}
>
{intl.formatMessage(messages.assignmentDeleteButton)}
</Button>
{isEditable && (
<Button
className="course-grading-assignment-delete-btn"
variant="outline-primary"
size="sm"
onClick={() => handleRemoveAssignment(gradeField.id)}
>
{intl.formatMessage(messages.assignmentDeleteButton)}
</Button>
)}
</div>
);
})}
Expand Down
10 changes: 6 additions & 4 deletions src/plugin-slots/PageBannerSlot/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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';
Expand All @@ -88,11 +91,12 @@ const config = {
widget: {
id: 'course_promotion_card',
type: DIRECT_PLUGIN,
RenderWidget: ({ lmsLinkForAboutPage, courseDisplayName, platformName }) => (
RenderWidget: ({ lmsLinkForAboutPage, courseDisplayName, platformName, isEditable }) => (
<CoursePromotionCard
lmsLinkForAboutPage={lmsLinkForAboutPage}
courseDisplayName={courseDisplayName}
platformName={platformName}
isEditable={isEditable}
/>
),
},
Expand All @@ -101,6 +105,4 @@ const config = {
},
},
};

export default config;
```
3 changes: 3 additions & 0 deletions src/plugin-slots/PageBannerSlot/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface PageBannerSlotProps {
lmsLinkForAboutPage?: string;
courseDisplayName?: string;
platformName?: string;
isEditable?: boolean;
}

const PageBannerSlot: React.FC<PageBannerSlotProps> = ({
Expand All @@ -18,6 +19,7 @@ const PageBannerSlot: React.FC<PageBannerSlotProps> = ({
lmsLinkForAboutPage,
courseDisplayName,
platformName,
isEditable,
}) => (
<PluginSlot
id="org.openedx.frontend.authoring.page_banner.v1"
Expand All @@ -28,6 +30,7 @@ const PageBannerSlot: React.FC<PageBannerSlotProps> = ({
lmsLinkForAboutPage,
courseDisplayName,
platformName,
isEditable,
}}
>
<div className="align-items-start">
Expand Down
34 changes: 34 additions & 0 deletions src/schedule-and-details/ScheduleAndDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,26 @@ describe('<ScheduleAndDetails /> 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 });
Expand All @@ -234,4 +254,18 @@ describe('<ScheduleAndDetails /> 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();
});
});
Loading