Compare commits

...

7 Commits

Author SHA1 Message Date
Lucas Calviño
a2bfb1fb7b [ROLES-47] Permissions definitions for Schedule & Details (#854) 2024-03-08 15:07:56 -03:00
hilary sinkoff
c754a5e519 feat: Add permissions checks for group_configuration, grading, outline (#829)
* feat: update header options for access control, course outline access checks, grade-settings access checks, and view only for grading page
2024-02-16 18:21:43 +00:00
hsinkoff
1e9146a5b9 fix: update tests missed on rebase 2024-02-16 18:21:43 +00:00
hilary sinkoff
a518fada29 feat: Access for Import/Export Pages Based on Permissions (#804)
* feat: import/export page access based on permissions
2024-02-16 18:21:43 +00:00
Lucas Calviño
69d9ea318e docs: Add permissions check architecture 2024-02-16 18:21:43 +00:00
Lucas Calviño
e74e1ff5aa feat: [ROLES-41] Permission checks (#718)
* feat: Permission check (#718)

This feature allows to fetch the User Permissions and check on every
page for the right permission to allow the user to make actions or even
to see the content depending on the page and the permission.

Co-authored-by: hsinkoff <hsinkoff@2u.com>
2024-02-16 18:21:43 +00:00
Lucas Calviño
1137dae97a feat: [ROLES-26] Helper function for ingesting permission data (#670)
* feat: Add UserPermissions api, specs, feature flag api
2024-02-16 18:21:43 +00:00
75 changed files with 1406 additions and 159 deletions

View File

@@ -0,0 +1,21 @@
Background
==========
This is a summary of the technical decisions made for the Roles & Permissions
project as we implemented the permissions check system in the ``frontend-app-course-authoring``.
The ``frontend-app-course-authoring`` was already created when the
Permissions project started, so it already had a coding style, store
management and its own best practices.
We aligned to these requirements.
Frontend Architecture
---------------------
* `Readme <https://github.com/openedx/frontend-app-course-authoring#readme>`__
* Developing locally:
https://github.com/openedx/frontend-app-course-authoring#readme
* **React.js** application ``version: 17.0.2``
* **Redux** store management ``version: 4.0.5``
* It uses **Thunk** for adding to Redux the ability of returning
functions.

View File

@@ -0,0 +1,66 @@
Local Development & Testing
===========================
Backend
~~~~~~~
The backend endpoints lives in the ``edx-platform`` repo, specifically
in this file: ``openedx/core/djangoapps/course_roles/views.py``
For quickly testing the different permissions and the flag change you
can tweak the values directly in the above file.
* ``UserPermissionsView`` is in charge of returning the permissions, so
for sending the permissions you want to check, you could do something
like this:
.. code-block:: python
permissions = {
'user_id': user_id,
'course_key': str(course_key),
#'permissions': sorted(permission.value.name for permission in permissions_set),
'permissions': ['the_permissions_being_tested']
}
return Response(permissions)
By making this change, the permissions object will be bypassed and
send a plain array with the specific permissions being tested.
* ``UserPermissionsFlagView`` is in charge of returning the flag value
(boolean), so you can easily turn the boolean like this:
.. code-block:: python
#payload = {'enabled': use_permission_checks()}
payload = {'enabled': true}
return Response(payload)
Flags
~~~~~
Youll need at least 2 flags to start:
* The basic flag for enabling the backend permissions system: ``course_roles.use_permission_checks``.
* The flag for enabling the page you want to test, for instance Course Team: ``contentstore.new_studio_mfe.use_new_course_team_page``.
All flags for enabling pages in the Studio MFE are listed
`here <https://2u-internal.atlassian.net/wiki/x/CQCcHQ>`__.
Flags can be added by:
^^^^^^^^^^^^^^^^^^^^^^
* Enter to ``http://localhost:18000/admin/``.
* Log in as an admin.
* Go to ``http://localhost:18000/admin/waffle/flag/``.
* Click on ``+ADD FLAG`` button at the top right of the page and add
the flag you need.
Testing
~~~~~~~
For unit testing you run the npm script included in the ``package.json``, you can use it plainly for testing all components at once: ``npm run test``.
Or you can test one file at a time: ``npm run test path-to-file``.

View File

@@ -0,0 +1,62 @@
Permissions Check implementation
================================
For the permissions checks we basically hit 2 endpoints from the
``edx-platform`` repo:
* **Permissions**:
``/api/course_roles/v1/user_permissions/?course_id=[course_key]&user_id=[user_id]``
Which will return this structure:
.. code-block:: js
permissions = {
'user_id': [user_id],
'course_key': [course_key],
'permissions': ['permission_1', 'permission_2']
}
* **Permissions enabled** (which returns the boolean flag value): ``/api/course_roles/v1/user_permissions/enabled/``
The basic scaffolding for *fetching* and *storing* the permissions is located in the ``src/generic/data`` folder:
* ``api.js``: Exposes the ``getUserPermissions(courseId)`` and ``getUserPermissionsEnabledFlag()`` methods.
* ``selectors.js``: Exposes the selectors ``getUserPermissions`` and ``getUserPermissionsEnabled`` to be used by ``useSelector()``.
* ``slice.js``: Exposes the ``updateUserPermissions`` and ``updateUserPermissionsEnabled`` methods that will be used by the ``thunks.js`` file for dispatching and storing.
* ``thunks.js``: Exposes the ``fetchUserPermissionsQuery(courseId)`` and ``fetchUserPermissionsEnabledFlag()`` methods for fetching.
In the ``src/generic/hooks.jsx`` we created a custom hook for exposing the ``checkPermission`` method, so that way we can call
this method from any page and pass the permission we want to check for the current logged in user.
In this example on the ``src/course-team/CourseTeam.jsx`` page, we use the hook for checking if the current user has the ``manage_all_users``
permission:
1. First, we import the hook (line 1).
2. Then we call the ``checkPermission`` method and assign it to a const (line 2).
3. Finally we use the const for showing or hiding a button (line 8).
.. code-block:: js
1. import { useUserPermissions } from '../generic/hooks';
2. const hasManageAllUsersPerm = checkPermission('manage_all_users');
3. <SubHeader
4. title={intl.formatMessage(messages.headingTitle)}
5. subtitle={intl.formatMessage(messages.headingSubtitle)}
6. headerActions={(
7. isAllowActions ||
8. hasManageAllUsersPerm
9. ) && (
10. <Button
11. variant="primary"
12. iconBefore={IconAdd}
13. size="sm"
14. onClick={openForm}
15. >
16. {intl.formatMessage(messages.addNewMemberButton)}
17. </Button>
18. )}
19. />

2
package-lock.json generated
View File

@@ -65,7 +65,7 @@
"@testing-library/user-event": "^13.2.1",
"axios-mock-adapter": "1.22.0",
"glob": "7.2.3",
"husky": "7.0.4",
"husky": "^7.0.4",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2",

View File

@@ -92,7 +92,7 @@
"@testing-library/user-event": "^13.2.1",
"axios-mock-adapter": "1.22.0",
"glob": "7.2.3",
"husky": "7.0.4",
"husky": "^7.0.4",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2",

View File

@@ -14,6 +14,8 @@ import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
import { getUserPermissions } from './generic/data/selectors';
const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId,
@@ -40,9 +42,14 @@ AppHeader.defaultProps = {
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
const userPermissions = useSelector(getUserPermissions);
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchUserPermissionsEnabledFlag());
if (!userPermissions) {
dispatch(fetchUserPermissionsQuery(courseId));
}
}, [courseId]);
const courseDetail = useModel('courseDetails', courseId);

View File

@@ -25,6 +25,9 @@ import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import getPageHeadTitle from '../generic/utils';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const AdvancedSettings = ({ intl, courseId }) => {
const dispatch = useDispatch();
@@ -41,6 +44,13 @@ const AdvancedSettings = ({ intl, courseId }) => {
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const viewOnly = checkPermission('view_course_settings');
const showPermissionDeniedAlert = userPermissionsEnabled && (
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
@@ -83,6 +93,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
@@ -215,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disableForm={viewOnly}
/>
);
})}

View File

@@ -3,7 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import {
render,
fireEvent,
waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
@@ -13,11 +17,15 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
@@ -43,11 +51,23 @@ const RootWrapper = () => (
</AppProvider>
);
const permissionsMockStore = async (permissions) => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const permissionDisabledMockStore = async () => {
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
describe('<AdvancedSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
permissionsMockStore(userPermissionsData);
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
const permissionsData = { permissions: ['view'] };
await permissionsMockStore(permissionsData);
const { queryByText } = render(<RootWrapper />);
await waitFor(() => {
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).toBeInTheDocument();
});
});
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
await permissionDisabledMockStore();
const { queryByText } = render(<RootWrapper />);
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).not.toBeInTheDocument();
});
it('should be view only if the permission is set for viewOnly', async () => {
const permissions = { permissions: ['view_course_settings'] };
await permissionsMockStore(permissions);
const { getByLabelText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByLabelText('Advanced Module List')).toBeDisabled();
});
});
});

View File

