Compare commits
7 Commits
open-relea
...
CourseRole
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2bfb1fb7b | ||
|
|
c754a5e519 | ||
|
|
1e9146a5b9 | ||
|
|
a518fada29 | ||
|
|
69d9ea318e | ||
|
|
e74e1ff5aa | ||
|
|
1137dae97a |
@@ -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.
|
||||
@@ -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
|
||||
~~~~~
|
||||
|
||||
You’ll 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``.
|
||||
@@ -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
2
package-lock.json
generated
@@ -65,7 +65,7 @@
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"husky": "^7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"husky": "^7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
|
||||
@@ -14,6 +14,8 @@ import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
|
||||
import { getUserPermissions } from './generic/data/selectors';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
@@ -40,9 +42,14 @@ AppHeader.defaultProps = {
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const userPermissions = useSelector(getUserPermissions);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchUserPermissionsEnabledFlag());
|
||||
if (!userPermissions) {
|
||||
dispatch(fetchUserPermissionsQuery(courseId));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
@@ -25,6 +25,9 @@ import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -41,6 +44,13 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const viewOnly = checkPermission('view_course_settings');
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && (
|
||||
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
@@ -83,6 +93,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
@@ -215,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
disableForm={viewOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
@@ -13,11 +17,15 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
@@ -43,11 +51,23 @@ const RootWrapper = () => (
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const permissionsMockStore = async (permissions) => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const permissionDisabledMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
permissionsMockStore(userPermissionsData);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
|
||||
const permissionsData = { permissions: ['view'] };
|
||||
await permissionsMockStore(permissionsData);
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
|
||||
await permissionDisabledMockStore();
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).not.toBeInTheDocument();
|
||||
});
|
||||
it('should be view only if the permission is set for viewOnly', async () => {
|
||||
const permissions = { permissions: ['view_course_settings'] };
|
||||
await permissionsMockStore(permissions);
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText('Advanced Module List')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const SettingCard = ({
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
disableForm,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
@@ -100,6 +101,7 @@ const SettingCard = ({
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
@@ -135,6 +137,7 @@ SettingCard.propTypes = {
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
disableForm: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
|
||||
@@ -41,6 +41,9 @@ import DeleteModal from './delete-modal/DeleteModal';
|
||||
import PageAlerts from './page-alerts/PageAlerts';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
@@ -108,6 +111,12 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const hasOutlinePermissions = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && (checkPermission('manage_libraries') || checkPermission('manage_content'))
|
||||
);
|
||||
|
||||
let initialSections = [...sectionsList];
|
||||
|
||||
const {
|
||||
@@ -241,6 +250,12 @@ const CourseOutline = ({ courseId }) => {
|
||||
setSections(sectionsList);
|
||||
}, [sectionsList]);
|
||||
|
||||
if (!hasOutlinePermissions) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return (
|
||||
|
||||
@@ -48,10 +48,15 @@ import pasteButtonMessages from './paste-button/messages';
|
||||
import subsectionMessages from './subsection-card/messages';
|
||||
import pageAlertMessages from './page-alerts/messages';
|
||||
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: [] };
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
@@ -90,7 +95,7 @@ describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -102,6 +107,14 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexMock);
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: false });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
@@ -114,6 +127,13 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should render permissionDenied if incorrect permissions', async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('check reindex and render success alert is correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
|
||||
|
||||
@@ -19,12 +19,13 @@ import AddTeamMember from './add-team-member/AddTeamMember';
|
||||
import CourseTeamMember from './course-team-member/CourseTeamMember';
|
||||
import InfoModal from './info-modal/InfoModal';
|
||||
import { useCourseTeam } from './hooks';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
|
||||
const CourseTeam = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const {
|
||||
@@ -55,6 +56,12 @@ const CourseTeam = ({ courseId }) => {
|
||||
handleInternetConnectionFailed,
|
||||
} = useCourseTeam({ intl, courseId });
|
||||
|
||||
const {
|
||||
checkPermission,
|
||||
} = useUserPermissions();
|
||||
|
||||
const hasManageAllUsersPerm = checkPermission('manage_all_users');
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
@@ -77,7 +84,7 @@ const CourseTeam = ({ courseId }) => {
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={isAllowActions && (
|
||||
headerActions={(isAllowActions || hasManageAllUsersPerm) && (
|
||||
<Button
|
||||
variant="primary"
|
||||
iconBefore={IconAdd}
|
||||
@@ -104,13 +111,13 @@ const CourseTeam = ({ courseId }) => {
|
||||
role={role}
|
||||
email={email}
|
||||
currentUserEmail={currentUserEmail || ''}
|
||||
isAllowActions={isAllowActions}
|
||||
isAllowActions={isAllowActions || hasManageAllUsersPerm}
|
||||
isHideActions={role === USER_ROLES.admin && isSingleAdmin}
|
||||
onChangeRole={handleChangeRoleUserSubmit}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
/>
|
||||
)) : null}
|
||||
{isShowAddTeamMember && (
|
||||
{(isShowAddTeamMember || hasManageAllUsersPerm) && (
|
||||
<AddTeamMember
|
||||
onFormOpen={openForm}
|
||||
isButtonDisable={isFormVisible}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import initializeStore from '../store';
|
||||
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
|
||||
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
import CourseTeam from './CourseTeam';
|
||||
import messages from './messages';
|
||||
import { USER_ROLES } from '../constants';
|
||||
@@ -24,6 +26,8 @@ let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: ['manage_all_users'] };
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -44,7 +48,7 @@ describe('<CourseTeam />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -53,6 +57,12 @@ describe('<CourseTeam />', () => {
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: true });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 3 team members correctly', async () => {
|
||||
@@ -165,13 +175,13 @@ describe('<CourseTeam />', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
const addButton = getByRole('button', { name: 'Add a new team member' });
|
||||
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
|
||||
fireEvent.click(addButton);
|
||||
expect(queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
|
||||
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false and hasManageAllUsersPerm is false', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
@@ -179,12 +189,39 @@ describe('<CourseTeam />', () => {
|
||||
...courseTeamWithOneUser,
|
||||
allowActions: false,
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: true });
|
||||
|
||||
const { queryByRole, queryByTestId } = render(<RootWrapper />);
|
||||
const { queryByRole, queryByText } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
|
||||
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
|
||||
expect(queryByText('add-team-member')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays "Add New Member" and AddTeamMember component when hasManageAllUsersPerm is true', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseTeamWithOneUser,
|
||||
allowActions: false,
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: true });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const CourseTeamMember = ({
|
||||
<div className="course-team-member" data-testid="course-team-member">
|
||||
<div className="member-info">
|
||||
<Badge className={`badge-current-user bg-${badgeColor} text-light-100`}>
|
||||
{isAdminRole
|
||||
{(isAdminRole)
|
||||
? intl.formatMessage(messages.roleAdmin)
|
||||
: intl.formatMessage(messages.roleStaff)}
|
||||
{currentUserEmail === email && (
|
||||
@@ -46,11 +46,13 @@ const CourseTeamMember = ({
|
||||
!isHideActions ? (
|
||||
<div className="member-actions">
|
||||
<Button
|
||||
variant={isAdminRole ? 'tertiary' : 'primary'}
|
||||
variant={(isAdminRole) ? 'tertiary' : 'primary'}
|
||||
size="sm"
|
||||
onClick={() => onChangeRole(email, isAdminRole ? USER_ROLES.staff : USER_ROLES.admin)}
|
||||
>
|
||||
{isAdminRole ? intl.formatMessage(messages.removeButton) : intl.formatMessage(messages.addButton)}
|
||||
{(isAdminRole)
|
||||
? intl.formatMessage(messages.removeButton)
|
||||
: intl.formatMessage(messages.addButton)}
|
||||
</Button>
|
||||
<IconButtonWithTooltip
|
||||
src={DeleteOutline}
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import { Add as AddIcon } from '@edx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../generic/model-store';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import CourseHandouts from './course-handouts/CourseHandouts';
|
||||
import CourseUpdate from './course-update/CourseUpdate';
|
||||
@@ -25,6 +25,8 @@ import { useCourseUpdates } from './hooks';
|
||||
import { getLoadingStatuses, getSavingStatuses } from './data/selectors';
|
||||
import { matchesAnyStatus } from './utils';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
|
||||
const CourseUpdates = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
@@ -60,7 +62,15 @@ const CourseUpdates = ({ courseId }) => {
|
||||
const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED);
|
||||
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
|
||||
const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING);
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
|
||||
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="px-4">
|
||||
|
||||
@@ -22,11 +22,16 @@ import { executeThunk } from '../utils';
|
||||
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
|
||||
import CourseUpdates from './CourseUpdates';
|
||||
import messages from './messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: ['manage_content'] };
|
||||
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -61,7 +66,7 @@ const RootWrapper = () => (
|
||||
);
|
||||
|
||||
describe('<CourseUpdates />', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
@@ -79,6 +84,7 @@ describe('<CourseUpdates />', () => {
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, courseHandoutsMock);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
});
|
||||
|
||||
it('render CourseUpdates component correctly', async () => {
|
||||
@@ -162,6 +168,26 @@ describe('<CourseUpdates />', () => {
|
||||
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not show PermissionDeniedAlert if User Permissions are the correct ones', async () => {
|
||||
const { queryByTestId } = render(<RootWrapper />);
|
||||
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Add new update form is visible after clicking "New update" button', async () => {
|
||||
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './
|
||||
import ExportModalError from './export-modal-error/ExportModalError';
|
||||
import ExportFooter from './export-footer/ExportFooter';
|
||||
import ExportStepper from './export-stepper/ExportStepper';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const CourseExportPage = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -38,6 +41,14 @@ const CourseExportPage = ({ intl, courseId }) => {
|
||||
const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS;
|
||||
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
|
||||
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const hasExportPermissions = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))
|
||||
);
|
||||
const viewOnly = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && checkPermission('view_course_settings') && !checkPermission('manage_course_settings')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
|
||||
@@ -48,6 +59,12 @@ const CourseExportPage = ({ intl, courseId }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!hasExportPermissions) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -89,13 +106,14 @@ const CourseExportPage = ({ intl, courseId }) => {
|
||||
className="mb-4"
|
||||
onClick={() => dispatch(startExportingCourse(courseId))}
|
||||
iconBefore={ArrowCircleDownIcon}
|
||||
disabled={viewOnly}
|
||||
>
|
||||
{intl.formatMessage(messages.buttonTitle)}
|
||||
</Button>
|
||||
</Card.Section>
|
||||
)}
|
||||
</Card>
|
||||
{exportTriggered && <ExportStepper courseId={courseId} />}
|
||||
{exportTriggered && <ExportStepper courseId={courseId} viewOnly={viewOnly} />}
|
||||
<ExportFooter />
|
||||
</article>
|
||||
</Layout.Element>
|
||||
|
||||
@@ -12,6 +12,9 @@ import initializeStore from '../store';
|
||||
import stepperMessages from './export-stepper/messages';
|
||||
import modalErrorMessages from './export-modal-error/messages';
|
||||
import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
import { executeThunk } from '../utils';
|
||||
import { EXPORT_STAGES } from './data/constants';
|
||||
import { exportPageMock } from './__mocks__';
|
||||
import messages from './messages';
|
||||
@@ -22,6 +25,8 @@ let axiosMock;
|
||||
let cookies;
|
||||
const courseId = '123';
|
||||
const courseName = 'About Node JS';
|
||||
const userId = 3;
|
||||
let userPermissionsData = { permissions: [] };
|
||||
|
||||
jest.mock('../generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({
|
||||
@@ -49,7 +54,7 @@ describe('<CourseExportPage />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -60,6 +65,14 @@ describe('<CourseExportPage />', () => {
|
||||
axiosMock
|
||||
.onGet(postExportCourseApiUrl(courseId))
|
||||
.reply(200, exportPageMock);
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: false });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
cookies = new Cookies();
|
||||
cookies.get.mockReturnValue(null);
|
||||
});
|
||||
@@ -85,8 +98,57 @@ describe('<CourseExportPage />', () => {
|
||||
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render permissionDenied if incorrect permissions', async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
it('should render without errors if correct permissions', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
userPermissionsData = { permissions: ['manage_course_settings'] };
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(exportPageElement).toBeInTheDocument();
|
||||
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render without errors if viewOnly permissions', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
userPermissionsData = { permissions: ['view_course_settings'] };
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
const buttonElement = getByText(messages.buttonTitle.defaultMessage);
|
||||
expect(exportPageElement).toBeInTheDocument();
|
||||
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
|
||||
expect(buttonElement).toBeInTheDocument();
|
||||
expect(buttonElement.disabled).toEqual(true);
|
||||
});
|
||||
userPermissionsData = { permissions: ['manage_course_settings'] };
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
});
|
||||
it('should start exporting on click', async () => {
|
||||
const { getByText, container } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
const button = container.querySelector('.btn-primary');
|
||||
fireEvent.click(button);
|
||||
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
|
||||
@@ -96,6 +158,8 @@ describe('<CourseExportPage />', () => {
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } });
|
||||
const { getByText, queryByText, container } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
@@ -116,6 +180,8 @@ describe('<CourseExportPage />', () => {
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' });
|
||||
const { getByText, container } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
@@ -129,6 +195,8 @@ describe('<CourseExportPage />', () => {
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' });
|
||||
const { getByText, container } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
const startExportButton = container.querySelector('.btn-primary');
|
||||
fireEvent.click(startExportButton);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
|
||||
@@ -17,7 +17,7 @@ import { EXPORT_STAGES } from '../data/constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const ExportStepper = ({ intl, courseId }) => {
|
||||
const ExportStepper = ({ intl, courseId, viewOnly }) => {
|
||||
const currentStage = useSelector(getCurrentStage);
|
||||
const downloadPath = useSelector(getDownloadPath);
|
||||
const successDate = useSelector(getSuccessDate);
|
||||
@@ -90,7 +90,7 @@ const ExportStepper = ({ intl, courseId }) => {
|
||||
errorMessage={errorMessage}
|
||||
hasError={!!errorMessage}
|
||||
/>
|
||||
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
|
||||
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download disabled={viewOnly}>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -98,6 +98,7 @@ const ExportStepper = ({ intl, courseId }) => {
|
||||
ExportStepper.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ExportStepper);
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
|
||||
import messages from './messages';
|
||||
import ExportStepper from './ExportStepper';
|
||||
import { exportStepperPageMock } from './__mocks__';
|
||||
import { fetchExportStatus } from '../data/thunks';
|
||||
import { getExportStatusApiUrl } from '../data/api';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} />
|
||||
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} viewOnly={false} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const ViewOnlyRootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} viewOnly />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -30,9 +46,22 @@ describe('<ExportStepper />', () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getExportStatusApiUrl(courseId))
|
||||
.reply(200, exportStepperPageMock);
|
||||
executeThunk(fetchExportStatus(courseId), store.dispatch);
|
||||
});
|
||||
it('render stepper correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('render stepper correctly if button is disabled', () => {
|
||||
const { getByText } = render(<ViewOnlyRootWrapper />);
|
||||
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
|
||||
const buttonElement = getByText(messages.downloadCourseButtonTitle.defaultMessage, {
|
||||
selector: '.disabled',
|
||||
});
|
||||
expect(buttonElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
exportStatus: 3,
|
||||
exportOutput: '/test',
|
||||
};
|
||||
2
src/export-page/export-stepper/__mocks__/index.js
Normal file
2
src/export-page/export-stepper/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as exportStepperPageMock } from './exportStepperPage';
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
import { getFileSizeToClosestByte } from '../../utils';
|
||||
import FileThumbnail from './FileThumbnail';
|
||||
import FileInfoModalSidebar from './FileInfoModalSidebar';
|
||||
import { useUserPermissions } from '../../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
|
||||
|
||||
const FilesPage = ({
|
||||
courseId,
|
||||
@@ -39,6 +42,9 @@ const FilesPage = ({
|
||||
const dispatch = useDispatch();
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAssets(courseId));
|
||||
@@ -160,6 +166,11 @@ const FilesPage = ({
|
||||
{ ...accessColumn },
|
||||
];
|
||||
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
if (loadingStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">
|
||||
|
||||
@@ -39,10 +39,16 @@ import {
|
||||
} from './data/thunks';
|
||||
import { getAssetsUrl } from './data/api';
|
||||
import messages from '../generic/messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let file;
|
||||
const userId = 3;
|
||||
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
|
||||
const userPermissionsData = { permissions: ['manage_content'] };
|
||||
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
jest.mock('file-saver');
|
||||
|
||||
@@ -68,6 +74,8 @@ const mockStore = async (
|
||||
}
|
||||
renderComponent();
|
||||
await executeThunk(fetchAssets(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const emptyMockStore = async (status) => {
|
||||
@@ -75,6 +83,27 @@ const emptyMockStore = async (status) => {
|
||||
axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse());
|
||||
renderComponent();
|
||||
await executeThunk(fetchAssets(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const wrongUserPermissionsMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const disabledUserPermissionsFlagMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const userPermissionsMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
describe('FilesAndUploads', () => {
|
||||
@@ -82,7 +111,7 @@ describe('FilesAndUploads', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
@@ -100,6 +129,21 @@ describe('FilesAndUploads', () => {
|
||||
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
|
||||
});
|
||||
|
||||
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
|
||||
renderComponent();
|
||||
await wrongUserPermissionsMockStore();
|
||||
expect(screen.getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
it('should not show PermissionDeniedAlert if User Permissions Flag is not enabled', async () => {
|
||||
renderComponent();
|
||||
await disabledUserPermissionsFlagMockStore();
|
||||
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
it('should not show PermissionDeniedAlert if User Permissions Flag is enabled and permissions are correct', async () => {
|
||||
renderComponent();
|
||||
await userPermissionsMockStore();
|
||||
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
it('should return placeholder component', async () => {
|
||||
await mockStore(RequestStatus.DENIED);
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ import VideoThumbnail from './VideoThumbnail';
|
||||
import { getFormattedDuration, resampleFile } from './data/utils';
|
||||
import FILES_AND_UPLOAD_TYPE_FILTERS from '../generic/constants';
|
||||
import VideoInfoModalSidebar from './info-sidebar';
|
||||
import { useUserPermissions } from '../../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
|
||||
|
||||
const VideosPage = ({
|
||||
courseId,
|
||||
@@ -53,6 +56,9 @@ const VideosPage = ({
|
||||
const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false);
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchVideos(courseId));
|
||||
@@ -179,6 +185,11 @@ const VideosPage = ({
|
||||
{ ...processingStatusColumn },
|
||||
];
|
||||
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
if (loadingStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">
|
||||
|
||||
@@ -39,10 +39,15 @@ import {
|
||||
import { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api';
|
||||
import videoMessages from './messages';
|
||||
import messages from '../generic/messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let file;
|
||||
const userId = 3;
|
||||
const wrongUserPermissionsData = { permissions: ['wrong_permission'] };
|
||||
const userPermissionsData = { permissions: ['manage_content'] };
|
||||
jest.mock('file-saver');
|
||||
|
||||
const renderComponent = () => {
|
||||
@@ -55,9 +60,7 @@ const renderComponent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const mockStore = async (
|
||||
status,
|
||||
) => {
|
||||
const mockStore = async (status) => {
|
||||
const fetchVideosUrl = getVideosUrl(courseId);
|
||||
const videosData = generateFetchVideosApiResponse();
|
||||
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), videosData);
|
||||
@@ -68,6 +71,8 @@ const mockStore = async (
|
||||
|
||||
renderComponent();
|
||||
await executeThunk(fetchVideos(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const emptyMockStore = async (status) => {
|
||||
@@ -75,6 +80,27 @@ const emptyMockStore = async (status) => {
|
||||
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse());
|
||||
renderComponent();
|
||||
await executeThunk(fetchVideos(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const wrongUserPermissionsMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const disabledUserPermissionsFlagMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const userPermissionsMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
describe('Videos page', () => {
|
||||
@@ -82,7 +108,7 @@ describe('Videos page', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
@@ -146,13 +172,31 @@ describe('Videos page', () => {
|
||||
|
||||
expect(screen.getByTestId('files-data-table')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
|
||||
renderComponent();
|
||||
await wrongUserPermissionsMockStore();
|
||||
expect(screen.getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not show PermissionDeniedAlert if User Permissions Flag is not enabled', async () => {
|
||||
renderComponent();
|
||||
await disabledUserPermissionsFlagMockStore();
|
||||
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show PermissionDeniedAlert if User Permissions Flag is enabled and permission is correct', async () => {
|
||||
renderComponent();
|
||||
await userPermissionsMockStore();
|
||||
expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid videos', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
|
||||
@@ -16,6 +16,8 @@ export const initialState = {
|
||||
},
|
||||
organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'],
|
||||
savingStatus: '',
|
||||
userPermissions: [],
|
||||
userPermissionsEnabled: false,
|
||||
},
|
||||
studioHome: {
|
||||
loadingStatuses: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
@@ -8,7 +8,8 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
|
||||
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
|
||||
|
||||
export const getUserPermissionsUrl = (courseId, userId) => `${getApiBaseUrl()}/api/course_roles/v1/user_permissions/?course_id=${encodeURIComponent(courseId)}&user_id=${userId}`;
|
||||
export const getUserPermissionsEnabledFlagUrl = new URL('/api/course_roles/v1/user_permissions/enabled/', getApiBaseUrl()).href;
|
||||
/**
|
||||
* Get's organizations data. Returns list of organization names.
|
||||
* @returns {Promise<string[]>}
|
||||
@@ -43,3 +44,22 @@ export async function createOrRerunCourse(courseData) {
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user course roles permissions.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getUserPermissions(courseId) {
|
||||
const { userId } = getAuthenticatedUser();
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getUserPermissionsUrl(courseId, userId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function getUserPermissionsEnabledFlag() {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getUserPermissionsEnabledFlagUrl);
|
||||
return data || false;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
getCreateOrRerunCourseUrl,
|
||||
getCourseRerunUrl,
|
||||
getCourseRerun,
|
||||
getUserPermissions,
|
||||
getUserPermissionsUrl,
|
||||
getUserPermissionsEnabledFlag,
|
||||
getUserPermissionsEnabledFlagUrl,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
const courseId = 'course-123';
|
||||
|
||||
describe('generic api calls', () => {
|
||||
beforeEach(() => {
|
||||
@@ -41,7 +46,6 @@ describe('generic api calls', () => {
|
||||
});
|
||||
|
||||
it('should get course rerun', async () => {
|
||||
const courseId = 'course-mock-id';
|
||||
const courseRerunData = {
|
||||
allowUnicodeCourseId: false,
|
||||
courseCreatorStatus: 'granted',
|
||||
@@ -72,4 +76,24 @@ describe('generic api calls', () => {
|
||||
expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl());
|
||||
expect(result).toEqual(courseRerunData);
|
||||
});
|
||||
|
||||
it('should get user permissions', async () => {
|
||||
const permissionsData = { permissions: ['manage_all_users'] };
|
||||
const queryUrl = getUserPermissionsUrl(courseId, 3);
|
||||
axiosMock.onGet(queryUrl).reply(200, permissionsData);
|
||||
const result = await getUserPermissions(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(queryUrl);
|
||||
expect(result).toEqual(permissionsData);
|
||||
});
|
||||
|
||||
it('should get user permissions enabled flag', async () => {
|
||||
const permissionsEnabledData = { enabled: true };
|
||||
const queryUrl = getUserPermissionsEnabledFlagUrl;
|
||||
axiosMock.onGet(queryUrl).reply(200, permissionsEnabledData);
|
||||
const result = await getUserPermissionsEnabledFlag();
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(queryUrl);
|
||||
expect(result).toEqual(permissionsEnabledData);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,3 +5,5 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
|
||||
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
|
||||
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
|
||||
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
|
||||
export const getUserPermissions = (state) => state.generic.userPermissions.permissions;
|
||||
export const getUserPermissionsEnabled = (state) => state.generic.userPermissionsEnabled;
|
||||
|
||||
@@ -9,6 +9,8 @@ const slice = createSlice({
|
||||
loadingStatuses: {
|
||||
organizationLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseRerunLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
userPermissionsLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
userPermissionsEnabledLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
savingStatus: '',
|
||||
organizations: [],
|
||||
@@ -18,6 +20,8 @@ const slice = createSlice({
|
||||
redirectUrlObj: {},
|
||||
postErrors: {},
|
||||
},
|
||||
userPermissions: [],
|
||||
userPermissionsEnabled: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchOrganizations: (state, { payload }) => {
|
||||
@@ -41,6 +45,12 @@ const slice = createSlice({
|
||||
updatePostErrors: (state, { payload }) => {
|
||||
state.createOrRerunCourse.postErrors = payload;
|
||||
},
|
||||
updateUserPermissions: (state, { payload }) => {
|
||||
state.userPermissions = payload;
|
||||
},
|
||||
updateUserPermissionsEnabled: (state, { payload }) => {
|
||||
state.userPermissionsEnabled = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +62,8 @@ export const {
|
||||
updateSavingStatus,
|
||||
updateCourseData,
|
||||
updateRedirectUrlObj,
|
||||
updateUserPermissions,
|
||||
updateUserPermissionsEnabled,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api';
|
||||
import {
|
||||
createOrRerunCourse, getOrganizations, getCourseRerun, getUserPermissions, getUserPermissionsEnabledFlag,
|
||||
} from './api';
|
||||
import {
|
||||
fetchOrganizations,
|
||||
updatePostErrors,
|
||||
@@ -7,6 +9,8 @@ import {
|
||||
updateRedirectUrlObj,
|
||||
updateCourseRerunData,
|
||||
updateSavingStatus,
|
||||
updateUserPermissions,
|
||||
updateUserPermissionsEnabled,
|
||||
} from './slice';
|
||||
|
||||
export function fetchOrganizationsQuery() {
|
||||
@@ -49,3 +53,28 @@ export function updateCreateOrRerunCourseQuery(courseData) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUserPermissionsQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const userPermissions = await getUserPermissions(courseId);
|
||||
dispatch(updateUserPermissions(userPermissions));
|
||||
dispatch(updateLoadingStatuses({ userPermissionsLoadingStatus: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatuses({ userPermissionsLoadingStatus: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchUserPermissionsEnabledFlag() {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await getUserPermissionsEnabledFlag();
|
||||
dispatch(updateUserPermissionsEnabled(data.enabled || false));
|
||||
dispatch(updateLoadingStatuses({ userPermissionsEnabledLoadingStatus: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateUserPermissionsEnabled(false));
|
||||
dispatch(updateLoadingStatuses({ userPermissionsEnabledLoadingStatus: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
21
src/generic/hooks.jsx
Normal file
21
src/generic/hooks.jsx
Normal 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 };
|
||||
@@ -30,6 +30,9 @@ import CreditSection from './credit-section';
|
||||
import DeadlineSection from './deadline-section';
|
||||
import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const GradingSettings = ({ intl, courseId }) => {
|
||||
const gradingSettingsData = useSelector(getGradingSettings);
|
||||
@@ -43,6 +46,14 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
|
||||
const [eligibleGrade, setEligibleGrade] = useState(null);
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const hasGradingPermissions = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))
|
||||
);
|
||||
const viewOnly = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && checkPermission('view_course_settings') && !checkPermission('manage_course_settings')
|
||||
);
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
@@ -83,6 +94,12 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
dispatch(fetchCourseSettingsQuery(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
if (!hasGradingPermissions) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
@@ -156,6 +173,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
resetDataRef={resetDataRef}
|
||||
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
|
||||
setEligibleGrade={setEligibleGrade}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</section>
|
||||
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
|
||||
@@ -170,6 +188,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
minimumGradeCredit={minimumGradeCredit}
|
||||
setGradingData={setGradingData}
|
||||
setShowSuccessAlert={setShowSuccessAlert}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
@@ -183,6 +202,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
gracePeriod={gracePeriod}
|
||||
setGradingData={setGradingData}
|
||||
setShowSuccessAlert={setShowSuccessAlert}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
@@ -201,11 +221,13 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
setGradingData={setGradingData}
|
||||
courseAssignmentLists={courseAssignmentLists}
|
||||
setShowSuccessAlert={setShowSuccessAlert}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconBefore={IconAdd}
|
||||
onClick={handleAddAssignment}
|
||||
disabled={viewOnly}
|
||||
>
|
||||
{intl.formatMessage(messages.addNewAssignmentTypeBtn)}
|
||||
</Button>
|
||||
|
||||
@@ -12,9 +12,15 @@ import gradingSettings from './__mocks__/gradingSettings';
|
||||
import GradingSettings from './GradingSettings';
|
||||
import messages from './messages';
|
||||
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
import { executeThunk } from '../utils';
|
||||
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
let axiosMock;
|
||||
let store;
|
||||
const userPermissionsData = { permissions: [] };
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
@@ -28,7 +34,7 @@ describe('<GradingSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -40,6 +46,25 @@ describe('<GradingSettings />', () => {
|
||||
axiosMock
|
||||
.onGet(getGradingSettingsApiUrl(courseId))
|
||||
.reply(200, gradingSettings);
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: false });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: false });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
@@ -54,6 +79,38 @@ describe('<GradingSettings />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without errors if access controlled by permissions', async () => {
|
||||
const { getByText, getAllByText, getAllByTestId } = render(<RootWrapper />);
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: true });
|
||||
const permissionsData = { permissions: ['manage_course_settings'] };
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, permissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await waitFor(() => {
|
||||
const gradingElements = getAllByText(messages.headingTitle.defaultMessage);
|
||||
const gradingTitle = gradingElements[0];
|
||||
const segmentButton = getAllByTestId('grading-scale-btn-add-segment');
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(gradingTitle).toBeInTheDocument();
|
||||
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.policiesDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(segmentButton.length).toEqual(1);
|
||||
expect(segmentButton[0]).toBeInTheDocument();
|
||||
expect(segmentButton[0].disabled).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render permissionDenied if incorrect permissions', async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should update segment input value and show save alert', async () => {
|
||||
const { getByTestId, getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
@@ -76,6 +133,7 @@ describe('<GradingSettings />', () => {
|
||||
expect(segmentInput).toHaveValue('a');
|
||||
});
|
||||
});
|
||||
|
||||
it('should save segment input changes and display saving message', async () => {
|
||||
const { getByText, getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
@@ -88,4 +146,23 @@ describe('<GradingSettings />', () => {
|
||||
expect(getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show disabled button to update segments', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: true });
|
||||
const permissionsData = { permissions: ['view_course_settings'] };
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, permissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await waitFor(() => {
|
||||
const segmentButton = getAllByTestId('grading-scale-btn-add-segment');
|
||||
expect(segmentButton.length).toEqual(1);
|
||||
expect(segmentButton[0]).toBeInTheDocument();
|
||||
expect(segmentButton[0].disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const AssignmentItem = ({
|
||||
secondErrorMsg,
|
||||
gradeField,
|
||||
trailingElement,
|
||||
viewOnly,
|
||||
}) => (
|
||||
<li className={className}>
|
||||
<Form.Group className={classNames('form-group-custom', {
|
||||
@@ -37,6 +38,7 @@ const AssignmentItem = ({
|
||||
value={value}
|
||||
isInvalid={errorEffort}
|
||||
trailingElement={trailingElement}
|
||||
disabled={viewOnly}
|
||||
/>
|
||||
<Form.Control.Feedback className="grading-description">
|
||||
{descriptions}
|
||||
@@ -81,6 +83,7 @@ AssignmentItem.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
gradeField: PropTypes.shape(defaultAssignmentsPropTypes),
|
||||
trailingElement: PropTypes.string,
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default AssignmentItem;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ASSIGNMENT_TYPES, DUPLICATE_ASSIGNMENT_NAME } from '../utils/enum';
|
||||
import messages from '../messages';
|
||||
|
||||
const AssignmentTypeName = ({
|
||||
intl, value, errorEffort, onChange,
|
||||
intl, value, errorEffort, onChange, viewOnly,
|
||||
}) => {
|
||||
const initialAssignmentName = useRef(value);
|
||||
|
||||
@@ -28,6 +28,7 @@ const AssignmentTypeName = ({
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
isInvalid={Boolean(errorEffort)}
|
||||
disabled={viewOnly}
|
||||
/>
|
||||
<Form.Control.Feedback className="grading-description">
|
||||
{intl.formatMessage(messages.assignmentTypeNameDescription)}
|
||||
@@ -64,6 +65,7 @@ AssignmentTypeName.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
errorEffort: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AssignmentTypeName);
|
||||
|
||||
@@ -22,6 +22,7 @@ const AssignmentSection = ({
|
||||
setGradingData,
|
||||
courseAssignmentLists,
|
||||
setShowSuccessAlert,
|
||||
viewOnly,
|
||||
}) => {
|
||||
const [errorList, setErrorList] = useState({});
|
||||
const {
|
||||
@@ -83,6 +84,7 @@ const AssignmentSection = ({
|
||||
value={gradeField.type}
|
||||
errorEffort={errorList[`${type}-${gradeField.id}`]}
|
||||
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<AssignmentItem
|
||||
className="course-grading-assignment-abbreviation"
|
||||
@@ -92,6 +94,7 @@ const AssignmentSection = ({
|
||||
name="shortLabel"
|
||||
value={gradeField.shortLabel}
|
||||
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<AssignmentItem
|
||||
className="course-grading-assignment-total-grade"
|
||||
@@ -106,6 +109,7 @@ const AssignmentSection = ({
|
||||
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
|
||||
errorEffort={errorList[`${weight}-${gradeField.id}`]}
|
||||
trailingElement="%"
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<AssignmentItem
|
||||
className="course-grading-assignment-total-number"
|
||||
@@ -118,6 +122,7 @@ const AssignmentSection = ({
|
||||
value={gradeField.minCount}
|
||||
onChange={(e) => handleAssignmentChange(e, gradeField.id)}
|
||||
errorEffort={errorList[`${minCount}-${gradeField.id}`]}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
<AssignmentItem
|
||||
className="course-grading-assignment-number-droppable"
|
||||
@@ -134,6 +139,7 @@ const AssignmentSection = ({
|
||||
type: gradeField.type,
|
||||
})}
|
||||
errorEffort={errorList[`${dropCount}-${gradeField.id}`]}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</ol>
|
||||
{showDefinedCaseAlert && (
|
||||
@@ -185,6 +191,7 @@ const AssignmentSection = ({
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAssignment(gradeField.id)}
|
||||
disabled={viewOnly}
|
||||
>
|
||||
{intl.formatMessage(messages.assignmentDeleteButton)}
|
||||
</Button>
|
||||
@@ -210,6 +217,7 @@ AssignmentSection.propTypes = {
|
||||
graders: PropTypes.arrayOf(
|
||||
PropTypes.shape(defaultAssignmentsPropTypes),
|
||||
),
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AssignmentSection);
|
||||
|
||||
@@ -18,6 +18,7 @@ const RootWrapper = (props = {}) => (
|
||||
minimumGradeCredit={0.1}
|
||||
setGradingData={jest.fn()}
|
||||
setShowSuccessAlert={jest.fn()}
|
||||
viewOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
@@ -38,6 +39,14 @@ describe('<CreditSection />', () => {
|
||||
expect(inputElement.value).toBe('10');
|
||||
fireEvent.change(inputElement, { target: { value: '2' } });
|
||||
expect(testObj.minimumGradeCredit).toBe(0.02);
|
||||
expect(inputElement.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
it('should disable the fields if viewOnly', async () => {
|
||||
const { getByTestId } = render(<RootWrapper viewOnly />);
|
||||
await waitFor(() => {
|
||||
const inputElement = getByTestId('minimum-grade-credit-input');
|
||||
expect(inputElement.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const CreditSection = ({
|
||||
intl, eligibleGrade, setShowSavePrompt, minimumGradeCredit, setGradingData, setShowSuccessAlert,
|
||||
intl, eligibleGrade, setShowSavePrompt, minimumGradeCredit, setGradingData, setShowSuccessAlert, viewOnly,
|
||||
}) => {
|
||||
const [errorEffort, setErrorEffort] = useState(false);
|
||||
|
||||
@@ -46,6 +46,7 @@ const CreditSection = ({
|
||||
value={Math.round(parseFloat(minimumGradeCredit) * 100) || ''}
|
||||
name="minimum_grade_credit"
|
||||
onChange={handleCreditChange}
|
||||
disabled={viewOnly}
|
||||
/>
|
||||
<Form.Control.Feedback className="grading-description">
|
||||
{intl.formatMessage(messages.creditEligibilityDescription)}
|
||||
@@ -66,6 +67,7 @@ CreditSection.propTypes = {
|
||||
setGradingData: PropTypes.func.isRequired,
|
||||
setShowSuccessAlert: PropTypes.func.isRequired,
|
||||
minimumGradeCredit: PropTypes.number.isRequired,
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CreditSection);
|
||||
|
||||
@@ -22,6 +22,7 @@ const RootWrapper = (props = {}) => (
|
||||
setShowSavePrompt={jest.fn()}
|
||||
setGradingData={jest.fn()}
|
||||
setShowSuccessAlert={jest.fn()}
|
||||
viewOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
@@ -46,6 +47,7 @@ describe('<DeadlineSection />', () => {
|
||||
fireEvent.change(inputElement, { target: { value: '13:13' } });
|
||||
expect(testObj.gracePeriod.hours).toBe(13);
|
||||
expect(testObj.gracePeriod.minutes).toBe(13);
|
||||
expect(inputElement.disabled).toEqual(false);
|
||||
});
|
||||
});
|
||||
it('checking deadline input value if grace Period equal null', async () => {
|
||||
@@ -78,4 +80,11 @@ describe('<DeadlineSection />', () => {
|
||||
expect(getByText(`Grace period must be specified in ${TIME_FORMAT.toUpperCase()} format.`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('checking deadline input is disabled if viewOnly', async () => {
|
||||
const { getByTestId } = render(<RootWrapper viewOnly />);
|
||||
await waitFor(() => {
|
||||
const inputElement = getByTestId('deadline-period-input');
|
||||
expect(inputElement.disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formatTime, timerValidation } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
const DeadlineSection = ({
|
||||
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert,
|
||||
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert, viewOnly,
|
||||
}) => {
|
||||
const timeStampValue = gracePeriod
|
||||
? gracePeriod.hours && `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}`
|
||||
@@ -52,6 +52,7 @@ const DeadlineSection = ({
|
||||
value={newDeadlineValue}
|
||||
onChange={handleDeadlineChange}
|
||||
placeholder={TIME_FORMAT.toUpperCase()}
|
||||
disabled={viewOnly}
|
||||
/>
|
||||
<Form.Control.Feedback className="grading-description">
|
||||
{intl.formatMessage(messages.gracePeriodOnDeadlineDescription)}
|
||||
@@ -78,6 +79,7 @@ DeadlineSection.propTypes = {
|
||||
hours: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
minutes: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
}),
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DeadlineSection);
|
||||
|
||||
@@ -23,6 +23,7 @@ const GradingScale = ({
|
||||
sortedGrades,
|
||||
setOverrideInternetConnectionAlert,
|
||||
setEligibleGrade,
|
||||
viewOnly,
|
||||
}) => {
|
||||
const [gradingSegments, setGradingSegments] = useState(sortedGrades);
|
||||
const [letters, setLetters] = useState(gradeLetters);
|
||||
@@ -191,7 +192,7 @@ const GradingScale = ({
|
||||
<IconButtonWithTooltip
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.addNewSegmentButtonAltText)}
|
||||
disabled={gradingSegments.length >= 5}
|
||||
disabled={gradingSegments.length >= 5 || viewOnly}
|
||||
data-testid="grading-scale-btn-add-segment"
|
||||
className="mr-3"
|
||||
src={IconAdd}
|
||||
@@ -245,6 +246,7 @@ GradingScale.propTypes = {
|
||||
}),
|
||||
).isRequired,
|
||||
setEligibleGrade: PropTypes.func.isRequired,
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradingScale);
|
||||
|
||||
@@ -16,7 +16,7 @@ const sortedGrades = [
|
||||
{ current: 20, previous: 0 },
|
||||
];
|
||||
|
||||
const RootWrapper = () => (
|
||||
const RootWrapper = (viewOnly = { viewOnly: false }) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GradingScale
|
||||
intl={injectIntl}
|
||||
@@ -29,6 +29,7 @@ const RootWrapper = () => (
|
||||
setGradingData={jest.fn()}
|
||||
setOverrideInternetConnectionAlert={jest.fn()}
|
||||
setEligibleGrade={jest.fn()}
|
||||
{...viewOnly}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
@@ -73,6 +74,26 @@ describe('<GradingScale />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not disable new grading segment button when viewOnly=false', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const addNewSegmentBtn = getAllByTestId('grading-scale-btn-add-segment');
|
||||
expect(addNewSegmentBtn).toHaveLength(1);
|
||||
expect(addNewSegmentBtn[0]).toBeInTheDocument();
|
||||
expect(addNewSegmentBtn[0].disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable new grading segment button when viewOnly', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper viewOnly />);
|
||||
await waitFor(() => {
|
||||
const addNewSegmentBtn = getAllByTestId('grading-scale-btn-add-segment');
|
||||
expect(addNewSegmentBtn).toHaveLength(1);
|
||||
expect(addNewSegmentBtn[0]).toBeInTheDocument();
|
||||
expect(addNewSegmentBtn[0].disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove grading segment when "Remove" button is clicked', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { StudioHeader } from '@edx/frontend-component-header';
|
||||
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissions, getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
const Header = ({
|
||||
@@ -16,24 +20,73 @@ const Header = ({
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissions = useSelector(getUserPermissions);
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const hasContentPermissions = !userPermissionsEnabled || (userPermissionsEnabled && checkPermission('manage_content'));
|
||||
const hasOutlinePermissions = !userPermissionsEnabled || (userPermissionsEnabled && (checkPermission('manage_content') || checkPermission('manage_libraries')));
|
||||
const hasAdvancedSettingsAccess = !userPermissionsEnabled
|
||||
|| (userPermissionsEnabled && (checkPermission('manage_advanced_settings') || checkPermission('view_course_settings')));
|
||||
const hasSettingsPermissions = !userPermissionsEnabled
|
||||
|| (userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings')));
|
||||
const hasToolsPermissions = !userPermissionsEnabled
|
||||
|| (userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings')));
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const mainMenuDropdowns = [
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.content']),
|
||||
items: getContentMenuItems({ studioBaseUrl, courseId, intl }),
|
||||
},
|
||||
const contentMenu = getContentMenuItems({
|
||||
studioBaseUrl,
|
||||
courseId,
|
||||
intl,
|
||||
hasContentPermissions,
|
||||
hasOutlinePermissions,
|
||||
});
|
||||
const mainMenuDropdowns = [];
|
||||
const toolsMenu = getToolsMenuItems({
|
||||
studioBaseUrl,
|
||||
courseId,
|
||||
intl,
|
||||
hasToolsPermissions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserPermissionsEnabledFlag());
|
||||
if (!userPermissions) {
|
||||
dispatch(fetchUserPermissionsQuery(courseId));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
if (contentMenu.length > 0) {
|
||||
mainMenuDropdowns.push(
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.content']),
|
||||
items: contentMenu,
|
||||
},
|
||||
);
|
||||
}
|
||||
mainMenuDropdowns.push(
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.settings']),
|
||||
items: getSettingMenuItems({ studioBaseUrl, courseId, intl }),
|
||||
items: getSettingMenuItems({
|
||||
studioBaseUrl,
|
||||
courseId,
|
||||
intl,
|
||||
hasSettingsPermissions,
|
||||
hasAdvancedSettingsAccess,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.tools']),
|
||||
items: getToolsMenuItems({ studioBaseUrl, courseId, intl }),
|
||||
},
|
||||
];
|
||||
);
|
||||
if (toolsMenu.length > 0) {
|
||||
mainMenuDropdowns.push(
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.tools']),
|
||||
items: toolsMenu,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const outlineLink = `${studioBaseUrl}/course/${courseId}`;
|
||||
return (
|
||||
<StudioHeader
|
||||
|
||||
@@ -2,72 +2,127 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { getPagePath } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => {
|
||||
const items = [
|
||||
{
|
||||
href: `${studioBaseUrl}/course/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.outline']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/course_info/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.updates']),
|
||||
},
|
||||
{
|
||||
href: getPagePath(courseId, 'true', 'tabs'),
|
||||
title: intl.formatMessage(messages['header.links.pages']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/assets/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.filesAndUploads']),
|
||||
},
|
||||
];
|
||||
export const getContentMenuItems = ({
|
||||
studioBaseUrl,
|
||||
courseId,
|
||||
intl,
|
||||
hasContentPermissions,
|
||||
hasOutlinePermissions,
|
||||
}) => {
|
||||
const items = [];
|
||||
|
||||
if (hasOutlinePermissions) {
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/course/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.outline']),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (hasContentPermissions) {
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/course_info/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.updates']),
|
||||
},
|
||||
{
|
||||
href: getPagePath(courseId, 'true', 'tabs'),
|
||||
title: intl.formatMessage(messages['header.links.pages']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/assets/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.filesAndUploads']),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') {
|
||||
items.push({
|
||||
href: `${studioBaseUrl}/videos/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.videoUploads']),
|
||||
});
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/videos/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.videoUploads']),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/details/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/grading/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.grading']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/course_team/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.courseTeam']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/group_configurations/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.groupConfigurations']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.advancedSettings']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/certificates/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.certificates']),
|
||||
},
|
||||
]);
|
||||
export const getSettingMenuItems = ({
|
||||
studioBaseUrl,
|
||||
courseId,
|
||||
intl,
|
||||
hasAdvancedSettingsAccess,
|
||||
hasSettingsPermissions,
|
||||
}) => {
|
||||
const items = [];
|
||||
|
||||
export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
{
|
||||
href: `${studioBaseUrl}/import/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.import']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/export/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.export']),
|
||||
}, {
|
||||
href: `${studioBaseUrl}/checklists/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.checklists']),
|
||||
},
|
||||
]);
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/details/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
|
||||
},
|
||||
);
|
||||
if (hasSettingsPermissions) {
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/grading/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.grading']),
|
||||
},
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/course_team/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.courseTeam']),
|
||||
},
|
||||
);
|
||||
if (hasSettingsPermissions) {
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/group_configurations/course-v1:${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.groupConfigurations']),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (hasAdvancedSettingsAccess) {
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.advancedSettings']),
|
||||
},
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/certificates/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.certificates']),
|
||||
},
|
||||
);
|
||||
return items;
|
||||
};
|
||||
|
||||
export const getToolsMenuItems = ({
|
||||
studioBaseUrl,
|
||||
courseId,
|
||||
intl,
|
||||
hasToolsPermissions,
|
||||
}) => {
|
||||
const items = [];
|
||||
if (hasToolsPermissions) {
|
||||
items.push(
|
||||
{
|
||||
href: `${studioBaseUrl}/import/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.import']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/export/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.export']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/checklists/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.checklists']),
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { getContentMenuItems } from './utils';
|
||||
import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils';
|
||||
|
||||
const props = {
|
||||
const baseProps = {
|
||||
studioBaseUrl: 'UrLSTuiO',
|
||||
courseId: '123',
|
||||
intl: {
|
||||
formatMessage: jest.fn(),
|
||||
},
|
||||
};
|
||||
const contentProps = { ...baseProps, hasContentPermissions: true, hasOutlinePermissions: true };
|
||||
const settingProps = { ...baseProps, hasAdvancedSettingsAccess: true, hasSettingsPermissions: true };
|
||||
const toolsProps = { ...baseProps, hasToolsPermissions: true };
|
||||
|
||||
describe('header utils', () => {
|
||||
describe('getContentMenuItems', () => {
|
||||
@@ -16,7 +19,7 @@ describe('header utils', () => {
|
||||
...getConfig(),
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
|
||||
});
|
||||
const actualItems = getContentMenuItems(props);
|
||||
const actualItems = getContentMenuItems(contentProps);
|
||||
expect(actualItems).toHaveLength(5);
|
||||
});
|
||||
it('should not include Video Uploads option', () => {
|
||||
@@ -24,8 +27,84 @@ describe('header utils', () => {
|
||||
...getConfig(),
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
|
||||
});
|
||||
const actualItems = getContentMenuItems(props);
|
||||
const actualItems = getContentMenuItems(contentProps);
|
||||
expect(actualItems).toHaveLength(4);
|
||||
});
|
||||
it('should include only Video Uploads option', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
|
||||
});
|
||||
const actualItems = getContentMenuItems({
|
||||
...baseProps,
|
||||
hasContentPermissions: false,
|
||||
hasOutlinePermissions: false,
|
||||
});
|
||||
expect(actualItems).toHaveLength(1);
|
||||
});
|
||||
it('should include Outline if outline permissions', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
|
||||
});
|
||||
const actualItems = getContentMenuItems({
|
||||
...baseProps,
|
||||
hasContentPermissions: false,
|
||||
hasOutlinePermissions: true,
|
||||
});
|
||||
expect(actualItems).toHaveLength(1);
|
||||
});
|
||||
it('should include content sections if content permissions', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
|
||||
});
|
||||
const actualItems = getContentMenuItems({
|
||||
...baseProps,
|
||||
hasContentPermissions: true,
|
||||
hasOutlinePermissions: false,
|
||||
});
|
||||
expect(actualItems).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
describe('getSettingMenuItems', async () => {
|
||||
it('should include all options', () => {
|
||||
const actualItems = getSettingMenuItems(settingProps);
|
||||
expect(actualItems).toHaveLength(6);
|
||||
});
|
||||
it('should include Advanced Settings option, but not settings options', () => {
|
||||
const actualItems = getSettingMenuItems({
|
||||
...baseProps,
|
||||
hasSettingsPermissions: false,
|
||||
hasAdvancedSettingsAccess: true,
|
||||
});
|
||||
expect(actualItems).toHaveLength(4);
|
||||
});
|
||||
it('should include settings, but not advanced settings', () => {
|
||||
const actualItems = getSettingMenuItems({
|
||||
...baseProps,
|
||||
hasSettingsPermissions: true,
|
||||
hasAdvancedSettingsAccess: false,
|
||||
});
|
||||
expect(actualItems).toHaveLength(5);
|
||||
});
|
||||
it('should only include default options', () => {
|
||||
const actualItems = getSettingMenuItems({
|
||||
...baseProps,
|
||||
hasSettingsPermissions: false,
|
||||
hasAdvancedSettingsAccess: false,
|
||||
});
|
||||
expect(actualItems).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
describe('getToolsMenuItems', async () => {
|
||||
it('should include all options', () => {
|
||||
const actualItems = getToolsMenuItems(toolsProps);
|
||||
expect(actualItems).toHaveLength(3);
|
||||
});
|
||||
it('should not include any items if there are no permissions', () => {
|
||||
const actualItems = getToolsMenuItems({ ...baseProps, hasToolsPermissions: false });
|
||||
expect(actualItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ import { LAST_IMPORT_COOKIE_NAME } from './data/constants';
|
||||
import ImportSidebar from './import-sidebar/ImportSidebar';
|
||||
import FileSection from './file-section/FileSection';
|
||||
import messages from './messages';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const CourseImportPage = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -32,6 +35,14 @@ const CourseImportPage = ({ intl, courseId }) => {
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
|
||||
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const hasImportPermissions = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))
|
||||
);
|
||||
const viewOnly = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && checkPermission('view_course_settings') && !checkPermission('manage_course_settings')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cookieData = cookies.get(LAST_IMPORT_COOKIE_NAME);
|
||||
@@ -43,6 +54,12 @@ const CourseImportPage = ({ intl, courseId }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!hasImportPermissions) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -72,7 +89,7 @@ const CourseImportPage = ({ intl, courseId }) => {
|
||||
<p className="small">{intl.formatMessage(messages.description1)}</p>
|
||||
<p className="small">{intl.formatMessage(messages.description2)}</p>
|
||||
<p className="small">{intl.formatMessage(messages.description3)}</p>
|
||||
<FileSection courseId={courseId} />
|
||||
<FileSection courseId={courseId} viewOnly={viewOnly} />
|
||||
{importTriggered && <ImportStepper courseId={courseId} />}
|
||||
</article>
|
||||
</Layout.Element>
|
||||
|
||||
@@ -14,12 +14,17 @@ import CourseImportPage from './CourseImportPage';
|
||||
import { getImportStatusApiUrl } from './data/api';
|
||||
import { IMPORT_STAGES } from './data/constants';
|
||||
import stepperMessages from './import-stepper/messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
import { executeThunk } from '../utils';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
let cookies;
|
||||
const courseId = '123';
|
||||
const courseName = 'About Node JS';
|
||||
const userId = 3;
|
||||
let userPermissionsData = { permissions: [] };
|
||||
|
||||
jest.mock('../generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({
|
||||
@@ -47,7 +52,7 @@ describe('<CourseImportPage />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -58,6 +63,12 @@ describe('<CourseImportPage />', () => {
|
||||
axiosMock
|
||||
.onGet(getImportStatusApiUrl(courseId, 'testFileName.test'))
|
||||
.reply(200, { importStatus: 1, message: '' });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: false });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
cookies = new Cookies();
|
||||
cookies.get.mockReturnValue(null);
|
||||
});
|
||||
@@ -83,6 +94,30 @@ describe('<CourseImportPage />', () => {
|
||||
expect(getByText(messages.description3.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render permissionDenied if incorrect permissions', async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
it('should render without errors if correct permissions', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
userPermissionsData = { permissions: ['manage_course_settings'] };
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData);
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const importPageElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(importPageElement).toBeInTheDocument();
|
||||
expect(getByText(messages.description1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description3.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should fetch status without clicking when cookies has', async () => {
|
||||
cookies.get.mockReturnValue({ date: 1679787000, completed: false, fileName: 'testFileName.test' });
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Card, Dropzone } from '@edx/paragon';
|
||||
|
||||
import { Alert, Card, Dropzone } from '@edx/paragon';
|
||||
import { IMPORT_STAGES } from '../data/constants';
|
||||
import {
|
||||
getCurrentStage, getError, getFileName, getImportTriggered,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import messages from './messages';
|
||||
import { handleProcessUpload } from '../data/thunks';
|
||||
|
||||
const FileSection = ({ intl, courseId }) => {
|
||||
const FileSection = ({ intl, courseId, viewOnly }) => {
|
||||
const dispatch = useDispatch();
|
||||
const importTriggered = useSelector(getImportTriggered);
|
||||
const currentStage = useSelector(getCurrentStage);
|
||||
@@ -30,21 +30,25 @@ const FileSection = ({ intl, courseId }) => {
|
||||
subtitle={fileName && intl.formatMessage(messages.fileChosen, { fileName })}
|
||||
/>
|
||||
<Card.Section className="px-3 pt-2 pb-4">
|
||||
{isShowedDropzone
|
||||
&& (
|
||||
<Dropzone
|
||||
onProcessUpload={
|
||||
({ fileData, requestConfig, handleError }) => dispatch(handleProcessUpload(
|
||||
courseId,
|
||||
fileData,
|
||||
requestConfig,
|
||||
handleError,
|
||||
))
|
||||
}
|
||||
accept={{ 'application/gzip': ['.tar.gz'] }}
|
||||
data-testid="dropzone"
|
||||
/>
|
||||
)}
|
||||
{!viewOnly && isShowedDropzone && (
|
||||
<Dropzone
|
||||
onProcessUpload={
|
||||
({ fileData, requestConfig, handleError }) => dispatch(handleProcessUpload(
|
||||
courseId,
|
||||
fileData,
|
||||
requestConfig,
|
||||
handleError,
|
||||
))
|
||||
}
|
||||
accept={{ 'application/gzip': ['.tar.gz'] }}
|
||||
data-testid="dropzone"
|
||||
/>
|
||||
)}
|
||||
{viewOnly && (
|
||||
<Alert variant="info">
|
||||
<FormattedMessage {...messages.viewOnlyAlert} />
|
||||
</Alert>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
@@ -53,6 +57,7 @@ const FileSection = ({ intl, courseId }) => {
|
||||
FileSection.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
viewOnly: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FileSection);
|
||||
|
||||
@@ -14,7 +14,15 @@ const courseId = '123';
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<FileSection intl={injectIntl} courseId={courseId} />
|
||||
<FileSection intl={injectIntl} courseId={courseId} viewOnly={false} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const RootWrapperViewOnly = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<FileSection intl={injectIntl} courseId={courseId} viewOnly />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -27,6 +35,7 @@ describe('<FileSection />', () => {
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
permisions: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
@@ -43,6 +52,13 @@ describe('<FileSection />', () => {
|
||||
expect(getByTestId('dropzone')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should not render Dropzone when view is viewOnly', async () => {
|
||||
const { getByText, queryByTestId, container } = render(<RootWrapperViewOnly />);
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId(container, 'dropzone')).not.toBeInTheDocument();
|
||||
expect(getByText(messages.viewOnlyAlert.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should work Dropzone', async () => {
|
||||
const {
|
||||
getByText, getByTestId, queryByTestId, container,
|
||||
|
||||
@@ -9,6 +9,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.import.file-section.chosen-file',
|
||||
defaultMessage: 'File chosen: {fileName}',
|
||||
},
|
||||
viewOnlyAlert: {
|
||||
id: 'course-authoring.import.file-section.view-only-alert',
|
||||
defaultMessage: 'You have view only access to this page. If you feel you should have full access, please reach out to your course team admin to be given access.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { RequestStatus } from '../data/constants';
|
||||
import SettingsComponent from './SettingsComponent';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
|
||||
const PagesAndResources = ({ courseId, intl }) => {
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
@@ -73,12 +74,14 @@ const PagesAndResources = ({ courseId, intl }) => {
|
||||
contentPermissionsPages.push(page);
|
||||
}
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
|
||||
if (loadingStatus === RequestStatus.IN_PROGRESS) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED || !checkPermission('manage_content')) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
|
||||
@@ -8,6 +8,12 @@ import * as xpertUnitSummaryApi from './xpert-unit-summary/data/api';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
|
||||
jest.mock('../generic/hooks', () => ({
|
||||
useUserPermissions: jest.fn(() => ({
|
||||
checkPermission: jest.fn(() => true),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('PagesAndResources', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -21,10 +21,13 @@ import scheduleMessages from './schedule-section/messages';
|
||||
import genericMessages from '../generic/help-sidebar/messages';
|
||||
import messages from './messages';
|
||||
import ScheduleAndDetails from '.';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
|
||||
// Mock the tinymce lib
|
||||
jest.mock('@tinymce/tinymce-react', () => {
|
||||
@@ -50,6 +53,18 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea {...props} onFocus={() => {}} onBlur={() => {}} />
|
||||
)));
|
||||
|
||||
const permissionsMockStore = async (permissions) => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const permissionDisabledMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
@@ -62,7 +77,7 @@ describe('<ScheduleAndDetails />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -111,6 +126,30 @@ describe('<ScheduleAndDetails />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
|
||||
const permissionsData = { permissions: ['wrong_permission'] };
|
||||
await permissionsMockStore(permissionsData);
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
|
||||
await permissionDisabledMockStore();
|
||||
|
||||
const { queryByText, getAllByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
const scheduleAndDetailElements = getAllByText(messages.headingTitle.defaultMessage);
|
||||
const scheduleAndDetailTitle = scheduleAndDetailElements[0];
|
||||
expect(permissionDeniedAlert).not.toBeInTheDocument();
|
||||
expect(scheduleAndDetailTitle).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide credit section with condition', async () => {
|
||||
const updatedResponse = {
|
||||
...courseSettingsMock,
|
||||
|
||||
@@ -19,6 +19,7 @@ module.exports = {
|
||||
enrollmentEndEditable: true,
|
||||
isCreditCourse: true,
|
||||
isEntranceExamsEnabled: true,
|
||||
isEditable: true,
|
||||
isPrerequisiteCoursesEnabled: true,
|
||||
languageOptions: [
|
||||
['en', 'English'],
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('<DetailsSection />', () => {
|
||||
language: courseSettingsMock.languageOptions[1][0],
|
||||
languageOptions: courseSettingsMock.languageOptions,
|
||||
onChange: onChangeMock,
|
||||
isEditable: courseSettingsMock.isEditable,
|
||||
};
|
||||
|
||||
it('renders details section successfully', () => {
|
||||
@@ -57,4 +58,10 @@ describe('<DetailsSection />', () => {
|
||||
getByRole('button', { name: messages.dropdownEmpty.defaultMessage }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable the dropdown if isEditable is false', () => {
|
||||
const initialProps = { ...props, isEditable: false };
|
||||
const { getByRole } = render(<RootWrapper {...initialProps} />);
|
||||
expect(getByRole('button').disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import SectionSubHeader from '../../generic/section-sub-header';
|
||||
import messages from './messages';
|
||||
|
||||
const DetailsSection = ({
|
||||
language, languageOptions, onChange,
|
||||
language, languageOptions, onChange, isEditable,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const formattedLanguage = () => {
|
||||
@@ -24,7 +24,7 @@ const DetailsSection = ({
|
||||
<Form.Group className="form-group-custom dropdown-language">
|
||||
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
|
||||
<Dropdown className="bg-white">
|
||||
<Dropdown.Toggle variant="outline-primary" id="languageDropdown">
|
||||
<Dropdown.Toggle variant="outline-primary" id="languageDropdown" disabled={!isEditable}>
|
||||
{formattedLanguage()}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
@@ -56,6 +56,7 @@ DetailsSection.propTypes = {
|
||||
PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
|
||||
).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default DetailsSection;
|
||||
|
||||
@@ -43,6 +43,9 @@ import LicenseSection from './license-section';
|
||||
import ScheduleSidebar from './schedule-sidebar';
|
||||
import messages from './messages';
|
||||
import { useLoadValuesPrompt, useSaveValuesPrompt } from './hooks';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const ScheduleAndDetails = ({ intl, courseId }) => {
|
||||
const courseSettings = useSelector(getCourseSettings);
|
||||
@@ -53,6 +56,12 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
|
||||
|| loadingSettingsStatus === RequestStatus.IN_PROGRESS;
|
||||
|
||||
const course = useModel('courseDetails', courseId);
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && (
|
||||
!checkPermission('manage_course_settings') && !checkPermission('view_course_settings')
|
||||
);
|
||||
const canEdit = (!userPermissionsEnabled) ? true : checkPermission('manage_course_settings');
|
||||
document.title = getPageHeadTitle(course?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const {
|
||||
@@ -145,6 +154,12 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
@@ -266,12 +281,14 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
|
||||
certificatesDisplayBehavior={certificatesDisplayBehavior}
|
||||
canShowCertificateAvailableDateField={canShowCertificateAvailableDateField}
|
||||
onChange={handleValuesChange}
|
||||
isEditable={canEdit}
|
||||
/>
|
||||
{aboutPageEditable && (
|
||||
<DetailsSection
|
||||
language={language}
|
||||
languageOptions={languageOptions}
|
||||
onChange={handleValuesChange}
|
||||
isEditable={canEdit}
|
||||
/>
|
||||
)}
|
||||
<IntroducingSection
|
||||
@@ -292,6 +309,7 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
|
||||
enableExtendedCourseDetails={enableExtendedCourseDetails}
|
||||
videoThumbnailImageAssetPath={videoThumbnailImageAssetPath}
|
||||
onChange={handleValuesChange}
|
||||
isEditable={canEdit}
|
||||
/>
|
||||
{enableExtendedCourseDetails && (
|
||||
<>
|
||||
@@ -319,12 +337,14 @@ const ScheduleAndDetails = ({ intl, courseId }) => {
|
||||
isPrerequisiteCoursesEnabled
|
||||
}
|
||||
onChange={handleValuesChange}
|
||||
isEditable={canEdit}
|
||||
/>
|
||||
)}
|
||||
{licensingEnabled && (
|
||||
<LicenseSection
|
||||
license={license}
|
||||
onChange={handleValuesChange}
|
||||
isEditable={canEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ const {
|
||||
} = courseDetailsMock;
|
||||
|
||||
const {
|
||||
aboutPageEditable, sidebarHtmlEnabled, shortDescriptionEditable, lmsLinkForAboutPage,
|
||||
aboutPageEditable, sidebarHtmlEnabled, shortDescriptionEditable, lmsLinkForAboutPage, isEditable,
|
||||
} = courseSettingsMock;
|
||||
|
||||
const props = {
|
||||
@@ -63,6 +63,7 @@ const props = {
|
||||
courseImageAssetPath,
|
||||
shortDescriptionEditable,
|
||||
onChange: onChangeMock,
|
||||
isEditable,
|
||||
};
|
||||
|
||||
describe('<IntroducingSection />', () => {
|
||||
@@ -98,4 +99,13 @@ describe('<IntroducingSection />', () => {
|
||||
expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0);
|
||||
expect(queryAllByText(messages.courseAboutSidebarLabel.defaultMessage).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should hide components if isEditable is false', () => {
|
||||
const initialProps = { ...props, isEditable: false };
|
||||
const { queryAllByText } = render(<RootWrapper {...initialProps} />);
|
||||
expect(queryAllByText(messages.introducingTitle.defaultMessage).length).toBe(0);
|
||||
expect(queryAllByText(messages.introducingDescription.defaultMessage).length).toBe(0);
|
||||
expect(queryAllByText(messages.courseOverviewLabel.defaultMessage).length).toBe(0);
|
||||
expect(queryAllByText(messages.courseAboutSidebarLabel.defaultMessage).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const IntroducingSection = ({
|
||||
enableExtendedCourseDetails,
|
||||
videoThumbnailImageAssetPath,
|
||||
onChange,
|
||||
isEditable,
|
||||
}) => {
|
||||
const overviewHelpText = (
|
||||
<FormattedMessage
|
||||
@@ -72,7 +73,7 @@ const IntroducingSection = ({
|
||||
|
||||
return (
|
||||
<section className="section-container introducing-section">
|
||||
{aboutPageEditable && (
|
||||
{aboutPageEditable && isEditable && (
|
||||
<SectionSubHeader
|
||||
title={intl.formatMessage(messages.introducingTitle)}
|
||||
description={intl.formatMessage(messages.introducingDescription)}
|
||||
@@ -87,7 +88,7 @@ const IntroducingSection = ({
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
{shortDescriptionEditable && (
|
||||
{shortDescriptionEditable && isEditable && (
|
||||
<Form.Group className="form-group-custom">
|
||||
<Form.Label>
|
||||
{intl.formatMessage(messages.courseShortDescriptionLabel)}
|
||||
@@ -107,7 +108,7 @@ const IntroducingSection = ({
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
)}
|
||||
{aboutPageEditable && (
|
||||
{aboutPageEditable && isEditable && (
|
||||
<>
|
||||
<Form.Group className="form-group-custom">
|
||||
<Form.Label>{intl.formatMessage(messages.courseOverviewLabel)}</Form.Label>
|
||||
@@ -160,7 +161,7 @@ const IntroducingSection = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{aboutPageEditable && (
|
||||
{aboutPageEditable && isEditable && (
|
||||
<IntroductionVideo introVideo={introVideo} onChange={onChange} />
|
||||
)}
|
||||
</section>
|
||||
@@ -200,6 +201,7 @@ IntroducingSection.propTypes = {
|
||||
enableExtendedCourseDetails: PropTypes.bool.isRequired,
|
||||
videoThumbnailImageAssetPath: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(IntroducingSection);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { courseDetailsMock } from '../__mocks__';
|
||||
import { courseDetailsMock, courseSettingsMock } from '../__mocks__';
|
||||
import messages from './messages';
|
||||
import LicenseSection from '.';
|
||||
|
||||
@@ -17,6 +17,7 @@ const RootWrapper = (props) => (
|
||||
const props = {
|
||||
license: courseDetailsMock.license,
|
||||
onChange: onChangeMock,
|
||||
isEditable: courseSettingsMock.isEditable,
|
||||
};
|
||||
|
||||
describe('<LicenseSection />', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LICENSE_TYPE } from './constants';
|
||||
import messages from './messages';
|
||||
import { useLicenseDetails } from './hooks';
|
||||
|
||||
const LicenseSection = ({ license, onChange }) => {
|
||||
const LicenseSection = ({ license, onChange, isEditable }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
licenseURL,
|
||||
@@ -29,6 +29,7 @@ const LicenseSection = ({ license, onChange }) => {
|
||||
<LicenseSelector
|
||||
licenseType={licenseType}
|
||||
onChangeLicenseType={handleChangeLicenseType}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
{licenseType === LICENSE_TYPE.creativeCommons && (
|
||||
<LicenseCommonsOptions
|
||||
@@ -52,6 +53,7 @@ LicenseSection.defaultProps = {
|
||||
LicenseSection.propTypes = {
|
||||
license: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default LicenseSection;
|
||||
|
||||
@@ -18,6 +18,7 @@ const RootWrapper = (props) => (
|
||||
const props = {
|
||||
licenseType: LICENSE_TYPE.allRightsReserved,
|
||||
onChangeLicenseType: onChangeLicenseTypeMock,
|
||||
isEditable: true,
|
||||
};
|
||||
|
||||
describe('<LicenseSelector />', () => {
|
||||
@@ -60,4 +61,13 @@ describe('<LicenseSelector />', () => {
|
||||
expect(buttonFirst).toHaveClass('btn btn-outline-primary');
|
||||
expect(buttonSecond).toHaveClass('btn btn-outline-primary');
|
||||
});
|
||||
|
||||
it('should show disabled buttons if isEditable is false', () => {
|
||||
const initialProps = { ...props, isEditable: false };
|
||||
const { getByRole } = render(<RootWrapper {...initialProps} />);
|
||||
const buttonFirst = getByRole('button', { name: messages.licenseChoice1.defaultMessage });
|
||||
const buttonSecond = getByRole('button', { name: messages.licenseChoice2.defaultMessage });
|
||||
expect(buttonFirst.disabled).toEqual(true);
|
||||
expect(buttonSecond.disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { LICENSE_TYPE } from '../constants';
|
||||
import messages from './messages';
|
||||
|
||||
const LicenseSelector = ({ licenseType, onChangeLicenseType }) => {
|
||||
const LicenseSelector = ({ licenseType, onChangeLicenseType, isEditable }) => {
|
||||
const LICENSE_BUTTON_GROUP_LABELS = {
|
||||
[LICENSE_TYPE.allRightsReserved]: {
|
||||
label: <FormattedMessage {...messages.licenseChoice1} />,
|
||||
@@ -37,6 +37,7 @@ const LicenseSelector = ({ licenseType, onChangeLicenseType }) => {
|
||||
<Button
|
||||
variant={isActive ? 'primary' : 'outline-primary'}
|
||||
onClick={() => onChangeLicenseType(type, 'license')}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
{LICENSE_BUTTON_GROUP_LABELS[type].label}
|
||||
</Button>
|
||||
@@ -64,6 +65,7 @@ LicenseSelector.defaultProps = {
|
||||
LicenseSelector.propTypes = {
|
||||
licenseType: PropTypes.oneOf(Object.values(LICENSE_TYPE)),
|
||||
onChangeLicenseType: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default LicenseSelector;
|
||||
|
||||
@@ -36,6 +36,7 @@ const {
|
||||
isEntranceExamsEnabled,
|
||||
possiblePreRequisiteCourses,
|
||||
isPrerequisiteCoursesEnabled,
|
||||
isEditable,
|
||||
} = courseSettingsMock;
|
||||
|
||||
const props = {
|
||||
@@ -49,6 +50,7 @@ const props = {
|
||||
entranceExamMinimumScorePct,
|
||||
isPrerequisiteCoursesEnabled,
|
||||
onChange: onChangeMock,
|
||||
isEditable,
|
||||
};
|
||||
|
||||
describe('<RequirementsSection />', () => {
|
||||
@@ -90,4 +92,10 @@ describe('<RequirementsSection />', () => {
|
||||
expect(queryAllByLabelText(messages.dropdownLabel.defaultMessage).length).toBe(0);
|
||||
expect(queryAllByLabelText(entranceExamMessages.requirementsEntrance.defaultMessage).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should disable the dropdown if isEditable is false', () => {
|
||||
const initialProps = { ...props, isEditable: false };
|
||||
const { queryByTestId } = render(<RootWrapper {...initialProps} />);
|
||||
expect(queryByTestId('prerequisite_dropdown').disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { courseDetailsMock } from '../../__mocks__';
|
||||
import { courseDetailsMock, courseSettingsMock } from '../../__mocks__';
|
||||
import gradeRequirementsMessages from '../grade-requirements/messages';
|
||||
import messages from './messages';
|
||||
import EntranceExam from '.';
|
||||
@@ -30,6 +30,7 @@ const props = {
|
||||
isCheckedString: courseDetailsMock.entranceExamEnabled,
|
||||
entranceExamMinimumScorePct: courseDetailsMock.entranceExamMinimumScorePct,
|
||||
onChange: onChangeMock,
|
||||
isEditable: courseSettingsMock.isEditable,
|
||||
};
|
||||
|
||||
describe('<EntranceExam />', () => {
|
||||
@@ -58,4 +59,11 @@ describe('<EntranceExam />', () => {
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable the checkbox if isEditable is false', () => {
|
||||
const initialProps = { ...props, isEditable: false };
|
||||
const { getAllByRole } = render(<RootWrapper {...initialProps} />);
|
||||
const checkbox = getAllByRole('checkbox')[0];
|
||||
expect(checkbox.disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ const EntranceExam = ({
|
||||
isCheckedString,
|
||||
entranceExamMinimumScorePct,
|
||||
onChange,
|
||||
isEditable,
|
||||
}) => {
|
||||
const { courseId } = useParams();
|
||||
const showEntranceExam = isCheckedString === 'true';
|
||||
@@ -33,6 +34,7 @@ const EntranceExam = ({
|
||||
<Form.Checkbox
|
||||
checked={showEntranceExam}
|
||||
onChange={toggleEntranceExam}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
<FormattedMessage {...messages.requirementsEntranceCollapseTitle} />
|
||||
</Form.Checkbox>
|
||||
@@ -63,6 +65,7 @@ const EntranceExam = ({
|
||||
errorEffort={errorEffort}
|
||||
entranceExamMinimumScorePct={entranceExamMinimumScorePct}
|
||||
onChange={onChange}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</Card.Body>
|
||||
</>
|
||||
@@ -83,6 +86,7 @@ EntranceExam.propTypes = {
|
||||
isCheckedString: PropTypes.string,
|
||||
entranceExamMinimumScorePct: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default EntranceExam;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { courseDetailsMock } from '../../__mocks__';
|
||||
import { courseDetailsMock, courseSettingsMock } from '../../__mocks__';
|
||||
import scheduleMessage from '../../messages';
|
||||
import messages from './messages';
|
||||
import GradeRequirements from '.';
|
||||
@@ -21,6 +21,7 @@ const props = {
|
||||
errorEffort: '',
|
||||
entranceExamMinimumScorePct: courseDetailsMock.entranceExamMinimumScorePct,
|
||||
onChange: onChangeMock,
|
||||
isEditable: courseSettingsMock.isEditable,
|
||||
};
|
||||
|
||||
describe('<GradeRequirements />', () => {
|
||||
@@ -31,6 +32,13 @@ describe('<GradeRequirements />', () => {
|
||||
expect(getByDisplayValue(props.entranceExamMinimumScorePct)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disable the input if isEditable is false', () => {
|
||||
const initialProps = { ...props, isEditable: false };
|
||||
const { getByDisplayValue } = render(<RootWrapper {...initialProps} />);
|
||||
const input = getByDisplayValue(props.entranceExamMinimumScorePct);
|
||||
expect(input.disabled).toEqual(true);
|
||||
});
|
||||
|
||||
it('should call onChange on input change', () => {
|
||||
const { getByDisplayValue } = render(<RootWrapper {...props} />);
|
||||
const input = getByDisplayValue(props.entranceExamMinimumScorePct);
|
||||
|
||||
@@ -10,6 +10,7 @@ const GradeRequirements = ({
|
||||
errorEffort,
|
||||
entranceExamMinimumScorePct,
|
||||
onChange,
|
||||
isEditable,
|
||||
}) => (
|
||||
<Form.Group
|
||||
className={classNames('form-group-custom', {
|
||||
@@ -27,6 +28,7 @@ const GradeRequirements = ({
|
||||
value={entranceExamMinimumScorePct}
|
||||
onChange={(e) => onChange(e.target.value, 'entranceExamMinimumScorePct')}
|
||||
trailingElement="%"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</Stack>
|
||||
{errorEffort && (
|
||||
@@ -49,6 +51,7 @@ GradeRequirements.propTypes = {
|
||||
errorEffort: PropTypes.string,
|
||||
entranceExamMinimumScorePct: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default GradeRequirements;
|
||||
|
||||
@@ -19,6 +19,7 @@ const RequirementsSection = ({
|
||||
entranceExamMinimumScorePct,
|
||||
isPrerequisiteCoursesEnabled,
|
||||
onChange,
|
||||
isEditable,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const selectedItem = possiblePreRequisiteCourses?.find(
|
||||
@@ -33,7 +34,7 @@ const RequirementsSection = ({
|
||||
>
|
||||
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
|
||||
<Dropdown className="bg-white">
|
||||
<Dropdown.Toggle id="prerequisiteDropdown" variant="outline-primary">
|
||||
<Dropdown.Toggle id="prerequisiteDropdown" variant="outline-primary" disabled={!isEditable} data-testid="prerequisite_dropdown">
|
||||
{formattedSelectedItem}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
@@ -74,6 +75,7 @@ const RequirementsSection = ({
|
||||
value={effort || ''}
|
||||
placeholder={TIME_FORMAT.toUpperCase()}
|
||||
onChange={(e) => onChange(e.target.value, 'effort')}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<Form.Control.Feedback>
|
||||
{intl.formatMessage(messages.timepickerHelpText)}
|
||||
@@ -87,6 +89,7 @@ const RequirementsSection = ({
|
||||
isCheckedString={entranceExamEnabled}
|
||||
entranceExamMinimumScorePct={entranceExamMinimumScorePct}
|
||||
onChange={onChange}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -125,6 +128,7 @@ RequirementsSection.propTypes = {
|
||||
entranceExamMinimumScorePct: PropTypes.string,
|
||||
isPrerequisiteCoursesEnabled: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default RequirementsSection;
|
||||
|
||||
@@ -125,7 +125,7 @@ const CertificateDisplayRow = ({
|
||||
<Form.Label>
|
||||
{intl.formatMessage(messages.certificateBehaviorLabel)}
|
||||
</Form.Label>
|
||||
<Dropdown claswsName="bg-white">
|
||||
<Dropdown className="bg-white">
|
||||
<Dropdown.Toggle id="certificate-behavior-dropdown" variant="outline-primary">
|
||||
{certificateDisplayValue}
|
||||
</Dropdown.Toggle>
|
||||
|
||||
@@ -20,6 +20,7 @@ const ScheduleSection = ({
|
||||
certificatesDisplayBehavior,
|
||||
canShowCertificateAvailableDateField,
|
||||
onChange,
|
||||
isEditable,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const enrollmentEndHelpText = intl.formatMessage(
|
||||
@@ -42,6 +43,7 @@ const ScheduleSection = ({
|
||||
],
|
||||
rowType: SCHEDULE_ROW_TYPES.datetime,
|
||||
helpText: intl.formatMessage(messages.scheduleCourseStartDateHelpText),
|
||||
readonly: !isEditable,
|
||||
controlName: 'startDate',
|
||||
errorFeedback: errorFields?.startDate,
|
||||
},
|
||||
@@ -53,6 +55,7 @@ const ScheduleSection = ({
|
||||
value: endDate,
|
||||
rowType: SCHEDULE_ROW_TYPES.datetime,
|
||||
helpText: intl.formatMessage(messages.scheduleCourseEndDateHelpText),
|
||||
readonly: !isEditable,
|
||||
controlName: 'endDate',
|
||||
errorFeedback: errorFields?.endDate,
|
||||
},
|
||||
@@ -73,6 +76,7 @@ const ScheduleSection = ({
|
||||
value: enrollmentStart,
|
||||
rowType: SCHEDULE_ROW_TYPES.datetime,
|
||||
helpText: intl.formatMessage(messages.scheduleEnrollmentStartDateHelpText),
|
||||
readonly: !isEditable,
|
||||
controlName: 'enrollmentStart',
|
||||
errorFeedback: errorFields?.enrollmentStart,
|
||||
},
|
||||
@@ -84,7 +88,7 @@ const ScheduleSection = ({
|
||||
value: enrollmentEnd,
|
||||
rowType: SCHEDULE_ROW_TYPES.datetime,
|
||||
helpText: computedEnrollmentEndHelpText,
|
||||
readonly: !enrollmentEndEditable,
|
||||
readonly: !enrollmentEndEditable || !isEditable,
|
||||
controlName: 'enrollmentEnd',
|
||||
errorFeedback: errorFields?.enrollmentEnd,
|
||||
},
|
||||
@@ -165,6 +169,7 @@ ScheduleSection.propTypes = {
|
||||
certificatesDisplayBehavior: PropTypes.string.isRequired,
|
||||
canShowCertificateAvailableDateField: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default ScheduleSection;
|
||||
|
||||
@@ -159,6 +159,7 @@ const StudioHome = ({ intl }) => {
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle, { studioShortName: studioShortName || 'Studio' })}
|
||||
headerActions={headerButtons}
|
||||
key={studioShortName}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('studio-home api calls', () => {
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
fit('should get studio courses data', async () => {
|
||||
it('should get studio courses data', async () => {
|
||||
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
|
||||
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
|
||||
const result = await getStudioHomeCourses('');
|
||||
|
||||
Reference in New Issue
Block a user