@@ -246,6 +249,7 @@ const AdvancedSettings = () => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
+ disabled={!canManageAdvancedSettings}
/>
);
})}
diff --git a/src/advanced-settings/setting-card/SettingCard.test.jsx b/src/advanced-settings/setting-card/SettingCard.test.tsx
similarity index 51%
rename from src/advanced-settings/setting-card/SettingCard.test.jsx
rename to src/advanced-settings/setting-card/SettingCard.test.tsx
index cb2fd25aba..78316e6156 100644
--- a/src/advanced-settings/setting-card/SettingCard.test.jsx
+++ b/src/advanced-settings/setting-card/SettingCard.test.tsx
@@ -1,8 +1,13 @@
-import { fireEvent, render, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
+import {
+ fireEvent,
+ initializeMocks,
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from '@src/testUtils';
-import SettingCard from './SettingCard';
+import SettingCard, { type SettingCardProps } from './SettingCard';
import messages from './messages';
const setEdited = jest.fn();
@@ -25,10 +30,9 @@ jest.mock('react-textarea-autosize', () =>
/>
)));
-const RootWrapper = () => (
-
+const renderComponent = (props: Partial = {}) =>
+ render(
(
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
- />
-
-);
+ {...props}
+ />,
+ );
describe('
', () => {
- afterEach(() => jest.clearAllMocks());
+ beforeEach(() => {
+ initializeMocks();
+ });
+
it('renders the setting card with the provided data', () => {
- const { getByText, getByLabelText } = render(
);
- const cardTitle = getByText(/Setting Name/i);
- const input = getByLabelText(/Setting Name/i);
+ renderComponent();
+ const cardTitle = screen.getByText(/Setting Name/i);
+ const input = screen.getByLabelText(/Setting Name/i);
expect(cardTitle).toBeInTheDocument();
expect(input).toBeInTheDocument();
- expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
+ expect(input).toHaveValue(JSON.stringify(settingData.value, null, 4));
});
+
it('displays the deprecated status when the setting is deprecated', () => {
- const deprecatedSettingData = { ...settingData, deprecated: true };
- const { getByText } = render(
-
-
- ,
- );
- const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
- expect(deprecatedStatus).toBeInTheDocument();
+ renderComponent({
+ settingData: { ...settingData, deprecated: true },
+ isEditableState: false,
+ saveSettingsPrompt: true,
+ });
+ expect(screen.getByText(messages.deprecated.defaultMessage)).toBeInTheDocument();
});
+
it('does not display the deprecated status when the setting is not deprecated', () => {
- const { queryByText } = render(
);
- expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
+ renderComponent();
+ expect(screen.queryByText(messages.deprecated.defaultMessage)).toBeNull();
});
+
it('calls setEdited on blur', async () => {
const user = userEvent.setup();
- const { getByLabelText } = render(
);
- const inputBox = getByLabelText(/Setting Name/i);
+ renderComponent();
+ const inputBox = screen.getByLabelText(/Setting Name/i);
fireEvent.focus(inputBox);
await user.clear(inputBox);
await user.type(inputBox, '3, 2, 1');
@@ -91,4 +89,9 @@ describe('
', () => {
expect(handleBlur).toHaveBeenCalled();
});
});
+
+ it('disables the setting input when `disabled` is true', () => {
+ renderComponent({ disabled: true });
+ expect(screen.getByLabelText(/Setting Name/i)).toBeDisabled();
+ });
});
diff --git a/src/advanced-settings/setting-card/SettingCard.tsx b/src/advanced-settings/setting-card/SettingCard.tsx
index e7dedd7df4..3d93cde881 100644
--- a/src/advanced-settings/setting-card/SettingCard.tsx
+++ b/src/advanced-settings/setting-card/SettingCard.tsx
@@ -9,13 +9,29 @@ import {
useToggle,
} from '@openedx/paragon';
import { InfoOutline, Warning } from '@openedx/paragon/icons';
-import PropTypes from 'prop-types';
import { capitalize } from 'lodash';
import { useIntl } from '@edx/frontend-platform/i18n';
import TextareaAutosize from 'react-textarea-autosize';
import messages from './messages';
+export interface SettingCardProps {
+ name: string;
+ settingData: {
+ deprecated?: boolean;
+ help?: string;
+ displayName?: string;
+ value?: unknown;
+ };
+ handleBlur: () => void;
+ setEdited: React.Dispatch
>>;
+ showSaveSettingsPrompt: (show: boolean) => void;
+ saveSettingsPrompt: boolean;
+ isEditableState: boolean;
+ setIsEditableState: (isEditable: boolean) => void;
+ disabled?: boolean;
+}
+
const SettingCard = ({
name,
settingData,
@@ -25,7 +41,8 @@ const SettingCard = ({
saveSettingsPrompt,
isEditableState,
setIsEditableState,
-}) => {
+ disabled = false,
+}: SettingCardProps) => {
const intl = useIntl();
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
@@ -83,7 +100,7 @@ const SettingCard = ({
@@ -99,6 +116,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
+ disabled={disabled}
/>
@@ -113,26 +131,4 @@ const SettingCard = ({
);
};
-SettingCard.propTypes = {
- settingData: PropTypes.shape({
- deprecated: PropTypes.bool,
- help: PropTypes.string,
- displayName: PropTypes.string,
- value: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.bool,
- PropTypes.number,
- PropTypes.object,
- PropTypes.array,
- ]),
- }).isRequired,
- setEdited: PropTypes.func.isRequired,
- showSaveSettingsPrompt: PropTypes.func.isRequired,
- name: PropTypes.string.isRequired,
- handleBlur: PropTypes.func.isRequired,
- saveSettingsPrompt: PropTypes.bool.isRequired,
- isEditableState: PropTypes.bool.isRequired,
- setIsEditableState: PropTypes.func.isRequired,
-};
-
export default SettingCard;
diff --git a/src/authz/constants.ts b/src/authz/constants.ts
index 028a395c3d..d056c7393d 100644
--- a/src/authz/constants.ts
+++ b/src/authz/constants.ts
@@ -19,6 +19,7 @@ export const COURSE_PERMISSIONS = {
VIEW_COURSE: 'courses.view_course',
EDIT_COURSE_CONTENT: 'courses.edit_course_content',
+ VIEW_ADVANCED_SETTINGS: 'courses.view_advanced_settings',
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
@@ -42,6 +43,8 @@ export const COURSE_PERMISSIONS = {
VIEW_COURSE_TEAM: 'courses.view_course_team',
MANAGE_GROUP_CONFIGURATIONS: 'courses.manage_group_configurations',
+
+ VIEW_CERTIFICATES: 'courses.view_certificates',
MANAGE_CERTIFICATES: 'courses.manage_certificates',
VIEW_CHECKLISTS: 'courses.view_checklists',
diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts
index 7798b9d5ca..014780df16 100644
--- a/src/authz/permissionHelpers.test.ts
+++ b/src/authz/permissionHelpers.test.ts
@@ -93,17 +93,26 @@ describe('permissionHelpers', () => {
});
describe('getAdvancedSettingsPermissions', () => {
- it('returns MANAGE permission with the correct action and scope', () => {
+ it('returns VIEW and MANAGE permissions with the correct actions and scope', () => {
const result = getAdvancedSettingsPermissions(courseId);
- expect(result.canManageAdvancedSettings.action).toBe(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS);
- expect(result.canManageAdvancedSettings.scope).toBe(courseId);
+ expect(result).toEqual({
+ canViewAdvancedSettings: {
+ action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS,
+ scope: courseId,
+ },
+ canManageAdvancedSettings: {
+ action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
+ scope: courseId,
+ },
+ });
});
it('uses the provided courseId as scope', () => {
const otherId = 'course-v1:another+test+run';
const result = getAdvancedSettingsPermissions(otherId);
+ expect(result.canViewAdvancedSettings.scope).toBe(otherId);
expect(result.canManageAdvancedSettings.scope).toBe(otherId);
});
});
@@ -208,10 +217,14 @@ describe('permissionHelpers', () => {
});
describe('getCertificatesPermissions', () => {
- it('returns MANAGE_CERTIFICATES permission with the correct action and scope', () => {
+ it('returns VIEW and MANAGE permissions with the correct actions and scope', () => {
const result = getCertificatesPermissions(courseId);
expect(result).toEqual({
+ canViewCertificates: {
+ action: COURSE_PERMISSIONS.VIEW_CERTIFICATES,
+ scope: courseId,
+ },
canManageCertificates: {
action: COURSE_PERMISSIONS.MANAGE_CERTIFICATES,
scope: courseId,
diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts
index 3520e80f10..2685beab9e 100644
--- a/src/authz/permissionHelpers.ts
+++ b/src/authz/permissionHelpers.ts
@@ -61,6 +61,10 @@ export const getPagesAndResourcesPermissions = (courseId: string) => ({
});
export const getAdvancedSettingsPermissions = (courseId: string) => ({
+ canViewAdvancedSettings: {
+ action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS,
+ scope: courseId,
+ },
canManageAdvancedSettings: {
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
scope: courseId,
@@ -99,6 +103,10 @@ export const getGroupConfigurationsPermissions = (courseId: string) => ({
});
export const getCertificatesPermissions = (courseId: string) => ({
+ canViewCertificates: {
+ action: COURSE_PERMISSIONS.VIEW_CERTIFICATES,
+ scope: courseId,
+ },
canManageCertificates: {
action: COURSE_PERMISSIONS.MANAGE_CERTIFICATES,
scope: courseId,
diff --git a/src/certificates/Certificates.test.tsx b/src/certificates/Certificates.test.tsx
index 5c7a622635..f4ac8dd7d0 100644
--- a/src/certificates/Certificates.test.tsx
+++ b/src/certificates/Certificates.test.tsx
@@ -18,6 +18,7 @@ const mockPermissions = (overrides = {}) =>
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
+ canViewCertificates: true,
canManageCertificates: true,
...overrides,
} as ReturnType);
@@ -38,7 +39,7 @@ describe('Certificates', () => {
});
it('shows PermissionDeniedAlert when user lacks manage certificates permission', async () => {
- mockPermissions({ canManageCertificates: false });
+ mockPermissions({ canViewCertificates: false });
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
@@ -47,6 +48,16 @@ describe('Certificates', () => {
expect(screen.queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
});
+ it('renders content in view-only mode when user can view but not manage', async () => {
+ mockPermissions({ canViewCertificates: true, canManageCertificates: false });
+ axiosMock
+ .onGet(getCertificatesApiUrl(courseId))
+ .reply(200, certificatesDataMock);
+ renderComponent();
+ expect(await screen.findByTestId('certificates-list')).toBeInTheDocument();
+ expect(screen.getByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument();
+ });
+
it('renders WithoutModes when there are certificates but no certificate modes', async () => {
const noModesMock = {
...certificatesDataMock,
diff --git a/src/certificates/Certificates.tsx b/src/certificates/Certificates.tsx
index 1d5a16642a..a8ddef281a 100644
--- a/src/certificates/Certificates.tsx
+++ b/src/certificates/Certificates.tsx
@@ -1,8 +1,5 @@
import { Helmet } from 'react-helmet';
-import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
-import { useCourseUserPermissions } from '@src/authz/hooks';
-import { getCertificatesPermissions } from '@src/authz/permissionHelpers';
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
import Placeholder from '../editors/Placeholder';
import Loading from '../generic/Loading';
@@ -14,6 +11,7 @@ import CertificateCreateForm from './certificate-create-form/CertificateCreateFo
import CertificateEditForm from './certificate-edit-form/CertificateEditForm';
import { MODE_STATES } from './data/constants';
import MainLayout from './layout/MainLayout';
+import { useCertificatesContext } from './context';
const MODE_COMPONENTS = {
[MODE_STATES.noModes]: CertificateWithoutModes,
@@ -24,7 +22,6 @@ const MODE_COMPONENTS = {
};
const Certificates = () => {
- const { courseId } = useCourseAuthoringContext();
const {
certificates,
componentMode,
@@ -34,12 +31,9 @@ const Certificates = () => {
hasCertificateModes,
} = useCertificatesData();
- const {
- isLoading: isLoadingUserPermissions,
- canManageCertificates,
- } = useCourseUserPermissions(courseId, getCertificatesPermissions(courseId));
+ const { canViewCertificates } = useCertificatesContext();
- if (!isLoadingUserPermissions && !canManageCertificates) {
+ if (!canViewCertificates) {
return ;
}
diff --git a/src/certificates/certificate-details/CertificateDetails.test.tsx b/src/certificates/certificate-details/CertificateDetails.test.tsx
index 281d4007b4..70cc773332 100644
--- a/src/certificates/certificate-details/CertificateDetails.test.tsx
+++ b/src/certificates/certificate-details/CertificateDetails.test.tsx
@@ -8,6 +8,8 @@ import {
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { CertificatesProvider } from '@src/certificates/context';
+import { mockWaffleFlags } from '@src/data/apiHooks.mock';
+import { useCourseUserPermissions } from '@src/authz/hooks';
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api';
import { certificatesDataMock } from '../__mocks__';
@@ -25,6 +27,19 @@ const defaultProps: CertificateDetialsProps = {
detailsCourseNumber: certificatesDataMock.courseNumber,
};
+jest.mock('@src/authz/hooks', () => ({
+ useCourseUserPermissions: jest.fn(),
+}));
+
+const mockPermissions = (overrides = {}) =>
+ jest.mocked(useCourseUserPermissions).mockReturnValue({
+ isLoading: false,
+ isAuthzEnabled: true,
+ canViewCertificates: true,
+ canManageCertificates: true,
+ ...overrides,
+ } as ReturnType);
+
const renderComponent = (props = defaultProps) =>
render(
@@ -37,6 +52,8 @@ const renderComponent = (props = defaultProps) =>
describe('CertificateDetails', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
+ mockWaffleFlags({ enableAuthzCourseAuthoring: false });
+ mockPermissions();
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
@@ -88,4 +105,15 @@ describe('CertificateDetails', () => {
await screen.findByText(messages.detailsSectionTitle.defaultMessage);
expect(screen.getByText(courseTitleOverride)).toBeInTheDocument();
});
+
+ it('hides edit and delete buttons in view-only mode', async () => {
+ mockPermissions({ canManageCertificates: false });
+ renderComponent();
+
+ await screen.findByText(messages.detailsSectionTitle.defaultMessage);
+ expect(screen.queryByRole('button', { name: commonMessages.editTooltip.defaultMessage })).not
+ .toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: commonMessages.deleteTooltip.defaultMessage })).not
+ .toBeInTheDocument();
+ });
});
diff --git a/src/certificates/certificate-details/CertificateDetails.tsx b/src/certificates/certificate-details/CertificateDetails.tsx
index 5a68dbdb83..03cd9e95d2 100644
--- a/src/certificates/certificate-details/CertificateDetails.tsx
+++ b/src/certificates/certificate-details/CertificateDetails.tsx
@@ -14,6 +14,7 @@ import ModalNotification from '../../generic/modal-notification';
import commonMessages from '../messages';
import messages from './messages';
import useCertificateDetails from './hooks/useCertificateDetails';
+import { useCertificatesContext } from '@src/certificates/context';
export interface CertificateDetialsProps {
certificateId: number;
@@ -31,6 +32,7 @@ const CertificateDetails = ({
courseNumberOverride,
}: CertificateDetialsProps) => {
const intl = useIntl();
+ const { canManageCertificates } = useCertificatesContext();
const {
isConfirmOpen,
confirmOpen,
@@ -48,24 +50,26 @@ const CertificateDetails = ({
title={intl.formatMessage(messages.detailsSectionTitle)}
className="certificate-details"
data-testid="certificate-details"
- actions={
-
- {intl.formatMessage(commonMessages.editTooltip)} }
- alt={intl.formatMessage(commonMessages.editTooltip)}
- onClick={isCertificateActive ? editModalOpen : handleEditAll}
- />
-