@@ -27,6 +27,7 @@ const SettingCard = ({
setIsEditableState,
// injected
intl,
disableForm,
}) => {
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
@@ -100,6 +101,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
disabled={disableForm}
/>
</Form.Group>
</Card.Section>
@@ -135,6 +137,7 @@ SettingCard.propTypes = {
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
disableForm: PropTypes.bool.isRequired,
};
export default injectIntl(SettingCard);

View File

@@ -41,6 +41,9 @@ import DeleteModal from './delete-modal/DeleteModal';
import PageAlerts from './page-alerts/PageAlerts';
import { useCourseOutline } from './hooks';
import messages from './messages';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const CourseOutline = ({ courseId }) => {
const intl = useIntl();
@@ -108,6 +111,12 @@ const CourseOutline = ({ courseId }) => {
const [sections, setSections] = useState(sectionsList);
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const hasOutlinePermissions = !userPermissionsEnabled || (
userPermissionsEnabled && (checkPermission('manage_libraries') || checkPermission('manage_content'))
);
let initialSections = [...sectionsList];
const {
@@ -241,6 +250,12 @@ const CourseOutline = ({ courseId }) => {
setSections(sectionsList);
}, [sectionsList]);
if (!hasOutlinePermissions) {
return (
<PermissionDeniedAlert />
);
}
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return (

View File

@@ -48,10 +48,15 @@ import pasteButtonMessages from './paste-button/messages';
import subsectionMessages from './subsection-card/messages';
import pageAlertMessages from './page-alerts/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: [] };
window.HTMLElement.prototype.scrollIntoView = jest.fn();
@@ -90,7 +95,7 @@ describe('<CourseOutline />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -102,6 +107,14 @@ describe('<CourseOutline />', () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, courseOutlineIndexMock);
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
});
@@ -114,6 +127,13 @@ describe('<CourseOutline />', () => {
});
});
it('should render permissionDenied if incorrect permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('check reindex and render success alert is correctly', async () => {
const { findByText, findByTestId } = render(<RootWrapper />);

View File

@@ -19,12 +19,13 @@ import AddTeamMember from './add-team-member/AddTeamMember';
import CourseTeamMember from './course-team-member/CourseTeamMember';
import InfoModal from './info-modal/InfoModal';
import { useCourseTeam } from './hooks';
import { useUserPermissions } from '../generic/hooks';
import getPageHeadTitle from '../generic/utils';
const CourseTeam = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const {
@@ -55,6 +56,12 @@ const CourseTeam = ({ courseId }) => {
handleInternetConnectionFailed,
} = useCourseTeam({ intl, courseId });
const {
checkPermission,
} = useUserPermissions();
const hasManageAllUsersPerm = checkPermission('manage_all_users');
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
@@ -77,7 +84,7 @@ const CourseTeam = ({ courseId }) => {
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions && (
headerActions={(isAllowActions || hasManageAllUsersPerm) && (
<Button
variant="primary"
iconBefore={IconAdd}
@@ -104,13 +111,13 @@ const CourseTeam = ({ courseId }) => {
role={role}
email={email}
currentUserEmail={currentUserEmail || ''}
isAllowActions={isAllowActions}
isAllowActions={isAllowActions || hasManageAllUsersPerm}
isHideActions={role === USER_ROLES.admin && isSingleAdmin}
onChangeRole={handleChangeRoleUserSubmit}
onDelete={handleOpenDeleteModal}
/>
)) : null}
{isShowAddTeamMember && (
{(isShowAddTeamMember || hasManageAllUsersPerm) && (
<AddTeamMember
onFormOpen={openForm}
isButtonDisable={isFormVisible}

View File

@@ -14,6 +14,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../store';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
import CourseTeam from './CourseTeam';
import messages from './messages';
import { USER_ROLES } from '../constants';
@@ -24,6 +26,8 @@ let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['manage_all_users'] };
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -44,7 +48,7 @@ describe('<CourseTeam />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -53,6 +57,12 @@ describe('<CourseTeam />', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: true });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
});
it('render CourseTeam component with 3 team members correctly', async () => {
@@ -165,13 +175,13 @@ describe('<CourseTeam />', () => {
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: 'Add a new team member' });
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false and hasManageAllUsersPerm is false', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
@@ -179,12 +189,39 @@ describe('<CourseTeam />', () => {
...courseTeamWithOneUser,
allowActions: false,
});
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: true });
const { queryByRole, queryByTestId } = render(<RootWrapper />);
const { queryByRole, queryByText } = render(<RootWrapper />);
await waitFor(() => {
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
expect(queryByText('add-team-member')).not.toBeInTheDocument();
});
});
it('displays "Add New Member" and AddTeamMember component when hasManageAllUsersPerm is true', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, {
...courseTeamWithOneUser,
allowActions: false,
});
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: true });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const { getByRole } = render(<RootWrapper />);
await waitFor(() => {
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
});
});

View File

@@ -31,7 +31,7 @@ const CourseTeamMember = ({
<div className="course-team-member" data-testid="course-team-member">
<div className="member-info">
<Badge className={`badge-current-user bg-${badgeColor} text-light-100`}>
{isAdminRole
{(isAdminRole)
? intl.formatMessage(messages.roleAdmin)
: intl.formatMessage(messages.roleStaff)}
{currentUserEmail === email && (
@@ -46,11 +46,13 @@ const CourseTeamMember = ({
!isHideActions ? (
<div className="member-actions">
<Button
variant={isAdminRole ? 'tertiary' : 'primary'}
variant={(isAdminRole) ? 'tertiary' : 'primary'}
size="sm"
onClick={() => onChangeRole(email, isAdminRole ? USER_ROLES.staff : USER_ROLES.admin)}
>
{isAdminRole ? intl.formatMessage(messages.removeButton) : intl.formatMessage(messages.addButton)}
{(isAdminRole)
? intl.formatMessage(messages.removeButton)
: intl.formatMessage(messages.addButton)}
</Button>
<IconButtonWithTooltip
src={DeleteOutline}

View File

@@ -8,12 +8,12 @@ import {
} from '@edx/paragon';
import { Add as AddIcon } from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import { useModel } from '../generic/model-store';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import ProcessingNotification from '../generic/processing-notification';
import SubHeader from '../generic/sub-header/SubHeader';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
import { RequestStatus } from '../data/constants';
import CourseHandouts from './course-handouts/CourseHandouts';
import CourseUpdate from './course-update/CourseUpdate';
@@ -25,6 +25,8 @@ import { useCourseUpdates } from './hooks';
import { getLoadingStatuses, getSavingStatuses } from './data/selectors';
import { matchesAnyStatus } from './utils';
import getPageHeadTitle from '../generic/utils';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import { useUserPermissions } from '../generic/hooks';
const CourseUpdates = ({ courseId }) => {
const intl = useIntl();
@@ -60,7 +62,15 @@ const CourseUpdates = ({ courseId }) => {
const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED);
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING);
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
return (
<>
<Container size="xl" className="px-4">

View File

@@ -22,11 +22,16 @@ import { executeThunk } from '../utils';
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
import CourseUpdates from './CourseUpdates';
import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['manage_content'] };
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -61,7 +66,7 @@ const RootWrapper = () => (
);
describe('<CourseUpdates />', () => {
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -79,6 +84,7 @@ describe('<CourseUpdates />', () => {
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
});
it('render CourseUpdates component correctly', async () => {
@@ -162,6 +168,26 @@ describe('<CourseUpdates />', () => {
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
});
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('should not show PermissionDeniedAlert if User Permissions are the correct ones', async () => {
const { queryByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument();
});
it('Add new update form is visible after clicking "New update" button', async () => {
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);

View File

@@ -25,6 +25,9 @@ import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './
import ExportModalError from './export-modal-error/ExportModalError';
import ExportFooter from './export-footer/ExportFooter';
import ExportStepper from './export-stepper/ExportStepper';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const CourseExportPage = ({ intl, courseId }) => {
const dispatch = useDispatch();
@@ -38,6 +41,14 @@ const CourseExportPage = ({ intl, courseId }) => {
const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS;
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const hasExportPermissions = !userPermissionsEnabled || (
userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))
);
const viewOnly = !userPermissionsEnabled || (
userPermissionsEnabled && checkPermission('view_course_settings') && !checkPermission('manage_course_settings')
);
useEffect(() => {
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
@@ -48,6 +59,12 @@ const CourseExportPage = ({ intl, courseId }) => {
}
}, []);
if (!hasExportPermissions) {
return (
<PermissionDeniedAlert />
);
}
return (
<>
<Helmet>
@@ -89,13 +106,14 @@ const CourseExportPage = ({ intl, courseId }) => {
className="mb-4"
onClick={() => dispatch(startExportingCourse(courseId))}
iconBefore={ArrowCircleDownIcon}
disabled={viewOnly}
>
{intl.formatMessage(messages.buttonTitle)}
</Button>
</Card.Section>
)}
</Card>
{exportTriggered && <ExportStepper courseId={courseId} />}
{exportTriggered && <ExportStepper courseId={courseId} viewOnly={viewOnly} />}
<ExportFooter />
</article>
</Layout.Element>

View File

@@ -12,6 +12,9 @@ import initializeStore from '../store';
import stepperMessages from './export-stepper/messages';
import modalErrorMessages from './export-modal-error/messages';
import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
import { executeThunk } from '../utils';
import { EXPORT_STAGES } from './data/constants';
import { exportPageMock } from './__mocks__';
import messages from './messages';
@@ -22,6 +25,8 @@ let axiosMock;
let cookies;
const courseId = '123';
const courseName = 'About Node JS';
const userId = 3;
let userPermissionsData = { permissions: [] };
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
@@ -49,7 +54,7 @@ describe('<CourseExportPage />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -60,6 +65,14 @@ describe('<CourseExportPage />', () => {
axiosMock
.onGet(postExportCourseApiUrl(courseId))
.reply(200, exportPageMock);
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});
@@ -85,8 +98,57 @@ describe('<CourseExportPage />', () => {
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
});
});
it('should render permissionDenied if incorrect permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('should render without errors if correct permissions', async () => {
const { getByText } = render(<RootWrapper />);
userPermissionsData = { permissions: ['manage_course_settings'] };
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(exportPageElement).toBeInTheDocument();
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
});
});
it('should render without errors if viewOnly permissions', async () => {
const { getByText } = render(<RootWrapper />);
userPermissionsData = { permissions: ['view_course_settings'] };
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
const buttonElement = getByText(messages.buttonTitle.defaultMessage);
expect(exportPageElement).toBeInTheDocument();
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(buttonElement).toBeInTheDocument();
expect(buttonElement.disabled).toEqual(true);
});
userPermissionsData = { permissions: ['manage_course_settings'] };
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
});
it('should start exporting on click', async () => {
const { getByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const button = container.querySelector('.btn-primary');
fireEvent.click(button);
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
@@ -96,6 +158,8 @@ describe('<CourseExportPage />', () => {
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } });
const { getByText, queryByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
@@ -116,6 +180,8 @@ describe('<CourseExportPage />', () => {
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' });
const { getByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
@@ -129,6 +195,8 @@ describe('<CourseExportPage />', () => {
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' });
const { getByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return

View File

@@ -17,7 +17,7 @@ import { EXPORT_STAGES } from '../data/constants';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
const ExportStepper = ({ intl, courseId }) => {
const ExportStepper = ({ intl, courseId, viewOnly }) => {
const currentStage = useSelector(getCurrentStage);
const downloadPath = useSelector(getDownloadPath);
const successDate = useSelector(getSuccessDate);
@@ -90,7 +90,7 @@ const ExportStepper = ({ intl, courseId }) => {
errorMessage={errorMessage}
hasError={!!errorMessage}
/>
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download disabled={viewOnly}>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
</div>
);
};
@@ -98,6 +98,7 @@ const ExportStepper = ({ intl, courseId }) => {
ExportStepper.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(ExportStepper);

View File

@@ -1,20 +1,36 @@
import React from 'react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import messages from './messages';
import ExportStepper from './ExportStepper';
import { exportStepperPageMock } from './__mocks__';
import { fetchExportStatus } from '../data/thunks';
import { getExportStatusApiUrl } from '../data/api';
const courseId = 'course-123';
let axiosMock;
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} />
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} viewOnly={false} />
</IntlProvider>
</AppProvider>
);
const ViewOnlyRootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} viewOnly />
</IntlProvider>
</AppProvider>
);
@@ -30,9 +46,22 @@ describe('<ExportStepper />', () => {
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, exportStepperPageMock);
executeThunk(fetchExportStatus(courseId), store.dispatch);
});
it('render stepper correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
});
it('render stepper correctly if button is disabled', () => {
const { getByText } = render(<ViewOnlyRootWrapper />);
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
const buttonElement = getByText(messages.downloadCourseButtonTitle.defaultMessage, {
selector: '.disabled',
});
expect(buttonElement).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,4 @@
module.exports = {
exportStatus: 3,
exportOutput: '/test',
};

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as exportStepperPageMock } from './exportStepperPage';

View File

@@ -30,6 +30,9 @@ import {
import { getFileSizeToClosestByte } from '../../utils';
import FileThumbnail from './FileThumbnail';
import FileInfoModalSidebar from './FileInfoModalSidebar';
import { useUserPermissions } from '../../generic/hooks';
import { getUserPermissionsEnabled } from '../../generic/data/selectors';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
const FilesPage = ({
courseId,
@@ -39,6 +42,9 @@ const FilesPage = ({
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
useEffect(() => {
dispatch(fetchAssets(courseId));
@@ -160,6 +166,11 @@ const FilesPage = ({
{ ...accessColumn },
];
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingStatus === RequestStatus.DENIED) {
return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">

View File

@@ -39,10 +39,16 @@ import {
} from './data/thunks';
import { getAssetsUrl } from './data/api';
import messages from '../generic/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks';
let axiosMock;
let store;
let file;
const userId = 3;
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
const userPermissionsData = { permissions: ['manage_content'] };
ReactDOM.createPortal = jest.fn(node => node);
jest.mock('file-saver');
@@ -68,6 +74,8 @@ const mockStore = async (
}
renderComponent();
await executeThunk(fetchAssets(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const emptyMockStore = async (status) => {
@@ -75,6 +83,27 @@ const emptyMockStore = async (status) => {
axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse());
renderComponent();
await executeThunk(fetchAssets(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const wrongUserPermissionsMockStore = async () => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const disabledUserPermissionsFlagMockStore = async () => {
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const userPermissionsMockStore = async () => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
describe('FilesAndUploads', () => {
@@ -82,7 +111,7 @@ describe('FilesAndUploads', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: false,
roles: [],
@@ -100,6 +129,21 @@ describe('FilesAndUploads', () => {
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
renderComponent();
await wrongUserPermissionsMockStore();
expect(screen.getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('should not show PermissionDeniedAlert if User Permissions Flag is not enabled', async () => {
renderComponent();
await disabledUserPermissionsFlagMockStore();
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
});
it('should not show PermissionDeniedAlert if User Permissions Flag is enabled and permissions are correct', async () => {
renderComponent();
await userPermissionsMockStore();
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
});
it('should return placeholder component', async () => {
await mockStore(RequestStatus.DENIED);

View File

@@ -43,6 +43,9 @@ import VideoThumbnail from './VideoThumbnail';
import { getFormattedDuration, resampleFile } from './data/utils';
import FILES_AND_UPLOAD_TYPE_FILTERS from '../generic/constants';
import VideoInfoModalSidebar from './info-sidebar';
import { useUserPermissions } from '../../generic/hooks';
import { getUserPermissionsEnabled } from '../../generic/data/selectors';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
const VideosPage = ({
courseId,
@@ -53,6 +56,9 @@ const VideosPage = ({
const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
useEffect(() => {
dispatch(fetchVideos(courseId));
@@ -179,6 +185,11 @@ const VideosPage = ({
{ ...processingStatusColumn },
];
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingStatus === RequestStatus.DENIED) {
return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">

View File

@@ -39,10 +39,15 @@ import {
import { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api';
import videoMessages from './messages';
import messages from '../generic/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks';
let axiosMock;
let store;
let file;
const userId = 3;
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
const userPermissionsData = { permissions: ['manage_content'] };
jest.mock('file-saver');
const renderComponent = () => {
@@ -55,9 +60,7 @@ const renderComponent = () => {
);
};
const mockStore = async (
status,
) => {
const mockStore = async (status) => {
const fetchVideosUrl = getVideosUrl(courseId);
const videosData = generateFetchVideosApiResponse();
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), videosData);
@@ -68,6 +71,8 @@ const mockStore = async (
renderComponent();
await executeThunk(fetchVideos(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const emptyMockStore = async (status) => {
@@ -75,6 +80,27 @@ const emptyMockStore = async (status) => {
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse());
renderComponent();
await executeThunk(fetchVideos(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const wrongUserPermissionsMockStore = async () => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const disabledUserPermissionsFlagMockStore = async () => {
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const userPermissionsMockStore = async () => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
describe('Videos page', () => {
@@ -82,7 +108,7 @@ describe('Videos page', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: false,
roles: [],
@@ -146,13 +172,31 @@ describe('Videos page', () => {
expect(screen.getByTestId('files-data-table')).toBeVisible();
});
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
renderComponent();
await wrongUserPermissionsMockStore();
expect(screen.getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('should not show PermissionDeniedAlert if User Permissions Flag is not enabled', async () => {
renderComponent();
await disabledUserPermissionsFlagMockStore();
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
});
it('should not show PermissionDeniedAlert if User Permissions Flag is enabled and permission is correct', async () => {
renderComponent();
await userPermissionsMockStore();
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
});
});
describe('valid videos', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: false,
roles: [],

View File

@@ -16,6 +16,8 @@ export const initialState = {
},
organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'],
savingStatus: '',
userPermissions: [],
userPermissionsEnabled: false,
},
studioHome: {
loadingStatuses: {

View File

@@ -1,6 +1,6 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '../../utils';
@@ -8,7 +8,8 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
export const getUserPermissionsUrl = (courseId, userId) => `${getApiBaseUrl()}/api/course_roles/v1/user_permissions/?course_id=${encodeURIComponent(courseId)}&user_id=${userId}`;
export const getUserPermissionsEnabledFlagUrl = new URL('/api/course_roles/v1/user_permissions/enabled/', getApiBaseUrl()).href;
/**
* Get's organizations data. Returns list of organization names.
* @returns {Promise<string[]>}
@@ -43,3 +44,22 @@ export async function createOrRerunCourse(courseData) {
);
return camelCaseObject(data);
}
/**
* Get user course roles permissions.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getUserPermissions(courseId) {
const { userId } = getAuthenticatedUser();
const { data } = await getAuthenticatedHttpClient()
.get(getUserPermissionsUrl(courseId, userId));
return camelCaseObject(data);
}
export async function getUserPermissionsEnabledFlag() {
const { data } = await getAuthenticatedHttpClient()
.get(getUserPermissionsEnabledFlagUrl);
return data || false;
}

View File

@@ -9,9 +9,14 @@ import {
getCreateOrRerunCourseUrl,
getCourseRerunUrl,
getCourseRerun,
getUserPermissions,
getUserPermissionsUrl,
getUserPermissionsEnabledFlag,
getUserPermissionsEnabledFlagUrl,
} from './api';
let axiosMock;
const courseId = 'course-123';
describe('generic api calls', () => {
beforeEach(() => {
@@ -41,7 +46,6 @@ describe('generic api calls', () => {
});
it('should get course rerun', async () => {
const courseId = 'course-mock-id';
const courseRerunData = {
allowUnicodeCourseId: false,
courseCreatorStatus: 'granted',
@@ -72,4 +76,24 @@ describe('generic api calls', () => {
expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl());
expect(result).toEqual(courseRerunData);
});
it('should get user permissions', async () => {
const permissionsData = { permissions: ['manage_all_users'] };
const queryUrl = getUserPermissionsUrl(courseId, 3);
axiosMock.onGet(queryUrl).reply(200, permissionsData);
const result = await getUserPermissions(courseId);
expect(axiosMock.history.get[0].url).toEqual(queryUrl);
expect(result).toEqual(permissionsData);
});
it('should get user permissions enabled flag', async () => {
const permissionsEnabledData = { enabled: true };
const queryUrl = getUserPermissionsEnabledFlagUrl;
axiosMock.onGet(queryUrl).reply(200, permissionsEnabledData);
const result = await getUserPermissionsEnabledFlag();
expect(axiosMock.history.get[0].url).toEqual(queryUrl);
expect(result).toEqual(permissionsEnabledData);
});
});

View File

@@ -5,3 +5,5 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
export const getUserPermissions = (state) => state.generic.userPermissions.permissions;
export const getUserPermissionsEnabled = (state) => state.generic.userPermissionsEnabled;

View File

@@ -9,6 +9,8 @@ const slice = createSlice({
loadingStatuses: {
organizationLoadingStatus: RequestStatus.IN_PROGRESS,
courseRerunLoadingStatus: RequestStatus.IN_PROGRESS,
userPermissionsLoadingStatus: RequestStatus.IN_PROGRESS,
userPermissionsEnabledLoadingStatus: RequestStatus.IN_PROGRESS,
},
savingStatus: '',
organizations: [],
@@ -18,6 +20,8 @@ const slice = createSlice({
redirectUrlObj: {},
postErrors: {},
},
userPermissions: [],
userPermissionsEnabled: false,
},
reducers: {
fetchOrganizations: (state, { payload }) => {
@@ -41,6 +45,12 @@ const slice = createSlice({
updatePostErrors: (state, { payload }) => {
state.createOrRerunCourse.postErrors = payload;
},
updateUserPermissions: (state, { payload }) => {
state.userPermissions = payload;
},
updateUserPermissionsEnabled: (state, { payload }) => {
state.userPermissionsEnabled = payload;
},
},
});
@@ -52,6 +62,8 @@ export const {
updateSavingStatus,
updateCourseData,
updateRedirectUrlObj,
updateUserPermissions,
updateUserPermissionsEnabled,
} = slice.actions;
export const {

View File

@@ -1,5 +1,7 @@
import { RequestStatus } from '../../data/constants';
import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api';
import {
createOrRerunCourse, getOrganizations, getCourseRerun, getUserPermissions, getUserPermissionsEnabledFlag,
} from './api';
import {
fetchOrganizations,
updatePostErrors,
@@ -7,6 +9,8 @@ import {
updateRedirectUrlObj,
updateCourseRerunData,
updateSavingStatus,
updateUserPermissions,
updateUserPermissionsEnabled,
} from './slice';
export function fetchOrganizationsQuery() {
@@ -49,3 +53,28 @@ export function updateCreateOrRerunCourseQuery(courseData) {
}
};
}
export function fetchUserPermissionsQuery(courseId) {
return async (dispatch) => {
try {
const userPermissions = await getUserPermissions(courseId);
dispatch(updateUserPermissions(userPermissions));
dispatch(updateLoadingStatuses({ userPermissionsLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLoadingStatuses({ userPermissionsLoadingStatus: RequestStatus.FAILED }));
}
};
}
export function fetchUserPermissionsEnabledFlag() {
return async (dispatch) => {
try {
const data = await getUserPermissionsEnabledFlag();
dispatch(updateUserPermissionsEnabled(data.enabled || false));
dispatch(updateLoadingStatuses({ userPermissionsEnabledLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateUserPermissionsEnabled(false));
dispatch(updateLoadingStatuses({ userPermissionsEnabledLoadingStatus: RequestStatus.FAILED }));
}
};
}

21
src/generic/hooks.jsx Normal file
View File

@@ -0,0 +1,21 @@
import { useSelector } from 'react-redux';
import { getUserPermissions, getUserPermissionsEnabled } from './data/selectors';
const useUserPermissions = () => {
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const userPermissions = useSelector(getUserPermissions);
const checkPermission = (permission) => {
if (!userPermissionsEnabled || !Array.isArray(userPermissions)) {
return false;
}
return userPermissions.includes(permission);
};
return {
checkPermission,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useUserPermissions };

View File

@@ -30,6 +30,9 @@ import CreditSection from './credit-section';
import DeadlineSection from './deadline-section';
import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks';
import getPageHeadTitle from '../generic/utils';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const GradingSettings = ({ intl, courseId }) => {
const gradingSettingsData = useSelector(getGradingSettings);
@@ -43,6 +46,14 @@ const GradingSettings = ({ intl, courseId }) => {
const [isQueryPending, setIsQueryPending] = useState(false);
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
const [eligibleGrade, setEligibleGrade] = useState(null);
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const hasGradingPermissions = !userPermissionsEnabled || (
userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))
);
const viewOnly = !userPermissionsEnabled || (
userPermissionsEnabled && checkPermission('view_course_settings') && !checkPermission('manage_course_settings')
);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
@@ -83,6 +94,12 @@ const GradingSettings = ({ intl, courseId }) => {
dispatch(fetchCourseSettingsQuery(courseId));
}, [courseId]);
if (!hasGradingPermissions) {
return (
<PermissionDeniedAlert />
);
}
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
@@ -156,6 +173,7 @@ const GradingSettings = ({ intl, courseId }) => {
resetDataRef={resetDataRef}
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
setEligibleGrade={setEligibleGrade}
viewOnly={viewOnly}
/>
</section>
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
@@ -170,6 +188,7 @@ const GradingSettings = ({ intl, courseId }) => {
minimumGradeCredit={minimumGradeCredit}
setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert}
viewOnly={viewOnly}
/>
</section>
)}
@@ -183,6 +202,7 @@ const GradingSettings = ({ intl, courseId }) => {
gracePeriod={gracePeriod}
setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert}
viewOnly={viewOnly}
/>
</section>
<section>
@@ -201,11 +221,13 @@ const GradingSettings = ({ intl, courseId }) => {
setGradingData={setGradingData}
courseAssignmentLists={courseAssignmentLists}
setShowSuccessAlert={setShowSuccessAlert}
viewOnly={viewOnly}
/>
<Button
variant="primary"
iconBefore={IconAdd}
onClick={handleAddAssignment}
disabled={viewOnly}
>
{intl.formatMessage(messages.addNewAssignmentTypeBtn)}
</Button>

View File

@@ -12,9 +12,15 @@ import gradingSettings from './__mocks__/gradingSettings';
import GradingSettings from './GradingSettings';
import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
import { executeThunk } from '../utils';
const courseId = '123';
const userId = 3;
let axiosMock;
let store;
const userPermissionsData = { permissions: [] };
const RootWrapper = () => (
<AppProvider store={store}>
@@ -28,7 +34,7 @@ describe('<GradingSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -40,6 +46,25 @@ describe('<GradingSettings />', () => {
axiosMock
.onGet(getGradingSettingsApiUrl(courseId))
.reply(200, gradingSettings);
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
});
afterEach(() => {
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
});
it('should render without errors', async () => {
@@ -54,6 +79,38 @@ describe('<GradingSettings />', () => {
});
});
it('should render without errors if access controlled by permissions', async () => {
const { getByText, getAllByText, getAllByTestId } = render(<RootWrapper />);
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: true });
const permissionsData = { permissions: ['manage_course_settings'] };
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, permissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await waitFor(() => {
const gradingElements = getAllByText(messages.headingTitle.defaultMessage);
const gradingTitle = gradingElements[0];
const segmentButton = getAllByTestId('grading-scale-btn-add-segment');
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(gradingTitle).toBeInTheDocument();
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.policiesDescription.defaultMessage)).toBeInTheDocument();
expect(segmentButton.length).toEqual(1);
expect(segmentButton[0]).toBeInTheDocument();
expect(segmentButton[0].disabled).toEqual(false);
});
});
it('should render permissionDenied if incorrect permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('should update segment input value and show save alert', async () => {
const { getByTestId, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
@@ -76,6 +133,7 @@ describe('<GradingSettings />', () => {
expect(segmentInput).toHaveValue('a');
});
});
it('should save segment input changes and display saving message', async () => {
const { getByText, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
@@ -88,4 +146,23 @@ describe('<GradingSettings />', () => {
expect(getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument();
});
});
it('should show disabled button to update segments', async () => {
const { getAllByTestId } = render(<RootWrapper />);
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: true });
const permissionsData = { permissions: ['view_course_settings'] };
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, permissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await waitFor(() => {
const segmentButton = getAllByTestId('grading-scale-btn-add-segment');
expect(segmentButton.length).toEqual(1);
expect(segmentButton[0]).toBeInTheDocument();
expect(segmentButton[0].disabled).toEqual(true);
});
});
});

View File

@@ -20,6 +20,7 @@ const AssignmentItem = ({
secondErrorMsg,
gradeField,
trailingElement,
viewOnly,
}) => (
<li className={className}>
<Form.Group className={classNames('form-group-custom', {
@@ -37,6 +38,7 @@ const AssignmentItem = ({
value={value}
isInvalid={errorEffort}
trailingElement={trailingElement}
disabled={viewOnly}
/>
<Form.Control.Feedback className="grading-description">
{descriptions}
@@ -81,6 +83,7 @@ AssignmentItem.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
gradeField: PropTypes.shape(defaultAssignmentsPropTypes),
trailingElement: PropTypes.string,
viewOnly: PropTypes.bool.isRequired,
};
export default AssignmentItem;

View File

@@ -8,7 +8,7 @@ import { ASSIGNMENT_TYPES, DUPLICATE_ASSIGNMENT_NAME } from '../utils/enum';
import messages from '../messages';
const AssignmentTypeName = ({
intl, value, errorEffort, onChange,
intl, value, errorEffort, onChange, viewOnly,
}) => {
const initialAssignmentName = useRef(value);
@@ -28,6 +28,7 @@ const AssignmentTypeName = ({
onChange={onChange}
value={value}
isInvalid={Boolean(errorEffort)}
disabled={viewOnly}
/>
<Form.Control.Feedback className="grading-description">
{intl.formatMessage(messages.assignmentTypeNameDescription)}
@@ -64,6 +65,7 @@ AssignmentTypeName.propTypes = {
onChange: PropTypes.func.isRequired,
errorEffort: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(AssignmentTypeName);

View File

@@ -22,6 +22,7 @@ const AssignmentSection = ({
setGradingData,
courseAssignmentLists,
setShowSuccessAlert,
viewOnly,
}) => {
const [errorList, setErrorList] = useState({});
const {
@@ -83,6 +84,7 @@ const AssignmentSection = ({
value={gradeField.type}
errorEffort={errorList[`${type}-${gradeField.id}`]}
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
viewOnly={viewOnly}
/>
<AssignmentItem
className="course-grading-assignment-abbreviation"
@@ -92,6 +94,7 @@ const AssignmentSection = ({
name="shortLabel"
value={gradeField.shortLabel}
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
viewOnly={viewOnly}
/>
<AssignmentItem
className="course-grading-assignment-total-grade"
@@ -106,6 +109,7 @@ const AssignmentSection = ({
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
errorEffort={errorList[`${weight}-${gradeField.id}`]}
trailingElement="%"
viewOnly={viewOnly}
/>
<AssignmentItem
className="course-grading-assignment-total-number"
@@ -118,6 +122,7 @@ const AssignmentSection = ({
value={gradeField.minCount}
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
errorEffort={errorList[`${minCount}-${gradeField.id}`]}
viewOnly={viewOnly}
/>
<AssignmentItem
className="course-grading-assignment-number-droppable"
@@ -134,6 +139,7 @@ const AssignmentSection = ({
type: gradeField.type,
})}
errorEffort={errorList[`${dropCount}-${gradeField.id}`]}
viewOnly={viewOnly}
/>
</ol>
{showDefinedCaseAlert && (
@@ -185,6 +191,7 @@ const AssignmentSection = ({
variant="outline-primary"
size="sm"
onClick={() => handleRemoveAssignment(gradeField.id)}
disabled={viewOnly}
>
{intl.formatMessage(messages.assignmentDeleteButton)}
</Button>
@@ -210,6 +217,7 @@ AssignmentSection.propTypes = {
graders: PropTypes.arrayOf(
PropTypes.shape(defaultAssignmentsPropTypes),
),
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(AssignmentSection);

View File

@@ -18,6 +18,7 @@ const RootWrapper = (props = {}) => (
minimumGradeCredit={0.1}
setGradingData={jest.fn()}
setShowSuccessAlert={jest.fn()}
viewOnly={false}
{...props}
/>
</IntlProvider>
@@ -38,6 +39,14 @@ describe('<CreditSection />', () => {
expect(inputElement.value).toBe('10');
fireEvent.change(inputElement, { target: { value: '2' } });
expect(testObj.minimumGradeCredit).toBe(0.02);
expect(inputElement.disabled).toBe(false);
});
});
it('should disable the fields if viewOnly', async () => {
const { getByTestId } = render(<RootWrapper viewOnly />);
await waitFor(() => {
const inputElement = getByTestId('minimum-grade-credit-input');
expect(inputElement.disabled).toBe(true);
});
});
});

View File

@@ -7,7 +7,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CreditSection = ({
intl, eligibleGrade, setShowSavePrompt, minimumGradeCredit, setGradingData, setShowSuccessAlert,
intl, eligibleGrade, setShowSavePrompt, minimumGradeCredit, setGradingData, setShowSuccessAlert, viewOnly,
}) => {
const [errorEffort, setErrorEffort] = useState(false);
@@ -46,6 +46,7 @@ const CreditSection = ({
value={Math.round(parseFloat(minimumGradeCredit) * 100) || ''}
name="minimum_grade_credit"
onChange={handleCreditChange}
disabled={viewOnly}
/>
<Form.Control.Feedback className="grading-description">
{intl.formatMessage(messages.creditEligibilityDescription)}
@@ -66,6 +67,7 @@ CreditSection.propTypes = {
setGradingData: PropTypes.func.isRequired,
setShowSuccessAlert: PropTypes.func.isRequired,
minimumGradeCredit: PropTypes.number.isRequired,
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(CreditSection);

View File

@@ -22,6 +22,7 @@ const RootWrapper = (props = {}) => (
setShowSavePrompt={jest.fn()}
setGradingData={jest.fn()}
setShowSuccessAlert={jest.fn()}
viewOnly={false}
{...props}
/>
</IntlProvider>
@@ -46,6 +47,7 @@ describe('<DeadlineSection />', () => {
fireEvent.change(inputElement, { target: { value: '13:13' } });
expect(testObj.gracePeriod.hours).toBe(13);
expect(testObj.gracePeriod.minutes).toBe(13);
expect(inputElement.disabled).toEqual(false);
});
});
it('checking deadline input value if grace Period equal null', async () => {
@@ -78,4 +80,11 @@ describe('<DeadlineSection />', () => {
expect(getByText(`Grace period must be specified in ${TIME_FORMAT.toUpperCase()} format.`)).toBeInTheDocument();
});
});
it('checking deadline input is disabled if viewOnly', async () => {
const { getByTestId } = render(<RootWrapper viewOnly />);
await waitFor(() => {
const inputElement = getByTestId('deadline-period-input');
expect(inputElement.disabled).toEqual(true);
});
});
});

View File

@@ -9,7 +9,7 @@ import { formatTime, timerValidation } from './utils';
import messages from './messages';
const DeadlineSection = ({
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert,
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert, viewOnly,
}) => {
const timeStampValue = gracePeriod
? gracePeriod.hours && `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}`
@@ -52,6 +52,7 @@ const DeadlineSection = ({
value={newDeadlineValue}
onChange={handleDeadlineChange}
placeholder={TIME_FORMAT.toUpperCase()}
disabled={viewOnly}
/>
<Form.Control.Feedback className="grading-description">
{intl.formatMessage(messages.gracePeriodOnDeadlineDescription)}
@@ -78,6 +79,7 @@ DeadlineSection.propTypes = {
hours: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
minutes: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}),
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(DeadlineSection);

View File

@@ -23,6 +23,7 @@ const GradingScale = ({
sortedGrades,
setOverrideInternetConnectionAlert,
setEligibleGrade,
viewOnly,
}) => {
const [gradingSegments, setGradingSegments] = useState(sortedGrades);
const [letters, setLetters] = useState(gradeLetters);
@@ -191,7 +192,7 @@ const GradingScale = ({
<IconButtonWithTooltip
tooltipPlacement="top"
tooltipContent={intl.formatMessage(messages.addNewSegmentButtonAltText)}
disabled={gradingSegments.length >= 5}
disabled={gradingSegments.length >= 5 || viewOnly}
data-testid="grading-scale-btn-add-segment"
className="mr-3"
src={IconAdd}
@@ -245,6 +246,7 @@ GradingScale.propTypes = {
}),
).isRequired,
setEligibleGrade: PropTypes.func.isRequired,
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(GradingScale);

View File

@@ -16,7 +16,7 @@ const sortedGrades = [
{ current: 20, previous: 0 },
];
const RootWrapper = () => (
const RootWrapper = (viewOnly = { viewOnly: false }) => (
<IntlProvider locale="en" messages={{}}>
<GradingScale
intl={injectIntl}
@@ -29,6 +29,7 @@ const RootWrapper = () => (
setGradingData={jest.fn()}
setOverrideInternetConnectionAlert={jest.fn()}
setEligibleGrade={jest.fn()}
{...viewOnly}
/>
</IntlProvider>
);
@@ -73,6 +74,26 @@ describe('<GradingScale />', () => {
});
});
it('should not disable new grading segment button when viewOnly=false', async () => {
const { getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const addNewSegmentBtn = getAllByTestId('grading-scale-btn-add-segment');
expect(addNewSegmentBtn).toHaveLength(1);
expect(addNewSegmentBtn[0]).toBeInTheDocument();
expect(addNewSegmentBtn[0].disabled).toBe(false);
});
});
it('should disable new grading segment button when viewOnly', async () => {
const { getAllByTestId } = render(<RootWrapper viewOnly />);
await waitFor(() => {
const addNewSegmentBtn = getAllByTestId('grading-scale-btn-add-segment');
expect(addNewSegmentBtn).toHaveLength(1);
expect(addNewSegmentBtn[0]).toBeInTheDocument();
expect(addNewSegmentBtn[0].disabled).toBe(true);
});
});
it('should remove grading segment when "Remove" button is clicked', async () => {
const { getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {

View File

@@ -1,10 +1,14 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { StudioHeader } from '@edx/frontend-component-header';
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissions, getUserPermissionsEnabled } from '../generic/data/selectors';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
import messages from './messages';
const Header = ({
@@ -16,24 +20,73 @@ const Header = ({
// injected
intl,
}) => {
const dispatch = useDispatch();
const { checkPermission } = useUserPermissions();
const userPermissions = useSelector(getUserPermissions);
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const hasContentPermissions = !userPermissionsEnabled || (userPermissionsEnabled && checkPermission('manage_content'));
const hasOutlinePermissions = !userPermissionsEnabled || (userPermissionsEnabled && (checkPermission('manage_content') || checkPermission('manage_libraries')));
const hasAdvancedSettingsAccess = !userPermissionsEnabled
|| (userPermissionsEnabled && (checkPermission('manage_advanced_settings') || checkPermission('view_course_settings')));
const hasSettingsPermissions = !userPermissionsEnabled
|| (userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings')));
const hasToolsPermissions = !userPermissionsEnabled
|| (userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings')));
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const mainMenuDropdowns = [
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.content']),
items: getContentMenuItems({ studioBaseUrl, courseId, intl }),
},
const contentMenu = getContentMenuItems({
studioBaseUrl,
courseId,
intl,
hasContentPermissions,
hasOutlinePermissions,
});
const mainMenuDropdowns = [];
const toolsMenu = getToolsMenuItems({
studioBaseUrl,
courseId,
intl,
hasToolsPermissions,
});
useEffect(() => {
dispatch(fetchUserPermissionsEnabledFlag());
if (!userPermissions) {
dispatch(fetchUserPermissionsQuery(courseId));
}
}, [courseId]);
if (contentMenu.length > 0) {
mainMenuDropdowns.push(
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.content']),
items: contentMenu,
},
);
}
mainMenuDropdowns.push(
{
id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.settings']),
items: getSettingMenuItems({ studioBaseUrl, courseId, intl }),
items: getSettingMenuItems({
studioBaseUrl,
courseId,
intl,
hasSettingsPermissions,
hasAdvancedSettingsAccess,
}),
},
{
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.tools']),
items: getToolsMenuItems({ studioBaseUrl, courseId, intl }),
},
];
);
if (toolsMenu.length > 0) {
mainMenuDropdowns.push(
{
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.tools']),
items: toolsMenu,
},
);
}
const outlineLink = `${studioBaseUrl}/course/${courseId}`;
return (
<StudioHeader

View File

@@ -2,72 +2,127 @@ import { getConfig } from '@edx/frontend-platform';
import { getPagePath } from '../utils';
import messages from './messages';
export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => {
const items = [
{
href: `${studioBaseUrl}/course/${courseId}`,
title: intl.formatMessage(messages['header.links.outline']),
},
{
href: `${studioBaseUrl}/course_info/${courseId}`,
title: intl.formatMessage(messages['header.links.updates']),
},
{
href: getPagePath(courseId, 'true', 'tabs'),
title: intl.formatMessage(messages['header.links.pages']),
},
{
href: `${studioBaseUrl}/assets/${courseId}`,
title: intl.formatMessage(messages['header.links.filesAndUploads']),
},
];
export const getContentMenuItems = ({
studioBaseUrl,
courseId,
intl,
hasContentPermissions,
hasOutlinePermissions,
}) => {
const items = [];
if (hasOutlinePermissions) {
items.push(
{
href: `${studioBaseUrl}/course/${courseId}`,
title: intl.formatMessage(messages['header.links.outline']),
},
);
}
if (hasContentPermissions) {
items.push(
{
href: `${studioBaseUrl}/course_info/${courseId}`,
title: intl.formatMessage(messages['header.links.updates']),
},
{
href: getPagePath(courseId, 'true', 'tabs'),
title: intl.formatMessage(messages['header.links.pages']),
},
{
href: `${studioBaseUrl}/assets/${courseId}`,
title: intl.formatMessage(messages['header.links.filesAndUploads']),
},
);
}
if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') {
items.push({
href: `${studioBaseUrl}/videos/${courseId}`,
title: intl.formatMessage(messages['header.links.videoUploads']),
});
items.push(
{
href: `${studioBaseUrl}/videos/${courseId}`,
title: intl.formatMessage(messages['header.links.videoUploads']),
},
);
}
return items;
};
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
{
href: `${studioBaseUrl}/settings/details/${courseId}`,
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
},
{
href: `${studioBaseUrl}/settings/grading/${courseId}`,
title: intl.formatMessage(messages['header.links.grading']),
},
{
href: `${studioBaseUrl}/course_team/${courseId}`,
title: intl.formatMessage(messages['header.links.courseTeam']),
},
{
href: `${studioBaseUrl}/group_configurations/${courseId}`,
title: intl.formatMessage(messages['header.links.groupConfigurations']),
},
{
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
title: intl.formatMessage(messages['header.links.advancedSettings']),
},
{
href: `${studioBaseUrl}/certificates/${courseId}`,
title: intl.formatMessage(messages['header.links.certificates']),
},
]);
export const getSettingMenuItems = ({
studioBaseUrl,
courseId,
intl,
hasAdvancedSettingsAccess,
hasSettingsPermissions,
}) => {
const items = [];
export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
{
href: `${studioBaseUrl}/import/${courseId}`,
title: intl.formatMessage(messages['header.links.import']),
},
{
href: `${studioBaseUrl}/export/${courseId}`,
title: intl.formatMessage(messages['header.links.export']),
}, {
href: `${studioBaseUrl}/checklists/${courseId}`,
title: intl.formatMessage(messages['header.links.checklists']),
},
]);
items.push(
{
href: `${studioBaseUrl}/settings/details/${courseId}`,
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
},
);
if (hasSettingsPermissions) {
items.push(
{
href: `${studioBaseUrl}/settings/grading/${courseId}`,
title: intl.formatMessage(messages['header.links.grading']),
},
);
}
items.push(
{
href: `${studioBaseUrl}/course_team/${courseId}`,
title: intl.formatMessage(messages['header.links.courseTeam']),
},
);
if (hasSettingsPermissions) {
items.push(
{
href: `${studioBaseUrl}/group_configurations/course-v1:${courseId}`,
title: intl.formatMessage(messages['header.links.groupConfigurations']),
},
);
}
if (hasAdvancedSettingsAccess) {
items.push(
{
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
title: intl.formatMessage(messages['header.links.advancedSettings']),
},
);
}
items.push(
{
href: `${studioBaseUrl}/certificates/${courseId}`,
title: intl.formatMessage(messages['header.links.certificates']),
},
);
return items;
};
export const getToolsMenuItems = ({
studioBaseUrl,
courseId,
intl,
hasToolsPermissions,
}) => {
const items = [];
if (hasToolsPermissions) {
items.push(
{
href: `${studioBaseUrl}/import/${courseId}`,
title: intl.formatMessage(messages['header.links.import']),
},
{
href: `${studioBaseUrl}/export/${courseId}`,
title: intl.formatMessage(messages['header.links.export']),
},
{
href: `${studioBaseUrl}/checklists/${courseId}`,
title: intl.formatMessage(messages['header.links.checklists']),
},
);
}
return items;
};

View File

@@ -1,13 +1,16 @@
import { getConfig, setConfig } from '@edx/frontend-platform';
import { getContentMenuItems } from './utils';
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
const props = {
const baseProps = {
studioBaseUrl: 'UrLSTuiO',
courseId: '123',
intl: {
formatMessage: jest.fn(),
},
};
const contentProps = { ...baseProps, hasContentPermissions: true, hasOutlinePermissions: true };
const settingProps = { ...baseProps, hasAdvancedSettingsAccess: true, hasSettingsPermissions: true };
const toolsProps = { ...baseProps, hasToolsPermissions: true };
describe('header utils', () => {
describe('getContentMenuItems', () => {
@@ -16,7 +19,7 @@ describe('header utils', () => {
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
});
const actualItems = getContentMenuItems(props);
const actualItems = getContentMenuItems(contentProps);
expect(actualItems).toHaveLength(5);
});
it('should not include Video Uploads option', () => {
@@ -24,8 +27,84 @@ describe('header utils', () => {
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
});
const actualItems = getContentMenuItems(props);
const actualItems = getContentMenuItems(contentProps);
expect(actualItems).toHaveLength(4);
});
it('should include only Video Uploads option', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
});
const actualItems = getContentMenuItems({
...baseProps,
hasContentPermissions: false,
hasOutlinePermissions: false,
});
expect(actualItems).toHaveLength(1);
});
it('should include Outline if outline permissions', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
});
const actualItems = getContentMenuItems({
...baseProps,
hasContentPermissions: false,
hasOutlinePermissions: true,
});
expect(actualItems).toHaveLength(1);
});
it('should include content sections if content permissions', () => {
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
});
const actualItems = getContentMenuItems({
...baseProps,
hasContentPermissions: true,
hasOutlinePermissions: false,
});
expect(actualItems).toHaveLength(3);
});
});
describe('getSettingMenuItems', async () => {
it('should include all options', () => {
const actualItems = getSettingMenuItems(settingProps);
expect(actualItems).toHaveLength(6);
});
it('should include Advanced Settings option, but not settings options', () => {
const actualItems = getSettingMenuItems({
...baseProps,
hasSettingsPermissions: false,
hasAdvancedSettingsAccess: true,
});
expect(actualItems).toHaveLength(4);
});
it('should include settings, but not advanced settings', () => {
const actualItems = getSettingMenuItems({
...baseProps,
hasSettingsPermissions: true,
hasAdvancedSettingsAccess: false,
});
expect(actualItems).toHaveLength(5);
});
it('should only include default options', () => {
const actualItems = getSettingMenuItems({
...baseProps,
hasSettingsPermissions: false,
hasAdvancedSettingsAccess: false,
});
expect(actualItems).toHaveLength(3);
});
});
describe('getToolsMenuItems', async () => {
it('should include all options', () => {
const actualItems = getToolsMenuItems(toolsProps);
expect(actualItems).toHaveLength(3);
});
it('should not include any items if there are no permissions', () => {
const actualItems = getToolsMenuItems({ ...baseProps, hasToolsPermissions: false });
expect(actualItems).toHaveLength(0);
});
});
});

View File

@@ -22,6 +22,9 @@ import { LAST_IMPORT_COOKIE_NAME } from './data/constants';
import ImportSidebar from './import-sidebar/ImportSidebar';
import FileSection from './file-section/FileSection';
import messages from './messages';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const CourseImportPage = ({ intl, courseId }) => {
const dispatch = useDispatch();
@@ -32,6 +35,14 @@ const CourseImportPage = ({ intl, courseId }) => {
const loadingStatus = useSelector(getLoadingStatus);
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const hasImportPermissions = !userPermissionsEnabled || (
userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))
);
const viewOnly = !userPermissionsEnabled || (
userPermissionsEnabled && checkPermission('view_course_settings') && !checkPermission('manage_course_settings')
);
useEffect(() => {
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
@@ -43,6 +54,12 @@ const CourseImportPage = ({ intl, courseId }) => {
}
}, []);
if (!hasImportPermissions) {
return (
<PermissionDeniedAlert />
);
}
return (
<>
<Helmet>
@@ -72,7 +89,7 @@ const CourseImportPage = ({ intl, courseId }) => {
<p className="small">{intl.formatMessage(messages.description1)}</p>
<p className="small">{intl.formatMessage(messages.description2)}</p>
<p className="small">{intl.formatMessage(messages.description3)}</p>
<FileSection courseId={courseId} />
<FileSection courseId={courseId} viewOnly={viewOnly} />
{importTriggered && <ImportStepper courseId={courseId} />}
</article>
</Layout.Element>

View File

@@ -14,12 +14,17 @@ import CourseImportPage from './CourseImportPage';
import { getImportStatusApiUrl } from './data/api';
import { IMPORT_STAGES } from './data/constants';
import stepperMessages from './import-stepper/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
import { executeThunk } from '../utils';
let store;
let axiosMock;
let cookies;
const courseId = '123';
const courseName = 'About Node JS';
const userId = 3;
let userPermissionsData = { permissions: [] };
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
@@ -47,7 +52,7 @@ describe('<CourseImportPage />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -58,6 +63,12 @@ describe('<CourseImportPage />', () => {
axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.test'))
.reply(200, { importStatus: 1, message: '' });
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});
@@ -83,6 +94,30 @@ describe('<CourseImportPage />', () => {
expect(getByText(messages.description3.defaultMessage)).toBeInTheDocument();
});
});
it('should render permissionDenied if incorrect permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('should render without errors if correct permissions', async () => {
const { getByText } = render(<RootWrapper />);
userPermissionsData = { permissions: ['manage_course_settings'] };
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const importPageElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(importPageElement).toBeInTheDocument();
expect(getByText(messages.description1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description3.defaultMessage)).toBeInTheDocument();
});
});
it('should fetch status without clicking when cookies has', async () => {
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.test' });
const { getByText } = render(<RootWrapper />);

View File

@@ -1,12 +1,12 @@
import React from 'react';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Card, Dropzone } from '@edx/paragon';
import { Alert, Card, Dropzone } from '@edx/paragon';
import { IMPORT_STAGES } from '../data/constants';
import {
getCurrentStage, getError, getFileName, getImportTriggered,
@@ -14,7 +14,7 @@ import {
import messages from './messages';
import { handleProcessUpload } from '../data/thunks';
const FileSection = ({ intl, courseId }) => {
const FileSection = ({ intl, courseId, viewOnly }) => {
const dispatch = useDispatch();
const importTriggered = useSelector(getImportTriggered);
const currentStage = useSelector(getCurrentStage);
@@ -30,21 +30,25 @@ const FileSection = ({ intl, courseId }) => {
subtitle={fileName && intl.formatMessage(messages.fileChosen, { fileName })}
/>
<Card.Section className="px-3 pt-2 pb-4">
{isShowedDropzone
&& (
<Dropzone
onProcessUpload={
({ fileData, requestConfig, handleError }) => dispatch(handleProcessUpload(
courseId,
fileData,
requestConfig,
handleError,
))
}
accept={{ 'application/gzip': ['.tar.gz'] }}
data-testid="dropzone"
/>
)}
{!viewOnly && isShowedDropzone && (
<Dropzone
onProcessUpload={
({ fileData, requestConfig, handleError }) => dispatch(handleProcessUpload(
courseId,
fileData,
requestConfig,
handleError,
))
}
accept={{ 'application/gzip': ['.tar.gz'] }}
data-testid="dropzone"
/>
)}
{viewOnly && (
<Alert variant="info">
<FormattedMessage {...messages.viewOnlyAlert} />
</Alert>
)}
</Card.Section>
</Card>
);
@@ -53,6 +57,7 @@ const FileSection = ({ intl, courseId }) => {
FileSection.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
viewOnly: PropTypes.bool.isRequired,
};
export default injectIntl(FileSection);

View File

@@ -14,7 +14,15 @@ const courseId = '123';
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<FileSection intl={injectIntl} courseId={courseId} />
<FileSection intl={injectIntl} courseId={courseId} viewOnly={false} />
</IntlProvider>
</AppProvider>
);
const RootWrapperViewOnly = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<FileSection intl={injectIntl} courseId={courseId} viewOnly />
</IntlProvider>
</AppProvider>
);
@@ -27,6 +35,7 @@ describe('<FileSection />', () => {
username: 'abc123',
administrator: true,
roles: [],
permisions: [],
},
});
store = initializeStore();
@@ -43,6 +52,13 @@ describe('<FileSection />', () => {
expect(getByTestId('dropzone')).toBeInTheDocument();
});
});
it('should not render Dropzone when view is viewOnly', async () => {
const { getByText, queryByTestId, container } = render(<RootWrapperViewOnly />);
await waitFor(() => {
expect(queryByTestId(container, 'dropzone')).not.toBeInTheDocument();
expect(getByText(messages.viewOnlyAlert.defaultMessage)).toBeInTheDocument();
});
});
it('should work Dropzone', async () => {
const {
getByText, getByTestId, queryByTestId, container,

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.import.file-section.chosen-file',
defaultMessage: 'File chosen: {fileName}',
},
viewOnlyAlert: {
id: 'course-authoring.import.file-section.view-only-alert',
defaultMessage: 'You have view only access to this page. If you feel you should have full access, please reach out to your course team admin to be given access.',
},
});
export default messages;

View File

@@ -26,6 +26,7 @@ import { RequestStatus } from '../data/constants';
import SettingsComponent from './SettingsComponent';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
import getPageHeadTitle from '../generic/utils';
import { useUserPermissions } from '../generic/hooks';
const PagesAndResources = ({ courseId, intl }) => {
const courseDetails = useModel('courseDetails', courseId);
@@ -73,12 +74,14 @@ const PagesAndResources = ({ courseId, intl }) => {
contentPermissionsPages.push(page);
}
const { checkPermission } = useUserPermissions();
if (loadingStatus === RequestStatus.IN_PROGRESS) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (courseAppsApiStatus === RequestStatus.DENIED) {
if (courseAppsApiStatus === RequestStatus.DENIED || !checkPermission('manage_content')) {
return (
<PermissionDeniedAlert />
);

View File

@@ -8,6 +8,12 @@ import * as xpertUnitSummaryApi from './xpert-unit-summary/data/api';
const courseId = 'course-v1:edX+TestX+Test_Course';
jest.mock('../generic/hooks', () => ({
useUserPermissions: jest.fn(() => ({
checkPermission: jest.fn(() => true),
})),
}));
describe('PagesAndResources', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -21,10 +21,13 @@ import scheduleMessages from './schedule-section/messages';
import genericMessages from '../generic/help-sidebar/messages';
import messages from './messages';
import ScheduleAndDetails from '.';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const courseId = '123';
const userId = 3;
// Mock the tinymce lib
jest.mock('@tinymce/tinymce-react', () => {
@@ -50,6 +53,18 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea {...props} onFocus={() => {}} onBlur={() => {}} />
)));
const permissionsMockStore = async (permissions) => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const permissionDisabledMockStore = async () => {
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
@@ -62,7 +77,7 @@ describe('<ScheduleAndDetails />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -111,6 +126,30 @@ describe('<ScheduleAndDetails />', () => {
});
});
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
const permissionsData = { permissions: ['wrong_permission'] };
await permissionsMockStore(permissionsData);
const { queryByText } = render(<RootWrapper />);
await waitFor(() => {
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).toBeInTheDocument();
});
});
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
await permissionDisabledMockStore();
const { queryByText, getAllByText } = render(<RootWrapper />);
await waitFor(() => {
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
const scheduleAndDetailElements = getAllByText(messages.headingTitle.defaultMessage);
const scheduleAndDetailTitle = scheduleAndDetailElements[0];
expect(permissionDeniedAlert).not.toBeInTheDocument();
expect(scheduleAndDetailTitle).toBeInTheDocument();
});
});
it('should hide credit section with condition', async () => {
const updatedResponse = {
...courseSettingsMock,

View File

@@ -19,6 +19,7 @@ module.exports = {
enrollmentEndEditable: true,
isCreditCourse: true,
isEntranceExamsEnabled: true,
isEditable: true,
isPrerequisiteCoursesEnabled: true,
languageOptions: [
['en', 'English'],

View File

@@ -18,6 +18,7 @@ describe('<DetailsSection />', () => {
language: courseSettingsMock.languageOptions[1][0],
languageOptions: courseSettingsMock.languageOptions,
onChange: onChangeMock,
isEditable: courseSettingsMock.isEditable,
};
it('renders details section successfully', () => {
@@ -57,4 +58,10 @@ describe('<DetailsSection />', () => {
getByRole('button', { name: messages.dropdownEmpty.defaultMessage }),
).toBeInTheDocument();
});
it('should disable the dropdown if isEditable is false', () => {
const initialProps = { ...props, isEditable: false };
const { getByRole } = render(<RootWrapper {...initialProps} />);
expect(getByRole('button').disabled).toEqual(true);
});
});

View File

@@ -7,7 +7,7 @@ import SectionSubHeader from '../../generic/section-sub-header';
import messages from './messages';
const DetailsSection = ({
language, languageOptions, onChange,
language, languageOptions, onChange, isEditable,
}) => {
const intl = useIntl();
const formattedLanguage = () => {
@@ -24,7 +24,7 @@ const DetailsSection = ({
<Form.Group className="form-group-custom dropdown-language">
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
<Dropdown className="bg-white">
<Dropdown.Toggle variant="outline-primary" id="languageDropdown">
<Dropdown.Toggle variant="outline-primary" id="languageDropdown" disabled={!isEditable}>
{formattedLanguage()}
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -56,6 +56,7 @@ DetailsSection.propTypes = {
PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
).isRequired,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default DetailsSection;

View File

@@ -43,6 +43,9 @@ import LicenseSection from './license-section';
import ScheduleSidebar from './schedule-sidebar';
import messages from './messages';
import { useLoadValuesPrompt, useSaveValuesPrompt } from './hooks';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const ScheduleAndDetails = ({ intl, courseId }) => {
const courseSettings = useSelector(getCourseSettings);
@@ -53,6 +56,12 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
|| loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const course = useModel('courseDetails', courseId);
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const showPermissionDeniedAlert = userPermissionsEnabled && (
!checkPermission('manage_course_settings') && !checkPermission('view_course_settings')
);
const canEdit = (!userPermissionsEnabled) ? true : checkPermission('manage_course_settings');
document.title = getPageHeadTitle(course?.name, intl.formatMessage(messages.headingTitle));
const {
@@ -145,6 +154,12 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
return <></>;
}
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
@@ -266,12 +281,14 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
certificatesDisplayBehavior={certificatesDisplayBehavior}
canShowCertificateAvailableDateField={canShowCertificateAvailableDateField}
onChange={handleValuesChange}
isEditable={canEdit}
/>
{aboutPageEditable && (
<DetailsSection
language={language}
languageOptions={languageOptions}
onChange={handleValuesChange}
isEditable={canEdit}
/>
)}
<IntroducingSection
@@ -292,6 +309,7 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
enableExtendedCourseDetails={enableExtendedCourseDetails}
videoThumbnailImageAssetPath={videoThumbnailImageAssetPath}
onChange={handleValuesChange}
isEditable={canEdit}
/>
{enableExtendedCourseDetails && (
<>
@@ -319,12 +337,14 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
isPrerequisiteCoursesEnabled
}
onChange={handleValuesChange}
isEditable={canEdit}
/>
)}
{licensingEnabled && (
<LicenseSection
license={license}
onChange={handleValuesChange}
isEditable={canEdit}
/>
)}
</div>

View File

@@ -47,7 +47,7 @@ const {
} = courseDetailsMock;
const {
aboutPageEditable, sidebarHtmlEnabled, shortDescriptionEditable, lmsLinkForAboutPage,
aboutPageEditable, sidebarHtmlEnabled, shortDescriptionEditable, lmsLinkForAboutPage, isEditable,
} = courseSettingsMock;
const props = {
@@ -63,6 +63,7 @@ const props = {
courseImageAssetPath,
shortDescriptionEditable,
onChange: onChangeMock,
isEditable,
};
describe('<IntroducingSection />', () => {
@@ -98,4 +99,13 @@ describe('<IntroducingSection />', () => {
expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0);
expect(queryAllByText(messages.courseAboutSidebarLabel.defaultMessage).length).toBe(0);
});
it('should hide components if isEditable is false', () => {
const initialProps = { ...props, isEditable: false };
const { queryAllByText } = render(<RootWrapper {...initialProps} />);
expect(queryAllByText(messages.introducingTitle.defaultMessage).length).toBe(0);
expect(queryAllByText(messages.introducingDescription.defaultMessage).length).toBe(0);
expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0);
expect(queryAllByText(messages.courseAboutSidebarLabel.defaultMessage).length).toBe(0);
});
});

View File

@@ -33,6 +33,7 @@ const IntroducingSection = ({
enableExtendedCourseDetails,
videoThumbnailImageAssetPath,
onChange,
isEditable,
}) => {
const overviewHelpText = (
<FormattedMessage
@@ -72,7 +73,7 @@ const IntroducingSection = ({
return (
<section className="section-container introducing-section">
{aboutPageEditable && (
{aboutPageEditable && isEditable && (
<SectionSubHeader
title={intl.formatMessage(messages.introducingTitle)}
description={intl.formatMessage(messages.introducingDescription)}
@@ -87,7 +88,7 @@ const IntroducingSection = ({
onChange={onChange}
/>
)}
{shortDescriptionEditable && (
{shortDescriptionEditable && isEditable && (
<Form.Group className="form-group-custom">
<Form.Label>
{intl.formatMessage(messages.courseShortDescriptionLabel)}
@@ -107,7 +108,7 @@ const IntroducingSection = ({
</Form.Control.Feedback>
</Form.Group>
)}
{aboutPageEditable && (
{aboutPageEditable && isEditable && (
<>
<Form.Group className="form-group-custom">
<Form.Label>{intl.formatMessage(messages.courseOverviewLabel)}</Form.Label>
@@ -160,7 +161,7 @@ const IntroducingSection = ({
/>
</>
)}
{aboutPageEditable && (
{aboutPageEditable && isEditable && (
<IntroductionVideo introVideo={introVideo} onChange={onChange} />
)}
</section>
@@ -200,6 +201,7 @@ IntroducingSection.propTypes = {
enableExtendedCourseDetails: PropTypes.bool.isRequired,
videoThumbnailImageAssetPath: PropTypes.string,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default injectIntl(IntroducingSection);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { courseDetailsMock } from '../__mocks__';
import { courseDetailsMock, courseSettingsMock } from '../__mocks__';
import messages from './messages';
import LicenseSection from '.';
@@ -17,6 +17,7 @@ const RootWrapper = (props) => (
const props = {
license: courseDetailsMock.license,
onChange: onChangeMock,
isEditable: courseSettingsMock.isEditable,
};
describe('<LicenseSection />', () => {

View File

@@ -10,7 +10,7 @@ import { LICENSE_TYPE } from './constants';
import messages from './messages';
import { useLicenseDetails } from './hooks';
const LicenseSection = ({ license, onChange }) => {
const LicenseSection = ({ license, onChange, isEditable }) => {
const intl = useIntl();
const {
licenseURL,
@@ -29,6 +29,7 @@ const LicenseSection = ({ license, onChange }) => {
<LicenseSelector
licenseType={licenseType}
onChangeLicenseType={handleChangeLicenseType}
isEditable={isEditable}
/>
{licenseType === LICENSE_TYPE.creativeCommons && (
<LicenseCommonsOptions
@@ -52,6 +53,7 @@ LicenseSection.defaultProps = {
LicenseSection.propTypes = {
license: PropTypes.string,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default LicenseSection;

View File

@@ -18,6 +18,7 @@ const RootWrapper = (props) => (
const props = {
licenseType: LICENSE_TYPE.allRightsReserved,
onChangeLicenseType: onChangeLicenseTypeMock,
isEditable: true,
};
describe('<LicenseSelector />', () => {
@@ -60,4 +61,13 @@ describe('<LicenseSelector />', () => {
expect(buttonFirst).toHaveClass('btn btn-outline-primary');
expect(buttonSecond).toHaveClass('btn btn-outline-primary');
});
it('should show disabled buttons if isEditable is false', () => {
const initialProps = { ...props, isEditable: false };
const { getByRole } = render(<RootWrapper {...initialProps} />);
const buttonFirst = getByRole('button', { name: messages.licenseChoice1.defaultMessage });
const buttonSecond = getByRole('button', { name: messages.licenseChoice2.defaultMessage });
expect(buttonFirst.disabled).toEqual(true);
expect(buttonSecond.disabled).toEqual(true);
});
});

View File

@@ -12,7 +12,7 @@ import {
import { LICENSE_TYPE } from '../constants';
import messages from './messages';
const LicenseSelector = ({ licenseType, onChangeLicenseType }) => {
const LicenseSelector = ({ licenseType, onChangeLicenseType, isEditable }) => {
const LICENSE_BUTTON_GROUP_LABELS = {
[LICENSE_TYPE.allRightsReserved]: {
label: <FormattedMessage {...messages.licenseChoice1} />,
@@ -37,6 +37,7 @@ const LicenseSelector = ({ licenseType, onChangeLicenseType }) => {
<Button
variant={isActive ? 'primary' : 'outline-primary'}
onClick={() => onChangeLicenseType(type, 'license')}
disabled={!isEditable}
>
{LICENSE_BUTTON_GROUP_LABELS[type].label}
</Button>
@@ -64,6 +65,7 @@ LicenseSelector.defaultProps = {
LicenseSelector.propTypes = {
licenseType: PropTypes.oneOf(Object.values(LICENSE_TYPE)),
onChangeLicenseType: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default LicenseSelector;

View File

@@ -36,6 +36,7 @@ const {
isEntranceExamsEnabled,
possiblePreRequisiteCourses,
isPrerequisiteCoursesEnabled,
isEditable,
} = courseSettingsMock;
const props = {
@@ -49,6 +50,7 @@ const props = {
entranceExamMinimumScorePct,
isPrerequisiteCoursesEnabled,
onChange: onChangeMock,
isEditable,
};
describe('<RequirementsSection />', () => {
@@ -90,4 +92,10 @@ describe('<RequirementsSection />', () => {
expect(queryAllByLabelText(messages.dropdownLabel.defaultMessage).length).toBe(0);
expect(queryAllByLabelText(entranceExamMessages.requirementsEntrance.defaultMessage).length).toBe(0);
});
it('should disable the dropdown if isEditable is false', () => {
const initialProps = { ...props, isEditable: false };
const { queryByTestId } = render(<RootWrapper {...initialProps} />);
expect(queryByTestId('prerequisite_dropdown').disabled).toEqual(true);
});
});

View File

@@ -4,7 +4,7 @@ import {
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { courseDetailsMock } from '../../__mocks__';
import { courseDetailsMock, courseSettingsMock } from '../../__mocks__';
import gradeRequirementsMessages from '../grade-requirements/messages';
import messages from './messages';
import EntranceExam from '.';
@@ -30,6 +30,7 @@ const props = {
isCheckedString: courseDetailsMock.entranceExamEnabled,
entranceExamMinimumScorePct: courseDetailsMock.entranceExamMinimumScorePct,
onChange: onChangeMock,
isEditable: courseSettingsMock.isEditable,
};
describe('<EntranceExam />', () => {
@@ -58,4 +59,11 @@ describe('<EntranceExam />', () => {
).toBe(0);
});
});
it('should disable the checkbox if isEditable is false', () => {
const initialProps = { ...props, isEditable: false };
const { getAllByRole } = render(<RootWrapper {...initialProps} />);
const checkbox = getAllByRole('checkbox')[0];
expect(checkbox.disabled).toEqual(true);
});
});

View File

@@ -13,6 +13,7 @@ const EntranceExam = ({
isCheckedString,
entranceExamMinimumScorePct,
onChange,
isEditable,
}) => {
const { courseId } = useParams();
const showEntranceExam = isCheckedString === 'true';
@@ -33,6 +34,7 @@ const EntranceExam = ({
<Form.Checkbox
checked={showEntranceExam}
onChange={toggleEntranceExam}
disabled={!isEditable}
>
<FormattedMessage {...messages.requirementsEntranceCollapseTitle} />
</Form.Checkbox>
@@ -63,6 +65,7 @@ const EntranceExam = ({
errorEffort={errorEffort}
entranceExamMinimumScorePct={entranceExamMinimumScorePct}
onChange={onChange}
isEditable={isEditable}
/>
</Card.Body>
</>
@@ -83,6 +86,7 @@ EntranceExam.propTypes = {
isCheckedString: PropTypes.string,
entranceExamMinimumScorePct: PropTypes.string,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default EntranceExam;

View File

@@ -4,7 +4,7 @@ import {
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { courseDetailsMock } from '../../__mocks__';
import { courseDetailsMock, courseSettingsMock } from '../../__mocks__';
import scheduleMessage from '../../messages';
import messages from './messages';
import GradeRequirements from '.';
@@ -21,6 +21,7 @@ const props = {
errorEffort: '',
entranceExamMinimumScorePct: courseDetailsMock.entranceExamMinimumScorePct,
onChange: onChangeMock,
isEditable: courseSettingsMock.isEditable,
};
describe('<GradeRequirements />', () => {
@@ -31,6 +32,13 @@ describe('<GradeRequirements />', () => {
expect(getByDisplayValue(props.entranceExamMinimumScorePct)).toBeInTheDocument();
});
it('disable the input if isEditable is false', () => {
const initialProps = { ...props, isEditable: false };
const { getByDisplayValue } = render(<RootWrapper {...initialProps} />);
const input = getByDisplayValue(props.entranceExamMinimumScorePct);
expect(input.disabled).toEqual(true);
});
it('should call onChange on input change', () => {
const { getByDisplayValue } = render(<RootWrapper {...props} />);
const input = getByDisplayValue(props.entranceExamMinimumScorePct);

View File

@@ -10,6 +10,7 @@ const GradeRequirements = ({
errorEffort,
entranceExamMinimumScorePct,
onChange,
isEditable,
}) => (
<Form.Group
className={classNames('form-group-custom', {
@@ -27,6 +28,7 @@ const GradeRequirements = ({
value={entranceExamMinimumScorePct}
onChange={(e) => onChange(e.target.value, 'entranceExamMinimumScorePct')}
trailingElement="%"
disabled={!isEditable}
/>
</Stack>
{errorEffort && (
@@ -49,6 +51,7 @@ GradeRequirements.propTypes = {
errorEffort: PropTypes.string,
entranceExamMinimumScorePct: PropTypes.string,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default GradeRequirements;

View File

@@ -19,6 +19,7 @@ const RequirementsSection = ({
entranceExamMinimumScorePct,
isPrerequisiteCoursesEnabled,
onChange,
isEditable,
}) => {
const intl = useIntl();
const selectedItem = possiblePreRequisiteCourses?.find(
@@ -33,7 +34,7 @@ const RequirementsSection = ({
>
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
<Dropdown className="bg-white">
<Dropdown.Toggle id="prerequisiteDropdown" variant="outline-primary">
<Dropdown.Toggle id="prerequisiteDropdown" variant="outline-primary" disabled={!isEditable} data-testid="prerequisite_dropdown">
{formattedSelectedItem}
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -74,6 +75,7 @@ const RequirementsSection = ({
value={effort || ''}
placeholder={TIME_FORMAT.toUpperCase()}
onChange={(e) => onChange(e.target.value, 'effort')}
disabled={!isEditable}
/>
<Form.Control.Feedback>
{intl.formatMessage(messages.timepickerHelpText)}
@@ -87,6 +89,7 @@ const RequirementsSection = ({
isCheckedString={entranceExamEnabled}
entranceExamMinimumScorePct={entranceExamMinimumScorePct}
onChange={onChange}
isEditable={isEditable}
/>
)}
</section>
@@ -125,6 +128,7 @@ RequirementsSection.propTypes = {
entranceExamMinimumScorePct: PropTypes.string,
isPrerequisiteCoursesEnabled: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default RequirementsSection;

View File

@@ -125,7 +125,7 @@ const CertificateDisplayRow = ({
<Form.Label>
{intl.formatMessage(messages.certificateBehaviorLabel)}
</Form.Label>
<Dropdown claswsName="bg-white">
<Dropdown className="bg-white">
<Dropdown.Toggle id="certificate-behavior-dropdown" variant="outline-primary">
{certificateDisplayValue}
</Dropdown.Toggle>

View File

@@ -20,6 +20,7 @@ const ScheduleSection = ({
certificatesDisplayBehavior,
canShowCertificateAvailableDateField,
onChange,
isEditable,
}) => {
const intl = useIntl();
const enrollmentEndHelpText = intl.formatMessage(
@@ -42,6 +43,7 @@ const ScheduleSection = ({
],
rowType: SCHEDULE_ROW_TYPES.datetime,
helpText: intl.formatMessage(messages.scheduleCourseStartDateHelpText),
readonly: !isEditable,
controlName: 'startDate',
errorFeedback: errorFields?.startDate,
},
@@ -53,6 +55,7 @@ const ScheduleSection = ({
value: endDate,
rowType: SCHEDULE_ROW_TYPES.datetime,
helpText: intl.formatMessage(messages.scheduleCourseEndDateHelpText),
readonly: !isEditable,
controlName: 'endDate',
errorFeedback: errorFields?.endDate,
},
@@ -73,6 +76,7 @@ const ScheduleSection = ({
value: enrollmentStart,
rowType: SCHEDULE_ROW_TYPES.datetime,
helpText: intl.formatMessage(messages.scheduleEnrollmentStartDateHelpText),
readonly: !isEditable,
controlName: 'enrollmentStart',
errorFeedback: errorFields?.enrollmentStart,
},
@@ -84,7 +88,7 @@ const ScheduleSection = ({
value: enrollmentEnd,
rowType: SCHEDULE_ROW_TYPES.datetime,
helpText: computedEnrollmentEndHelpText,
readonly: !enrollmentEndEditable,
readonly: !enrollmentEndEditable || !isEditable,
controlName: 'enrollmentEnd',
errorFeedback: errorFields?.enrollmentEnd,
},
@@ -165,6 +169,7 @@ ScheduleSection.propTypes = {
certificatesDisplayBehavior: PropTypes.string.isRequired,
canShowCertificateAvailableDateField: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
};
export default ScheduleSection;

View File

@@ -159,6 +159,7 @@ const StudioHome = ({ intl }) => {
<SubHeader
title={intl.formatMessage(messages.headingTitle, { studioShortName: studioShortName || 'Studio' })}
headerActions={headerButtons}
key={studioShortName}
/>
</section>
</article>

View File

@@ -43,7 +43,7 @@ describe('studio-home api calls', () => {
expect(result).toEqual(expected);
});
fit('should get studio courses data', async () => {
it('should get studio courses data', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCourses('');