Compare commits
26 Commits
test_hyper
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6841e94ab0 | ||
|
|
4f04698a60 | ||
|
|
82a76b0cad | ||
|
|
62445a18f3 | ||
|
|
91ee5004a4 | ||
|
|
e0ec87c969 | ||
|
|
4835f72f2c | ||
|
|
3ab329d373 | ||
|
|
7c97ffecb5 | ||
|
|
90727590dd | ||
|
|
1c82a67364 | ||
|
|
d08ef83659 | ||
|
|
13bce7e034 | ||
|
|
54888d03bc | ||
|
|
e6d9f3a50d | ||
|
|
74b455287e | ||
|
|
e2adb45493 | ||
|
|
d4e9a6bec2 | ||
|
|
e6741496dc | ||
|
|
9304a83bef | ||
|
|
3173f41e63 | ||
|
|
866dd9bd31 | ||
|
|
f10ad9f525 | ||
|
|
81d78b9613 | ||
|
|
4886df7d6f | ||
|
|
62dfb75169 |
4
.env
4
.env
@@ -44,6 +44,4 @@ INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
# TODO: Missing support for ORA2
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
|
||||
|
||||
@@ -47,5 +47,4 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
|
||||
|
||||
@@ -39,6 +39,4 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
# TODO: Missing support for ORA2
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
|
||||
|
||||
13
.github/workflows/validate.yml
vendored
13
.github/workflows/validate.yml
vendored
@@ -9,17 +9,22 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
name: code-coverage-report-${{ matrix.node }}
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,7 +34,9 @@ jobs:
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
name: code-coverage-report-20
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
.run
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
10
README.rst
10
README.rst
@@ -38,7 +38,7 @@ Cloning and Setup
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-authoring.git
|
||||
|
||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||
2. Use node v20.x.
|
||||
|
||||
The current version of the micro-frontend build scripts supports node 20.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
@@ -85,8 +85,8 @@ Troubleshooting
|
||||
---------------
|
||||
|
||||
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -98,7 +98,7 @@ Troubleshooting
|
||||
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
|
||||
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
|
||||
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
|
||||
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
|
||||
[this forum post](https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2)
|
||||
|
||||
|
||||
Features
|
||||
@@ -315,7 +315,7 @@ In additional to the standard settings, the following local configurations can b
|
||||
Developing
|
||||
**********
|
||||
|
||||
`Tutor <https://docs.tutor.edly.io/>`_ is the community-supported Open edX development environment. See the `tutor-mfe plugin README <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ for more information.
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
|
||||
|
||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||
|
||||
@@ -12,7 +12,6 @@ metadata:
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:2u-tnl
|
||||
type: 'website'
|
||||
|
||||
11
openedx.yaml
Normal file
11
openedx.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: cath
|
||||
oeps: {}
|
||||
owner: edx/platform-core-tnl
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
9401
package-lock.json
generated
9401
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -23,6 +23,11 @@
|
||||
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
|
||||
@@ -44,10 +49,10 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-component-footer": "^14.3.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/frontend-component-footer": "^14.1.0",
|
||||
"@edx/frontend-component-header": "^5.6.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-platform": "^8.0.3",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
@@ -59,10 +64,9 @@
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||
"@openedx/frontend-slot-footer": "^1.2.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@openedx/frontend-build": "^14.0.14",
|
||||
"@openedx/frontend-plugin-framework": "^1.2.1",
|
||||
"@openedx/paragon": "^22.8.1",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
@@ -81,9 +85,9 @@
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"npm": "^10.8.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "17.0.2",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
@@ -106,19 +110,21 @@
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/react-unit-test-utils": "^4.0.0",
|
||||
"@edx/react-unit-test-utils": "3.0.0",
|
||||
"@edx/stylelint-config-edx": "2.3.3",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { bbbPlanTypes } from '../constants';
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('ORASettings', () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: false });
|
||||
|
||||
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.queryByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
@@ -544,9 +544,12 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
describe('Connection states', () => {
|
||||
it('Shows the spinner before the connection is complete', async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
await act(async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
|
||||
const spinner = screen.getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
});
|
||||
|
||||
it('Show connection error message when we suffer studio server side error', async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ const TeamSettings = ({
|
||||
description: '',
|
||||
type: GroupTypes.OPEN,
|
||||
maxTeamSize: null,
|
||||
userPartitionId: null,
|
||||
id: null,
|
||||
key: uuid(),
|
||||
};
|
||||
@@ -39,7 +38,6 @@ const TeamSettings = ({
|
||||
type: group.type,
|
||||
description: group.description,
|
||||
max_team_size: group.maxTeamSize,
|
||||
user_partition_id: group.userPartitionId,
|
||||
}));
|
||||
return saveSettings({
|
||||
team_sets: groups,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
@@ -106,9 +106,8 @@ describe('XpertUnitSummarySettings', () => {
|
||||
});
|
||||
|
||||
test('Shows switch on if enabled from backend', async () => {
|
||||
const enableBadge = await findByTestId(container, 'enable-badge');
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
||||
expect(enableBadge).toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows switch on if disabled from backend', async () => {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
|
||||
import { fetchStudioHomeData } from './studio-home/data/thunks';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
@@ -21,11 +21,10 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchWaffleFlags(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchOnlyStudioHomeData());
|
||||
dispatch(fetchStudioHomeData());
|
||||
}, []);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
@@ -66,7 +65,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import initializeStore from './store';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -19,14 +25,17 @@ jest.mock('react-router-dom', () => ({
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -42,9 +51,13 @@ describe('Editor Pages Load no header', () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
||||
@@ -53,9 +66,13 @@ describe('Editor Pages Load no header', () => {
|
||||
mockPathname = '/evilguy/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
||||
@@ -83,7 +100,14 @@ describe('Course authoring page', () => {
|
||||
};
|
||||
test('renders not found page on non-existent course key', async () => {
|
||||
await mockStoreNotFound();
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render not found page on other kinds of error', async () => {
|
||||
@@ -94,9 +118,13 @@ describe('Course authoring page', () => {
|
||||
// found alert is not present.
|
||||
const contentTestId = 'courseAuthoringPageContent';
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||
|
||||
@@ -20,13 +20,10 @@ import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import { Certificates } from './certificates';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
import CourseChecklist from './course-checklist';
|
||||
import GroupConfigurations from './group-configurations';
|
||||
import { CourseLibraries } from './course-libraries';
|
||||
import { IframeProvider } from './generic/hooks/context/iFrameContext';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -58,10 +55,6 @@ const CourseAuthoringRoutes = () => {
|
||||
path="course_info"
|
||||
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="libraries"
|
||||
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="assets"
|
||||
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||
@@ -86,7 +79,7 @@ const CourseAuthoringRoutes = () => {
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
@@ -125,10 +118,6 @@ const CourseAuthoringRoutes = () => {
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="optimizer"
|
||||
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="checklists"
|
||||
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import { executeThunk } from './utils';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { fetchWaffleFlags } from './data/thunks';
|
||||
import {
|
||||
screen, initializeMocks, render, waitFor,
|
||||
} from './testUtils';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
@@ -50,59 +50,68 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock, reduxStore } = initializeMocks();
|
||||
store = reduxStore;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
||||
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', async () => {
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
learningContextId: courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'sequential',
|
||||
blockTypeDisplay: 'Subsection',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
|
||||
import Header from '../header';
|
||||
import messages from './messages';
|
||||
@@ -29,7 +29,7 @@ const AccessibilityPage = ({
|
||||
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||
<AccessibilityForm accessibilityEmail={email} />
|
||||
</Container>
|
||||
<StudioFooterSlot />
|
||||
<StudioFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const defaultCertificate = {
|
||||
courseTitle: '',
|
||||
signatories: [{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as Certificates } from './Certificates';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getSidebarData = ({ messages, intl }) => [
|
||||
{
|
||||
title: intl.formatMessage(messages.workingWithCertificatesTitle),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { convertObjectToSnakeCase } from '../utils';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const prepareCertificatePayload = (data) => convertObjectToSnakeCase(({
|
||||
...data,
|
||||
courseTitle: data.courseTitle,
|
||||
|
||||
@@ -27,8 +27,6 @@ export const NOTIFICATION_MESSAGES = {
|
||||
copying: 'Copying',
|
||||
pasting: 'Pasting',
|
||||
discardChanges: 'Discarding changes',
|
||||
moving: 'Moving',
|
||||
undoMoving: 'Undo moving',
|
||||
publishing: 'Publishing',
|
||||
hidingFromStudents: 'Hiding from students',
|
||||
makingVisibleToStudents: 'Making visible to students',
|
||||
@@ -58,8 +56,6 @@ export const COURSE_BLOCK_NAMES = ({
|
||||
chapter: { id: 'chapter', name: 'Section' },
|
||||
sequential: { id: 'sequential', name: 'Subsection' },
|
||||
vertical: { id: 'vertical', name: 'Unit' },
|
||||
libraryContent: { id: 'library_content', name: 'Library content' },
|
||||
splitTest: { id: 'split_test', name: 'Split Test' },
|
||||
component: { id: 'component', name: 'Component' },
|
||||
});
|
||||
|
||||
@@ -78,31 +74,3 @@ export const REGEX_RULES = {
|
||||
specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/,
|
||||
noSpaceRule: /^\S*$/,
|
||||
};
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
export const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *'
|
||||
);
|
||||
|
||||
export const iframeStateKeys = {
|
||||
iframeHeight: 'iframeHeight',
|
||||
hasLoaded: 'hasLoaded',
|
||||
showError: 'showError',
|
||||
windowTopOffset: 'windowTopOffset',
|
||||
};
|
||||
|
||||
export const iframeMessageTypes = {
|
||||
modal: 'plugin.modal',
|
||||
resize: 'plugin.resize',
|
||||
videoFullScreen: 'plugin.videoFullScreen',
|
||||
xblockEvent: 'xblock-event',
|
||||
};
|
||||
|
||||
@@ -25,9 +25,9 @@ import TagsTree from './TagsTree';
|
||||
import { ContentTagsDrawerContext } from './common/context';
|
||||
|
||||
/** @typedef {import("./ContentTagsCollapsible").TaxonomySelectProps} TaxonomySelectProps */
|
||||
/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.js").Tag} ContentTagData */
|
||||
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
|
||||
|
||||
/**
|
||||
* Custom Menu component for our Select box
|
||||
|
||||
@@ -699,7 +699,7 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
|
||||
name: /delete/i,
|
||||
});
|
||||
await userEvent.click(xButtonAppliedTag);
|
||||
xButtonAppliedTag.click();
|
||||
|
||||
// Check that the applied tag has been removed
|
||||
expect(appliedTag).not.toBeInTheDocument();
|
||||
|
||||
@@ -6,11 +6,11 @@ import { cloneDeep } from 'lodash';
|
||||
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
|
||||
import { ContentTagsDrawerContext } from './common/context';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.js").Tag} ContentTagData */
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */
|
||||
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
|
||||
/** @typedef {import("./data/types.js").UpdateTagsData} UpdateTagsData */
|
||||
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
|
||||
/** @typedef {import("./data/types.mjs").UpdateTagsData} UpdateTagsData */
|
||||
|
||||
/**
|
||||
* Util function that sorts the keys of a tree in alphabetical order.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
render,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from './data/api.mocks';
|
||||
import { getContentTaxonomyTagsApiUrl } from './data/api';
|
||||
|
||||
const path = '/content/:contentId?/*';
|
||||
const path = '/content/:contentId/*';
|
||||
const mockOnClose = jest.fn();
|
||||
const mockSetBlockingSheet = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
@@ -60,15 +61,19 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('shows spinner before the content data query is complete', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
const spinner = (await screen.findAllByRole('status'))[0];
|
||||
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
|
||||
await act(async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
const spinner = screen.getAllByRole('status')[0];
|
||||
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
|
||||
});
|
||||
});
|
||||
|
||||
it('shows spinner before the taxonomy tags query is complete', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
const spinner = (await screen.findAllByRole('status'))[1];
|
||||
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
|
||||
await act(async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
const spinner = screen.getAllByRole('status')[1];
|
||||
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the content display name after the query is complete in drawer variant', async () => {
|
||||
@@ -93,12 +98,15 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||
const { container } = renderDrawer(largeTagsId);
|
||||
await screen.findByText('Taxonomy 1');
|
||||
await screen.findByText('Taxonomy 2');
|
||||
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
|
||||
expect(tagCountBadges[0].textContent).toBe('3');
|
||||
expect(tagCountBadges[1].textContent).toBe('2');
|
||||
await act(async () => {
|
||||
const { container } = renderDrawer(largeTagsId);
|
||||
await waitFor(() => { expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
expect(screen.getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taxonomy 2')).toBeInTheDocument();
|
||||
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
|
||||
expect(tagCountBadges[0].textContent).toBe('3');
|
||||
expect(tagCountBadges[1].textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should be read only on first render on drawer variant', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import classNames from 'classnames';
|
||||
import messages from './messages';
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import Loading from '../generic/Loading';
|
||||
import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper';
|
||||
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
|
||||
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
interface TaxonomyListProps {
|
||||
@@ -227,6 +227,7 @@ interface ContentTagsDrawerProps {
|
||||
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
|
||||
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
|
||||
* Functions to close the drawer are handled internally.
|
||||
* TODO: We can delete this method when is no longer used on edx-platform.
|
||||
* - If you want to use it as react component, you need to pass the content id and the close functions
|
||||
* through the component parameters.
|
||||
*/
|
||||
@@ -245,7 +246,7 @@ const ContentTagsDrawer = ({
|
||||
throw new Error('Error: contentId cannot be null.');
|
||||
}
|
||||
|
||||
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
|
||||
const context = useContentTagsDrawerContext(contentId, !readOnly);
|
||||
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
|
||||
|
||||
const {
|
||||
|
||||
@@ -8,23 +8,46 @@ import { extractOrgFromContentId, languageExportId } from './utils';
|
||||
import messages from './messages';
|
||||
import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
/** @typedef {import("./data/types.js").Tag} ContentTagData */
|
||||
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
|
||||
/** @typedef {import("./data/types.js").TagsInTaxonomy} TagsInTaxonomy */
|
||||
/** @typedef {import("./common/context").ContentTagsDrawerContextData} ContentTagsDrawerContextData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
|
||||
/** @typedef {import("./data/types.mjs").TagsInTaxonomy} TagsInTaxonomy */
|
||||
|
||||
/**
|
||||
* Helper hook for *creating* a `ContentTagsDrawerContext`.
|
||||
* Handles the context and all the underlying logic for the ContentTagsDrawer component.
|
||||
*
|
||||
* To *use* the context, just use `useContext(ContentTagsDrawerContext)`
|
||||
* Handles the context and all the underlying logic for the ContentTagsDrawer component
|
||||
* @param {string} contentId
|
||||
* @param {boolean} canTagObject
|
||||
* @param {boolean} fetchMetadata=false If true, fetches metadata for the contentId. This is used on `edx-platform`
|
||||
* and the Course/Unit Outline to show the content name as the drawer title.
|
||||
* @returns {ContentTagsDrawerContextData}
|
||||
* @returns {{
|
||||
* stagedContentTags: Record<number, StagedTagData[]>,
|
||||
* addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void,
|
||||
* removeStagedContentTag: (taxonomyId: number, tagValue: string) => void,
|
||||
* removeGlobalStagedContentTag: (taxonomyId: number, tagValue: string) => void,
|
||||
* addRemovedContentTag: (taxonomyId: number, tagValue: string) => void,
|
||||
* deleteRemovedContentTag: (taxonomyId: number, tagValue: string) => void,
|
||||
* setStagedTags: (taxonomyId: number, tagsList: StagedTagData[]) => void,
|
||||
* globalStagedContentTags: Record<number, StagedTagData[]>,
|
||||
* globalStagedRemovedContentTags: Record<number, string>,
|
||||
* setGlobalStagedContentTags: Function,
|
||||
* commitGlobalStagedTags: () => void,
|
||||
* commitGlobalStagedTagsStatus: string,
|
||||
* isContentDataLoaded: boolean,
|
||||
* isContentTaxonomyTagsLoaded: boolean,
|
||||
* isTaxonomyListLoaded: boolean,
|
||||
* contentName: string,
|
||||
* tagsByTaxonomy: TagsInTaxonomy[],
|
||||
* isEditMode: boolean,
|
||||
* toEditMode: () => void,
|
||||
* toReadMode: () => void,
|
||||
* collapsibleStates: Record<number, boolean>,
|
||||
* openCollapsible: (taxonomyId: number) => void,
|
||||
* closeCollapsible: (taxonomyId: number) => void,
|
||||
* toastMessage: string | undefined,
|
||||
* showToastAfterSave: () => void,
|
||||
* closeToast: () => void,
|
||||
* setCollapsibleToInitalState: () => void,
|
||||
* otherTaxonomies: TagsInTaxonomy[],
|
||||
* }}
|
||||
*/
|
||||
export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetchMetadata = false) => {
|
||||
const useContentTagsDrawerContext = (contentId, canTagObject) => {
|
||||
const intl = useIntl();
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
@@ -50,7 +73,7 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
|
||||
const updateTags = useContentTaxonomyTagsUpdater(contentId);
|
||||
|
||||
// Fetch from database
|
||||
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId, fetchMetadata);
|
||||
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
|
||||
const {
|
||||
data: contentTaxonomyTagsData,
|
||||
isSuccess: isContentTaxonomyTagsLoaded,
|
||||
@@ -442,3 +465,5 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
|
||||
otherTaxonomies,
|
||||
};
|
||||
};
|
||||
|
||||
export default useContentTagsDrawerContext;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
@@ -73,9 +74,11 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
}
|
||||
|
||||
it('should render taxonomy tags drop down selector loading with spinner', async () => {
|
||||
const { getByRole } = await getComponent();
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
|
||||
await act(async () => {
|
||||
const { getByRole } = await getComponent();
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector with no sub tags', async () => {
|
||||
@@ -96,11 +99,13 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { container, getByText } = await getComponent();
|
||||
await act(async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,11 +127,13 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { container, getByText } = await getComponent();
|
||||
await act(async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,45 +155,47 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = await getComponent(dataWithTagsTree);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
};
|
||||
const { container, getByText } = await getComponent(dataWithTagsTree);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.click(expandToggle);
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,46 +219,48 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
|
||||
const initalSearchTerm = 'test 1';
|
||||
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
|
||||
await act(async () => {
|
||||
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
|
||||
});
|
||||
|
||||
const updatedSearchTerm = 'test 2';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={updatedSearchTerm}
|
||||
appliedContentTagsTree={{}}
|
||||
stagedContentTagsTree={{}}
|
||||
/>);
|
||||
const updatedSearchTerm = 'test 2';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={updatedSearchTerm}
|
||||
appliedContentTagsTree={{}}
|
||||
stagedContentTagsTree={{}}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
|
||||
});
|
||||
|
||||
// Clean search term
|
||||
const cleanSearchTerm = '';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={cleanSearchTerm}
|
||||
appliedContentTagsTree={{}}
|
||||
stagedContentTagsTree={{}}
|
||||
/>);
|
||||
// Clean search term
|
||||
const cleanSearchTerm = '';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={cleanSearchTerm}
|
||||
appliedContentTagsTree={{}}
|
||||
stagedContentTagsTree={{}}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render "noTag" message if search doesnt return taxonomies', async () => {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
@@ -260,18 +271,20 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
|
||||
const searchTerm = 'uncommon search term';
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
await act(async () => {
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
});
|
||||
|
||||
const message = `No tags found with the search term "${searchTerm}"`;
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const message = `No tags found with the search term "${searchTerm}"`;
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "noTagsInTaxonomy" message if taxonomy is empty', async () => {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
@@ -282,13 +295,15 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
|
||||
const searchTerm = '';
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
await act(async () => {
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
});
|
||||
|
||||
const message = 'No tags in this taxonomy yet';
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const message = 'No tags in this taxonomy yet';
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
49
src/content-tags-drawer/common/context.js
Normal file
49
src/content-tags-drawer/common/context.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// @ts-check
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
/** @typedef {import("../data/types.mjs").TagsInTaxonomy} TagsInTaxonomy */
|
||||
/** @typedef {import("../data/types.mjs").StagedTagData} StagedTagData */
|
||||
|
||||
/* istanbul ignore next */
|
||||
export const ContentTagsDrawerContext = React.createContext({
|
||||
stagedContentTags: /** @type{Record<number, StagedTagData[]>} */ ({}),
|
||||
globalStagedContentTags: /** @type{Record<number, StagedTagData[]>} */ ({}),
|
||||
globalStagedRemovedContentTags: /** @type{Record<number, string>} */ ({}),
|
||||
addStagedContentTag: /** @type{(taxonomyId: number, addedTag: StagedTagData) => void} */ (() => {}),
|
||||
removeStagedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
|
||||
removeGlobalStagedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
|
||||
addRemovedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
|
||||
deleteRemovedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
|
||||
setStagedTags: /** @type{(taxonomyId: number, tagsList: StagedTagData[]) => void} */ (() => {}),
|
||||
setGlobalStagedContentTags: /** @type{Function} */ (() => {}),
|
||||
commitGlobalStagedTags: /** @type{() => void} */ (() => {}),
|
||||
commitGlobalStagedTagsStatus: /** @type{null|string} */ (null),
|
||||
isContentDataLoaded: /** @type{boolean} */ (false),
|
||||
isContentTaxonomyTagsLoaded: /** @type{boolean} */ (false),
|
||||
isTaxonomyListLoaded: /** @type{boolean} */ (false),
|
||||
contentName: /** @type{string} */ (''),
|
||||
tagsByTaxonomy: /** @type{TagsInTaxonomy[]} */ ([]),
|
||||
isEditMode: /** @type{boolean} */ (false),
|
||||
toEditMode: /** @type{() => void} */ (() => {}),
|
||||
toReadMode: /** @type{() => void} */ (() => {}),
|
||||
collapsibleStates: /** @type{Record<number, boolean>} */ ({}),
|
||||
openCollapsible: /** @type{(taxonomyId: number) => void} */ (() => {}),
|
||||
closeCollapsible: /** @type{(taxonomyId: number) => void} */ (() => {}),
|
||||
toastMessage: /** @type{string|undefined} */ (undefined),
|
||||
showToastAfterSave: /** @type{() => void} */ (() => {}),
|
||||
closeToast: /** @type{() => void} */ (() => {}),
|
||||
setCollapsibleToInitalState: /** @type{() => void} */ (() => {}),
|
||||
otherTaxonomies: /** @type{TagsInTaxonomy[]} */ ([]),
|
||||
});
|
||||
|
||||
// This context has not been added to ContentTagsDrawerContext because it has been
|
||||
// created one level higher to control the behavior of the Sheet that contatins the Drawer.
|
||||
// This logic is not used in legacy edx-platform screens. But it can be separated if we keep
|
||||
// the contexts separate.
|
||||
// TODO We can join both contexts when the Drawer is no longer used on edx-platform
|
||||
/* istanbul ignore next */
|
||||
export const ContentTagsDrawerSheetContext = React.createContext({
|
||||
blockingSheet: /** @type{boolean} */ (false),
|
||||
setBlockingSheet: /** @type{Function} */ (() => {}),
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { TagsInTaxonomy, StagedTagData } from '../data/types';
|
||||
|
||||
export interface ContentTagsDrawerContextData {
|
||||
stagedContentTags: Record<number, StagedTagData[]>;
|
||||
globalStagedContentTags: Record<number, StagedTagData[]>;
|
||||
globalStagedRemovedContentTags: Record<number, string>;
|
||||
addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void;
|
||||
removeStagedContentTag: (taxonomyId: number, tagValue: string) => void;
|
||||
removeGlobalStagedContentTag: (taxonomyId: number, tagValue: string) => void;
|
||||
addRemovedContentTag: (taxonomyId: number, tagValue: string) => void;
|
||||
deleteRemovedContentTag: (taxonomyId: number, tagValue: string) => void;
|
||||
setStagedTags: (taxonomyId: number, tagsList: StagedTagData[]) => void;
|
||||
setGlobalStagedContentTags: Function;
|
||||
commitGlobalStagedTags: () => void;
|
||||
commitGlobalStagedTagsStatus: null | string;
|
||||
isContentDataLoaded: boolean;
|
||||
isContentTaxonomyTagsLoaded: boolean;
|
||||
isTaxonomyListLoaded: boolean;
|
||||
contentName: string;
|
||||
tagsByTaxonomy: TagsInTaxonomy[];
|
||||
isEditMode: boolean;
|
||||
toEditMode: () => void;
|
||||
toReadMode: () => void;
|
||||
collapsibleStates: Record<number, boolean>;
|
||||
openCollapsible: (taxonomyId: number) => void;
|
||||
closeCollapsible: (taxonomyId: number) => void;
|
||||
toastMessage: string | undefined;
|
||||
showToastAfterSave: () => void;
|
||||
closeToast: () => void;
|
||||
setCollapsibleToInitalState: () => void;
|
||||
otherTaxonomies: TagsInTaxonomy[];
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export const ContentTagsDrawerContext = React.createContext<ContentTagsDrawerContextData>({
|
||||
stagedContentTags: {},
|
||||
globalStagedContentTags: {},
|
||||
globalStagedRemovedContentTags: {},
|
||||
addStagedContentTag: () => {},
|
||||
removeStagedContentTag: () => {},
|
||||
removeGlobalStagedContentTag: () => {},
|
||||
addRemovedContentTag: () => {},
|
||||
deleteRemovedContentTag: () => {},
|
||||
setStagedTags: () => {},
|
||||
setGlobalStagedContentTags: () => {},
|
||||
commitGlobalStagedTags: () => {},
|
||||
commitGlobalStagedTagsStatus: null,
|
||||
isContentDataLoaded: false,
|
||||
isContentTaxonomyTagsLoaded: false,
|
||||
isTaxonomyListLoaded: false,
|
||||
contentName: '',
|
||||
tagsByTaxonomy: [],
|
||||
isEditMode: false,
|
||||
toEditMode: () => {},
|
||||
toReadMode: () => {},
|
||||
collapsibleStates: {},
|
||||
openCollapsible: () => {},
|
||||
closeCollapsible: () => {},
|
||||
toastMessage: undefined,
|
||||
showToastAfterSave: () => {},
|
||||
closeToast: () => {},
|
||||
setCollapsibleToInitalState: () => {},
|
||||
otherTaxonomies: [],
|
||||
});
|
||||
|
||||
// This context has not been added to ContentTagsDrawerContext because it has been
|
||||
// created one level higher to control the behavior of the Sheet that contatins the Drawer.
|
||||
// This logic is not used in legacy edx-platform screens. But it can be separated if we keep
|
||||
// the contexts separate.
|
||||
// TODO We can join both contexts when the Drawer is no longer used on edx-platform
|
||||
/* istanbul ignore next */
|
||||
export const ContentTagsDrawerSheetContext = React.createContext({
|
||||
blockingSheet: false,
|
||||
setBlockingSheet: (() => {}) as (blockingSheet: boolean) => void,
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/con
|
||||
* Get all tags that belong to taxonomy.
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||
* @returns {Promise<import("../../taxonomy/data/types.js").TagListData>}
|
||||
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
|
||||
*/
|
||||
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
|
||||
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
|
||||
@@ -49,7 +49,7 @@ export async function getTaxonomyTagsData(taxonomyId, options = {}) {
|
||||
/**
|
||||
* Get the tags that are applied to the content object
|
||||
* @param {string} contentId The id of the content object to fetch the applied tags for
|
||||
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
|
||||
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export async function getContentTaxonomyTagsData(contentId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
|
||||
@@ -70,12 +70,17 @@ export async function getContentTaxonomyTagsCount(contentId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch meta data (eg: display_name) about the content object (unit/component)
|
||||
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @returns {Promise<import("./types.js").ContentData>}
|
||||
* @returns {Promise<import("./types.mjs").ContentData | null>}
|
||||
*/
|
||||
export async function getContentData(contentId) {
|
||||
let url;
|
||||
if (contentId.startsWith('lib-collection:')) {
|
||||
// This type of usage_key is not used to obtain collections
|
||||
// is only used in tagging.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentId.startsWith('lb:')) {
|
||||
url = getLibraryContentDataApiUrl(contentId);
|
||||
@@ -91,8 +96,8 @@ export async function getContentData(contentId) {
|
||||
/**
|
||||
* Update content object's applied tags
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @param {Promise<import("./types.js").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
|
||||
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
|
||||
* @param {Promise<import("./types.mjs").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
|
||||
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export async function updateContentTaxonomyTags(contentId, tagsData) {
|
||||
const url = getContentTaxonomyTagsApiUrl(contentId);
|
||||
|
||||
@@ -13,7 +13,6 @@ export async function mockContentTaxonomyTagsData(contentId: string): Promise<an
|
||||
case thisMock.languageWithTagsId: return thisMock.languageWithTags;
|
||||
case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags;
|
||||
case thisMock.largeTagsId: return thisMock.largeTags;
|
||||
case thisMock.containerTagsId: return thisMock.largeTags;
|
||||
case thisMock.emptyTagsId: return thisMock.emptyTags;
|
||||
default: throw new Error(`No mock has been set up for contentId "${contentId}"`);
|
||||
}
|
||||
@@ -205,7 +204,6 @@ mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+typ
|
||||
mockContentTaxonomyTagsData.emptyTags = {
|
||||
taxonomies: [],
|
||||
};
|
||||
mockContentTaxonomyTagsData.containerTagsId = 'lct:org:lib:unit:container_tags';
|
||||
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
|
||||
import { getLibraryId } from '../../generic/key-utils';
|
||||
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags
|
||||
@@ -112,13 +112,11 @@ export const useContentTaxonomyTagsData = (contentId) => (
|
||||
/**
|
||||
* Builds the query to get meta data about the content object
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @param {boolean} enabled Flag to enable/disable the query
|
||||
*/
|
||||
export const useContentData = (contentId, enabled) => (
|
||||
export const useContentData = (contentId) => (
|
||||
useQuery({
|
||||
queryKey: ['contentData', contentId],
|
||||
queryFn: enabled ? () => getContentData(contentId) : undefined,
|
||||
enabled,
|
||||
queryFn: () => getContentData(contentId),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -128,7 +126,6 @@ export const useContentData = (contentId, enabled) => (
|
||||
*/
|
||||
export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
const queryClient = useQueryClient();
|
||||
const unitIframe = window.frames['xblock-iframe'];
|
||||
|
||||
return useMutation({
|
||||
/**
|
||||
@@ -136,7 +133,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
* any,
|
||||
* any,
|
||||
* {
|
||||
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
|
||||
* tagsData: Promise<import("./types.mjs").UpdateTagsData[]>
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
@@ -151,7 +148,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
contentPattern = contentId.replace(/\+type@.*$/, '*');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
|
||||
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:') || contentId.startsWith('lct:')) {
|
||||
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:')) {
|
||||
// Obtain library id from contentId
|
||||
const libraryId = getLibraryId(contentId);
|
||||
// Invalidate component metadata to update tags count
|
||||
@@ -163,8 +160,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
onSuccess: /* istanbul ignore next */ () => {
|
||||
/* istanbul ignore next */
|
||||
if (window.top != null) {
|
||||
// Sends messages to the parent page if the drawer was opened
|
||||
// from an iframe or the unit iframe within the course.
|
||||
// This send messages to the parent page if the drawer is called from a iframe.
|
||||
// Is used on Studio to update tags data and counts.
|
||||
// In the future, when the Course Outline Page and Unit Page are integrated into this MFE,
|
||||
// they should just use React Query to load the tag counts, and React Query will automatically
|
||||
@@ -173,32 +169,26 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
|
||||
// Sends content tags.
|
||||
getContentTaxonomyTagsData(contentId).then((data) => {
|
||||
const contentData = { contentId, ...data };
|
||||
|
||||
const message = {
|
||||
type: 'authoring.events.tags.updated',
|
||||
data: contentData,
|
||||
const contentData = {
|
||||
contentId,
|
||||
...data,
|
||||
};
|
||||
|
||||
const targetOrigin = getConfig().STUDIO_BASE_URL;
|
||||
|
||||
unitIframe?.postMessage(message, targetOrigin);
|
||||
window.top?.postMessage(message, targetOrigin);
|
||||
window.top?.postMessage(
|
||||
{ type: 'authoring.events.tags.updated', data: contentData },
|
||||
getConfig().STUDIO_BASE_URL,
|
||||
);
|
||||
});
|
||||
|
||||
// Sends tags count.
|
||||
getContentTaxonomyTagsCount(contentId).then((count) => {
|
||||
const contentData = { contentId, count };
|
||||
|
||||
const message = {
|
||||
type: 'authoring.events.tags.count.updated',
|
||||
data: contentData,
|
||||
getContentTaxonomyTagsCount(contentId).then((data) => {
|
||||
const contentData = {
|
||||
contentId,
|
||||
count: data,
|
||||
};
|
||||
|
||||
const targetOrigin = getConfig().STUDIO_BASE_URL;
|
||||
|
||||
unitIframe?.postMessage(message, targetOrigin);
|
||||
window.top?.postMessage(message, targetOrigin);
|
||||
window.top?.postMessage(
|
||||
{ type: 'authoring.events.tags.count.updated', data: contentData },
|
||||
getConfig().STUDIO_BASE_URL,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import {
|
||||
useTaxonomyTagsData,
|
||||
useContentTaxonomyTagsData,
|
||||
|
||||
101
src/content-tags-drawer/data/types.mjs
Normal file
101
src/content-tags-drawer/data/types.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} Tag A tag that has been applied to some content.
|
||||
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
|
||||
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
|
||||
* @property {boolean} canChangeObjecttag
|
||||
* @property {boolean} canDeleteObjecttag
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
|
||||
* @property {string} name
|
||||
* @property {number} taxonomyId
|
||||
* @property {boolean} canTagObject
|
||||
* @property {Tag[]} tags
|
||||
* @property {string} exportId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
|
||||
* @property {ContentTaxonomyTagData[]} taxonomies
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentActions
|
||||
* @property {boolean} deleteable
|
||||
* @property {boolean} draggable
|
||||
* @property {boolean} childAddable
|
||||
* @property {boolean} duplicable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} XBlockData
|
||||
* @property {string} id
|
||||
* @property {string} displayName
|
||||
* @property {string} category
|
||||
* @property {boolean} hasChildren
|
||||
* @property {string} editedOn
|
||||
* @property {boolean} published
|
||||
* @property {string} publishedOn
|
||||
* @property {string} studioUrl
|
||||
* @property {boolean} releasedToStudents
|
||||
* @property {string|null} releaseDate
|
||||
* @property {string} visibilityState
|
||||
* @property {boolean} hasExplicitStaffLock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} dueDate
|
||||
* @property {string} due
|
||||
* @property {string|null} relativeWeeksDue
|
||||
* @property {string|null} format
|
||||
* @property {boolean} hasChanges
|
||||
* @property {ContentActions} actions
|
||||
* @property {string} explanatoryMessage
|
||||
* @property {string} showCorrectness
|
||||
* @property {boolean} discussionEnabled
|
||||
* @property {boolean} ancestorHasStaffLock
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TagsInTaxonomy
|
||||
* @property {boolean} allOrgs
|
||||
* @property {boolean} allowFreeText
|
||||
* @property {boolean} allowMultiple
|
||||
* @property {boolean} canChangeTaxonomy
|
||||
* @property {boolean} canDeleteTaxonomy
|
||||
* @property {boolean} canTagObject
|
||||
* @property {Tag[]} contentTags
|
||||
* @property {string} description
|
||||
* @property {boolean} enabled
|
||||
* @property {string} exportId
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {boolean} systemDefined
|
||||
* @property {number} tagsCount
|
||||
* @property {boolean} visibleToAuthors
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CourseData
|
||||
* @property {string} courseDisplayNameWithDefault
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {XBlockData | CourseData} ContentData
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UpdateTagsData
|
||||
* @property {number} taxonomy
|
||||
* @property {string[]} tags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StagedTagData
|
||||
* @property {string} value
|
||||
* @property {string} label
|
||||
*/
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { TaxonomyData } from '../../taxonomy/data/types';
|
||||
|
||||
/** A tag that has been applied to some content. */
|
||||
export interface Tag {
|
||||
/** The value of the tag, also its ID. e.g. "Biology" */
|
||||
value: string;
|
||||
/** The values of the tag and its parent(s) in the hierarchy */
|
||||
lineage: string[];
|
||||
canChangeObjecttag: boolean;
|
||||
canDeleteObjecttag: boolean;
|
||||
}
|
||||
|
||||
/** A list of the tags from one taxonomy that are applied to a content object. */
|
||||
export interface ContentTaxonomyTagData {
|
||||
name: string;
|
||||
taxonomyId: number;
|
||||
canTagObject: boolean;
|
||||
tags: Tag[];
|
||||
exportId: string;
|
||||
}
|
||||
|
||||
/** A list of all the tags applied to some content object, grouped by taxonomy. */
|
||||
export interface ContentTaxonomyTagsData {
|
||||
taxonomies: ContentTaxonomyTagData[];
|
||||
}
|
||||
|
||||
export interface ContentActions {
|
||||
deleteable: boolean;
|
||||
draggable: boolean;
|
||||
childAddable: boolean;
|
||||
duplicable: boolean;
|
||||
}
|
||||
|
||||
export interface XBlockData {
|
||||
id: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
hasChildren: boolean;
|
||||
editedOn: string;
|
||||
published: boolean;
|
||||
publishedOn: string;
|
||||
studioUrl: string;
|
||||
releasedToStudents: boolean;
|
||||
releaseDate: string | null;
|
||||
visibilityState: string;
|
||||
hasExplicitStaffLock: boolean;
|
||||
start: string;
|
||||
graded: boolean;
|
||||
dueDate: string;
|
||||
due: string;
|
||||
relativeWeeksDue: string | null;
|
||||
format: string | null;
|
||||
hasChanges: boolean;
|
||||
actions: ContentActions;
|
||||
explanatoryMessage: string;
|
||||
showCorrectness: string;
|
||||
discussionEnabled: boolean;
|
||||
ancestorHasStaffLock: boolean;
|
||||
staffOnlyMessage: boolean;
|
||||
hasPartitionGroupComponents: boolean;
|
||||
}
|
||||
|
||||
export interface TagsInTaxonomy extends TaxonomyData {
|
||||
contentTags: Tag[];
|
||||
}
|
||||
|
||||
export interface CourseData {
|
||||
courseDisplayNameWithDefault: string;
|
||||
}
|
||||
|
||||
export type ContentData = XBlockData | CourseData;
|
||||
|
||||
export interface UpdateTagsData {
|
||||
taxonomy: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface StagedTagData {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as ContentTagsDrawerSheet } from './ContentTagsDrawerSheet';
|
||||
export { useContentTaxonomyTagsData } from './data/apiHooks';
|
||||
3
src/content-tags-drawer/utils.js
Normal file
3
src/content-tags-drawer/utils.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||
export const languageExportId = 'languages-v1';
|
||||
@@ -1,2 +0,0 @@
|
||||
export const extractOrgFromContentId = (contentId: string): string => contentId.split('+')[0].split(':')[1];
|
||||
export const languageExportId = 'languages-v1';
|
||||
@@ -1,95 +1,70 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, Button, Icon } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Hyperlink,
|
||||
Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { getWaffleFlags } from '../../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
const getUpdateLinks = (courseId, waffleFlags) => {
|
||||
const baseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const isLegacyGradingUrl = !waffleFlags.useNewGradingPage;
|
||||
const isLegacyCertificateUrl = !waffleFlags.useNewCertificatesPage;
|
||||
const isLegacyCourseDatesUrl = !waffleFlags.useNewScheduleDetailsPage;
|
||||
const isLegacyOutlineUrl = !waffleFlags.useNewCourseOutlinePage;
|
||||
|
||||
return {
|
||||
welcomeMessage: `/course/${courseId}/course_info`,
|
||||
gradingPolicy: isLegacyGradingUrl
|
||||
? `${baseUrl}/settings/grading/${courseId}` : `/course/${courseId}/settings/grading`,
|
||||
certificate: isLegacyCertificateUrl
|
||||
? `${baseUrl}/certificates/${courseId}` : `/course/${courseId}/certificates`,
|
||||
courseDates: isLegacyCourseDatesUrl
|
||||
? `${baseUrl}/settings/details/${courseId}#schedule` : `/course/${courseId}/settings/details/#schedule`,
|
||||
proctoringEmail: `${baseUrl}/pages-and-resources/proctoring/settings`,
|
||||
outline: isLegacyOutlineUrl ? `${baseUrl}/course/${courseId}` : `/course/${courseId}`,
|
||||
};
|
||||
};
|
||||
|
||||
const ChecklistItemBody = ({
|
||||
courseId,
|
||||
checkId,
|
||||
isCompleted,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const waffleFlags = useSelector(getWaffleFlags);
|
||||
const updateLinks = getUpdateLinks(courseId, waffleFlags);
|
||||
|
||||
return (
|
||||
<ActionRow>
|
||||
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
|
||||
{isCompleted ? (
|
||||
<Icon
|
||||
data-testid="completed-icon"
|
||||
src={CheckCircle}
|
||||
className="text-success"
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
data-testid="uncompleted-icon"
|
||||
src={RadioButtonUnchecked}
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
|
||||
</div>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
{updateLinks?.[checkId] && (
|
||||
<Link
|
||||
to={updateLinks[checkId]}
|
||||
data-testid="update-link"
|
||||
>
|
||||
<Button size="sm">
|
||||
<FormattedMessage {...messages.updateLinkLabel} />
|
||||
</Button>
|
||||
</Link>
|
||||
updateLink,
|
||||
// injected
|
||||
intl,
|
||||
}) => (
|
||||
<ActionRow>
|
||||
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
|
||||
{isCompleted ? (
|
||||
<Icon
|
||||
data-testid="completed-icon"
|
||||
src={CheckCircle}
|
||||
className="text-success"
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
data-testid="uncompleted-icon"
|
||||
src={RadioButtonUnchecked}
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
|
||||
/>
|
||||
)}
|
||||
</ActionRow>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
|
||||
</div>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
{updateLink && (
|
||||
<Hyperlink destination={updateLink} data-testid="update-hyperlink">
|
||||
<Button size="sm">
|
||||
<FormattedMessage {...messages.updateLinkLabel} />
|
||||
</Button>
|
||||
</Hyperlink>
|
||||
)}
|
||||
</ActionRow>
|
||||
);
|
||||
|
||||
ChecklistItemBody.defaultProps = {
|
||||
updateLink: null,
|
||||
};
|
||||
|
||||
ChecklistItemBody.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
checkId: PropTypes.string.isRequired,
|
||||
isCompleted: PropTypes.bool.isRequired,
|
||||
updateLink: PropTypes.string,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default ChecklistItemBody;
|
||||
export default injectIntl(ChecklistItemBody);
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Hyperlink, Icon } from '@openedx/paragon';
|
||||
import { ModeComment } from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getWaffleFlags } from '../../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
const ChecklistItemComment = ({
|
||||
courseId,
|
||||
checkId,
|
||||
outlineUrl,
|
||||
data,
|
||||
}) => {
|
||||
const waffleFlags = useSelector(getWaffleFlags);
|
||||
|
||||
const getPathToCourseOutlinePage = (assignmentId) => (waffleFlags.useNewCourseOutlinePage
|
||||
? `/course/${courseId}#${assignmentId}` : `${getConfig().STUDIO_BASE_URL}/course/${courseId}#${assignmentId}`);
|
||||
|
||||
const commentWrapper = (comment) => (
|
||||
<div className="row m-0 mt-3 pt-3 border-top align-items-center" data-identifier="comment">
|
||||
<div className="mr-4">
|
||||
@@ -87,9 +79,9 @@ const ChecklistItemComment = ({
|
||||
<ul className="assignment-list">
|
||||
{gradedAssignmentsOutsideDateRange.map(assignment => (
|
||||
<li className="assignment-list-item" key={assignment.id}>
|
||||
<Link to={getPathToCourseOutlinePage(assignment.id)}>
|
||||
<Hyperlink destination={`${outlineUrl}#${assignment.id}`}>
|
||||
{assignment.displayName}
|
||||
</Link>
|
||||
</Hyperlink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -104,7 +96,6 @@ const ChecklistItemComment = ({
|
||||
};
|
||||
|
||||
ChecklistItemComment.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
checkId: PropTypes.string.isRequired,
|
||||
outlineUrl: PropTypes.string.isRequired,
|
||||
data: PropTypes.oneOfType([
|
||||
|
||||
@@ -10,11 +10,11 @@ import ChecklistItemComment from './ChecklistItemComment';
|
||||
import { checklistItems } from './utils/courseChecklistData';
|
||||
|
||||
const ChecklistSection = ({
|
||||
courseId,
|
||||
dataHeading,
|
||||
data,
|
||||
idPrefix,
|
||||
isLoading,
|
||||
updateLinks,
|
||||
}) => {
|
||||
const dataList = checklistItems[idPrefix];
|
||||
const getCompletionCountID = () => (`${idPrefix}-completion-count`);
|
||||
@@ -37,6 +37,8 @@ const ChecklistSection = ({
|
||||
{checks.map(check => {
|
||||
const checkId = check.id;
|
||||
const isCompleted = values[checkId];
|
||||
const updateLink = updateLinks?.[checkId];
|
||||
const outlineUrl = updateLinks.outline;
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border py-3 px-4 ${isCompleted && 'checklist-item-complete'}`}
|
||||
@@ -44,9 +46,9 @@ const ChecklistSection = ({
|
||||
data-testid={`checklist-item-${checkId}`}
|
||||
key={checkId}
|
||||
>
|
||||
<ChecklistItemBody courseId={courseId} {...{ checkId, isCompleted }} />
|
||||
<ChecklistItemBody {...{ checkId, isCompleted, updateLink }} />
|
||||
<div data-testid={`comment-section-${checkId}`}>
|
||||
<ChecklistItemComment {...{ courseId, checkId, data }} />
|
||||
<ChecklistItemComment {...{ checkId, outlineUrl, data }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -59,11 +61,11 @@ const ChecklistSection = ({
|
||||
};
|
||||
|
||||
ChecklistSection.defaultProps = {
|
||||
updateLinks: {},
|
||||
data: {},
|
||||
};
|
||||
|
||||
ChecklistSection.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
dataHeading: PropTypes.string.isRequired,
|
||||
data: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
@@ -127,6 +129,14 @@ ChecklistSection.propTypes = {
|
||||
]),
|
||||
idPrefix: PropTypes.string.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
updateLinks: PropTypes.shape({
|
||||
welcomeMessage: PropTypes.string,
|
||||
gradingPolicy: PropTypes.string,
|
||||
certificate: PropTypes.string,
|
||||
courseDates: PropTypes.string,
|
||||
proctoringEmail: PropTypes.string,
|
||||
outline: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistSection);
|
||||
|
||||
@@ -1,49 +1,59 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
|
||||
/* eslint-disable */
|
||||
import {
|
||||
initializeMocks, render, screen, within,
|
||||
} from '../../testUtils';
|
||||
import { getApiWaffleFlagsUrl } from '../../data/api';
|
||||
import { fetchWaffleFlags } from '../../data/thunks';
|
||||
import { generateCourseLaunchData } from '../factories/mockApiResponses';
|
||||
import { executeThunk } from '../../utils';
|
||||
import { checklistItems } from './utils/courseChecklistData';
|
||||
import messages from './messages';
|
||||
render,
|
||||
within,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ChecklistSection from '.';
|
||||
import initializeStore from '../../store';
|
||||
import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses';
|
||||
import messages from './messages';
|
||||
import ChecklistSection from './index';
|
||||
import { checklistItems } from './utils/courseChecklistData';
|
||||
import getUpdateLinks from '../utils';
|
||||
|
||||
const testData = camelCaseObject(generateCourseLaunchData());
|
||||
|
||||
const courseId = '123';
|
||||
|
||||
const defaultProps = {
|
||||
courseId,
|
||||
data: testData,
|
||||
dataHeading: 'Test checklist',
|
||||
idPrefix: 'launchChecklist',
|
||||
updateLinks: getUpdateLinks('courseId'),
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
const testChecklistData = checklistItems[defaultProps.idPrefix];
|
||||
|
||||
const completedItemIds = ['welcomeMessage', 'courseDates'];
|
||||
const completedItemIds = ['welcomeMessage', 'courseDates']
|
||||
|
||||
const renderComponent = (props) => {
|
||||
render(<ChecklistSection {...props} />);
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<ChecklistSection {...props} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
let store;
|
||||
|
||||
describe('ChecklistSection', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock, reduxStore } = initializeMocks();
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {
|
||||
useNewGradingPage: true,
|
||||
useNewCertificatesPage: true,
|
||||
useNewScheduleDetailsPage: true,
|
||||
useNewCourseOutlinePage: true,
|
||||
});
|
||||
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
|
||||
it('a heading using the dataHeading prop', () => {
|
||||
@@ -54,7 +64,6 @@ describe('ChecklistSection', () => {
|
||||
|
||||
it('completion count text', () => {
|
||||
renderComponent(defaultProps);
|
||||
|
||||
const completionText = `${completedItemIds.length}/6 completed`;
|
||||
expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText);
|
||||
});
|
||||
@@ -113,7 +122,7 @@ describe('ChecklistSection', () => {
|
||||
grades: {
|
||||
...defaultProps.data.grades,
|
||||
sumOfWeights: 1,
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
renderComponent(props);
|
||||
@@ -145,7 +154,7 @@ describe('ChecklistSection', () => {
|
||||
...defaultProps.data.assignments,
|
||||
assignmentsWithDatesAfterEnd: [],
|
||||
assignmentsWithOraDatesBeforeStart: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
renderComponent(props);
|
||||
@@ -174,52 +183,73 @@ describe('ChecklistSection', () => {
|
||||
expect(assigmentLinks[1].textContent).toEqual('ORA subsection');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Checklist Component', () => {
|
||||
let checklistData;
|
||||
let updateLinks;
|
||||
|
||||
testChecklistData.forEach((check) => {
|
||||
describe(`check with id '${check.id}'`, () => {
|
||||
let checkItem;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
renderComponent(defaultProps);
|
||||
|
||||
checklistData = testChecklistData.map((item) => ({
|
||||
itemId: item.id,
|
||||
checklistItem: screen.getAllByTestId(`checklist-item-${item.id}`),
|
||||
icon: screen.getAllByTestId(`icon-${item.id}`),
|
||||
shortDescription: messages[`${item.id}ShortDescription`].defaultMessage,
|
||||
longDescription: messages[`${item.id}LongDescription`].defaultMessage,
|
||||
}));
|
||||
|
||||
updateLinks = screen.getAllByTestId('update-link');
|
||||
checkItem = screen.getAllByTestId(`checklist-item-${check.id}`);
|
||||
});
|
||||
|
||||
it('should display the correct icons based on completion status', () => {
|
||||
checklistData.forEach(({ itemId, icon }) => {
|
||||
const { queryByTestId } = within(icon[0]);
|
||||
|
||||
if (completedItemIds.includes(itemId)) {
|
||||
expect(queryByTestId('completed-icon')).not.toBeNull();
|
||||
} else {
|
||||
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
it('renders', () => {
|
||||
expect(checkItem).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should display short and long descriptions for each checklist item', () => {
|
||||
checklistData.forEach(({ checklistItem, shortDescription, longDescription }) => {
|
||||
const { getByText } = within(checklistItem[0]);
|
||||
it('has correct icon', () => {
|
||||
const icon = screen.getAllByTestId(`icon-${check.id}`)
|
||||
|
||||
expect(getByText(shortDescription)).toBeVisible();
|
||||
expect(getByText(longDescription)).toBeVisible();
|
||||
});
|
||||
expect(icon).toHaveLength(1);
|
||||
|
||||
const { queryByTestId } = within(icon[0]);
|
||||
if (completedItemIds.includes(check.id)) {
|
||||
expect(queryByTestId('completed-icon')).not.toBeNull();
|
||||
} else {
|
||||
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid update links for each checklist item', () => {
|
||||
checklistData.forEach(({ itemId }) => {
|
||||
updateLinks.forEach((link) => {
|
||||
expect(link).toHaveAttribute('href', updateLinks[itemId]);
|
||||
it('has correct short description', () => {
|
||||
const { getByText } = within(checkItem[0]);
|
||||
const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage;
|
||||
expect(getByText(shortDescription)).toBeVisible();
|
||||
});
|
||||
|
||||
it('has correct long description', () => {
|
||||
const { getByText } = within(checkItem[0]);
|
||||
const longDescription = messages[`${check.id}LongDescription`].defaultMessage;
|
||||
expect(getByText(longDescription)).toBeVisible();
|
||||
});
|
||||
|
||||
describe('has correct link', () => {
|
||||
const links = getUpdateLinks('courseId')
|
||||
const shouldShowLink = Object.keys(links).includes(check.id);
|
||||
|
||||
if (shouldShowLink) {
|
||||
it('with a Hyperlink', () => {
|
||||
const { getByRole, getByText } = within(checkItem[0]);
|
||||
|
||||
expect(getByText('Update')).toBeVisible();
|
||||
|
||||
expect(getByRole('link').href).toMatch(links[check.id]);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
it('without a Hyperlink', () => {
|
||||
const { queryByText } = within(checkItem[0]);
|
||||
|
||||
expect(queryByText('Update')).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import AriaLiveRegion from './AriaLiveRegion';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import ChecklistSection from './ChecklistSection';
|
||||
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
|
||||
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
import getUpdateLinks from './utils';
|
||||
|
||||
const CourseChecklist = ({
|
||||
courseId,
|
||||
@@ -23,6 +23,7 @@ const CourseChecklist = ({
|
||||
const dispatch = useDispatch();
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
|
||||
const updateLinks = getUpdateLinks(courseId);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseLaunchQuery({ courseId }));
|
||||
@@ -35,19 +36,10 @@ const CourseChecklist = ({
|
||||
bestPracticeData,
|
||||
} = useSelector(state => state.courseChecklist);
|
||||
|
||||
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
|
||||
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus } = loadingStatus;
|
||||
|
||||
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
|
||||
|
||||
if (isLoadingDenied) {
|
||||
return (
|
||||
<Container size="xl" className="course-unit px-4 mt-4">
|
||||
<ConnectionErrorAlert />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,19 +66,19 @@ const CourseChecklist = ({
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<ChecklistSection
|
||||
courseId={courseId}
|
||||
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
|
||||
data={launchData}
|
||||
idPrefix="launchChecklist"
|
||||
isLoading={isCourseLaunchChecklistLoading}
|
||||
updateLinks={updateLinks}
|
||||
/>
|
||||
{enableQuality && (
|
||||
<ChecklistSection
|
||||
courseId={courseId}
|
||||
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
|
||||
data={bestPracticeData}
|
||||
idPrefix="bestPracticesChecklist"
|
||||
isLoading={isCourseBestPracticeChecklistLoading}
|
||||
updateLinks={updateLinks}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -149,20 +149,5 @@ describe('CourseChecklistPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
|
||||
const courseLaunchApiUrl = getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
});
|
||||
axiosMock.onGet(courseLaunchApiUrl).reply(403);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
|
||||
expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,11 +24,7 @@ export function fetchCourseLaunchQuery({
|
||||
dispatch(fetchLaunchChecklistSuccess({ data }));
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
12
src/course-checklist/utils.js
Normal file
12
src/course-checklist/utils.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const getUpdateLinks = (courseId) => ({
|
||||
welcomeMessage: `${getConfig().STUDIO_BASE_URL}/course_info/${courseId}`,
|
||||
gradingPolicy: `${getConfig().STUDIO_BASE_URL}/settings/grading/${courseId}`,
|
||||
certificate: `${getConfig().STUDIO_BASE_URL}/certificates/${courseId}`,
|
||||
courseDates: `${getConfig().STUDIO_BASE_URL}/settings/details/${courseId}#schedule`,
|
||||
proctoringEmail: 'pages-and-resources/proctoring/settings',
|
||||
outline: `${getConfig().STUDIO_BASE_URL}/course/${courseId}`,
|
||||
});
|
||||
|
||||
export default getUpdateLinks;
|
||||
@@ -1,244 +0,0 @@
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '../testUtils';
|
||||
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
|
||||
import { CourseLibraries } from './CourseLibraries';
|
||||
import {
|
||||
mockGetEntityLinks,
|
||||
mockGetEntityLinksSummaryByDownstreamContext,
|
||||
mockFetchIndexDocuments,
|
||||
mockUseLibBlockMetadata,
|
||||
} from './data/api.mocks';
|
||||
import { libraryBlockChangesUrl } from '../course-unit/data/api';
|
||||
import { type ToastActionData } from '../generic/toast-context';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockGetEntityLinks.applyMock();
|
||||
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
|
||||
mockUseLibBlockMetadata.applyMock();
|
||||
|
||||
const searchParamsGetMock = jest.fn();
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
jest.mock('../studio-home/hooks', () => ({
|
||||
useStudioHome: () => ({
|
||||
isLoadingPage: false,
|
||||
isFailedLoadingPage: false,
|
||||
librariesV2Enabled: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useSearchParams: () => [{
|
||||
get: searchParamsGetMock,
|
||||
getAll: () => [],
|
||||
}],
|
||||
}));
|
||||
|
||||
describe('<CourseLibraries />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
fetchMock.mockReset();
|
||||
mockFetchIndexDocuments.applyMock();
|
||||
localStorage.clear();
|
||||
searchParamsGetMock.mockReturnValue('all');
|
||||
});
|
||||
|
||||
const renderCourseLibrariesPage = async (courseKey?: string) => {
|
||||
const courseId = courseKey || mockGetEntityLinks.courseKey;
|
||||
render(<CourseLibraries courseId={courseId} />);
|
||||
};
|
||||
|
||||
it('shows the spinner before the query is complete', async () => {
|
||||
// This mock will never return data (it loads forever):
|
||||
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading);
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
|
||||
it('shows empty state when no links are present', async () => {
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKeyEmpty);
|
||||
const emptyMsg = await screen.findByText('This course does not use any content from libraries.');
|
||||
expect(emptyMsg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows alert when out of sync components are present', async () => {
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
|
||||
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
|
||||
// review tab should be open by default as outOfSyncCount is greater than 0
|
||||
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
userEvent.click(allTab);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
'5 library components are out of sync. Review updates to accept or ignore changes',
|
||||
)).toBeInTheDocument();
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
const reviewBtn = await screen.findByRole('button', { name: 'Review' });
|
||||
userEvent.click(reviewBtn);
|
||||
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'false');
|
||||
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(alert).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hide alert on dismiss', async () => {
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
|
||||
// review tab should be open by default as outOfSyncCount is greater than 0
|
||||
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
|
||||
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
|
||||
userEvent.click(allTab);
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
'5 library components are out of sync. Review updates to accept or ignore changes',
|
||||
)).toBeInTheDocument();
|
||||
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
userEvent.click(dismissBtn);
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
waitFor(() => expect(alert).not.toBeInTheDocument());
|
||||
// review updates button
|
||||
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
|
||||
userEvent.click(reviewActionBtn);
|
||||
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<CourseLibraries ReviewTab />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
fetchMock.mockReset();
|
||||
mockFetchIndexDocuments.applyMock();
|
||||
localStorage.clear();
|
||||
searchParamsGetMock.mockReturnValue('review');
|
||||
queryClient = mocks.queryClient;
|
||||
});
|
||||
|
||||
const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
|
||||
const courseId = courseKey || mockGetEntityLinks.courseKey;
|
||||
render(<CourseLibraries courseId={courseId} />);
|
||||
};
|
||||
|
||||
it('shows the spinner before the query is complete', async () => {
|
||||
// This mock will never return data (it loads forever):
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading);
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
|
||||
it('shows empty state when no readyToSync links are present', async () => {
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate);
|
||||
const emptyMsg = await screen.findByText('All components are up to date');
|
||||
expect(emptyMsg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all readyToSync links', async () => {
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
|
||||
expect(updateBtns.length).toEqual(5);
|
||||
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
|
||||
expect(ignoreBtns.length).toEqual(5);
|
||||
});
|
||||
|
||||
it('update changes works', async () => {
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
|
||||
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
|
||||
expect(updateBtns.length).toEqual(5);
|
||||
userEvent.click(updateBtns[0]);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
});
|
||||
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
|
||||
});
|
||||
|
||||
it('update changes works in preview modal', async () => {
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
|
||||
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
|
||||
expect(previewBtns.length).toEqual(5);
|
||||
userEvent.click(previewBtns[0]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
|
||||
userEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
});
|
||||
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
|
||||
});
|
||||
|
||||
it('ignore change works', async () => {
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
|
||||
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
|
||||
expect(ignoreBtns.length).toEqual(5);
|
||||
// Show confirmation modal on clicking ignore.
|
||||
userEvent.click(ignoreBtns[0]);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
|
||||
userEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
});
|
||||
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
|
||||
});
|
||||
|
||||
it('ignore change works in preview', async () => {
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
|
||||
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
|
||||
expect(previewBtns.length).toEqual(5);
|
||||
userEvent.click(previewBtns[0]);
|
||||
const previewDialog = await screen.findByRole('dialog');
|
||||
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
|
||||
userEvent.click(ignoreBtn);
|
||||
// Show confirmation modal on clicking ignore.
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
|
||||
userEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
});
|
||||
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert,
|
||||
ActionRow,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Hyperlink,
|
||||
Icon,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Cached, CheckCircle, Launch, Loop,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import sumBy from 'lodash/sumBy';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useModel } from '../generic/model-store';
|
||||
import messages from './messages';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
|
||||
import type { PublishableEntityLinkSummary } from './data/api';
|
||||
import Loading from '../generic/Loading';
|
||||
import { useStudioHome } from '../studio-home/hooks';
|
||||
import NewsstandIcon from '../generic/NewsstandIcon';
|
||||
import ReviewTabContent from './ReviewTabContent';
|
||||
import { OutOfSyncAlert } from './OutOfSyncAlert';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
interface LibraryCardProps {
|
||||
linkSummary: PublishableEntityLinkSummary;
|
||||
}
|
||||
|
||||
export enum CourseLibraryTabs {
|
||||
all = 'all',
|
||||
review = 'review',
|
||||
}
|
||||
|
||||
const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Card className="my-3 border-light-500 border shadow-none">
|
||||
<Card.Header
|
||||
title={(
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={NewsstandIcon} />
|
||||
{linkSummary.upstreamContextTitle}
|
||||
</Stack>
|
||||
)}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
destination={`${getConfig().PUBLIC_PATH}library/${linkSummary.upstreamContextKey}`}
|
||||
target="_blank"
|
||||
className="border border-light-300"
|
||||
variant="tertiary"
|
||||
as={Hyperlink}
|
||||
size="sm"
|
||||
showLaunchIcon={false}
|
||||
iconAfter={Launch}
|
||||
>
|
||||
View Library
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
size="sm"
|
||||
/>
|
||||
<Card.Section>
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
gap={4}
|
||||
className="x-small"
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(messages.totalComponentLabel, { totalComponents: linkSummary.totalCount })}
|
||||
</span>
|
||||
{linkSummary.readyToSyncCount > 0 && (
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon src={Loop} size="xs" />
|
||||
<span>
|
||||
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount: linkSummary.readyToSyncCount })}
|
||||
</span>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(
|
||||
() => searchParams.get('tab') as CourseLibraryTabs,
|
||||
);
|
||||
const [showReviewAlert, setShowReviewAlert] = useState(false);
|
||||
const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const outOfSyncCount = useMemo(() => sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
|
||||
const {
|
||||
isLoadingPage: isLoadingStudioHome,
|
||||
isFailedLoadingPage: isFailedLoadingStudioHome,
|
||||
librariesV2Enabled,
|
||||
} = useStudioHome();
|
||||
|
||||
const onAlertReview = () => {
|
||||
setTabKey(CourseLibraryTabs.review);
|
||||
};
|
||||
|
||||
const tabChange = useCallback((selectedTab: CourseLibraryTabs) => {
|
||||
setTabKey(selectedTab);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTabKey((prev) => {
|
||||
if (outOfSyncCount > 0) {
|
||||
return CourseLibraryTabs.review;
|
||||
}
|
||||
if (prev) {
|
||||
return prev;
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return CourseLibraryTabs.all;
|
||||
});
|
||||
}, [outOfSyncCount]);
|
||||
|
||||
const renderLibrariesTabContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (libraries?.length === 0) {
|
||||
return <small><FormattedMessage {...messages.homeTabDescriptionEmpty} /></small>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<small><FormattedMessage {...messages.homeTabDescription} /></small>
|
||||
{libraries?.map((library) => (
|
||||
<LibraryCard
|
||||
linkSummary={library}
|
||||
key={library.upstreamContextKey}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, [libraries, isLoading]);
|
||||
|
||||
const renderReviewTabContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (tabKey !== CourseLibraryTabs.review) {
|
||||
return null;
|
||||
}
|
||||
if (!outOfSyncCount || outOfSyncCount === 0) {
|
||||
return (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={CheckCircle} size="xs" />
|
||||
<small>
|
||||
<FormattedMessage {...messages.reviewTabDescriptionEmpty} />
|
||||
</small>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return <ReviewTabContent courseId={courseId} />;
|
||||
}, [outOfSyncCount, isLoading, tabKey]);
|
||||
|
||||
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
|
||||
return (
|
||||
<Alert variant="danger">
|
||||
{intl.formatMessage(messages.librariesV2DisabledError)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="px-4 pt-4 mt-3">
|
||||
<OutOfSyncAlert
|
||||
courseId={courseId}
|
||||
onReview={onAlertReview}
|
||||
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
|
||||
setShowAlert={setShowReviewAlert}
|
||||
/>
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAlertReview}
|
||||
iconBefore={Cached}
|
||||
>
|
||||
{intl.formatMessage(messages.reviewUpdatesBtn)}
|
||||
</Button>
|
||||
)}
|
||||
hideBorder
|
||||
/>
|
||||
<section className="mb-4">
|
||||
<Tabs
|
||||
id="course-library-tabs"
|
||||
activeKey={tabKey}
|
||||
onSelect={tabChange}
|
||||
>
|
||||
<Tab
|
||||
eventKey={CourseLibraryTabs.all}
|
||||
title={intl.formatMessage(messages.homeTabTitle)}
|
||||
className="px-2 mt-3"
|
||||
>
|
||||
{renderLibrariesTabContent()}
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={CourseLibraryTabs.review}
|
||||
title={(
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon src={Loop} />
|
||||
{intl.formatMessage(messages.reviewTabTitle)}
|
||||
</Stack>
|
||||
)}
|
||||
notification={outOfSyncCount}
|
||||
className="px-2 mt-3"
|
||||
>
|
||||
{renderReviewTabContent()}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { Loop } from '@openedx/paragon/icons';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
interface OutOfSyncAlertProps {
|
||||
showAlert: boolean,
|
||||
setShowAlert: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
courseId: string,
|
||||
onDismiss?: () => void;
|
||||
onReview: () => void;
|
||||
}
|
||||
/**
|
||||
* Shows an alert when library components used in the current course were updated and the blocks
|
||||
* in course can be updated. Following are the conditions for displaying the alert.
|
||||
*
|
||||
* * The alert is displayed if components are out of sync.
|
||||
* * If the user clicks on dismiss button, the state is stored in localstorage of user
|
||||
* in this format: outOfSyncCountAlert-${courseId} = <number of out of sync components>.
|
||||
* * If the number of sync components don't change for the course and the user opens outline
|
||||
* in the same browser, they don't see the alert again.
|
||||
* * If the number changes, i.e., if a new component is out of sync or the user updates or ignores
|
||||
* a component, the alert is displayed again.
|
||||
*/
|
||||
export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
|
||||
showAlert,
|
||||
setShowAlert,
|
||||
courseId,
|
||||
onDismiss,
|
||||
onReview,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const outOfSyncCount = data?.reduce((count, lib) => count + lib.readyToSyncCount, 0);
|
||||
const alertKey = `outOfSyncCountAlert-${courseId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (outOfSyncCount === 0) {
|
||||
localStorage.removeItem(alertKey);
|
||||
setShowAlert(false);
|
||||
return;
|
||||
}
|
||||
const dismissedAlert = localStorage.getItem(alertKey);
|
||||
setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount);
|
||||
}, [outOfSyncCount, isLoading, data]);
|
||||
|
||||
const dismissAlert = () => {
|
||||
setShowAlert(false);
|
||||
localStorage.setItem(alertKey, String(outOfSyncCount));
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.outOfSyncCountAlertTitle, { outOfSyncCount })}
|
||||
dismissible
|
||||
show={showAlert}
|
||||
icon={Loop}
|
||||
variant="info"
|
||||
onClose={dismissAlert}
|
||||
actions={[
|
||||
<Button
|
||||
onClick={onReview}
|
||||
>
|
||||
{intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,393 +0,0 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Hyperlink,
|
||||
Icon,
|
||||
Stack,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import {
|
||||
tail, keyBy, orderBy, merge, omitBy,
|
||||
} from 'lodash';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Loop, Warning } from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
import previewChangesMessages from '../course-unit/preview-changes/messages';
|
||||
import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks';
|
||||
import {
|
||||
SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget,
|
||||
} from '../search-manager';
|
||||
import { getItemIcon } from '../generic/block-type-utils';
|
||||
import type { ContentHit } from '../search-manager/data/api';
|
||||
import { SearchSortOption } from '../search-manager/data/api';
|
||||
import Loading from '../generic/Loading';
|
||||
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../course-unit/data/apiHooks';
|
||||
import { PreviewLibraryXBlockChanges, LibraryChangesMessageData } from '../course-unit/preview-changes';
|
||||
import LoadingButton from '../generic/loading-button';
|
||||
import { ToastContext } from '../generic/toast-context';
|
||||
import { useLoadOnScroll } from '../hooks';
|
||||
import DeleteModal from '../generic/delete-modal/DeleteModal';
|
||||
import { PublishableEntityLink } from './data/api';
|
||||
import AlertError from '../generic/alert-error';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
interface BlockCardProps {
|
||||
info: ContentHit;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
|
||||
const intl = useIntl();
|
||||
const componentIcon = getItemIcon(info.blockType);
|
||||
const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
|
||||
|
||||
const getBlockLink = useCallback(() => {
|
||||
let key = info.usageKey;
|
||||
if (breadcrumbs?.length > 1) {
|
||||
key = breadcrumbs[breadcrumbs.length - 1].usageKey || key;
|
||||
}
|
||||
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
|
||||
}, [info]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="my-3 border-light-500 border shadow-none"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<Card.Section
|
||||
className="py-3"
|
||||
>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Stack direction="vertical" gap={1}>
|
||||
<Stack direction="horizontal" gap={1} className="micro text-gray-500">
|
||||
<Icon src={componentIcon} size="xs" />
|
||||
<BlockTypeLabel blockType={info.blockType} />
|
||||
</Stack>
|
||||
<Stack direction="horizontal" className="small" gap={1}>
|
||||
<strong>
|
||||
<Highlight text={info.formatted?.displayName ?? ''} />
|
||||
</strong>
|
||||
</Stack>
|
||||
<Stack direction="horizontal" className="micro" gap={3}>
|
||||
{intl.formatMessage(messages.breadcrumbLabel)}
|
||||
<Hyperlink showLaunchIcon={false} destination={getBlockLink()} target="_blank">
|
||||
<Breadcrumb
|
||||
className="micro text-gray-700 border-bottom"
|
||||
ariaLabel={intl.formatMessage(messages.breadcrumbLabel)}
|
||||
links={breadcrumbs.map((breadcrumb) => ({ label: breadcrumb.displayName }))}
|
||||
spacer={<span className="custom-spacer">/</span>}
|
||||
linkAs="span"
|
||||
/>
|
||||
</Hyperlink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{actions}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ComponentReviewList = ({
|
||||
outOfSyncComponents,
|
||||
onSearchUpdate,
|
||||
}: {
|
||||
outOfSyncComponents: PublishableEntityLink[];
|
||||
onSearchUpdate: () => void;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
|
||||
// ignore changes confirmation modal toggle.
|
||||
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
|
||||
const {
|
||||
hits: downstreamInfo,
|
||||
isLoading: isIndexDataLoading,
|
||||
searchKeywords,
|
||||
searchSortOrder,
|
||||
hasError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useSearchContext() as {
|
||||
hits: ContentHit[];
|
||||
isLoading: boolean;
|
||||
searchKeywords: string;
|
||||
searchSortOrder: SearchSortOption;
|
||||
hasError: boolean;
|
||||
hasNextPage: boolean | undefined,
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
};
|
||||
|
||||
useLoadOnScroll(
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
true,
|
||||
);
|
||||
|
||||
const outOfSyncComponentsByKey = useMemo(
|
||||
() => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
|
||||
[outOfSyncComponents],
|
||||
);
|
||||
const downstreamInfoByKey = useMemo(
|
||||
() => keyBy(downstreamInfo, 'usageKey'),
|
||||
[downstreamInfo],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchKeywords) {
|
||||
onSearchUpdate();
|
||||
}
|
||||
}, [searchKeywords]);
|
||||
|
||||
// Toggle preview changes modal
|
||||
const [isModalOpen, openModal, closeModal] = useToggle(false);
|
||||
const acceptChangesMutation = useAcceptLibraryBlockChanges();
|
||||
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
|
||||
|
||||
const setSeletecdBlockData = (info: ContentHit) => {
|
||||
setBlockData({
|
||||
displayName: info.displayName,
|
||||
downstreamBlockId: info.usageKey,
|
||||
upstreamBlockId: outOfSyncComponentsByKey[info.usageKey].upstreamUsageKey,
|
||||
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
|
||||
isVertical: info.blockType === 'vertical',
|
||||
});
|
||||
};
|
||||
// Show preview changes on review
|
||||
const onReview = useCallback((info: ContentHit) => {
|
||||
setSeletecdBlockData(info);
|
||||
openModal();
|
||||
}, [setSeletecdBlockData, openModal]);
|
||||
|
||||
const onIgnoreClick = useCallback((info: ContentHit) => {
|
||||
setSeletecdBlockData(info);
|
||||
openConfirmModal();
|
||||
}, [setSeletecdBlockData, openConfirmModal]);
|
||||
|
||||
const reloadLinks = useCallback((usageKey: string) => {
|
||||
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
|
||||
queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey));
|
||||
}, [outOfSyncComponentsByKey]);
|
||||
|
||||
const postChange = (accept: boolean) => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!blockData) {
|
||||
return;
|
||||
}
|
||||
reloadLinks(blockData.downstreamBlockId);
|
||||
if (accept) {
|
||||
showToast(intl.formatMessage(
|
||||
messages.updateSingleBlockSuccess,
|
||||
{ name: blockData.displayName },
|
||||
));
|
||||
} else {
|
||||
showToast(intl.formatMessage(
|
||||
messages.ignoreSingleBlockSuccess,
|
||||
{ name: blockData.displayName },
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const updateBlock = useCallback(async (info: ContentHit) => {
|
||||
try {
|
||||
await acceptChangesMutation.mutateAsync(info.usageKey);
|
||||
reloadLinks(info.usageKey);
|
||||
showToast(intl.formatMessage(
|
||||
messages.updateSingleBlockSuccess,
|
||||
{ name: info.displayName },
|
||||
));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ignoreBlock = useCallback(async () => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!blockData) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
|
||||
reloadLinks(blockData.downstreamBlockId);
|
||||
showToast(intl.formatMessage(
|
||||
messages.ignoreSingleBlockSuccess,
|
||||
{ name: blockData.displayName },
|
||||
));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
|
||||
} finally {
|
||||
closeConfirmModal();
|
||||
}
|
||||
}, [blockData]);
|
||||
|
||||
const orderInfo = useMemo(() => {
|
||||
if (searchSortOrder !== SearchSortOption.RECENTLY_MODIFIED) {
|
||||
return downstreamInfo;
|
||||
}
|
||||
if (isIndexDataLoading) {
|
||||
return [];
|
||||
}
|
||||
let merged = merge(downstreamInfoByKey, outOfSyncComponentsByKey);
|
||||
merged = omitBy(merged, (o) => !o.displayName);
|
||||
const ordered = orderBy(Object.values(merged), 'updated', 'desc');
|
||||
return ordered;
|
||||
}, [downstreamInfoByKey, outOfSyncComponentsByKey]);
|
||||
|
||||
if (isIndexDataLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return <AlertError error={intl.formatMessage(messages.genericErrorMessage)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{orderInfo?.map((info) => (
|
||||
<BlockCard
|
||||
key={info.usageKey}
|
||||
info={info}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-primary border-light-300"
|
||||
onClick={() => onReview(info)}
|
||||
iconBefore={Loop}
|
||||
className="mr-2"
|
||||
>
|
||||
{intl.formatMessage(messages.cardReviewContentBtn)}
|
||||
</Button>
|
||||
<span className="border border-dark py-3 ml-4 mr-3" />
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onIgnoreClick(info)}
|
||||
>
|
||||
{intl.formatMessage(messages.cardIgnoreContentBtn)}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.cardUpdateContentBtn)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => updateBlock(info)}
|
||||
className="rounded-0"
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<PreviewLibraryXBlockChanges
|
||||
blockData={blockData}
|
||||
isModalOpen={isModalOpen}
|
||||
closeModal={closeModal}
|
||||
postChange={postChange}
|
||||
alertNode={(
|
||||
<AlertMessage
|
||||
show
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<DeleteModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
close={closeConfirmModal}
|
||||
variant="warning"
|
||||
title={intl.formatMessage(previewChangesMessages.confirmationTitle)}
|
||||
description={intl.formatMessage(previewChangesMessages.confirmationDescription)}
|
||||
onDeleteSubmit={ignoreBlock}
|
||||
btnLabel={intl.formatMessage(previewChangesMessages.confirmationConfirmBtn)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewTabContent = ({ courseId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
data: linkPages,
|
||||
isLoading: isSyncComponentsLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error,
|
||||
} = useEntityLinks({ courseId, readyToSync: true });
|
||||
|
||||
const outOfSyncComponents = useMemo(
|
||||
() => linkPages?.pages?.reduce((links, page) => [...links, ...page.results], []) ?? [],
|
||||
[linkPages],
|
||||
);
|
||||
const downstreamKeys = useMemo(
|
||||
() => outOfSyncComponents?.map(link => link.downstreamUsageKey),
|
||||
[outOfSyncComponents],
|
||||
);
|
||||
|
||||
useLoadOnScroll(
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
true,
|
||||
);
|
||||
|
||||
const onSearchUpdate = () => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const disableSortOptions = [
|
||||
SearchSortOption.RELEVANCE,
|
||||
SearchSortOption.OLDEST,
|
||||
SearchSortOption.NEWEST,
|
||||
SearchSortOption.RECENTLY_PUBLISHED,
|
||||
];
|
||||
|
||||
if (isSyncComponentsLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <AlertError error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContextProvider
|
||||
extraFilter={[`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys?.join('","')}"]`]}
|
||||
skipUrlUpdate
|
||||
skipBlockTypeFetch
|
||||
>
|
||||
<ActionRow>
|
||||
<SearchKeywordsField
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
/>
|
||||
<SearchSortWidget disableOptions={disableSortOptions} />
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
<ComponentReviewList
|
||||
outOfSyncComponents={outOfSyncComponents}
|
||||
onSearchUpdate={onSearchUpdate}
|
||||
/>
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewTabContent;
|
||||
@@ -1,374 +0,0 @@
|
||||
[
|
||||
{
|
||||
"filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef\"]",
|
||||
"result": {
|
||||
"hits": [
|
||||
{
|
||||
"display_name": "Dropdown",
|
||||
"description": "asfd sdaf afd",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
|
||||
"block_type": "problem",
|
||||
"_formatted": {
|
||||
"display_name": "Dropdown",
|
||||
"description": "asfd sdaf afd",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
|
||||
"block_type": "problem"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "HTML 12",
|
||||
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
|
||||
"block_type": "html",
|
||||
"_formatted": {
|
||||
"display_name": "HTML 12",
|
||||
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks…",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
|
||||
"block_type": "html"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Text",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
|
||||
"block_type": "html",
|
||||
"_formatted": {
|
||||
"display_name": "Text",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
|
||||
"block_type": "html"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Text",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
|
||||
"block_type": "html",
|
||||
"_formatted": {
|
||||
"display_name": "Text",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
|
||||
"block_type": "html"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 4,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83\"]",
|
||||
"result": {
|
||||
"hits": [
|
||||
{
|
||||
"display_name": "Edited title",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
|
||||
"block_type": "video",
|
||||
"_formatted": {
|
||||
"display_name": "Edited title",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
|
||||
"block_type": "video"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Text 1",
|
||||
"description": " 8¹⁺² 3² Accept change now!d",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
|
||||
"block_type": "html",
|
||||
"_formatted": {
|
||||
"display_name": "Text 1",
|
||||
"description": " 8¹⁺² 3² Accept change now!d",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
|
||||
"block_type": "html"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Text 23",
|
||||
"description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
|
||||
"block_type": "html",
|
||||
"_formatted": {
|
||||
"display_name": "Text 23",
|
||||
"description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Learn UNIXY"
|
||||
},
|
||||
{
|
||||
"display_name": "Section",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
|
||||
"block_type": "html"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 3,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"id": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"def_key": null,
|
||||
"block_type": "problem",
|
||||
"display_name": "Dropdown 123",
|
||||
"last_published": "2025-02-19T13:58:49Z",
|
||||
"published_by": "edx",
|
||||
"last_draft_created": "2025-02-19T13:58:48Z",
|
||||
"last_draft_created_by": null,
|
||||
"has_unpublished_changes": false,
|
||||
"created": "2024-10-30T10:48:35Z",
|
||||
"modified": "2025-02-19T13:58:48Z",
|
||||
"collections": [
|
||||
{
|
||||
"key": "second-collection",
|
||||
"title": "Second collection"
|
||||
},
|
||||
{
|
||||
"key": "test-collection-2",
|
||||
"title": "Test collection 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[
|
||||
{
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"readyToSyncCount": 5,
|
||||
"totalCount": 14
|
||||
},
|
||||
{
|
||||
"upstreamContextTitle": "CS problems 2",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB2",
|
||||
"readyToSyncCount": 0,
|
||||
"totalCount": 21
|
||||
},
|
||||
{
|
||||
"upstreamContextTitle": "CS problems",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB",
|
||||
"readyToSyncCount": 0,
|
||||
"totalCount": 3
|
||||
}
|
||||
]
|
||||
@@ -1,376 +0,0 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"indexUid": "tutor_studio_content",
|
||||
"hits": [
|
||||
{
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "problem3",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "problem3",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "problem6",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "problem6",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "210e356cfa304b0aac591af53f6a6ae0",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "210e356cfa304b0aac591af53f6a6ae0",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
|
||||
},
|
||||
{
|
||||
"display_name": "Problem Bank",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "HTML 1",
|
||||
"block_id": "257e68e3386d4a8f8739d45b67e76a9b",
|
||||
"content": {
|
||||
"html_content": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1"
|
||||
},
|
||||
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
|
||||
"block_type": "html",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "HTML 1",
|
||||
"block_id": "257e68e3386d4a8f8739d45b67e76a9b",
|
||||
"content": {
|
||||
"html_content": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1"
|
||||
},
|
||||
"description": "A step beyond the simplicity of the WYSIWYG editor is…",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
|
||||
"block_type": "html",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "a4455860b03647219ff8b01cde49cf37",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "Dropdown",
|
||||
"block_id": "a4455860b03647219ff8b01cde49cf37",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "asfd sdaf afd"
|
||||
},
|
||||
"description": "asfd sdaf afd",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Module 1: Dive into the Open edX® platform!",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit",
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
|
||||
"block_type": "problem",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 8,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"count": 7,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"num_pages": 1,
|
||||
"current_page": 1,
|
||||
"results": [
|
||||
{
|
||||
"id": 875,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
{
|
||||
"id": 876,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
{
|
||||
"id": 884,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 26,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 16,
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
{
|
||||
"id": 889,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
{
|
||||
"id": 890,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
import mockLinksResult from '../__mocks__/publishableEntityLinks.json';
|
||||
import mockSummaryResult from '../__mocks__/linkCourseSummary.json';
|
||||
import mockLinkDetailsFromIndex from '../__mocks__/linkDetailsFromIndex.json';
|
||||
import mockLibBlockMetadata from '../__mocks__/libBlockMetadata.json';
|
||||
import { createAxiosError } from '../../testUtils';
|
||||
import * as api from './api';
|
||||
import * as libApi from '../../library-authoring/data/api';
|
||||
|
||||
/**
|
||||
* Mock for `getEntityLinks()`
|
||||
*
|
||||
* This mock returns a fixed response for the downstreamContextKey.
|
||||
*/
|
||||
export async function mockGetEntityLinks(
|
||||
downstreamContextKey?: string,
|
||||
readyToSync?: boolean,
|
||||
): ReturnType<typeof api.getEntityLinks> {
|
||||
switch (downstreamContextKey) {
|
||||
case mockGetEntityLinks.invalidCourseKey:
|
||||
throw createAxiosError({
|
||||
code: 404,
|
||||
message: 'Not found.',
|
||||
path: api.getEntityLinksByDownstreamContextUrl(),
|
||||
});
|
||||
case mockGetEntityLinks.courseKeyLoading:
|
||||
return new Promise(() => {});
|
||||
case mockGetEntityLinks.courseKeyEmpty:
|
||||
return Promise.resolve({
|
||||
next: null,
|
||||
previous: null,
|
||||
nextPageNum: null,
|
||||
previousPageNum: null,
|
||||
count: 0,
|
||||
numPages: 0,
|
||||
currentPage: 0,
|
||||
results: [],
|
||||
});
|
||||
default: {
|
||||
const { response } = mockGetEntityLinks;
|
||||
if (readyToSync !== undefined) {
|
||||
response.results = response.results.filter((o) => o.readyToSync === readyToSync);
|
||||
response.count = response.results.length;
|
||||
}
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey;
|
||||
mockGetEntityLinks.invalidCourseKey = 'course_key_error';
|
||||
mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading';
|
||||
mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty';
|
||||
mockGetEntityLinks.response = mockLinksResult;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockGetEntityLinks.applyMock = () => {
|
||||
jest.spyOn(api, 'getEntityLinks').mockImplementation(mockGetEntityLinks);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock for `getEntityLinksSummaryByDownstreamContext()`
|
||||
*
|
||||
* This mock returns a fixed response for the downstreamContextKey.
|
||||
*/
|
||||
export async function mockGetEntityLinksSummaryByDownstreamContext(
|
||||
courseId?: string,
|
||||
): ReturnType<typeof api.getEntityLinksSummaryByDownstreamContext> {
|
||||
switch (courseId) {
|
||||
case mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey:
|
||||
throw createAxiosError({
|
||||
code: 404,
|
||||
message: 'Not found.',
|
||||
path: api.getEntityLinksByDownstreamContextUrl(),
|
||||
});
|
||||
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading:
|
||||
return new Promise(() => {});
|
||||
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty:
|
||||
return Promise.resolve([]);
|
||||
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate:
|
||||
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response.filter(
|
||||
(o: { readyToSyncCount: number }) => o.readyToSyncCount === 0,
|
||||
));
|
||||
default:
|
||||
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response);
|
||||
}
|
||||
}
|
||||
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey;
|
||||
mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error';
|
||||
mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading';
|
||||
mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';
|
||||
mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate = 'courseKeyUpToDate';
|
||||
mockGetEntityLinksSummaryByDownstreamContext.response = mockSummaryResult;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockGetEntityLinksSummaryByDownstreamContext.applyMock = () => {
|
||||
jest.spyOn(api, 'getEntityLinksSummaryByDownstreamContext').mockImplementation(mockGetEntityLinksSummaryByDownstreamContext);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock for multi-search from meilisearch index for link details.
|
||||
*/
|
||||
export async function mockFetchIndexDocuments() {
|
||||
return mockLinkDetailsFromIndex;
|
||||
}
|
||||
mockFetchIndexDocuments.applyMock = () => {
|
||||
fetchMock.post(
|
||||
'http://mock.meilisearch.local/multi-search',
|
||||
mockFetchIndexDocuments,
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock for library block metadata
|
||||
*/
|
||||
export async function mockUseLibBlockMetadata() {
|
||||
return mockLibBlockMetadata;
|
||||
}
|
||||
mockUseLibBlockMetadata.applyMock = () => {
|
||||
jest.spyOn(libApi, 'getLibraryBlockMetadata').mockImplementation(mockUseLibBlockMetadata);
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`;
|
||||
|
||||
export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`;
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
nextPageNum: number | null;
|
||||
previousPageNum: number | null;
|
||||
count: number;
|
||||
numPages: number;
|
||||
currentPage: number;
|
||||
results: T,
|
||||
}
|
||||
|
||||
export interface PublishableEntityLink {
|
||||
id: number;
|
||||
upstreamUsageKey: string;
|
||||
upstreamContextKey: string;
|
||||
upstreamContextTitle: string;
|
||||
upstreamVersion: number;
|
||||
downstreamUsageKey: string;
|
||||
downstreamContextKey: string;
|
||||
versionSynced: number;
|
||||
versionDeclined: number | null;
|
||||
created: string;
|
||||
updated: string;
|
||||
readyToSync: boolean;
|
||||
}
|
||||
|
||||
export interface PublishableEntityLinkSummary {
|
||||
upstreamContextKey: string;
|
||||
upstreamContextTitle: string;
|
||||
readyToSyncCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const getEntityLinks = async (
|
||||
downstreamContextKey?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
pageParam?: number,
|
||||
pageSize?: number,
|
||||
): Promise<PaginatedData<PublishableEntityLink[]>> => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getEntityLinksByDownstreamContextUrl(), {
|
||||
params: {
|
||||
course_id: downstreamContextKey,
|
||||
ready_to_sync: readyToSync,
|
||||
upstream_usage_key: upstreamUsageKey,
|
||||
page_size: pageSize,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
return camelCaseObject(data);
|
||||
};
|
||||
|
||||
export const getUnpaginatedEntityLinks = async (
|
||||
downstreamContextKey?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
): Promise<PublishableEntityLink[]> => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getEntityLinksByDownstreamContextUrl(), {
|
||||
params: {
|
||||
course_id: downstreamContextKey,
|
||||
ready_to_sync: readyToSync,
|
||||
upstream_usage_key: upstreamUsageKey,
|
||||
no_page: true,
|
||||
},
|
||||
});
|
||||
return camelCaseObject(data);
|
||||
};
|
||||
|
||||
export const getEntityLinksSummaryByDownstreamContext = async (
|
||||
downstreamContextKey: string,
|
||||
): Promise<PublishableEntityLinkSummary[]> => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getEntityLinksSummaryByDownstreamContextUrl(downstreamContextKey));
|
||||
return camelCaseObject(data);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { getEntityLinksByDownstreamContextUrl } from './api';
|
||||
import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks';
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('course libraries api hooks', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('should return paginated links for course', async () => {
|
||||
const courseId = 'course-v1:some+key';
|
||||
const url = getEntityLinksByDownstreamContextUrl();
|
||||
const expectedResult = {
|
||||
next: null, results: [], previous: null, total: 0,
|
||||
};
|
||||
axiosMock.onGet(url).reply(200, expectedResult);
|
||||
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBeFalsy();
|
||||
});
|
||||
expect(result.current.data?.pages).toEqual([expectedResult]);
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
});
|
||||
|
||||
it('should return links for course', async () => {
|
||||
const courseId = 'course-v1:some+key';
|
||||
const url = getEntityLinksByDownstreamContextUrl();
|
||||
axiosMock.onGet(url).reply(200, []);
|
||||
const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBeFalsy();
|
||||
});
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
expect(axiosMock.history.get[0].params).toEqual({
|
||||
course_id: courseId,
|
||||
ready_to_sync: undefined,
|
||||
upstream_usage_key: undefined,
|
||||
no_page: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api';
|
||||
|
||||
export const courseLibrariesQueryKeys = {
|
||||
all: ['courseLibraries'],
|
||||
courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId],
|
||||
courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: {
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
pageSize?: number,
|
||||
}) => {
|
||||
const key: Array<string | boolean | number> = [...courseLibrariesQueryKeys.all];
|
||||
if (courseId !== undefined) {
|
||||
key.push(courseId);
|
||||
}
|
||||
if (readyToSync !== undefined) {
|
||||
key.push(readyToSync);
|
||||
}
|
||||
if (upstreamUsageKey !== undefined) {
|
||||
key.push(upstreamUsageKey);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch publishable entity links by course key.
|
||||
* (That is, get a list of the library components used in the given course.)
|
||||
*/
|
||||
export const useEntityLinks = ({
|
||||
courseId, readyToSync, upstreamUsageKey, pageSize,
|
||||
}: {
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
pageSize?: number
|
||||
}) => (
|
||||
useInfiniteQuery({
|
||||
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamUsageKey,
|
||||
}),
|
||||
queryFn: ({ pageParam }) => getEntityLinks(
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamUsageKey,
|
||||
pageParam,
|
||||
pageSize,
|
||||
),
|
||||
getNextPageParam: (lastPage) => lastPage.nextPageNum,
|
||||
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to fetch unpaginated list of publishable entity links by course key.
|
||||
*/
|
||||
export const useUnpaginatedEntityLinks = ({
|
||||
courseId, readyToSync, upstreamUsageKey,
|
||||
}: {
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
}) => (
|
||||
useQuery({
|
||||
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamUsageKey,
|
||||
}),
|
||||
queryFn: () => getUnpaginatedEntityLinks(
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamUsageKey,
|
||||
),
|
||||
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to fetch publishable entity links summary by course key.
|
||||
*/
|
||||
export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => (
|
||||
useQuery({
|
||||
queryKey: courseLibrariesQueryKeys.courseLibrariesSummary(courseId),
|
||||
queryFn: () => getEntityLinksSummaryByDownstreamContext(courseId!),
|
||||
enabled: courseId !== undefined,
|
||||
})
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export { CourseLibraries } from './CourseLibraries';
|
||||
@@ -1,126 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.course-libraries.header.title',
|
||||
defaultMessage: 'Libraries',
|
||||
description: 'Title for page',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.course-libraries.header.subtitle',
|
||||
defaultMessage: 'Content',
|
||||
description: 'Subtitle for page',
|
||||
},
|
||||
homeTabTitle: {
|
||||
id: 'course-authoring.course-libraries.tab.home.title',
|
||||
defaultMessage: 'Libraries',
|
||||
description: 'Tab title for home tab',
|
||||
},
|
||||
homeTabDescription: {
|
||||
id: 'course-authoring.course-libraries.tab.home.description',
|
||||
defaultMessage: 'Your course contains content from these libraries.',
|
||||
description: 'Description text for home tab',
|
||||
},
|
||||
homeTabDescriptionEmpty: {
|
||||
id: 'course-authoring.course-libraries.tab.home.description-no-links',
|
||||
defaultMessage: 'This course does not use any content from libraries.',
|
||||
description: 'Description text for home tab',
|
||||
},
|
||||
reviewTabTitle: {
|
||||
id: 'course-authoring.course-libraries.tab.review.title',
|
||||
defaultMessage: 'Review Content Updates',
|
||||
description: 'Tab title for review tab',
|
||||
},
|
||||
reviewTabDescriptionEmpty: {
|
||||
id: 'course-authoring.course-libraries.tab.home.description-no-links',
|
||||
defaultMessage: 'All components are up to date',
|
||||
description: 'Description text for home tab',
|
||||
},
|
||||
breadcrumbLabel: {
|
||||
id: 'course-authoring.course-libraries.downstream-block.breadcrumb.label',
|
||||
defaultMessage: 'Location:',
|
||||
description: 'label for breadcrumb in component cards in course libraries page.',
|
||||
},
|
||||
totalComponentLabel: {
|
||||
id: 'course-authoring.course-libraries.libcard.total-component.label',
|
||||
defaultMessage: '{totalComponents, plural, one {# component} other {# components}} applied',
|
||||
description: 'Prints total components applied from library',
|
||||
},
|
||||
allUptodateLabel: {
|
||||
id: 'course-authoring.course-libraries.libcard.up-to-date.label',
|
||||
defaultMessage: 'All components up to date',
|
||||
description: 'Shown if all components under a library are up to date',
|
||||
},
|
||||
outOfSyncCountLabel: {
|
||||
id: 'course-authoring.course-libraries.libcard.out-of-sync.label',
|
||||
defaultMessage: '{outOfSyncCount, plural, one {# component} other {# components}} out of sync',
|
||||
description: 'Prints number of components out of sync from library',
|
||||
},
|
||||
outOfSyncCountAlertTitle: {
|
||||
id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.title',
|
||||
defaultMessage: '{outOfSyncCount, plural, one {# library component is} other {# library components are}} out of sync. Review updates to accept or ignore changes',
|
||||
description: 'Alert message shown when library components are out of sync',
|
||||
},
|
||||
reviewUpdatesBtn: {
|
||||
id: 'course-authoring.course-libraries.libcard.review-updates.btn.text',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Action button to review updates',
|
||||
},
|
||||
outOfSyncCountAlertReviewBtn: {
|
||||
id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.review-btn-text',
|
||||
defaultMessage: 'Review',
|
||||
description: 'Alert review button text',
|
||||
},
|
||||
librariesV2DisabledError: {
|
||||
id: 'course-authoring.course-libraries.alert.error.libraries.v2.disabled',
|
||||
defaultMessage: 'This page cannot be shown: Libraries v2 are disabled.',
|
||||
description: 'Error message shown to users when trying to load a libraries V2 page while libraries v2 are disabled.',
|
||||
},
|
||||
cardReviewContentBtn: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.review-btn-text',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Card review button for component in review tab',
|
||||
},
|
||||
cardUpdateContentBtn: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.update-btn-text',
|
||||
defaultMessage: 'Update',
|
||||
description: 'Card update button for component in review tab',
|
||||
},
|
||||
cardIgnoreContentBtn: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.ignore-btn-text',
|
||||
defaultMessage: 'Ignore',
|
||||
description: 'Card ignore button for component in review tab',
|
||||
},
|
||||
updateSingleBlockSuccess: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.update-success-toast',
|
||||
defaultMessage: 'Success! "{name}" is updated',
|
||||
description: 'Success toast message when a component is updated.',
|
||||
},
|
||||
ignoreSingleBlockSuccess: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.ignore-success-toast',
|
||||
defaultMessage: '"{name}" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
description: 'Success toast message when a component update is ignored.',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'course-authoring.course-libraries.review-tab.search.placeholder',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Search text box in review tab placeholder text',
|
||||
},
|
||||
brokenLinkTooltip: {
|
||||
id: 'course-authoring.course-libraries.home-tab.broken-link.tooltip',
|
||||
defaultMessage: 'Sourced from a library - but the upstream link is broken/invalid.',
|
||||
description: 'Tooltip text describing broken link in component listing.',
|
||||
},
|
||||
genericErrorMessage: {
|
||||
id: 'course-authoring.course-libraries.home-tab.error.message',
|
||||
defaultMessage: 'Something went wrong! Could not fetch results.',
|
||||
description: 'Generic error message displayed when fetching link data fails.',
|
||||
},
|
||||
olderVersionPreviewAlert: {
|
||||
id: 'course-authoring.course-libraries.reviw-tab.preview.old-version-alert',
|
||||
defaultMessage: 'The old version preview is the previous library version',
|
||||
description: 'Alert message stating that older version in preview is of library block',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CourseAuthoringOutlineSidebarSlot } from '../plugin-slots/CourseAuthoringOutlineSidebarSlot';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
@@ -36,6 +35,8 @@ import AlertMessage from '../generic/alert-message';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { getCurrentItem, getProctoredExamsFlag } from './data/selectors';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import HeaderNavigations from './header-navigations/HeaderNavigations';
|
||||
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
|
||||
import SectionCard from './section-card/SectionCard';
|
||||
@@ -45,16 +46,15 @@ import HighlightsModal from './highlights-modal/HighlightsModal';
|
||||
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
|
||||
import PublishModal from './publish-modal/PublishModal';
|
||||
import PageAlerts from './page-alerts/PageAlerts';
|
||||
import DraggableList from './drag-helper/DraggableList';
|
||||
import DraggableList from '../generic/drag-helper/DraggableList';
|
||||
import {
|
||||
canMoveSection,
|
||||
possibleUnitMoves,
|
||||
possibleSubsectionMoves,
|
||||
} from './drag-helper/utils';
|
||||
} from '../generic/drag-helper/utils';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
import CourseOutlineHeaderActionsSlot from '../plugin-slots/CourseOutlineHeaderActionsSlot';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
@@ -68,7 +68,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
sectionsList,
|
||||
isCustomRelativeDatesActive,
|
||||
isLoading,
|
||||
isLoadingDenied,
|
||||
isReIndexShow,
|
||||
showSuccessAlert,
|
||||
isSectionsExpanded,
|
||||
@@ -108,6 +107,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextFeedbackUrl,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
deprecatedBlocksInfo,
|
||||
proctoringErrors,
|
||||
@@ -232,26 +232,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingDenied) {
|
||||
return (
|
||||
<Container size="xl" className="px-4 mt-4">
|
||||
<PageAlerts
|
||||
courseId={courseId}
|
||||
notificationDismissUrl={notificationDismissUrl}
|
||||
handleDismissNotification={handleDismissNotification}
|
||||
discussionsSettings={discussionsSettings}
|
||||
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
|
||||
deprecatedBlocksInfo={deprecatedBlocksInfo}
|
||||
proctoringErrors={proctoringErrors}
|
||||
mfeProctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||
advanceSettingsUrl={advanceSettingsUrl}
|
||||
savingStatus={savingStatus}
|
||||
errors={errors}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -264,6 +244,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
notificationDismissUrl={notificationDismissUrl}
|
||||
handleDismissNotification={handleDismissNotification}
|
||||
discussionsSettings={discussionsSettings}
|
||||
discussionsIncontextFeedbackUrl={discussionsIncontextFeedbackUrl}
|
||||
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
|
||||
deprecatedBlocksInfo={deprecatedBlocksInfo}
|
||||
proctoringErrors={proctoringErrors}
|
||||
@@ -291,7 +272,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={(
|
||||
<CourseOutlineHeaderActionsSlot
|
||||
<HeaderNavigations
|
||||
isReIndexShow={isReIndexShow}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
headerNavigationsActions={headerNavigationsActions}
|
||||
@@ -299,7 +280,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
hasSections={Boolean(sectionsList.length)}
|
||||
courseActions={courseActions}
|
||||
errors={errors}
|
||||
sections={sections}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -453,7 +433,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<CourseAuthoringOutlineSidebarSlot courseId={courseId} />
|
||||
<OutlineSideBar courseId={courseId} />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
<EnableHighlightsModal
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "./header-navigations/HeaderNavigations";
|
||||
@import "./status-bar/StatusBar";
|
||||
@import "./section-card/SectionCard";
|
||||
@import "./subsection-card/SubsectionCard";
|
||||
@@ -7,4 +8,3 @@
|
||||
@import "./highlights-modal/HighlightsModal";
|
||||
@import "./publish-modal/PublishModal";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./drag-helper/SortableItem";
|
||||
|
||||
@@ -11,7 +11,6 @@ import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
@@ -59,7 +58,7 @@ import {
|
||||
moveUnitOver,
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
} from './drag-helper/utils';
|
||||
} from '../generic/drag-helper/utils';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -68,6 +67,13 @@ const courseId = '123';
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -591,10 +597,10 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||
render(<RootWrapper />);
|
||||
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
@@ -604,7 +610,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
const checkDeleteBtn = async (item, element, elementName) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||
expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||
});
|
||||
|
||||
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
|
||||
@@ -613,11 +619,11 @@ describe('<CourseOutline />', () => {
|
||||
fireEvent.click(menu);
|
||||
const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`);
|
||||
fireEvent.click(deleteButton);
|
||||
const confirmButton = await screen.findByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(confirmButton);
|
||||
const confirmButton = await findByTestId('delete-confirm-button');
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||
expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2167,7 +2173,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, courseSectionMock);
|
||||
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await userEvent.click(expandBtn);
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
@@ -2248,14 +2254,9 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
it('should show toats on export tags', async () => {
|
||||
const expectedResponse = 'this is a test';
|
||||
|
||||
// Delay to ensure we see "Please wait."
|
||||
// Without the delay the success message renders too quickly
|
||||
const delayedResponse = axiosMock
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.withDelayInMs(500);
|
||||
delayedResponse(200, expectedResponse);
|
||||
|
||||
.reply(200, expectedResponse);
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
@@ -2263,43 +2264,25 @@ describe('<CourseOutline />', () => {
|
||||
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
|
||||
window.URL.revokeObjectURL = jest.fn();
|
||||
render(<RootWrapper />);
|
||||
await screen.findByText('Please wait. Creating export file for course tags...');
|
||||
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
|
||||
|
||||
const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
|
||||
expect(expectedRequest.length).toBe(1);
|
||||
|
||||
await screen.findByText('Course tags exported successfully');
|
||||
expect(await screen.findByText('Course tags exported successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show toast on export tags error', async () => {
|
||||
// Delay to ensure we see "Please wait."
|
||||
// Without the delay the error renders too quickly
|
||||
const delayedResponse = axiosMock
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.withDelayInMs(500);
|
||||
delayedResponse(404);
|
||||
|
||||
.reply(404);
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
await screen.findByText('Please wait. Creating export file for course tags...');
|
||||
await screen.findByText('An error has occurred creating the file');
|
||||
});
|
||||
|
||||
it('sets status to DENIED when API responds with 403', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(403);
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('redux-provider')).toBeInTheDocument();
|
||||
const { outlineIndexLoadingStatus } = store.getState().courseOutline.loadingStatus;
|
||||
expect(outlineIndexLoadingStatus).toEqual(RequestStatus.DENIED);
|
||||
});
|
||||
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('An error has occurred creating the file')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ module.exports = {
|
||||
highlightsEnabledForMessaging: false,
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
enableProctoredExams: true,
|
||||
createZendeskTickets: true,
|
||||
enableTimedExams: true,
|
||||
@@ -128,7 +128,7 @@ module.exports = {
|
||||
],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
@@ -517,7 +517,7 @@ module.exports = {
|
||||
],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
@@ -1837,7 +1837,7 @@ module.exports = {
|
||||
highlights: [],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
@@ -2787,7 +2787,7 @@ module.exports = {
|
||||
highlights: [],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
@@ -3044,6 +3044,7 @@ module.exports = {
|
||||
blocks: [],
|
||||
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
|
||||
},
|
||||
discussionsIncontextFeedbackUrl: '',
|
||||
discussionsIncontextLearnmoreUrl: '',
|
||||
initialState: {
|
||||
expandedLocators: [
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
blocks: [],
|
||||
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
|
||||
},
|
||||
discussionsIncontextFeedbackUrl: '',
|
||||
discussionsIncontextLearnmoreUrl: '',
|
||||
initialState: {
|
||||
expandedLocators: [
|
||||
|
||||
@@ -55,7 +55,7 @@ module.exports = {
|
||||
highlights: [],
|
||||
highlights_enabled: true,
|
||||
highlights_preview_only: false,
|
||||
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
|
||||
highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
child_info: {
|
||||
category: 'sequential',
|
||||
display_name: 'Subsection',
|
||||
|
||||
@@ -54,7 +54,6 @@ const CardHeader = ({
|
||||
discussionEnabled,
|
||||
discussionsSettings,
|
||||
parentInfo,
|
||||
extraActionsComponent,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -146,7 +145,6 @@ const CardHeader = ({
|
||||
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
|
||||
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
|
||||
)}
|
||||
{extraActionsComponent}
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="item-card-header__menu"
|
||||
@@ -254,7 +252,6 @@ CardHeader.defaultProps = {
|
||||
discussionsSettings: {},
|
||||
parentInfo: {},
|
||||
cardId: '',
|
||||
extraActionsComponent: null,
|
||||
};
|
||||
|
||||
CardHeader.propTypes = {
|
||||
@@ -298,9 +295,6 @@ CardHeader.propTypes = {
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}),
|
||||
// An optional component that is rendered before the dropdown. This is used by the Subsection
|
||||
// and Unit card components to render their plugin slots.
|
||||
extraActionsComponent: PropTypes.node,
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.item-card-header__title-btn {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
flex: 1 1 0%;
|
||||
width: fit-content;
|
||||
height: 1.5rem;
|
||||
margin-right: .25rem;
|
||||
background: transparent;
|
||||
@@ -15,7 +15,6 @@
|
||||
.item-card-edit-icon {
|
||||
opacity: 0;
|
||||
transition: opacity .3s linear;
|
||||
margin-right: .5rem;
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
|
||||
@@ -87,5 +87,4 @@ export const API_ERROR_TYPES = /** @type {const} */ ({
|
||||
networkError: 'networkError',
|
||||
serverError: 'serverError',
|
||||
unknown: 'unknown',
|
||||
forbidden: 'forbidden',
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/
|
||||
* @property {string} courseReleaseDate
|
||||
* @property {Object} courseStructure
|
||||
* @property {Object} deprecatedBlocksInfo
|
||||
* @property {string} discussionsIncontextFeedbackUrl
|
||||
* @property {string} discussionsIncontextLearnmoreUrl
|
||||
* @property {Object} initialState
|
||||
* @property {Object} initialUserClipboard
|
||||
|
||||
@@ -104,7 +104,7 @@ const slice = createSlice({
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarSelfPacedSuccess: (state, { payload }) => {
|
||||
fetchStatusBarSelPacedSuccess: (state, { payload }) => {
|
||||
state.statusBarData.isSelfPaced = payload.isSelfPaced;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
@@ -206,7 +206,7 @@ export const {
|
||||
updateStatusBar,
|
||||
updateCourseActions,
|
||||
fetchStatusBarChecklistSuccess,
|
||||
fetchStatusBarSelfPacedSuccess,
|
||||
fetchStatusBarSelPacedSuccess,
|
||||
updateFetchSectionLoadingStatus,
|
||||
updateCourseLaunchQueryStatus,
|
||||
updateSavingStatus,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getCourseBestPracticesChecklist,
|
||||
getCourseLaunchChecklist,
|
||||
} from '../utils/getChecklistForStatusBar';
|
||||
import { getErrorDetails } from '../utils/getErrorDetails';
|
||||
import {
|
||||
addNewCourseItem,
|
||||
deleteCourseItem,
|
||||
@@ -41,7 +40,7 @@ import {
|
||||
updateStatusBar,
|
||||
updateCourseActions,
|
||||
fetchStatusBarChecklistSuccess,
|
||||
fetchStatusBarSelfPacedSuccess,
|
||||
fetchStatusBarSelPacedSuccess,
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
updateFetchSectionLoadingStatus,
|
||||
@@ -54,6 +53,24 @@ import {
|
||||
updateCourseLaunchQueryStatus,
|
||||
} from './slice';
|
||||
|
||||
const getErrorDetails = (error, dismissible = true) => {
|
||||
const errorInfo = { dismissible };
|
||||
if (error.response?.data) {
|
||||
const { data } = error.response;
|
||||
if ((typeof data === 'string' && !data.includes('</html>')) || typeof data === 'object') {
|
||||
errorInfo.data = JSON.stringify(data);
|
||||
}
|
||||
errorInfo.status = error.response.status;
|
||||
errorInfo.type = API_ERROR_TYPES.serverError;
|
||||
} else if (error.request) {
|
||||
errorInfo.type = API_ERROR_TYPES.networkError;
|
||||
} else {
|
||||
errorInfo.type = API_ERROR_TYPES.unknown;
|
||||
errorInfo.data = error.message;
|
||||
}
|
||||
return errorInfo;
|
||||
};
|
||||
|
||||
export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
@@ -80,16 +97,10 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateOutlineIndexLoadingStatus({
|
||||
status: RequestStatus.DENIED,
|
||||
}));
|
||||
} else {
|
||||
dispatch(updateOutlineIndexLoadingStatus({
|
||||
status: RequestStatus.FAILED,
|
||||
errors: getErrorDetails(error, false),
|
||||
}));
|
||||
}
|
||||
dispatch(updateOutlineIndexLoadingStatus({
|
||||
status: RequestStatus.FAILED,
|
||||
errors: getErrorDetails(error, false),
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -106,7 +117,7 @@ export function fetchCourseLaunchQuery({
|
||||
const data = await getCourseLaunch({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
});
|
||||
dispatch(fetchStatusBarSelfPacedSuccess({ isSelfPaced: data.isSelfPaced }));
|
||||
dispatch(fetchStatusBarSelPacedSuccess({ isSelfPaced: data.isSelfPaced }));
|
||||
dispatch(fetchStatusBarChecklistSuccess(getCourseLaunchChecklist(data)));
|
||||
|
||||
dispatch(updateCourseLaunchQueryStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
|
||||
@@ -25,7 +25,7 @@ const HeaderNavigations = ({
|
||||
} = headerNavigationsActions;
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="header-navigations ml-auto">
|
||||
{courseActions.childAddable && (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
@@ -90,7 +90,7 @@ const HeaderNavigations = ({
|
||||
{intl.formatMessage(messages.viewLiveButton)}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.header-navigations {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||
import { getWaffleFlags } from '../data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
@@ -58,7 +57,6 @@ import {
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const waffleFlags = useSelector(getWaffleFlags);
|
||||
|
||||
const {
|
||||
reindexLink,
|
||||
@@ -66,6 +64,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
lmsLink,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextFeedbackUrl,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
deprecatedBlocksInfo,
|
||||
proctoringErrors,
|
||||
@@ -109,7 +108,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
};
|
||||
|
||||
const getUnitUrl = (locator) => {
|
||||
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
|
||||
if (getConfig().ENABLE_UNIT_PAGE === 'true') {
|
||||
return `/course/${courseId}/container/${locator}`;
|
||||
}
|
||||
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
|
||||
@@ -117,7 +116,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const openUnitPage = (locator) => {
|
||||
const url = getUnitUrl(locator);
|
||||
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
|
||||
if (getConfig().ENABLE_UNIT_PAGE === 'true') {
|
||||
navigate(url);
|
||||
} else {
|
||||
window.location.assign(url);
|
||||
@@ -237,7 +236,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
};
|
||||
|
||||
const handleDismissNotification = () => {
|
||||
dispatch(dismissNotificationQuery(`${getConfig().STUDIO_BASE_URL}${notificationDismissUrl}`));
|
||||
dispatch(dismissNotificationQuery(notificationDismissUrl));
|
||||
};
|
||||
|
||||
const handleSectionDragAndDrop = (
|
||||
@@ -297,7 +296,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
sectionsList,
|
||||
isCustomRelativeDatesActive,
|
||||
isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS,
|
||||
isLoadingDenied: outlineIndexLoadingStatus === RequestStatus.DENIED,
|
||||
isReIndexShow: Boolean(reindexLink),
|
||||
showSuccessAlert,
|
||||
isDisabledReindexButton,
|
||||
@@ -340,6 +338,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextFeedbackUrl,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
deprecatedBlocksInfo,
|
||||
proctoringErrors,
|
||||
@@ -354,4 +353,5 @@ const useCourseOutline = ({ courseId }) => {
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { useCourseOutline };
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CourseOutline } from './CourseOutline';
|
||||
|
||||
@@ -26,6 +26,7 @@ const OutlineSideBar = ({ courseId }) => {
|
||||
|
||||
return (
|
||||
<HelpSidebar
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
showOtherSettings={false}
|
||||
className="outline-sidebar mt-4"
|
||||
|
||||
@@ -65,4 +65,5 @@ const getFormattedSidebarMessages = (docsLinks, intl) => {
|
||||
];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { getFormattedSidebarMessages };
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import {
|
||||
Alert, Button, Hyperlink, Truncate,
|
||||
} from '@openedx/paragon';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
@@ -24,13 +24,13 @@ import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { getPasteFileNotices } from '../data/selectors';
|
||||
import { dismissError, removePasteFileNotices } from '../data/slice';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
|
||||
const PageAlerts = ({
|
||||
courseId,
|
||||
notificationDismissUrl,
|
||||
handleDismissNotification,
|
||||
discussionsSettings,
|
||||
discussionsIncontextFeedbackUrl,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
deprecatedBlocksInfo,
|
||||
proctoringErrors,
|
||||
@@ -48,8 +48,6 @@ const PageAlerts = ({
|
||||
localStorage.getItem(discussionAlertDismissKey) === null,
|
||||
);
|
||||
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
|
||||
const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getAssetsUrl = () => {
|
||||
if (getConfig().ENABLE_ASSETS_PAGE === 'true') {
|
||||
@@ -113,6 +111,13 @@ const PageAlerts = ({
|
||||
platformName: process.env.SITE_NAME,
|
||||
})}
|
||||
</div>
|
||||
<Hyperlink
|
||||
showLaunchIcon={false}
|
||||
destination={discussionsIncontextFeedbackUrl}
|
||||
target="_blank"
|
||||
>
|
||||
{intl.formatMessage(messages.discussionNotificationFeedback)}
|
||||
</Hyperlink>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -338,38 +343,13 @@ const PageAlerts = ({
|
||||
const renderApiErrors = () => {
|
||||
let errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => {
|
||||
switch (v.type) {
|
||||
case API_ERROR_TYPES.forbidden: {
|
||||
const description = intl.formatMessage(messages.forbiddenAlertBody, {
|
||||
LMS: (
|
||||
<Hyperlink
|
||||
destination={`${getConfig().LMS_BASE_URL}`}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.forbiddenAlertLmsUrl)}
|
||||
</Hyperlink>
|
||||
),
|
||||
});
|
||||
case API_ERROR_TYPES.serverError:
|
||||
return {
|
||||
key: k,
|
||||
desc: description,
|
||||
title: intl.formatMessage(messages.forbiddenAlert),
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
}
|
||||
case API_ERROR_TYPES.serverError: {
|
||||
const description = (
|
||||
<Truncate lines={2}>
|
||||
{v.data || intl.formatMessage(messages.serverErrorAlertBody)}
|
||||
</Truncate>
|
||||
);
|
||||
return {
|
||||
key: k,
|
||||
desc: description,
|
||||
desc: v.data || intl.formatMessage(messages.serverErrorAlertBody),
|
||||
title: intl.formatMessage(messages.serverErrorAlert),
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
}
|
||||
case API_ERROR_TYPES.networkError:
|
||||
return {
|
||||
key: k,
|
||||
@@ -398,7 +378,7 @@ const PageAlerts = ({
|
||||
dismissError={() => dispatch(dismissError(msgObj.key))}
|
||||
>
|
||||
<Alert.Heading>{msgObj.title}</Alert.Heading>
|
||||
{msgObj.desc}
|
||||
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
|
||||
</ErrorAlert>
|
||||
) : (
|
||||
<Alert
|
||||
@@ -407,22 +387,13 @@ const PageAlerts = ({
|
||||
key={msgObj.key}
|
||||
>
|
||||
<Alert.Heading>{msgObj.title}</Alert.Heading>
|
||||
{msgObj.desc}
|
||||
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
|
||||
</Alert>
|
||||
)
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
const renderOutOfSyncAlert = () => (
|
||||
<OutOfSyncAlert
|
||||
courseId={courseId}
|
||||
onReview={() => navigate(`/course/${courseId}/libraries?tab=review`)}
|
||||
showAlert={showOutOfSyncAlert}
|
||||
setShowAlert={setShowOutOfSyncAlert}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{configurationErrors()}
|
||||
@@ -436,7 +407,6 @@ const PageAlerts = ({
|
||||
{errorFilesPasteAlert()}
|
||||
{conflictingFilesPasteAlert()}
|
||||
{newFilesPasteAlert()}
|
||||
{renderOutOfSyncAlert()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -445,6 +415,7 @@ PageAlerts.defaultProps = {
|
||||
notificationDismissUrl: '',
|
||||
handleDismissNotification: null,
|
||||
discussionsSettings: {},
|
||||
discussionsIncontextFeedbackUrl: '',
|
||||
discussionsIncontextLearnmoreUrl: '',
|
||||
deprecatedBlocksInfo: {},
|
||||
proctoringErrors: [],
|
||||
@@ -461,6 +432,7 @@ PageAlerts.propTypes = {
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
}),
|
||||
discussionsIncontextFeedbackUrl: PropTypes.string,
|
||||
discussionsIncontextLearnmoreUrl: PropTypes.string,
|
||||
deprecatedBlocksInfo: PropTypes.shape({
|
||||
blocks: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
render,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -27,13 +28,6 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../course-libraries/data/apiHooks', () => ({
|
||||
useEntityLinksSummaryByDownstreamContext: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
let store;
|
||||
const handleDismissNotification = jest.fn();
|
||||
|
||||
@@ -42,6 +36,7 @@ const pageAlertsData = {
|
||||
notificationDismissUrl: '',
|
||||
handleDismissNotification: null,
|
||||
discussionsSettings: {},
|
||||
discussionsIncontextFeedbackUrl: '',
|
||||
discussionsIncontextLearnmoreUrl: '',
|
||||
deprecatedBlocksInfo: {},
|
||||
proctoringErrors: [],
|
||||
@@ -75,20 +70,20 @@ describe('<PageAlerts />', () => {
|
||||
useSelector.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('renders null when no alerts are present', async () => {
|
||||
renderComponent();
|
||||
expect(await screen.findByTestId('browser-router')).toBeEmptyDOMElement();
|
||||
it('renders null when no alerts are present', () => {
|
||||
const { queryByTestId } = renderComponent();
|
||||
expect(queryByTestId('browser-router')).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders configuration alerts', async () => {
|
||||
renderComponent({
|
||||
const { queryByText } = renderComponent({
|
||||
...pageAlertsData,
|
||||
notificationDismissUrl: 'some-url',
|
||||
handleDismissNotification,
|
||||
});
|
||||
|
||||
expect(screen.queryByText(messages.configurationErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
const dismissBtn = screen.queryByText('Dismiss');
|
||||
expect(queryByText(messages.configurationErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
const dismissBtn = queryByText('Dismiss');
|
||||
await act(async () => fireEvent.click(dismissBtn));
|
||||
|
||||
expect(handleDismissNotification).toBeCalled();
|
||||
@@ -100,6 +95,7 @@ describe('<PageAlerts />', () => {
|
||||
discussionsSettings: {
|
||||
providerType: 'openedx',
|
||||
},
|
||||
discussionsIncontextFeedbackUrl: 'some-feedback-url',
|
||||
discussionsIncontextLearnmoreUrl: 'some-learn-more-url',
|
||||
});
|
||||
|
||||
@@ -112,10 +108,16 @@ describe('<PageAlerts />', () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
const discussionAlertDismissKey = `discussionAlertDismissed-${pageAlertsData.courseId}`;
|
||||
expect(localStorage.getItem(discussionAlertDismissKey)).toBe('true');
|
||||
|
||||
await waitFor(() => {
|
||||
const feedbackLink = screen.queryByText(messages.discussionNotificationFeedback.defaultMessage);
|
||||
expect(feedbackLink).toBeInTheDocument();
|
||||
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders deprecation warning alerts', async () => {
|
||||
renderComponent({
|
||||
const { queryByText } = renderComponent({
|
||||
...pageAlertsData,
|
||||
deprecatedBlocksInfo: {
|
||||
blocks: [['url1', 'block1'], ['url2']],
|
||||
@@ -124,20 +126,20 @@ describe('<PageAlerts />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText(messages.deprecationWarningTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.deprecationWarningBlocksText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText('block1')).toHaveAttribute('href', 'url1');
|
||||
expect(screen.queryByText(messages.deprecatedComponentName.defaultMessage)).toHaveAttribute('href', 'url2');
|
||||
expect(queryByText(messages.deprecationWarningTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText(messages.deprecationWarningBlocksText.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText('block1')).toHaveAttribute('href', 'url1');
|
||||
expect(queryByText(messages.deprecatedComponentName.defaultMessage)).toHaveAttribute('href', 'url2');
|
||||
|
||||
const feedbackLink = screen.queryByText(messages.advancedSettingLinkText.defaultMessage);
|
||||
const feedbackLink = queryByText(messages.advancedSettingLinkText.defaultMessage);
|
||||
expect(feedbackLink).toBeInTheDocument();
|
||||
expect(feedbackLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}/some-url`);
|
||||
expect(screen.queryByText('lti')).toBeInTheDocument();
|
||||
expect(screen.queryByText('video')).toBeInTheDocument();
|
||||
expect(queryByText('lti')).toBeInTheDocument();
|
||||
expect(queryByText('video')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders proctoring alerts with mfe settings link', async () => {
|
||||
renderComponent({
|
||||
const { queryByText } = renderComponent({
|
||||
...pageAlertsData,
|
||||
mfeProctoredExamSettingsUrl: 'mfe-url',
|
||||
proctoringErrors: [
|
||||
@@ -146,15 +148,15 @@ describe('<PageAlerts />', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('error 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('error 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('message 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('message 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.proctoredSettingsLinkText.defaultMessage)).toHaveAttribute('href', 'mfe-url');
|
||||
expect(queryByText('error 1')).toBeInTheDocument();
|
||||
expect(queryByText('error 2')).toBeInTheDocument();
|
||||
expect(queryByText('message 1')).toBeInTheDocument();
|
||||
expect(queryByText('message 2')).toBeInTheDocument();
|
||||
expect(queryByText(messages.proctoredSettingsLinkText.defaultMessage)).toHaveAttribute('href', 'mfe-url');
|
||||
});
|
||||
|
||||
it('renders proctoring alerts without mfe settings link', async () => {
|
||||
renderComponent({
|
||||
const { queryByText } = renderComponent({
|
||||
...pageAlertsData,
|
||||
advanceSettingsUrl: '/some-url',
|
||||
proctoringErrors: [
|
||||
@@ -163,11 +165,11 @@ describe('<PageAlerts />', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('error 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('error 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('message 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('message 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.advancedSettingLinkText.defaultMessage)).toHaveAttribute(
|
||||
expect(queryByText('error 1')).toBeInTheDocument();
|
||||
expect(queryByText('error 2')).toBeInTheDocument();
|
||||
expect(queryByText('message 1')).toBeInTheDocument();
|
||||
expect(queryByText('message 2')).toBeInTheDocument();
|
||||
expect(queryByText(messages.advancedSettingLinkText.defaultMessage)).toHaveAttribute(
|
||||
'href',
|
||||
`${getConfig().STUDIO_BASE_URL}/some-url`,
|
||||
);
|
||||
@@ -179,10 +181,10 @@ describe('<PageAlerts />', () => {
|
||||
conflictingFiles: [],
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(
|
||||
'href',
|
||||
`${getConfig().STUDIO_BASE_URL}/assets/course-id`,
|
||||
);
|
||||
@@ -194,16 +196,16 @@ describe('<PageAlerts />', () => {
|
||||
conflictingFiles: ['some.css', 'some.js'],
|
||||
errorFiles: [],
|
||||
});
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(
|
||||
'href',
|
||||
`${getConfig().STUDIO_BASE_URL}/assets/course-id`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders api error alerts', async () => {
|
||||
renderComponent({
|
||||
const { queryByText } = renderComponent({
|
||||
...pageAlertsData,
|
||||
errors: {
|
||||
outlineIndexApi: { data: 'some error', status: 400, type: API_ERROR_TYPES.serverError },
|
||||
@@ -211,34 +213,9 @@ describe('<PageAlerts />', () => {
|
||||
reindexApi: { type: API_ERROR_TYPES.unknown, data: 'some unknown error' },
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText(messages.networkErrorAlert.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.serverErrorAlert.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText('some error')).toBeInTheDocument();
|
||||
expect(screen.queryByText('some unknown error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders forbidden api error alerts', async () => {
|
||||
renderComponent({
|
||||
...pageAlertsData,
|
||||
errors: {
|
||||
outlineIndexApi: {
|
||||
data: 'some error', status: 403, type: API_ERROR_TYPES.forbidden, dismissable: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText(messages.forbiddenAlert.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.forbiddenAlertBody.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders api error alerts when status is not 403', async () => {
|
||||
renderComponent({
|
||||
...pageAlertsData,
|
||||
errors: {
|
||||
outlineIndexApi: {
|
||||
data: 'some error', status: 500, type: API_ERROR_TYPES.serverError, dismissable: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText('some error')).toBeInTheDocument();
|
||||
expect(queryByText(messages.networkErrorAlert.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText(messages.serverErrorAlert.defaultMessage)).toBeInTheDocument();
|
||||
expect(queryByText('some error')).toBeInTheDocument();
|
||||
expect(queryByText('some unknown error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,21 +121,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Network error',
|
||||
description: 'Generic network error alert.',
|
||||
},
|
||||
forbiddenAlert: {
|
||||
id: 'course-authoring.course-outline.page-alert.forbidden.title',
|
||||
defaultMessage: 'Access Restricted',
|
||||
description: 'Forbidden(403) alert title',
|
||||
},
|
||||
forbiddenAlertBody: {
|
||||
id: 'course-authoring.course-outline.page-alert.forbidden.body',
|
||||
defaultMessage: 'It looks like you’re trying to access a page you don’t have permission to view. Contact your admin if you think this is a mistake, or head back to the {LMS}.',
|
||||
description: 'Forbidden(403) alert body',
|
||||
},
|
||||
forbiddenAlertLmsUrl: {
|
||||
id: 'course-authoring.course-outline.page-alert.lms',
|
||||
defaultMessage: 'LMS',
|
||||
description: 'LMS base redirection url',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -13,8 +13,8 @@ import classNames from 'classnames';
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import moment from 'moment/moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -6,14 +6,11 @@ import { getConfig } from '@edx/frontend-platform/config';
|
||||
import {
|
||||
Button, Hyperlink, Form, Stack, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { ContentTagsDrawerSheet } from '../../content-tags-drawer';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { useHelpUrls } from '../../help-urls/hooks';
|
||||
import { getWaffleFlags } from '../../data/selectors';
|
||||
import { VIDEO_SHARING_OPTIONS } from '../constants';
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import messages from './messages';
|
||||
@@ -46,7 +43,6 @@ const StatusBar = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { config } = useContext(AppContext);
|
||||
const waffleFlags = useSelector(getWaffleFlags);
|
||||
|
||||
const {
|
||||
courseReleaseDate,
|
||||
@@ -66,6 +62,7 @@ const StatusBar = ({
|
||||
|
||||
const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY at HH:mm UTC', true);
|
||||
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
|
||||
const checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
|
||||
const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href;
|
||||
|
||||
const {
|
||||
@@ -85,9 +82,10 @@ const StatusBar = ({
|
||||
<>
|
||||
<Stack direction="horizontal" gap={3.5} className="d-flex align-items-stretch outline-status-bar" data-testid="outline-status-bar">
|
||||
<StatusBarItem title={intl.formatMessage(messages.startDateTitle)}>
|
||||
<Link
|
||||
<Hyperlink
|
||||
className="small"
|
||||
to={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()}
|
||||
destination={scheduleDestination()}
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{courseReleaseDateObj.isValid() ? (
|
||||
<FormattedDate
|
||||
@@ -99,7 +97,7 @@ const StatusBar = ({
|
||||
minute="numeric"
|
||||
/>
|
||||
) : courseReleaseDate}
|
||||
</Link>
|
||||
</Hyperlink>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem title={intl.formatMessage(messages.pacingTypeTitle)}>
|
||||
<span className="small">
|
||||
@@ -109,12 +107,13 @@ const StatusBar = ({
|
||||
</span>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem title={intl.formatMessage(messages.checklistTitle)}>
|
||||
<Link
|
||||
<Hyperlink
|
||||
className="small"
|
||||
to={`/course/${courseId}/checklists`}
|
||||
destination={checklistDestination()}
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
|
||||
</Link>
|
||||
</Hyperlink>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem title={intl.formatMessage(messages.highlightEmailsTitle)}>
|
||||
<div className="d-flex align-items-center">
|
||||
|
||||
@@ -11,12 +11,11 @@ import { Add as IconAdd } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import CourseOutlineSubsectionCardExtraActionsSlot from '../../plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import { useClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
@@ -128,13 +127,6 @@ const SubsectionCard = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const extraActionsComponent = (
|
||||
<CourseOutlineSubsectionCardExtraActionsSlot
|
||||
subsection={subsection}
|
||||
section={section}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId === id && isExpanded) {
|
||||
setIsExpanded(false);
|
||||
@@ -213,7 +205,6 @@ const SubsectionCard = ({
|
||||
actions={actions}
|
||||
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
|
||||
isSequential
|
||||
extraActionsComponent={extraActionsComponent}
|
||||
/>
|
||||
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
|
||||
<XBlockStatus
|
||||
|
||||
@@ -21,6 +21,13 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const unit = {
|
||||
id: 'unit-1',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user