Compare commits

..

70 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
f16ccfe9cf fix: use hyperlink instead of Link 2025-04-21 11:10:42 +05:00
edX requirements bot
febf5cf5d0 chore: update browserslist DB (#1839)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-21 00:20:48 +00:00
Raymond Zhou
ac127e2b15 Revert "fix: use navigate instead of Link from react-dom"
This reverts commit 06bdff1796.
2025-04-19 10:26:42 +05:00
Muhammad Faraz Maqsood
06bdff1796 fix: use navigate instead of Link from react-dom
getting TypeError: r is not a function. Replace Link with navigate.
2025-04-18 21:26:22 +05:00
Braden MacDonald
ea0a031d7b feat: button to publish a container [FC-0083] (#1827)
- Publish button with functionality of publish units and components inside the unit
2025-04-18 09:34:46 -05:00
Muhammad Faraz Maqsood
ea8a8e5285 fix: toggle behaviour for video & file view
- fix toggle behaviour for video and file view.
- Before:
  - The default view was card. And The videos and files both pages were sharing same variable & default view.
  - Whenever user selects list view on videos/files page and redirects to another page, the toggle/view shifts again to default(card) view whenever it returns to videos/files page.

- After:
  - The default view is list now. And The videos and files both pages can have different state & default view.
  - Whenever user selects card view on videos/files page and redirects to another page, the toggle/view remain same whatever user had selected before when it returns to videos/files page.

Note: Refreshing a page will use default(list) view.
2025-04-18 11:13:32 +05:00
Chris Chávez
9adfa58d65 feat: Remove component from unit [FC-0083] (#1824)
* Users can remove a component from a unit
* The component is NOT deleted, and remains present in the library
* A toast shows that the component was removed, and allows the user to undo
* Overflow menu item appears in sidebar for selected components in unit
* Overflow menu item appears directly on components in full page unit view
2025-04-17 17:51:42 -05:00
Navin Karkera
4ddb8c3168 feat: edit components in unit page [FC-0083] (#1821)
Allows authors to edit components from unit page. It makes sure that the component preview is updated on save, allows user to double click and open editor in modal etc.
2025-04-17 09:59:16 -05:00
Navin Karkera
3b2adc2fc1 feat: reorder components in unit page [FC-00083] (#1816)
Reorders components in unit page via drag and drop. This PR also refactors and moves draggable list and sortable item components to appropriate location.

Course authors will be affected by this change.
2025-04-16 14:34:28 -05:00
Régis Behmo
4bd2c3b29a feat: lighter build by rewriting lodash imports (#1772)
Incorrect lodash imports are causing MFEs to import the entire lodash
library. This change shaves off a few kB of the compressed build.
2025-04-15 17:07:16 -07:00
Braden MacDonald
f531d5471d fix: merge errors in previous commit (#1819) 2025-04-15 23:46:16 +00:00
Braden MacDonald
f24b89c847 feat: allow pasting units from a course into a library (#1812) 2025-04-15 15:26:19 -07:00
Rômulo Penido
d9dcdfe1e3 feat: add existing components to unit [FC-0083] (#1811)
allows adding existing components to units
2025-04-15 16:49:53 -05:00
Rômulo Penido
990073cb38 feat: renames unit in LibraryUnitPage and adds InplaceTextEditor component (#1810) 2025-04-15 15:42:36 -05:00
Jillian
afecd8ba83 fix: sort Advanced Blocks by default display name (#1817) 2025-04-15 15:07:21 -05:00
Rômulo Penido
aa8a5bfba4 feat: add collections support for containers [FC-0083] (#1797)
Adds support to add Units to Collections.
2025-04-15 13:13:12 -05:00
Navin Karkera
87695ae636 fix: auto adjust min height of xblock previews [FC-0083] (#1813)
Sets minimum height of library block previews based on its render
location and block type.
2025-04-15 10:37:59 -05:00
Braden MacDonald
681854209a fix: Copy to clipboard would seemingly fail even if it worked 2025-04-14 17:21:10 -07:00
Chris Chávez
a522c48045 feat: Add component to Unit [FC-0083] (#1784)
Creation workflow in unit page.
2025-04-14 22:36:46 +00:00
edX requirements bot
f46e4ce4e8 chore: update browserslist DB (#1814)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-14 00:21:14 +00:00
Navin Karkera
a43027b328 feat: library unit page skeleton [FC-0083] (#1779)
* View a unit page, which has its own URL
* Components appear within a unit as full previews. Their top bar shows type icon and title on the left, and draft status (if any), tag count, overflow menu, and drag handle on the right.
* Components have an overflow menu within a unit
* Components can be selected within a unit
* When components are selected, the standard component sidebar appears. The preview tab is hidden, since component previews are visible in the main content area.
* Components within a unit full-page view have hover and selected states
* Unit sidebar preview.
* Frontend implementation Drag-n-drop components to reorder them in unit.
2025-04-11 13:50:40 -05:00
Muhammad Anas
01365d080e feat: replace StudioFooter with StudioFooterSlot (#1729) 2025-04-11 13:29:41 -04:00
Chris Chávez
e00a4c4d03 refactor: Build new action buttons for cancel confirmation modal (#1732)
Build new action buttons for cancel confirmation modal in basic block and advanced block editors.
2025-04-11 11:55:32 -05:00
Kshitij Sobti
341a03c50b feat: Add plugin slots for sidebars (#1752) 2025-04-11 07:19:13 -04:00
Chris Chávez
5df7adffec feat: Delete unit [FC-0083] (#1773)
* Adds the delete menu item in unit cards.
* Delete a unit with a confirmation modal.
* Restore a component
2025-04-10 23:59:10 +00:00
Rômulo Penido
04faf54ad8 feat: add container (e.g. unit) tag support (#1782) 2025-04-09 12:10:31 -07:00
Bryann Valderrama
d688cf57b7 fix: add user partition id when update groups 2025-04-09 11:55:11 -07:00
sundasnoreen12
fe36e65d2d fix: removed all varaibles of feedback url 2025-04-09 12:45:56 +05:00
sundasnoreen12
8e99ebf072 test: removed variables from test file 2025-04-09 12:45:56 +05:00
sundasnoreen12
024537c80e feat: hide feedback widget 2025-04-09 12:45:56 +05:00
Muhammad Faraz Maqsood
0ddcbbb7a5 fix: ignore altText to avoid replace &quot; with "
- ignore altText to avoid replacing &quot; with "(double quotes) in alt text value
- modify unit tests to cover this new code
2025-04-09 11:58:22 +05:00
Jillian
7ceeb32820 feat: Unit card previews [FC-0083] (#1774)
Adds block tiles to the Unit card to indicate type and quantity of children in the container.
2025-04-08 17:47:04 -05:00
Sarina Canelake
451b821c3b docs: Update references to docs.edx.org (#1783)
With the transition to docs.openedx.org, update links to docs.edx.org to the community-supported site.
2025-04-07 13:55:19 -04:00
Rômulo Penido
68d62cd62f feat: library unit sidebar [FC-0083] (#1762)
Implements the placeholder for the Unit Sidebar.
2025-04-07 11:51:10 -05:00
Rômulo Penido
2a31434a55 fix: prevent multiple submits while creating units [FC-0083] (#1776)
Fixes a bug where the form is submitted multiple times when the user presses Enter on the Unit create form.

The issue was that when the user hit Enter, the form was submitted without calling the button's onClick, allowing multiple calls. Also, because the onClick was not called, we had to add the isLoading property to the LoadingButton to display the status correctly.
2025-04-07 09:24:51 -05:00
Muhammad Faraz Maqsood
fdd8928f36 feat: add manual check count to collapsed view 2025-04-07 10:40:11 +05:00
Muhammad Faraz Maqsood
552ff395df chore: rename course optimizer name in dropdown 2025-04-07 10:40:01 +05:00
edX requirements bot
c324446722 chore: update browserslist DB (#1785)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-07 00:20:14 +00:00
Arunmozhi
15fcb55075 feat: adds slots for in-context metrics in studio outline and unit views (#1725) 2025-04-04 07:29:36 -04:00
Régis Behmo
d1a6af51a4 chore: remove husky 🪓🐶
We remove husky, which is triggering pre-push git hooks, including
running "npm lint". This is causing failures when building Docker
images, because "npm clean-install --omit=dev" automatically triggers "npm
prepare", which attemps to run "husky". But husky is not listed in the
build dependencies, only in devDependencies. As a consequence, package
installation is failing with the following error:

        14.13 > @edx/frontend-app-ora-grading@0.0.1 prepare
        14.13 > husky install
        14.13
        14.15 sh: 1: husky: not found

Similar to: https://github.com/openedx/frontend-app-learning/pull/1622
2025-04-02 14:00:02 -07:00
sarina
55344bc55d docs: Update edx.rtd links to docs.openedx.org 2025-04-02 15:59:31 -04:00
sarina
a23f6a6fa7 docs: Update README: s/devstack/tutor/ 2025-04-02 15:59:31 -04:00
sarina
5cedaacc3e docs: Add note to use .nvmrc node version 2025-04-02 15:59:31 -04:00
Brian Smith
0ce0b7526e feat: upgrade to react 18 (#1766) 2025-04-02 12:04:48 -04:00
Ihor Romaniuk
3685dbd6a1 feat: [FC-0070] rendering split test content in unit page (#1492)
Introduces functionality to display Split Test Content within the new Unit page interface.
2025-04-01 11:37:14 -03:00
Rômulo Penido
272e30f1b1 feat: library units tab (#1754)
Implements the "Units" tab on the Library Authoring.
2025-03-31 13:34:25 -05:00
Rômulo Penido
98ae74e78c feat: unit cards in library [FC-0083] (#1742)
Unit cards in library and rename BaseComponentCard -> BaseCard
2025-03-31 10:54:07 -05:00
Rômulo Penido
df7405ec39 feat: create unit workflow (#1741)
Implements the basic workflow to create a Unit in a Library.
2025-03-28 15:44:58 -05:00
Muhammad Farhan
d497bf2ccc fix: update useRef before dispatching file upload action 2025-03-28 19:38:06 +05:00
Muhammad Faraz Maqsood
94f34074ce fix: link navigation for BrokenLink and GoToBlock
In this commit, Fix link navigation in BrokenLinkHref and GoToBlock components
- Updated BrokenLinkHref to prevent default anchor behavior and open broken links directly in a new tab.
- Updated GoToBlock to prevent default anchor behavior and open block URLs directly in a new tab.
- added test coverage for this fix code in `CourseOptimizerPage.test.js`, this wasn't covered before: https://github.com/openedx/frontend-app-authoring/pull/1760/checks?check_run_id=39390124321
2025-03-28 12:19:29 +05:00
Brian Smith
92a8b42e36 feat(deps): update @openedx dependencies to versions that support React 18 (#1759) 2025-03-27 12:19:00 -04:00
Muhammad Farhan
08368582e3 fix: video ID not populating issue 2025-03-26 23:07:27 +05:00
Navin Karkera
a52f6d9b94 fix: prevent editor from loading twice before initialization (#1761)
Data from previous editor instance was being processed by current editor
instance and sometimes failed due to mismatch. For example, editing text
editor or any other basic editor after opening an advanced problem like
drag-n-drop crashed. Now the editor is only rendered after the
initialization process is complete.
2025-03-25 20:14:57 -05:00
Navin Karkera
bac63583ac refactor: don't dismiss out of sync alert on review (#1750)
Clicking review button on out of sync alert should not dismiss the alert
and it should be displayed again in outline or all tab.
2025-03-24 11:39:10 -05:00
Hina Khadim
545bb4a8a6 feat: add manual check links section separately for 403 links (#1751) 2025-03-24 19:55:56 +05:00
Kyle McCormick
9e65424ca6 refactor: Remove unused defaultToAdvanced and getBetaParsedOLXData (#1753)
edx-platform would pass a default_to_advanced flag in through the REST
API, depending on the value of a waffle flag. The flag did not actually
cause anything to default to advanced. What it actually did was switch
from getParsedOLXData to getParsedBetaOLXData. However, getBetaOLXParser
was never implemented--it just logs a console warning and return
getOLXParser.

We remove this unused flag and unused function.
The underlying default_to_advanced API flag and the backing waffle flag
will be removed from edx-platform in a separate PR.
2025-03-20 15:49:36 -04:00
Navin Karkera
27c4eec746 refactor: open review tab in course libraries if out of sync (#1743) 2025-03-19 12:49:22 -05:00
Muhammad Farhan
cc20dfd8ca fix: fetch only studio home data without courses 2025-03-19 12:57:03 +05:00
Rômulo Penido
a26e3f9e92 fix: show error information when taxonomy import fails (#1730)
Adds the error information when we have a failure while importing a Taxonomy
2025-03-17 10:00:33 -05:00
Navin Karkera
e66da2cb49 fix: image rendering in single and multi select problems (#1731)
Fix images in single and multi select problems in libraries. Found following issues and fixed them:

* Images were not being rendered in any of the fields in these problems.
* Base url was not being set which is used by tinymce to load images with relative path.
* Answer fields were set to inline mode which does not initialize images or base paths
* If same image twice is used twice in a problem, the logic of replacing `static/image.jpg` with `/static/image.jpg` would replace the first occurrence twice resulting in `//static/image.jpg`, breaking both the links.
* On initialization of answer fields, the absolute static asset urls were being replaced by relative urls causing the editor being set as dirty without user changes.
2025-03-13 11:11:44 -05:00
Navin Karkera
77a55d9ad3 feat: course libraries review tab [FC-0076] (#1699)
Adds review tab to course libraries page. Also refactors all libraries page as per new designs.
2025-03-12 12:58:27 -05:00
Chris Chávez
3aa409d065 feat: Add Publish confirmation modal [FC-0076] (#1677) 2025-03-11 17:37:40 -05:00
Muhammad Farhan
732fd28eb9 refactor: Improve conditions readability 2025-03-12 00:08:06 +05:00
Muhammad Farhan
091e120224 fix: Use defaultValue when item is null or empty 2025-03-12 00:08:06 +05:00
Brayan Ceron
1174b09ac4 fix: render proper visibility message on self-paced course type 2025-03-11 16:50:59 +05:00
Chris Chávez
b2472cfc0a refactor: Separate Publish order sort from publish filter [FC-0076] (#1701) 2025-03-10 10:55:15 -05:00
Rômulo Penido
17ebb90cd1 fix: paste button on unit page wasn't working (#1724) 2025-03-07 11:09:00 -08:00
Hina Khadim
49fbe766b0 fix: Prevent Alt Text from Being Truncated Due to Double Quotation Marks (#1721) 2025-03-07 23:17:37 +05:00
Rômulo Penido
dbba4dd296 fix: excessive calls to the clipboard API endpoint (#1700) 2025-03-07 08:16:39 -08:00
Chris Chávez
0eda5aec23 feat: Create advanced blocks in libraries [FC-0076] (#1653)
List view to show and create the advanced blocks
2025-03-05 12:46:17 -05:00
430 changed files with 19143 additions and 6992 deletions

4
.env
View File

@@ -44,4 +44,6 @@ INVITE_STUDENTS_EMAIL_TO=''
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2"
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

View File

@@ -47,4 +47,5 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2"
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"

View File

@@ -39,5 +39,6 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "other" is used to test the workflow for creating blocks that aren't supported by the built-in editors
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2,other"
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

View File

@@ -38,7 +38,7 @@ Cloning and Setup
git clone https://github.com/openedx/frontend-app-authoring.git
2. Use node v20.x.
2. Use the version of Node specified in the ``.nvmrc`` file.
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
@@ -315,7 +315,7 @@ In additional to the standard settings, the following local configurations can b
Developing
**********
`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.
`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.
If your devstack includes the default Demo course, you can visit the following URLs to see content:

9319
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,11 +23,6 @@
"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",
@@ -49,10 +44,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.1.0",
"@edx/frontend-component-header": "^5.8.3",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^8.0.3",
"@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/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",
@@ -64,9 +59,10 @@
"@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.2.0",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/paragon": "^22.8.1",
"@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",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
@@ -85,9 +81,9 @@
"moment-shortformat": "^2.1.0",
"npm": "^10.8.1",
"prop-types": "^15.8.1",
"react": "17.0.2",
"react": "^18.3.1",
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
@@ -110,21 +106,19 @@
"yup": "0.31.1"
},
"devDependencies": {
"@edx/react-unit-test-utils": "3.0.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/typescript-config": "^1.0.1",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@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": "17.0.2",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}
}

View File

@@ -142,7 +142,7 @@ describe('ORASettings', () => {
renderComponent();
await mockStore({ apiStatus: 200, enabled: false });
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.queryByTestId('enable-badge');
expect(label).toBeVisible();

View File

@@ -544,12 +544,9 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
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...');
});
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('Show connection error message when we suffer studio server side error', async () => {

View File

@@ -26,6 +26,7 @@ const TeamSettings = ({
description: '',
type: GroupTypes.OPEN,
maxTeamSize: null,
userPartitionId: null,
id: null,
key: uuid(),
};
@@ -38,6 +39,7 @@ const TeamSettings = ({
type: group.type,
description: group.description,
max_team_size: group.maxTeamSize,
user_partition_id: group.userPartitionId,
}));
return saveSettings({
team_sets: groups,

View File

@@ -7,7 +7,7 @@ import {
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import {
queryByTestId, render, waitFor, getByText, fireEvent,
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
@@ -106,8 +106,9 @@ 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(queryByTestId(container, 'enable-badge')).toBeTruthy();
expect(enableBadge).toBeTruthy();
});
test('Shows switch on if disabled from backend', async () => {

View File

@@ -5,12 +5,13 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooter } from '@edx/frontend-component-footer';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import Header from './header';
import { fetchCourseDetail, fetchWaffleFlags } 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 { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
@@ -23,6 +24,10 @@ const CourseAuthoringPage = ({ courseId, children }) => {
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
@@ -61,7 +66,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
)
)}
{children}
{!inProgress && !isEditor && <StudioFooter />}
{!inProgress && !isEditor && <StudioFooterSlot />}
</div>
);
};

View File

@@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, IframeProvider } from './course-unit';
import { CourseUnit } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
@@ -25,7 +25,8 @@ 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 { 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:

View File

@@ -0,0 +1,16 @@
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',
};

View File

@@ -1,2 +1,3 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';

View File

@@ -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 { StudioFooter } from '@edx/frontend-component-footer';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import Header from '../header';
import messages from './messages';
@@ -29,7 +29,7 @@ const AccessibilityPage = ({
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
<AccessibilityForm accessibilityEmail={email} />
</Container>
<StudioFooter />
<StudioFooterSlot />
</>
);
};

View File

@@ -59,6 +59,7 @@ export const COURSE_BLOCK_NAMES = ({
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' },
});
@@ -91,3 +92,17 @@ export const REGEX_RULES = {
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',
};

View File

@@ -699,7 +699,7 @@ describe('<ContentTagsCollapsible />', () => {
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
name: /delete/i,
});
xButtonAppliedTag.click();
await userEvent.click(xButtonAppliedTag);
// Check that the applied tag has been removed
expect(appliedTag).not.toBeInTheDocument();

View File

@@ -1,5 +1,4 @@
import {
act,
fireEvent,
initializeMocks,
render,
@@ -61,19 +60,15 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the content data query is complete', async () => {
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
it('shows spinner before the taxonomy tags query is complete', async () => {
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
it('shows the content display name after the query is complete in drawer variant', async () => {
@@ -98,15 +93,12 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
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');
});
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');
});
it('should be read only on first render on drawer variant', async () => {

View File

@@ -227,7 +227,6 @@ 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.
*/
@@ -246,7 +245,7 @@ const ContentTagsDrawer = ({
throw new Error('Error: contentId cannot be null.');
}
const context = useCreateContentTagsDrawerContext(contentId, !readOnly);
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {

View File

@@ -20,9 +20,11 @@ import { ContentTagsDrawerSheetContext } from './common/context';
* To *use* the context, just use `useContext(ContentTagsDrawerContext)`
* @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}
*/
export const useCreateContentTagsDrawerContext = (contentId, canTagObject) => {
export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetchMetadata = false) => {
const intl = useIntl();
const org = extractOrgFromContentId(contentId);
@@ -48,7 +50,7 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject) => {
const updateTags = useContentTaxonomyTagsUpdater(contentId);
// Fetch from database
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId, fetchMetadata);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
waitFor,
fireEvent,
@@ -74,11 +73,9 @@ describe('<ContentTagsDropDownSelector />', () => {
}
it('should render taxonomy tags drop down selector loading with spinner', async () => {
await act(async () => {
const { getByRole } = await getComponent();
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
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 () => {
@@ -99,13 +96,11 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
await act(async () => {
const { container, getByText } = await getComponent();
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);
});
});
@@ -127,13 +122,11 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
await act(async () => {
const { container, getByText } = await getComponent();
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);
});
});
@@ -155,47 +148,45 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
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();
});
});
@@ -219,48 +210,46 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const initalSearchTerm = 'test 1';
await act(async () => {
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(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).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(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).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
});
it('should render "noTag" message if search doesnt return taxonomies', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
@@ -271,20 +260,18 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const searchTerm = 'uncommon search term';
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
const { getByText } = await getComponent({ ...data, 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();
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
});
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.mockReturnValueOnce({
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
@@ -295,15 +282,13 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const searchTerm = '';
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = 'No tags in this taxonomy yet';
expect(getByText(message)).toBeInTheDocument();
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = 'No tags in this taxonomy yet';
expect(getByText(message)).toBeInTheDocument();
});
});

View File

@@ -70,17 +70,12 @@ export async function getContentTaxonomyTagsCount(contentId) {
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* Fetch meta data (eg: display_name) about the content object (unit/component)
* @param {string} contentId The id of the content object (unit/component)
* @returns {Promise<import("./types.js").ContentData | null>}
* @returns {Promise<import("./types.js").ContentData>}
*/
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);

View File

@@ -13,6 +13,7 @@ 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}"`);
}
@@ -204,6 +205,7 @@ 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);
/**

View File

@@ -112,11 +112,13 @@ 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) => (
export const useContentData = (contentId, enabled) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled,
})
);
@@ -149,7 +151,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:')) {
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:') || contentId.startsWith('lct:')) {
// Obtain library id from contentId
const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count

View File

@@ -1,6 +1,5 @@
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react';
import {
useTaxonomyTagsData,
useContentTaxonomyTagsData,

View File

@@ -1,2 +1,3 @@
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
export { default as ContentTagsDrawerSheet } from './ContentTagsDrawerSheet';
export { useContentTaxonomyTagsData } from './data/apiHooks';

View File

@@ -1,22 +1,35 @@
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';
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 mockInfoResult from './__mocks__/courseBlocksInfo.json';
import CourseLibraries from './CourseLibraries';
import { mockGetEntityLinksByDownstreamContext } from './data/api.mocks';
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();
mockGetEntityLinksByDownstreamContext.applyMock();
mockGetEntityLinks.applyMock();
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
mockUseLibBlockMetadata.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search';
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
@@ -26,123 +39,206 @@ jest.mock('../studio-home/hooks', () => ({
}),
}));
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();
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const filter = requestData?.filter[1];
const mockInfoResultCopy = cloneDeep(mockInfoResult);
const resp = mockInfoResultCopy.filter((o: { filter: string }) => o.filter === filter)[0] || {
result: {
hits: [],
query: '',
processingTimeMs: 0,
limit: 4,
offset: 0,
estimatedTotalHits: 0,
},
};
const { result } = resp;
return result;
});
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
});
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinksByDownstreamContext.courseKey;
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(mockGetEntityLinksByDownstreamContext.courseKeyLoading);
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state wheen no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyEmpty);
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(mockGetEntityLinksByDownstreamContext.courseKey);
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(
'1 library components are out of sync. Review updates to accept or ignore changes',
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
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 (1)' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
// go back to all tab
userEvent.click(allTab);
// alert should not be back
expect(alert).not.toBeInTheDocument();
expect(allTab).toHaveAttribute('aria-selected', 'true');
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true');
});
it('hide alert on dismiss', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
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(
'1 library components are out of sync. Review updates to accept or ignore changes',
'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);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
expect(allTab).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
});
it('shows links split by library', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
const msg = await screen.findByText('This course contains content from these libraries.');
expect(msg).toBeInTheDocument();
const allButtons = await screen.findAllByRole('button');
// total 3 components used from lib 1
const expectedLib1Blocks = 3;
// total 4 components used from lib 1
const expectedLib2Blocks = 4;
// 1 component has updates.
const expectedLib2ToUpdate = 1;
const libraryCards = allButtons.filter((el) => el.classList.contains('collapsible-trigger'));
expect(libraryCards.length).toEqual(2);
expect(await within(libraryCards[0]).findByText('CS problems 2')).toBeInTheDocument();
expect(await within(libraryCards[0]).findByText(`${expectedLib1Blocks} components applied`)).toBeInTheDocument();
expect(await within(libraryCards[0]).findByText('All components up to date')).toBeInTheDocument();
const libParent1 = libraryCards[0].parentElement;
expect(libParent1).not.toBeNull();
userEvent.click(libraryCards[0]);
const xblockCards1 = libParent1!.querySelectorAll('div.card');
expect(xblockCards1.length).toEqual(expectedLib1Blocks);
expect(await within(libraryCards[1]).findByText('CS problems 3')).toBeInTheDocument();
expect(await within(libraryCards[1]).findByText(`${expectedLib2Blocks} components applied`)).toBeInTheDocument();
expect(await within(libraryCards[1]).findByText(`${expectedLib2ToUpdate} component out of sync`)).toBeInTheDocument();
const libParent2 = libraryCards[1].parentElement;
expect(libParent2).not.toBeNull();
userEvent.click(libraryCards[1]);
const xblockCards2 = libParent2!.querySelectorAll('div.card');
expect(xblockCards2.length).toEqual(expectedLib2Blocks);
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']);
});
});

View File

@@ -1,241 +1,115 @@
import React, {
useCallback, useMemo, useState,
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,
Breadcrumb, Button, Card, Collapsible, Container, Dropdown, Hyperlink, Icon, IconButton, Layout, Stack, Tab, Tabs,
ActionRow,
Button,
Card,
Container,
Hyperlink,
Icon,
Stack,
Tab,
Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, KeyboardArrowDown, KeyboardArrowRight, Loop, MoreVert,
Cached, CheckCircle, Launch, Loop,
} from '@openedx/paragon/icons';
import {
countBy, groupBy, keyBy, tail, uniq,
} from 'lodash';
import classNames from 'classnames';
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 { useEntityLinksByDownstreamContext } from './data/apiHooks';
import type { PublishableEntityLink } from './data/api';
import { useFetchIndexDocuments } from '../search-manager/data/apiHooks';
import { getItemIcon } from '../generic/block-type-utils';
import { BlockTypeLabel } from '../search-manager';
import AlertMessage from '../generic/alert-message';
import type { ContentHit } from '../search-manager/data/api';
import { SearchSortOption } from '../search-manager/data/api';
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 {
courseId: string;
title: string;
links: PublishableEntityLink[];
}
interface ComponentInfo extends ContentHit {
readyToSync: boolean;
}
interface BlockCardProps {
info: ComponentInfo;
linkSummary: PublishableEntityLinkSummary;
}
export enum CourseLibraryTabs {
home = '',
all = 'all',
review = 'review',
}
const BlockCard: React.FC<BlockCardProps> = ({ info }) => {
const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
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={classNames(
'my-3 shadow-none border-light-600 border',
{ 'bg-primary-100': info.readyToSync },
)}
orientation="horizontal"
key={info.usageKey}
>
<Card.Section
className="py-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} />
<Hyperlink className="lead ml-auto text-black" destination={getBlockLink()} target="_blank">
{' '}
</Hyperlink>
<Card className="my-3 border-light-500 border shadow-none">
<Card.Header
title={(
<Stack direction="horizontal" gap={2}>
<Icon src={NewsstandIcon} />
{linkSummary.upstreamContextTitle}
</Stack>
<Stack direction="horizontal" className="small" gap={1}>
{info.readyToSync && <Icon src={Loop} size="xs" />}
{info.formatted?.displayName}
</Stack>
<div className="micro">{info.formatted?.description}</div>
<Breadcrumb
className="micro text-gray-500"
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
links={breadcrumbs.map((breadcrumb) => ({ label: breadcrumb.displayName }))}
spacer={<span className="custom-spacer">/</span>}
linkAs="span"
/>
)}
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>
);
};
const LibraryCard: React.FC<LibraryCardProps> = ({ courseId, title, links }) => {
const intl = useIntl();
const linksInfo = useMemo(() => keyBy(links, 'downstreamUsageKey'), [links]);
const totalComponents = links.length;
const outOfSyncCount = useMemo(() => countBy(links, 'readyToSync').true, [links]);
const downstreamKeys = useMemo(() => uniq(Object.keys(linksInfo)), [links]);
const { data: downstreamInfo } = useFetchIndexDocuments({
filter: [`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys.join('","')}"]`],
limit: downstreamKeys.length,
attributesToRetrieve: ['usage_key', 'display_name', 'breadcrumbs', 'description', 'block_type'],
attributesToCrop: ['description:30'],
sort: [SearchSortOption.TITLE_AZ],
}) as unknown as { data: ComponentInfo[] };
const renderBlockCards = (info: ComponentInfo) => {
// eslint-disable-next-line no-param-reassign
info.readyToSync = linksInfo[info.usageKey].readyToSync;
return <BlockCard info={info} key={info.usageKey} />;
};
return (
<Collapsible.Advanced>
<Collapsible.Trigger className="bg-white shadow px-2 py-2 my-3 collapsible-trigger d-flex font-weight-normal text-dark">
<Collapsible.Visible whenClosed>
<Icon src={KeyboardArrowRight} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={KeyboardArrowDown} />
</Collapsible.Visible>
<Stack direction="vertical" className="flex-grow-1 pl-2 x-small" gap={1}>
<h4>{title}</h4>
<Stack direction="horizontal" gap={2}>
<span>
{intl.formatMessage(messages.totalComponentLabel, { totalComponents })}
</span>
<span>/</span>
{outOfSyncCount ? (
<>
<Icon src={Loop} size="xs" />
<span>
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount })}
</span>
</>
) : (
<>
<Icon src={CheckCircle} size="xs" />
<span>
{intl.formatMessage(messages.allUptodateLabel)}
</span>
</>
)}
</Stack>
</Stack>
<Dropdown onClick={(e: { stopPropagation: () => void; }) => e.stopPropagation()}>
<Dropdown.Toggle
id={`dropdown-toggle-${title}`}
alt="dropdown-toggle-menu-items"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
disabled
/>
<Dropdown.Menu>
<Dropdown.Item>TODO 1</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body border-left border-left-purple px-2">
{downstreamInfo?.map(info => renderBlockCards(info))}
</Collapsible.Body>
</Collapsible.Advanced>
);
};
interface ReviewAlertProps {
show: boolean;
outOfSyncCount: number;
onDismiss: () => void;
onReview: () => void;
}
const ReviewAlert: React.FC<ReviewAlertProps> = ({
show, outOfSyncCount, onDismiss, onReview,
}) => {
const intl = useIntl();
return (
<AlertMessage
title={intl.formatMessage(messages.outOfSyncCountAlertTitle, { outOfSyncCount })}
dismissible
show={show}
icon={Loop}
variant="info"
onClose={onDismiss}
actions={[
<Button
onClick={onReview}
>
{intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)}
</Button>,
]}
/>
);
};
const TabContent = ({ children }: { children: React.ReactNode }) => (
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 12 }, { span: 12 }]}
xs={[{ span: 12 }, { span: 12 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
{children}
</Layout.Element>
<Layout.Element>
Help panel
</Layout.Element>
</Layout>
);
const CourseLibraries: React.FC<Props> = ({ courseId }) => {
export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(CourseLibraryTabs.home);
const [showReviewAlert, setShowReviewAlert] = useState(true);
const { data: links, isLoading } = useEntityLinksByDownstreamContext(courseId);
const linksByLib = useMemo(() => groupBy(links, 'upstreamContextKey'), [links]);
const outOfSyncCount = useMemo(() => countBy(links, 'readyToSync').true, [links]);
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,
@@ -244,33 +118,64 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
const onAlertReview = () => {
setTabKey(CourseLibraryTabs.review);
setShowReviewAlert(false);
};
const onAlertDismiss = () => {
setShowReviewAlert(false);
};
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 (links?.length === 0) {
if (libraries?.length === 0) {
return <small><FormattedMessage {...messages.homeTabDescriptionEmpty} /></small>;
}
return (
<>
<small><FormattedMessage {...messages.homeTabDescription} /></small>
{Object.entries(linksByLib).map(([libKey, libLinks]) => (
{libraries?.map((library) => (
<LibraryCard
courseId={courseId}
title={libLinks[0].upstreamContextTitle}
links={libLinks}
key={libKey}
linkSummary={library}
key={library.upstreamContextKey}
/>
))}
</>
);
}, [links, isLoading, linksByLib]);
}, [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 (
@@ -288,16 +193,16 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4 mt-3">
<ReviewAlert
show={outOfSyncCount > 0 && tabKey === CourseLibraryTabs.home && showReviewAlert}
outOfSyncCount={outOfSyncCount}
onDismiss={onAlertDismiss}
<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 && tabKey === CourseLibraryTabs.home && (
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && (
<Button
variant="primary"
onClick={onAlertReview}
@@ -312,25 +217,27 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
<Tabs
id="course-library-tabs"
activeKey={tabKey}
onSelect={(k: CourseLibraryTabs) => setTabKey(k)}
onSelect={tabChange}
>
<Tab
eventKey={CourseLibraryTabs.home}
eventKey={CourseLibraryTabs.all}
title={intl.formatMessage(messages.homeTabTitle)}
className="px-2 mt-3"
>
<TabContent>
{renderLibrariesTabContent()}
</TabContent>
{renderLibrariesTabContent()}
</Tab>
<Tab
eventKey={CourseLibraryTabs.review}
title={intl.formatMessage(
outOfSyncCount > 0 ? messages.reviewTabTitle : messages.reviewTabTitleEmpty,
{ count: outOfSyncCount },
title={(
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} />
{intl.formatMessage(messages.reviewTabTitle)}
</Stack>
)}
notification={outOfSyncCount}
className="px-2 mt-3"
>
<TabContent>Help</TabContent>
{renderReviewTabContent()}
</Tab>
</Tabs>
</section>
@@ -338,5 +245,3 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
</>
);
};
export default CourseLibraries;

View File

@@ -0,0 +1,76 @@
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>,
]}
/>
);
};

View File

@@ -0,0 +1,393 @@
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;

View File

@@ -0,0 +1,23 @@
{
"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"
}
]
}

View File

@@ -0,0 +1,20 @@
[
{
"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
}
]

View File

@@ -0,0 +1,376 @@
{
"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
}
]
}

View File

@@ -1,100 +1,79 @@
[
{
"id": 970,
"upstreamContextTitle": "CS problems 2",
"upstreamVersion": 15,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB2:html:c0c1ca28-ff25-4757-83bc-3a2c2a0fe9c8",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 15,
"versionDeclined": 13,
"created": "2025-02-08T14:11:23.650589Z",
"updated": "2025-02-08T14:11:23.650589Z"
},
{
"id": 971,
"upstreamContextTitle": "CS problems 2",
"upstreamVersion": 3,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB2:html:fd2d3827-e633-4217-bca9-c6661086b4b2",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 3,
"versionDeclined": null,
"created": "2025-02-08T14:11:23.650589Z",
"updated": "2025-02-08T14:11:23.650589Z"
},
{
"id": 972,
"upstreamContextTitle": "CS problems 2",
"upstreamVersion": 3,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB2:video:ba2023d4-b4e4-44a5-bfc8-322203e8737f",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 3,
"versionDeclined": null,
"created": "2025-02-08T14:11:23.650589Z",
"updated": "2025-02-08T14:11:23.650589Z"
},
{
"id": 974,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 18,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 17,
"versionDeclined": 18,
"created": "2025-02-12T05:38:53.967738Z",
"updated": "2025-02-12T05:41:01.225542Z"
},
{
"id": 975,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 1,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:4abdfa10-dd1a-4ebb-bad3-489000671acb",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 1,
"versionDeclined": null,
"created": "2025-02-12T05:38:55.899821Z",
"updated": "2025-02-12T05:38:55.899821Z"
},
{
"id": 976,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 1,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:6aff1b41-e406-41ff-9d31-70d02ef42deb",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 1,
"versionDeclined": null,
"created": "2025-02-12T05:38:57.228152Z",
"updated": "2025-02-12T05:38:57.228152Z"
},
{
"id": 977,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 3,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-12T05:38:58.538280Z",
"updated": "2025-02-12T05:38:58.538280Z"
}
]
{
"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"
}
]
}

View File

@@ -1,36 +1,121 @@
/* 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 `getEntityLinksByDownstreamContext()`
* Mock for `getEntityLinks()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinksByDownstreamContext(
downstreamContextKey: string,
): Promise<api.PublishableEntityLink[]> {
export async function mockGetEntityLinks(
downstreamContextKey?: string,
readyToSync?: boolean,
): ReturnType<typeof api.getEntityLinks> {
switch (downstreamContextKey) {
case mockGetEntityLinksByDownstreamContext.invalidCourseKey:
case mockGetEntityLinks.invalidCourseKey:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(downstreamContextKey),
path: api.getEntityLinksByDownstreamContextUrl(),
});
case mockGetEntityLinksByDownstreamContext.courseKeyLoading:
case mockGetEntityLinks.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinksByDownstreamContext.courseKeyEmpty:
return Promise.resolve([]);
default:
return Promise.resolve(mockGetEntityLinksByDownstreamContext.response);
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);
}
}
}
mockGetEntityLinksByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksByDownstreamContext.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinksByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinksByDownstreamContext.response = mockLinksResult;
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. */
mockGetEntityLinksByDownstreamContext.applyMock = () => {
jest.spyOn(api, 'getEntityLinksByDownstreamContext').mockImplementation(mockGetEntityLinksByDownstreamContext);
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);
};

View File

@@ -3,27 +3,84 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getEntityLinksByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/upstreams/${downstreamContextKey}`;
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: string;
upstreamVersion: number;
downstreamUsageKey: string;
downstreamContextTitle: string;
downstreamContextKey: string;
versionSynced: string;
versionDeclined: string;
versionSynced: number;
versionDeclined: number | null;
created: string;
updated: string;
readyToSync: boolean;
}
export const getEntityLinksByDownstreamContext = async (
downstreamContextKey: string,
): Promise<PublishableEntityLink[]> => {
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(downstreamContextKey));
.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);
};

View File

@@ -1,11 +1,10 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { waitFor } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react';
import { getEntityLinksByDownstreamContextUrl } from './api';
import { useEntityLinksByDownstreamContext } from './apiHooks';
import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks';
let axiosMock: MockAdapter;
@@ -36,15 +35,39 @@ describe('course libraries api hooks', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('should create library block', async () => {
const courseKey = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl(courseKey);
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useEntityLinksByDownstreamContext(courseKey), { wrapper });
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).toEqual([]);
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,
});
});
});

View File

@@ -1,20 +1,95 @@
import {
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { getEntityLinksByDownstreamContext } from './api';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
courseLibraries: (courseKey?: string) => [...courseLibrariesQueryKeys.all, courseKey],
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 a content library by its ID.
* 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 useEntityLinksByDownstreamContext = (courseKey: string | undefined) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey),
queryFn: () => getEntityLinksByDownstreamContext(courseKey!),
enabled: courseKey !== undefined,
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,
})
);

View File

@@ -1 +1 @@
export { default } from './CourseLibraries';
export { CourseLibraries } from './CourseLibraries';

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
},
homeTabDescription: {
id: 'course-authoring.course-libraries.tab.home.description',
defaultMessage: 'This course contains content from these libraries.',
defaultMessage: 'Your course contains content from these libraries.',
description: 'Description text for home tab',
},
homeTabDescriptionEmpty: {
@@ -28,18 +28,18 @@ const messages = defineMessages({
},
reviewTabTitle: {
id: 'course-authoring.course-libraries.tab.review.title',
defaultMessage: 'Review Content Updates ({count})',
defaultMessage: 'Review Content Updates',
description: 'Tab title for review tab',
},
reviewTabTitleEmpty: {
id: 'course-authoring.course-libraries.tab.review.title-no-updates',
defaultMessage: 'Review Content Updates',
description: 'Tab title for review tab when no updates are available',
reviewTabDescriptionEmpty: {
id: 'course-authoring.course-libraries.tab.home.description-no-links',
defaultMessage: 'All components are up to date',
description: 'Description text for home tab',
},
breadcrumbAriaLabel: {
id: 'course-authoring.course-libraries.downstream-block.breadcrumb.aria-label',
defaultMessage: 'Component breadcrumb',
description: 'Aria label for breadcrumb in component cards in course libraries page.',
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',
@@ -58,7 +58,7 @@ const messages = defineMessages({
},
outOfSyncCountAlertTitle: {
id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.title',
defaultMessage: '{outOfSyncCount} library components are out of sync. Review updates to accept or ignore changes',
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: {
@@ -76,6 +76,51 @@ const messages = defineMessages({
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;

View File

@@ -22,6 +22,7 @@ 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';
@@ -35,8 +36,6 @@ 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';
@@ -46,15 +45,16 @@ 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 '../generic/drag-helper/DraggableList';
import DraggableList from './drag-helper/DraggableList';
import {
canMoveSection,
possibleUnitMoves,
possibleSubsectionMoves,
} from '../generic/drag-helper/utils';
} from './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();
@@ -105,11 +105,9 @@ const CourseOutline = ({ courseId }) => {
handleNewUnitSubmit,
getUnitUrl,
handleVideoSharingOptionChange,
handleCopyToClipboardClick,
handlePasteClipboardClick,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextFeedbackUrl,
discussionsIncontextLearnmoreUrl,
deprecatedBlocksInfo,
proctoringErrors,
@@ -242,7 +240,6 @@ const CourseOutline = ({ courseId }) => {
notificationDismissUrl={notificationDismissUrl}
handleDismissNotification={handleDismissNotification}
discussionsSettings={discussionsSettings}
discussionsIncontextFeedbackUrl={discussionsIncontextFeedbackUrl}
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
deprecatedBlocksInfo={deprecatedBlocksInfo}
proctoringErrors={proctoringErrors}
@@ -267,7 +264,6 @@ const CourseOutline = ({ courseId }) => {
notificationDismissUrl={notificationDismissUrl}
handleDismissNotification={handleDismissNotification}
discussionsSettings={discussionsSettings}
discussionsIncontextFeedbackUrl={discussionsIncontextFeedbackUrl}
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
deprecatedBlocksInfo={deprecatedBlocksInfo}
proctoringErrors={proctoringErrors}
@@ -295,7 +291,7 @@ const CourseOutline = ({ courseId }) => {
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={(
<HeaderNavigations
<CourseOutlineHeaderActionsSlot
isReIndexShow={isReIndexShow}
isSectionsExpanded={isSectionsExpanded}
headerNavigationsActions={headerNavigationsActions}
@@ -303,6 +299,7 @@ const CourseOutline = ({ courseId }) => {
hasSections={Boolean(sectionsList.length)}
courseActions={courseActions}
errors={errors}
sections={sections}
/>
)}
/>
@@ -419,7 +416,6 @@ const CourseOutline = ({ courseId }) => {
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
/>
))}
@@ -457,7 +453,7 @@ const CourseOutline = ({ courseId }) => {
</article>
</Layout.Element>
<Layout.Element>
<OutlineSideBar courseId={courseId} />
<CourseAuthoringOutlineSidebarSlot courseId={courseId} />
</Layout.Element>
</Layout>
<EnableHighlightsModal

View File

@@ -1,4 +1,3 @@
@import "./header-navigations/HeaderNavigations";
@import "./status-bar/StatusBar";
@import "./section-card/SectionCard";
@import "./subsection-card/SubsectionCard";
@@ -8,3 +7,4 @@
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./xblock-status/XBlockStatus";
@import "./drag-helper/SortableItem";

View File

@@ -11,6 +11,7 @@ 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,
@@ -58,7 +59,7 @@ import {
moveUnitOver,
moveSubsection,
moveUnit,
} from '../generic/drag-helper/utils';
} from './drag-helper/utils';
let axiosMock;
let store;
@@ -67,13 +68,6 @@ 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(),
@@ -2173,7 +2167,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 act(async () => fireEvent.click(expandBtn));
await userEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -2182,9 +2176,6 @@ describe('<CourseOutline />', () => {
.onPost(getClipboardUrl(), {
usage_key: unit.id,
}).reply(200, clipboardUnit);
// check that initialUserClipboard state is empty
const { initialUserClipboard } = store.getState().courseOutline;
expect(initialUserClipboard).toBeUndefined();
// find menu button and click on it to open menu
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
@@ -2194,9 +2185,6 @@ describe('<CourseOutline />', () => {
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
await act(async () => fireEvent.click(copyButton));
// check that initialUserClipboard state is updated
expect(store.getState().generic.clipboardData).toEqual(clipboardUnit);
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
// find clipboard content label
const clipboardLabel = await within(subsectionElement).findByText(
@@ -2260,9 +2248,14 @@ describe('<CourseOutline />', () => {
it('should show toats on export tags', async () => {
const expectedResponse = 'this is a test';
axiosMock
// Delay to ensure we see "Please wait."
// Without the delay the success message renders too quickly
const delayedResponse = axiosMock
.onGet(exportTags(courseId))
.reply(200, expectedResponse);
.withDelayInMs(500);
delayedResponse(200, expectedResponse);
useLocation.mockReturnValue({
pathname: '/foo-bar',
hash: '#export-tags',
@@ -2270,37 +2263,41 @@ describe('<CourseOutline />', () => {
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
window.URL.revokeObjectURL = jest.fn();
render(<RootWrapper />);
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
await screen.findByText('Please wait. Creating export file for course tags...');
const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
expect(expectedRequest.length).toBe(1);
expect(await screen.findByText('Course tags exported successfully')).toBeInTheDocument();
await screen.findByText('Course tags exported successfully');
});
it('should show toast on export tags error', async () => {
axiosMock
// Delay to ensure we see "Please wait."
// Without the delay the error renders too quickly
const delayedResponse = axiosMock
.onGet(exportTags(courseId))
.reply(404);
.withDelayInMs(500);
delayedResponse(404);
useLocation.mockReturnValue({
pathname: '/foo-bar',
hash: '#export-tags',
});
render(<RootWrapper />);
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();
await screen.findByText('Please wait. Creating export file for course tags...');
await screen.findByText('An error has occurred creating the file');
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
it('sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(403);
const { getByRole } = render(<RootWrapper />);
const { getByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(getByRole('alert')).toBeInTheDocument();
expect(getByTestId('redux-provider')).toBeInTheDocument();
const { outlineIndexLoadingStatus } = store.getState().courseOutline.loadingStatus;
expect(outlineIndexLoadingStatus).toEqual(RequestStatus.DENIED);
});

View File

@@ -60,7 +60,7 @@ module.exports = {
highlightsEnabledForMessaging: false,
highlightsEnabled: true,
highlightsPreviewOnly: false,
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',
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
enableProctoredExams: true,
createZendeskTickets: true,
enableTimedExams: true,
@@ -128,7 +128,7 @@ module.exports = {
],
highlightsEnabled: true,
highlightsPreviewOnly: false,
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',
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
@@ -517,7 +517,7 @@ module.exports = {
],
highlightsEnabled: true,
highlightsPreviewOnly: false,
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',
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
@@ -1837,7 +1837,7 @@ module.exports = {
highlights: [],
highlightsEnabled: true,
highlightsPreviewOnly: false,
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',
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
@@ -2787,7 +2787,7 @@ module.exports = {
highlights: [],
highlightsEnabled: true,
highlightsPreviewOnly: false,
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',
highlightsDocUrl: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
@@ -3044,7 +3044,6 @@ module.exports = {
blocks: [],
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
},
discussionsIncontextFeedbackUrl: '',
discussionsIncontextLearnmoreUrl: '',
initialState: {
expandedLocators: [

View File

@@ -6,7 +6,6 @@ module.exports = {
blocks: [],
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
},
discussionsIncontextFeedbackUrl: '',
discussionsIncontextLearnmoreUrl: '',
initialState: {
expandedLocators: [

View File

@@ -55,7 +55,7 @@ module.exports = {
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
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',
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',

View File

@@ -54,6 +54,7 @@ const CardHeader = ({
discussionEnabled,
discussionsSettings,
parentInfo,
extraActionsComponent,
}) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -145,6 +146,7 @@ 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"
@@ -252,6 +254,7 @@ CardHeader.defaultProps = {
discussionsSettings: {},
parentInfo: {},
cardId: '',
extraActionsComponent: null,
};
CardHeader.propTypes = {
@@ -295,6 +298,9 @@ 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;

View File

@@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
/**
@@ -36,7 +35,6 @@ 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

View File

@@ -1,5 +1,4 @@
import { RequestStatus } from '../../data/constants';
import { updateClipboardData } from '../../generic/data/slice';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { COURSE_BLOCK_NAMES } from '../constants';
import {
@@ -71,7 +70,6 @@ export function fetchCourseOutlineIndexQuery(courseId) {
},
} = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
dispatch(updateStatusBar({
courseReleaseDate,
highlightsEnabledForMessaging,

View File

@@ -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>
</>
);
};

View File

@@ -1,4 +0,0 @@
.header-navigations {
display: flex;
gap: .75rem;
}

View File

@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { copyToClipboard } from '../generic/data/thunks';
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
import { getWaffleFlags } from '../data/selectors';
import { RequestStatus } from '../data/constants';
@@ -67,13 +66,13 @@ const useCourseOutline = ({ courseId }) => {
lmsLink,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextFeedbackUrl,
discussionsIncontextLearnmoreUrl,
deprecatedBlocksInfo,
proctoringErrors,
mfeProctoredExamSettingsUrl,
advanceSettingsUrl,
} = useSelector(getOutlineIndexData);
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
const statusBarData = useSelector(getStatusBarData);
const savingStatus = useSelector(getSavingStatus);
@@ -97,10 +96,6 @@ const useCourseOutline = ({ courseId }) => {
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
const handleCopyToClipboardClick = (usageKey) => {
dispatch(copyToClipboard(usageKey));
};
const handlePasteClipboardClick = (parentLocator, sectionId) => {
dispatch(pasteClipboardContent(parentLocator, sectionId));
};
@@ -342,11 +337,9 @@ const useCourseOutline = ({ courseId }) => {
openUnitPage,
handleNewUnitSubmit,
handleVideoSharingOptionChange,
handleCopyToClipboardClick,
handlePasteClipboardClick,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextFeedbackUrl,
discussionsIncontextLearnmoreUrl,
deprecatedBlocksInfo,
proctoringErrors,

View File

@@ -26,7 +26,6 @@ const OutlineSideBar = ({ courseId }) => {
return (
<HelpSidebar
intl={intl}
courseId={courseId}
showOtherSettings={false}
className="outline-sidebar mt-4"

View File

@@ -13,7 +13,7 @@ import {
import {
Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { Link, useNavigate } 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,6 +48,8 @@ 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') {
@@ -111,13 +113,6 @@ const PageAlerts = ({
platformName: process.env.SITE_NAME,
})}
</div>
<Hyperlink
showLaunchIcon={false}
destination={discussionsIncontextFeedbackUrl}
target="_blank"
>
{intl.formatMessage(messages.discussionNotificationFeedback)}
</Hyperlink>
</Alert>
);
};
@@ -419,6 +414,15 @@ const PageAlerts = ({
);
};
const renderOutOfSyncAlert = () => (
<OutOfSyncAlert
courseId={courseId}
onReview={() => navigate(`/course/${courseId}/libraries?tab=review`)}
showAlert={showOutOfSyncAlert}
setShowAlert={setShowOutOfSyncAlert}
/>
);
return (
<>
{configurationErrors()}
@@ -432,6 +436,7 @@ const PageAlerts = ({
{errorFilesPasteAlert()}
{conflictingFilesPasteAlert()}
{newFilesPasteAlert()}
{renderOutOfSyncAlert()}
</>
);
};
@@ -440,7 +445,6 @@ PageAlerts.defaultProps = {
notificationDismissUrl: '',
handleDismissNotification: null,
discussionsSettings: {},
discussionsIncontextFeedbackUrl: '',
discussionsIncontextLearnmoreUrl: '',
deprecatedBlocksInfo: {},
proctoringErrors: [],
@@ -457,7 +461,6 @@ PageAlerts.propTypes = {
discussionsSettings: PropTypes.shape({
providerType: PropTypes.string,
}),
discussionsIncontextFeedbackUrl: PropTypes.string,
discussionsIncontextLearnmoreUrl: PropTypes.string,
deprecatedBlocksInfo: PropTypes.shape({
blocks: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),

View File

@@ -5,7 +5,6 @@ import {
render,
fireEvent,
screen,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -28,6 +27,13 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('../../course-libraries/data/apiHooks', () => ({
useEntityLinksSummaryByDownstreamContext: () => ({
data: [],
isLoading: false,
}),
}));
let store;
const handleDismissNotification = jest.fn();
@@ -36,7 +42,6 @@ const pageAlertsData = {
notificationDismissUrl: '',
handleDismissNotification: null,
discussionsSettings: {},
discussionsIncontextFeedbackUrl: '',
discussionsIncontextLearnmoreUrl: '',
deprecatedBlocksInfo: {},
proctoringErrors: [],
@@ -70,9 +75,9 @@ describe('<PageAlerts />', () => {
useSelector.mockReturnValue({});
});
it('renders null when no alerts are present', () => {
it('renders null when no alerts are present', async () => {
renderComponent();
expect(screen.queryByTestId('browser-router')).toBeEmptyDOMElement();
expect(await screen.findByTestId('browser-router')).toBeEmptyDOMElement();
});
it('renders configuration alerts', async () => {
@@ -95,7 +100,6 @@ describe('<PageAlerts />', () => {
discussionsSettings: {
providerType: 'openedx',
},
discussionsIncontextFeedbackUrl: 'some-feedback-url',
discussionsIncontextLearnmoreUrl: 'some-learn-more-url',
});
@@ -108,12 +112,6 @@ 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 () => {

View File

@@ -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 '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../drag-helper/DragContextProvider';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';

View File

@@ -11,12 +11,13 @@ 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 '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
import SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../drag-helper/DragContextProvider';
import { useClipboard, PasteComponent } from '../../generic/clipboard';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
@@ -49,7 +50,7 @@ const SubsectionCard = ({
const isScrolledToElement = locatorId === subsection.id;
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'subsection';
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
const { sharedClipboardData, showPasteUnit } = useClipboard();
const {
id,
@@ -127,6 +128,13 @@ const SubsectionCard = ({
/>
);
const extraActionsComponent = (
<CourseOutlineSubsectionCardExtraActionsSlot
subsection={subsection}
section={section}
/>
);
useEffect(() => {
if (activeId === id && isExpanded) {
setIsExpanded(false);
@@ -205,6 +213,7 @@ const SubsectionCard = ({
actions={actions}
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
isSequential
extraActionsComponent={extraActionsComponent}
/>
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
<XBlockStatus
@@ -233,7 +242,7 @@ const SubsectionCard = ({
>
{intl.formatMessage(messages.newUnitButton)}
</Button>
{enableCopyPasteUnits && showPasteUnit && (
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
<PasteComponent
className="mt-4"
text={intl.formatMessage(messages.pasteButton)}

View File

@@ -21,13 +21,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const unit = {
id: 'unit-1',
};

View File

@@ -6,13 +6,15 @@ import { useToggle } from '@openedx/paragon';
import { isEmpty } from 'lodash';
import { useSearchParams } from 'react-router-dom';
import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
import CardHeader from '../card-header/CardHeader';
import SortableItem from '../../generic/drag-helper/SortableItem';
import SortableItem from '../drag-helper/SortableItem';
import TitleLink from '../card-header/TitleLink';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import { useClipboard } from '../../generic/clipboard';
const UnitCard = ({
unit,
@@ -30,7 +32,6 @@ const UnitCard = ({
onDuplicateSubmit,
getTitleLink,
onOrderChange,
onCopyToClipboardClick,
discussionsSettings,
}) => {
const currentRef = useRef(null);
@@ -41,6 +42,8 @@ const UnitCard = ({
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'unit';
const { copyToClipboard } = useClipboard();
const {
id,
category,
@@ -98,7 +101,7 @@ const UnitCard = ({
};
const handleCopyClick = () => {
onCopyToClipboardClick(unit.id);
copyToClipboard(id);
};
const titleComponent = (
@@ -109,6 +112,14 @@ const UnitCard = ({
/>
);
const extraActionsComponent = (
<CourseOutlineUnitCardExtraActionsSlot
unit={unit}
subsection={subsection}
section={section}
/>
);
useEffect(() => {
// if this items has been newly added, scroll to it.
// we need to check section.shouldScroll as whole section is fetched when a
@@ -175,6 +186,7 @@ const UnitCard = ({
discussionEnabled={discussionEnabled}
discussionsSettings={discussionsSettings}
parentInfo={parentInfo}
extraActionsComponent={extraActionsComponent}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
@@ -241,7 +253,6 @@ UnitCard.propTypes = {
onOrderChange: PropTypes.func.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
onCopyToClipboardClick: PropTypes.func.isRequired,
discussionsSettings: PropTypes.shape({
providerType: PropTypes.string,
enableGradedUnits: PropTypes.bool,

View File

@@ -1,4 +1,3 @@
import React from 'react';
import {
act, render, fireEvent, within,
} from '@testing-library/react';
@@ -62,7 +61,6 @@ const renderComponent = (props) => render(
onOpenPublishModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
onCopyToClipboardClick={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}

View File

@@ -6,7 +6,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import {
act, fireEvent, render, waitFor,
fireEvent, render, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
@@ -78,16 +78,15 @@ describe('<CourseRerun />', () => {
it('shows the spinner before the query is complete', async () => {
useSelector.mockReturnValue({ organizationLoadingStatus: RequestStatus.IN_PROGRESS });
await act(async () => {
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
const { findByRole } = render(<RootWrapper />);
const spinner = await findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('should show footer', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Looking for help with Studio?')).toBeInTheDocument();
expect(getByText('LMS')).toHaveAttribute('href', process.env.LMS_BASE_URL);
it('should show footer', async () => {
const { findByText } = render(<RootWrapper />);
await findByText('Looking for help with Studio?');
const lmsElement = await findByText('LMS');
expect(lmsElement).toHaveAttribute('href', process.env.LMS_BASE_URL);
});
});

View File

@@ -7,7 +7,8 @@ import {
ActionRow,
Button,
} from '@openedx/paragon';
import { StudioFooter } from '@edx/frontend-component-footer';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { useNavigate, useParams } from 'react-router-dom';
import Header from '../header';
@@ -88,7 +89,7 @@ const CourseRerun = () => {
isQueryPending={savingStatus === RequestStatus.PENDING}
/>
</div>
<StudioFooter />
<StudioFooterSlot />
</>
);
};

View File

@@ -5,12 +5,12 @@ import { useParams } from 'react-router-dom';
import {
Container, Layout, Stack, Button, TransitionReplace,
} from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
} from '@openedx/paragon/icons';
import { CourseAuthoringUnitSidebarSlot } from '../plugin-slots/CourseAuthoringUnitSidebarSlot';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import SubHeader from '../generic/sub-header/SubHeader';
@@ -25,18 +25,16 @@ import Loading from '../generic/Loading';
import AddComponent from './add-component/AddComponent';
import HeaderTitle from './header-title/HeaderTitle';
import Breadcrumbs from './breadcrumbs/Breadcrumbs';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import Sequence from './course-sequence';
import Sidebar from './sidebar';
import SplitTestSidebarInfo from './sidebar/SplitTestSidebarInfo';
import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks';
import messages from './messages';
import PublishControls from './sidebar/PublishControls';
import LocationInfo from './sidebar/LocationInfo';
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
import { PasteNotificationAlert } from './clipboard';
import XBlockContainerIframe from './xblock-container-iframe';
import MoveModal from './move-modal';
import PreviewLibraryXBlockChanges from './preview-changes';
import IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -52,6 +50,7 @@ const CourseUnit = ({ courseId }) => {
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
staticFileNotices,
currentlyVisibleToStudents,
unitXBlockActions,
@@ -72,6 +71,7 @@ const CourseUnit = ({ courseId }) => {
handleRollbackMovedXBlock,
handleCloseXBlockMovedAlert,
handleNavigateToTargetUnit,
addComponentTemplateData,
} = useCourseUnit({ courseId, blockId });
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);
@@ -154,9 +154,11 @@ const CourseUnit = ({ courseId }) => {
/>
)}
headerActions={(
<HeaderNavigations
unitCategory={unitCategory}
<CourseUnitHeaderActionsSlot
category={unitCategory}
headerNavigationsActions={headerNavigationsActions}
unitTitle={unitTitle}
verticalBlocks={courseVerticalChildren.children}
/>
)}
/>
@@ -188,20 +190,24 @@ const CourseUnit = ({ courseId }) => {
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
{isUnitVerticalType && (
<AddComponent
blockId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
/>
)}
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
{showPasteXBlock && canPasteComponent && isUnitVerticalType && (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={handleCreateNewCourseXBlock}
onClick={
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
}
text={intl.formatMessage(messages.pasteButtonText)}
/>
)}
@@ -211,24 +217,21 @@ const CourseUnit = ({ courseId }) => {
closeModal={closeMoveModal}
courseId={courseId}
/>
<PreviewLibraryXBlockChanges />
<IframePreviewLibraryXBlockChanges />
</Layout.Element>
<Layout.Element>
<Stack gap={3}>
{isUnitVerticalType && (
<>
<Sidebar data-testid="course-unit-sidebar">
<PublishControls blockId={blockId} />
</Sidebar>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Sidebar className="tags-sidebar">
<TagsSidebarControls />
</Sidebar>
)}
<Sidebar data-testid="course-unit-location-sidebar">
<LocationInfo />
</Sidebar>
</>
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
/>
)}
{isSplitTestType && (
<Sidebar data-testid="course-split-test-sidebar">
<SplitTestSidebarInfo />
</Sidebar>
)}
</Stack>
</Layout.Element>
@@ -253,4 +256,4 @@ CourseUnit.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default injectIntl(CourseUnit);
export default CourseUnit;

View File

@@ -5,6 +5,7 @@
@import "./header-title/HeaderTitle";
@import "./move-modal";
@import "./preview-changes";
@import "./xblock-container-iframe";
.course-unit {
min-width: 900px;

View File

@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act, render, waitFor, within, screen,
} from '@testing-library/react';
@@ -47,10 +48,8 @@ import { executeThunk } from '../utils';
import { IFRAME_FEATURE_POLICY } from '../constants';
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
import headerNavigationsMessages from './header-navigations/messages';
import headerTitleMessages from './header-title/messages';
import courseSequenceMessages from './course-sequence/messages';
import sidebarMessages from './sidebar/messages';
import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
@@ -60,13 +59,16 @@ import configureModalMessages from '../generic/configure-modal/messages';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
import addComponentMessages from './add-component/messages';
import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
import { IframeProvider } from './context/iFrameContext';
import { IframeProvider } from '../generic/hooks/context/iFrameContext';
import moveModalMessages from './move-modal/messages';
import xblockContainerIframeMessages from './xblock-container-iframe/messages';
import headerNavigationsMessages from './header-navigations/messages';
import sidebarMessages from './sidebar/messages';
import messages from './messages';
let axiosMock;
let store;
let queryClient;
const courseId = '123';
const blockId = '567890';
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
@@ -91,52 +93,6 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate,
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(({ queryKey }) => {
const taxonomyApiHooksModule = jest.requireActual('../taxonomy/data/apiHooks');
const actualQueryKeys = taxonomyApiHooksModule.taxonomyQueryKeys;
if (queryKey[0] === 'contentTaxonomyTags') {
return {
data: {
taxonomies: [],
},
isSuccess: true,
};
} if (queryKey[0] === 'contentTagsCount') {
return {
data: 17,
isSuccess: true,
};
}
if (actualQueryKeys.all.includes(queryKey[0])) {
return {
data: {
results: [],
},
isSuccess: true,
};
}
return {
data: {},
isSuccess: true,
};
}),
useQueryClient: jest.fn(() => ({
setQueryData: jest.fn(),
})),
useMutation: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
/**
* Simulates receiving a post message event for testing purposes.
* This can be used to mimic events like deletion or other actions
@@ -157,7 +113,9 @@ const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<IframeProvider>
<CourseUnit courseId={courseId} />
<QueryClientProvider client={queryClient}>
<CourseUnit courseId={courseId} />
</QueryClientProvider>
</IframeProvider>
</IntlProvider>
</AppProvider>
@@ -176,6 +134,13 @@ describe('<CourseUnit />', () => {
window.scrollTo = jest.fn();
global.localStorage.clear();
store = initializeStore();
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getClipboardUrl())
@@ -194,7 +159,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
axiosMock
.onGet(getContentTaxonomyTagsApiUrl(blockId))
.reply(200, {});
.reply(200, { taxonomies: [] });
axiosMock
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
.reply(200, 17);
@@ -224,7 +189,7 @@ describe('<CourseUnit />', () => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`);
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;');
expect(iframe).toHaveAttribute('style', 'height: 0px;');
expect(iframe).toHaveAttribute('scrolling', 'no');
expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
expect(iframe).toHaveAttribute('loading', 'lazy');
@@ -233,16 +198,15 @@ describe('<CourseUnit />', () => {
});
it('adjusts iframe height dynamically based on courseXBlockDropdownHeight postMessage event', async () => {
const { getByTitle } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;');
simulatePostMessageEvent(messageTypes.toggleCourseXBlockDropdown, {
courseXBlockDropdownHeight: 200,
});
expect(iframe).toHaveAttribute('style', 'width: 100%; height: 200px;');
let iframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('style', 'height: 0px;');
simulatePostMessageEvent(messageTypes.toggleCourseXBlockDropdown, {
courseXBlockDropdownHeight: 200,
});
iframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('style', 'height: 200px;');
});
it('displays an error alert when a studioAjaxError message is received', async () => {
@@ -274,16 +238,13 @@ describe('<CourseUnit />', () => {
});
it('renders the xBlocks iframe and opens the tags drawer on postMessage event', async () => {
const { getByTitle, getByText } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
});
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId });
expect(getByText(tagsDrawerMessages.headerSubtitle.defaultMessage)).toBeInTheDocument();
await screen.findByText(tagsDrawerMessages.headerSubtitle.defaultMessage);
});
it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => {
@@ -380,10 +341,10 @@ describe('<CourseUnit />', () => {
it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => {
const {
getByTitle, getByText, queryByRole, getAllByRole, getByRole,
getByTitle, getByText, queryByRole, getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
await waitFor(async () => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
@@ -398,13 +359,12 @@ describe('<CourseUnit />', () => {
expect(getByText(/Delete this component?/i)).toBeInTheDocument();
expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument();
expect(getByRole('dialog')).toBeInTheDocument();
const dialog = getByRole('dialog');
expect(dialog).toBeInTheDocument();
// Find the Cancel and Delete buttons within the iframe by their specific classes
const cancelButton = getAllByRole('button', { name: /Cancel/i })
.find(({ classList }) => classList.contains('btn-tertiary'));
const deleteButton = getAllByRole('button', { name: /Delete/i })
.find(({ classList }) => classList.contains('btn-primary'));
const cancelButton = await within(dialog).findByRole('button', { name: /Cancel/i });
const deleteButton = await within(dialog).findByRole('button', { name: /Delete/i });
expect(cancelButton).toBeInTheDocument();
@@ -1312,13 +1272,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1368,11 +1321,9 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardXBlock,
});
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
@@ -1443,13 +1394,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1502,13 +1446,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1563,13 +1500,6 @@ describe('<CourseUnit />', () => {
enable_copy_paste_units: true,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
user_clipboard: clipboardUnit,
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
@@ -1657,27 +1587,21 @@ describe('<CourseUnit />', () => {
it('should display "Move Modal" on receive trigger message', async () => {
const {
getByText,
getByRole,
} = render(<RootWrapper />);
await act(async () => {
await waitFor(() => {
expect(getByText(unitDisplayName))
.toBeInTheDocument();
});
await screen.findByText(unitDisplayName);
axiosMock
.onGet(getCourseOutlineInfoUrl(courseId))
.reply(200, courseOutlineInfoMock);
await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch);
axiosMock
.onGet(getCourseOutlineInfoUrl(courseId))
.reply(200, courseOutlineInfoMock);
await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch);
window.dispatchEvent(messageEvent);
});
window.dispatchEvent(messageEvent);
expect(getByText(
await screen.findByText(
moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title),
)).toBeInTheDocument();
);
expect(getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument();
});
@@ -1688,23 +1612,18 @@ describe('<CourseUnit />', () => {
getByRole,
} = render(<RootWrapper />);
await act(async () => {
await waitFor(() => {
expect(getByText(unitDisplayName))
.toBeInTheDocument();
});
await screen.findByText(unitDisplayName);
axiosMock
.onGet(getCourseOutlineInfoUrl(courseId))
.reply(200, courseOutlineInfoMock);
await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch);
axiosMock
.onGet(getCourseOutlineInfoUrl(courseId))
.reply(200, courseOutlineInfoMock);
await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch);
window.dispatchEvent(messageEvent);
});
window.dispatchEvent(messageEvent);
expect(getByText(
await screen.findByText(
moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title),
)).toBeInTheDocument();
);
const currentSection = courseOutlineInfoMock.child_info.children[1];
const currentSectionItemBtn = getByRole('button', {
@@ -1732,7 +1651,6 @@ describe('<CourseUnit />', () => {
it('should allow move operation and handles it successfully', async () => {
const {
getByText,
getByRole,
} = render(<RootWrapper />);
@@ -1744,23 +1662,18 @@ describe('<CourseUnit />', () => {
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
await act(async () => {
await waitFor(() => {
expect(getByText(unitDisplayName))
.toBeInTheDocument();
});
await screen.findByText(unitDisplayName);
axiosMock
.onGet(getCourseOutlineInfoUrl(courseId))
.reply(200, courseOutlineInfoMock);
await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch);
axiosMock
.onGet(getCourseOutlineInfoUrl(courseId))
.reply(200, courseOutlineInfoMock);
await executeThunk(getCourseOutlineInfoQuery(courseId), store.dispatch);
window.dispatchEvent(messageEvent);
});
window.dispatchEvent(messageEvent);
expect(getByText(
await screen.findByText(
moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title),
)).toBeInTheDocument();
);
const currentSection = courseOutlineInfoMock.child_info.children[1];
const currentSectionItemBtn = getByRole('button', {
@@ -1880,7 +1793,7 @@ describe('<CourseUnit />', () => {
});
describe('XBlock restrict access', () => {
it('opens xblock restrict access modal successfully', () => {
it('opens xblock restrict access modal successfully', async () => {
const {
getByTitle, getByTestId,
} = render(<RootWrapper />);
@@ -1889,7 +1802,7 @@ describe('<CourseUnit />', () => {
const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage;
const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage;
waitFor(() => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const usageId = courseVerticalChildrenMock.children[0].block_id;
expect(iframe).toBeInTheDocument();
@@ -1899,7 +1812,7 @@ describe('<CourseUnit />', () => {
});
});
waitFor(() => {
await waitFor(() => {
const configureModal = getByTestId('configure-modal');
expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument();
@@ -1913,7 +1826,7 @@ describe('<CourseUnit />', () => {
getByTitle, queryByTestId, getByTestId,
} = render(<RootWrapper />);
waitFor(() => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.manageXBlockAccess, {
@@ -1921,7 +1834,7 @@ describe('<CourseUnit />', () => {
});
});
waitFor(() => {
await waitFor(() => {
const configureModal = getByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
userEvent.click(within(configureModal).getByRole('button', {
@@ -1942,52 +1855,313 @@ describe('<CourseUnit />', () => {
.reply(200, { dummy: 'value' });
const {
getByTitle, getByRole, getByTestId,
getByTitle, getByRole, getByTestId, queryByTestId,
} = render(<RootWrapper />);
const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name;
const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name;
waitFor(() => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
});
await act(async () => {
simulatePostMessageEvent(messageTypes.manageXBlockAccess, {
usageId: courseVerticalChildrenMock.children[0].block_id,
});
});
waitFor(() => {
const configureModal = getByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
const configureModal = await waitFor(() => getByTestId('configure-modal'));
expect(configureModal).toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
const restrictAccessSelect = getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
const restrictAccessSelect = getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
});
await userEvent.selectOptions(restrictAccessSelect, '0');
await waitFor(() => {
userPartitionInfoFormatted.selectablePartitions[0].groups.forEach((group) => {
const checkbox = within(configureModal).getByRole('checkbox', { name: group.name });
expect(checkbox).not.toBeChecked();
expect(checkbox).toBeInTheDocument();
});
});
userEvent.selectOptions(restrictAccessSelect, '0');
const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 });
await userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
// eslint-disable-next-line array-callback-return
userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => {
expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked();
expect(within(configureModal).queryByText(group.name)).toBeInTheDocument();
const saveModalBtnText = within(configureModal).getByRole('button', {
name: configureModalMessages.saveButton.defaultMessage,
});
expect(saveModalBtnText).toBeInTheDocument();
await userEvent.click(saveModalBtnText);
await waitFor(() => {
expect(axiosMock.history.post.length).toBeGreaterThan(0);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id));
});
expect(queryByTestId('configure-modal')).not.toBeInTheDocument();
});
});
const checkLegacyEditModalOnEditMessage = async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editButton = getByTestId('header-edit-button');
expect(editButton).toBeInTheDocument();
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
userEvent.click(editButton);
});
};
const checkRenderVisibilityModal = async (headingMessageId) => {
const { findByRole, getByTestId } = render(<RootWrapper />);
let configureModal;
let restrictAccessSelect;
const headerConfigureBtn = await findByRole('button', { name: /settings/i });
await userEvent.click(headerConfigureBtn);
await waitFor(() => {
configureModal = getByTestId('configure-modal');
restrictAccessSelect = within(configureModal)
.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage });
expect(within(configureModal)
.getByRole('heading', { name: configureModalMessages[headingMessageId].defaultMessage })).toBeInTheDocument();
expect(within(configureModal)
.queryByText(configureModalMessages.unitVisibility.defaultMessage)).not.toBeInTheDocument();
expect(within(configureModal)
.getByText(configureModalMessages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
expect(restrictAccessSelect).toBeInTheDocument();
expect(restrictAccessSelect).toHaveValue('-1');
});
const modalSaveBtn = within(configureModal)
.getByRole('button', { name: configureModalMessages.saveButton.defaultMessage });
userEvent.click(modalSaveBtn);
};
describe('Library Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock: {
...courseSectionVerticalMock.xblock,
category: 'library_content',
},
xblock_info: {
...courseSectionVerticalMock.xblock_info,
category: 'library_content',
},
});
const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 });
userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
const saveModalBtnText = within(configureModal).getByRole('button', {
name: configureModalMessages.saveButton.defaultMessage,
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
category: 'library_content',
ancestor_info: {
...courseUnitIndexMock.ancestor_info,
child_info: {
...courseUnitIndexMock.ancestor_info.child_info,
category: 'library_content',
},
},
});
expect(saveModalBtnText).toBeInTheDocument();
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
userEvent.click(saveModalBtnText);
expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1);
it('navigates to library content page on receive window event', async () => {
render(<RootWrapper />);
await waitFor(() => {
simulatePostMessageEvent(messageTypes.handleViewXBlockContent, { usageId: newUnitId });
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${newUnitId}/${sequenceId}`);
});
});
it('should render library content page correctly', async () => {
const {
findByText,
getByRole,
queryByRole,
findByTestId,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const unitHeaderTitle = await findByTestId('unit-header-title');
await findByText(unitDisplayName);
await waitFor(() => {
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument();
});
});
it('should display visibility modal correctly', async () => (
checkRenderVisibilityModal('libraryContentAccess')
));
it('opens legacy edit modal on edit button click', checkLegacyEditModalOnEditMessage);
});
describe('Split Test Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock: {
...courseSectionVerticalMock.xblock,
category: 'split_test',
},
xblock_info: {
...courseSectionVerticalMock.xblock_info,
category: 'split_test',
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
category: 'split_test',
ancestor_info: {
...courseUnitIndexMock.ancestor_info,
child_info: {
...courseUnitIndexMock.ancestor_info.child_info,
category: 'split_test',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
it('navigates to split test content page on receive window event', async () => {
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.handleViewXBlockContent, { usageId: newUnitId });
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${newUnitId}/${sequenceId}`);
});
it('navigates to group configuration page on receive window event', async () => {
const groupId = 12345;
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.handleViewGroupConfigurations, { usageId: `${courseId}#${groupId}` });
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/group_configurations#${groupId}`);
});
it('displays processing notification on receiving post message', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
await waitFor(() => {
simulatePostMessageEvent(messageTypes.addNewComponent);
expect(getByText(('Adding'))).toBeInTheDocument();
});
await waitFor(() => {
simulatePostMessageEvent(messageTypes.hideProcessingNotification);
expect(queryByText(('Adding'))).not.toBeInTheDocument();
});
await waitFor(() => {
simulatePostMessageEvent(messageTypes.pasteNewComponent);
expect(getByText(('Pasting'))).toBeInTheDocument();
});
await waitFor(() => {
simulatePostMessageEvent(messageTypes.hideProcessingNotification);
expect(queryByText(('Pasting'))).not.toBeInTheDocument();
});
});
it('should render split test content page correctly', async () => {
const {
getByText,
getByRole,
queryByRole,
getByTestId,
queryByText,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument();
// Sidebar
const sidebarContent = [
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage },
{ query: queryByRole, type: 'link', name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage },
];
sidebarContent.forEach(({ query, type, name }) => {
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument();
});
expect(
queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
).toHaveAttribute('href', helpLinkUrl);
});
});
it('should display visibility modal correctly', async () => (
checkRenderVisibilityModal('splitTestAccess')
));
it('opens legacy edit modal on edit button click', checkLegacyEditModalOnEditMessage);
});
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
@@ -2021,61 +2195,4 @@ describe('<CourseUnit />', () => {
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
});
});
describe('Library Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock: {
...courseSectionVerticalMock.xblock,
category: 'library_content',
},
xblock_info: {
...courseSectionVerticalMock.xblock_info,
category: 'library_content',
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('navigates to library content page on receive window event', () => {
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.handleViewXBlockContent, { usageId: newUnitId });
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${newUnitId}/${sequenceId}`);
});
it('should render library content page correctly', async () => {
const {
getByText,
getByRole,
queryByRole,
getByTestId,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
await waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument();
});
});
});
});

View File

@@ -5,7 +5,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
unit_level_discussions: false,
child_info: {
category: 'chapter',
@@ -18,7 +18,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',
@@ -30,7 +30,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -42,7 +42,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -52,7 +52,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@f7cc083ff66d442eafafd48152881276',
@@ -61,7 +61,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd',
@@ -70,7 +70,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@6e72ebc448694e42ac56553af74304e7',
@@ -79,7 +79,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -94,7 +94,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -106,7 +106,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',
@@ -118,7 +118,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -130,7 +130,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -140,7 +140,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -152,7 +152,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -162,7 +162,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@7e9b434e6de3435ab99bd3fb25bde807',
@@ -171,7 +171,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@412dc8dbb6674014862237b23c1f643f',
@@ -180,7 +180,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -192,7 +192,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -202,7 +202,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@5c90cffecd9b48b188cbfea176bf7fe9',
@@ -211,7 +211,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@722085be27c84ac693cfebc8ac5da700',
@@ -220,7 +220,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -232,7 +232,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -242,7 +242,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@9f9e1373cc8243b985c8750cc8acec7d',
@@ -251,7 +251,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -263,7 +263,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -273,7 +273,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6',
@@ -282,7 +282,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@e2cb0e0994f84b0abfa5f4ae42ed9d44',
@@ -291,7 +291,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -303,7 +303,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -313,7 +313,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@3169f89efde2452993f2f2d9bc74f5b2',
@@ -322,7 +322,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -334,7 +334,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -344,7 +344,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@1c8d47c425724346a7968fa1bc745dcd',
@@ -353,7 +353,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -365,7 +365,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -375,7 +375,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@2574c523e97b477a9d72fbb37bfb995f',
@@ -384,7 +384,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@932e6f2ce8274072a355a94560216d1a',
@@ -393,7 +393,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@303034da25524878a2e66fb57c91cf85',
@@ -402,7 +402,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ffa5817d49e14fec83ad6187cbe16358',
@@ -411,7 +411,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -423,7 +423,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -433,7 +433,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -448,7 +448,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -460,7 +460,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -470,7 +470,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@e5eac7e1a5a24f5fa7ed77bb6d136591',
@@ -479,7 +479,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -491,7 +491,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -501,7 +501,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@5ab88e67d46049b9aa694cb240c39cef',
@@ -510,7 +510,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -522,7 +522,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -532,7 +532,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@67c26b1e826e47aaa29757f62bcd1ad0',
@@ -541,7 +541,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -553,7 +553,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -563,7 +563,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@870371212ba04dcf9536d7c7b8f3109e',
@@ -572,7 +572,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -584,7 +584,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -594,7 +594,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@4d672c5893cb4f1dad0de67d2008522e',
@@ -603,7 +603,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -615,7 +615,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -625,7 +625,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@501aed9d902349eeb2191fa505548de2',
@@ -634,7 +634,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -646,7 +646,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -656,7 +656,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@6244918637ed4ff4b5f94a840a7e4b43',
@@ -665,7 +665,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -677,7 +677,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [],
},
@@ -695,7 +695,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',
@@ -707,7 +707,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -719,7 +719,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -729,7 +729,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -741,7 +741,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -751,7 +751,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@6f7a6670f87147149caeff6afa07a526',
@@ -760,7 +760,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -772,7 +772,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -782,7 +782,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@e0d7423118ab432582d03e8e8dad8e36',
@@ -791,7 +791,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -803,7 +803,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -813,7 +813,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@03f051f9a8814881a3783d2511613aa6',
@@ -822,7 +822,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -834,7 +834,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -844,7 +844,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -859,7 +859,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -871,7 +871,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -881,7 +881,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -893,7 +893,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -903,7 +903,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader',
@@ -912,7 +912,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@c6cd4bea43454aaea60ad01beb0cf213',
@@ -921,7 +921,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -933,7 +933,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -943,7 +943,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@free_form_simulation',
@@ -952,7 +952,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@logic_gate_problem',
@@ -961,7 +961,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@4f06b358a96f4d1dae57d6d81acd06f2',
@@ -970,7 +970,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -982,7 +982,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -992,7 +992,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@700x_proteinmake',
@@ -1001,7 +1001,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ed01bcd164e64038a78964a16eac3edc',
@@ -1010,7 +1010,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1022,7 +1022,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1032,7 +1032,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1047,7 +1047,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -1059,7 +1059,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1069,7 +1069,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@12ad4f3ff4c14114a6e629b00e000976',
@@ -1078,7 +1078,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1096,7 +1096,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',
@@ -1108,7 +1108,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -1120,7 +1120,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1130,7 +1130,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1142,7 +1142,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1152,7 +1152,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@discussion_5deb6081620d',
@@ -1161,7 +1161,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1173,7 +1173,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1183,7 +1183,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1195,7 +1195,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1205,7 +1205,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1220,7 +1220,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -1232,7 +1232,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1242,7 +1242,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1257,7 +1257,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -1269,7 +1269,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1279,7 +1279,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@55cbc99f262443d886a25cf84594eafb',
@@ -1288,7 +1288,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ade92343df3d4953a40ab3adc8805390',
@@ -1297,7 +1297,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1315,7 +1315,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',
@@ -1327,7 +1327,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -1339,7 +1339,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1349,7 +1349,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1361,7 +1361,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1371,7 +1371,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@4aba537a78774bd5a862485a8563c345',
@@ -1380,7 +1380,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1392,7 +1392,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1402,7 +1402,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@f480df4ce91347c5ae4301ddf6146238',
@@ -1411,7 +1411,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1423,7 +1423,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1433,7 +1433,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@b8cec2a19ebf463f90cd3544c7927b0e',
@@ -1442,7 +1442,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1454,7 +1454,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1464,7 +1464,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@problem+block@d1b84dcd39b0423d9e288f27f0f7f242',
@@ -1473,7 +1473,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@cd177caa62444fbca48aa8f843f09eac',
@@ -1482,7 +1482,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1494,7 +1494,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1504,7 +1504,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@ddede76df71045ffa16de9d1481d2119',
@@ -1513,7 +1513,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1525,7 +1525,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1535,7 +1535,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@1a810b1a3b2447b998f0917d0e5a802b',
@@ -1544,7 +1544,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1556,7 +1556,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1566,7 +1566,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@discussion+block@23e6eda482c04335af2bb265beacaf59',
@@ -1575,7 +1575,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1587,7 +1587,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1597,7 +1597,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@html+block@6b6bee43c7c641509da71c9299cc9f5a',
@@ -1606,7 +1606,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1624,7 +1624,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'sequential',
display_name: 'Subsection',
@@ -1636,7 +1636,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
category: 'vertical',
display_name: 'Unit',
@@ -1648,7 +1648,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
child_info: {
children: [
{
@@ -1658,7 +1658,7 @@ module.exports = {
has_children: false,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},
@@ -1673,7 +1673,7 @@ module.exports = {
has_children: true,
video_sharing_enabled: true,
video_sharing_options: 'per-video',
video_sharing_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-quince.master/developing_course/social_sharing.html',
video_sharing_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html',
},
],
},

View File

@@ -1310,7 +1310,7 @@ module.exports = {
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
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',
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
@@ -1396,7 +1396,7 @@ module.exports = {
highlights_enabled_for_messaging: false,
highlights_enabled: true,
highlights_preview_only: false,
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',
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
enable_proctored_exams: false,
create_zendesk_tickets: true,
enable_timed_exams: true,

View File

@@ -968,7 +968,7 @@ module.exports = {
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
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',
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
@@ -1054,7 +1054,7 @@ module.exports = {
highlights_enabled_for_messaging: false,
highlights_enabled: true,
highlights_preview_only: false,
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',
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
enable_proctored_exams: false,
create_zendesk_tickets: true,
enable_timed_exams: true,

View File

@@ -14,26 +14,38 @@ import AddComponentButton from './add-component-btn';
import messages from './messages';
import { ComponentPicker } from '../../library-authoring/component-picker';
import { messageTypes } from '../constants';
import { useIframe } from '../context/hooks';
import { useIframe } from '../../generic/hooks/context/hooks';
import { useEventListener } from '../../generic/hooks';
const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
const AddComponent = ({
parentLocator,
isSplitTestType,
isUnitVerticalType,
addComponentTemplateData,
handleCreateNewCourseXBlock,
}) => {
const navigate = useNavigate();
const intl = useIntl();
const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false);
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false);
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
const blockId = addComponentTemplateData.parentLocator || parentLocator;
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const receiveMessage = useCallback(({ data: { type } }) => {
const receiveMessage = useCallback(({ data: { type, payload } }) => {
if (type === messageTypes.showMultipleComponentPicker) {
showSelectLibraryContentModal();
}
}, [showSelectLibraryContentModal]);
if (type === messageTypes.showSingleComponentPicker) {
setUsageId(payload.usageId);
showAddLibraryContentModal();
}
}, [showSelectLibraryContentModal, showAddLibraryContentModal, setUsageId]);
useEventListener('message', receiveMessage);
@@ -46,11 +58,11 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2,
category: selection.blockType,
parentLocator: blockId,
parentLocator: usageId || blockId,
libraryContentKey: selection.usageKey,
});
closeAddLibraryContentModal();
}, []);
}, [usageId]);
const handleCreateNewXBlock = (type, moduleName) => {
switch (type) {
@@ -77,14 +89,10 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
showAddLibraryContentModal();
break;
case COMPONENT_TYPES.advanced:
handleCreateNewCourseXBlock({
type: moduleName, category: moduleName, parentLocator: blockId,
});
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
break;
case COMPONENT_TYPES.openassessment:
handleCreateNewCourseXBlock({
boilerplate: moduleName, category: type, parentLocator: blockId,
});
handleCreateNewCourseXBlock({ boilerplate: moduleName, category: type, parentLocator: blockId });
break;
case COMPONENT_TYPES.html:
handleCreateNewCourseXBlock({
@@ -100,104 +108,136 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
}
};
if (!Object.keys(componentTemplates).length) {
return null;
if (isUnitVerticalType || isSplitTestType) {
return (
<div className="py-4">
{Object.keys(componentTemplates).length && isUnitVerticalType ? (
<>
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
{componentTemplates.map((component) => {
const { type, displayName, beta } = component;
let modalParams;
if (!component.templates.length) {
return null;
}
switch (type) {
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,
isOpen: isOpenOpenAssessment,
};
break;
default:
return (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
beta={beta}
/>
</li>
);
}
return (
<ComponentModalView
key={type}
component={component}
handleCreateNewXBlock={handleCreateNewXBlock}
modalParams={modalParams}
/>
);
})}
</ul>
</>
) : null}
<StandardModal
title={
isAddLibraryContentModalOpen
? intl.formatMessage(messages.singleComponentPickerModalTitle)
: intl.formatMessage(messages.multipleComponentPickerModalTitle)
}
isOpen={isAddLibraryContentModalOpen || isSelectLibraryContentModalOpen}
onClose={() => {
closeAddLibraryContentModal();
closeSelectLibraryContentModal();
}}
isOverflowVisible={false}
size="xl"
footerNode={
isSelectLibraryContentModalOpen && (
<ActionRow>
<Button onClick={onComponentSelectionSubmit}>
<FormattedMessage {...messages.multipleComponentPickerModalBtn} />
</Button>
</ActionRow>
)
}
>
<ComponentPicker
showOnlyPublished
extraFilter={['NOT block_type = "unit"']}
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
onComponentSelected={handleLibraryV2Selection}
onChangeComponentSelection={setSelectedComponents}
/>
</StandardModal>
</div>
);
}
return (
<div className="py-4">
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
{componentTemplates.map((component) => {
const { type, displayName, beta } = component;
let modalParams;
return null;
};
if (!component.templates.length) {
return null;
}
switch (type) {
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,
isOpen: isOpenOpenAssessment,
};
break;
default:
return (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
beta={beta}
/>
</li>
);
}
return (
<ComponentModalView
key={type}
component={component}
handleCreateNewXBlock={handleCreateNewXBlock}
modalParams={modalParams}
/>
);
})}
</ul>
<StandardModal
title={
isAddLibraryContentModalOpen
? intl.formatMessage(messages.singleComponentPickerModalTitle)
: intl.formatMessage(messages.multipleComponentPickerModalTitle)
}
isOpen={isAddLibraryContentModalOpen || isSelectLibraryContentModalOpen}
onClose={() => {
closeAddLibraryContentModal();
closeSelectLibraryContentModal();
}}
isOverflowVisible={false}
size="xl"
footerNode={
isSelectLibraryContentModalOpen && (
<ActionRow>
<Button variant="primary" onClick={onComponentSelectionSubmit}>
<FormattedMessage {...messages.multipleComponentPickerModalBtn} />
</Button>
</ActionRow>
)
}
>
<ComponentPicker
showOnlyPublished
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
onComponentSelected={handleLibraryV2Selection}
onChangeComponentSelection={setSelectedComponents}
/>
</StandardModal>
</div>
);
AddComponent.defaultProps = {
addComponentTemplateData: {},
};
AddComponent.propTypes = {
blockId: PropTypes.string.isRequired,
isSplitTestType: PropTypes.bool.isRequired,
isUnitVerticalType: PropTypes.bool.isRequired,
parentLocator: PropTypes.string.isRequired,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
addComponentTemplateData: {
blockId: PropTypes.string.isRequired,
model: PropTypes.shape({
displayName: PropTypes.string.isRequired,
category: PropTypes.string,
type: PropTypes.string.isRequired,
templates: PropTypes.arrayOf(
PropTypes.shape({
boilerplateName: PropTypes.string,
category: PropTypes.string,
displayName: PropTypes.string.isRequired,
supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
}),
),
supportLegend: PropTypes.shape({
allowUnsupportedXblocks: PropTypes.bool,
documentationLabel: PropTypes.string,
showLegend: PropTypes.bool,
}),
}),
},
};
export default AddComponent;

View File

@@ -18,7 +18,7 @@ import { courseSectionVerticalMock } from '../__mocks__';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import AddComponent from './AddComponent';
import messages from './messages';
import { IframeProvider } from '../context/iFrameContext';
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { messageTypes } from '../constants';
let store;
@@ -52,7 +52,7 @@ jest.mock('../../library-authoring/component-picker', () => ({
}));
const mockSendMessageToIframe = jest.fn();
jest.mock('../context/hooks', () => ({
jest.mock('../../generic/hooks/context/hooks', () => ({
useIframe: () => ({
sendMessageToIframe: mockSendMessageToIframe,
}),
@@ -64,6 +64,9 @@ const renderComponent = (props) => render(
<IframeProvider>
<AddComponent
blockId={blockId}
isUnitVerticalType
parentLocator={blockId}
addComponentTemplateData={{}}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
{...props}
/>

View File

@@ -14,6 +14,7 @@ const ComponentModalView = ({
component,
modalParams,
handleCreateNewXBlock,
isRequestedModalView,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -30,15 +31,19 @@ const ComponentModalView = ({
setModuleTitle('');
};
const renderAddComponentButton = () => (
<li>
<AddComponentButton
onClick={open}
type={type}
displayName={displayName}
/>
</li>
);
return (
<>
<li>
<AddComponentButton
onClick={open}
type={type}
displayName={displayName}
/>
</li>
{!isRequestedModalView && renderAddComponentButton()}
<ModalContainer
isOpen={isOpen}
close={close}
@@ -92,6 +97,10 @@ const ComponentModalView = ({
);
};
ComponentModalView.defaultProps = {
isRequestedModalView: false,
};
ComponentModalView.propTypes = {
modalParams: PropTypes.shape({
open: PropTypes.func,
@@ -117,6 +126,7 @@ ComponentModalView.propTypes = {
showLegend: PropTypes.bool,
}),
}).isRequired,
isRequestedModalView: PropTypes.bool,
};
export default ComponentModalView;

View File

@@ -58,27 +58,27 @@ const messages = defineMessages({
},
modalComponentSupportTooltipFullySupported: {
id: 'course-authoring.course-unit.modal.component.support.tooltip.fully-supported',
defaultMessage: 'Fully supported tools and features are available on edX, are '
+ 'fully tested, have user interfaces where applicable, and are documented in the '
+ 'official edX guides that are available on docs.edx.org.',
defaultMessage: 'Fully supported tools and features are available for Open edX installations, '
+ 'are fully tested, have user interfaces where applicable, and are documented in the '
+ 'official Open edX guides that are available on docs.openedx.org.',
description: 'Message for support status tooltip for modules with full platform support',
},
modalComponentSupportTooltipNotSupported: {
id: 'course-authoring.course-unit.modal.component.support.tooltip.not-supported',
defaultMessage: 'Tools with no support are not maintained by edX, and might be '
+ 'deprecated in the future. They are not recommended for use in courses due to '
+ 'non-compliance with one or more of the base requirements, such as testing, '
+ 'accessibility, internationalization, and documentation.',
defaultMessage: 'Tools with no support are not maintained by the Open edX community, '
+ 'and might be deprecated in the future. They are not recommended for use in '
+ 'courses due to non-compliance with one or more of the base requirements, such as '
+ 'testing, accessibility, internationalization, and documentation.',
description: 'Message for support status tooltip for modules which is not supported',
},
modalComponentSupportTooltipProvisionallySupported: {
id: 'course-authoring.course-unit.modal.component.support.tooltip.provisionally-support',
defaultMessage: 'Provisionally supported tools might lack the robustness of functionality '
+ 'that your courses require. edX does not have control over the quality of the software, '
+ 'that your courses require. Open edX does not have control over the quality of the software, '
+ 'or of the content that can be provided using these tools. Test these tools thoroughly '
+ 'before using them in your course, especially in graded sections. Complete documentation '
+ 'might not be available for provisionally supported tools, or documentation might be '
+ 'available from sources other than edX.',
+ 'available from sources other than the Open edX community.',
description: 'Message for support status tooltip for modules with provisional platform support',
},
});

View File

@@ -3,7 +3,7 @@
background: transparent;
}
.sub-header-title .sub-header-breadcrumbs {
.sub-header-breadcrumbs {
.dropdown-toggle::after {
display: none;
}

View File

@@ -26,43 +26,55 @@ const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitI
isOutlinePage ? getPathToCourseOutlinePage(url) : getPathToCourseUnitPage(url)
);
const hasChildWithUrl = (children = []) => (
!!children.filter((child : any) => child?.url).length
);
return (
<nav className="d-flex align-center mb-2.5">
<ol className="p-0 m-0 d-flex align-center">
<ol className="p-0 m-0 d-flex align-center flex-wrap">
{ancestorXblocks.map(({ children, title, isLast }, index) => (
<li
className="d-flex"
className="d-flex mb-2.5"
// eslint-disable-next-line react/no-array-index-key
key={`${title}-${index}`}
>
<Dropdown>
<Dropdown.Toggle
id="breadcrumbs-dropdown-section"
variant="link"
className="p-0 text-primary small"
>
{hasChildWithUrl(children) ? (
<Dropdown>
<Dropdown.Toggle
id="breadcrumbs-dropdown-section"
variant="link"
className="p-0 text-primary small"
>
<span className="small text-gray-700">
{title}
</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCoursePage(index < 2, url)}
className="small"
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
) : (
<span className="p-0 text-primary small btn btn-link text-decoration-none">
<span className="small text-gray-700">
{title}
</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCoursePage(index < 2, url)}
className="small"
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</span>
)}
{!isLast && (
<Icon
src={ChevronRightIcon}

View File

@@ -39,22 +39,13 @@ export const getXBlockSupportMessages = (intl) => ({
},
});
export const stateKeys = {
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
};
export const messageTypes = {
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
refreshXBlock: 'refreshXBlock',
showMoveXBlockModal: 'showMoveXBlockModal',
completeXBlockMoving: 'completeXBlockMoving',
rollbackMovedXBlock: 'rollbackMovedXBlock',
showMultipleComponentPicker: 'showMultipleComponentPicker',
showSingleComponentPicker: 'showSingleComponentPicker',
addSelectedComponentsToBank: 'addSelectedComponentsToBank',
showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview',
copyXBlock: 'copyXBlock',
@@ -69,6 +60,7 @@ export const messageTypes = {
addXBlock: 'addXBlock',
scrollToXBlock: 'scrollToXBlock',
handleViewXBlockContent: 'handleViewXBlockContent',
handleViewGroupConfigurations: 'handleViewGroupConfigurations',
editXBlock: 'editXBlock',
closeXBlockEditorModal: 'closeXBlockEditorModal',
saveEditedXBlockData: 'saveEditedXBlockData',
@@ -76,4 +68,10 @@ export const messageTypes = {
studioAjaxError: 'studioAjaxError',
refreshPositions: 'refreshPositions',
openManageTags: 'openManageTags',
showComponentTemplates: 'showComponentTemplates',
addNewComponent: 'addNewComponent',
pasteNewComponent: 'pasteComponent',
copyXBlockLegacy: 'copyXBlockLegacy',
hideProcessingNotification: 'hideProcessingNotification',
handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage',
};

View File

@@ -1,4 +1,4 @@
import { renderHook } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react';
import { useSelector } from 'react-redux';
import { useSequenceNavigationMetadata } from './hooks';
import { getCourseSectionVertical, getSequenceIds } from '../data/selectors';

View File

@@ -8,7 +8,6 @@ import { handleResponseErrors } from '../../generic/saving-error-alert';
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
import { updateClipboardData } from '../../generic/data/slice';
import { messageTypes } from '../constants';
import {
getCourseUnitData,
@@ -31,7 +30,6 @@ import {
fetchSequenceSuccess,
fetchCourseSectionVerticalDataSuccess,
updateLoadingCourseSectionVerticalDataStatus,
updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
updateQueryPendingStatus,
@@ -77,7 +75,6 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
}));
dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices'))));
localStorage.removeItem('staticFileNotices');
dispatch(updateClipboardData(courseSectionVerticalData.userClipboard));
dispatch(fetchSequenceSuccess({ sequenceId }));
return true;
} catch (error) {
@@ -202,18 +199,30 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.FAILED }));
handleResponseErrors(error, dispatch, updateSavingStatus);
}
};
}
export function fetchCourseVerticalChildrenData(itemId) {
export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPageLoading) {
return async (dispatch) => {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
if (!skipPageLoading) {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
}
try {
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
if (isSplitTestType) {
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
const childrenDataArray = await Promise.all(
blockIds.map(blockId => getCourseVerticalChildren(blockId)),
);
const allChildren = childrenDataArray.reduce(
(acc, data) => acc.concat(data.children || []),
[],
);
courseVerticalChildrenData.children = [...courseVerticalChildrenData.children, ...allChildren];
}
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -230,8 +239,6 @@ export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) {
try {
await deleteUnitItem(xblockId);
sendMessageToIframe(messageTypes.completeXBlockDeleting, { locator: xblockId });
const { userClipboard } = await getCourseSectionVerticalData(itemId);
dispatch(updateClipboardData(userClipboard));
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());
@@ -304,13 +311,13 @@ export function patchUnitItemQuery({
dispatch(updateMovedXBlockParams(xBlockParams));
dispatch(updateCourseOutlineInfo({}));
dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
callbackFn(sourceLocator);
try {
const courseUnit = await getCourseUnitData(currentParentLocator);
dispatch(fetchCourseItemSuccess(courseUnit));
} catch (error) {
handleResponseErrors(error, dispatch, updateSavingStatus);
}
callbackFn(sourceLocator);
} catch (error) {
handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {

View File

@@ -6,13 +6,13 @@ import { Edit as EditIcon } from '@openedx/paragon/icons';
import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => {
const HeaderNavigations = ({ headerNavigationsActions, category }) => {
const intl = useIntl();
const { handleViewLive, handlePreview, handleEdit } = headerNavigationsActions;
return (
<nav className="header-navigations ml-auto flex-shrink-0">
{unitCategory === COURSE_BLOCK_NAMES.vertical.id && (
{category === COURSE_BLOCK_NAMES.vertical.id && (
<>
<Button
variant="outline-primary"
@@ -28,11 +28,12 @@ const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => {
</Button>
</>
)}
{unitCategory === COURSE_BLOCK_NAMES.libraryContent.id && (
{[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.splitTest.id].includes(category) && (
<Button
iconBefore={EditIcon}
variant="outline-primary"
onClick={handleEdit}
data-testid="header-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
@@ -47,7 +48,7 @@ HeaderNavigations.propTypes = {
handlePreview: PropTypes.func.isRequired,
handleEdit: PropTypes.func.isRequired,
}).isRequired,
unitCategory: PropTypes.string.isRequired,
category: PropTypes.string.isRequired,
};
export default HeaderNavigations;

View File

@@ -18,6 +18,7 @@ const headerNavigationsActions = {
const renderComponent = (props) => render(
<IntlProvider locale="en">
<HeaderNavigations
category={COURSE_BLOCK_NAMES.vertical.id}
headerNavigationsActions={headerNavigationsActions}
{...props}
/>
@@ -47,17 +48,17 @@ describe('<HeaderNavigations />', () => {
expect(editButton).not.toBeInTheDocument();
});
it('calls the correct handlers when clicking buttons for library page', () => {
const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.libraryContent.id });
['libraryContent', 'splitTest'].forEach((category) => {
it(`calls the correct handlers when clicking buttons for ${category} page`, () => {
const { getByRole, queryByRole } = renderComponent({ category: COURSE_BLOCK_NAMES[category].id });
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(handleViewLiveFn).toHaveBeenCalledTimes(1);
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(handleViewLiveFn).toHaveBeenCalledTimes(1);
const viewLiveButton = queryByRole('button', { name: messages.viewLiveButton.defaultMessage });
expect(viewLiveButton).not.toBeInTheDocument();
const previewButton = queryByRole('button', { name: messages.previewButton.defaultMessage });
expect(previewButton).not.toBeInTheDocument();
[messages.viewLiveButton.defaultMessage, messages.previewButton.defaultMessage].forEach((btnName) => {
expect(queryByRole('button', { name: btnName })).not.toBeInTheDocument();
});
});
});
});

View File

@@ -26,7 +26,13 @@ const HeaderTitle = ({
const [titleValue, setTitleValue] = useState(unitTitle);
const currentItemData = useSelector(getCourseUnitData);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo ?? {};
const isXBlockComponent = [
COURSE_BLOCK_NAMES.libraryContent.id,
COURSE_BLOCK_NAMES.splitTest.id,
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
@@ -87,9 +93,8 @@ const HeaderTitle = ({
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
isSelfPaced={false}
isXBlockComponent={
[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.component.id].includes(currentItemData.category)
}
isXBlockComponent={isXBlockComponent}
userPartitionInfo={currentItemData?.userPartitionInfo || {}}
/>
</div>
{getVisibilityMessage()}

View File

@@ -4,11 +4,12 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { RequestStatus } from '../data/constants';
import { useCopyToClipboard } from '../generic/clipboard';
import { useClipboard } from '../generic/clipboard';
import { useEventListener } from '../generic/hooks';
import { COURSE_BLOCK_NAMES } from '../constants';
import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '../constants';
import { messageTypes, PUBLISH_TYPES } from './constants';
import {
createNewCourseXBlock,
@@ -40,12 +41,13 @@ import {
updateMovedXBlockParams,
updateQueryPendingStatus,
} from './data/slice';
import { useIframe } from './context/hooks';
import { useIframe } from '../generic/hooks/context/hooks';
export const useCourseUnit = ({ courseId, blockId }) => {
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { sendMessageToIframe } = useIframe();
const [addComponentTemplateData, setAddComponentTemplateData] = useState({});
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);
@@ -62,12 +64,13 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const courseOutlineInfo = useSelector(getCourseOutlineInfo);
const movedXBlockParams = useSelector(getMovedXBlockParams);
const { currentlyVisibleToStudents } = courseUnit;
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit);
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
const { canPasteComponent } = courseVerticalChildren;
const { displayName: unitTitle, category: unitCategory } = xblockInfo;
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
const headerNavigationsActions = {
handleViewLive: () => {
@@ -76,7 +79,9 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handlePreview: () => {
window.open(draftPreviewLink, '_blank');
},
handleEdit: () => {},
handleEdit: () => {
sendMessageToIframe(messageTypes.editXBlock, { id: courseUnit.id }, window);
},
};
const handleTitleEdit = () => {
@@ -170,6 +175,16 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const { usageId } = payload;
navigate(`/course/${courseId}/container/${usageId}/${sequenceId}`);
}
if (type === messageTypes.handleViewGroupConfigurations) {
const { usageId } = payload;
const groupId = usageId.split('#').pop();
navigate(`/course/${courseId}/group_configurations#${groupId}`);
}
if (type === messageTypes.showComponentTemplates) {
setAddComponentTemplateData(camelCaseObject(payload));
}
}, [courseId, sequenceId]);
useEventListener('message', receiveMessage);
@@ -183,12 +198,17 @@ export const useCourseUnit = ({ courseId, blockId }) => {
useEffect(() => {
dispatch(fetchCourseUnitQuery(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
handleNavigate(sequenceId);
dispatch(updateMovedXBlockParams({ isSuccess: false }));
}, [courseId, blockId, sequenceId]);
useEffect(() => {
if (isSplitTestType) {
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
}
}, [isSplitTestType, blockId]);
useEffect(() => {
if (isMoveModalOpen && !Object.keys(courseOutlineInfo).length) {
dispatch(getCourseOutlineInfoQuery(courseId));
@@ -209,6 +229,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
sharedClipboardData,
showPasteXBlock,
showPasteUnit,
@@ -227,6 +248,8 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleCloseXBlockMovedAlert,
movedXBlockParams,
handleNavigateToTargetUnit,
addComponentTemplateData,
setAddComponentTemplateData,
};
};
@@ -290,7 +313,7 @@ export const useScrollToLastPosition = (storageKey = 'createXBlockLastYPosition'
}, [storageKey]);
const handleMessage = useCallback((event) => {
if (event.data?.type === messageTypes.resize) {
if (event.data?.type === iframeMessageTypes.resize) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react';
import { useScrollToLastPosition, useLayoutGrid } from './hooks';
import { messageTypes } from './constants';
import { iframeMessageTypes } from '../constants';
jest.useFakeTimers();
@@ -108,7 +108,7 @@ describe('useScrollToLastPosition', () => {
const { unmount } = renderHook(() => useScrollToLastPosition(storageKey));
act(() => {
window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } }));
jest.advanceTimersByTime(1000);
});
@@ -136,8 +136,8 @@ describe('useScrollToLastPosition', () => {
renderHook(() => useScrollToLastPosition(storageKey));
act(() => {
window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } }));
});
expect(clearTimeoutSpy).toHaveBeenCalled();
@@ -150,9 +150,9 @@ describe('useScrollToLastPosition', () => {
renderHook(() => useScrollToLastPosition(storageKey));
act(() => {
window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } }));
jest.advanceTimersByTime(500);
window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } }));
});
expect(window.scrollTo).not.toHaveBeenCalled();
@@ -164,7 +164,7 @@ describe('useScrollToLastPosition', () => {
renderHook(() => useScrollToLastPosition(storageKey));
act(() => {
window.dispatchEvent(new MessageEvent('message', { data: { type: messageTypes.resize } }));
window.dispatchEvent(new MessageEvent('message', { data: { type: iframeMessageTypes.resize } }));
jest.advanceTimersByTime(1000);
});

View File

@@ -1,2 +1 @@
export { default as CourseUnit } from './CourseUnit';
export { IframeProvider } from './context/iFrameContext';

View File

@@ -11,7 +11,7 @@ import { RequestStatus } from '../../data/constants';
import { useEventListener } from '../../generic/hooks';
import { getCourseOutlineInfo, getCourseOutlineInfoLoadingStatus } from '../data/selectors';
import { getCourseOutlineInfoQuery, patchUnitItemQuery } from '../data/thunk';
import { useIframe } from '../context/hooks';
import { useIframe } from '../../generic/hooks/context/hooks';
import { messageTypes } from '../constants';
import { CATEGORIES, MOVE_DIRECTIONS } from './constants';
import {

View File

@@ -105,6 +105,7 @@ const MoveModal: FC<IUseMoveModalParams> = ({
title={intl.formatMessage(messages.moveModalTitle, { displayName })}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible
>
<ModalDialog.Header>
<ModalDialog.Title>

View File

@@ -11,7 +11,7 @@ import { getCourseOutlineInfoUrl } from '../data/api';
import { courseOutlineInfoMock } from '../__mocks__';
import { executeThunk } from '../../utils';
import { getCourseOutlineInfoQuery } from '../data/thunk';
import { IframeProvider } from '../context/iFrameContext';
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { IXBlock } from './interfaces';
import MoveModal from './index';
import messages from './messages';

View File

@@ -8,9 +8,8 @@ import {
waitFor,
} from '../../testUtils';
import PreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { IframeProvider } from '../context/iFrameContext';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
import { getLibraryBlockMetadataUrl } from '../../library-authoring/data/api';
@@ -25,15 +24,15 @@ const defaultEventData: LibraryChangesMessageData = {
};
const mockSendMessageToIframe = jest.fn();
jest.mock('../context/hooks', () => ({
jest.mock('../../generic/hooks/context/hooks', () => ({
useIframe: () => ({
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
setIframeRef: () => {},
sendMessageToIframe: mockSendMessageToIframe,
}),
}));
const render = (eventData?: LibraryChangesMessageData) => {
baseRender(<PreviewLibraryXBlockChanges />, {
extraWrapper: ({ children }) => <IframeProvider>{ children }</IframeProvider>,
});
baseRender(<IframePreviewLibraryXBlockChanges />);
const message = {
data: {
type: messageTypes.showXBlockLibraryChangesPreview,
@@ -49,7 +48,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<PreviewLibraryXBlockChanges />', () => {
describe('<IframePreviewLibraryXBlockChanges />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useState } from 'react';
import React, { useCallback, useContext, useState } from 'react';
import {
ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon';
@@ -8,7 +8,7 @@ import { useEventListener } from '../../generic/hooks';
import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import { useIframe } from '../context/hooks';
import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages';
import { ToastContext } from '../../generic/toast-context';
@@ -24,36 +24,34 @@ export interface LibraryChangesMessageData {
isVertical: boolean,
}
const PreviewLibraryXBlockChanges = () => {
export interface PreviewLibraryXBlockChangesProps {
blockData?: LibraryChangesMessageData,
isModalOpen: boolean,
closeModal: () => void,
postChange: (accept: boolean) => void,
alertNode?: React.ReactNode,
}
/**
* Component to preview two xblock versions in a modal that depends on params
* to display blocks, open-close modal, accept-ignore changes and post change triggers
*/
export const PreviewLibraryXBlockChanges = ({
blockData,
isModalOpen,
closeModal,
postChange,
alertNode,
}: PreviewLibraryXBlockChangesProps) => {
const { showToast } = useContext(ToastContext);
const intl = useIntl();
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
// Main preview library modal toggle.
const [isModalOpen, openModal, closeModal] = useToggle(false);
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const { sendMessageToIframe } = useIframe();
const receiveMessage = useCallback(({ data }: { data: {
payload: LibraryChangesMessageData;
type: string;
} }) => {
const { payload, type } = data;
if (type === messageTypes.showXBlockLibraryChangesPreview) {
setBlockData(payload);
openModal();
}
}, [openModal]);
useEventListener('message', receiveMessage);
const getTitle = useCallback(() => {
const oldName = blockData?.displayName;
@@ -95,7 +93,7 @@ const PreviewLibraryXBlockChanges = () => {
try {
await mutation.mutateAsync(blockData.downstreamBlockId);
sendMessageToIframe(messageTypes.refreshXBlock, null);
postChange(accept);
} catch (e) {
showToast(intl.formatMessage(failureMsg));
} finally {
@@ -112,6 +110,7 @@ const PreviewLibraryXBlockChanges = () => {
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title>
@@ -119,6 +118,7 @@ const PreviewLibraryXBlockChanges = () => {
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{alertNode}
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
@@ -151,4 +151,42 @@ const PreviewLibraryXBlockChanges = () => {
);
};
export default PreviewLibraryXBlockChanges;
/**
* Wrapper over PreviewLibraryXBlockChanges to preview two xblock versions in a modal
* that depends on iframe message events to setBlockData and display modal.
*/
const IframePreviewLibraryXBlockChanges = () => {
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
// Main preview library modal toggle.
const [isModalOpen, openModal, closeModal] = useToggle(false);
const { sendMessageToIframe } = useIframe();
const receiveMessage = useCallback(({ data }: {
data: {
payload: LibraryChangesMessageData;
type: string;
}
}) => {
const { payload, type } = data;
if (type === messageTypes.showXBlockLibraryChangesPreview) {
setBlockData(payload);
openModal();
}
}, [openModal]);
useEventListener('message', receiveMessage);
return (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={() => sendMessageToIframe(messageTypes.refreshXBlock, null)}
/>
);
};
export default IframePreviewLibraryXBlockChanges;

View File

@@ -4,7 +4,7 @@ import { useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import useCourseUnitData from './hooks';
import { useIframe } from '../context/hooks';
import { useIframe } from '../../generic/hooks/context/hooks';
import { editCourseUnitVisibilityAndData } from '../data/thunk';
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
import { PUBLISH_TYPES, messageTypes } from '../constants';

View File

@@ -81,4 +81,19 @@
text-decoration: line-through;
}
}
.course-split-test-sidebar {
padding: $spacer;
@extend %base-font-params;
.course-split-test-sidebar-title {
font-size: $font-size-base;
line-height: $line-height-base;
}
.course-split-test-sidebar-devider {
width: 100%;
}
}
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Card, Hyperlink, Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const SplitTestSidebarInfo = () => {
const intl = useIntl();
const boldTagWrapper = (chunks: React.ReactNode) => <strong>{chunks}</strong>;
return (
<Card.Body className="course-split-test-sidebar">
<Stack>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestAddComponentTitle)}
</h3>
<p>
{intl.formatMessage(messages.sidebarSplitTestSelectComponentType, { bold_tag: boldTagWrapper })}
</p>
<p>
{intl.formatMessage(messages.sidebarSplitTestComponentAdded)}
</p>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestEditComponentTitle)}
</h3>
<p>
{intl.formatMessage(messages.sidebarSplitTestEditComponentInstruction, { bold_tag: boldTagWrapper })}
</p>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestReorganizeComponentTitle)}
</h3>
<p>
{intl.formatMessage(messages.sidebarSplitTestReorganizeComponentInstruction)}
</p>
<p>
{intl.formatMessage(messages.sidebarSplitTestReorganizeGroupsInstruction)}
</p>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestExperimentComponentTitle)}
</h3>
<p className="mb-0">
{intl.formatMessage(messages.sidebarSplitTestExperimentComponentInstruction)}
</p>
<hr className="course-split-test-sidebar-devider my-4" />
<Hyperlink
showLaunchIcon={false}
destination="https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components"
className="btn btn-outline-primary btn-sm"
target="_blank"
>
{intl.formatMessage(messages.sidebarSplitTestLearnMoreLinkLabel)}
</Hyperlink>
</Stack>
</Card.Body>
);
};
export default SplitTestSidebarInfo;

View File

@@ -1,15 +1,14 @@
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Divider } from '../../../../generic/divider';
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
import { copyToClipboard } from '../../../../generic/data/thunks';
import { useClipboard } from '../../../../generic/clipboard';
import messages from '../../messages';
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
const dispatch = useDispatch();
const intl = useIntl();
const {
id,
@@ -18,6 +17,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
enableCopyPasteUnits,
} = useSelector(getCourseUnitData);
const canEdit = useSelector(getCanEdit);
const { copyToClipboard } = useClipboard();
return (
<>
@@ -40,7 +40,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
<>
<Divider className="course-unit-sidebar-footer__divider" />
<Button
onClick={() => dispatch(copyToClipboard(id))}
onClick={() => copyToClipboard(id)}
variant="outline-primary"
size="sm"
>

View File

@@ -1,3 +1,4 @@
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -12,24 +13,21 @@ import { clipboardUnit } from '../../../../__mocks__';
import { getCourseUnitApiUrl } from '../../../data/api';
import { getClipboardUrl } from '../../../../generic/data/api';
import { fetchCourseUnitQuery } from '../../../data/thunk';
import { copyToClipboard } from '../../../../generic/data/thunks';
import { courseUnitIndexMock } from '../../../__mocks__';
import messages from '../../messages';
import ActionButtons from './ActionButtons';
jest.mock('../../../../generic/data/thunks', () => ({
...jest.requireActual('../../../../generic/data/thunks'),
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
}));
let store;
let axiosMock;
let queryClient;
const courseId = '123';
const renderComponent = (props = {}) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<ActionButtons {...props} />
<QueryClientProvider client={queryClient}>
<ActionButtons {...props} />
</QueryClientProvider>
</IntlProvider>
</AppProvider>,
);
@@ -57,6 +55,8 @@ describe('<ActionButtons />', () => {
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
queryClient = new QueryClient();
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
@@ -73,8 +73,8 @@ describe('<ActionButtons />', () => {
const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage });
userEvent.click(copyXBlockBtn);
expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
jest.resetAllMocks();
});
});

View File

@@ -1,24 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Card } from '@openedx/paragon';
const Sidebar = ({ className, children, ...props }) => (
<Card
className={classNames('course-unit-sidebar', className)}
{...props}
>
{children}
</Card>
);
Sidebar.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
Sidebar.defaultProps = {
className: null,
children: null,
};
export default Sidebar;

Some files were not shown because too many files have changed in this diff Show More