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", "@testing-library/user-event": "^13.2.1",
"axios-mock-adapter": "1.22.0", "axios-mock-adapter": "1.22.0",
"glob": "7.2.3", "glob": "7.2.3",
"husky": "7.0.4", "husky": "^7.0.4",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2", "react-test-renderer": "17.0.2",

View File

@@ -92,7 +92,7 @@
"@testing-library/user-event": "^13.2.1", "@testing-library/user-event": "^13.2.1",
"axios-mock-adapter": "1.22.0", "axios-mock-adapter": "1.22.0",
"glob": "7.2.3", "glob": "7.2.3",
"husky": "7.0.4", "husky": "^7.0.4",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2", "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 { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants'; import { RequestStatus } from './data/constants';
import Loading from './generic/Loading'; import Loading from './generic/Loading';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
import { getUserPermissions } from './generic/data/selectors';
const AppHeader = ({ const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId, courseNumber, courseOrg, courseTitle, courseId,
@@ -40,9 +42,14 @@ AppHeader.defaultProps = {
const CourseAuthoringPage = ({ courseId, children }) => { const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const userPermissions = useSelector(getUserPermissions);
useEffect(() => { useEffect(() => {
dispatch(fetchCourseDetail(courseId)); dispatch(fetchCourseDetail(courseId));
dispatch(fetchUserPermissionsEnabledFlag());
if (!userPermissions) {
dispatch(fetchUserPermissionsQuery(courseId));
}
}, [courseId]); }, [courseId]);
const courseDetail = useModel('courseDetails', courseId); const courseDetail = useModel('courseDetails', courseId);

View File

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

View File

@@ -3,7 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react'; 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 MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store'; import initializeStore from '../store';
@@ -13,11 +17,15 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks'; import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings'; import AdvancedSettings from './AdvancedSettings';
import messages from './messages'; import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock; let axiosMock;
let store; let store;
const mockPathname = '/foo-bar'; const mockPathname = '/foo-bar';
const courseId = '123'; const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
// Mock the TextareaAutosize component // Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => ( jest.mock('react-textarea-autosize', () => jest.fn((props) => (
@@ -43,11 +51,23 @@ const RootWrapper = () => (
</AppProvider> </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 />', () => { describe('<AdvancedSettings />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
axiosMock axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`) .onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock); .reply(200, advancedSettingsMock);
permissionsMockStore(userPermissionsData);
}); });
it('should render without errors', async () => { it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />); const { getByText } = render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch); await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument(); 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, setIsEditableState,
// injected // injected
intl, intl,
disableForm,
}) => { }) => {
const { deprecated, help, displayName } = settingData; const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4); const initialValue = JSON.stringify(settingData.value, null, 4);
@@ -100,6 +101,7 @@ const SettingCard = ({
onChange={handleSettingChange} onChange={handleSettingChange}
aria-label={displayName} aria-label={displayName}
onBlur={handleCardBlur} onBlur={handleCardBlur}
disabled={disableForm}
/> />
</Form.Group> </Form.Group>
</Card.Section> </Card.Section>
@@ -135,6 +137,7 @@ SettingCard.propTypes = {
saveSettingsPrompt: PropTypes.bool.isRequired, saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired, isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired, setIsEditableState: PropTypes.func.isRequired,
disableForm: PropTypes.bool.isRequired,
}; };
export default injectIntl(SettingCard); export default injectIntl(SettingCard);

View File

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

View File

@@ -48,10 +48,15 @@ import pasteButtonMessages from './paste-button/messages';
import subsectionMessages from './subsection-card/messages'; import subsectionMessages from './subsection-card/messages';
import pageAlertMessages from './page-alerts/messages'; import pageAlertMessages from './page-alerts/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock; let axiosMock;
let store; let store;
const mockPathname = '/foo-bar'; const mockPathname = '/foo-bar';
const courseId = '123'; const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: [] };
window.HTMLElement.prototype.scrollIntoView = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn();
@@ -90,7 +95,7 @@ describe('<CourseOutline />', () => {
beforeEach(async () => { beforeEach(async () => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
@@ -102,6 +107,14 @@ describe('<CourseOutline />', () => {
axiosMock axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId)) .onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, courseOutlineIndexMock); .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); 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 () => { it('check reindex and render success alert is correctly', async () => {
const { findByText, findByTestId } = render(<RootWrapper />); 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 CourseTeamMember from './course-team-member/CourseTeamMember';
import InfoModal from './info-modal/InfoModal'; import InfoModal from './info-modal/InfoModal';
import { useCourseTeam } from './hooks'; import { useCourseTeam } from './hooks';
import { useUserPermissions } from '../generic/hooks';
import getPageHeadTitle from '../generic/utils'; import getPageHeadTitle from '../generic/utils';
const CourseTeam = ({ courseId }) => { const CourseTeam = ({ courseId }) => {
const intl = useIntl(); const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId); const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { const {
@@ -55,6 +56,12 @@ const CourseTeam = ({ courseId }) => {
handleInternetConnectionFailed, handleInternetConnectionFailed,
} = useCourseTeam({ intl, courseId }); } = useCourseTeam({ intl, courseId });
const {
checkPermission,
} = useUserPermissions();
const hasManageAllUsersPerm = checkPermission('manage_all_users');
if (isLoading) { if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment // eslint-disable-next-line react/jsx-no-useless-fragment
return <></>; return <></>;
@@ -77,7 +84,7 @@ const CourseTeam = ({ courseId }) => {
<SubHeader <SubHeader
title={intl.formatMessage(messages.headingTitle)} title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)} subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions && ( headerActions={(isAllowActions || hasManageAllUsersPerm) && (
<Button <Button
variant="primary" variant="primary"
iconBefore={IconAdd} iconBefore={IconAdd}
@@ -104,13 +111,13 @@ const CourseTeam = ({ courseId }) => {
role={role} role={role}
email={email} email={email}
currentUserEmail={currentUserEmail || ''} currentUserEmail={currentUserEmail || ''}
isAllowActions={isAllowActions} isAllowActions={isAllowActions || hasManageAllUsersPerm}
isHideActions={role === USER_ROLES.admin && isSingleAdmin} isHideActions={role === USER_ROLES.admin && isSingleAdmin}
onChangeRole={handleChangeRoleUserSubmit} onChangeRole={handleChangeRoleUserSubmit}
onDelete={handleOpenDeleteModal} onDelete={handleOpenDeleteModal}
/> />
)) : null} )) : null}
{isShowAddTeamMember && ( {(isShowAddTeamMember || hasManageAllUsersPerm) && (
<AddTeamMember <AddTeamMember
onFormOpen={openForm} onFormOpen={openForm}
isButtonDisable={isFormVisible} isButtonDisable={isFormVisible}

View File

@@ -14,6 +14,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../store'; import initializeStore from '../store';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__'; import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api'; 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 CourseTeam from './CourseTeam';
import messages from './messages'; import messages from './messages';
import { USER_ROLES } from '../constants'; import { USER_ROLES } from '../constants';
@@ -24,6 +26,8 @@ let axiosMock;
let store; let store;
const mockPathname = '/foo-bar'; const mockPathname = '/foo-bar';
const courseId = '123'; const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['manage_all_users'] };
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@@ -44,7 +48,7 @@ describe('<CourseTeam />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
@@ -53,6 +57,12 @@ describe('<CourseTeam />', () => {
store = initializeStore(); store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); 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 () => { it('render CourseTeam component with 3 team members correctly', async () => {
@@ -165,13 +175,13 @@ describe('<CourseTeam />', () => {
await waitFor(() => { await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument(); 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); fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument(); 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(); cleanup();
axiosMock axiosMock
.onGet(getCourseTeamApiUrl(courseId)) .onGet(getCourseTeamApiUrl(courseId))
@@ -179,12 +189,39 @@ describe('<CourseTeam />', () => {
...courseTeamWithOneUser, ...courseTeamWithOneUser,
allowActions: false, allowActions: false,
}); });
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: true });
const { queryByRole, queryByTestId } = render(<RootWrapper />); const { queryByRole, queryByText } = render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument(); 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="course-team-member" data-testid="course-team-member">
<div className="member-info"> <div className="member-info">
<Badge className={`badge-current-user bg-${badgeColor} text-light-100`}> <Badge className={`badge-current-user bg-${badgeColor} text-light-100`}>
{isAdminRole {(isAdminRole)
? intl.formatMessage(messages.roleAdmin) ? intl.formatMessage(messages.roleAdmin)
: intl.formatMessage(messages.roleStaff)} : intl.formatMessage(messages.roleStaff)}
{currentUserEmail === email && ( {currentUserEmail === email && (
@@ -46,11 +46,13 @@ const CourseTeamMember = ({
!isHideActions ? ( !isHideActions ? (
<div className="member-actions"> <div className="member-actions">
<Button <Button
variant={isAdminRole ? 'tertiary' : 'primary'} variant={(isAdminRole) ? 'tertiary' : 'primary'}
size="sm" size="sm"
onClick={() => onChangeRole(email, isAdminRole ? USER_ROLES.staff : USER_ROLES.admin)} 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> </Button>
<IconButtonWithTooltip <IconButtonWithTooltip
src={DeleteOutline} src={DeleteOutline}

View File

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

View File

@@ -22,11 +22,16 @@ import { executeThunk } from '../utils';
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__'; import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
import CourseUpdates from './CourseUpdates'; import CourseUpdates from './CourseUpdates';
import messages from './messages'; import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock; let axiosMock;
let store; let store;
const mockPathname = '/foo-bar'; const mockPathname = '/foo-bar';
const courseId = '123'; const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['manage_content'] };
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@@ -61,7 +66,7 @@ const RootWrapper = () => (
); );
describe('<CourseUpdates />', () => { describe('<CourseUpdates />', () => {
beforeEach(() => { beforeEach(async () => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId: 3,
@@ -79,6 +84,7 @@ describe('<CourseUpdates />', () => {
axiosMock axiosMock
.onGet(getCourseHandoutApiUrl(courseId)) .onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock); .reply(200, courseHandoutsMock);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
}); });
it('render CourseUpdates component correctly', async () => { it('render CourseUpdates component correctly', async () => {
@@ -162,6 +168,26 @@ describe('<CourseUpdates />', () => {
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument(); 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 () => { it('Add new update form is visible after clicking "New update" button', async () => {
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />); 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 ExportModalError from './export-modal-error/ExportModalError';
import ExportFooter from './export-footer/ExportFooter'; import ExportFooter from './export-footer/ExportFooter';
import ExportStepper from './export-stepper/ExportStepper'; 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 CourseExportPage = ({ intl, courseId }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -38,6 +41,14 @@ const CourseExportPage = ({ intl, courseId }) => {
const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS; const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS;
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; 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(() => { useEffect(() => {
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
@@ -48,6 +59,12 @@ const CourseExportPage = ({ intl, courseId }) => {
} }
}, []); }, []);
if (!hasExportPermissions) {
return (
<PermissionDeniedAlert />
);
}
return ( return (
<> <>
<Helmet> <Helmet>
@@ -89,13 +106,14 @@ const CourseExportPage = ({ intl, courseId }) => {
className="mb-4" className="mb-4"
onClick={() => dispatch(startExportingCourse(courseId))} onClick={() => dispatch(startExportingCourse(courseId))}
iconBefore={ArrowCircleDownIcon} iconBefore={ArrowCircleDownIcon}
disabled={viewOnly}
> >
{intl.formatMessage(messages.buttonTitle)} {intl.formatMessage(messages.buttonTitle)}
</Button> </Button>
</Card.Section> </Card.Section>
)} )}
</Card> </Card>
{exportTriggered && <ExportStepper courseId={courseId} />} {exportTriggered && <ExportStepper courseId={courseId} viewOnly={viewOnly} />}
<ExportFooter /> <ExportFooter />
</article> </article>
</Layout.Element> </Layout.Element>

View File

@@ -12,6 +12,9 @@ import initializeStore from '../store';
import stepperMessages from './export-stepper/messages'; import stepperMessages from './export-stepper/messages';
import modalErrorMessages from './export-modal-error/messages'; import modalErrorMessages from './export-modal-error/messages';
import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api'; 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 { EXPORT_STAGES } from './data/constants';
import { exportPageMock } from './__mocks__'; import { exportPageMock } from './__mocks__';
import messages from './messages'; import messages from './messages';
@@ -22,6 +25,8 @@ let axiosMock;
let cookies; let cookies;
const courseId = '123'; const courseId = '123';
const courseName = 'About Node JS'; const courseName = 'About Node JS';
const userId = 3;
let userPermissionsData = { permissions: [] };
jest.mock('../generic/model-store', () => ({ jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ useModel: jest.fn().mockReturnValue({
@@ -49,7 +54,7 @@ describe('<CourseExportPage />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
@@ -60,6 +65,14 @@ describe('<CourseExportPage />', () => {
axiosMock axiosMock
.onGet(postExportCourseApiUrl(courseId)) .onGet(postExportCourseApiUrl(courseId))
.reply(200, exportPageMock); .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 = new Cookies();
cookies.get.mockReturnValue(null); cookies.get.mockReturnValue(null);
}); });
@@ -85,8 +98,57 @@ describe('<CourseExportPage />', () => {
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument(); 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 () => { it('should start exporting on click', async () => {
const { getByText, container } = render(<RootWrapper />); const { getByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const button = container.querySelector('.btn-primary'); const button = container.querySelector('.btn-primary');
fireEvent.click(button); fireEvent.click(button);
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument(); expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
@@ -96,6 +158,8 @@ describe('<CourseExportPage />', () => {
.onGet(getExportStatusApiUrl(courseId)) .onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } }); .reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } });
const { getByText, queryByText, container } = render(<RootWrapper />); const { getByText, queryByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const startExportButton = container.querySelector('.btn-primary'); const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton); fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return // eslint-disable-next-line no-promise-executor-return
@@ -116,6 +180,8 @@ describe('<CourseExportPage />', () => {
.onGet(getExportStatusApiUrl(courseId)) .onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' }); .reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' });
const { getByText, container } = render(<RootWrapper />); const { getByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const startExportButton = container.querySelector('.btn-primary'); const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton); fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return // eslint-disable-next-line no-promise-executor-return
@@ -129,6 +195,8 @@ describe('<CourseExportPage />', () => {
.onGet(getExportStatusApiUrl(courseId)) .onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' }); .reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' });
const { getByText, container } = render(<RootWrapper />); const { getByText, container } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
const startExportButton = container.querySelector('.btn-primary'); const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton); fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return // 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 { RequestStatus } from '../../data/constants';
import messages from './messages'; import messages from './messages';
const ExportStepper = ({ intl, courseId }) => { const ExportStepper = ({ intl, courseId, viewOnly }) => {
const currentStage = useSelector(getCurrentStage); const currentStage = useSelector(getCurrentStage);
const downloadPath = useSelector(getDownloadPath); const downloadPath = useSelector(getDownloadPath);
const successDate = useSelector(getSuccessDate); const successDate = useSelector(getSuccessDate);
@@ -90,7 +90,7 @@ const ExportStepper = ({ intl, courseId }) => {
errorMessage={errorMessage} errorMessage={errorMessage}
hasError={!!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> </div>
); );
}; };
@@ -98,6 +98,7 @@ const ExportStepper = ({ intl, courseId }) => {
ExportStepper.propTypes = { ExportStepper.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
viewOnly: PropTypes.bool.isRequired,
}; };
export default injectIntl(ExportStepper); export default injectIntl(ExportStepper);

View File

@@ -1,20 +1,36 @@
import React from 'react'; import React from 'react';
import { render } from '@testing-library/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 { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform'; import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store'; import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import messages from './messages'; import messages from './messages';
import ExportStepper from './ExportStepper'; import ExportStepper from './ExportStepper';
import { exportStepperPageMock } from './__mocks__';
import { fetchExportStatus } from '../data/thunks';
import { getExportStatusApiUrl } from '../data/api';
const courseId = 'course-123'; const courseId = 'course-123';
let axiosMock;
let store; let store;
const RootWrapper = () => ( const RootWrapper = () => (
<AppProvider store={store}> <AppProvider store={store}>
<IntlProvider locale="en" messages={{}}> <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> </IntlProvider>
</AppProvider> </AppProvider>
); );
@@ -30,9 +46,22 @@ describe('<ExportStepper />', () => {
}, },
}); });
store = initializeStore(); store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, exportStepperPageMock);
executeThunk(fetchExportStatus(courseId), store.dispatch);
}); });
it('render stepper correctly', () => { it('render stepper correctly', () => {
const { getByText } = render(<RootWrapper />); const { getByText } = render(<RootWrapper />);
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument(); 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 { getFileSizeToClosestByte } from '../../utils';
import FileThumbnail from './FileThumbnail'; import FileThumbnail from './FileThumbnail';
import FileInfoModalSidebar from './FileInfoModalSidebar'; import FileInfoModalSidebar from './FileInfoModalSidebar';
import { useUserPermissions } from '../../generic/hooks';
import { getUserPermissionsEnabled } from '../../generic/data/selectors';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
const FilesPage = ({ const FilesPage = ({
courseId, courseId,
@@ -39,6 +42,9 @@ const FilesPage = ({
const dispatch = useDispatch(); const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId); const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
useEffect(() => { useEffect(() => {
dispatch(fetchAssets(courseId)); dispatch(fetchAssets(courseId));
@@ -160,6 +166,11 @@ const FilesPage = ({
{ ...accessColumn }, { ...accessColumn },
]; ];
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingStatus === RequestStatus.DENIED) { if (loadingStatus === RequestStatus.DENIED) {
return ( return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6"> <div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">

View File

@@ -39,10 +39,16 @@ import {
} from './data/thunks'; } from './data/thunks';
import { getAssetsUrl } from './data/api'; import { getAssetsUrl } from './data/api';
import messages from '../generic/messages'; import messages from '../generic/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks';
let axiosMock; let axiosMock;
let store; let store;
let file; let file;
const userId = 3;
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
const userPermissionsData = { permissions: ['manage_content'] };
ReactDOM.createPortal = jest.fn(node => node); ReactDOM.createPortal = jest.fn(node => node);
jest.mock('file-saver'); jest.mock('file-saver');
@@ -68,6 +74,8 @@ const mockStore = async (
} }
renderComponent(); renderComponent();
await executeThunk(fetchAssets(courseId), store.dispatch); await executeThunk(fetchAssets(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
}; };
const emptyMockStore = async (status) => { const emptyMockStore = async (status) => {
@@ -75,6 +83,27 @@ const emptyMockStore = async (status) => {
axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse()); axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse());
renderComponent(); renderComponent();
await executeThunk(fetchAssets(courseId), store.dispatch); 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', () => { describe('FilesAndUploads', () => {
@@ -82,7 +111,7 @@ describe('FilesAndUploads', () => {
beforeEach(async () => { beforeEach(async () => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: false, administrator: false,
roles: [], roles: [],
@@ -100,6 +129,21 @@ describe('FilesAndUploads', () => {
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' }); 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 () => { it('should return placeholder component', async () => {
await mockStore(RequestStatus.DENIED); await mockStore(RequestStatus.DENIED);

View File

@@ -43,6 +43,9 @@ import VideoThumbnail from './VideoThumbnail';
import { getFormattedDuration, resampleFile } from './data/utils'; import { getFormattedDuration, resampleFile } from './data/utils';
import FILES_AND_UPLOAD_TYPE_FILTERS from '../generic/constants'; import FILES_AND_UPLOAD_TYPE_FILTERS from '../generic/constants';
import VideoInfoModalSidebar from './info-sidebar'; import VideoInfoModalSidebar from './info-sidebar';
import { useUserPermissions } from '../../generic/hooks';
import { getUserPermissionsEnabled } from '../../generic/data/selectors';
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
const VideosPage = ({ const VideosPage = ({
courseId, courseId,
@@ -53,6 +56,9 @@ const VideosPage = ({
const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false); const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false);
const courseDetails = useModel('courseDetails', courseId); const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
useEffect(() => { useEffect(() => {
dispatch(fetchVideos(courseId)); dispatch(fetchVideos(courseId));
@@ -179,6 +185,11 @@ const VideosPage = ({
{ ...processingStatusColumn }, { ...processingStatusColumn },
]; ];
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingStatus === RequestStatus.DENIED) { if (loadingStatus === RequestStatus.DENIED) {
return ( return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6"> <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 { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api';
import videoMessages from './messages'; import videoMessages from './messages';
import messages from '../generic/messages'; import messages from '../generic/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks';
let axiosMock; let axiosMock;
let store; let store;
let file; let file;
const userId = 3;
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
const userPermissionsData = { permissions: ['manage_content'] };
jest.mock('file-saver'); jest.mock('file-saver');
const renderComponent = () => { const renderComponent = () => {
@@ -55,9 +60,7 @@ const renderComponent = () => {
); );
}; };
const mockStore = async ( const mockStore = async (status) => {
status,
) => {
const fetchVideosUrl = getVideosUrl(courseId); const fetchVideosUrl = getVideosUrl(courseId);
const videosData = generateFetchVideosApiResponse(); const videosData = generateFetchVideosApiResponse();
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), videosData); axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), videosData);
@@ -68,6 +71,8 @@ const mockStore = async (
renderComponent(); renderComponent();
await executeThunk(fetchVideos(courseId), store.dispatch); await executeThunk(fetchVideos(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
}; };
const emptyMockStore = async (status) => { const emptyMockStore = async (status) => {
@@ -75,6 +80,27 @@ const emptyMockStore = async (status) => {
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse()); axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse());
renderComponent(); renderComponent();
await executeThunk(fetchVideos(courseId), store.dispatch); 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', () => { describe('Videos page', () => {
@@ -82,7 +108,7 @@ describe('Videos page', () => {
beforeEach(async () => { beforeEach(async () => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: false, administrator: false,
roles: [], roles: [],
@@ -146,13 +172,31 @@ describe('Videos page', () => {
expect(screen.getByTestId('files-data-table')).toBeVisible(); 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', () => { describe('valid videos', () => {
beforeEach(async () => { beforeEach(async () => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: false, administrator: false,
roles: [], roles: [],

View File

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

View File

@@ -1,6 +1,6 @@
// @ts-check // @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform'; 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'; 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 getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, 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 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. * Get's organizations data. Returns list of organization names.
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
@@ -43,3 +44,22 @@ export async function createOrRerunCourse(courseData) {
); );
return camelCaseObject(data); 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, getCreateOrRerunCourseUrl,
getCourseRerunUrl, getCourseRerunUrl,
getCourseRerun, getCourseRerun,
getUserPermissions,
getUserPermissionsUrl,
getUserPermissionsEnabledFlag,
getUserPermissionsEnabledFlagUrl,
} from './api'; } from './api';
let axiosMock; let axiosMock;
const courseId = 'course-123';
describe('generic api calls', () => { describe('generic api calls', () => {
beforeEach(() => { beforeEach(() => {
@@ -41,7 +46,6 @@ describe('generic api calls', () => {
}); });
it('should get course rerun', async () => { it('should get course rerun', async () => {
const courseId = 'course-mock-id';
const courseRerunData = { const courseRerunData = {
allowUnicodeCourseId: false, allowUnicodeCourseId: false,
courseCreatorStatus: 'granted', courseCreatorStatus: 'granted',
@@ -72,4 +76,24 @@ describe('generic api calls', () => {
expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl()); expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl());
expect(result).toEqual(courseRerunData); 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 getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj; export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors; 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: { loadingStatuses: {
organizationLoadingStatus: RequestStatus.IN_PROGRESS, organizationLoadingStatus: RequestStatus.IN_PROGRESS,
courseRerunLoadingStatus: RequestStatus.IN_PROGRESS, courseRerunLoadingStatus: RequestStatus.IN_PROGRESS,
userPermissionsLoadingStatus: RequestStatus.IN_PROGRESS,
userPermissionsEnabledLoadingStatus: RequestStatus.IN_PROGRESS,
}, },
savingStatus: '', savingStatus: '',
organizations: [], organizations: [],
@@ -18,6 +20,8 @@ const slice = createSlice({
redirectUrlObj: {}, redirectUrlObj: {},
postErrors: {}, postErrors: {},
}, },
userPermissions: [],
userPermissionsEnabled: false,
}, },
reducers: { reducers: {
fetchOrganizations: (state, { payload }) => { fetchOrganizations: (state, { payload }) => {
@@ -41,6 +45,12 @@ const slice = createSlice({
updatePostErrors: (state, { payload }) => { updatePostErrors: (state, { payload }) => {
state.createOrRerunCourse.postErrors = payload; state.createOrRerunCourse.postErrors = payload;
}, },
updateUserPermissions: (state, { payload }) => {
state.userPermissions = payload;
},
updateUserPermissionsEnabled: (state, { payload }) => {
state.userPermissionsEnabled = payload;
},
}, },
}); });
@@ -52,6 +62,8 @@ export const {
updateSavingStatus, updateSavingStatus,
updateCourseData, updateCourseData,
updateRedirectUrlObj, updateRedirectUrlObj,
updateUserPermissions,
updateUserPermissionsEnabled,
} = slice.actions; } = slice.actions;
export const { export const {

View File

@@ -1,5 +1,7 @@
import { RequestStatus } from '../../data/constants'; import { RequestStatus } from '../../data/constants';
import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api'; import {
createOrRerunCourse, getOrganizations, getCourseRerun, getUserPermissions, getUserPermissionsEnabledFlag,
} from './api';
import { import {
fetchOrganizations, fetchOrganizations,
updatePostErrors, updatePostErrors,
@@ -7,6 +9,8 @@ import {
updateRedirectUrlObj, updateRedirectUrlObj,
updateCourseRerunData, updateCourseRerunData,
updateSavingStatus, updateSavingStatus,
updateUserPermissions,
updateUserPermissionsEnabled,
} from './slice'; } from './slice';
export function fetchOrganizationsQuery() { 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 DeadlineSection from './deadline-section';
import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks'; import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks';
import getPageHeadTitle from '../generic/utils'; 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 GradingSettings = ({ intl, courseId }) => {
const gradingSettingsData = useSelector(getGradingSettings); const gradingSettingsData = useSelector(getGradingSettings);
@@ -43,6 +46,14 @@ const GradingSettings = ({ intl, courseId }) => {
const [isQueryPending, setIsQueryPending] = useState(false); const [isQueryPending, setIsQueryPending] = useState(false);
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false); const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
const [eligibleGrade, setEligibleGrade] = useState(null); 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); const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
@@ -83,6 +94,12 @@ const GradingSettings = ({ intl, courseId }) => {
dispatch(fetchCourseSettingsQuery(courseId)); dispatch(fetchCourseSettingsQuery(courseId));
}, [courseId]); }, [courseId]);
if (!hasGradingPermissions) {
return (
<PermissionDeniedAlert />
);
}
if (isLoading) { if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment // eslint-disable-next-line react/jsx-no-useless-fragment
return <></>; return <></>;
@@ -156,6 +173,7 @@ const GradingSettings = ({ intl, courseId }) => {
resetDataRef={resetDataRef} resetDataRef={resetDataRef}
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert} setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
setEligibleGrade={setEligibleGrade} setEligibleGrade={setEligibleGrade}
viewOnly={viewOnly}
/> />
</section> </section>
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && ( {courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
@@ -170,6 +188,7 @@ const GradingSettings = ({ intl, courseId }) => {
minimumGradeCredit={minimumGradeCredit} minimumGradeCredit={minimumGradeCredit}
setGradingData={setGradingData} setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert} setShowSuccessAlert={setShowSuccessAlert}
viewOnly={viewOnly}
/> />
</section> </section>
)} )}
@@ -183,6 +202,7 @@ const GradingSettings = ({ intl, courseId }) => {
gracePeriod={gracePeriod} gracePeriod={gracePeriod}
setGradingData={setGradingData} setGradingData={setGradingData}
setShowSuccessAlert={setShowSuccessAlert} setShowSuccessAlert={setShowSuccessAlert}
viewOnly={viewOnly}
/> />
</section> </section>
<section> <section>
@@ -201,11 +221,13 @@ const GradingSettings = ({ intl, courseId }) => {
setGradingData={setGradingData} setGradingData={setGradingData}
courseAssignmentLists={courseAssignmentLists} courseAssignmentLists={courseAssignmentLists}
setShowSuccessAlert={setShowSuccessAlert} setShowSuccessAlert={setShowSuccessAlert}
viewOnly={viewOnly}
/> />
<Button <Button
variant="primary" variant="primary"
iconBefore={IconAdd} iconBefore={IconAdd}
onClick={handleAddAssignment} onClick={handleAddAssignment}
disabled={viewOnly}
> >
{intl.formatMessage(messages.addNewAssignmentTypeBtn)} {intl.formatMessage(messages.addNewAssignmentTypeBtn)}
</Button> </Button>

View File

@@ -12,9 +12,15 @@ import gradingSettings from './__mocks__/gradingSettings';
import GradingSettings from './GradingSettings'; import GradingSettings from './GradingSettings';
import messages from './messages'; 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 courseId = '123';
const userId = 3;
let axiosMock; let axiosMock;
let store; let store;
const userPermissionsData = { permissions: [] };
const RootWrapper = () => ( const RootWrapper = () => (
<AppProvider store={store}> <AppProvider store={store}>
@@ -28,7 +34,7 @@ describe('<GradingSettings />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
@@ -40,6 +46,25 @@ describe('<GradingSettings />', () => {
axiosMock axiosMock
.onGet(getGradingSettingsApiUrl(courseId)) .onGet(getGradingSettingsApiUrl(courseId))
.reply(200, gradingSettings); .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 () => { 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 () => { it('should update segment input value and show save alert', async () => {
const { getByTestId, getAllByTestId } = render(<RootWrapper />); const { getByTestId, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
@@ -76,6 +133,7 @@ describe('<GradingSettings />', () => {
expect(segmentInput).toHaveValue('a'); expect(segmentInput).toHaveValue('a');
}); });
}); });
it('should save segment input changes and display saving message', async () => { it('should save segment input changes and display saving message', async () => {
const { getByText, getAllByTestId } = render(<RootWrapper />); const { getByText, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
@@ -88,4 +146,23 @@ describe('<GradingSettings />', () => {
expect(getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument(); 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, secondErrorMsg,
gradeField, gradeField,
trailingElement, trailingElement,
viewOnly,
}) => ( }) => (
<li className={className}> <li className={className}>
<Form.Group className={classNames('form-group-custom', { <Form.Group className={classNames('form-group-custom', {
@@ -37,6 +38,7 @@ const AssignmentItem = ({
value={value} value={value}
isInvalid={errorEffort} isInvalid={errorEffort}
trailingElement={trailingElement} trailingElement={trailingElement}
disabled={viewOnly}
/> />
<Form.Control.Feedback className="grading-description"> <Form.Control.Feedback className="grading-description">
{descriptions} {descriptions}
@@ -81,6 +83,7 @@ AssignmentItem.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
gradeField: PropTypes.shape(defaultAssignmentsPropTypes), gradeField: PropTypes.shape(defaultAssignmentsPropTypes),
trailingElement: PropTypes.string, trailingElement: PropTypes.string,
viewOnly: PropTypes.bool.isRequired,
}; };
export default AssignmentItem; export default AssignmentItem;

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ const RootWrapper = (props = {}) => (
minimumGradeCredit={0.1} minimumGradeCredit={0.1}
setGradingData={jest.fn()} setGradingData={jest.fn()}
setShowSuccessAlert={jest.fn()} setShowSuccessAlert={jest.fn()}
viewOnly={false}
{...props} {...props}
/> />
</IntlProvider> </IntlProvider>
@@ -38,6 +39,14 @@ describe('<CreditSection />', () => {
expect(inputElement.value).toBe('10'); expect(inputElement.value).toBe('10');
fireEvent.change(inputElement, { target: { value: '2' } }); fireEvent.change(inputElement, { target: { value: '2' } });
expect(testObj.minimumGradeCredit).toBe(0.02); 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'; import messages from './messages';
const CreditSection = ({ const CreditSection = ({
intl, eligibleGrade, setShowSavePrompt, minimumGradeCredit, setGradingData, setShowSuccessAlert, intl, eligibleGrade, setShowSavePrompt, minimumGradeCredit, setGradingData, setShowSuccessAlert, viewOnly,
}) => { }) => {
const [errorEffort, setErrorEffort] = useState(false); const [errorEffort, setErrorEffort] = useState(false);
@@ -46,6 +46,7 @@ const CreditSection = ({
value={Math.round(parseFloat(minimumGradeCredit) * 100) || ''} value={Math.round(parseFloat(minimumGradeCredit) * 100) || ''}
name="minimum_grade_credit" name="minimum_grade_credit"
onChange={handleCreditChange} onChange={handleCreditChange}
disabled={viewOnly}
/> />
<Form.Control.Feedback className="grading-description"> <Form.Control.Feedback className="grading-description">
{intl.formatMessage(messages.creditEligibilityDescription)} {intl.formatMessage(messages.creditEligibilityDescription)}
@@ -66,6 +67,7 @@ CreditSection.propTypes = {
setGradingData: PropTypes.func.isRequired, setGradingData: PropTypes.func.isRequired,
setShowSuccessAlert: PropTypes.func.isRequired, setShowSuccessAlert: PropTypes.func.isRequired,
minimumGradeCredit: PropTypes.number.isRequired, minimumGradeCredit: PropTypes.number.isRequired,
viewOnly: PropTypes.bool.isRequired,
}; };
export default injectIntl(CreditSection); export default injectIntl(CreditSection);

View File

@@ -22,6 +22,7 @@ const RootWrapper = (props = {}) => (
setShowSavePrompt={jest.fn()} setShowSavePrompt={jest.fn()}
setGradingData={jest.fn()} setGradingData={jest.fn()}
setShowSuccessAlert={jest.fn()} setShowSuccessAlert={jest.fn()}
viewOnly={false}
{...props} {...props}
/> />
</IntlProvider> </IntlProvider>
@@ -46,6 +47,7 @@ describe('<DeadlineSection />', () => {
fireEvent.change(inputElement, { target: { value: '13:13' } }); fireEvent.change(inputElement, { target: { value: '13:13' } });
expect(testObj.gracePeriod.hours).toBe(13); expect(testObj.gracePeriod.hours).toBe(13);
expect(testObj.gracePeriod.minutes).toBe(13); expect(testObj.gracePeriod.minutes).toBe(13);
expect(inputElement.disabled).toEqual(false);
}); });
}); });
it('checking deadline input value if grace Period equal null', async () => { 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(); 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'; import messages from './messages';
const DeadlineSection = ({ const DeadlineSection = ({
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert, intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert, viewOnly,
}) => { }) => {
const timeStampValue = gracePeriod const timeStampValue = gracePeriod
? gracePeriod.hours && `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}` ? gracePeriod.hours && `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}`
@@ -52,6 +52,7 @@ const DeadlineSection = ({
value={newDeadlineValue} value={newDeadlineValue}
onChange={handleDeadlineChange} onChange={handleDeadlineChange}
placeholder={TIME_FORMAT.toUpperCase()} placeholder={TIME_FORMAT.toUpperCase()}
disabled={viewOnly}
/> />
<Form.Control.Feedback className="grading-description"> <Form.Control.Feedback className="grading-description">
{intl.formatMessage(messages.gracePeriodOnDeadlineDescription)} {intl.formatMessage(messages.gracePeriodOnDeadlineDescription)}
@@ -78,6 +79,7 @@ DeadlineSection.propTypes = {
hours: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), hours: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
minutes: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), minutes: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}), }),
viewOnly: PropTypes.bool.isRequired,
}; };
export default injectIntl(DeadlineSection); export default injectIntl(DeadlineSection);

View File

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

View File

@@ -16,7 +16,7 @@ const sortedGrades = [
{ current: 20, previous: 0 }, { current: 20, previous: 0 },
]; ];
const RootWrapper = () => ( const RootWrapper = (viewOnly = { viewOnly: false }) => (
<IntlProvider locale="en" messages={{}}> <IntlProvider locale="en" messages={{}}>
<GradingScale <GradingScale
intl={injectIntl} intl={injectIntl}
@@ -29,6 +29,7 @@ const RootWrapper = () => (
setGradingData={jest.fn()} setGradingData={jest.fn()}
setOverrideInternetConnectionAlert={jest.fn()} setOverrideInternetConnectionAlert={jest.fn()}
setEligibleGrade={jest.fn()} setEligibleGrade={jest.fn()}
{...viewOnly}
/> />
</IntlProvider> </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 () => { it('should remove grading segment when "Remove" button is clicked', async () => {
const { getAllByTestId } = render(<RootWrapper />); const { getAllByTestId } = render(<RootWrapper />);
await waitFor(() => { 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 PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { StudioHeader } from '@edx/frontend-component-header'; import { StudioHeader } from '@edx/frontend-component-header';
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; 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'; import messages from './messages';
const Header = ({ const Header = ({
@@ -16,24 +20,73 @@ const Header = ({
// injected // injected
intl, 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 studioBaseUrl = getConfig().STUDIO_BASE_URL;
const mainMenuDropdowns = [ const contentMenu = getContentMenuItems({
{ studioBaseUrl,
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, courseId,
buttonTitle: intl.formatMessage(messages['header.links.content']), intl,
items: 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`, id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.settings']), 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`, if (toolsMenu.length > 0) {
buttonTitle: intl.formatMessage(messages['header.links.tools']), mainMenuDropdowns.push(
items: getToolsMenuItems({ studioBaseUrl, courseId, intl }), {
}, id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
]; buttonTitle: intl.formatMessage(messages['header.links.tools']),
items: toolsMenu,
},
);
}
const outlineLink = `${studioBaseUrl}/course/${courseId}`; const outlineLink = `${studioBaseUrl}/course/${courseId}`;
return ( return (
<StudioHeader <StudioHeader

View File

@@ -2,72 +2,127 @@ import { getConfig } from '@edx/frontend-platform';
import { getPagePath } from '../utils'; import { getPagePath } from '../utils';
import messages from './messages'; import messages from './messages';
export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => { export const getContentMenuItems = ({
const items = [ studioBaseUrl,
{ courseId,
href: `${studioBaseUrl}/course/${courseId}`, intl,
title: intl.formatMessage(messages['header.links.outline']), hasContentPermissions,
}, hasOutlinePermissions,
{ }) => {
href: `${studioBaseUrl}/course_info/${courseId}`, const items = [];
title: intl.formatMessage(messages['header.links.updates']),
}, if (hasOutlinePermissions) {
{ items.push(
href: getPagePath(courseId, 'true', 'tabs'), {
title: intl.formatMessage(messages['header.links.pages']), href: `${studioBaseUrl}/course/${courseId}`,
}, title: intl.formatMessage(messages['header.links.outline']),
{ },
href: `${studioBaseUrl}/assets/${courseId}`, );
title: intl.formatMessage(messages['header.links.filesAndUploads']), }
}, 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') { if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') {
items.push({ items.push(
href: `${studioBaseUrl}/videos/${courseId}`, {
title: intl.formatMessage(messages['header.links.videoUploads']), href: `${studioBaseUrl}/videos/${courseId}`,
}); title: intl.formatMessage(messages['header.links.videoUploads']),
},
);
} }
return items; return items;
}; };
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([ export const getSettingMenuItems = ({
{ studioBaseUrl,
href: `${studioBaseUrl}/settings/details/${courseId}`, courseId,
title: intl.formatMessage(messages['header.links.scheduleAndDetails']), intl,
}, hasAdvancedSettingsAccess,
{ hasSettingsPermissions,
href: `${studioBaseUrl}/settings/grading/${courseId}`, }) => {
title: intl.formatMessage(messages['header.links.grading']), const items = [];
},
{
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 getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([ items.push(
{ {
href: `${studioBaseUrl}/import/${courseId}`, href: `${studioBaseUrl}/settings/details/${courseId}`,
title: intl.formatMessage(messages['header.links.import']), title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
}, },
{ );
href: `${studioBaseUrl}/export/${courseId}`, if (hasSettingsPermissions) {
title: intl.formatMessage(messages['header.links.export']), items.push(
}, { {
href: `${studioBaseUrl}/checklists/${courseId}`, href: `${studioBaseUrl}/settings/grading/${courseId}`,
title: intl.formatMessage(messages['header.links.checklists']), 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 { getConfig, setConfig } from '@edx/frontend-platform';
import { getContentMenuItems } from './utils'; import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
const props = { const baseProps = {
studioBaseUrl: 'UrLSTuiO', studioBaseUrl: 'UrLSTuiO',
courseId: '123', courseId: '123',
intl: { intl: {
formatMessage: jest.fn(), 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('header utils', () => {
describe('getContentMenuItems', () => { describe('getContentMenuItems', () => {
@@ -16,7 +19,7 @@ describe('header utils', () => {
...getConfig(), ...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true', ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
}); });
const actualItems = getContentMenuItems(props); const actualItems = getContentMenuItems(contentProps);
expect(actualItems).toHaveLength(5); expect(actualItems).toHaveLength(5);
}); });
it('should not include Video Uploads option', () => { it('should not include Video Uploads option', () => {
@@ -24,8 +27,84 @@ describe('header utils', () => {
...getConfig(), ...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false', ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
}); });
const actualItems = getContentMenuItems(props); const actualItems = getContentMenuItems(contentProps);
expect(actualItems).toHaveLength(4); 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 ImportSidebar from './import-sidebar/ImportSidebar';
import FileSection from './file-section/FileSection'; import FileSection from './file-section/FileSection';
import messages from './messages'; 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 CourseImportPage = ({ intl, courseId }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -32,6 +35,14 @@ const CourseImportPage = ({ intl, courseId }) => {
const loadingStatus = useSelector(getLoadingStatus); const loadingStatus = useSelector(getLoadingStatus);
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED; const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS; 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(() => { useEffect(() => {
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME); const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
@@ -43,6 +54,12 @@ const CourseImportPage = ({ intl, courseId }) => {
} }
}, []); }, []);
if (!hasImportPermissions) {
return (
<PermissionDeniedAlert />
);
}
return ( return (
<> <>
<Helmet> <Helmet>
@@ -72,7 +89,7 @@ const CourseImportPage = ({ intl, courseId }) => {
<p className="small">{intl.formatMessage(messages.description1)}</p> <p className="small">{intl.formatMessage(messages.description1)}</p>
<p className="small">{intl.formatMessage(messages.description2)}</p> <p className="small">{intl.formatMessage(messages.description2)}</p>
<p className="small">{intl.formatMessage(messages.description3)}</p> <p className="small">{intl.formatMessage(messages.description3)}</p>
<FileSection courseId={courseId} /> <FileSection courseId={courseId} viewOnly={viewOnly} />
{importTriggered && <ImportStepper courseId={courseId} />} {importTriggered && <ImportStepper courseId={courseId} />}
</article> </article>
</Layout.Element> </Layout.Element>

View File

@@ -14,12 +14,17 @@ import CourseImportPage from './CourseImportPage';
import { getImportStatusApiUrl } from './data/api'; import { getImportStatusApiUrl } from './data/api';
import { IMPORT_STAGES } from './data/constants'; import { IMPORT_STAGES } from './data/constants';
import stepperMessages from './import-stepper/messages'; 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 store;
let axiosMock; let axiosMock;
let cookies; let cookies;
const courseId = '123'; const courseId = '123';
const courseName = 'About Node JS'; const courseName = 'About Node JS';
const userId = 3;
let userPermissionsData = { permissions: [] };
jest.mock('../generic/model-store', () => ({ jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ useModel: jest.fn().mockReturnValue({
@@ -47,7 +52,7 @@ describe('<CourseImportPage />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
@@ -58,6 +63,12 @@ describe('<CourseImportPage />', () => {
axiosMock axiosMock
.onGet(getImportStatusApiUrl(courseId, 'testFileName.test')) .onGet(getImportStatusApiUrl(courseId, 'testFileName.test'))
.reply(200, { importStatus: 1, message: '' }); .reply(200, { importStatus: 1, message: '' });
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
cookies = new Cookies(); cookies = new Cookies();
cookies.get.mockReturnValue(null); cookies.get.mockReturnValue(null);
}); });
@@ -83,6 +94,30 @@ describe('<CourseImportPage />', () => {
expect(getByText(messages.description3.defaultMessage)).toBeInTheDocument(); 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 () => { it('should fetch status without clicking when cookies has', async () => {
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.test' }); cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.test' });
const { getByText } = render(<RootWrapper />); const { getByText } = render(<RootWrapper />);

View File

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

View File

@@ -14,7 +14,15 @@ const courseId = '123';
const RootWrapper = () => ( const RootWrapper = () => (
<AppProvider store={store}> <AppProvider store={store}>
<IntlProvider locale="en" messages={{}}> <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> </IntlProvider>
</AppProvider> </AppProvider>
); );
@@ -27,6 +35,7 @@ describe('<FileSection />', () => {
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], roles: [],
permisions: [],
}, },
}); });
store = initializeStore(); store = initializeStore();
@@ -43,6 +52,13 @@ describe('<FileSection />', () => {
expect(getByTestId('dropzone')).toBeInTheDocument(); 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 () => { it('should work Dropzone', async () => {
const { const {
getByText, getByTestId, queryByTestId, container, getByText, getByTestId, queryByTestId, container,

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.import.file-section.chosen-file', id: 'course-authoring.import.file-section.chosen-file',
defaultMessage: 'File chosen: {fileName}', 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; export default messages;

View File

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

View File

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

View File

@@ -21,10 +21,13 @@ import scheduleMessages from './schedule-section/messages';
import genericMessages from '../generic/help-sidebar/messages'; import genericMessages from '../generic/help-sidebar/messages';
import messages from './messages'; import messages from './messages';
import ScheduleAndDetails from '.'; import ScheduleAndDetails from '.';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock; let axiosMock;
let store; let store;
const courseId = '123'; const courseId = '123';
const userId = 3;
// Mock the tinymce lib // Mock the tinymce lib
jest.mock('@tinymce/tinymce-react', () => { jest.mock('@tinymce/tinymce-react', () => {
@@ -50,6 +53,18 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea {...props} onFocus={() => {}} onBlur={() => {}} /> <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 = () => ( const RootWrapper = () => (
<AppProvider store={store}> <AppProvider store={store}>
<IntlProvider locale="en" messages={{}}> <IntlProvider locale="en" messages={{}}>
@@ -62,7 +77,7 @@ describe('<ScheduleAndDetails />', () => {
beforeEach(() => { beforeEach(() => {
initializeMockApp({ initializeMockApp({
authenticatedUser: { authenticatedUser: {
userId: 3, userId,
username: 'abc123', username: 'abc123',
administrator: true, administrator: true,
roles: [], 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 () => { it('should hide credit section with condition', async () => {
const updatedResponse = { const updatedResponse = {
...courseSettingsMock, ...courseSettingsMock,

View File

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

View File

@@ -18,6 +18,7 @@ describe('<DetailsSection />', () => {
language: courseSettingsMock.languageOptions[1][0], language: courseSettingsMock.languageOptions[1][0],
languageOptions: courseSettingsMock.languageOptions, languageOptions: courseSettingsMock.languageOptions,
onChange: onChangeMock, onChange: onChangeMock,
isEditable: courseSettingsMock.isEditable,
}; };
it('renders details section successfully', () => { it('renders details section successfully', () => {
@@ -57,4 +58,10 @@ describe('<DetailsSection />', () => {
getByRole('button', { name: messages.dropdownEmpty.defaultMessage }), getByRole('button', { name: messages.dropdownEmpty.defaultMessage }),
).toBeInTheDocument(); ).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'; import messages from './messages';
const DetailsSection = ({ const DetailsSection = ({
language, languageOptions, onChange, language, languageOptions, onChange, isEditable,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const formattedLanguage = () => { const formattedLanguage = () => {
@@ -24,7 +24,7 @@ const DetailsSection = ({
<Form.Group className="form-group-custom dropdown-language"> <Form.Group className="form-group-custom dropdown-language">
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label> <Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
<Dropdown className="bg-white"> <Dropdown className="bg-white">
<Dropdown.Toggle variant="outline-primary" id="languageDropdown"> <Dropdown.Toggle variant="outline-primary" id="languageDropdown" disabled={!isEditable}>
{formattedLanguage()} {formattedLanguage()}
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
@@ -56,6 +56,7 @@ DetailsSection.propTypes = {
PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
).isRequired, ).isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
}; };
export default DetailsSection; export default DetailsSection;

View File

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

View File

@@ -47,7 +47,7 @@ const {
} = courseDetailsMock; } = courseDetailsMock;
const { const {
aboutPageEditable, sidebarHtmlEnabled, shortDescriptionEditable, lmsLinkForAboutPage, aboutPageEditable, sidebarHtmlEnabled, shortDescriptionEditable, lmsLinkForAboutPage, isEditable,
} = courseSettingsMock; } = courseSettingsMock;
const props = { const props = {
@@ -63,6 +63,7 @@ const props = {
courseImageAssetPath, courseImageAssetPath,
shortDescriptionEditable, shortDescriptionEditable,
onChange: onChangeMock, onChange: onChangeMock,
isEditable,
}; };
describe('<IntroducingSection />', () => { describe('<IntroducingSection />', () => {
@@ -98,4 +99,13 @@ describe('<IntroducingSection />', () => {
expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0); expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0);
expect(queryAllByText(messages.courseAboutSidebarLabel.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, enableExtendedCourseDetails,
videoThumbnailImageAssetPath, videoThumbnailImageAssetPath,
onChange, onChange,
isEditable,
}) => { }) => {
const overviewHelpText = ( const overviewHelpText = (
<FormattedMessage <FormattedMessage
@@ -72,7 +73,7 @@ const IntroducingSection = ({
return ( return (
<section className="section-container introducing-section"> <section className="section-container introducing-section">
{aboutPageEditable && ( {aboutPageEditable && isEditable && (
<SectionSubHeader <SectionSubHeader
title={intl.formatMessage(messages.introducingTitle)} title={intl.formatMessage(messages.introducingTitle)}
description={intl.formatMessage(messages.introducingDescription)} description={intl.formatMessage(messages.introducingDescription)}
@@ -87,7 +88,7 @@ const IntroducingSection = ({
onChange={onChange} onChange={onChange}
/> />
)} )}
{shortDescriptionEditable && ( {shortDescriptionEditable && isEditable && (
<Form.Group className="form-group-custom"> <Form.Group className="form-group-custom">
<Form.Label> <Form.Label>
{intl.formatMessage(messages.courseShortDescriptionLabel)} {intl.formatMessage(messages.courseShortDescriptionLabel)}
@@ -107,7 +108,7 @@ const IntroducingSection = ({
</Form.Control.Feedback> </Form.Control.Feedback>
</Form.Group> </Form.Group>
)} )}
{aboutPageEditable && ( {aboutPageEditable && isEditable && (
<> <>
<Form.Group className="form-group-custom"> <Form.Group className="form-group-custom">
<Form.Label>{intl.formatMessage(messages.courseOverviewLabel)}</Form.Label> <Form.Label>{intl.formatMessage(messages.courseOverviewLabel)}</Form.Label>
@@ -160,7 +161,7 @@ const IntroducingSection = ({
/> />
</> </>
)} )}
{aboutPageEditable && ( {aboutPageEditable && isEditable && (
<IntroductionVideo introVideo={introVideo} onChange={onChange} /> <IntroductionVideo introVideo={introVideo} onChange={onChange} />
)} )}
</section> </section>
@@ -200,6 +201,7 @@ IntroducingSection.propTypes = {
enableExtendedCourseDetails: PropTypes.bool.isRequired, enableExtendedCourseDetails: PropTypes.bool.isRequired,
videoThumbnailImageAssetPath: PropTypes.string, videoThumbnailImageAssetPath: PropTypes.string,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
}; };
export default injectIntl(IntroducingSection); export default injectIntl(IntroducingSection);

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ const RootWrapper = (props) => (
const props = { const props = {
licenseType: LICENSE_TYPE.allRightsReserved, licenseType: LICENSE_TYPE.allRightsReserved,
onChangeLicenseType: onChangeLicenseTypeMock, onChangeLicenseType: onChangeLicenseTypeMock,
isEditable: true,
}; };
describe('<LicenseSelector />', () => { describe('<LicenseSelector />', () => {
@@ -60,4 +61,13 @@ describe('<LicenseSelector />', () => {
expect(buttonFirst).toHaveClass('btn btn-outline-primary'); expect(buttonFirst).toHaveClass('btn btn-outline-primary');
expect(buttonSecond).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 { LICENSE_TYPE } from '../constants';
import messages from './messages'; import messages from './messages';
const LicenseSelector = ({ licenseType, onChangeLicenseType }) => { const LicenseSelector = ({ licenseType, onChangeLicenseType, isEditable }) => {
const LICENSE_BUTTON_GROUP_LABELS = { const LICENSE_BUTTON_GROUP_LABELS = {
[LICENSE_TYPE.allRightsReserved]: { [LICENSE_TYPE.allRightsReserved]: {
label: <FormattedMessage {...messages.licenseChoice1} />, label: <FormattedMessage {...messages.licenseChoice1} />,
@@ -37,6 +37,7 @@ const LicenseSelector = ({ licenseType, onChangeLicenseType }) => {
<Button <Button
variant={isActive ? 'primary' : 'outline-primary'} variant={isActive ? 'primary' : 'outline-primary'}
onClick={() => onChangeLicenseType(type, 'license')} onClick={() => onChangeLicenseType(type, 'license')}
disabled={!isEditable}
> >
{LICENSE_BUTTON_GROUP_LABELS[type].label} {LICENSE_BUTTON_GROUP_LABELS[type].label}
</Button> </Button>
@@ -64,6 +65,7 @@ LicenseSelector.defaultProps = {
LicenseSelector.propTypes = { LicenseSelector.propTypes = {
licenseType: PropTypes.oneOf(Object.values(LICENSE_TYPE)), licenseType: PropTypes.oneOf(Object.values(LICENSE_TYPE)),
onChangeLicenseType: PropTypes.func.isRequired, onChangeLicenseType: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
}; };
export default LicenseSelector; export default LicenseSelector;

View File

@@ -36,6 +36,7 @@ const {
isEntranceExamsEnabled, isEntranceExamsEnabled,
possiblePreRequisiteCourses, possiblePreRequisiteCourses,
isPrerequisiteCoursesEnabled, isPrerequisiteCoursesEnabled,
isEditable,
} = courseSettingsMock; } = courseSettingsMock;
const props = { const props = {
@@ -49,6 +50,7 @@ const props = {
entranceExamMinimumScorePct, entranceExamMinimumScorePct,
isPrerequisiteCoursesEnabled, isPrerequisiteCoursesEnabled,
onChange: onChangeMock, onChange: onChangeMock,
isEditable,
}; };
describe('<RequirementsSection />', () => { describe('<RequirementsSection />', () => {
@@ -90,4 +92,10 @@ describe('<RequirementsSection />', () => {
expect(queryAllByLabelText(messages.dropdownLabel.defaultMessage).length).toBe(0); expect(queryAllByLabelText(messages.dropdownLabel.defaultMessage).length).toBe(0);
expect(queryAllByLabelText(entranceExamMessages.requirementsEntrance.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'; } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { courseDetailsMock } from '../../__mocks__'; import { courseDetailsMock, courseSettingsMock } from '../../__mocks__';
import gradeRequirementsMessages from '../grade-requirements/messages'; import gradeRequirementsMessages from '../grade-requirements/messages';
import messages from './messages'; import messages from './messages';
import EntranceExam from '.'; import EntranceExam from '.';
@@ -30,6 +30,7 @@ const props = {
isCheckedString: courseDetailsMock.entranceExamEnabled, isCheckedString: courseDetailsMock.entranceExamEnabled,
entranceExamMinimumScorePct: courseDetailsMock.entranceExamMinimumScorePct, entranceExamMinimumScorePct: courseDetailsMock.entranceExamMinimumScorePct,
onChange: onChangeMock, onChange: onChangeMock,
isEditable: courseSettingsMock.isEditable,
}; };
describe('<EntranceExam />', () => { describe('<EntranceExam />', () => {
@@ -58,4 +59,11 @@ describe('<EntranceExam />', () => {
).toBe(0); ).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, isCheckedString,
entranceExamMinimumScorePct, entranceExamMinimumScorePct,
onChange, onChange,
isEditable,
}) => { }) => {
const { courseId } = useParams(); const { courseId } = useParams();
const showEntranceExam = isCheckedString === 'true'; const showEntranceExam = isCheckedString === 'true';
@@ -33,6 +34,7 @@ const EntranceExam = ({
<Form.Checkbox <Form.Checkbox
checked={showEntranceExam} checked={showEntranceExam}
onChange={toggleEntranceExam} onChange={toggleEntranceExam}
disabled={!isEditable}
> >
<FormattedMessage {...messages.requirementsEntranceCollapseTitle} /> <FormattedMessage {...messages.requirementsEntranceCollapseTitle} />
</Form.Checkbox> </Form.Checkbox>
@@ -63,6 +65,7 @@ const EntranceExam = ({
errorEffort={errorEffort} errorEffort={errorEffort}
entranceExamMinimumScorePct={entranceExamMinimumScorePct} entranceExamMinimumScorePct={entranceExamMinimumScorePct}
onChange={onChange} onChange={onChange}
isEditable={isEditable}
/> />
</Card.Body> </Card.Body>
</> </>
@@ -83,6 +86,7 @@ EntranceExam.propTypes = {
isCheckedString: PropTypes.string, isCheckedString: PropTypes.string,
entranceExamMinimumScorePct: PropTypes.string, entranceExamMinimumScorePct: PropTypes.string,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
isEditable: PropTypes.bool.isRequired,
}; };
export default EntranceExam; export default EntranceExam;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ describe('studio-home api calls', () => {
expect(result).toEqual(expected); 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`; const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse()); axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCourses(''); const result = await getStudioHomeCourses('');