Compare commits

...

60 Commits

Author SHA1 Message Date
Asad Ali
14e122a672 fix: do not reload multiple tabs on block save (backport #2600) (#2704) 2025-12-01 18:26:06 -05:00
Asad Ali
f459f53343 fix: load sequences in unit page (#1867) (#2424)
This handles loading errors when opening the course unit page via direct link as an unauthorized user.

Co-authored-by: Ihor Romaniuk <ihor.romaniuk@raccoongang.com>
2025-09-08 22:23:06 -07:00
Asad Ali
a5a7d03d12 fix: allow thumbnail upload on Videos page if no thumbnail (#2388) (#2434)
* fix: allow thumbnail upload if no thumbnail

* fix: improve thumbnail upload impl

* test: fix tests

* test: fix tests

* fix: do not show thumbnail upload if not allowed

* test: fix coverage

* test: add thumbnail test

* fix: display thumbnail overlay when video status is success
2025-09-08 20:45:27 -07:00
Chris Chávez
41fc478efe [Teak] style: Fixing nits about sync units [FC-0097] (#2320)
* Add a warning banner about units in the libraries sync page.
* Update the message in the sync unit modal.
* Stay visible the sync icon in the course outline.
* Add a tooltip to the edit (in normal and disabled mode) and sync button.
2025-08-28 11:15:24 -05:00
Muhammad Faraz Maqsood
06497bf85c fix: publish btn doesn't show after component edit
When we edit & save the component, publish button doesn't show up until we refresh the page manualy or open this unit by opening previous unit and coming back to this unit again.
In this commit, we are dispatching a storage event whenever we edit the component, it'll refresh the page & show the publish button as expected.
2025-08-20 15:08:24 +05:00
Jacobo Dominguez
7e0b7f94e8 docs: (backport) adding comprehensive readme documentation for plugin slots (#2340) 2025-07-29 15:27:24 -07:00
Jansen Kantor
4bc34c268b fix: pages and resources plugins not rendered (#1885) 2025-07-22 13:26:38 +05:30
Muhammad Anas
2973614e3b fix: loading unit page directly from link after logging in in Teak (#2246)
This is a simple version of the fix for Teak; on master it was fixed with https://github.com/openedx/frontend-app-authoring/pull/1867
2025-07-09 09:35:58 -07:00
Brayan Cerón
bdc99fddc3 fix: clear selection on files & uploads page after deleting (backport) (#2228)
* refactor: remove selected rows when deleting or adding elements

* refactor: ensure unique asset IDs when adding new ones

* refactor: remove unnecessary loading checks in mockStore function

* test: add unit tests for TableActions component
2025-07-07 16:47:45 -07:00
José Ignacio Palma
92c59cbf0c fix: advanced-settings api should not camel-case return value (backport) (#2087)
* fix: advanced-settings api should not camel-case return value (#1581)

* fix: update advanced module list not working (#2189)

Backend was still expecting `{'advanced_modules', {'value': ['poll', 'problem-builder', 'h5pxblock']}}` but without this change, it was receiving `{'advancedModules', ['poll', 'problem-builder', 'h5pxblock']}`

Follow up to https://github.com/openedx/frontend-app-authoring/pull/1581

---------

Co-authored-by: Muhammad Faraz Maqsood <fmaqsood@2u.com>
2025-06-19 09:06:31 -07:00
Arunmozhi
b6bd94c114 feat: add v2 CourseAuthoringUnitSidebarSlot (#2000) 2025-06-18 12:17:13 +05:30
Chris Chávez
c9896a8fe5 [Teak] fix: published name in unit sidebar in container picker & Issues on Inplace Editor (#2140)
Backport of fix: show unit published name in sidebar on content picker [FC-0090] #2100 
Backport of fix: Issue on the Inplace editor [FC-0090] #2101
2025-06-17 19:58:57 -05:00
bydawen
4ba8cde587 fix: (backport) text truncate issue in the search modal (#2151) 2025-06-16 14:43:02 -07:00
Diana Villalvazo
86d0a7e7db fix: remove icon and empty breadcrumb from libraries (#2129) (#2133) 2025-06-12 14:43:18 -07:00
Braden MacDonald
1968d146cd fix: (backport) enable markdown editor in libraries (#2098)
* fix: enable markdown editor for problems in libraries too

This fix is also achieved on master via 5991fd3997 / https://github.com/openedx/frontend-app-authoring/pull/2068 but this is a simpler fix, not a direct backport of that refactor.

* fix: remove duplicate markdown_edited save request (#2127)

Removes the unnecessary duplicate save  request of markdown_edited
value to the backend.

Part of: https://github.com/openedx/frontend-app-authoring/issues/2099
Backports: 62589aea50

---------

Co-authored-by: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com>
2025-06-12 09:16:53 -07:00
Ihor Romaniuk
3e737b5b0d fix: (backport) remove an extra editing xblock modal on unit page (#2111) (#2130) 2025-06-11 13:25:47 -07:00
diana-villalvazo-wgu
fcdf1fdecb fix: files & uploads menu was truncated due to overflow-x (#2071) (#2077) 2025-06-05 19:41:21 +05:30
Victor Navarro
efb1a28b4d fix: Expand all now expands subsections (#2085) 2025-06-05 09:35:41 -03:00
Muhammad Anas
1ff5e5bdae fix: markdown editor issues in modal (#2076)
This PR resolves rendering issues with the Markdown editor inside the modal.
The problem began after a PR [1] introduced the use of modals for the editor.
The EditorPage [2] component expects a `isMarkdownEditorEnabledForCourse` prop,
which was missing in that implementation.

[1] https://github.com/openedx/frontend-app-authoring/pull/1838 
[2] https://github.com/openedx/frontend-app-authoring/pull/1838/files#diff-147218ef88726880178ea895988a5d3feaf2c0c4459086a8de7a4080cbe37de7R226

Backports https://github.com/openedx/frontend-app-authoring/pull/2074
2025-06-04 12:59:24 -04:00
Tony Busa
19ef80553a fix: backport changes for html button in text component markdown editor (#2065) 2025-06-04 17:51:05 +05:30
Rômulo Penido
2beb91c63b fix: set unit preview readonly on sidebar (#2008) (#2059)
Make the unit preview on the sidebar read-only and add `Truncate` to the `InplaceTextEditor`
2025-06-02 12:11:58 -05:00
Rômulo Penido
d325a92204 fix: selection card wiggle (#2047) 2025-05-29 14:06:35 -05:00
Jillian
7dfd93d4f1 fix: upstreamInfo is not always provided (#2041) (#2042)
(cherry picked from commit 3fc0f27d67)
2025-05-29 13:15:01 -05:00
Jillian
e34df7f270 fix: set maxHeight on TextEditor TinyMce widget [FC-0090] (#2024) (#2030)
Sets a max_height=500px for the TinyMCE editor when editing a Text/Html component.
This prevents the autoresize plugin from expanding the editor textarea beyond the bounds of the editor modal.

⚠️ Because the max height can only be a numeric pixel value, we can't use clever settings like vh or %, and so we're forced to limit the height of the editor to a fixed size for all screen sizes in order to address this issue.

(cherry picked from commit c5f7d0cf3b)
2025-05-26 13:05:48 -05:00
Jillian
317bc757cf fix: refresh xblock inline after accepting/rejecting library sync (#2022) (#2028)
Instead of reloading the entire Unit after syncing changes from the
library, just reload the xblock that was changed.

(cherry picked from commit ac5574d2c4)
2025-05-23 14:03:57 -05:00
Chris Chávez
212a54f76e [Teak] fix: Inconsistent publish status filter menu placement & fix: Remove never published filter from component picker (#2021)
* fix: Inconsistent publish status filter menu placement (#1966)

* fix: Remove never published filter from component picker (#1947)

Removes the never-published filter option from the component picker and unit picker.
2025-05-22 10:22:53 -05:00
Daniel Valenzuela
944d1316ad fix: do open editor of new xblock when duplicating (#2017)
* feat: display editors as modals  (#1838)

* fix: do open editor of new xblock when duplicating (#1887)

Fixes bug where after duplicating an xblock, the editor modal of the old xblock is being open instead of the new copied xblock.
2025-05-22 10:04:35 -05:00
Rômulo Penido
dd731a0d19 fix: rename library publish button (#2015) 2025-05-21 18:18:26 -05:00
Rômulo Penido
976dfcaab7 fix: change InplaceTextEditor style and add optimistic update (#1953) (#2014)
* Optimistic update for renaming Components, Collections and Containers
* Change the InplaceTextEditor to show the new text until the onSave promise resolves
* Change the InplaceTextEditor style to: Always show the rename button
2025-05-21 17:33:23 -05:00
Navin Karkera
403dfa1e6b [Teak] backport #1949, #1999 and #2002 (#2006)
* feat: select component and show sidebar on edit  (#1949)

Select component that is being edited in library and show its sidebar. Also fixes issue with children component listing in library unit page

(cherry picked from commit 08ac1c0c4d)

* fix: search text flickering (#1999)

Fix flickering issue in search field.

(cherry picked from commit 6f3b7ab962)

* feat: open collection or unit page on double click only (#2002)

Opens collection or unit page only on double click.

(cherry picked from commit 503642be8c)
2025-05-21 17:20:16 -05:00
Navin Karkera
1919eb4845 fix: search modal refresh on typing (#1938) (#1948) 2025-05-14 13:15:24 -05:00
Chris Chávez
3d6e221f99 fix: Issue with read-only units in libraries & published version of units in library units picker (#1940)
Fixes the issues from https://github.com/openedx/frontend-app-authoring/issues/1633#issuecomment-2828953801

* In successfully added units, the "add new component" widget appears sometimes
* In the "add existing unit" modal, the preview shows draft versions of units
2025-05-13 12:53:32 -05:00
Rômulo Penido
fab786a6c6 fix: review/sync bugs [FC-0083] (#1905) (#1941)
Fixes issues related to component libraries' review/sync flow

* Inconsistent sync pane title versions
* Library content shown in preview warning only appears in review changes modal when that modal is opened from the review tab
* Some new changes only appear within library review tab on scroll at top of list
* Vertically misaligned sync icon in review changes message on course outline
* Show available updates whenever content is updated, regardless of number of updates available
2025-05-12 14:42:34 -05:00
Rômulo Penido
a162929fd7 fix: improve focus/selected style on library authoring (#1918) (#1930)
Improves the focus and selected styles from the LibraryPage and UnitPage.
2025-05-12 12:05:28 -05:00
Jillian
6c4634ebbe fix: invalidate search results when publishing all changes in library (#1925) (#1927)
(cherry picked from commit cdb8016657)

Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-05-09 11:03:58 -05:00
Navin Karkera
79f865b328 fix: UX issues in unit page (#1913) (#1923)
Fixes the following issues:

* Selection behavior
* Component selection is by header click only
* Newly created blocks within a unit should be selected on creation/save, appear selected, and have their sidebar open
* Some long text components seem to display at the default height rather than a longer height
* Within the full-page unit view, the "add to collection" overflow menu item on components does not seem to work/only opens the sidebar.
* Draft status indicator text is not vertically centered with icon
* When reordering, dragging a short component past a long component often causes a strange stutter effect.
* When dragging to reorder a component, moving quickly or scrolling often causes the drag handle to be lost / causes the block to jump somewhere else
* Reordering may not consistently support a keyboard-accessible option to change order, like in course authoring
* Tag button on component header opens the old tag side pane

(cherry picked from commit 8c3fab3792)
2025-05-08 10:15:44 -05:00
Rômulo Penido
d5e36cf2b8 fix: unit pages ux bugs [FC-0083] (#1884) (#1916)
This PR fixes some UX bugs related to the unit pages:

* Sort for "recently modified" on unit tab does not update after adding new components to units
* Change component delete warning message

It's a backport of https://github.com/openedx/frontend-app-authoring/pull/1884
2025-05-07 17:39:55 -05:00
Ihor Romaniuk
8ffafc094f fix: manage access modal on duplicated xblock (#1874) 2025-05-07 15:40:34 -03:00
Jillian
b375806fd2 perf: use Library search results to populate container card preview [FC-0083] [TEAK] (#1889)
* fix: several library unit page UX bugs (#1868)

* fix: rename "Organize" tab to "Manage"

* fix: duplicate key warnings

* fix: uniform messages while adding to collection

* fix: do not allow units be added to a unit

(cherry picked from commit 0fdc460c5b)

* perf: use Library search results to populate container card preview (#1820)

* fix: use Library search results to populate container card preview

* feat: show published children when showing only published Unit content

* fix: nits

(cherry picked from commit 24e469542d)

---------

Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
2025-05-02 10:18:20 -07:00
Navin Karkera
ab0e0d71c1 refactor: remove custom order function from course libraries list (#1865) (#1888)
(cherry picked from commit bc18fffedf)
2025-05-01 15:28:09 -07:00
Jillian
b30a1c8c5e fix: NaN library components are out of sync [FC-0083] (#1864) 2025-04-25 10:08:33 -05:00
Chris Chávez
855b44f745 feat: Add 'This unit can only be edited from the library' banner (#1860) 2025-04-24 20:50:29 -07:00
Jillian
2d55ba4ccc fix: allow units sourced from libraries to update their settings (#1863) 2025-04-24 17:58:37 -07:00
Chris Chávez
d62c4cf4f8 feat: Sync units in course outline [FC-0083] (#1850)
* Adds the sync button in unit cards in the course outline.
* Opens the compare previews.
* Functionality to sync units.
* Functionality to decline sync units.
2025-04-24 20:18:04 +00:00
Rômulo Penido
9824502278 feat: disallow edits to units in courses that are sourced from a library (#1833) 2025-04-24 19:20:46 +00:00
Chris Chávez
d6b51ecf0c feat: Add unit from library into course (#1829)
* feat: Initial worflow to add unit to course
* test: Add initial tests
* feat: Show only published units
* test: Update Subsection card test and ComponentPicker tests
* feat: Connect add unit from library API
* test: Test for Add unit from library in CourseOutline
* fix: create a new Vertical from a Library Unit
* docs: add a little note about avoiding 'vertical' where possible
* refactor: Use visibleTabs instead of showOnlyHomeTab

---------

Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-04-24 12:07:54 -07:00
Muhammad Faraz Maqsood
1fe1f93314 fix: incorrect video durations (#1856)
- fix: incorrect video durations in video info tab and  videos list view

Co-authored-by: Muhammad Faraz  Maqsood <faraz.maqsood@A006-01130.local>
2025-04-24 13:17:40 -04:00
Brian Smith
fbc1273955 feat: import StudioFooterSlot from component package instead of slot package (#1832) 2025-04-24 12:58:27 -04:00
Brian Smith
7edb3528ba feat: standardize slot ids (#1854) 2025-04-24 07:27:04 -04:00
renovate[bot]
e7c22b1cbf fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#1790)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 16:24:47 -04:00
Muhammad Anas
380f3be164 feat: added markdown editor for editing problems in markdown format (#1805) 2025-04-23 13:24:27 -07:00
Chris Chávez
74d7d66c59 feat: New Transcript widget state on video editors on creation workflow [FC-0076] (#1825)
* New Transcript widget state on video editors on creation workflow
* Which edX user roles will this change impact? "Course Author",
2025-04-23 09:52:10 -05:00
Arunmozhi
e2189f2fdd feat: Enhance Sidebar Slot properties (#1845)
The commit add some extra properties to the CourseAuthoringSidebarSlot
and CourseAuthoringUnitSidebarSlot components to enable
the widgets in the sidebar to have more context to work with.
2025-04-23 09:46:00 -04:00
Muhammad Faraz Maqsood
293b7941dd fix: video & file page toggles
- react redux state changes back to default whenever page refreshes.
 - On course authoring mfe, whenever we redirect from one page to another, it automatically refreshes the page which react app shouldn't do.
 - So, instead of managing video and file pages previously selected view in react redux, save & manage these values in localStorage. So that page refreshes doesn't bother end users.
2025-04-23 12:22:17 +05:00
Rômulo Penido
eaa075464c feat: rename component on library unit page (#1823) 2025-04-22 22:16:38 -07:00
Muhammad Faraz Maqsood
03d732846e fix: TypeError r is not function for custom pages
- fix TypeError r is not a function for custom pages by replacing injectIntl with useIntl() hook.
- fix import for textbooks app
2025-04-23 10:13:45 +05:00
Muhammad Faraz Maqsood
c1302f1089 fix: TypeError r is not a function
- injectIntl is deprecated, Used useIntl() hook instead.
- ErrorLog
```
React Router caught the following error during render TypeError: r is not a function {
  "componentStack": "
    at A (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:5259944)
    at Suspense
    at d (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:65420)
    at t (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:7316739)
    at g (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:8043115)
    at w (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:4746781)
    at div
    at https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:6453516
    at div
    at https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:7617877
    at div
    at h (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:4740935)
    at rR (https://course-authoring.edx.org/app.756074826164c8adbdbb.js:2:1102691)
    at r (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:1493518)
    at injectIntl(r)
    at main
    at l (https://course-authoring.edx.org/app.756074826164c8adbdbb.js:2:2224276)
    at cR (https://course-authoring.edx.org/app.756074826164c8adbdbb.js:2:1103620)
    at r (https://course-authoring.edx.org/817.bfc0047cf532fb354633.js:2:1493518)
    at injectIntl(r)"
}
```
2025-04-23 10:13:45 +05:00
Chris Chávez
ea26981393 feat: Enable ORA2 in libraries by default (#1847) 2025-04-22 14:34:34 -07:00
Braden MacDonald
55e505eb36 chore: simplify imports in library-authoring/data/apiHooks.ts (#1842)
This just simplifies how API methods are imported into one apiHooks.ts file, reducing the overall lines of code and chance for conflicts. Since we're importing all the API methods anyways, there is nothing to gain from explicitly importing each one separately.
2025-04-22 11:50:06 -05:00
Muhammad Faraz Maqsood
9002f7acfe chore: upgrade footer version 2025-04-22 12:28:18 +05:00
309 changed files with 6681 additions and 3753 deletions

3
.env
View File

@@ -45,5 +45,4 @@ ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"

View File

@@ -40,5 +40,4 @@ ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"

View File

@@ -10,4 +10,5 @@ coverage:
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/index.js"

66
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "AGPL-3.0",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/state": "^6.0.0",
@@ -20,7 +21,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.3.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.3.1",
@@ -36,8 +37,7 @@
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.3.3",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-slot-footer": "^1.2.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
@@ -2033,6 +2033,21 @@
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.2.tgz",
"integrity": "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
@@ -2329,9 +2344,9 @@
}
},
"node_modules/@edx/frontend-component-footer": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.3.0.tgz",
"integrity": "sha512-domQOIsAf+b1YiQvpt245Cfz6OgrKKw3TJrDIFS+J70Mn98MpCGGg55mBraOzTfopsouzp5bN03F1PLkXyjnEQ==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.6.0.tgz",
"integrity": "sha512-cgRhom6W/WErQ9yvLmfgB6ANBs+rBDLOH73NcvJIhfwWgAg67q+MLUscIbcX9N/9Yykk+kb7Ytr3CDefiKS7HA==",
"license": "AGPL-3.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.7.2",
@@ -2339,6 +2354,7 @@
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"classnames": "^2.5.1",
"jest-environment-jsdom": "^29.7.0",
"lodash": "^4.17.21",
@@ -2353,9 +2369,9 @@
}
},
"node_modules/@edx/frontend-component-header": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-6.2.0.tgz",
"integrity": "sha512-rM/+NtvPAQk+RmAA/fhXnsneeta/CGi319Wei/or6aW7ETpSmMkfoYM4MKv+JPhF/vLMxqBzz6lwzefF9D62Lw==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-6.4.0.tgz",
"integrity": "sha512-RNV3XRXhhN9QlhAoP26CjzoRIPlLSYDp3PZCnK6g6kIHgxC9dCpu2PTZdxV2AVChqVuxtZK5zLbk9yeAtf4U/A==",
"license": "AGPL-3.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.6.0",
@@ -2363,7 +2379,7 @@
"@fortawesome/free-regular-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"axios-mock-adapter": "1.22.0",
"babel-polyfill": "6.26.0",
"classnames": "^2.5.1",
@@ -3869,6 +3885,16 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.2.tgz",
"integrity": "sha512-iYewCigG/517D0xJPQd7RGaCjZAFwROiH8T9h7OTtz0bRVtkxzFhGBFJ9JGKgBBs4uuo1cvxzyQ5iKhDLMcLUQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
@@ -4157,9 +4183,9 @@
}
},
"node_modules/@openedx/frontend-plugin-framework": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.6.0.tgz",
"integrity": "sha512-zgP+/hs/cvcPmFOgVm2xt/qgX1nheNsfipzCO7I3bON4hHyOhmOyzwFZJ7pz7GzCJwKlMVguh3HcJgf4p/BPKQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.7.0.tgz",
"integrity": "sha512-8tGkuHvtzhbqb9dU4sXUtR0K44+Hjh1uGR6DvhZAt9wSKQC1v4RBk34ef8DFzQhoNQa/Jtn6BJuta4Un6MmHmw==",
"license": "AGPL-3.0",
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
@@ -4242,20 +4268,6 @@
"@babel/runtime": "^7.9.2"
}
},
"node_modules/@openedx/frontend-slot-footer": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@openedx/frontend-slot-footer/-/frontend-slot-footer-1.2.0.tgz",
"integrity": "sha512-bJuqgdiAlPRj1QuUOJWtNqGTCTcdsk4vHeOM3jRkxtWycq+j1JpGnnZEWAmjoRv9dDKr39vt2buNrmvj0sCTbA==",
"license": "AGPL-3.0",
"dependencies": {
"@openedx/frontend-plugin-framework": "^1.5.0"
},
"peerDependencies": {
"@edx/frontend-component-footer": "*",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@openedx/paragon": {
"version": "22.17.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.17.0.tgz",

View File

@@ -35,6 +35,7 @@
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
@@ -44,7 +45,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.3.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.3.1",
@@ -60,8 +61,7 @@
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.3.3",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-slot-footer": "^1.2.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
@@ -11,10 +11,10 @@ import LiveCommonFields from './LiveCommonFields';
import messages from './messages';
const BbbSettings = ({
intl,
values,
setFieldValue,
}) => {
const intl = useIntl();
const [bbbPlan, setBbbPlan] = useState(values.tierType);
useEffect(() => {
@@ -107,12 +107,10 @@ const BbbSettings = ({
)}
</>
</>
);
};
BbbSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -127,4 +125,4 @@ BbbSettings.propTypes = {
setFieldValue: PropTypes.func.isRequired,
};
export default injectIntl(BbbSettings);
export default BbbSettings;

View File

@@ -1,42 +1,43 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
const LiveCommonFields = ({
intl,
values,
}) => (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
}) => {
const intl = useIntl();
return (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
};
LiveCommonFields.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -45,4 +46,4 @@ LiveCommonFields.propTypes = {
}).isRequired,
};
export default injectIntl(LiveCommonFields);
export default LiveCommonFields;

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { Icon } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
@@ -20,9 +20,9 @@ import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';
const LiveSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const courseId = useSelector(state => state.courseDetail.courseId);
@@ -130,8 +130,7 @@ const LiveSettings = ({
};
LiveSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(LiveSettings);
export default LiveSettings;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
@@ -8,37 +8,38 @@ import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
const ZoomSettings = ({
intl,
values,
}) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!values.piiSharingEnable ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<LiveCommonFields values={values} />
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
</>
);
}) => {
const intl = useIntl();
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!values.piiSharingEnable ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<LiveCommonFields values={values} />
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
</>
);
};
ZoomSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -51,4 +52,4 @@ ZoomSettings.propTypes = {
}).isRequired,
};
export default injectIntl(ZoomSettings);
export default ZoomSettings;

View File

@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
@@ -25,7 +25,8 @@ import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/Pa
import messages from './messages';
const ProctoringSettings = ({ intl, onClose }) => {
const ProctoringSettings = ({ onClose }) => {
const intl = useIntl();
const initialFormValues = {
enableProctoredExams: false,
proctoringProvider: false,
@@ -652,10 +653,9 @@ const ProctoringSettings = ({ intl, onClose }) => {
};
ProctoringSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
ProctoringSettings.defaultProps = {};
export default injectIntl(ProctoringSettings);
export default ProctoringSettings;

View File

@@ -1,4 +1,4 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
@@ -8,7 +8,8 @@ import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ intl, onClose }) => {
const ProgressSettings = ({ onClose }) => {
const intl = useIntl();
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
@@ -48,8 +49,7 @@ const ProgressSettings = ({ intl, onClose }) => {
};
ProgressSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(ProgressSettings);
export default ProgressSettings;

View File

@@ -1,4 +1,4 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Form, TransitionReplace } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
@@ -30,8 +30,9 @@ const TeamTypeNameMessage = {
};
const GroupEditor = ({
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
}) => {
const intl = useIntl();
const [isDeleting, setDeleting] = useState(false);
const [isOpen, setOpen] = useState(group.id === null);
const initiateDeletion = () => setDeleting(true);
@@ -149,7 +150,6 @@ export const groupShape = PropTypes.shape({
});
GroupEditor.propTypes = {
intl: intlShape.isRequired,
fieldNameCommonBase: PropTypes.string.isRequired,
errors: PropTypes.shape({
name: PropTypes.string,
@@ -170,4 +170,4 @@ GroupEditor.defaultProps = {
},
};
export default injectIntl(GroupEditor);
export default GroupEditor;

View File

@@ -1,4 +1,4 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Form } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
@@ -17,9 +17,9 @@ import messages from './messages';
setupYupExtensions();
const TeamSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
const blankNewGroup = {
name: '',
@@ -166,8 +166,7 @@ const TeamSettings = ({
};
TeamSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(TeamSettings);
export default TeamSettings;

View File

@@ -1,4 +1,4 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
@@ -8,7 +8,8 @@ import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ intl, onClose }) => {
const WikiSettings = ({ onClose }) => {
const intl = useIntl();
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
@@ -32,7 +33,7 @@ const WikiSettings = ({ intl, onClose }) => {
label={intl.formatMessage(messages.enablePublicWikiLabel)}
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
onChange={handleChange}
onBlue={handleBlur}
onBlur={handleBlur}
checked={values.enablePublicWiki}
/>
)
@@ -42,8 +43,7 @@ const WikiSettings = ({ intl, onClose }) => {
};
WikiSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(WikiSettings);
export default WikiSettings;

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useNavigate } from 'react-router-dom';
@@ -10,7 +9,8 @@ import messages from './messages';
import { fetchXpertSettings } from './data/thunks';
const XpertUnitSummarySettings = ({ intl }) => {
const XpertUnitSummarySettings = () => {
const intl = useIntl();
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -38,8 +38,4 @@ const XpertUnitSummarySettings = ({ intl }) => {
);
};
XpertUnitSummarySettings.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(XpertUnitSummarySettings);
export default XpertUnitSummarySettings;

View File

@@ -1,4 +1,4 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
@@ -70,38 +70,40 @@ AppSettingsForm.defaultProps = {
};
const SettingsModalBase = ({
intl, title, onClose, variant, isMobile, children, footer,
}) => (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
title, onClose, variant, isMobile, children, footer,
}) => {
const intl = useIntl();
return (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
SettingsModalBase.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
@@ -115,11 +117,11 @@ SettingsModalBase.defaultProps = {
};
const ResetUnitsButton = ({
intl,
courseId,
checked,
visible,
}) => {
const intl = useIntl();
const resetStatusRequestStatus = useSelector(getResetStatus);
const dispatch = useDispatch();
@@ -185,7 +187,6 @@ const ResetUnitsButton = ({
};
ResetUnitsButton.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
checked: PropTypes.oneOf(['true', 'false']).isRequired,
visible: PropTypes.bool,
@@ -196,7 +197,6 @@ ResetUnitsButton.defaultProps = {
};
const SettingsModal = ({
intl,
appId,
title,
children,
@@ -213,6 +213,7 @@ const SettingsModal = ({
allUnitsEnabledText,
noUnitsEnabledText,
}) => {
const intl = useIntl();
const { courseId } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
@@ -372,7 +373,6 @@ const SettingsModal = ({
>
{allUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'true'}
@@ -385,7 +385,6 @@ const SettingsModal = ({
>
{noUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'false'}
@@ -423,7 +422,6 @@ const SettingsModal = ({
};
SettingsModal.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
appId: PropTypes.string.isRequired,
children: PropTypes.func,
@@ -450,4 +448,4 @@ SettingsModal.defaultProps = {
enableReinitialize: false,
};
export default injectIntl(SettingsModal);
export default SettingsModal;

View File

@@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { useModel } from './generic/model-store';

View File

@@ -4,7 +4,7 @@ import {
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from 'CourseAuthoring/textbooks';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';

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 { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from '../header';
import messages from './messages';

View File

@@ -1,5 +1,10 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
/* eslint-disable import/prefer-default-export */
import {
camelCaseObject,
getConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -14,7 +19,19 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
return camelCaseObject(data);
const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
}
/**
@@ -26,7 +43,19 @@ export async function getCourseAdvancedSettings(courseId) {
export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
return camelCaseObject(data);
const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
}
/**
@@ -36,5 +65,17 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
*/
export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
return camelCaseObject(data);
const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
}

View File

@@ -0,0 +1,236 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
describe('courseSettings API', () => {
const mockHttpClient = {
get: jest.fn(),
patch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
});
describe('getCourseAdvancedSettings', () => {
it('should fetch and unformat course advanced settings', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted',
UPPERCASE: 'To come lowercase',
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.get.mockResolvedValue({ data: fakeData });
const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024');
expect(mockHttpClient.get).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`,
);
expect(result).toEqual(expected);
});
});
describe('updateCourseAdvancedSettings', () => {
it('should update and unformat course advanced settings', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted', // because already be camelCase
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted
UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.patch.mockResolvedValue({ data: fakeData });
const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {});
expect(mockHttpClient.patch).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`,
{},
);
expect(result).toEqual(expected);
});
});
describe('getProctoringExamErrors', () => {
it('should fetch proctoring errors and return unformat object', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted',
UPPERCASE: 'To come lowercase',
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.get.mockResolvedValue({ data: fakeData });
const result = await getProctoringExamErrors('course-v1:Test+T101+2024');
expect(mockHttpClient.get).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`,
);
expect(result).toEqual(expected);
});
});
});

View File

@@ -21,6 +21,7 @@ const path = '/content/:contentId?/*';
const mockOnClose = jest.fn();
const mockSetBlockingSheet = jest.fn();
const mockNavigate = jest.fn();
const mockSidebarAction = jest.fn();
mockContentTaxonomyTagsData.applyMock();
mockTaxonomyListData.applyMock();
mockTaxonomyTagsData.applyMock();
@@ -40,6 +41,11 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
jest.mock('../library-authoring/common/context/SidebarContext', () => ({
...jest.requireActual('../library-authoring/common/context/SidebarContext'),
useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }),
}));
const renderDrawer = (contentId, drawerParams = {}) => (
render(
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
@@ -184,6 +190,26 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to edit mode sidebar action is set to JumpToManageTags', async () => {
mockSidebarAction.mockReturnValueOnce('jump-to-manage-tags');
renderDrawer(stagedTagsId, { variant: 'component' });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// Show delete tag buttons
expect(screen.getAllByRole('button', {
name: /delete/i,
}).length).toBe(2);
// Show add a tag select
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
// Show cancel button
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
// Show save button
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to read mode when click on `Cancel` on drawer variant', async () => {
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();

View File

@@ -14,6 +14,7 @@ import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading';
import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext';
interface TaxonomyListProps {
contentId: string;
@@ -244,6 +245,7 @@ const ContentTagsDrawer = ({
if (contentId === undefined) {
throw new Error('Error: contentId cannot be null.');
}
const { sidebarAction } = useSidebarContext();
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
@@ -260,6 +262,7 @@ const ContentTagsDrawer = ({
closeToast,
setCollapsibleToInitalState,
otherTaxonomies,
toEditMode,
} = context;
let onCloseDrawer: () => void;
@@ -302,8 +305,13 @@ const ContentTagsDrawer = ({
// First call of the initial collapsible states
React.useEffect(() => {
setCollapsibleToInitalState();
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
// Open tag edit mode when sidebarAction is JumpToManageTags
if (sidebarAction === SidebarActions.JumpToManageTags) {
toEditMode();
} else {
setCollapsibleToInitalState();
}
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded, sidebarAction, toEditMode]);
const renderFooter = () => {
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {

View File

@@ -7,6 +7,7 @@ import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useParams } from 'react-router';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
@@ -14,7 +15,7 @@ import {
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
@@ -129,6 +130,7 @@ export const useContentData = (contentId, enabled) => (
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe'];
const { unitId } = useParams();
return useMutation({
/**
@@ -158,6 +160,10 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
// Invalidate content search to update tags count
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
// If the tags for a compoent were edited from Unit page, invalidate children query to fetch count again.
if (unitId) {
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
}
}
},
onSuccess: /* istanbul ignore next */ () => {

View File

@@ -157,7 +157,7 @@ describe('useContentTaxonomyTagsUpdater', () => {
const contentId = 'testerContent';
const taxonomyId = 123;
const mutation = useContentTaxonomyTagsUpdater(contentId);
const mutation = renderHook(() => useContentTaxonomyTagsUpdater(contentId)).result.current;
const tagsData = [{
taxonomy: taxonomyId,
tags: ['tag1', 'tag2'],

View File

@@ -1,5 +1,4 @@
// @ts-check
import React, { useState, useMemo } from 'react';
import { useState, useMemo } from 'react';
import {
Card, Stack, Button, Collapsible, Icon,
} from '@openedx/paragon';
@@ -10,10 +9,19 @@ import { ContentTagsDrawerSheet } from '..';
import messages from '../messages';
import { useContentTaxonomyTagsData } from '../data/apiHooks';
import type { ContentTaxonomyTagData, Tag } from '../data/types';
import { LoadingSpinner } from '../../generic/Loading';
import TagsTree from '../TagsTree';
const TagsSidebarBody = () => {
interface TagsSidebarBodyProps {
readOnly: boolean
}
type TagTree = {
[key: string]: { children: TagTree, canChangeObjecttag: boolean, canDeleteObjecttag: boolean }
};
const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
const intl = useIntl();
const [showManageTags, setShowManageTags] = useState(false);
const contentId = useParams().blockId;
@@ -24,8 +32,8 @@ const TagsSidebarBody = () => {
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId || '');
const buildTagsTree = (contentTags) => {
const resultTree = {};
const buildTagsTree = (contentTags: Tag[]) => {
const resultTree: TagTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;
@@ -46,7 +54,7 @@ const TagsSidebarBody = () => {
};
const tree = useMemo(() => {
const result = [];
const result: (Omit<ContentTaxonomyTagData, 'tags'> & { tags: TagTree })[] = [];
if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
result.push({
@@ -88,7 +96,13 @@ const TagsSidebarBody = () => {
</div>
)}
<Button className="mt-3 ml-2" variant="outline-primary" size="sm" onClick={() => setShowManageTags(true)}>
<Button
className="mt-3 ml-2"
variant="outline-primary"
size="sm"
onClick={() => setShowManageTags(true)}
disabled={readOnly}
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
</Stack>
@@ -102,6 +116,4 @@ const TagsSidebarBody = () => {
);
};
TagsSidebarBody.propTypes = {};
export default TagsSidebarBody;

View File

@@ -1,10 +1,14 @@
import TagsSidebarHeader from './TagsSidebarHeader';
import TagsSidebarBody from './TagsSidebarBody';
const TagsSidebarControls = () => (
interface TagsSidebarControlsProps {
readOnly: boolean,
}
const TagsSidebarControls = ({ readOnly }: TagsSidebarControlsProps) => (
<>
<TagsSidebarHeader />
<TagsSidebarBody />
<TagsSidebarBody readOnly={readOnly} />
</>
);

View File

@@ -82,7 +82,7 @@ describe('<CourseLibraries />', () => {
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
const alert = (await screen.findAllByRole('alert'))[0];
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
@@ -105,7 +105,7 @@ describe('<CourseLibraries />', () => {
userEvent.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
const alert = await screen.findByRole('alert');
const alert = (await screen.findAllByRole('alert'))[0];
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
@@ -118,6 +118,46 @@ describe('<CourseLibraries />', () => {
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
});
it('show alert if max lastPublishedDate is greated than the local storage value', async () => {
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() - 1000),
);
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.findAllByRole('alert'))[0];
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
});
it('doesnt show alert if max lastPublishedDate is less than the local storage value', async () => {
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() + 1000),
);
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);
expect(allTab).toHaveAttribute('aria-selected', 'true');
screen.logTestingPlaygroundURL();
expect(screen.queryAllByRole('alert').length).toEqual(1);
});
});
describe('<CourseLibraries ReviewTab />', () => {
@@ -160,7 +200,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
@@ -176,7 +216,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
@@ -195,7 +235,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
@@ -218,7 +258,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });

View File

@@ -17,7 +17,7 @@ import {
Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, Launch, Loop,
Cached, CheckCircle, Launch, Loop, Info,
} from '@openedx/paragon/icons';
import sumBy from 'lodash/sumBy';
@@ -33,6 +33,7 @@ import { useStudioHome } from '../studio-home/hooks';
import NewsstandIcon from '../generic/NewsstandIcon';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
import AlertMessage from '../generic/alert-message';
interface Props {
courseId: string;
@@ -164,7 +165,7 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
if (tabKey !== CourseLibraryTabs.review) {
return null;
}
if (!outOfSyncCount || outOfSyncCount === 0) {
if (!outOfSyncCount) {
return (
<Stack direction="horizontal" gap={2}>
<Icon src={CheckCircle} size="xs" />
@@ -199,6 +200,12 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
setShowAlert={setShowReviewAlert}
/>
{ /* TODO: Remove this alert after implement container in this page */}
<AlertMessage
title={intl.formatMessage(messages.unitsUpdatesWarning)}
icon={Info}
variant="info"
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}

View File

@@ -18,12 +18,11 @@ interface OutOfSyncAlertProps {
* 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
* * If the user clicks on dismiss button, the state of dismiss is stored in localstorage of user
* in this format: outOfSyncCountAlert-${courseId} = <datetime value in milliseconds>.
* * If there are not new published components 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.
* * If there is a new published component upstream, the alert is displayed again.
*/
export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
showAlert,
@@ -34,7 +33,9 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
}) => {
const intl = useIntl();
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + lib.readyToSyncCount, 0);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
const alertKey = `outOfSyncCountAlert-${courseId}`;
useEffect(() => {
@@ -46,13 +47,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
setShowAlert(false);
return;
}
const dismissedAlert = localStorage.getItem(alertKey);
setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount);
}, [outOfSyncCount, isLoading, data]);
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => {
setShowAlert(false);
localStorage.setItem(alertKey, String(outOfSyncCount));
localStorage.setItem(alertKey, Date.now().toString());
onDismiss?.();
};

View File

@@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useMemo, useState,
useCallback, useContext, useMemo, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -14,11 +14,9 @@ import {
useToggle,
} from '@openedx/paragon';
import {
tail, keyBy, orderBy, merge, omitBy,
} from 'lodash';
import { tail, keyBy } from 'lodash';
import { useQueryClient } from '@tanstack/react-query';
import { Loop, Warning } from '@openedx/paragon/icons';
import { Loop } from '@openedx/paragon/icons';
import messages from './messages';
import previewChangesMessages from '../course-unit/preview-changes/messages';
import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks';
@@ -37,7 +35,6 @@ 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;
@@ -102,10 +99,8 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
const ComponentReviewList = ({
outOfSyncComponents,
onSearchUpdate,
}: {
outOfSyncComponents: PublishableEntityLink[];
onSearchUpdate: () => void;
}) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
@@ -113,24 +108,15 @@ const ComponentReviewList = ({
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const {
hits: downstreamInfo,
hits,
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;
};
} = useSearchContext();
const downstreamInfo = hits as ContentHit[];
useLoadOnScroll(
hasNextPage,
@@ -143,24 +129,14 @@ const ComponentReviewList = ({
() => 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) => {
const setSelectedBlockData = useCallback((info: ContentHit) => {
setBlockData({
displayName: info.displayName,
downstreamBlockId: info.usageKey,
@@ -168,17 +144,18 @@ const ComponentReviewList = ({
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
isVertical: info.blockType === 'vertical',
});
};
}, [outOfSyncComponentsByKey]);
// Show preview changes on review
const onReview = useCallback((info: ContentHit) => {
setSeletecdBlockData(info);
setSelectedBlockData(info);
openModal();
}, [setSeletecdBlockData, openModal]);
}, [setSelectedBlockData, openModal]);
const onIgnoreClick = useCallback((info: ContentHit) => {
setSeletecdBlockData(info);
setSelectedBlockData(info);
openConfirmModal();
}, [setSeletecdBlockData, openConfirmModal]);
}, [setSelectedBlockData, openConfirmModal]);
const reloadLinks = useCallback((usageKey: string) => {
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
@@ -236,19 +213,6 @@ const ComponentReviewList = ({
}
}, [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 />;
}
@@ -259,7 +223,7 @@ const ComponentReviewList = ({
return (
<>
{orderInfo?.map((info) => (
{downstreamInfo?.map((info) => (
<BlockCard
key={info.usageKey}
info={info}
@@ -293,20 +257,14 @@ const ComponentReviewList = ({
)}
/>
))}
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={postChange}
alertNode={(
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
)}
/>
{blockData && (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={postChange}
/>
)}
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
@@ -323,37 +281,17 @@ const ComponentReviewList = ({
const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: linkPages,
data: outOfSyncComponents,
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,
@@ -384,7 +322,6 @@ const ReviewTabContent = ({ courseId }: Props) => {
</ActionRow>
<ComponentReviewList
outOfSyncComponents={outOfSyncComponents}
onSearchUpdate={onSearchUpdate}
/>
</SearchContextProvider>
);

View File

@@ -3,18 +3,20 @@
"upstreamContextTitle": "CS problems 3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"readyToSyncCount": 5,
"totalCount": 14
"totalCount": 14,
"lastPublishedAt": "2025-05-01T20:20:44.989042Z"
},
{
"upstreamContextTitle": "CS problems 2",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"readyToSyncCount": 0,
"totalCount": 21
"totalCount": 21,
"lastPublishedAt": "2025-05-01T21:20:44.989042Z"
},
{
"upstreamContextTitle": "CS problems",
"upstreamContextKey": "lib:OpenedX:CSPROB",
"readyToSyncCount": 0,
"totalCount": 3
"totalCount": 3,
"lastPublishedAt": "2025-05-01T22:20:44.989042Z"
}
]

View File

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

@@ -28,27 +28,17 @@ export async function mockGetEntityLinks(
case mockGetEntityLinks.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinks.courseKeyEmpty:
return Promise.resolve({
next: null,
previous: null,
nextPageNum: null,
previousPageNum: null,
count: 0,
numPages: 0,
currentPage: 0,
results: [],
});
return Promise.resolve([]);
default: {
const { response } = mockGetEntityLinks;
let { response } = mockGetEntityLinks;
if (readyToSync !== undefined) {
response.results = response.results.filter((o) => o.readyToSync === readyToSync);
response.count = response.results.length;
response = response.filter((o) => o.readyToSync === readyToSync);
}
return Promise.resolve(response);
}
}
}
mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey;
mockGetEntityLinks.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinks.invalidCourseKey = 'course_key_error';
mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty';
@@ -85,7 +75,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext(
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response);
}
}
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';

View File

@@ -38,32 +38,13 @@ export interface PublishableEntityLinkSummary {
upstreamContextTitle: string;
readyToSyncCount: number;
totalCount: number;
lastPublishedAt: string;
}
export const getEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageParam?: number,
pageSize?: number,
): Promise<PaginatedData<PublishableEntityLink[]>> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_usage_key: upstreamUsageKey,
page_size: pageSize,
page: pageParam,
},
});
return camelCaseObject(data);
};
export const getUnpaginatedEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
): Promise<PublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {

View File

@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { renderHook, waitFor } from '@testing-library/react';
import { getEntityLinksByDownstreamContextUrl } from './api';
import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks';
import { useEntityLinks } from './apiHooks';
let axiosMock: MockAdapter;
@@ -39,26 +39,11 @@ describe('course libraries api hooks', () => {
axiosMock.reset();
});
it('should return paginated links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
const expectedResult = {
next: null, results: [], previous: null, total: 0,
};
axiosMock.onGet(url).reply(200, expectedResult);
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data?.pages).toEqual([expectedResult]);
expect(axiosMock.history.get[0].url).toEqual(url);
});
it('should return links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper });
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});

View File

@@ -1,8 +1,7 @@
import {
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
@@ -29,39 +28,10 @@ export const courseLibrariesQueryKeys = {
};
/**
* Hook to fetch publishable entity links by course key.
* Hook to fetch list of publishable entity links by course key.
* (That is, get a list of the library components used in the given course.)
*/
export const useEntityLinks = ({
courseId, readyToSync, upstreamUsageKey, pageSize,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageSize?: number
}) => (
useInfiniteQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamUsageKey,
}),
queryFn: ({ pageParam }) => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
pageParam,
pageSize,
),
getNextPageParam: (lastPage) => lastPage.nextPageNum,
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
})
);
/**
* Hook to fetch unpaginated list of publishable entity links by course key.
*/
export const useUnpaginatedEntityLinks = ({
courseId, readyToSync, upstreamUsageKey,
}: {
courseId?: string,
@@ -74,7 +44,7 @@ export const useUnpaginatedEntityLinks = ({
readyToSync,
upstreamUsageKey,
}),
queryFn: () => getUnpaginatedEntityLinks(
queryFn: () => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,

View File

@@ -116,10 +116,10 @@ const messages = defineMessages({
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',
unitsUpdatesWarning: {
id: 'course-authoring.course-libraries.home-tab.warning.units',
defaultMessage: 'Currently this page only tracks component updates. To check for unit updates, go to your Course Outline.',
description: 'Warning message shown in library sync page about units updates.',
},
});

View File

@@ -103,6 +103,7 @@ const CourseOutline = ({ courseId }) => {
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddUnitFromLibrary,
getUnitUrl,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
@@ -374,6 +375,7 @@ const CourseOutline = ({ courseId }) => {
section,
section.childInfo.children,
)}
isSectionsExpanded={isSectionsExpanded}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
@@ -383,6 +385,7 @@ const CourseOutline = ({ courseId }) => {
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
onAddUnitFromLibrary={handleAddUnitFromLibrary}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
>
@@ -453,7 +456,11 @@ const CourseOutline = ({ courseId }) => {
</article>
</Layout.Element>
<Layout.Element>
<CourseAuthoringOutlineSidebarSlot courseId={courseId} />
<CourseAuthoringOutlineSidebarSlot
courseId={courseId}
courseName={courseName}
sections={sections}
/>
</Layout.Element>
</Layout>
<EnableHighlightsModal

View File

@@ -11,7 +11,6 @@ import { cloneDeep } from 'lodash';
import { closestCorners } from '@dnd-kit/core';
import { useLocation } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import {
getCourseBestPracticesApiUrl,
getCourseLaunchApiUrl,
@@ -60,11 +59,14 @@ import {
moveSubsection,
moveUnit,
} from './drag-helper/utils';
import { postXBlockBaseApiUrl } from '../course-unit/data/api';
import { COMPONENT_TYPES } from '../generic/block-type-utils/constants';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const containerKey = 'lct:org:lib:unit:1';
window.HTMLElement.prototype.scrollIntoView = jest.fn();
@@ -94,6 +96,24 @@ jest.mock('./data/api', () => ({
getTagsCount: () => jest.fn().mockResolvedValue({}),
}));
// Mock ComponentPicker to call onComponentSelected on click
jest.mock('../library-authoring/component-picker', () => ({
ComponentPicker: (props) => {
const onClick = () => {
// eslint-disable-next-line react/prop-types
props.onComponentSelected({
usageKey: containerKey,
blockType: 'unti',
});
};
return (
<button type="submit" onClick={onClick}>
Dummy button
</button>
);
},
}));
const queryClient = new QueryClient();
jest.mock('@dnd-kit/core', () => ({
@@ -133,7 +153,9 @@ describe('<CourseOutline />', () => {
pathname: mockPathname,
});
store = initializeStore();
store = initializeStore({
studioHome: { studioHomeData: { librariesV2Enabled: true } },
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
@@ -152,6 +174,10 @@ describe('<CourseOutline />', () => {
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('render CourseOutline component correctly', async () => {
const { getByText } = render(<RootWrapper />);
@@ -262,13 +288,15 @@ describe('<CourseOutline />', () => {
});
it('check that new section list is saved when dragged', async () => {
const { findAllByRole } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const { findAllByRole, findByTestId } = render(<RootWrapper />);
const expandAllButton = await findByTestId('expand-collapse-all-button');
fireEvent.click(expandAllButton);
const [section] = store.getState().courseOutline.sectionsList;
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = sectionsDraggers[6];
const draggableButton = sectionsDraggers[1];
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.onPut(getCourseBlockApiUrl(section.id))
.reply(200, { dummy: 'value' });
const section1 = store.getState().courseOutline.sectionsList[0].id;
@@ -287,13 +315,15 @@ describe('<CourseOutline />', () => {
});
it('check section list is restored to original order when API call fails', async () => {
const { findAllByRole } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const { findAllByRole, findByTestId } = render(<RootWrapper />);
const expandAllButton = await findByTestId('expand-collapse-all-button');
fireEvent.click(expandAllButton);
const [section] = store.getState().courseOutline.sectionsList;
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = sectionsDraggers[6];
const draggableButton = sectionsDraggers[1];
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.onPut(getCourseBlockApiUrl(section.id))
.reply(500);
const section1 = store.getState().courseOutline.sectionsList[0].id;
@@ -368,8 +398,6 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const units = await within(subsectionElement).findAllByTestId('unit-card');
expect(units.length).toBe(1);
@@ -390,6 +418,40 @@ describe('<CourseOutline />', () => {
}));
});
it('adds a unit from library correctly', async () => {
render(<RootWrapper />);
const [sectionElement] = await screen.findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const units = await within(subsectionElement).findAllByTestId('unit-card');
expect(units.length).toBe(1);
axiosMock
.onPost(postXBlockBaseApiUrl())
.reply(200, {
locator: 'some',
});
const addUnitFromLibraryButton = within(subsectionElement).getByRole('button', {
name: /use unit from library/i,
});
fireEvent.click(addUnitFromLibraryButton);
// click dummy button to execute onComponentSelected prop.
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
fireEvent.click(dummyBtn);
waitFor(() => expect(axiosMock.history.post.length).toBe(1));
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [subsection] = section.childInfo.children;
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
type: COMPONENT_TYPES.libraryV2,
category: 'vertical',
parent_locator: subsection.id,
library_content_key: containerKey,
}));
});
it('render checklist value correctly', async () => {
const { getByText } = render(<RootWrapper />);
@@ -583,8 +645,6 @@ describe('<CourseOutline />', () => {
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
// check unit
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
@@ -597,8 +657,6 @@ describe('<CourseOutline />', () => {
const [sectionElement] = await screen.findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -637,8 +695,6 @@ describe('<CourseOutline />', () => {
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -708,8 +764,6 @@ describe('<CourseOutline />', () => {
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1418,8 +1472,6 @@ describe('<CourseOutline />', () => {
const [firstSection] = await findAllByTestId('section-card');
const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card');
const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(subsectionExpandButton);
const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card');
const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button');
@@ -1779,8 +1831,6 @@ describe('<CourseOutline />', () => {
const [, sectionElement] = await findAllByTestId('section-card');
const [, subsection] = section.childInfo.children;
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [, secondUnit] = subsection.childInfo.children;
const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1820,8 +1870,6 @@ describe('<CourseOutline />', () => {
const [, sectionElement] = await findAllByTestId('section-card');
const [firstSubsection, subsection] = section.childInfo.children;
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1857,8 +1905,6 @@ describe('<CourseOutline />', () => {
const [subsection] = secondSection.childInfo.children;
const firstSectionLastSubsection = firstSection.childInfo.children[firstSection.childInfo.children.length - 1];
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1903,8 +1949,6 @@ describe('<CourseOutline />', () => {
const [, sectionElement] = await findAllByTestId('section-card');
const [firstSubsection, subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const lastUnitIdx = firstSubsection.childInfo.children.length - 1;
const unit = firstSubsection.childInfo.children[lastUnitIdx];
const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx];
@@ -1942,8 +1986,6 @@ describe('<CourseOutline />', () => {
const secondSectionLastSubsection = secondSection.childInfo.children[lastSubIndex];
const thirdSectionFirstSubsection = thirdSection.childInfo.children[0];
const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubIndex];
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const lastUnitIdx = secondSectionLastSubsection.childInfo.children.length - 1;
const unit = secondSectionLastSubsection.childInfo.children[lastUnitIdx];
const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx];
@@ -1988,8 +2030,6 @@ describe('<CourseOutline />', () => {
const sections = await findAllByTestId('section-card');
const [sectionElement] = sections;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
// get first and only unit in the subsection
const [firstUnit] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -2009,8 +2049,6 @@ describe('<CourseOutline />', () => {
const lastSection = sections[sections.length - 1];
// it has only one subsection
const [lastSubsectionElement] = await within(lastSection).findAllByTestId('subsection-card');
const lastExpandBtn = await within(lastSubsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(lastExpandBtn));
// get last and the only unit in the subsection
const [lastUnit] = await within(lastSubsectionElement).findAllByTestId('unit-card');
@@ -2031,6 +2069,9 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1];
@@ -2062,6 +2103,9 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1];
@@ -2091,8 +2135,6 @@ describe('<CourseOutline />', () => {
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const section = store.getState().courseOutline.sectionsList[2];
const [subsection] = section.childInfo.children;
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = unitDraggers[1];
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
@@ -2127,8 +2169,6 @@ describe('<CourseOutline />', () => {
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const section = store.getState().courseOutline.sectionsList[2];
const [subsection] = section.childInfo.children;
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = unitDraggers[1];
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
@@ -2166,8 +2206,6 @@ describe('<CourseOutline />', () => {
.onGet(getXBlockApiUrl(section.id))
.reply(200, courseSectionMock);
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await userEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');

View File

@@ -292,6 +292,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
],
},
@@ -675,6 +680,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
@@ -759,6 +769,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
@@ -843,6 +858,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
@@ -927,6 +947,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
@@ -1011,6 +1036,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
],
},
@@ -1196,6 +1226,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
@@ -1280,6 +1315,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
@@ -1364,6 +1404,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
@@ -1448,6 +1493,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
@@ -1532,6 +1582,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
],
},
@@ -1717,6 +1772,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
],
},
@@ -1995,6 +2055,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
@@ -2079,6 +2144,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
@@ -2163,6 +2233,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
@@ -2247,6 +2322,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
@@ -2331,6 +2411,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
@@ -2415,6 +2500,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
@@ -2499,6 +2589,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
@@ -2583,6 +2678,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
@@ -2667,6 +2767,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
],
},
@@ -2945,6 +3050,11 @@ module.exports = {
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
upstreamInfo: {
readyToSync: false,
upstreamRef: undefined,
versionSynced: undefined,
},
},
],
},

View File

@@ -1,6 +1,7 @@
// @ts-check
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchParams } from 'react-router-dom';
@@ -10,11 +11,13 @@ import {
Hyperlink,
Icon,
IconButton,
IconButtonWithTooltip,
useToggle,
} from '@openedx/paragon';
import {
MoreVert as MoveVertIcon,
EditOutline as EditIcon,
Sync as SyncIcon,
} from '@openedx/paragon/icons';
import { useContentTagsCount } from '../../generic/data/apiHooks';
@@ -55,6 +58,8 @@ const CardHeader = ({
discussionsSettings,
parentInfo,
extraActionsComponent,
onClickSync,
readyToSync,
}) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -130,12 +135,28 @@ const CardHeader = ({
) : (
<>
{titleComponent}
<IconButton
className="item-card-edit-icon"
<IconButtonWithTooltip
className={classNames(
'item-card-button-icon',
{
'item-card-button-icon-disabled': isDisabledEditField,
},
)}
data-testid={`${namePrefix}-edit-button`}
alt={intl.formatMessage(messages.altButtonEdit)}
alt={intl.formatMessage(
isDisabledEditField ? messages.cannotEditTooltip : messages.altButtonRename,
)}
tooltipContent={(
<div>
{intl.formatMessage(
isDisabledEditField ? messages.cannotEditTooltip : messages.altButtonRename,
)}
</div>
)}
iconAs={EditIcon}
onClick={onClickEdit}
// @ts-ignore
disabled={isDisabledEditField}
/>
</>
)}
@@ -147,6 +168,15 @@ const CardHeader = ({
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
)}
{extraActionsComponent}
{readyToSync && (
<IconButtonWithTooltip
data-testid={`${namePrefix}-sync-button`}
alt={intl.formatMessage(messages.readyToSyncButtonAlt)}
iconAs={SyncIcon}
tooltipContent={<div>{intl.formatMessage(messages.readyToSyncButtonAlt)}</div>}
onClick={onClickSync}
/>
)}
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
<Dropdown.Toggle
className="item-card-header__menu"
@@ -178,6 +208,7 @@ const CardHeader = ({
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
disabled={isDisabledEditField}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
@@ -185,6 +216,7 @@ const CardHeader = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={isDisabledEditField}
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.menuManageTags)}
@@ -255,6 +287,8 @@ CardHeader.defaultProps = {
parentInfo: {},
cardId: '',
extraActionsComponent: null,
readyToSync: false,
onClickSync: null,
};
CardHeader.propTypes = {
@@ -301,6 +335,8 @@ CardHeader.propTypes = {
// 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,
onClickSync: PropTypes.func,
readyToSync: PropTypes.bool,
};
export default CardHeader;

View File

@@ -12,7 +12,7 @@
color: $black;
}
.item-card-edit-icon {
.item-card-button-icon {
opacity: 0;
transition: opacity .3s linear;
margin-right: .5rem;
@@ -23,8 +23,14 @@
}
&:hover {
.item-card-edit-icon {
.item-card-button-icon {
opacity: 1;
&.item-card-button-icon-disabled {
pointer-events: all;
opacity: .5;
cursor: default;
}
}
}
}

View File

@@ -240,6 +240,35 @@ describe('<CardHeader />', () => {
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
});
it('check editing is enabled when isDisabledEditField is false', async () => {
const { getByTestId } = renderComponent({
...cardHeaderProps,
});
expect(getByTestId('subsection-edit-button')).toBeEnabled();
// Ensure menu items related to editing are enabled
const menuButton = getByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(menuButton));
expect(await getByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled');
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
});
it('check editing is disabled when isDisabledEditField is true', async () => {
const { getByTestId } = renderComponent({
...cardHeaderProps,
isDisabledEditField: true,
});
expect(await getByTestId('subsection-edit-button')).toBeDisabled();
// Ensure menu items related to editing are disabled
const menuButton = getByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(menuButton));
expect(await getByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true');
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true');
});
it('calls onClickDelete when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent();
@@ -339,4 +368,19 @@ describe('<CardHeader />', () => {
renderComponent();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
it('should render sync button when is ready to sync', () => {
const mockClickSync = jest.fn();
renderComponent({
readyToSync: true,
onClickSync: mockClickSync,
});
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
expect(syncButton).toBeInTheDocument();
fireEvent.click(syncButton);
expect(mockClickSync).toHaveBeenCalled();
});
});

View File

@@ -29,9 +29,9 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.card.status-badge.draft-unpublished-changes',
defaultMessage: 'Draft (Unpublished changes)',
},
altButtonEdit: {
altButtonRename: {
id: 'course-authoring.course-outline.card.button.edit.alt',
defaultMessage: 'Edit',
defaultMessage: 'Rename',
},
menuPublish: {
id: 'course-authoring.course-outline.card.menu.publish',
@@ -77,6 +77,16 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.card.menu.manageTags',
defaultMessage: 'Manage tags',
},
readyToSyncButtonAlt: {
id: 'course-authoring.course-outline.card.button.sync.alt',
defaultMessage: 'Update available - click to sync',
description: 'Alt text for the sync icon button.',
},
cannotEditTooltip: {
id: 'course-authoring.course-outline.card.button.edit.disable.tooltip',
defaultMessage: 'This object was added from a library, so it cannot be edited.',
description: 'Tooltip text of button when the object was added from a library.',
},
});
export default messages;

View File

@@ -53,6 +53,7 @@ import {
setPasteFileNotices,
updateCourseLaunchQueryStatus,
} from './slice';
import { createCourseXblock } from '../../course-unit/data/api';
export function fetchCourseOutlineIndexQuery(courseId) {
return async (dispatch) => {
@@ -540,6 +541,26 @@ export function addNewUnitQuery(parentLocator, callback) {
};
}
export function addUnitFromLibrary(body, callback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await createCourseXblock(body).then(async (result) => {
if (result) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
callback(result.locator);
}
});
} catch (error) /* istanbul ignore next */ {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
function setBlockOrderListQuery(
parentId,
blockIds,

View File

@@ -66,6 +66,8 @@ const HeaderNavigations = ({
{hasSections && (
<Button
variant="outline-primary"
id="expand-collapse-all-button"
data-testid="expand-collapse-all-button"
iconBefore={isSectionsExpanded ? ArrowUpIcon : ArrowDownIcon}
onClick={handleExpandAll}
>

View File

@@ -53,6 +53,7 @@ import {
setUnitOrderListQuery,
pasteClipboardContent,
dismissNotificationQuery,
addUnitFromLibrary,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -128,6 +129,10 @@ const useCourseOutline = ({ courseId }) => {
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
};
const handleAddUnitFromLibrary = (body) => {
dispatch(addUnitFromLibrary(body, openUnitPage));
};
const headerNavigationsActions = {
handleNewSection: handleNewSectionSubmit,
handleReIndex: () => {
@@ -336,6 +341,7 @@ const useCourseOutline = ({ courseId }) => {
getUnitUrl,
openUnitPage,
handleNewUnitSubmit,
handleAddUnitFromLibrary,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
notificationDismissUrl,

View File

@@ -1,12 +1,12 @@
// @ts-check
import React, {
useContext, useEffect, useState, useRef,
useContext, useEffect, useState, useRef, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@openedx/paragon';
import { Button, StandardModal, useToggle } from '@openedx/paragon';
import { Add as IconAdd } from '@openedx/paragon/icons';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
@@ -22,10 +22,16 @@ import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import messages from './messages';
import { ComponentPicker } from '../../library-authoring';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import { ContainerType } from '../../generic/key-utils';
import { ContentType } from '../../library-authoring/routes';
import { getStudioHomeData } from '../../studio-home/data/selectors';
const SubsectionCard = ({
section,
subsection,
isSectionsExpanded,
isSelfPaced,
isCustomRelativeDatesActive,
children,
@@ -37,6 +43,7 @@ const SubsectionCard = ({
onOpenDeleteModal,
onDuplicateSubmit,
onNewUnitSubmit,
onAddUnitFromLibrary,
onOrderChange,
onOpenConfigureModal,
onPasteClick,
@@ -51,6 +58,17 @@ const SubsectionCard = ({
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'subsection';
const { sharedClipboardData, showPasteUnit } = useClipboard();
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
// as it has a useEffect that fetches course waffle flags whenever
// location.search is updated. Course search updates location.search when
// user types, which will then trigger the useEffect and reload the page.
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
const { librariesV2Enabled } = useSelector(getStudioHomeData);
const [
isAddLibraryUnitModalOpen,
openAddLibraryUnitModal,
closeAddLibraryUnitModal,
] = useToggle(false);
const {
id,
@@ -81,7 +99,7 @@ const SubsectionCard = ({
return false;
};
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || !isHeaderVisible);
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || !isHeaderVisible || isSectionsExpanded);
const subsectionStatus = getItemStatus({
published,
visibilityState,
@@ -89,6 +107,10 @@ const SubsectionCard = ({
});
const borderStyle = getItemStatusBorder(subsectionStatus);
useEffect(() => {
setIsExpanded(isSectionsExpanded);
}, [isSectionsExpanded]);
const handleExpandContent = () => {
setIsExpanded((prevState) => !prevState);
};
@@ -172,90 +194,129 @@ const SubsectionCard = ({
&& !(isHeaderVisible === false)
);
const handleSelectLibraryUnit = useCallback((selectedUnit) => {
onAddUnitFromLibrary({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator: id,
libraryContentKey: selectedUnit.usageKey,
});
closeAddLibraryUnitModal();
}, []);
return (
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#f8f7f6',
...borderStyle,
}}
>
<div
className={`subsection-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="subsection-card"
ref={currentRef}
<>
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#f8f7f6',
...borderStyle,
}}
>
{isHeaderVisible && (
<>
<CardHeader
title={displayName}
status={subsectionStatus}
cardId={id}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleSubsectionMoveUp}
onClickMoveDown={handleSubsectionMoveDown}
onClickConfigure={onOpenConfigureModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
isSequential
extraActionsComponent={extraActionsComponent}
/>
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={subsection}
<div
className={`subsection-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="subsection-card"
ref={currentRef}
>
{isHeaderVisible && (
<>
<CardHeader
title={displayName}
status={subsectionStatus}
cardId={id}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleSubsectionMoveUp}
onClickMoveDown={handleSubsectionMoveDown}
onClickConfigure={onOpenConfigureModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
isSequential
extraActionsComponent={extraActionsComponent}
/>
</div>
</>
)}
{isExpanded && (
<div
data-testid="subsection-card__units"
className={classNames('subsection-card__units', { 'item-children': isDraggable })}
>
{children}
{actions.childAddable && (
<>
<Button
data-testid="new-unit-button"
className="mt-4"
variant="outline-primary"
iconBefore={IconAdd}
block
onClick={handleNewButtonClick}
>
{intl.formatMessage(messages.newUnitButton)}
</Button>
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
<PasteComponent
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={subsection}
/>
</div>
</>
)}
{(isExpanded) && (
<div
data-testid="subsection-card__units"
className={classNames('subsection-card__units', { 'item-children': isDraggable })}
>
{children}
{actions.childAddable && (
<>
<Button
data-testid="new-unit-button"
className="mt-4"
text={intl.formatMessage(messages.pasteButton)}
clipboardData={sharedClipboardData}
onClick={handlePasteButtonClick}
/>
)}
</>
)}
</div>
)}
</div>
</SortableItem>
variant="outline-primary"
iconBefore={IconAdd}
block
onClick={handleNewButtonClick}
>
{intl.formatMessage(messages.newUnitButton)}
</Button>
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
<PasteComponent
className="mt-4"
text={intl.formatMessage(messages.pasteButton)}
clipboardData={sharedClipboardData}
onClick={handlePasteButtonClick}
/>
)}
{librariesV2Enabled && (
<Button
data-testid="use-unit-from-library"
className="mt-4"
variant="outline-primary"
iconBefore={IconAdd}
block
onClick={openAddLibraryUnitModal}
>
{intl.formatMessage(messages.useUnitFromLibraryButton)}
</Button>
)}
</>
)}
</div>
)}
</div>
</SortableItem>
<StandardModal
title={intl.formatMessage(messages.unitPickerModalTitle)}
isOpen={isAddLibraryUnitModalOpen}
onClose={closeAddLibraryUnitModal}
isOverflowVisible={false}
size="xl"
>
<ComponentPicker
showOnlyPublished
extraFilter={['block_type = "unit"']}
componentPickerMode="single"
onComponentSelected={handleSelectLibraryUnit}
visibleTabs={[ContentType.units]}
/>
</StandardModal>
</>
);
};
@@ -298,6 +359,7 @@ SubsectionCard.propTypes = {
}).isRequired,
}).isRequired,
children: PropTypes.node,
isSectionsExpanded: PropTypes.bool.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
onOpenPublishModal: PropTypes.func.isRequired,
@@ -306,6 +368,7 @@ SubsectionCard.propTypes = {
onOpenDeleteModal: PropTypes.func.isRequired,
onDuplicateSubmit: PropTypes.func.isRequired,
onNewUnitSubmit: PropTypes.func.isRequired,
onAddUnitFromLibrary: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
getPossibleMoves: PropTypes.func.isRequired,
onOrderChange: PropTypes.func.isRequired,

View File

@@ -1,6 +1,6 @@
import { MemoryRouter } from 'react-router-dom';
import {
act, render, fireEvent, within,
act, render, fireEvent, within, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -10,9 +10,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import SubsectionCard from './SubsectionCard';
import cardHeaderMessages from '../card-header/messages';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
let store;
const mockPathname = '/foo-bar';
const containerKey = 'lct:org:lib:unit:1';
const handleOnAddUnitFromLibrary = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -21,6 +24,31 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: () => ({
librariesV2Enabled: true,
}),
}));
// Mock ComponentPicker to call onComponentSelected on click
jest.mock('../../library-authoring/component-picker', () => ({
ComponentPicker: (props) => {
const onClick = () => {
// eslint-disable-next-line react/prop-types
props.onComponentSelected({
usageKey: containerKey,
blockType: 'unti',
});
};
return (
<button type="submit" onClick={onClick}>
Dummy button
</button>
);
},
}));
const unit = {
id: 'unit-1',
};
@@ -80,6 +108,7 @@ const renderComponent = (props, entry = '/') => render(
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onNewUnitSubmit={jest.fn()}
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false}
onEditClick={jest.fn()}
savingStatus=""
@@ -247,4 +276,31 @@ describe('<SubsectionCard />', () => {
expect(cardUnits).toBeNull();
expect(newUnitButton).toBeNull();
});
it('should add unit from library', async () => {
renderComponent();
const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandButton);
const useUnitFromLibraryButton = screen.getByRole('button', {
name: /use unit from library/i,
});
expect(useUnitFromLibraryButton).toBeInTheDocument();
fireEvent.click(useUnitFromLibraryButton);
expect(await screen.findByText('Select unit'));
// click dummy button to execute onComponentSelected prop.
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
fireEvent.click(dummyBtn);
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
type: COMPONENT_TYPES.libraryV2,
parentLocator: '123',
category: 'vertical',
libraryContentKey: containerKey,
});
});
});

View File

@@ -4,10 +4,22 @@ const messages = defineMessages({
newUnitButton: {
id: 'course-authoring.course-outline.subsection.button.new-unit',
defaultMessage: 'New unit',
description: 'Message of the button to create a new unit in a subsection.',
},
pasteButton: {
id: 'course-authoring.course-outline.subsection.button.paste-unit',
defaultMessage: 'Paste unit',
description: 'Message of the button to paste a new unit in a subsection.',
},
useUnitFromLibraryButton: {
id: 'course-authoring.course-outline.subsection.button.use-unit-from-library',
defaultMessage: 'Use unit from library',
description: 'Message of the button to add a new unit from a library in a subsection.',
},
unitPickerModalTitle: {
id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text',
defaultMessage: 'Select unit',
description: 'Library unit picker modal title.',
},
});

View File

@@ -1,5 +1,10 @@
// @ts-check
import React, { useEffect, useRef } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useToggle } from '@openedx/paragon';
@@ -8,13 +13,16 @@ import { useSearchParams } from 'react-router-dom';
import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
import { fetchCourseSectionQuery } from '../data/thunk';
import { RequestStatus } from '../../data/constants';
import { isUnitReadOnly } from '../../course-unit/data/utils';
import CardHeader from '../card-header/CardHeader';
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';
import { PreviewLibraryXBlockChanges } from '../../course-unit/preview-changes';
const UnitCard = ({
unit,
@@ -40,6 +48,7 @@ const UnitCard = ({
const locatorId = searchParams.get('show');
const isScrolledToElement = locatorId === unit.id;
const [isFormOpen, openForm, closeForm] = useToggle(false);
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
const namePrefix = 'unit';
const { copyToClipboard } = useClipboard();
@@ -55,8 +64,24 @@ const UnitCard = ({
isHeaderVisible = true,
enableCopyPasteUnits = false,
discussionEnabled,
upstreamInfo,
} = unit;
const blockSyncData = useMemo(() => {
if (!upstreamInfo.readyToSync) {
return undefined;
}
return {
displayName,
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isVertical: true,
};
}, [upstreamInfo]);
const readOnly = isUnitReadOnly(unit);
// re-create actions object for customizations
const actions = { ...unitActions };
// add actions to control display of move up & down menu buton.
@@ -104,6 +129,10 @@ const UnitCard = ({
copyToClipboard(id);
};
const handleOnPostChangeSync = useCallback(async () => {
await dispatch(fetchCourseSectionQuery([section.id]));
}, [dispatch, section]);
const titleComponent = (
<TitleLink
title={displayName}
@@ -144,59 +173,71 @@ const UnitCard = ({
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
return (
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#fdfdfd',
...borderStyle,
}}
>
<div
className={`unit-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="unit-card"
ref={currentRef}
<>
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#fdfdfd',
...borderStyle,
}}
>
<CardHeader
title={displayName}
status={unitStatus}
hasChanges={hasChanges}
cardId={id}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickConfigure={onOpenConfigureModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleUnitMoveUp}
onClickMoveDown={handleUnitMoveDown}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
isVertical
enableCopyPasteUnits={enableCopyPasteUnits}
onClickCopy={handleCopyClick}
discussionEnabled={discussionEnabled}
discussionsSettings={discussionsSettings}
parentInfo={parentInfo}
extraActionsComponent={extraActionsComponent}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={unit}
<div
className={`unit-card ${isScrolledToElement ? 'highlight' : ''}`}
data-testid="unit-card"
ref={currentRef}
>
<CardHeader
title={displayName}
status={unitStatus}
hasChanges={hasChanges}
cardId={id}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickConfigure={onOpenConfigureModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleUnitMoveUp}
onClickMoveDown={handleUnitMoveDown}
onClickSync={openSyncModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
isVertical
enableCopyPasteUnits={enableCopyPasteUnits}
onClickCopy={handleCopyClick}
discussionEnabled={discussionEnabled}
discussionsSettings={discussionsSettings}
parentInfo={parentInfo}
extraActionsComponent={extraActionsComponent}
readyToSync={upstreamInfo.readyToSync}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={unit}
/>
</div>
</div>
</div>
</SortableItem>
</SortableItem>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
/>
)}
</>
);
};
@@ -222,6 +263,11 @@ UnitCard.propTypes = {
isHeaderVisible: PropTypes.bool,
enableCopyPasteUnits: PropTypes.bool,
discussionEnabled: PropTypes.bool,
upstreamInfo: PropTypes.shape({
readyToSync: PropTypes.bool.isRequired,
upstreamRef: PropTypes.string.isRequired,
versionSynced: PropTypes.number.isRequired,
}).isRequired,
}).isRequired,
subsection: PropTypes.shape({
id: PropTypes.string.isRequired,

View File

@@ -1,5 +1,6 @@
import {
act, render, fireEvent, within,
act, render, fireEvent, within, screen,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -11,6 +12,17 @@ import UnitCard from './UnitCard';
import cardMessages from '../card-header/messages';
let store;
const mockUseAcceptLibraryBlockChanges = jest.fn();
const mockUseIgnoreLibraryBlockChanges = jest.fn();
jest.mock('../../course-unit/data/apiHooks', () => ({
useAcceptLibraryBlockChanges: () => ({
mutateAsync: mockUseAcceptLibraryBlockChanges,
}),
useIgnoreLibraryBlockChanges: () => ({
mutateAsync: mockUseIgnoreLibraryBlockChanges,
}),
}));
const section = {
id: '1',
@@ -43,6 +55,11 @@ const unit = {
duplicable: true,
},
isHeaderVisible: true,
upstreamInfo: {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:unit:1',
versionSynced: 1,
},
};
const queryClient = new QueryClient();
@@ -147,4 +164,51 @@ describe('<UnitCard />', () => {
});
expect(queryByRole('status')).not.toBeInTheDocument();
});
it('should sync unit changes from upstream', async () => {
renderComponent();
expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument();
// Click on sync button
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
fireEvent.click(syncButton);
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for unit changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
fireEvent.click(acceptChangesButton);
await waitFor(() => expect(mockUseAcceptLibraryBlockChanges).toHaveBeenCalled());
});
it('should decline sync unit changes from upstream', async () => {
renderComponent();
expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument();
// Click on sync button
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
fireEvent.click(syncButton);
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for unit changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
fireEvent.click(ignoreChangesButton);
// Should open the confirmation modal
expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument();
// Click on ignore button
const ignoreButton = screen.getByRole('button', { name: /ignore/i });
fireEvent.click(ignoreButton);
await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled());
});
});

View File

@@ -7,7 +7,7 @@ import {
ActionRow,
Button,
} from '@openedx/paragon';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { useNavigate, useParams } from 'react-router-dom';

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import {
Container, Layout, Stack, Button, TransitionReplace,
Alert, Container, Layout, Button, TransitionReplace,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -26,8 +26,6 @@ import AddComponent from './add-component/AddComponent';
import HeaderTitle from './header-title/HeaderTitle';
import Breadcrumbs from './breadcrumbs/Breadcrumbs';
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 { PasteNotificationAlert } from './clipboard';
@@ -40,8 +38,10 @@ const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
const intl = useIntl();
const {
courseUnit,
isLoading,
sequenceId,
courseUnitLoadingStatus,
unitTitle,
unitCategory,
errorMessage,
@@ -75,6 +75,8 @@ const CourseUnit = ({ courseId }) => {
} = useCourseUnit({ courseId, blockId });
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);
const readOnly = !!courseUnit.readOnly;
useEffect(() => {
document.title = getPageHeadTitle('', unitTitle);
}, [unitTitle]);
@@ -136,6 +138,24 @@ const CourseUnit = ({ courseId }) => {
/>
) : null}
</TransitionReplace>
{courseUnit.upstreamInfo?.upstreamLink && (
<AlertMessage
title={intl.formatMessage(
messages.alertLibraryUnitReadOnlyText,
{
link: (
<Alert.Link
className="ml-1"
href={courseUnit.upstreamInfo.upstreamLink}
>
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
</Alert.Link>
),
},
)}
variant="info"
/>
)}
<SubHeader
hideBorder
title={(
@@ -191,18 +211,21 @@ const CourseUnit = ({ courseId }) => {
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
courseUnitLoadingStatus={courseUnitLoadingStatus}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
{showPasteXBlock && canPasteComponent && isUnitVerticalType && (
{!readOnly && (
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
)}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={
@@ -220,20 +243,15 @@ const CourseUnit = ({ courseId }) => {
<IframePreviewLibraryXBlockChanges />
</Layout.Element>
<Layout.Element>
<Stack gap={3}>
{isUnitVerticalType && (
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
/>
)}
{isSplitTestType && (
<Sidebar data-testid="course-split-test-sidebar">
<SplitTestSidebarInfo />
</Sidebar>
)}
</Stack>
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/>
</Layout.Element>
</Layout>
</section>

View File

@@ -17,7 +17,6 @@ import { cloneDeep, set } from 'lodash';
import {
getCourseSectionVerticalApiUrl,
getCourseUnitApiUrl,
getCourseVerticalChildrenApiUrl,
getCourseOutlineInfoUrl,
getXBlockBaseApiUrl,
@@ -28,7 +27,6 @@ import {
deleteUnitItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
@@ -37,13 +35,12 @@ import initializeStore from '../store';
import {
courseCreateXblockMock,
courseSectionVerticalMock,
courseUnitIndexMock,
courseUnitMock,
courseVerticalChildrenMock,
clipboardMockResponse,
courseOutlineInfoMock,
} from './__mocks__';
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
import { clipboardUnit } from '../__mocks__';
import { executeThunk } from '../utils';
import { IFRAME_FEATURE_POLICY } from '../constants';
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
@@ -65,13 +62,15 @@ import xblockContainerIframeMessages from './xblock-container-iframe/messages';
import headerNavigationsMessages from './header-navigations/messages';
import sidebarMessages from './sidebar/messages';
import messages from './messages';
import * as selectors from '../data/selectors';
let axiosMock;
let store;
let queryClient;
const courseId = '123';
const blockId = '567890';
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
const unitDisplayName = courseSectionVerticalMock.xblock_info.display_name;
const mockedUsedNavigate = jest.fn();
const userName = 'openedx';
const handleConfigureSubmitMock = jest.fn();
@@ -89,7 +88,7 @@ const postXBlockBody = {
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ blockId }),
useParams: () => ({ blockId, sequenceId }),
useNavigate: () => mockedUsedNavigate,
}));
@@ -145,14 +144,10 @@ describe('<CourseUnit />', () => {
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId, courseId), store.dispatch);
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);
@@ -166,27 +161,27 @@ describe('<CourseUnit />', () => {
});
it('render CourseUnit component correctly', async () => {
const { getByText, getByRole, getByTestId } = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
render(<RootWrapper />);
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
await waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument();
const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(screen.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: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
});
});
it('renders the course unit iframe with correct attributes', async () => {
const { getByTitle } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.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', 'height: 0px;');
@@ -210,27 +205,27 @@ describe('<CourseUnit />', () => {
});
it('displays an error alert when a studioAjaxError message is received', async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.studioAjaxError, {
error: 'Some error text...',
});
});
expect(getByTestId('saving-error-alert')).toBeInTheDocument();
expect(screen.getByTestId('saving-error-alert')).toBeInTheDocument();
});
it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => {
const { getByTitle } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId });
const legacyXBlockEditModalIframe = getByTitle(
const legacyXBlockEditModalIframe = screen.getByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).toBeInTheDocument();
@@ -248,14 +243,14 @@ describe('<CourseUnit />', () => {
});
it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => {
const { getByTitle, queryByTitle } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId });
const legacyXBlockEditModalIframe = queryByTitle(
const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
@@ -263,29 +258,32 @@ describe('<CourseUnit />', () => {
});
it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => {
const { getByTitle, queryByTitle, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.saveEditedXBlockData);
const legacyXBlockEditModalIframe = queryByTitle(
const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_changes: true,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_changes: true,
published_by: userName,
},
});
await waitFor(() => {
const courseUnitSidebar = getByTestId('course-unit-sidebar');
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
@@ -304,24 +302,27 @@ describe('<CourseUnit />', () => {
});
it('updates course unit sidebar after receiving refreshPositions message', async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.refreshPositions);
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_changes: true,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_changes: true,
published_by: userName,
},
});
await waitFor(() => {
const courseUnitSidebar = getByTestId('course-unit-sidebar');
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
@@ -340,12 +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, getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(async () => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -356,10 +355,10 @@ describe('<CourseUnit />', () => {
usageId: courseVerticalChildrenMock.children[0].block_id,
});
expect(getByText(/Delete this component?/i)).toBeInTheDocument();
expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument();
expect(screen.getByText(/Delete this component?/i)).toBeInTheDocument();
expect(screen.getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument();
const dialog = getByRole('dialog');
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
// Find the Cancel and Delete buttons within the iframe by their specific classes
@@ -372,7 +371,7 @@ describe('<CourseUnit />', () => {
usageId: courseVerticalChildrenMock.children[0].block_id,
});
expect(getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
userEvent.click(deleteButton);
});
@@ -382,30 +381,36 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(getByText(unitDisplayName)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
});
axiosMock
.onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id))
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);
await executeThunk(deleteUnitItemQuery(
courseId,
courseVerticalChildrenMock.children[0].block_id,
@@ -426,43 +431,41 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
);
// after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
});
it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => {
const {
getByTitle, getByRole, getByText, queryByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.duplicateXBlock, {
id: courseVerticalChildrenMock.children[0].block_id,
@@ -478,8 +481,14 @@ describe('<CourseUnit />', () => {
const updatedCourseVerticalChildren = [
...courseVerticalChildrenMock.children,
{
...courseVerticalChildrenMock.children[0],
name: 'New Cloned XBlock',
block_id: '1234567890',
block_type: 'drag-and-drop-v2',
user_partition_info: {
selectable_partitions: [],
selected_partition_index: -1,
selected_groups_label: '',
},
},
];
@@ -491,9 +500,9 @@ describe('<CourseUnit />', () => {
});
await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -511,34 +520,37 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(getByText(unitDisplayName)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -546,23 +558,23 @@ describe('<CourseUnit />', () => {
);
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
});
@@ -570,19 +582,19 @@ describe('<CourseUnit />', () => {
it('handles CourseUnit header action buttons', async () => {
const { open } = window;
window.open = jest.fn();
const { getByRole } = render(<RootWrapper />);
render(<RootWrapper />);
const {
draft_preview_link: draftPreviewLink,
published_preview_link: publishedPreviewLink,
} = courseSectionVerticalMock;
await waitFor(() => {
const viewLiveButton = getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage });
const viewLiveButton = screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage });
userEvent.click(viewLiveButton);
expect(window.open).toHaveBeenCalled();
expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank');
const previewButton = getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage });
const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage });
userEvent.click(previewButton);
expect(window.open).toHaveBeenCalled();
expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank');
@@ -592,12 +604,7 @@ describe('<CourseUnit />', () => {
});
it('checks courseUnit title changing when edit query is successfully', async () => {
const {
findByText,
queryByRole,
getByRole,
getByTestId,
} = render(<RootWrapper />);
render(<RootWrapper />);
let editTitleButton = null;
let titleEditField = null;
const newDisplayName = `${unitDisplayName} new`;
@@ -610,12 +617,15 @@ describe('<CourseUnit />', () => {
}))
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
metadata: {
...courseUnitIndexMock.metadata,
display_name: newDisplayName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
});
axiosMock
@@ -633,7 +643,7 @@ describe('<CourseUnit />', () => {
});
await waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title');
const unitHeaderTitle = screen.getByTestId('unit-header-title');
editTitleButton = within(unitHeaderTitle)
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
titleEditField = within(unitHeaderTitle)
@@ -641,7 +651,7 @@ describe('<CourseUnit />', () => {
});
expect(titleEditField).not.toBeInTheDocument();
userEvent.click(editTitleButton);
titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
await userEvent.clear(titleEditField);
await userEvent.type(titleEditField, newDisplayName);
@@ -649,9 +659,10 @@ describe('<CourseUnit />', () => {
expect(titleEditField).toHaveValue(newDisplayName);
titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
expect(titleEditField).not.toBeInTheDocument();
expect(await findByText(newDisplayName)).toBeInTheDocument();
expect(await screen.findByText(newDisplayName)).toBeInTheDocument();
});
it('doesn\'t handle creating xblock and displays an error message', async () => {
@@ -671,15 +682,14 @@ describe('<CourseUnit />', () => {
});
});
it('handle creating Problem xblock and navigate to editor page', async () => {
const { courseKey, locator } = courseCreateXblockMock;
it('handle creating Problem xblock and showing editor modal', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId }))
.reply(200, courseCreateXblockMock);
const { getByText, getByRole } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
@@ -688,93 +698,57 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
const problemButton = getByRole('button', {
const problemButton = screen.getByRole('button', {
name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'),
hidden: true,
});
userEvent.click(problemButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/problem/${locator}`);
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating problem xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
it('handle creating Text xblock and saves scroll position in localStorage', async () => {
const { getByText, getByRole } = render(<RootWrapper />);
const xblockType = 'text';
axiosMock
.onPost(postXBlockBaseApiUrl({ type: xblockType, category: 'html', parentLocator: blockId }))
.reply(200, courseCreateXblockMock);
window.scrollTo(0, 250);
Object.defineProperty(window, 'scrollY', { value: 250, configurable: true });
await waitFor(() => {
const textButton = screen.getByRole('button', { name: /Text/i });
expect(getByText(addComponentMessages.title.defaultMessage)).toBeInTheDocument();
userEvent.click(textButton);
const addXBlockDialog = getByRole('dialog');
expect(addXBlockDialog).toBeInTheDocument();
expect(getByText(
addComponentMessages.modalContainerTitle.defaultMessage.replace('{componentTitle}', xblockType),
)).toBeInTheDocument();
const textRadio = screen.getByRole('radio', { name: /Text/i });
userEvent.click(textRadio);
expect(textRadio).toBeChecked();
const selectBtn = getByRole('button', { name: addComponentMessages.modalBtnText.defaultMessage });
expect(selectBtn).toBeInTheDocument();
userEvent.click(selectBtn);
});
expect(localStorage.getItem('createXBlockLastYPosition')).toBe('250');
});
it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
const { getByRole, getAllByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
let units = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -784,7 +758,7 @@ describe('<CourseUnit />', () => {
]);
await waitFor(async () => {
units = getAllByTestId('course-unit-btn');
units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
expect(units).toHaveLength(courseUnits.length);
});
@@ -801,8 +775,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const addNewUnitBtn = getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
units = getAllByTestId('course-unit-btn');
const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children;
@@ -814,7 +788,7 @@ describe('<CourseUnit />', () => {
});
it('the sequence unit is updated after changing the unit header', async () => {
const { getAllByTestId, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
@@ -831,12 +805,15 @@ describe('<CourseUnit />', () => {
},
}))
.reply(200, { dummy: 'value' })
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
metadata: {
...courseUnitIndexMock.metadata,
display_name: newDisplayName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
})
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -846,7 +823,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const unitHeaderTitle = getByTestId('unit-header-title');
const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
userEvent.click(editTitleButton);
@@ -858,20 +835,21 @@ describe('<CourseUnit />', () => {
await userEvent.tab();
await waitFor(async () => {
const units = getAllByTestId('course-unit-btn');
const units = screen.getAllByTestId('course-unit-btn');
expect(units.some(unit => unit.title === newDisplayName)).toBe(true);
});
});
it('handles creating Video xblock and navigates to editor page', async () => {
const { courseKey, locator } = courseCreateXblockMock;
it('handles creating Video xblock and showing editor modal using videogalleryflow', async () => {
const waffleSpy = jest.spyOn(selectors, 'getWaffleFlags').mockReturnValue({ useVideoGalleryFlow: true });
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.reply(200, courseCreateXblockMock);
const { getByText, queryByRole, getByRole } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
@@ -880,96 +858,181 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
});
userEvent.click(videoButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`);
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = screen.getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
hidden: true,
});
userEvent.click(videoButton);
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating video xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
waffleSpy.mockRestore();
});
it('handles creating Video xblock and showing editor modal', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.reply(200, courseCreateXblockMock);
render(<RootWrapper />);
await waitFor(() => {
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.makePublic,
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = screen.getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
hidden: true,
});
userEvent.click(videoButton);
});
/** TODO -- fix this test.
await waitFor(() => {
expect(getByRole('textbox', { name: /paste your video id or url/i })).toBeInTheDocument();
});
*/
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating video xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
it('renders course unit details for a draft with unpublished changes', async () => {
const { getByText } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(getByText(
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
});
it('renders course unit details in the sidebar', async () => {
const { getByText } = render(<RootWrapper />);
const courseUnitLocationId = extractCourseUnitId(courseUnitIndexMock.id);
render(<RootWrapper />);
const courseUnitLocationId = extractCourseUnitId(courseSectionVerticalMock.xblock_info.id);
await waitFor(() => {
expect(getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitLocationId)).toBeInTheDocument();
expect(getByText(sidebarMessages.unitLocationDescription.defaultMessage
expect(screen.getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitLocationId)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.unitLocationDescription.defaultMessage
.replace('{id}', courseUnitLocationId))).toBeInTheDocument();
});
});
@@ -990,13 +1053,16 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
currently_visible_to_students: false,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
currently_visible_to_students: false,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
await waitFor(() => {
const alert = screen.queryAllByRole('alert').find(
@@ -1007,13 +1073,13 @@ describe('<CourseUnit />', () => {
});
it('should toggle visibility from sidebar and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
let courseUnitSidebar;
let draftUnpublishedChangesHeading;
let visibilityCheckbox;
await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar');
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
draftUnpublishedChangesHeading = within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
@@ -1033,11 +1099,14 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch);
@@ -1050,7 +1119,7 @@ describe('<CourseUnit />', () => {
userEvent.click(visibilityCheckbox);
const modalNotification = getByRole('dialog');
const modalNotification = screen.getByRole('dialog');
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityActionButtonText.defaultMessage });
const cancelBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityCancelButtonText.defaultMessage });
const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' });
@@ -1070,8 +1139,8 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null), store.dispatch);
@@ -1082,12 +1151,12 @@ describe('<CourseUnit />', () => {
});
it('should publish course unit after click on the "Publish" button', async () => {
const { getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
let courseUnitSidebar;
let publishBtn;
await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar');
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage });
expect(publishBtn).toBeInTheDocument();
@@ -1100,12 +1169,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -1114,19 +1186,19 @@ describe('<CourseUnit />', () => {
.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(within(courseUnitSidebar).getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(publishBtn).not.toBeInTheDocument();
});
it('should discard changes after click on the "Discard changes" button', async () => {
const { getByTestId, getByRole } = render(<RootWrapper />);
render(<RootWrapper />);
let courseUnitSidebar;
let discardChangesBtn;
await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar');
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
const draftUnpublishedChangesHeading = within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
@@ -1136,7 +1208,7 @@ describe('<CourseUnit />', () => {
userEvent.click(discardChangesBtn);
const modalNotification = getByRole('dialog');
const modalNotification = screen.getByRole('dialog');
expect(modalNotification).toBeInTheDocument();
expect(within(modalNotification)
.getByText(sidebarMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
@@ -1156,9 +1228,14 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock, published: true, has_changes: false,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
published: true,
has_changes: false,
},
});
await executeThunk(editCourseUnitVisibilityAndData(
@@ -1173,7 +1250,7 @@ describe('<CourseUnit />', () => {
});
it('should toggle visibility from header configure modal and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
let courseUnitSidebar;
let sidebarVisibilityCheckbox;
let modalVisibilityCheckbox;
@@ -1181,16 +1258,16 @@ describe('<CourseUnit />', () => {
let restrictAccessSelect;
await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar');
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
sidebarVisibilityCheckbox = within(courseUnitSidebar)
.getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage);
expect(sidebarVisibilityCheckbox).not.toBeChecked();
const headerConfigureBtn = getByRole('button', { name: /settings/i });
const headerConfigureBtn = screen.getByRole('button', { name: /settings/i });
expect(headerConfigureBtn).toBeInTheDocument();
userEvent.click(headerConfigureBtn);
configureModal = getByTestId('configure-modal');
configureModal = screen.getByTestId('configure-modal');
restrictAccessSelect = within(configureModal)
.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage });
expect(within(configureModal)
@@ -1215,17 +1292,20 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onPost(getXBlockBaseApiUrl(courseUnitIndexMock.id), {
.onPost(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id), {
publish: null,
metadata: { visible_to_staff_only: true, group_access: { 50: [2] }, discussion_enabled: true },
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.replyOnce(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
},
});
const modalSaveBtn = within(configureModal)
@@ -1246,8 +1326,8 @@ describe('<CourseUnit />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
const { getByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Unit tags')).toBeInTheDocument(); });
render(<RootWrapper />);
await waitFor(() => { expect(screen.getByText('Unit tags')).toBeInTheDocument(); });
});
it('hides the Tags sidebar when not enabled', async () => {
@@ -1255,28 +1335,28 @@ describe('<CourseUnit />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
const { queryByText } = render(<RootWrapper />);
await waitFor(() => { expect(queryByText('Unit tags')).not.toBeInTheDocument(); });
render(<RootWrapper />);
await waitFor(() => { expect(screen.queryByText('Unit tags')).not.toBeInTheDocument(); });
});
describe('Copy paste functionality', () => {
it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => {
const {
getAllByTestId, getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -1287,7 +1367,7 @@ describe('<CourseUnit />', () => {
]);
await waitFor(() => {
units = getAllByTestId('course-unit-btn');
units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
expect(units).toHaveLength(courseUnits.length);
});
@@ -1303,7 +1383,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
units = getAllByTestId('course-unit-btn');
units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children;
@@ -1314,30 +1394,27 @@ describe('<CourseUnit />', () => {
});
it('should increase the number of course XBlocks after copying and pasting a block', async () => {
const { getByRole, getByTitle } = render(<RootWrapper />);
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.copyXBlock, {
id: courseVerticalChildrenMock.children[0].block_id,
});
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -1373,7 +1450,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -1383,22 +1460,22 @@ describe('<CourseUnit />', () => {
});
it('displays a notification about new files after pasting a component', async () => {
const {
queryByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -1417,7 +1494,7 @@ describe('<CourseUnit />', () => {
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
const newFilesAlert = getByTestId('has-new-files-alert');
const newFilesAlert = screen.getByTestId('has-new-files-alert');
expect(within(newFilesAlert)
.getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument();
@@ -1431,26 +1508,26 @@ describe('<CourseUnit />', () => {
userEvent.click(within(newFilesAlert).getByText(/Dismiss/i));
expect(queryByTestId('has-new-files-alert')).toBeNull();
expect(screen.queryByTestId('has-new-files-alert')).toBeNull();
});
it('displays a notification about conflicting errors after pasting a component', async () => {
const {
queryByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -1471,7 +1548,7 @@ describe('<CourseUnit />', () => {
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
const conflictingErrorsAlert = getByTestId('has-conflicting-errors-alert');
const conflictingErrorsAlert = screen.getByTestId('has-conflicting-errors-alert');
expect(within(conflictingErrorsAlert)
.getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument();
@@ -1485,26 +1562,26 @@ describe('<CourseUnit />', () => {
userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i));
expect(queryByTestId('has-conflicting-errors-alert')).toBeNull();
expect(screen.queryByTestId('has-conflicting-errors-alert')).toBeNull();
});
it('displays a notification about error files after pasting a component', async () => {
const {
queryByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -1525,7 +1602,7 @@ describe('<CourseUnit />', () => {
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
const errorFilesAlert = getByTestId('has-error-files-alert');
const errorFilesAlert = screen.getByTestId('has-error-files-alert');
expect(within(errorFilesAlert)
.getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument();
@@ -1534,11 +1611,11 @@ describe('<CourseUnit />', () => {
userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i));
expect(queryByTestId('has-error-files')).toBeNull();
expect(screen.queryByTestId('has-error-files')).toBeNull();
});
it('should hide the "Paste component" block if canPasteComponent is false', async () => {
const { queryByText, queryByRole } = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
@@ -1549,10 +1626,10 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
expect(queryByRole('button', {
expect(screen.queryByRole('button', {
name: messages.pasteButtonText.defaultMessage,
})).not.toBeInTheDocument();
expect(queryByText(
expect(screen.queryByText(
pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage,
)).not.toBeInTheDocument();
});
@@ -1586,9 +1663,7 @@ describe('<CourseUnit />', () => {
});
it('should display "Move Modal" on receive trigger message', async () => {
const {
getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
await screen.findByText(unitDisplayName);
@@ -1602,15 +1677,12 @@ describe('<CourseUnit />', () => {
await screen.findByText(
moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title),
);
expect(getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument();
});
it('should navigates to xBlock current unit', async () => {
const {
getByText,
getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
await screen.findByText(unitDisplayName);
@@ -1626,7 +1698,7 @@ describe('<CourseUnit />', () => {
);
const currentSection = courseOutlineInfoMock.child_info.children[1];
const currentSectionItemBtn = getByRole('button', {
const currentSectionItemBtn = screen.getByRole('button', {
name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
expect(currentSectionItemBtn).toBeInTheDocument();
@@ -1634,7 +1706,7 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
const currentSubsection = currentSection.child_info.children[0];
const currentSubsectionItemBtn = getByRole('button', {
const currentSubsectionItemBtn = screen.getByRole('button', {
name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
expect(currentSubsectionItemBtn).toBeInTheDocument();
@@ -1642,7 +1714,7 @@ describe('<CourseUnit />', () => {
});
await waitFor(() => {
const currentComponentLocationText = getByText(
const currentComponentLocationText = screen.getByText(
moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage,
);
expect(currentComponentLocationText).toBeInTheDocument();
@@ -1650,17 +1722,15 @@ describe('<CourseUnit />', () => {
});
it('should allow move operation and handles it successfully', async () => {
const {
getByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onPatch(postXBlockBaseApiUrl())
.reply(200, {});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await screen.findByText(unitDisplayName);
@@ -1676,7 +1746,7 @@ describe('<CourseUnit />', () => {
);
const currentSection = courseOutlineInfoMock.child_info.children[1];
const currentSectionItemBtn = getByRole('button', {
const currentSectionItemBtn = screen.getByRole('button', {
name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
expect(currentSectionItemBtn).toBeInTheDocument();
@@ -1684,7 +1754,7 @@ describe('<CourseUnit />', () => {
const currentSubsection = currentSection.child_info.children[1];
await waitFor(() => {
const currentSubsectionItemBtn = getByRole('button', {
const currentSubsectionItemBtn = screen.getByRole('button', {
name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
expect(currentSubsectionItemBtn).toBeInTheDocument();
@@ -1693,14 +1763,14 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
const currentUnit = currentSubsection.child_info.children[0];
const currentUnitItemBtn = getByRole('button', {
const currentUnitItemBtn = screen.getByRole('button', {
name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
expect(currentUnitItemBtn).toBeInTheDocument();
userEvent.click(currentUnitItemBtn);
});
const moveModalBtn = getByRole('button', {
const moveModalBtn = screen.getByRole('button', {
name: moveModalMessages.moveModalSubmitButton.defaultMessage,
});
expect(moveModalBtn).toBeInTheDocument();
@@ -1714,10 +1784,7 @@ describe('<CourseUnit />', () => {
});
it('should display "Move Confirmation" alert after moving and undo operations', async () => {
const {
queryByRole,
getByText,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onPatch(postXBlockBaseApiUrl())
@@ -1734,18 +1801,18 @@ describe('<CourseUnit />', () => {
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
const dismissButton = queryByRole('button', {
const dismissButton = screen.queryByRole('button', {
name: /dismiss/i, hidden: true,
});
const undoButton = queryByRole('button', {
const undoButton = screen.queryByRole('button', {
name: messages.undoMoveButton.defaultMessage, hidden: true,
});
const newLocationButton = queryByRole('button', {
const newLocationButton = screen.queryByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true,
});
expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(`${requestData.title} has been moved`)).toBeInTheDocument();
expect(screen.getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(`${requestData.title} has been moved`)).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument();
expect(undoButton).toBeInTheDocument();
expect(newLocationButton).toBeInTheDocument();
@@ -1753,9 +1820,9 @@ describe('<CourseUnit />', () => {
userEvent.click(undoButton);
await waitFor(() => {
expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument();
});
expect(getByText(
expect(screen.getByText(
messages.alertMoveCancelDescription.defaultMessage.replace('{title}', requestData.title),
)).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument();
@@ -1764,9 +1831,7 @@ describe('<CourseUnit />', () => {
});
it('should navigate to new location by button click', async () => {
const {
queryByRole,
} = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onPatch(postXBlockBaseApiUrl())
@@ -1781,7 +1846,7 @@ describe('<CourseUnit />', () => {
callbackFn: requestData.callbackFn,
}), store.dispatch);
const newLocationButton = queryByRole('button', {
const newLocationButton = screen.queryByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true,
});
userEvent.click(newLocationButton);
@@ -1794,16 +1859,14 @@ describe('<CourseUnit />', () => {
describe('XBlock restrict access', () => {
it('opens xblock restrict access modal successfully', async () => {
const {
getByTitle, getByTestId,
} = render(<RootWrapper />);
render(<RootWrapper />);
const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage;
const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage;
const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage;
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const usageId = courseVerticalChildrenMock.children[0].block_id;
expect(iframe).toBeInTheDocument();
@@ -1813,7 +1876,7 @@ describe('<CourseUnit />', () => {
});
await waitFor(() => {
const configureModal = getByTestId('configure-modal');
const configureModal = screen.getByTestId('configure-modal');
expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument();
expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument();
@@ -1822,12 +1885,10 @@ describe('<CourseUnit />', () => {
});
it('closes xblock restrict access modal when cancel button is clicked', async () => {
const {
getByTitle, queryByTestId, getByTestId,
} = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.manageXBlockAccess, {
usageId: courseVerticalChildrenMock.children[0].block_id,
@@ -1835,7 +1896,7 @@ describe('<CourseUnit />', () => {
});
await waitFor(() => {
const configureModal = getByTestId('configure-modal');
const configureModal = screen.getByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
userEvent.click(within(configureModal).getByRole('button', {
name: configureModalMessages.cancelButton.defaultMessage,
@@ -1843,7 +1904,7 @@ describe('<CourseUnit />', () => {
expect(handleConfigureSubmitMock).not.toHaveBeenCalled();
});
expect(queryByTestId('configure-modal')).not.toBeInTheDocument();
expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
});
it('handles submit xblock restrict access data when save button is clicked', async () => {
@@ -1854,15 +1915,13 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
const {
getByTitle, getByRole, getByTestId, queryByTestId,
} = render(<RootWrapper />);
render(<RootWrapper />);
const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name;
const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name;
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
});
@@ -1872,13 +1931,13 @@ describe('<CourseUnit />', () => {
});
});
const configureModal = await waitFor(() => getByTestId('configure-modal'));
const configureModal = await waitFor(() => screen.getByTestId('configure-modal'));
expect(configureModal).toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
const restrictAccessSelect = getByRole('combobox', {
const restrictAccessSelect = screen.getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
});
@@ -1908,17 +1967,17 @@ describe('<CourseUnit />', () => {
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id));
});
expect(queryByTestId('configure-modal')).not.toBeInTheDocument();
expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
});
});
const checkLegacyEditModalOnEditMessage = async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
render(<RootWrapper />);
await waitFor(() => {
const editButton = getByTestId('header-edit-button');
const editButton = screen.getByTestId('header-edit-button');
expect(editButton).toBeInTheDocument();
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
userEvent.click(editButton);
});
@@ -1953,7 +2012,6 @@ describe('<CourseUnit />', () => {
describe('Library Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
@@ -1970,20 +2028,6 @@ describe('<CourseUnit />', () => {
},
});
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',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
it('navigates to library content page on receive window event', async () => {
@@ -2003,8 +2047,8 @@ describe('<CourseUnit />', () => {
findByTestId,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const unitHeaderTitle = await findByTestId('unit-header-title');
await findByText(unitDisplayName);
@@ -2032,7 +2076,6 @@ describe('<CourseUnit />', () => {
describe('Split Test Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
@@ -2049,20 +2092,6 @@ describe('<CourseUnit />', () => {
},
});
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 () => {
@@ -2105,46 +2134,65 @@ describe('<CourseUnit />', () => {
});
it('should render split test content page correctly', async () => {
const {
getByText,
getByRole,
queryByRole,
getByTestId,
queryByText,
} = render(<RootWrapper />);
render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.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();
const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(screen.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(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(screen.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(screen.queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument();
expect(screen.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();
expect(screen.queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument();
expect(screen.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 },
{ query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage },
{ query: screen.queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: screen.queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage },
{ query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage },
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage
.replaceAll('{bold_tag}', ''),
},
{
query: screen.queryByRole,
type: 'heading',
name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage,
},
{
query: screen.queryByRole,
type: 'heading',
name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage,
},
{
query: screen.queryByRole,
type: 'link',
name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage,
},
];
sidebarContent.forEach(({ query, type, name }) => {
@@ -2152,7 +2200,7 @@ describe('<CourseUnit />', () => {
});
expect(
queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
screen.queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
).toHaveAttribute('href', helpLinkUrl);
});
});
@@ -2165,7 +2213,7 @@ describe('<CourseUnit />', () => {
});
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
const { getByTitle } = render(<RootWrapper />);
render(<RootWrapper />);
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
@@ -2174,6 +2222,13 @@ describe('<CourseUnit />', () => {
? { ...child, block_type: 'html' }
: child));
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' });
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, updatedCourseVerticalChildrenMock);
@@ -2181,7 +2236,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.currentXBlockId, {
id: targetBlockId,
@@ -2195,4 +2250,58 @@ describe('<CourseUnit />', () => {
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
});
});
it('renders units from libraries with some components read-only', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
upstreamInfo: {
...courseSectionVerticalMock.xblock_info,
upstreamRef: 'lct:org:lib:unit:unit-1',
upstreamLink: 'some-link',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
// Disable the "Edit" button
const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editButton = within(unitHeaderTitle).getByRole(
'button',
{ name: 'Edit' },
);
expect(editButton).toBeInTheDocument();
expect(editButton).toBeDisabled();
// The "Publish" button should still be enabled
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
const publishButton = within(courseUnitSidebar).getByRole(
'button',
{ name: sidebarMessages.actionButtonPublishTitle.defaultMessage },
);
expect(publishButton).toBeInTheDocument();
expect(publishButton).toBeEnabled();
// Disable the "Manage Tags" button
const manageTagsButton = screen.getByRole(
'button',
{ name: tagsDrawerMessages.manageTagsButton.defaultMessage },
);
expect(manageTagsButton).toBeInTheDocument();
expect(manageTagsButton).toBeDisabled();
// Does not render the "Add Components" section
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
});
});

View File

@@ -1,1123 +0,0 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
display_name: 'Getting Started',
category: 'vertical',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
data: '',
metadata: {
display_name: 'Getting Started',
xml_attributes: {
filename: [
'vertical/867dddb6f55d410caaa9c1eb9c6743ec.xml',
'vertical/867dddb6f55d410caaa9c1eb9c6743ec.xml',
],
},
},
ancestor_info: {
ancestors: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
display_name: 'Lesson 1 - Getting Started',
category: 'sequential',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%4019a30717eff543078a5d94ae9d6c18a5',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
hide_after_due: false,
is_proctored_exam: false,
was_exam_ever_linked_with_external: false,
online_proctoring_rules: '',
is_practice_exam: false,
is_onboarding_exam: false,
is_time_limited: false,
exam_review_rules: '',
default_time_limit_minutes: null,
proctoring_exam_configuration_link: null,
supports_onboarding: false,
show_review_rules: true,
child_info: {
category: 'vertical',
display_name: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
display_name: 'Getting Started',
category: 'vertical',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
display_name: 'Working with Videos',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
display_name: 'Videos on edX',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
display_name: 'Video Demonstrations',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
display_name: 'Video Presentation Styles',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
display_name: 'Interactive Questions',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606',
display_name: 'Exciting Labs and Tools',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
display_name: 'Reading Assignments',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193',
display_name: 'When Are Your Exams? ',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
],
},
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions',
display_name: 'Example Week 1: Getting Started',
category: 'chapter',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40interactive_demonstrations',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
display_name: 'Demonstration Course',
category: 'course',
has_children: true,
unit_level_discussions: false,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Jan 03, 2024 at 08:57 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: null,
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
highlights_enabled_for_messaging: false,
highlights_enabled: true,
highlights_preview_only: false,
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
enable_proctored_exams: false,
create_zendesk_tickets: true,
enable_timed_exams: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
],
},
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
edited_by: 'edx',
published_by: null,
currently_visible_to_students: true,
has_partition_group_components: false,
release_date_from: 'Section "Example Week 1: Getting Started"',
staff_lock_from: null,
};

View File

@@ -1,4 +1,3 @@
export { default as courseUnitIndexMock } from './courseUnitIndex';
export { default as courseSectionVerticalMock } from './courseSectionVertical';
export { default as courseUnitMock } from './courseUnit';
export { default as courseCreateXblockMock } from './courseCreateXblock';

View File

@@ -1,13 +1,14 @@
import { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, StandardModal, useToggle,
} from '@openedx/paragon';
import { getCourseSectionVertical } from '../data/selectors';
import { getWaffleFlags } from '../../data/selectors';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import ComponentModalView from './add-component-modals/ComponentModalView';
import AddComponentButton from './add-component-btn';
@@ -16,6 +17,8 @@ import { ComponentPicker } from '../../library-authoring/component-picker';
import { messageTypes } from '../constants';
import { useIframe } from '../../generic/hooks/context/hooks';
import { useEventListener } from '../../generic/hooks';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
const AddComponent = ({
parentLocator,
@@ -24,7 +27,6 @@ const AddComponent = ({
addComponentTemplateData,
handleCreateNewCourseXBlock,
}) => {
const navigate = useNavigate();
const intl = useIntl();
const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false);
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
@@ -32,10 +34,17 @@ const AddComponent = ({
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
const blockId = addComponentTemplateData.parentLocator || parentLocator;
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState(null);
const [courseId, setCourseId] = useState(null);
const [newBlockId, setNewBlockId] = useState(null);
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags);
const receiveMessage = useCallback(({ data: { type, payload } }) => {
if (type === messageTypes.showMultipleComponentPicker) {
@@ -54,6 +63,12 @@ const AddComponent = ({
closeSelectLibraryContentModal();
}, [selectedComponents]);
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
closeXBlockEditorModal();
closeVideoSelectorModal();
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]);
const handleLibraryV2Selection = useCallback((selection) => {
handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2,
@@ -71,12 +86,28 @@ const AddComponent = ({
handleCreateNewCourseXBlock({ type, parentLocator: blockId });
break;
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
localStorage.setItem('createXBlockLastYPosition', window.scrollY);
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
setCourseId(courseKey);
setBlockType(type);
setNewBlockId(locator);
showXBlockEditorModal();
});
break;
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock(
{ type, parentLocator: blockId },
/* istanbul ignore next */ ({ courseKey, locator }) => {
setCourseId(courseKey);
setBlockType(type);
setNewBlockId(locator);
if (useVideoGalleryFlow) {
showVideoSelectorModal();
} else {
showXBlockEditorModal();
}
},
);
break;
// TODO: The library functional will be a bit different of current legacy (CMS)
// behaviour and this ticket is on hold (blocked by other development team).
case COMPONENT_TYPES.library:
@@ -99,9 +130,11 @@ const AddComponent = ({
type,
boilerplate: moduleName,
parentLocator: blockId,
}, ({ courseKey, locator }) => {
localStorage.setItem('createXBlockLastYPosition', window.scrollY);
navigate(`/course/${courseKey}/editor/html/${locator}`);
}, /* istanbul ignore next */ ({ courseKey, locator }) => {
setCourseId(courseKey);
setBlockType(type);
setNewBlockId(locator);
showXBlockEditorModal();
});
break;
default:
@@ -201,6 +234,38 @@ const AddComponent = ({
onChangeComponentSelection={setSelectedComponents}
/>
</StandardModal>
<StandardModal
title={intl.formatMessage(messages.videoPickerModalTitle)}
isOpen={isVideoSelectorModalOpen}
onClose={closeVideoSelectorModal}
isOverflowVisible={false}
size="xl"
>
<div className="selector-page">
<VideoSelectorPage
blockId={newBlockId}
courseId={courseId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onCancel={closeVideoSelectorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && (
<div className="editor-page">
<EditorPage
courseId={courseId}
blockType={blockType}
blockId={newBlockId}
isMarkdownEditorEnabledForCourse={useReactMarkdownEditor}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={closeXBlockEditorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
)}
</div>
);
}
@@ -208,10 +273,6 @@ const AddComponent = ({
return null;
};
AddComponent.defaultProps = {
addComponentTemplateData: {},
};
AddComponent.propTypes = {
isSplitTestType: PropTypes.bool.isRequired,
isUnitVerticalType: PropTypes.bool.isRequired,

View File

@@ -31,6 +31,11 @@ const messages = defineMessages({
defaultMessage: 'Add selected components',
description: 'Problem bank component add button text.',
},
videoPickerModalTitle: {
id: 'course-authoring.course-unit.modal.video-title.text',
defaultMessage: 'Select video',
description: 'Video picker modal title.',
},
modalContainerTitle: {
id: 'course-authoring.course-unit.modal.container.title',
defaultMessage: 'Add {componentTitle} component',

View File

@@ -5,11 +5,11 @@ import {
} from '../../testUtils';
import { executeThunk } from '../../utils';
import { getCourseSectionVerticalApiUrl, getCourseUnitApiUrl } from '../data/api';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { fetchCourseSectionVerticalData, fetchCourseUnitQuery } from '../data/thunk';
import { courseSectionVerticalMock, courseUnitIndexMock } from '../__mocks__';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { courseSectionVerticalMock } from '../__mocks__';
import Breadcrumbs from './Breadcrumbs';
let axiosMock;
@@ -43,9 +43,9 @@ describe('<Breadcrumbs />', () => {
reduxStore = mocks.reduxStore;
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(courseId), reduxStore.dispatch);
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(courseId), reduxStore.dispatch);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);

View File

@@ -12,16 +12,19 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
const isLastUnit = !nextUrl;
const sequenceIds = useSelector(getSequenceIds);
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
let unitIndex = sequence?.unitIds.indexOf(currentUnitId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
if (!unitIndex) {
// Handle case where unitIndex is not found
unitIndex = 0;
}
let nextLink;
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
if (nextIndex < sequence?.unitIds.length) {
const nextUnitId = sequence?.unitIds[nextIndex];
nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`;
} else if (nextSequenceId) {
const pathToNextUnit = decodeURIComponent(nextUrl);
@@ -32,7 +35,7 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
const previousUnitId = sequence?.unitIds[previousIndex];
previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`;
} else if (previousSequenceId) {
const pathToPreviousUnit = decodeURIComponent(prevUrl);

View File

@@ -35,7 +35,7 @@ const SequenceNavigation = ({
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
const renderUnitButtons = () => {
if (sequence.unitIds?.length === 0 || unitId === null) {
if (sequence.unitIds.length === 0 || unitId === null) {
return (
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
);
@@ -43,7 +43,7 @@ const SequenceNavigation = ({
return (
<SequenceNavigationTabs
unitIds={sequence.unitIds || []}
unitIds={sequence?.unitIds || []}
unitId={unitId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}

View File

@@ -3,11 +3,10 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import { normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`;
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
@@ -15,18 +14,6 @@ export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/cour
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
/**
* Get course unit.
* @param {string} unitId
* @returns {Promise<Object>}
*/
export async function getCourseUnitData(unitId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseUnitApiUrl(unitId));
return camelCaseObject(data);
}
/**
* Edit course unit display name.
* @param {string} unitId
@@ -45,15 +32,18 @@ export async function editUnitDisplayName(unitId, displayName) {
}
/**
* Get an object containing course section vertical data.
* Fetch vertical block data from the container_handler endpoint.
* @param {string} unitId
* @returns {Promise<Object>}
*/
export async function getCourseSectionVerticalData(unitId) {
export async function getVerticalData(unitId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionVerticalApiUrl(unitId));
return normalizeCourseSectionVerticalData(data);
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitReadOnly(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}
/**

View File

@@ -1,7 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { RequestStatus } from 'CourseAuthoring/data/constants';
export const getCourseUnitData = (state) => state.courseUnit.unit;
export const getCourseUnitData = (state) => state.courseUnit.courseSectionVertical.xblockInfo ?? {};
export const getCanEdit = (state) => state.courseUnit.canEdit;
export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices;
export const getCourseUnit = (state) => state.courseUnit;
@@ -16,7 +16,7 @@ export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerti
export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo;
export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus;
export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams;
const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
export const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
export const getIsLoading = createSelector(
[getLoadingStatuses],
loadingStatus => Object.values(loadingStatus)

View File

@@ -12,11 +12,9 @@ const slice = createSlice({
isTitleEditFormOpen: false,
canEdit: true,
loadingStatus: {
fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS,
courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS,
courseVerticalChildrenLoadingStatus: RequestStatus.IN_PROGRESS,
},
unit: {},
courseSectionVertical: {},
courseVerticalChildren: { children: [], isPublished: true },
staticFileNotices: {},
@@ -31,15 +29,6 @@ const slice = createSlice({
},
},
reducers: {
fetchCourseItemSuccess: (state, { payload }) => {
state.unit = payload;
},
updateLoadingCourseUnitStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchUnitLoadingStatus: payload.status,
};
},
updateQueryPendingStatus: (state, { payload }) => {
state.isQueryPending = payload;
},
@@ -81,12 +70,6 @@ const slice = createSlice({
createUnitXblockLoadingStatus: payload.status,
};
},
addNewUnitStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchUnitLoadingStatus: payload.status,
};
},
updateCourseVerticalChildren: (state, { payload }) => {
state.courseVerticalChildren = payload;
},
@@ -109,8 +92,6 @@ const slice = createSlice({
});
export const {
fetchCourseItemSuccess,
updateLoadingCourseUnitStatus,
updateSavingStatus,
updateModel,
fetchSequenceRequest,

View File

@@ -10,9 +10,8 @@ import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
import { messageTypes } from '../constants';
import {
getCourseUnitData,
editUnitDisplayName,
getCourseSectionVerticalData,
getVerticalData,
createCourseXblock,
getCourseVerticalChildren,
handleCourseUnitVisibilityAndData,
@@ -22,8 +21,6 @@ import {
patchUnitItem,
} from './api';
import {
updateLoadingCourseUnitStatus,
fetchCourseItemSuccess,
updateSavingStatus,
fetchSequenceRequest,
fetchSequenceFailure,
@@ -40,29 +37,13 @@ import {
} from './slice';
import { getNotificationMessage } from './utils';
export function fetchCourseUnitQuery(courseId) {
return async (dispatch) => {
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const courseUnit = await getCourseUnitData(courseId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchCourseSectionVerticalData(courseId, sequenceId) {
return async (dispatch) => {
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(fetchSequenceRequest({ sequenceId }));
try {
const courseSectionVerticalData = await getCourseSectionVerticalData(courseId);
const courseSectionVerticalData = await getVerticalData(courseId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateModel({
@@ -93,8 +74,7 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) {
try {
await editUnitDisplayName(itemId, displayName).then(async (result) => {
if (result) {
const courseUnit = await getCourseUnitData(itemId);
const courseSectionVerticalData = await getCourseSectionVerticalData(itemId);
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateModel({
@@ -106,7 +86,6 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) {
models: courseSectionVerticalData.units || [],
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
@@ -145,8 +124,8 @@ export function editCourseUnitVisibilityAndData(
if (callback) {
callback();
}
const courseUnit = await getCourseUnitData(blockId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(blockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
@@ -173,7 +152,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
if (result) {
const formattedResult = camelCaseObject(result);
if (body.category === 'vertical') {
const courseSectionVerticalData = await getCourseSectionVerticalData(formattedResult.locator);
const courseSectionVerticalData = await getVerticalData(formattedResult.locator);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
}
if (body.stagedContent) {
@@ -193,8 +172,8 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
sendMessageToIframe(messageTypes.addXBlock, { data: result });
}
const currentBlockId = body.category === 'vertical' ? formattedResult.locator : blockId;
const courseUnit = await getCourseUnitData(currentBlockId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(currentBlockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
}
});
} catch (error) {
@@ -239,8 +218,8 @@ export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) {
try {
await deleteUnitItem(xblockId);
sendMessageToIframe(messageTypes.completeXBlockDeleting, { locator: xblockId });
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -258,8 +237,10 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
try {
const { courseKey, locator } = await duplicateUnitItem(itemId, xblockId);
callback(courseKey, locator);
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -313,8 +294,8 @@ export function patchUnitItemQuery({
dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
callbackFn(sourceLocator);
try {
const courseUnit = await getCourseUnitData(currentParentLocator);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(currentParentLocator);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
} catch (error) {
handleResponseErrors(error, dispatch, updateSavingStatus);
}
@@ -332,8 +313,8 @@ export function updateCourseUnitSidebar(itemId) {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {

View File

@@ -84,3 +84,15 @@ export const updateXBlockBlockIdToId = (data) => {
return updatedData;
};
/**
* Returns whether the given Unit should be read-only.
*
* Units sourced from libraries are read-only (temporary, for Teak).
*
* @param {object} unit - uses the 'upstreamInfo' object if found.
* @returns {boolean} True if readOnly, False if editable.
*/
export const isUnitReadOnly = ({ upstreamInfo }) => (
upstreamInfo && upstreamInfo.upstreamRef && upstreamInfo.upstreamRef.startsWith('lct:')
);

View File

@@ -34,6 +34,8 @@ const HeaderTitle = ({
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const readOnly = !!currentItemData.readOnly;
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
};
@@ -80,6 +82,7 @@ const HeaderTitle = ({
className="ml-1 flex-shrink-0"
iconAs={EditIcon}
onClick={handleTitleEdit}
disabled={readOnly}
/>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}
@@ -102,6 +105,8 @@ const HeaderTitle = ({
);
};
export default HeaderTitle;
HeaderTitle.propTypes = {
unitTitle: PropTypes.string.isRequired,
isTitleEditFormOpen: PropTypes.bool.isRequired,
@@ -109,5 +114,3 @@ HeaderTitle.propTypes = {
handleTitleEditSubmit: PropTypes.func.isRequired,
handleConfigureSubmit: PropTypes.func.isRequired,
};
export default HeaderTitle;

View File

@@ -8,9 +8,9 @@ import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { getCourseUnitApiUrl } from '../data/api';
import { fetchCourseUnitQuery } from '../data/thunk';
import { courseUnitIndexMock } from '../__mocks__';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { courseSectionVerticalMock } from '../__mocks__';
import HeaderTitle from './HeaderTitle';
import messages from './messages';
@@ -52,9 +52,9 @@ describe('<HeaderTitle />', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('render HeaderTitle component correctly', () => {
@@ -72,8 +72,31 @@ describe('<HeaderTitle />', () => {
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument();
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle);
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a disabled edit button', async () => {
// Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
upstreamInfo: {
...courseSectionVerticalMock.xblock_info.upstreamInfo,
upstreamRef: 'lct:org:lib:unit:unit-1',
},
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByRole } = renderComponent();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('calls toggle edit title form by clicking on Edit button', () => {
@@ -103,16 +126,19 @@ describe('<HeaderTitle />', () => {
it('displays a visibility message with the selected groups for the unit', async () => {
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
user_partition_info: {
...courseUnitIndexMock.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
},
},
});
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByText } = renderComponent();
const visibilityMessage = messages.definedVisibilityMessage.defaultMessage
.replace('{selectedGroupsLabel}', 'Visibility group 1');
@@ -124,12 +150,15 @@ describe('<HeaderTitle />', () => {
it('displays a visibility message with the selected groups for some of xblock', async () => {
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_partition_group_components: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_partition_group_components: true,
},
});
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByText } = renderComponent();
await waitFor(() => {

View File

@@ -18,10 +18,10 @@ import {
editCourseItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
updateCourseUnitSidebar,
} from './data/thunk';
import {
getCanEdit,
@@ -35,6 +35,7 @@ import {
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
getLoadingStatuses,
} from './data/selectors';
import {
changeEditTitleFormOpen,
@@ -51,6 +52,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);
const courseUnitLoadingStatus = useSelector(getLoadingStatuses);
const savingStatus = useSelector(getSavingStatus);
const isLoading = useSelector(getIsLoading);
const errorMessage = useSelector(getErrorMessage);
@@ -196,7 +198,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
}, [savingStatus]);
useEffect(() => {
dispatch(fetchCourseUnitQuery(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
handleNavigate(sequenceId);
@@ -215,9 +216,27 @@ export const useCourseUnit = ({ courseId, blockId }) => {
}
}, [isMoveModalOpen]);
useEffect(() => {
const handlePageRefreshUsingStorage = (event) => {
// ignoring tests for if block, because it triggers when someone
// edits the component using editor which has a separate store
/* istanbul ignore next */
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
dispatch(updateCourseUnitSidebar(blockId));
localStorage.removeItem(event.key);
}
};
window.addEventListener('storage', handlePageRefreshUsingStorage);
return () => {
window.removeEventListener('storage', handlePageRefreshUsingStorage);
};
}, [blockId, sequenceId, isSplitTestType]);
return {
sequenceId,
courseUnit,
courseUnitLoadingStatus,
unitTitle,
unitCategory,
errorMessage,

View File

@@ -43,6 +43,16 @@ const messages = defineMessages({
defaultMessage: 'Take me to the new location',
description: 'Text for the button allowing users to navigate to the new location after an XBlock has been moved',
},
alertLibraryUnitReadOnlyText: {
id: 'course-authoring.course-unit.alert.read-only.text',
defaultMessage: 'This unit can only be edited from the {link}.',
description: 'Text of the alert when the unit is read only because is a library unit',
},
alertLibraryUnitReadOnlyLinkText: {
id: 'course-authoring.course-unit.alert.read-only.link.text',
defaultMessage: 'library',
description: 'Text of the link in the alert when the unit is read only because is a library unit',
},
});
export default messages;

View File

@@ -12,13 +12,12 @@ import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'
import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
import { getLibraryBlockMetadataUrl } from '../../library-authoring/data/api';
const usageKey = 'some-id';
const defaultEventData: LibraryChangesMessageData = {
displayName: 'Test block',
downstreamBlockId: usageKey,
upstreamBlockId: 'some-lib-id',
upstreamBlockId: 'lct:org:lib1:unit:1',
upstreamBlockVersionSynced: 1,
isVertical: false,
};
@@ -66,7 +65,7 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
});
it('renders displayName for units', async () => {
it('renders default displayName for units with no displayName', async () => {
render({ ...defaultEventData, isVertical: true, displayName: '' });
expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument();
@@ -78,15 +77,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument();
});
it('renders both new and old title if they are different', async () => {
axiosMock.onGet(getLibraryBlockMetadataUrl(defaultEventData.upstreamBlockId)).reply(200, {
displayName: 'New test block',
});
render();
expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument();
});
it('accept changes works', async () => {
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render();
@@ -95,7 +85,10 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' });
userEvent.click(acceptBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
@@ -110,7 +103,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' });
userEvent.click(acceptBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).not.toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
@@ -128,7 +120,10 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
const ignoreConfirmBtn = await screen.findByRole('button', { name: 'Ignore' });
userEvent.click(ignoreConfirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.delete.length).toEqual(1);
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});

View File

@@ -1,20 +1,21 @@
import React, { useCallback, useContext, useState } from 'react';
import { useCallback, useContext, useState } from 'react';
import {
ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon';
import { Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useEventListener } from '../../generic/hooks';
import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import AlertMessage from '../../generic/alert-message';
import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading';
import { useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks';
export interface LibraryChangesMessageData {
displayName: string,
@@ -25,11 +26,10 @@ export interface LibraryChangesMessageData {
}
export interface PreviewLibraryXBlockChangesProps {
blockData?: LibraryChangesMessageData,
blockData: LibraryChangesMessageData,
isModalOpen: boolean,
closeModal: () => void,
postChange: (accept: boolean) => void,
alertNode?: React.ReactNode,
}
/**
@@ -41,34 +41,16 @@ export const PreviewLibraryXBlockChanges = ({
isModalOpen,
closeModal,
postChange,
alertNode,
}: PreviewLibraryXBlockChangesProps) => {
const { showToast } = useContext(ToastContext);
const intl = useIntl();
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const getTitle = useCallback(() => {
const oldName = blockData?.displayName;
const newName = componentMetadata?.displayName;
if (!oldName) {
if (blockData?.isVertical) {
return intl.formatMessage(messages.defaultUnitTitle);
}
return intl.formatMessage(messages.defaultComponentTitle);
}
if (oldName === newName || !newName) {
return intl.formatMessage(messages.title, { blockTitle: oldName });
}
return intl.formatMessage(messages.diffTitle, { oldName, newName });
}, [blockData, componentMetadata]);
const getBody = useCallback(() => {
if (!blockData) {
return <Loading />;
@@ -78,6 +60,7 @@ export const PreviewLibraryXBlockChanges = ({
usageKey={blockData.upstreamBlockId}
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
newVersion="published"
isContainer={blockData.isVertical}
/>
);
}, [blockData]);
@@ -101,12 +84,21 @@ export const PreviewLibraryXBlockChanges = ({
}
}, [blockData]);
const defaultTitle = intl.formatMessage(
blockData.isVertical
? messages.defaultUnitTitle
: messages.defaultComponentTitle,
);
const title = blockData.displayName
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
: defaultTitle;
return (
<ModalDialog
isOpen={isModalOpen}
onClose={closeModal}
size="xl"
title={getTitle()}
title={title}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
@@ -114,11 +106,16 @@ export const PreviewLibraryXBlockChanges = ({
>
<ModalDialog.Header>
<ModalDialog.Title>
{getTitle()}
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{alertNode}
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
@@ -179,12 +176,18 @@ const IframePreviewLibraryXBlockChanges = () => {
useEventListener('message', receiveMessage);
if (!blockData) {
return null;
}
const blockPayload = { locator: blockData.downstreamBlockId };
return (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={() => sendMessageToIframe(messageTypes.refreshXBlock, null)}
postChange={() => sendMessageToIframe(messageTypes.completeXBlockEditing, blockPayload)}
/>
);
};

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text',
},
diffTitle: {
id: 'authoring.course-unit.preview-changes.modal-diff-title',
defaultMessage: 'Preview changes: {oldName} -> {newName}',
description: 'Preview changes modal title text',
},
defaultUnitTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: Unit',
@@ -61,6 +56,11 @@ const messages = defineMessages({
defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
},
olderVersionPreviewAlert: {
id: 'course-authoring.review-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

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
@@ -12,14 +11,19 @@ import { getCourseUnitData } from '../data/selectors';
import messages from './messages';
import ModalNotification from '../../generic/modal-notification';
const PublishControls = ({ blockId }) => {
interface PublishControlsProps {
blockId?: string,
}
const PublishControls = ({ blockId }: PublishControlsProps) => {
const unitData = useSelector(getCourseUnitData);
const {
title,
locationId,
releaseLabel,
visibilityState,
visibleToStaffOnly,
} = useCourseUnitData(useSelector(getCourseUnitData));
} = useCourseUnitData(unitData);
const intl = useIntl();
const { sendMessageToIframe } = useIframe();
@@ -90,12 +94,4 @@ const PublishControls = ({ blockId }) => {
);
};
PublishControls.propTypes = {
blockId: PropTypes.string,
};
PublishControls.defaultProps = {
blockId: null,
};
export default PublishControls;

View File

@@ -10,10 +10,10 @@ import userEvent from '@testing-library/user-event';
import initializeStore from '../../../../store';
import { executeThunk } from '../../../../utils';
import { clipboardUnit } from '../../../../__mocks__';
import { getCourseUnitApiUrl } from '../../../data/api';
import { getCourseSectionVerticalApiUrl } from '../../../data/api';
import { getClipboardUrl } from '../../../../generic/data/api';
import { fetchCourseUnitQuery } from '../../../data/thunk';
import { courseUnitIndexMock } from '../../../__mocks__';
import { fetchCourseSectionVerticalData } from '../../../data/thunk';
import { courseSectionVerticalMock } from '../../../__mocks__';
import messages from '../../messages';
import ActionButtons from './ActionButtons';
@@ -46,8 +46,14 @@ describe('<ActionButtons />', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, { ...courseUnitIndexMock, enable_copy_paste_units: true });
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
@@ -57,7 +63,7 @@ describe('<ActionButtons />', () => {
queryClient = new QueryClient();
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
});
it('render ActionButtons component with Copy to clipboard', () => {
@@ -74,7 +80,9 @@ describe('<ActionButtons />', () => {
userEvent.click(copyXBlockBtn);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
expect(axiosMock.history.post[0].data).toBe(
JSON.stringify({ usage_key: courseSectionVerticalMock.xblock_info.id }),
);
jest.resetAllMocks();
});
});

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -8,7 +7,15 @@ import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
import { useClipboard } from '../../../../generic/clipboard';
import messages from '../../messages';
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
interface ActionButtonsProps {
openDiscardModal: () => void,
handlePublishing: () => void,
}
const ActionButtons = ({
openDiscardModal,
handlePublishing,
}: ActionButtonsProps) => {
const intl = useIntl();
const {
id,
@@ -22,7 +29,12 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
return (
<>
{(!published || hasChanges) && (
<Button size="sm" className="mt-3.5" variant="outline-primary" onClick={handlePublishing}>
<Button
size="sm"
className="mt-3.5"
variant="outline-primary"
onClick={handlePublishing}
>
{intl.formatMessage(messages.actionButtonPublishTitle)}
</Button>
)}
@@ -52,9 +64,4 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
);
};
ActionButtons.propTypes = {
openDiscardModal: PropTypes.func.isRequired,
handlePublishing: PropTypes.func.isRequired,
};
export default ActionButtons;

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -10,7 +9,15 @@ import { PUBLISH_TYPES } from '../../../constants';
import { getVisibilityTitle } from '../../utils';
import messages from '../../messages';
const UnitVisibilityInfo = ({ openVisibleModal, visibleToStaffOnly }) => {
interface UnitVisibilityInfoProps {
openVisibleModal: () => void,
visibleToStaffOnly: boolean,
}
const UnitVisibilityInfo = ({
openVisibleModal,
visibleToStaffOnly,
}: UnitVisibilityInfoProps) => {
const intl = useIntl();
const { blockId } = useParams();
const dispatch = useDispatch();
@@ -59,9 +66,4 @@ const UnitVisibilityInfo = ({ openVisibleModal, visibleToStaffOnly }) => {
);
};
UnitVisibilityInfo.propTypes = {
openVisibleModal: PropTypes.func.isRequired,
visibleToStaffOnly: PropTypes.bool.isRequired,
};
export default UnitVisibilityInfo;

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { Card, Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -6,14 +5,23 @@ import messages from '../../messages';
import UnitVisibilityInfo from './UnitVisibilityInfo';
import ActionButtons from './ActionButtons';
interface SidebarFooterProps {
locationId?: string,
displayUnitLocation?: boolean,
openDiscardModal: () => void,
openVisibleModal: () => void,
handlePublishing: () => void,
visibleToStaffOnly: boolean,
}
const SidebarFooter = ({
locationId,
openVisibleModal,
handlePublishing,
openDiscardModal,
visibleToStaffOnly,
displayUnitLocation,
}) => {
displayUnitLocation = false,
}: SidebarFooterProps) => {
const intl = useIntl();
return (
@@ -40,18 +48,4 @@ const SidebarFooter = ({
);
};
SidebarFooter.propTypes = {
locationId: PropTypes.string,
displayUnitLocation: PropTypes.bool,
openDiscardModal: PropTypes.func,
openVisibleModal: PropTypes.func,
handlePublishing: PropTypes.func,
visibleToStaffOnly: PropTypes.bool.isRequired,
};
SidebarFooter.defaultProps = {
displayUnitLocation: false,
locationId: null,
};
export default SidebarFooter;

View File

@@ -99,4 +99,4 @@ export const getIconVariant = (visibilityState, published, hasChanges) => {
* @param {string} id - The course unit ID.
* @returns {string} The clear course unit ID extracted from the provided data.
*/
export const extractCourseUnitId = (id) => id.match(/block@(.+)$/)[1];
export const extractCourseUnitId = (id) => id?.match(/block@(.+)$/)[1];

View File

@@ -1,11 +1,11 @@
export type UseMessageHandlersTypes = {
courseId: string;
navigate: (path: string) => void;
dispatch: (action: any) => void;
setIframeOffset: (height: number) => void;
handleDeleteXBlock: (usageId: string) => void;
handleScrollToXBlock: (scrollOffset: number) => void;
handleDuplicateXBlock: (blockType: string, usageId: string) => void;
handleDuplicateXBlock: (usageId: string) => void;
handleEditXBlock: (blockType: string, usageId: string) => void;
handleManageXBlockAccess: (usageId: string) => void;
handleShowLegacyEditXBlockModal: (id: string) => void;
handleCloseLegacyEditorXBlockModal: () => void;
@@ -14,7 +14,6 @@ export type UseMessageHandlersTypes = {
handleOpenManageTagsModal: (id: string) => void;
handleShowProcessingNotification: (variant: string) => void;
handleHideProcessingNotification: () => void;
handleRedirectToXBlockEditPage: (payload: { type: string, locator: string }) => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -16,7 +16,6 @@ import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
*/
export const useMessageHandlers = ({
courseId,
navigate,
dispatch,
setIframeOffset,
handleDeleteXBlock,
@@ -30,15 +29,15 @@ export const useMessageHandlers = ({
handleOpenManageTagsModal,
handleShowProcessingNotification,
handleHideProcessingNotification,
handleRedirectToXBlockEditPage,
handleEditXBlock,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
return useMemo(() => ({
[messageTypes.copyXBlock]: ({ usageId }) => copyToClipboard(usageId),
[messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId),
[messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`),
[messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId),
[messageTypes.newXBlockEditor]: ({ blockType, usageId }) => handleEditXBlock(blockType, usageId),
[messageTypes.duplicateXBlock]: ({ usageId }) => handleDuplicateXBlock(usageId),
[messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId),
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
[messageTypes.toggleCourseXBlockDropdown]: ({
@@ -52,9 +51,14 @@ export const useMessageHandlers = ({
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),
[messageTypes.copyXBlockLegacy]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.copying),
[messageTypes.copyXBlockLegacy]: /* istanbul ignore next */ () => handleShowProcessingNotification(
NOTIFICATION_MESSAGES.copying,
),
[messageTypes.hideProcessingNotification]: handleHideProcessingNotification,
[messageTypes.handleRedirectToXBlockEditPage]: (payload) => handleRedirectToXBlockEditPage(payload),
[messageTypes.handleRedirectToXBlockEditPage]: /* istanbul ignore next */ (payload) => handleEditXBlock(
payload.type,
payload.locator,
),
}), [
courseId,
handleDeleteXBlock,

View File

@@ -1,10 +1,10 @@
import { getConfig } from '@edx/frontend-platform';
import {
FC, useEffect, useState, useMemo, useCallback,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle, Sheet } from '@openedx/paragon';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useToggle, Sheet, StandardModal } from '@openedx/paragon';
import { useDispatch, useSelector } from 'react-redux';
import {
hideProcessingNotification,
@@ -13,9 +13,9 @@ import {
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import ModalIframe from '../../generic/modal-iframe';
import { getWaffleFlags } from '../../data/selectors';
import { IFRAME_FEATURE_POLICY } from '../../constants';
import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer';
import supportedEditors from '../../editors/supportedEditors';
import { useIframe } from '../../generic/hooks/context/hooks';
import {
fetchCourseSectionVerticalData,
@@ -35,16 +35,29 @@ import messages from './messages';
import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior';
import { useIframeContent } from '../../generic/hooks/useIframeContent';
import { useIframeMessages } from '../../generic/hooks/useIframeMessages';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
import { RequestStatus } from '../../data/constants';
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
courseId,
blockId,
unitXBlockActions,
courseVerticalChildren,
handleConfigureSubmit,
isUnitVerticalType,
courseUnitLoadingStatus,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const navigate = useNavigate();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState<string>('');
const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags);
const [newBlockId, setNewBlockId] = useState<string>('');
const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({});
const [iframeOffset, setIframeOffset] = useState(0);
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
@@ -64,14 +77,44 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
setIframeRef(iframeRef);
}, [setIframeRef]);
const handleDuplicateXBlock = useCallback(
(blockType: string, usageId: string) => {
unitXBlockActions.handleDuplicate(usageId);
if (supportedEditors[blockType]) {
navigate(`/course/${courseId}/editor/${blockType}/${usageId}`);
useEffect(() => {
const iframe = iframeRef?.current;
if (!iframe) { return undefined; }
const handleIframeLoad = () => {
if (courseUnitLoadingStatus.fetchUnitLoadingStatus === RequestStatus.FAILED) {
window.location.reload();
}
};
iframe.addEventListener('load', handleIframeLoad);
return () => {
iframe.removeEventListener('load', handleIframeLoad);
};
}, [iframeRef]);
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
closeXBlockEditorModal();
closeVideoSelectorModal();
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]);
const handleEditXBlock = useCallback((type: string, id: string) => {
setBlockType(type);
setNewBlockId(id);
if (type === 'video' && useVideoGalleryFlow) {
showVideoSelectorModal();
} else {
showXBlockEditorModal();
}
}, [showVideoSelectorModal, showXBlockEditorModal]);
const handleDuplicateXBlock = useCallback(
(usageId: string) => {
unitXBlockActions.handleDuplicate(usageId);
},
[unitXBlockActions, courseId, navigate],
[unitXBlockActions, courseId],
);
const handleDeleteXBlock = (usageId: string) => {
@@ -147,13 +190,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
dispatch(hideProcessingNotification());
};
const handleRedirectToXBlockEditPage = (payload: { type: string, locator: string }) => {
navigate(`/course/${courseId}/editor/${payload.type}/${payload.locator}`);
};
const messageHandlers = useMessageHandlers({
courseId,
navigate,
dispatch,
setIframeOffset,
handleDeleteXBlock,
@@ -167,7 +205,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleOpenManageTagsModal,
handleShowProcessingNotification,
handleHideProcessingNotification,
handleRedirectToXBlockEditPage,
handleEditXBlock,
});
useIframeMessages(messageHandlers);
@@ -186,6 +224,38 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
close={closeDeleteModal}
onDeleteSubmit={onDeleteSubmit}
/>
<StandardModal
title={intl.formatMessage(messages.videoPickerModalTitle)}
isOpen={isVideoSelectorModalOpen}
onClose={closeVideoSelectorModal}
isOverflowVisible={false}
size="xl"
>
<div className="selector-page">
<VideoSelectorPage
blockId={newBlockId}
courseId={courseId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onCancel={closeVideoSelectorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && (
<div className="editor-page">
<EditorPage
courseId={courseId}
blockType={blockType}
blockId={newBlockId}
isMarkdownEditorEnabledForCourse={useReactMarkdownEditor}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={closeXBlockEditorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
)}
{Object.keys(accessManagedXBlockData).length ? (
<ConfigureModal
isXBlockComponent

View File

@@ -15,6 +15,10 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.xblock.iframe.label',
defaultMessage: '{xblockCount} xBlocks inside the frame',
},
videoPickerModalTitle: {
id: 'course-authoring.course-unit.xblock.video-editor.title',
defaultMessage: 'Select video',
},
});
export default messages;

View File

@@ -42,6 +42,11 @@ export interface XBlockContainerIframeProps {
courseId: string;
blockId: string;
isUnitVerticalType: boolean,
courseUnitLoadingStatus: {
fetchUnitLoadingStatus: string;
fetchVerticalChildrenLoadingStatus: string;
fetchXBlockDataLoadingStatus: string;
};
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDuplicate: (XBlockId: string | null) => void;

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { intlShape, injectIntl } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
IconButtonWithTooltip,
@@ -27,9 +27,8 @@ const CustomPageCard = ({
dispatch,
deletePageStatus,
setCurrentPage,
// injected
intl,
}) => {
const intl = useIntl();
const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false);
const { path: customPagesPath } = useContext(CustomPagesContext);
const navigate = useNavigate();
@@ -129,8 +128,6 @@ CustomPageCard.propTypes = {
dispatch: PropTypes.func.isRequired,
deletePageStatus: PropTypes.string.isRequired,
setCurrentPage: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(CustomPageCard);
export default CustomPageCard;

View File

@@ -5,7 +5,7 @@ import {
} from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { AppContext, PageWrap } from '@edx/frontend-platform/react';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Breadcrumb,
@@ -45,9 +45,8 @@ import { getPagePath } from '../utils';
const CustomPages = ({
courseId,
// injected
intl,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const [orderedPages, setOrderedPages] = useState([]);
@@ -278,8 +277,6 @@ const CustomPages = ({
CustomPages.propTypes = {
courseId: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(CustomPages);
export default CustomPages;

View File

@@ -26,6 +26,7 @@ const slice = createSlice({
useNewCertificatesPage: true,
useNewTextbooksPage: true,
useNewGroupConfigurationsPage: true,
useVideoGalleryFlow: false,
},
},
reducers: {

View File

@@ -7,22 +7,22 @@ import * as hooks from './hooks';
import supportedEditors from './supportedEditors';
import type { EditorComponent } from './EditorComponent';
import { useEditorContext } from './EditorContext';
import AdvancedEditor from './AdvancedEditor';
export interface Props extends EditorComponent {
blockType: string;
blockId: string | null;
isMarkdownEditorEnabledForCourse: boolean;
learningContextId: string | null;
lmsEndpointUrl: string | null;
studioEndpointUrl: string | null;
fullScreen?: boolean; // eslint-disable-line react/no-unused-prop-types
}
const Editor: React.FC<Props> = ({
learningContextId,
blockType,
blockId,
isMarkdownEditorEnabledForCourse,
lmsEndpointUrl,
studioEndpointUrl,
onClose = null,
@@ -34,12 +34,12 @@ const Editor: React.FC<Props> = ({
data: {
blockId,
blockType,
isMarkdownEditorEnabledForCourse,
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
},
});
const { fullScreen } = useEditorContext();
const EditorComponent = supportedEditors[blockType];
@@ -57,24 +57,7 @@ const Editor: React.FC<Props> = ({
);
}
const innerEditor = <EditorComponent {...{ onClose, returnFunction }} />;
if (fullScreen) {
return (
<div
className="d-flex flex-column"
>
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
>
{innerEditor}
</div>
</div>
);
}
return innerEditor;
return <EditorComponent {...{ onClose, returnFunction }} />;
};
export default Editor;

View File

@@ -24,6 +24,13 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: () => ({
useReactMarkdownEditor: true, // or false depending on the test
}),
}));
const props = { learningContextId: 'cOuRsEId' };
describe('Editor Container', () => {

View File

@@ -5,11 +5,13 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@openedx/paragon';
import { Warning as WarningIcon } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import EditorPage from './EditorPage';
import AlertMessage from '../generic/alert-message';
import messages from './messages';
import { getLibraryId } from '../generic/key-utils';
import { createCorrectInternalRoute } from '../utils';
import { getWaffleFlags } from '../data/selectors';
interface Props {
/** Course ID or Library ID */
@@ -37,6 +39,8 @@ const EditorContainer: React.FC<Props> = ({
const location = useLocation();
const [searchParams] = useSearchParams();
const upstreamLibRef = searchParams.get('upstreamLibRef');
const waffleFlags = useSelector(getWaffleFlags);
const isMarkdownEditorEnabledForCourse = waffleFlags?.useReactMarkdownEditor;
if (blockType === undefined || blockId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
@@ -76,6 +80,7 @@ const EditorContainer: React.FC<Props> = ({
courseId={learningContextId}
blockType={blockType}
blockId={blockId}
isMarkdownEditorEnabledForCourse={isMarkdownEditorEnabledForCourse}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={onClose ? () => onClose(location.state?.from) : null}

View File

@@ -7,14 +7,6 @@ import React from 'react';
*/
export interface EditorContext {
learningContextId: string;
/**
* When editing components in the libraries part of the Authoring MFE, we show
* the editors in a modal (fullScreen = false). This is the preferred approach
* so that authors can see context behind the modal.
* However, when making edits from the legacy course view, we display the
* editors in a fullscreen view. This approach is deprecated.
*/
fullScreen: boolean;
}
const context = React.createContext<EditorContext | undefined>(undefined);
@@ -32,7 +24,6 @@ export function useEditorContext() {
export const EditorContextProvider: React.FC<{
children: React.ReactNode,
learningContextId: string;
fullScreen: boolean;
}> = ({ children, ...contextData }) => {
const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []);
return <context.Provider value={ctx}>{children}</context.Provider>;

View File

@@ -37,7 +37,6 @@ const defaultPropsHtml = {
lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(),
fullScreen: false,
};
const fieldsHtml = {
displayName: 'Introduction to Testing',
@@ -66,22 +65,6 @@ describe('EditorPage', () => {
expect(modalElement.classList).not.toContain('pgn__modal-fullscreen');
});
test('it can display the Text (html) editor as a full page (when coming from the legacy UI)', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
render(<EditorPage {...defaultPropsHtml} fullScreen />);
// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
const modalElement = screen.getByRole('dialog');
expect(modalElement.classList).toContain('pgn__modal-fullscreen');
expect(modalElement.classList).not.toContain('pgn__modal');
expect(modalElement.classList).not.toContain('pgn__modal-xl');
});
test('it shows the Advanced Editor if there is no corresponding editor', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line
{ status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } }

View File

@@ -11,9 +11,9 @@ interface Props extends EditorComponent {
blockId?: string;
blockType: string;
courseId: string;
isMarkdownEditorEnabledForCourse?: boolean;
lmsEndpointUrl?: string;
studioEndpointUrl?: string;
fullScreen?: boolean;
children?: never;
}
@@ -25,11 +25,11 @@ const EditorPage: React.FC<Props> = ({
courseId,
blockType,
blockId = null,
isMarkdownEditorEnabledForCourse = false,
lmsEndpointUrl = null,
studioEndpointUrl = null,
onClose = null,
returnFunction = null,
fullScreen = true,
}) => (
<Provider store={store}>
<ErrorBoundary
@@ -38,13 +38,14 @@ const EditorPage: React.FC<Props> = ({
studioEndpointUrl,
}}
>
<EditorContextProvider fullScreen={fullScreen} learningContextId={courseId}>
<EditorContextProvider learningContextId={courseId}>
<Editor
{...{
onClose,
learningContextId: courseId,
blockType,
blockId,
isMarkdownEditorEnabledForCourse,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,

View File

@@ -9,6 +9,8 @@ const VideoSelector = ({
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,
onCancel,
}) => {
const dispatch = useDispatch();
const loading = hooks.useInitializeApp({
@@ -26,7 +28,7 @@ const VideoSelector = ({
return null;
}
return (
<VideoGallery />
<VideoGallery returnFunction={returnFunction} onCancel={onCancel} />
);
};
@@ -35,6 +37,8 @@ VideoSelector.propTypes = {
learningContextId: PropTypes.string.isRequired,
lmsEndpointUrl: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,
returnFunction: PropTypes.func,
onCancel: PropTypes.func,
};
export default VideoSelector;

View File

@@ -10,6 +10,8 @@ const VideoSelectorPage = ({
courseId,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,
onCancel,
}) => (
<Provider store={store}>
<ErrorBoundary
@@ -24,6 +26,8 @@ const VideoSelectorPage = ({
learningContextId: courseId,
lmsEndpointUrl,
studioEndpointUrl,
returnFunction,
onCancel,
}}
/>
</ErrorBoundary>
@@ -42,6 +46,8 @@ VideoSelectorPage.propTypes = {
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string,
returnFunction: PropTypes.func,
onCancel: PropTypes.func,
};
export default VideoSelectorPage;

View File

@@ -60,6 +60,7 @@ exports[`Editor Container snapshots rendering correctly with expected Input 1`]
blockId="company-id1"
blockType="html"
courseId="cOuRsEId"
isMarkdownEditorEnabledForCourse={true}
lmsEndpointUrl="http://localhost:18000"
onClose={null}
returnFunction={null}

View File

@@ -32,7 +32,6 @@ const defaultPropsHtml = {
lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(),
fullScreen: false,
};
const fieldsHtml = {
displayName: 'Introduction to Testing',

View File

@@ -14,7 +14,6 @@ import { Close } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { EditorComponent } from '../../EditorComponent';
import { useEditorContext } from '../../EditorContext';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
@@ -30,37 +29,18 @@ interface WrapperProps {
}
export const EditorModalWrapper: React.FC<WrapperProps & { onClose: () => void }> = ({ children, onClose }) => {
const { fullScreen } = useEditorContext();
const intl = useIntl();
if (fullScreen) {
return (
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={{ minHeight: '100%' }}
>
{children}
</div>
);
}
const title = intl.formatMessage(messages.modalTitle);
return (
<ModalDialog isOpen size="xl" isOverflowVisible={false} onClose={onClose} title={title}>{children}</ModalDialog>
);
};
export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => {
const { fullScreen } = useEditorContext();
return <ModalDialog.Body className={fullScreen ? 'pb-6' : 'pb-0'}>{ children }</ModalDialog.Body>;
};
export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => <ModalDialog.Body className="pb-0">{ children }</ModalDialog.Body>;
export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => {
const { fullScreen } = useEditorContext();
if (fullScreen) {
return <div className="editor-footer fixed-bottom">{children}</div>;
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
// eslint-disable-next-line react/jsx-no-useless-fragment
export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => <>{ children }</>;
interface Props extends EditorComponent {
children: React.ReactNode;

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