Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
23 changes: 21 additions & 2 deletions src/advanced-settings/AdvancedSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: false,
canViewAdvancedSettings: true,
canManageAdvancedSettings: true,
} as ReturnType<typeof useCourseUserPermissions>);
});
Expand Down Expand Up @@ -175,6 +176,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canViewAdvancedSettings: true,
canManageAdvancedSettings: true,
} as ReturnType<typeof useCourseUserPermissions>);
render();
Expand All @@ -192,7 +194,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canManageAdvancedSettings: false,
canViewAdvancedSettings: false,
} as ReturnType<typeof useCourseUserPermissions>);
render();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
Expand All @@ -203,7 +205,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canManageAdvancedSettings: false,
canViewAdvancedSettings: false,
} as ReturnType<typeof useCourseUserPermissions>);
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
Expand All @@ -212,4 +214,21 @@ describe('<AdvancedSettings />', () => {
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
expect(screen.queryByText(/Under Construction/i)).not.toBeInTheDocument();
});

it('should show view-only alert and disable editing when user has view but not manage permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canViewAdvancedSettings: true,
canManageAdvancedSettings: false,
} as ReturnType<typeof useCourseUserPermissions>);
render();
expect(await screen.findByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const textarea = screen.getByLabelText(/Advanced Module List/i);
expect(textarea).toBeDisabled();
expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
expect(screen.queryByText(messages.buttonCancelText.defaultMessage)).not.toBeInTheDocument();
});
});
6 changes: 5 additions & 1 deletion src/advanced-settings/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { getAdvancedSettingsPermissions } from '@src/authz/permissionHelpers';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
Expand Down Expand Up @@ -45,6 +46,7 @@ const AdvancedSettings = () => {

const {
isLoading: isLoadingUserPermissions,
canViewAdvancedSettings,
canManageAdvancedSettings,
} = useCourseUserPermissions(courseId, getAdvancedSettingsPermissions(courseId));

Expand Down Expand Up @@ -102,7 +104,7 @@ const AdvancedSettings = () => {
);
}

if (!canManageAdvancedSettings) {
if (!canViewAdvancedSettings) {
return <PermissionDeniedAlert />;
}

Expand Down Expand Up @@ -201,6 +203,7 @@ const AdvancedSettings = () => {
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
banner={!canManageAdvancedSettings ? <ViewOnlyPermissionsAlert /> : null}
/>
<article>
<div>
Expand Down Expand Up @@ -246,6 +249,7 @@ const AdvancedSettings = () => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disabled={!canManageAdvancedSettings}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -25,10 +30,9 @@ jest.mock('react-textarea-autosize', () =>
/>
)));

const RootWrapper = () => (
<IntlProvider locale="en">
const renderComponent = (props: Partial<SettingCardProps> = {}) =>
render(
<SettingCard
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
Expand All @@ -37,48 +41,42 @@ const RootWrapper = () => (
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
/>
</IntlProvider>
);
{...props}
/>,
);

describe('<SettingCard />', () => {
afterEach(() => jest.clearAllMocks());
beforeEach(() => {
initializeMocks();
});

it('renders the setting card with the provided data', () => {
const { getByText, getByLabelText } = render(<RootWrapper />);
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(
<IntlProvider locale="en">
<SettingCard
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={deprecatedSettingData}
handleBlur={handleBlur}
isEditable={false}
saveSettingsPrompt
/>
</IntlProvider>,
);
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(<RootWrapper />);
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(<RootWrapper />);
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');
Expand All @@ -91,4 +89,9 @@ describe('<SettingCard />', () => {
expect(handleBlur).toHaveBeenCalled();
});
});

it('disables the setting input when `disabled` is true', () => {
renderComponent({ disabled: true });
expect(screen.getByLabelText(/Setting Name/i)).toBeDisabled();
});
});
46 changes: 21 additions & 25 deletions src/advanced-settings/setting-card/SettingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.SetStateAction<Record<string, unknown>>>;
showSaveSettingsPrompt: (show: boolean) => void;
saveSettingsPrompt: boolean;
isEditableState: boolean;
setIsEditableState: (isEditable: boolean) => void;
disabled?: boolean;
}

const SettingCard = ({
name,
settingData,
Expand All @@ -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);
Expand Down Expand Up @@ -83,7 +100,7 @@ const SettingCard = ({
<div
className="p-2 x-small rounded modal-popup-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: help }}
dangerouslySetInnerHTML={{ __html: help ?? '' }}
/>
</ModalPopup>
<ActionRow.Spacer />
Expand All @@ -99,6 +116,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
disabled={disabled}
/>
</Form.Group>
</Card.Section>
Expand All @@ -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;
3 changes: 3 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
21 changes: 17 additions & 4 deletions src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading