Compare commits

...

217 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
c2e4f7f51e feat: course optimizer page better design
- Add filter functionality to course optimizer broken links to check different results
- modify design, make use of logo with better tooltip
- change message texts in different area of the page
2025-07-30 16:40:45 -05:00
Brian Smith
96a04d4492 feat!: add design tokens support (#2187)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

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

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

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

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

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

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

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

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

* Images were not being rendered in any of the fields in these problems.
* Base url was not being set which is used by tinymce to load images with relative path.
* Answer fields were set to inline mode which does not initialize images or base paths
* If same image twice is used twice in a problem, the logic of replacing `static/image.jpg` with `/static/image.jpg` would replace the first occurrence twice resulting in `//static/image.jpg`, breaking both the links.
* On initialization of answer fields, the absolute static asset urls were being replaced by relative urls causing the editor being set as dirty without user changes.
2025-03-13 11:11:44 -05:00
Navin Karkera
77a55d9ad3 feat: course libraries review tab [FC-0076] (#1699)
Adds review tab to course libraries page. Also refactors all libraries page as per new designs.
2025-03-12 12:58:27 -05:00
Chris Chávez
3aa409d065 feat: Add Publish confirmation modal [FC-0076] (#1677) 2025-03-11 17:37:40 -05:00
Muhammad Farhan
732fd28eb9 refactor: Improve conditions readability 2025-03-12 00:08:06 +05:00
Muhammad Farhan
091e120224 fix: Use defaultValue when item is null or empty 2025-03-12 00:08:06 +05:00
Brayan Ceron
1174b09ac4 fix: render proper visibility message on self-paced course type 2025-03-11 16:50:59 +05:00
Chris Chávez
b2472cfc0a refactor: Separate Publish order sort from publish filter [FC-0076] (#1701) 2025-03-10 10:55:15 -05:00
Rômulo Penido
17ebb90cd1 fix: paste button on unit page wasn't working (#1724) 2025-03-07 11:09:00 -08:00
Hina Khadim
49fbe766b0 fix: Prevent Alt Text from Being Truncated Due to Double Quotation Marks (#1721) 2025-03-07 23:17:37 +05:00
Rômulo Penido
dbba4dd296 fix: excessive calls to the clipboard API endpoint (#1700) 2025-03-07 08:16:39 -08:00
Chris Chávez
0eda5aec23 feat: Create advanced blocks in libraries [FC-0076] (#1653)
List view to show and create the advanced blocks
2025-03-05 12:46:17 -05:00
Dima Alipov
26c919a070 fix: default setting not updating with updated default settings 2025-03-04 17:01:39 +05:00
Demid
e9d85e85d3 fix: broken re-run notification dismiss (#1590) 2025-03-04 16:59:31 +05:00
Dmytro
e100193744 fix: prevent passing empty string in the date field to backend (#1457)
Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2025-03-04 16:59:00 +05:00
Peter Kulko
411607ec59 feat: [FC-0070] Manage Tags interoperation (#1454)
Added interaction between MFE and Legacy tagging functionality for xblocks on the Course unit page.
2025-03-03 11:07:04 -03:00
Rômulo Penido
06d591df13 fix: change ComponentPreview modal size to xl (#1650) 2025-02-26 17:27:51 -05:00
Jillian
e5360dc1f1 fix: allow users who can create orgs to see the full list of orgs when creating a course [FC-0076] (#1689)
The fixes for https://github.com/openedx/frontend-app-authoring/issues/1577 caused issues with edx.org's Global Staff users not being able to create courses. edx.org/2U Global Staff are seeing an empty Organization drop-down list.

This issue arose due to misuse of two similarly-named flags returned by the contentstore's home API

* `can_create_organizations`: granted to Global Staff or everyone if `FEATURES[ENABLE_CREATOR_GROUP] == True`, 
* `allow_to_create_new_org`: which is actually about auto-creating organizations, so both `can_create_organizations` + `settings.ORGANIZATIONS_AUTOCREATE` must be True

In this change, we use `canCreateOrganizations` to decide whether the user can see the full list of organizations when creating courses. We preserve the use of `allowToCreateNewOrg`  when deciding whether to allow users to "typeahead" to create a new organization not in the drop-down list.
2025-02-25 21:07:43 -05:00
Navin Karkera
11a7e78b73 fix: parsing text input problems (#1679)
* Parse incorrect answer fields in text input problems
* Fix feedback order
2025-02-25 15:41:49 -05:00
Rômulo Penido
56b7a7b17a feat: add component usage data in the ComponentDetails component [FC-0076] (#1656)
Adds the list of Courses and Units/Containers using a component to the "Details" tab on the sidebar.
2025-02-25 13:55:36 -05:00
Diana Olarte
6b2ba6e063 feat: cancellable block creation/edit workflow in libraries v2 (#1574)
Before, clicking "new problem" (etc) would create a new block, then launch the editor. Now it launches the editor and then creates the new block only on save. This makes the "Cancel" button work as expected. Only affects libraries so far, not courses.
2025-02-25 10:28:42 -08:00
Hina Khadim
63caf098a5 [TNL-11885] fix: resolve course-optimizer failure case bug (#1674)
* fix: resolve course-optimizer failure case bug

---------

Co-authored-by: Hina Khadim <hina.khadim@PF1H334R.2tor.net>
2025-02-25 14:52:16 +05:00
Kyle McCormick
8fe52d22e7 fix: Upgrade header, temporarily re-introducing Maintenance link (#1676)
We are temporarily re-introducing the Maintenance link, as the
Maintenance Announcements tool is still in use, as discussed on:
openedx/edx-platform#35852

There are no other significant changes in this version bump:
https://github.com/openedx/frontend-component-header/compare/v5.8.0...v5.8.3
2025-02-24 09:41:06 -05:00
Muhammad Faraz Maqsood
a0a0b9dc84 fix: Resolve Course Optimizer Scanning Bug
- Corrected LINK_CHECK_STATUSES.IN_PROGRESS value that is sent by backend.
2025-02-24 13:31:28 +05:00
Chris Chávez
ba896a3b15 feat: Enable transcripts for video library [FC-0076] (#1596)
* Get updateTranscriptHandlerUrl() and call it when is ready.
* Enable LanguageNamesWidget in a library.
* Enable add transcripts for libraries.
* Enable delete transcripts for libraries.
* Enable replace transcripts for libraries.
* Enable download transcripts for libraries.
* Enable download transcripts from YouTube
2025-02-21 13:33:50 -05:00
KEVYN SUAREZ
3e235d3360 fix: Adjust course import dropzone height to make course import status visible (#1664)
Closes: https://github.com/openedx/frontend-app-authoring/issues/1387
2025-02-21 16:48:57 +00:00
Chris Chávez
0db1727537 feat: Add cancel confirmation modal to Advanced editors in libraries [FC-0076] (#1672)
* Extracts the cancel confirmation modal as a new component.
* Adds the cancel confirmation modal to the Advanced editors in libraries.
2025-02-21 11:24:23 -05:00
Peter Kulko
7e4ecff4e8 feat: handle edit modals from advanced xblocks (#1445)
Adds new message types, updates message handlers, and implements a new modal iframe for legacy XBlock editing.
2025-02-20 17:05:48 -03:00
Saad Yousaf
2befd82e51 refactor: Update information message on Course Optimizer page 2025-02-20 16:57:17 +05:00
Navin Karkera
8275bbe8ce feat: course libraries page [FC-0076] (#1641)
Adds Libraries page that lists all library components being used in the current course to Content > Libraries
2025-02-18 10:36:31 -05:00
Chris Chávez
59243b0cb3 fix: Height in Advanced Editor [FC-0076] (#1649)
Updates the height of the Advanced Editor.
2025-02-13 11:22:02 -05:00
Braden MacDonald
0b08d82f03 fix: bugs with ExpandableTextArea toolbars & modals in problem editor (#1646)
* fix: clicking library name in Studio header would show 404

* fix: when ExpandableTextArea is in a modal, the selection toolbar could not be clicked

* fix: in ExpandableTextArea, shrink the "insert toolbar" that blocks the input

* chore: ignore coverage of modal fixer

* fix: make sure emoji/formula modals are working in the text editor too
2025-02-12 13:40:18 -08:00
Peter Kulko
e9130d3852 chore: iframe rendering optimization (#1544)
Iframe reload optimizations for various xblock related actions. Added some improvements related to scrolling to the current xblock. Fixed behavior of the xblock action dropdown list.
2025-02-11 16:31:07 -03:00
Chris Chávez
b0fc3d923b feat: Add drag and drop to LIBRARY_SUPPORTED_BLOCKS (#1651)
Add drag and drop v2 to LIBRARY_SUPPORTED_BLOCKS to enable the block by default
2025-02-07 12:47:33 -05:00
Daniel Valenzuela
05dddce920 feat: allow filtering library by publish status (#1570)
* Adds a filter widget for Publish status.
* Adds "Unpublished changes" badge.
2025-02-05 13:09:27 -05:00
Muhammad Farhan
31f39cb015 fix: regex mismatch in text block (#1597) 2025-01-30 09:13:26 -05:00
Braden MacDonald
b7241a124c docs: clarify/update a TODO comment about Meilisearch functionality (#1638)
Just a small update to a comment in the code, now that Meilisearch has new functionality that implements a feature we need.
2025-01-29 12:20:48 -05:00
Jillian
be600a91f0 feat: hide some settings fields when editing a library problem (#1601)
Hides some XBlock settings fields when editing library blocks. These hidden settings fields are relevant to course blocks, but not library blocks.

This change impacts Library Authors, and Course Authors who use Library Blocks and/or Problem Banks.
2025-01-27 10:47:09 -05:00
salman2013
de7affd97f chore: update catalog-info file and remove openedx.yaml file 2025-01-24 09:35:54 -05:00
Kristin Aoki
2102c7a612 fix: hidden remove button (#1600)
This PR fixes the visibility of the "Remove" button for grade segments. Previously, the button would appear on hover above the segment. But the styling of the button blocked it from being visible. Now, the button appears on hover under the grade range. This change impacts Course Authors.
2025-01-24 17:25:15 +05:00
Jillian
654c06b596 fix: a11y and performance issues with library authoring navigation [FC-0076] (#1593)
Fixes some a11y and performance issues for Library Authors

**Accessibility**

* When navigating between the various Library Authoring pages, the search keyword box should not be auto-focused, so focus will follow the user's click/tab events and keep that continuity.
The course content search modal behavior is unchanged -- loading this modal auto-focuses the search input box as one would expect.

**Performance**

* Navigating between the various Library Authoring pages was causing a full re-mount of the `<LibraryLayout/>` component
* React query's `staleTime` option is used to control how long data is considered fresh in both queries and routes. By default, the `staleTime` for queries is set to 0 milliseconds, meaning that the data will always be considered stale and will be refetched whenever the query is mounted.
2025-01-22 11:39:47 -05:00
Rômulo Penido
13b2ed5363 feat: add last published date to HistoryWidget [FC-0076] (#1585)
Adds the "Last Published" date to the `HistoryWidget`
2025-01-20 12:30:10 -05:00
Chris Chávez
fd6a6dd443 feat: Add AdvancedEditors with an iframe [FC-0076] (#1568)
Creates the AdvancedEditor to support editors like Drag and Drop, openresponse, poll, survey, and other advanced editors.

- AdvancedEditor created to call studio_view of the block
- Update LibraryBlock to support any view (and use studio_view in AdvancedEditor)
Intercept xblock-event message to close the Advanced editor on cancel or save
2025-01-16 13:22:27 -05:00
Ihor Romaniuk
619ab9a267 feat: [FC-0070] rendering library content in unit page (#1475)
The enables opening a Library Content page within the new Studio unit page. This page displays the xBlocks from the specified library and provides basic configuration options for the library.
2025-01-16 14:06:48 -03:00
Peter Kulko
98fbcff842 feat: [FC-0070] listen to xblock interaction events (#1431)
This is part of the effort to support the new embedded Studio Unit Page.  It includes changes to the CourseUnit component and the functionality of interaction between xblocks in the iframe and the react page.

The following events have been processed:

* delete event
* Manage Access event (opening and closing a modal window)
* edit event for new xblock React editors
* clipboard events
* duplicate event
2025-01-15 13:28:43 -05:00
Jillian
45f6ef42a7 fix: allow user provided value if can auto-create orgs [FC-0076] (#1582)
Allows Content Authors to auto-create an organization when creating a library, if auto-creating orgs is allowed by the platform.
2025-01-15 12:44:30 -05:00
Jesper Hodge
8385c4e8ed Feat course optimizer page (#1533)
Course Optimizer is a feature approved by the Openedx community that adds a "Course Optimizer" page to studio where users can run a scan of a course for broken links - links that point to pages that have a 404.

Depends on backend: openedx/edx-platform#35887 - test together.

This also requires adding a nav menu item to edx-platform legacy studio. That should be implemented before enabling the waffle flag on prod.

Links:
- [Internal JIRA ticket](https://2u-internal.atlassian.net/browse/TNL-11809)
- [Course Optimizer Discovery](https://2u-internal.atlassian.net/wiki/spaces/TNL/pages/1426587703/TNL-11744+Course+Optimizer+Discovery)
- [Openedx community proposal](https://github.com/openedx/platform-roadmap/issues/388)
2025-01-13 11:44:25 -05:00
Farhaan Bukhsh
e6bce560bc feat: Adding human readable 403 error access restricted (#1569)
Updated to have human-readable forbidden error (403)
2025-01-10 13:22:49 -05:00
Jillian
811be226d1 feat: shareable URLs for library components and searches [FC-0076] (#1575)
Adds new routes and URL parameters to use when viewing and performing searches on library components. These changes allow these pages to be bookmarked or shared by copy/pasting the browser's current URL.

No changes were made to the UI.

Use cases covered:

* As an author working with content libraries, I want to easily share any component in a library with other people on my team, by copying the URL from my browser and sending it to them.
* As an author working with content libraries, I want to easily share any search results with other people on my team, by copying the URL from my browser and sending it to them.
* As an author working with content libraries, I want to bookmark a search in my browser and return to it at any time, with the same filters and keywords applied.
* As an author of a content library with public read access, I want to easily share any component in a library with any authors on the same Open edX instance, by copying the URL from my browser and sending it to them.
* As an author of a content library, I want to easily share a library's "Manage Team" page with other people on my team by copying the URL from my browser and sending it to them.
* As an author working with content libraries, I want to easily share any selected sidebar tab with other people on my team, by copying the URL from my browser and sending it to them.
2025-01-10 10:36:46 -05:00
edX requirements bot
f586b095fa chore: update browserslist DB (#1494)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-01-06 10:38:02 -08:00
Rômulo Penido
dc0ba6aac4 fix: disable filter by block on collections tab [FC-0076] (#1576)
Adds the disabled prop for `FilterByBlockType` component and uses it on the Library Collection tab.
2024-12-21 07:01:22 -05:00
Rômulo Penido
230960b711 refactor: improve library sub header (#1573)
Changes the Library (and Collection) subheader
2024-12-18 12:56:21 -05:00
Chris Chávez
64906a1b9d refactor: Update error message while adding library team member [FC-0062] (#1572)
Update the error message while adding a library team member to show the error message generated by the server
2024-12-18 12:50:09 -05:00
Navin Karkera
b110b6bdc9 feat: undo component delete [FC-0076] (#1556)
Allows library authors to undo component deletion by displaying a toast message with an undo button for some duration after deletion.
2024-12-13 13:18:29 -05:00
Rômulo Penido
69bbeda816 refactor: split up library context (#1539)
Split the library context into smaller contexts:

* LibraryContext
* ComponentPickerContext
* SidebarContext
2024-12-12 13:14:45 -05:00
Brayan Cerón
c7e2bf9934 fix: find proper courses when searching (#1497)
When active/archived filters were on or there was selected any order filter, the search skipped these values and it was just returned the courses list without the respective filters. Additionally, when a search keyword was applied and a filter was selected, the keyword stayed stuck and the search list returned were not the appropriate
2024-12-09 14:44:02 -08:00
Daniel Valenzuela
73490a5741 fix: avoid changing url when removing filters (#1530)
* Makes the Active Tab Key independent from the URL, except for the initial load, where the active tab is set from the url.
*Avoids unnecessarily changing SearchParams: Due to a limitation of the useSearchParams react hook, which uses a memoized value for the URL that becomes stale after selecting a tab, it unexpectedly changes the URL value. Unfortunately there's no way to completely avoid this, so if there's a usageKey url param, the hook setter function will be called and the URL will revert to the stale memoized url.
2024-12-06 16:24:04 -05:00
Chris Chávez
d2d753203f fix: Update error messages when adding user to library [FC-0062] (#1543)
Updates the message error when the user doesn't exist when adding a new team member to a library.
2024-12-06 15:46:52 -05:00
Rômulo Penido
0e9025a670 fix: legacy library links were not working (#1548) 2024-12-06 18:50:53 +00:00
Rômulo Penido
2f1263ab5a fix: show/hide "new library" button based on separate v1/v2 permissions (#1545) 2024-12-06 10:40:45 -08:00
Chris Chávez
a0f6f4357e fix: Show published OLX in Library Content Picker [FC-0062] (#1534) 2024-12-05 14:13:04 -05:00
Braden MacDonald
e75ce15a67 refactor: Add TypeScript types for the Editors' Redux state (#1537)
It starts to add type information to the messy Redux state used by the editors, mostly focusing on the state shared by all editors and the problem editor.
2024-12-03 17:51:52 -05:00
Rômulo Penido
0771923183 feat: preserve library sidebar tab while switching items (#1535)
In the library home page, makes the selected tab on the sidebar persist while selecting Components or Collections Info Sidebar.
2024-12-03 17:13:22 -05:00
Braden MacDonald
6e53e37bfe refactor: Convert more Taxonomy code to TypeScript (2) (#1536)
* Converts some files from .js or .mjs to .ts
* Refactors some tests to use the new initializeMocks helper
* Cleans up and improves some type definitions
2024-12-02 09:24:17 -05:00
Braden MacDonald
abe68ac599 refactor: Convert more Taxonomy code to TypeScript (#1532)
* Converts some files from .js or .mjs to .ts
* Moves the API code from src/taxonomy/tag-list/data into src/taxonomy/data
* Cleans up and improves some type definitions
* No user-visible changes / functionality changes.
2024-11-27 15:31:55 -05:00
Rômulo Penido
f86c609ff1 fix: editor flicker after creating xblock (#1531) 2024-11-26 14:41:04 -08:00
Kyle McCormick
ec3f78f0ea feat!: remove Maintenance header link (#1526)
...by bumping frontend-component-header 5.7.0 -> 5.8.0

Our reasoning is that the two functions of the Studio Maintenance
dashboard (Announcements and Maintenance Banner) have been broken
for a while.

It's actually version 5.7.2 that removes the link [1] but since 5.8.0
has no breaking changes, it seemed prudent to jump straight to latest.

[1] https://github.com/openedx/frontend-component-header/releases/v5.7.2

Related PR: https://github.com/openedx/edx-platform/pull/35852
2024-11-26 09:49:34 -05:00
Navin Karkera
55fe87a3db feat: show problem bank component picker on window msg [FC-0062] (#1522)
Fix for: If you have a unit with many components and a problem bank on the NEW MFE unit page (with an iframe), clicking "Add Components" will open a modal that's way too tall.
2024-11-22 20:29:18 +00:00
Navin Karkera
7aa5accdbb feat: preview library block changes in course unit [FC-0062] (#1506)
Creates a new preview library block modal. Intercepts the message when the block is iframed to open the new modal.
2024-11-22 15:18:43 -05:00
Diana Olarte
31f59d6bca fix: Schedule and Details page was not loading (#1527)
The page relied on obscure behavior of setting "isLibrary" to disable image uploads even in a course context. This commit refactors to use an explicit enableImageUpload prop in TinyMceWidget.
2024-11-22 12:10:11 -08:00
Peter Kulko
bc8d59b0eb chore: [FC-0070] Some tests refactoring (#1518) 2024-11-21 15:24:31 -03:00
Ihor Romaniuk
b5419acd74 feat: [FC-0070] implement move xblock modal (#1422) 2024-11-21 15:22:41 -03:00
Braden MacDonald
66577b0d59 chore: bump frontend-build, remove exceptions for prefer-default-export (#1519) 2024-11-20 09:00:02 -08:00
Chris Chávez
624f5addcf fix: Show published count component in library content picker (#1481)
When using the library component picker, show the correct number on component count (published components) in collection cards.
2024-11-19 16:10:00 -05:00
Chris Chávez
0365e3809b fix: TinyMce aux modal issues in text editors [FC-0062] (#1500)
The following bugs were found with the TinyMCE aux modal (used in emoticons, formulas and embed iframe):

* The TinyMCE aux modal and the Editor modal close when clicking on any content in the aux modal.
* When the user opens the Edit Source Code modal, this adds data-focus-on-hidden to the TinyMce aux modal, making it unusable (not clickable).
* Since they are two separate modals, the focus remains on the editor modal, making it impossible to use scrolling or inputs from the modal aux.

Solution: Move the aux modal inside the editor modal.

One discarded solution: Block the modal editor from closing when interacting with the modal aux. The modal editor still retained focus.
2024-11-19 15:10:04 -05:00
Diana Olarte
b260708080 test: fix contentContainer test (#1516) 2024-11-19 12:24:32 -05:00
Diana Olarte
f740f57454 feat: upload images to v2 library components from the TinyMCE in library editor (#1458) 2024-11-18 10:38:08 -08:00
Peter Kulko
ba48a273a1 fix: fixed Configure modal for unit page (#1452) 2024-11-18 11:39:44 -03:00
Peter Kulko
0706a09acb fix: error handling (#1079)
Added alert message if 403 error occurs.
2024-11-18 11:27:36 -03:00
Régis Behmo
771c5d3e19 docs: minor README improvements (#1504) 2024-11-15 10:32:22 -08:00
Kyle McCormick
6ffdb01c24 refactor: remove pointless maintenance link message (#1503)
This link is defined in frontend-component-header, so the message
shouldn't be here. Anyway, we are deleting the link from
frontend-component-header too

Related:
* https://github.com/openedx/frontend-component-header/pull/553
* https://github.com/openedx/edx-platform/pull/35852
2024-11-15 10:55:23 -05:00
Pooja Kulkarni
32e5fa68d8 fix: Adjust styling when title is truncated (#1382) 2024-11-14 17:30:19 -05:00
Navin Karkera
cee88885d9 feat: enable problem bank button functionality on unit page (#1480) 2024-11-14 12:00:23 -08:00
Navin Karkera
033acc45f1 fix: remove unnecessary toast notification on adding component (#1490)
Fix for: #1489
2024-11-14 12:03:09 -05:00
Navin Karkera
efd2b3d27d feat: show info banner in component picker (#1498)
Displays a infor banner if only published content is visible in component picker.
2024-11-13 13:54:20 -05:00
Peter Kulko
9b4cf8718f fix: fixing broken tests 2024-11-11 11:20:15 -03:00
Rômulo Penido
67faf9a63a fix: infinite scroll bug on library page (#1483) 2024-11-08 10:59:48 -08:00
Peter Kulko
e59f2846e3 feat: render iframe with xblocks (#1375)
This refactors the CourseUnit component by removing the DraggableList and CourseXBlock components and replacing them with a simpler XBlockContainerIframe. Additionally, it introduces new constants for iframe handling.
2024-11-08 10:53:32 -03:00
Peter Kulko
f9ef00e29f feat: [FC-0070] Remove backend redirects (use SPA functionality) (#1372)
Introduces the ability to utilize SPA functionality when the relevant waffle flags are enabled for current MFE pages. When any new MFE page is loaded, a request is made to retrieve the waffle flags. This includes both global waffle flags related to MFE Authoring pages, as well as waffle flags specific to the current course.
2024-11-08 08:19:23 -03:00
Daniel Valenzuela
979c69b48e feat: simplify Library Home Page (v2) (#1443) 2024-11-07 09:48:19 -08:00
Rômulo Penido
d99e3f0f62 fix: add spacing to searchbar and simplify render conditions (#1461)
Adds padding between the search bar and the library list.

Also, the render method was refactored to be a bit simpler.
2024-11-06 22:01:38 -05:00
Jillian
f1bdc6200f fix: show a more detailed error on Bad Request (#1468)
Show a detailed error when 400 Bad Request received while adding a component to a library, either a new or pasted component. The most likely error from the backend here is "library can only have {max} components", and since this error is translated already, we can just report it through.
2024-11-06 21:53:24 -05:00
Navin Karkera
e118eb5971 chore: hide transcripts in video preview for library (#1459)
Fixes: #1453
2024-11-05 15:15:28 -05:00
Jillian
d7bbd40de1 fix: Hide / error on Libraries v2 pages if !librariesV2Enabled (#1449)
Show an error message if the user tries to view a v2 Library while Libraries V2 are disabled in the platform.
2024-11-05 15:14:09 -05:00
edX requirements bot
fc94667a57 chore: update browserslist DB (#1381)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-11-04 12:27:02 -08:00
Navin Karkera
df8a65dc4e feat: handle unsaved changes in text & problem editors (#1444)
The text & problem xblock editors will display a confirmation box before
cancelling only if user has changed something else it will directly go
back.
2024-11-04 12:41:00 -05:00
Rômulo Penido
949e4ac94c fix: enable publish button on library after component edit [FC-0062] (#1446)
Fixes the following bug: After publishing a library then editing a component, the "Publish" button in Library Info doesn't become enabled until you refresh. Updates the apiHooks to invalidates library query.
2024-11-01 13:58:39 -05:00
Navin Karkera
549dbaa0fa fix: add component to collection on paste [FC-0062] (#1450)
Link component to collection if pasted in a collection page.
Fixes: https://github.com/openedx/frontend-app-authoring/issues/1435
2024-10-31 17:44:34 -05:00
Bilal Qamar
28569aa3da test: Remove support for Node 18 (#1247)
* test: Remove support for Node 18

* chore: update code coverage artifact naming
2024-10-31 15:32:54 -04:00
Rômulo Penido
ecfe27b043 fix: empty state for library selection on component picker [FC-0062] (#1440)
This PR fixes the empty state text for adding library content if the user can't access any library.
2024-10-28 18:49:18 -05:00
Chris Chávez
cff1177ae9 fix: Library Preview Expand button covers dropdown (#1438) 2024-10-25 13:55:24 -07:00
Kshitij Sobti
4d4adce715 feat: Use configured DEFAULT_GRADE_DESIGNATIONS (#1227)
Support was recently added to edx-platform to add customisised default grade
designations, this change adds support for them to this MFE as well to bring it
to partiy with the edx-platform UI

It also refactors the grading-settings page to use React Query and updates the
logic used when partitioning grades by default to make it work better when there
are more than 5 partitions.

Co-authored-by: Farhaan Bukhsh <farhaan@opencraft.com>
2024-10-25 15:34:04 +05:30
Jillian
774728a9c0 fix: use absolute URL for Export Tags menu item (#1432)
use absolute URL for Export Tags menu item so that the menu item works no matter where in the course it's used. Fix this issue: https://github.com/openedx/frontend-app-authoring/issues/1380
2024-10-24 21:03:51 -05:00
994 changed files with 47382 additions and 19096 deletions

5
.env
View File

@@ -44,4 +44,7 @@ INVITE_STUDENTS_EMAIL_TO=''
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -47,4 +47,7 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -39,4 +39,6 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
PARAGON_THEME_URLS=

View File

@@ -9,22 +9,17 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report-${{ matrix.node }}
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
@@ -34,9 +29,7 @@ jobs:
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
name: code-coverage-report-20
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v4
with:

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.DS_Store
.eslintcache
.idea
.run
node_modules
npm-debug.log
coverage

View File

@@ -38,7 +38,7 @@ Cloning and Setup
git clone https://github.com/openedx/frontend-app-authoring.git
2. Use node v20.x.
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports node 20.
Using other major versions of node *may* work, but this is unsupported. For
@@ -85,8 +85,8 @@ Troubleshooting
---------------
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
these commands to update your devstack's domain names:
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
these commands to update your devstack's domain names:
.. code-block:: bash
@@ -98,7 +98,7 @@ these commands to update your devstack's domain names:
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
[this forum post](https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2)
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
Features
@@ -315,7 +315,7 @@ In additional to the standard settings, the following local configurations can b
Developing
**********
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
`Tutor <https://docs.tutor.edly.io/>`_ is the community-supported Open edX development environment. See the `tutor-mfe plugin README <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ for more information.
If your devstack includes the default Demo course, you can visit the following URLs to see content:

View File

@@ -12,6 +12,7 @@ metadata:
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:2u-tnl
type: 'website'

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"

View File

@@ -1,11 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: cath
oeps: {}
owner: edx/platform-core-tnl
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

19648
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
@@ -23,11 +23,6 @@
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
@@ -39,6 +34,7 @@
},
"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",
@@ -48,12 +44,12 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.1.0",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^8.0.3",
"@edx/openedx-atlas": "^0.6.0",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -64,9 +60,9 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.0.14",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/paragon": "^22.8.1",
"@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
@@ -83,48 +79,44 @@
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"npm": "^10.8.1",
"prop-types": "^15.8.1",
"react": "17.0.2",
"react": "^18.3.1",
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.23.1",
"react-router-dom": "6.23.1",
"react-select": "5.8.0",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-select": "5.10.1",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",
"redux": "4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"start": "^5.1.0",
"tinymce": "^5.10.4",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"uuid": "^11.1.0",
"xmlchecker": "^0.1.0",
"yup": "0.31.1"
"yup": "0.32.11"
},
"devDependencies": {
"@edx/react-unit-test-utils": "3.0.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/typescript-config": "^1.0.1",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.2.1",
"@types/lodash": "^4.17.7",
"@types/lodash": "^4.17.17",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"fetch-mock-jest": "^1.5.1",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}
}

View File

@@ -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

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { bbbPlanTypes } from '../constants';

View File

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

View File

@@ -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

@@ -544,12 +544,9 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
await act(async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
const spinner = screen.getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('Show connection error message when we suffer studio server side error', async () => {

View File

@@ -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,15 +17,16 @@ import messages from './messages';
setupYupExtensions();
const TeamSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
const blankNewGroup = {
name: '',
description: '',
type: GroupTypes.OPEN,
maxTeamSize: null,
userPartitionId: null,
id: null,
key: uuid(),
};
@@ -38,6 +39,7 @@ const TeamSettings = ({
type: group.type,
description: group.description,
max_team_size: group.maxTeamSize,
user_partition_id: group.userPartitionId,
}));
return saveSettings({
team_sets: groups,
@@ -164,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,3 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';

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

@@ -7,7 +7,7 @@ import {
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import {
queryByTestId, render, waitFor, getByText, fireEvent,
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
@@ -106,8 +106,9 @@ describe('XpertUnitSummarySettings', () => {
});
test('Shows switch on if enabled from backend', async () => {
const enableBadge = await findByTestId(container, 'enable-badge');
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
expect(enableBadge).toBeTruthy();
});
test('Shows switch on if disabled from backend', async () => {

View File

@@ -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

@@ -1,5 +1,4 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/utilities-only";
@import "~@openedx/paragon/styles/scss/core/utilities-only";
.summary-radio {
display: flex;

View File

@@ -5,13 +5,13 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooter } from '@edx/frontend-component-footer';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchStudioHomeData } from './studio-home/data/thunks';
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
@@ -21,10 +21,11 @@ const CourseAuthoringPage = ({ courseId, children }) => {
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchStudioHomeData());
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
@@ -65,7 +66,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
)
)}
{children}
{!inProgress && !isEditor && <StudioFooter />}
{!inProgress && !isEditor && <StudioFooterSlot />}
</div>
);
};

View File

@@ -1,18 +1,12 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { render } from '@testing-library/react';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from './store';
import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -25,17 +19,14 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
describe('Editor Pages Load no header', () => {
@@ -51,13 +42,9 @@ describe('Editor Pages Load no header', () => {
mockPathname = '/editor/';
await mockStoreSuccess();
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
);
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
@@ -66,13 +53,9 @@ describe('Editor Pages Load no header', () => {
mockPathname = '/evilguy/';
await mockStoreSuccess();
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
,
);
expect(wrapper.queryByRole('status')).toBeInTheDocument();
@@ -100,14 +83,7 @@ describe('Course authoring page', () => {
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId} />
</IntlProvider>
</AppProvider>
,
);
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
@@ -118,13 +94,9 @@ describe('Course authoring page', () => {
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();

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';
@@ -20,10 +20,13 @@ import { CourseUpdates } from './course-updates';
import { CourseUnit } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -55,6 +58,10 @@ const CourseAuthoringRoutes = () => {
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
@@ -79,7 +86,7 @@ const CourseAuthoringRoutes = () => {
<Route
key={path}
path={path}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
/>
))}
<Route
@@ -118,6 +125,10 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import initializeStore from './store';
import { executeThunk } from './utils';
import { getApiWaffleFlagsUrl } from './data/api';
import { fetchWaffleFlags } from './data/thunks';
import {
screen, initializeMocks, render, waitFor,
} from './testUtils';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
@@ -50,68 +50,59 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
});
describe('<CourseAuthoringRoutes>', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
beforeEach(async () => {
const { axiosMock, reduxStore } = initializeMocks();
store = reduxStore;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
render(
<CourseAuthoringRoutes />,
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
);
await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
store = initializeStore();
});
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
it('renders the EditorContainer component when the course editor route is active', async () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/pages-and-resources']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
<CourseAuthoringRoutes />,
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
);
await waitFor(() => {
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
learningContextId: courseId,
}),
);
});
});
it('renders the EditorContainer component when the course editor route is active', () => {
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/video/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
<CourseAuthoringRoutes />,
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
);
await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
});

View File

@@ -0,0 +1,16 @@
export default {
content: {
id: 67,
userId: 3,
created: '2024-01-16T13:09:11.540615Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'sequential',
blockTypeDisplay: 'Subsection',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Sequences',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
};

View File

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

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooter } from '@edx/frontend-component-footer';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from '../header';
import messages from './messages';
@@ -29,7 +29,7 @@ const AccessibilityPage = ({
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
<AccessibilityForm accessibilityEmail={email} />
</Container>
<StudioFooter />
<StudioFooterSlot />
</>
);
};

View File

@@ -1,2 +1 @@
// eslint-disable-next-line import/prefer-default-export
export { default as advancedSettingsMock } from './advancedSettings';

View File

@@ -1,6 +1,10 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
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;
@@ -15,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;
}
/**
@@ -27,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;
}
/**
@@ -37,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

@@ -1,2 +1 @@
/* eslint-disable import/prefer-default-export */
export { default as AdvancedSettings } from './AdvancedSettings';

View File

@@ -32,7 +32,7 @@
bottom: 0;
width: 100%;
padding: 0 .625rem;
z-index: $zindex-modal;
z-index: var(--pgn-elevation-modal-zindex);
}
.alert-proctoring-error {
@@ -66,13 +66,13 @@
.setting-sidebar-supplementary {
.setting-sidebar-supplementary-about {
.setting-sidebar-supplementary-about-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
color: $headings-color;
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
color: var(--pgn-color-headings-base);
margin-bottom: 1.25rem;
}
.setting-sidebar-supplementary-about-descriptions {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
color: $text-color-base;
}
}
@@ -81,16 +81,16 @@
list-style: none;
.setting-sidebar-supplementary-other-link {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
line-height: 1.5rem;
color: $info-500;
color: var(--pgn-color-info-500);
margin-bottom: .5rem;
}
}
.setting-sidebar-supplementary-other-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
color: $headings-color;
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
color: var(--pgn-color-headings-base);
margin-bottom: 1.25rem;
}
}
@@ -102,7 +102,7 @@
display: inline-block;
margin-right: 5px;
margin-bottom: 5px;
color: $danger;
color: var(--pgn-color-danger-base);
}
.modal-error-item-title {
@@ -113,12 +113,12 @@
.modal-popup-content {
max-width: 200px;
color: $white;
background-color: $black;
color: var(--pgn-color-white);
background-color: var(--pgn-color-black);
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
font-weight: 400;
}
.pgn__modal-popup__arrow::after {
border-top-color: $black;
border-top-color: var(--pgn-color-black);
}

View File

@@ -1 +1 @@
$text-color-base: $gray-700;
$text-color-base: var(--pgn-color-gray-700);

View File

@@ -1,14 +1,14 @@
.form-group-custom {
.pgn__form-label {
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
color: $gray-500;
font: normal var(--pgn-typography-font-weight-bold) .75rem/1.25rem var(--pgn-typography-font-family-base);
color: var(--pgn-color-gray-500);
margin-bottom: .5rem;
}
.pgn__form-control-description,
.pgn__form-text {
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
color: $gray-500;
font: normal var(--pgn-typography-font-weight-normal) .75rem/1.25rem var(--pgn-typography-font-family-base);
color: var(--pgn-color-gray-500);
margin-top: .5rem;
}
@@ -19,12 +19,12 @@
.form-group-custom_isInvalid {
input {
border-color: $form-feedback-invalid-color;
border-color: var(--pgn-color-form-feedback-invalid);
}
}
.feedback-error {
color: $form-feedback-invalid-color;
color: var(--pgn-color-form-feedback-invalid);
}
}
@@ -34,40 +34,40 @@
.datepicker-custom-control {
display: block;
width: 100%;
font-size: $input-font-size;
font-weight: $input-font-weight;
line-height: $input-line-height;
background: $input-bg;
border-color: $input-border-color;
border-width: $input-border-width;
box-shadow: $input-box-shadow;
border-radius: $input-border-radius;
color: $input-color;
padding: $input-padding-y $input-padding-x;
height: $input-height;
font-size: var(--pgn-typography-form-input-font-size-base);
font-weight: var(--pgn-typography-form-input-font-weight);
line-height: var(--pgn-typography-form-input-line-height-base);
background: var(--pgn-color-form-input-bg-base);
border-color: var(--pgn-color-form-input-border);
border-width: var(--pgn-size-form-input-width-border);
box-shadow: var(--pgn-elevation-form-input-base);
border-radius: var(--pgn-size-form-input-radius-border-base);
color: var(--pgn-color-form-input-base);
padding: var(--pgn-spacing-form-input-padding-y-base) var(--pgn-spacing-form-input-padding-x-base);
height: var(--pgn-size-form-input-height-base);
resize: none;
&:focus,
:focus-visible {
color: $input-focus-color;
background-color: $input-bg;
border-color: $input-focus-border-color;
box-shadow: $input-focus-box-shadow;
color: var(--pgn-color-form-input-focus-base);
background-color: var(--pgn-color-form-input-bg-base);
border-color: var(--pgn-color-form-input-focus-border);
box-shadow: var(--pgn-elevation-form-input-focus);
outline: 0;
}
&::placeholder {
color: $input-placeholder-color;
color: var(--pgn-color-form-input-placeholder);
}
}
.datepicker-custom-control_readonly {
border-color: transparent;
background: $input-disabled-bg;
background: var(--pgn-color-form-input-bg-disabled);
}
.datepicker-custom-control_isInvalid {
border-color: $form-feedback-invalid-color;
border-color: var(--pgn-color-form-feedback-invalid);
}
.datepicker-custom-control-icon {
@@ -76,7 +76,7 @@
right: 1.188rem;
top: 50%;
transform: translateY(-50%);
color: $black;
color: var(--pgn-color-black);
}
}

View File

@@ -1,5 +1,5 @@
.text-black {
color: $black;
color: var(--pgn-color-black);
}
.h-200px {

View File

@@ -1,2 +1,2 @@
$text-color-base: $gray-700;
$text-color-base: var(--pgn-color-gray-700);
$text-color-weak: #3E3E3C;

View File

@@ -1,6 +1,5 @@
import { v4 as uuid } from 'uuid';
// eslint-disable-next-line import/prefer-default-export
export const defaultCertificate = {
courseTitle: '',
signatories: [{

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

View File

@@ -1,2 +1 @@
/* eslint-disable import/prefer-default-export */
export { default as Certificates } from './Certificates';

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line import/prefer-default-export
export const getSidebarData = ({ messages, intl }) => [
{
title: intl.formatMessage(messages.workingWithCertificatesTitle),

View File

@@ -2,7 +2,7 @@
.certificates {
.section-title {
color: $black;
color: var(--pgn-color-black);
}
.sub-header-actions {
@@ -11,7 +11,7 @@
.certificate-details {
.certificate-details__info {
color: $black;
color: var(--pgn-color-black);
justify-content: space-between;
align-items: baseline;
}
@@ -22,7 +22,7 @@
.certificate-details__info-paragraph-course-number {
flex: 1;
color: $gray-700;
color: var(--pgn-color-gray-700);
text-align: right;
}
}
@@ -74,7 +74,7 @@
}
}
@media (max-width: map-get($grid-breakpoints, "xl")) {
@media (--pgn-size-breakpoint-max-width-xl) {
.signatory {
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,5 @@
import { convertObjectToSnakeCase } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const prepareCertificatePayload = (data) => convertObjectToSnakeCase(({
...data,
courseTitle: data.courseTitle,

View File

@@ -27,6 +27,8 @@ export const NOTIFICATION_MESSAGES = {
copying: 'Copying',
pasting: 'Pasting',
discardChanges: 'Discarding changes',
moving: 'Moving',
undoMoving: 'Undo moving',
publishing: 'Publishing',
hidingFromStudents: 'Hiding from students',
makingVisibleToStudents: 'Making visible to students',
@@ -56,6 +58,8 @@ export const COURSE_BLOCK_NAMES = ({
chapter: { id: 'chapter', name: 'Section' },
sequential: { id: 'sequential', name: 'Subsection' },
vertical: { id: 'vertical', name: 'Unit' },
libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' },
});
@@ -74,3 +78,31 @@ export const REGEX_RULES = {
specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/,
noSpaceRule: /^\S*$/,
};
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *'
);
export const iframeStateKeys = {
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
};
export const iframeMessageTypes = {
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
xblockEvent: 'xblock-event',
};

View File

@@ -25,9 +25,9 @@ import TagsTree from './TagsTree';
import { ContentTagsDrawerContext } from './common/context';
/** @typedef {import("./ContentTagsCollapsible").TaxonomySelectProps} TaxonomySelectProps */
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.js").Tag} ContentTagData */
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
/**
* Custom Menu component for our Select box

View File

@@ -38,7 +38,7 @@
.add-tags-button:not([disabled]):hover {
background-color: transparent;
color: $info-900 !important;
color: var(--pgn-color-info-900) !important;
}
.react-select-add-tags__control {

View File

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

View File

@@ -6,11 +6,11 @@ import { cloneDeep } from 'lodash';
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
import { ContentTagsDrawerContext } from './common/context';
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.js").Tag} ContentTagData */
/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.mjs").UpdateTagsData} UpdateTagsData */
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.js").UpdateTagsData} UpdateTagsData */
/**
* Util function that sorts the keys of a tree in alphabetical order.

View File

@@ -16,7 +16,7 @@
.tags-drawer-cancel-button:hover {
background-color: transparent;
color: $gray-300 !important;
color: var(--pgn-color-gray-300) !important;
}
.other-description {
@@ -25,7 +25,7 @@
.enable-taxonomies-button:not([disabled]):hover {
background-color: transparent;
color: $info-900 !important;
color: var(--pgn-color-info-900) !important;
}
}

View File

@@ -1,5 +1,4 @@
import {
act,
fireEvent,
initializeMocks,
render,
@@ -18,10 +17,11 @@ import {
} from './data/api.mocks';
import { getContentTaxonomyTagsApiUrl } from './data/api';
const path = '/content/:contentId/*';
const path = '/content/:contentId?/*';
const mockOnClose = jest.fn();
const mockSetBlockingSheet = jest.fn();
const mockNavigate = jest.fn();
const mockSidebarAction = jest.fn();
mockContentTaxonomyTagsData.applyMock();
mockTaxonomyListData.applyMock();
mockTaxonomyTagsData.applyMock();
@@ -41,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}>
@@ -61,19 +66,15 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the content data query is complete', async () => {
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
it('shows spinner before the taxonomy tags query is complete', async () => {
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
it('shows the content display name after the query is complete in drawer variant', async () => {
@@ -98,15 +99,12 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
await act(async () => {
const { container } = renderDrawer(largeTagsId);
await waitFor(() => { expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(screen.getByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
expect(tagCountBadges[0].textContent).toBe('3');
expect(tagCountBadges[1].textContent).toBe('2');
});
const { container } = renderDrawer(largeTagsId);
await screen.findByText('Taxonomy 1');
await screen.findByText('Taxonomy 2');
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
expect(tagCountBadges[0].textContent).toBe('3');
expect(tagCountBadges[1].textContent).toBe('2');
});
it('should be read only on first render on drawer variant', async () => {
@@ -192,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

@@ -12,8 +12,9 @@ import classNames from 'classnames';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading';
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext';
interface TaxonomyListProps {
contentId: string;
@@ -227,7 +228,6 @@ interface ContentTagsDrawerProps {
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
* Functions to close the drawer are handled internally.
* TODO: We can delete this method when is no longer used on edx-platform.
* - If you want to use it as react component, you need to pass the content id and the close functions
* through the component parameters.
*/
@@ -245,8 +245,9 @@ const ContentTagsDrawer = ({
if (contentId === undefined) {
throw new Error('Error: contentId cannot be null.');
}
const { sidebarAction } = useSidebarContext();
const context = useContentTagsDrawerContext(contentId, !readOnly);
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {
@@ -261,6 +262,7 @@ const ContentTagsDrawer = ({
closeToast,
setCollapsibleToInitalState,
otherTaxonomies,
toEditMode,
} = context;
let onCloseDrawer: () => void;
@@ -303,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

@@ -8,46 +8,23 @@ import { extractOrgFromContentId, languageExportId } from './utils';
import messages from './messages';
import { ContentTagsDrawerSheetContext } from './common/context';
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.mjs").TagsInTaxonomy} TagsInTaxonomy */
/** @typedef {import("./data/types.js").Tag} ContentTagData */
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.js").TagsInTaxonomy} TagsInTaxonomy */
/** @typedef {import("./common/context").ContentTagsDrawerContextData} ContentTagsDrawerContextData */
/**
* Handles the context and all the underlying logic for the ContentTagsDrawer component
* Helper hook for *creating* a `ContentTagsDrawerContext`.
* Handles the context and all the underlying logic for the ContentTagsDrawer component.
*
* To *use* the context, just use `useContext(ContentTagsDrawerContext)`
* @param {string} contentId
* @param {boolean} canTagObject
* @returns {{
* stagedContentTags: Record<number, StagedTagData[]>,
* addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void,
* removeStagedContentTag: (taxonomyId: number, tagValue: string) => void,
* removeGlobalStagedContentTag: (taxonomyId: number, tagValue: string) => void,
* addRemovedContentTag: (taxonomyId: number, tagValue: string) => void,
* deleteRemovedContentTag: (taxonomyId: number, tagValue: string) => void,
* setStagedTags: (taxonomyId: number, tagsList: StagedTagData[]) => void,
* globalStagedContentTags: Record<number, StagedTagData[]>,
* globalStagedRemovedContentTags: Record<number, string>,
* setGlobalStagedContentTags: Function,
* commitGlobalStagedTags: () => void,
* commitGlobalStagedTagsStatus: string,
* isContentDataLoaded: boolean,
* isContentTaxonomyTagsLoaded: boolean,
* isTaxonomyListLoaded: boolean,
* contentName: string,
* tagsByTaxonomy: TagsInTaxonomy[],
* isEditMode: boolean,
* toEditMode: () => void,
* toReadMode: () => void,
* collapsibleStates: Record<number, boolean>,
* openCollapsible: (taxonomyId: number) => void,
* closeCollapsible: (taxonomyId: number) => void,
* toastMessage: string | undefined,
* showToastAfterSave: () => void,
* closeToast: () => void,
* setCollapsibleToInitalState: () => void,
* otherTaxonomies: TagsInTaxonomy[],
* }}
* @param {boolean} fetchMetadata=false If true, fetches metadata for the contentId. This is used on `edx-platform`
* and the Course/Unit Outline to show the content name as the drawer title.
* @returns {ContentTagsDrawerContextData}
*/
const useContentTagsDrawerContext = (contentId, canTagObject) => {
export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetchMetadata = false) => {
const intl = useIntl();
const org = extractOrgFromContentId(contentId);
@@ -73,7 +50,7 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
const updateTags = useContentTaxonomyTagsUpdater(contentId);
// Fetch from database
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId, fetchMetadata);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
@@ -465,5 +442,3 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
otherTaxonomies,
};
};
export default useContentTagsDrawerContext;

View File

@@ -7,7 +7,7 @@
&:hover {
background-color: transparent;
color: $info-900 !important;
color: var(--pgn-color-info-900) !important;
}
}
@@ -19,7 +19,8 @@
// In the future, this customizability should be implemented in paragon instead
input.pgn__form-checkbox-input {
&:indeterminate {
@extend :checked; /* stylelint-disable-line scss/at-extend-no-missing-placeholder */
border-color: var(--pgn-color-form-control-indicator-checked-border-base);
background-image: var(--pgn-other-content-form-control-checkbox-indicator-icon-checked-base);
}
}
}
@@ -34,6 +35,6 @@
}
.dropdown-selector-tag-actions:focus-visible {
outline: solid 2px $info-900;
outline: solid 2px var(--pgn-color-info-900);
border-radius: 4px;
}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
waitFor,
fireEvent,
@@ -74,11 +73,9 @@ describe('<ContentTagsDropDownSelector />', () => {
}
it('should render taxonomy tags drop down selector loading with spinner', async () => {
await act(async () => {
const { getByRole } = await getComponent();
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
const { getByRole } = await getComponent();
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
@@ -99,13 +96,11 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
await act(async () => {
const { container, getByText } = await getComponent();
const { container, getByText } = await getComponent();
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
});
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
});
});
@@ -127,13 +122,11 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
await act(async () => {
const { container, getByText } = await getComponent();
const { container, getByText } = await getComponent();
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
});
@@ -155,47 +148,45 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
};
const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
},
};
const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.click(expandToggle);
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.click(expandToggle);
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
@@ -219,48 +210,46 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const initalSearchTerm = 'test 1';
await act(async () => {
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
const updatedSearchTerm = 'test 2';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
const updatedSearchTerm = 'test 2';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
});
it('should render "noTag" message if search doesnt return taxonomies', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
@@ -271,20 +260,18 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const searchTerm = 'uncommon search term';
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
});
it('should render "noTagsInTaxonomy" message if taxonomy is empty', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
@@ -295,15 +282,13 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const searchTerm = '';
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = 'No tags in this taxonomy yet';
expect(getByText(message)).toBeInTheDocument();
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = 'No tags in this taxonomy yet';
expect(getByText(message)).toBeInTheDocument();
});
});

View File

@@ -9,13 +9,13 @@
&:hover {
svg {
color: $gray-900;
color: var(--pgn-color-gray-900);
}
}
&:focus-visible {
border: 2px solid;
border-color: $gray-900;
border-color: var(--pgn-color-gray-900);
}
}
}

View File

@@ -1,49 +0,0 @@
// @ts-check
/* eslint-disable import/prefer-default-export */
import React from 'react';
/** @typedef {import("../data/types.mjs").TagsInTaxonomy} TagsInTaxonomy */
/** @typedef {import("../data/types.mjs").StagedTagData} StagedTagData */
/* istanbul ignore next */
export const ContentTagsDrawerContext = React.createContext({
stagedContentTags: /** @type{Record<number, StagedTagData[]>} */ ({}),
globalStagedContentTags: /** @type{Record<number, StagedTagData[]>} */ ({}),
globalStagedRemovedContentTags: /** @type{Record<number, string>} */ ({}),
addStagedContentTag: /** @type{(taxonomyId: number, addedTag: StagedTagData) => void} */ (() => {}),
removeStagedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
removeGlobalStagedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
addRemovedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
deleteRemovedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
setStagedTags: /** @type{(taxonomyId: number, tagsList: StagedTagData[]) => void} */ (() => {}),
setGlobalStagedContentTags: /** @type{Function} */ (() => {}),
commitGlobalStagedTags: /** @type{() => void} */ (() => {}),
commitGlobalStagedTagsStatus: /** @type{null|string} */ (null),
isContentDataLoaded: /** @type{boolean} */ (false),
isContentTaxonomyTagsLoaded: /** @type{boolean} */ (false),
isTaxonomyListLoaded: /** @type{boolean} */ (false),
contentName: /** @type{string} */ (''),
tagsByTaxonomy: /** @type{TagsInTaxonomy[]} */ ([]),
isEditMode: /** @type{boolean} */ (false),
toEditMode: /** @type{() => void} */ (() => {}),
toReadMode: /** @type{() => void} */ (() => {}),
collapsibleStates: /** @type{Record<number, boolean>} */ ({}),
openCollapsible: /** @type{(taxonomyId: number) => void} */ (() => {}),
closeCollapsible: /** @type{(taxonomyId: number) => void} */ (() => {}),
toastMessage: /** @type{string|undefined} */ (undefined),
showToastAfterSave: /** @type{() => void} */ (() => {}),
closeToast: /** @type{() => void} */ (() => {}),
setCollapsibleToInitalState: /** @type{() => void} */ (() => {}),
otherTaxonomies: /** @type{TagsInTaxonomy[]} */ ([]),
});
// This context has not been added to ContentTagsDrawerContext because it has been
// created one level higher to control the behavior of the Sheet that contatins the Drawer.
// This logic is not used in legacy edx-platform screens. But it can be separated if we keep
// the contexts separate.
// TODO We can join both contexts when the Drawer is no longer used on edx-platform
/* istanbul ignore next */
export const ContentTagsDrawerSheetContext = React.createContext({
blockingSheet: /** @type{boolean} */ (false),
setBlockingSheet: /** @type{Function} */ (() => {}),
});

View File

@@ -0,0 +1,77 @@
import React from 'react';
import type { TagsInTaxonomy, StagedTagData } from '../data/types';
export interface ContentTagsDrawerContextData {
stagedContentTags: Record<number, StagedTagData[]>;
globalStagedContentTags: Record<number, StagedTagData[]>;
globalStagedRemovedContentTags: Record<number, string>;
addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void;
removeStagedContentTag: (taxonomyId: number, tagValue: string) => void;
removeGlobalStagedContentTag: (taxonomyId: number, tagValue: string) => void;
addRemovedContentTag: (taxonomyId: number, tagValue: string) => void;
deleteRemovedContentTag: (taxonomyId: number, tagValue: string) => void;
setStagedTags: (taxonomyId: number, tagsList: StagedTagData[]) => void;
setGlobalStagedContentTags: Function;
commitGlobalStagedTags: () => void;
commitGlobalStagedTagsStatus: null | string;
isContentDataLoaded: boolean;
isContentTaxonomyTagsLoaded: boolean;
isTaxonomyListLoaded: boolean;
contentName: string;
tagsByTaxonomy: TagsInTaxonomy[];
isEditMode: boolean;
toEditMode: () => void;
toReadMode: () => void;
collapsibleStates: Record<number, boolean>;
openCollapsible: (taxonomyId: number) => void;
closeCollapsible: (taxonomyId: number) => void;
toastMessage: string | undefined;
showToastAfterSave: () => void;
closeToast: () => void;
setCollapsibleToInitalState: () => void;
otherTaxonomies: TagsInTaxonomy[];
}
/* istanbul ignore next */
export const ContentTagsDrawerContext = React.createContext<ContentTagsDrawerContextData>({
stagedContentTags: {},
globalStagedContentTags: {},
globalStagedRemovedContentTags: {},
addStagedContentTag: () => {},
removeStagedContentTag: () => {},
removeGlobalStagedContentTag: () => {},
addRemovedContentTag: () => {},
deleteRemovedContentTag: () => {},
setStagedTags: () => {},
setGlobalStagedContentTags: () => {},
commitGlobalStagedTags: () => {},
commitGlobalStagedTagsStatus: null,
isContentDataLoaded: false,
isContentTaxonomyTagsLoaded: false,
isTaxonomyListLoaded: false,
contentName: '',
tagsByTaxonomy: [],
isEditMode: false,
toEditMode: () => {},
toReadMode: () => {},
collapsibleStates: {},
openCollapsible: () => {},
closeCollapsible: () => {},
toastMessage: undefined,
showToastAfterSave: () => {},
closeToast: () => {},
setCollapsibleToInitalState: () => {},
otherTaxonomies: [],
});
// This context has not been added to ContentTagsDrawerContext because it has been
// created one level higher to control the behavior of the Sheet that contatins the Drawer.
// This logic is not used in legacy edx-platform screens. But it can be separated if we keep
// the contexts separate.
// TODO We can join both contexts when the Drawer is no longer used on edx-platform
/* istanbul ignore next */
export const ContentTagsDrawerSheetContext = React.createContext({
blockingSheet: false,
setBlockingSheet: (() => {}) as (blockingSheet: boolean) => void,
});

View File

@@ -38,7 +38,7 @@ export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/con
* Get all tags that belong to taxonomy.
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
* @returns {Promise<import("../../taxonomy/data/types.js").TagListData>}
*/
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
@@ -49,7 +49,7 @@ export async function getTaxonomyTagsData(taxonomyId, options = {}) {
/**
* Get the tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
*/
export async function getContentTaxonomyTagsData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
@@ -70,17 +70,12 @@ export async function getContentTaxonomyTagsCount(contentId) {
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* Fetch meta data (eg: display_name) about the content object (unit/component)
* @param {string} contentId The id of the content object (unit/component)
* @returns {Promise<import("./types.mjs").ContentData | null>}
* @returns {Promise<import("./types.js").ContentData>}
*/
export async function getContentData(contentId) {
let url;
if (contentId.startsWith('lib-collection:')) {
// This type of usage_key is not used to obtain collections
// is only used in tagging.
return null;
}
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
@@ -96,8 +91,8 @@ export async function getContentData(contentId) {
/**
* Update content object's applied tags
* @param {string} contentId The id of the content object (unit/component)
* @param {Promise<import("./types.mjs").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
* @param {Promise<import("./types.js").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
*/
export async function updateContentTaxonomyTags(contentId, tagsData) {
const url = getContentTaxonomyTagsApiUrl(contentId);

View File

@@ -13,6 +13,7 @@ export async function mockContentTaxonomyTagsData(contentId: string): Promise<an
case thisMock.languageWithTagsId: return thisMock.languageWithTags;
case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags;
case thisMock.largeTagsId: return thisMock.largeTags;
case thisMock.containerTagsId: return thisMock.largeTags;
case thisMock.emptyTagsId: return thisMock.emptyTags;
default: throw new Error(`No mock has been set up for contentId "${contentId}"`);
}
@@ -204,6 +205,7 @@ mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+typ
mockContentTaxonomyTagsData.emptyTags = {
taxonomies: [],
};
mockContentTaxonomyTagsData.containerTagsId = 'lct:org:lib:unit:container_tags';
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);
/**

View File

@@ -7,6 +7,7 @@ import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useParams } from 'react-router';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
@@ -14,11 +15,11 @@ 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/tag-list/data/types.mjs").TagListData} TagListData */
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
/**
* Builds the query to get the taxonomy tags
@@ -112,11 +113,13 @@ export const useContentTaxonomyTagsData = (contentId) => (
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
* @param {boolean} enabled Flag to enable/disable the query
*/
export const useContentData = (contentId) => (
export const useContentData = (contentId, enabled) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled,
})
);
@@ -126,6 +129,8 @@ export const useContentData = (contentId) => (
*/
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe'];
const { unitId } = useParams();
return useMutation({
/**
@@ -133,7 +138,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
* any,
* any,
* {
* tagsData: Promise<import("./types.mjs").UpdateTagsData[]>
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
* }
* >}
*/
@@ -148,19 +153,24 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:')) {
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:') || contentId.startsWith('lct:')) {
// Obtain library id from contentId
const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count
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 */ () => {
/* istanbul ignore next */
if (window.top != null) {
// This send messages to the parent page if the drawer is called from a iframe.
// Sends messages to the parent page if the drawer was opened
// from an iframe or the unit iframe within the course.
// Is used on Studio to update tags data and counts.
// In the future, when the Course Outline Page and Unit Page are integrated into this MFE,
// they should just use React Query to load the tag counts, and React Query will automatically
@@ -169,26 +179,32 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
// Sends content tags.
getContentTaxonomyTagsData(contentId).then((data) => {
const contentData = {
contentId,
...data,
const contentData = { contentId, ...data };
const message = {
type: 'authoring.events.tags.updated',
data: contentData,
};
window.top?.postMessage(
{ type: 'authoring.events.tags.updated', data: contentData },
getConfig().STUDIO_BASE_URL,
);
const targetOrigin = getConfig().STUDIO_BASE_URL;
unitIframe?.postMessage(message, targetOrigin);
window.top?.postMessage(message, targetOrigin);
});
// Sends tags count.
getContentTaxonomyTagsCount(contentId).then((data) => {
const contentData = {
contentId,
count: data,
getContentTaxonomyTagsCount(contentId).then((count) => {
const contentData = { contentId, count };
const message = {
type: 'authoring.events.tags.count.updated',
data: contentData,
};
window.top?.postMessage(
{ type: 'authoring.events.tags.count.updated', data: contentData },
getConfig().STUDIO_BASE_URL,
);
const targetOrigin = getConfig().STUDIO_BASE_URL;
unitIframe?.postMessage(message, targetOrigin);
window.top?.postMessage(message, targetOrigin);
});
}
},

View File

@@ -1,6 +1,5 @@
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react';
import {
useTaxonomyTagsData,
useContentTaxonomyTagsData,
@@ -158,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,101 +0,0 @@
// @ts-check
/**
* @typedef {Object} Tag A tag that has been applied to some content.
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
* @property {boolean} canChangeObjecttag
* @property {boolean} canDeleteObjecttag
*/
/**
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
* @property {string} name
* @property {number} taxonomyId
* @property {boolean} canTagObject
* @property {Tag[]} tags
* @property {string} exportId
*/
/**
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
* @property {ContentTaxonomyTagData[]} taxonomies
*/
/**
* @typedef {Object} ContentActions
* @property {boolean} deleteable
* @property {boolean} draggable
* @property {boolean} childAddable
* @property {boolean} duplicable
*/
/**
* @typedef {Object} XBlockData
* @property {string} id
* @property {string} displayName
* @property {string} category
* @property {boolean} hasChildren
* @property {string} editedOn
* @property {boolean} published
* @property {string} publishedOn
* @property {string} studioUrl
* @property {boolean} releasedToStudents
* @property {string|null} releaseDate
* @property {string} visibilityState
* @property {boolean} hasExplicitStaffLock
* @property {string} start
* @property {boolean} graded
* @property {string} dueDate
* @property {string} due
* @property {string|null} relativeWeeksDue
* @property {string|null} format
* @property {boolean} hasChanges
* @property {ContentActions} actions
* @property {string} explanatoryMessage
* @property {string} showCorrectness
* @property {boolean} discussionEnabled
* @property {boolean} ancestorHasStaffLock
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
*/
/**
* @typedef {Object} TagsInTaxonomy
* @property {boolean} allOrgs
* @property {boolean} allowFreeText
* @property {boolean} allowMultiple
* @property {boolean} canChangeTaxonomy
* @property {boolean} canDeleteTaxonomy
* @property {boolean} canTagObject
* @property {Tag[]} contentTags
* @property {string} description
* @property {boolean} enabled
* @property {string} exportId
* @property {number} id
* @property {string} name
* @property {boolean} systemDefined
* @property {number} tagsCount
* @property {boolean} visibleToAuthors
*/
/**
* @typedef {Object} CourseData
* @property {string} courseDisplayNameWithDefault
*/
/**
* @typedef {XBlockData | CourseData} ContentData
*/
/**
* @typedef {Object} UpdateTagsData
* @property {number} taxonomy
* @property {string[]} tags
*/
/**
* @typedef {Object} StagedTagData
* @property {string} value
* @property {string} label
*/

View File

@@ -0,0 +1,81 @@
import type { TaxonomyData } from '../../taxonomy/data/types';
/** A tag that has been applied to some content. */
export interface Tag {
/** The value of the tag, also its ID. e.g. "Biology" */
value: string;
/** The values of the tag and its parent(s) in the hierarchy */
lineage: string[];
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
}
/** A list of the tags from one taxonomy that are applied to a content object. */
export interface ContentTaxonomyTagData {
name: string;
taxonomyId: number;
canTagObject: boolean;
tags: Tag[];
exportId: string;
}
/** A list of all the tags applied to some content object, grouped by taxonomy. */
export interface ContentTaxonomyTagsData {
taxonomies: ContentTaxonomyTagData[];
}
export interface ContentActions {
deleteable: boolean;
draggable: boolean;
childAddable: boolean;
duplicable: boolean;
}
export interface XBlockData {
id: string;
displayName: string;
category: string;
hasChildren: boolean;
editedOn: string;
published: boolean;
publishedOn: string;
studioUrl: string;
releasedToStudents: boolean;
releaseDate: string | null;
visibilityState: string;
hasExplicitStaffLock: boolean;
start: string;
graded: boolean;
dueDate: string;
due: string;
relativeWeeksDue: string | null;
format: string | null;
hasChanges: boolean;
actions: ContentActions;
explanatoryMessage: string;
showCorrectness: string;
discussionEnabled: boolean;
ancestorHasStaffLock: boolean;
staffOnlyMessage: boolean;
hasPartitionGroupComponents: boolean;
}
export interface TagsInTaxonomy extends TaxonomyData {
contentTags: Tag[];
}
export interface CourseData {
courseDisplayNameWithDefault: string;
}
export type ContentData = XBlockData | CourseData;
export interface UpdateTagsData {
taxonomy: number;
tags: string[];
}
export interface StagedTagData {
value: string;
label: string;
}

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawerSheet } from './ContentTagsDrawerSheet';
export { useContentTaxonomyTagsData } from './data/apiHooks';

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

@@ -1,3 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
export const languageExportId = 'languages-v1';

View File

@@ -0,0 +1,2 @@
export const extractOrgFromContentId = (contentId: string): string => contentId.split('+')[0].split(':')[1];
export const languageExportId = 'languages-v1';

View File

@@ -1,70 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Hyperlink,
Icon,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button, Icon } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const getUpdateLinks = (courseId, waffleFlags) => {
const baseUrl = getConfig().STUDIO_BASE_URL;
const isLegacyGradingUrl = !waffleFlags.useNewGradingPage;
const isLegacyCertificateUrl = !waffleFlags.useNewCertificatesPage;
const isLegacyCourseDatesUrl = !waffleFlags.useNewScheduleDetailsPage;
const isLegacyOutlineUrl = !waffleFlags.useNewCourseOutlinePage;
return {
welcomeMessage: `/course/${courseId}/course_info`,
gradingPolicy: isLegacyGradingUrl
? `${baseUrl}/settings/grading/${courseId}` : `/course/${courseId}/settings/grading`,
certificate: isLegacyCertificateUrl
? `${baseUrl}/certificates/${courseId}` : `/course/${courseId}/certificates`,
courseDates: isLegacyCourseDatesUrl
? `${baseUrl}/settings/details/${courseId}#schedule` : `/course/${courseId}/settings/details/#schedule`,
proctoringEmail: `${baseUrl}/pages-and-resources/proctoring/settings`,
outline: isLegacyOutlineUrl ? `${baseUrl}/course/${courseId}` : `/course/${courseId}`,
};
};
const ChecklistItemBody = ({
courseId,
checkId,
isCompleted,
updateLink,
// injected
intl,
}) => (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</div>
<div>
}) => {
const intl = useIntl();
const waffleFlags = useSelector(getWaffleFlags);
const updateLinks = getUpdateLinks(courseId, waffleFlags);
return (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</div>
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<ActionRow.Spacer />
{updateLink && (
<Hyperlink destination={updateLink} data-testid="update-hyperlink">
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Hyperlink>
)}
</ActionRow>
);
<ActionRow.Spacer />
{updateLinks?.[checkId] && (
<Link
to={updateLinks[checkId]}
data-testid="update-link"
>
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Link>
)}
</ActionRow>
);
};
ChecklistItemBody.defaultProps = {
updateLink: null,
};
ChecklistItemBody.propTypes = {
courseId: PropTypes.string.isRequired,
checkId: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
updateLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(ChecklistItemBody);
export default ChecklistItemBody;

View File

@@ -1,15 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { Hyperlink, Icon } from '@openedx/paragon';
import { Icon } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { ModeComment } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const ChecklistItemComment = ({
courseId,
checkId,
outlineUrl,
data,
}) => {
const waffleFlags = useSelector(getWaffleFlags);
const getPathToCourseOutlinePage = (assignmentId) => (waffleFlags.useNewCourseOutlinePage
? `/course/${courseId}#${assignmentId}` : `${getConfig().STUDIO_BASE_URL}/course/${courseId}#${assignmentId}`);
const commentWrapper = (comment) => (
<div className="row m-0 mt-3 pt-3 border-top align-items-center" data-identifier="comment">
<div className="mr-4">
@@ -79,9 +87,9 @@ const ChecklistItemComment = ({
<ul className="assignment-list">
{gradedAssignmentsOutsideDateRange.map(assignment => (
<li className="assignment-list-item" key={assignment.id}>
<Hyperlink destination={`${outlineUrl}#${assignment.id}`}>
<Link to={getPathToCourseOutlinePage(assignment.id)}>
{assignment.displayName}
</Hyperlink>
</Link>
</li>
))}
</ul>
@@ -96,6 +104,7 @@ const ChecklistItemComment = ({
};
ChecklistItemComment.propTypes = {
courseId: PropTypes.string.isRequired,
checkId: PropTypes.string.isRequired,
outlineUrl: PropTypes.string.isRequired,
data: PropTypes.oneOfType([

View File

@@ -10,11 +10,11 @@ import ChecklistItemComment from './ChecklistItemComment';
import { checklistItems } from './utils/courseChecklistData';
const ChecklistSection = ({
courseId,
dataHeading,
data,
idPrefix,
isLoading,
updateLinks,
}) => {
const dataList = checklistItems[idPrefix];
const getCompletionCountID = () => (`${idPrefix}-completion-count`);
@@ -37,8 +37,6 @@ const ChecklistSection = ({
{checks.map(check => {
const checkId = check.id;
const isCompleted = values[checkId];
const updateLink = updateLinks?.[checkId];
const outlineUrl = updateLinks.outline;
return (
<div
className={`bg-white border py-3 px-4 ${isCompleted && 'checklist-item-complete'}`}
@@ -46,9 +44,9 @@ const ChecklistSection = ({
data-testid={`checklist-item-${checkId}`}
key={checkId}
>
<ChecklistItemBody {...{ checkId, isCompleted, updateLink }} />
<ChecklistItemBody courseId={courseId} {...{ checkId, isCompleted }} />
<div data-testid={`comment-section-${checkId}`}>
<ChecklistItemComment {...{ checkId, outlineUrl, data }} />
<ChecklistItemComment {...{ courseId, checkId, data }} />
</div>
</div>
);
@@ -61,11 +59,11 @@ const ChecklistSection = ({
};
ChecklistSection.defaultProps = {
updateLinks: {},
data: {},
};
ChecklistSection.propTypes = {
courseId: PropTypes.string.isRequired,
dataHeading: PropTypes.string.isRequired,
data: PropTypes.oneOfType([
PropTypes.shape({
@@ -129,14 +127,6 @@ ChecklistSection.propTypes = {
]),
idPrefix: PropTypes.string.isRequired,
isLoading: PropTypes.bool.isRequired,
updateLinks: PropTypes.shape({
welcomeMessage: PropTypes.string,
gradingPolicy: PropTypes.string,
certificate: PropTypes.string,
courseDates: PropTypes.string,
proctoringEmail: PropTypes.string,
outline: PropTypes.string,
}),
};
export default injectIntl(ChecklistSection);

View File

@@ -13,10 +13,10 @@
.assignment-list {
display: inline;
padding-inline-start: map-get($spacers, 1);
padding-inline-start: var(--pgn-spacing-spacer-1);
}
//complete checklist item style
.checklist-item-complete {
box-shadow: -5px 0 0 0 $success-500;
box-shadow: -5px 0 0 0 var(--pgn-color-success-500);
}

View File

@@ -1,59 +1,49 @@
/* eslint-disable */
import {
render,
within,
screen,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { camelCaseObject } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses';
import messages from './messages';
import ChecklistSection from './index';
import {
initializeMocks, render, screen, within,
} from '../../testUtils';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { generateCourseLaunchData } from '../factories/mockApiResponses';
import { executeThunk } from '../../utils';
import { checklistItems } from './utils/courseChecklistData';
import getUpdateLinks from '../utils';
import messages from './messages';
import ChecklistSection from '.';
const testData = camelCaseObject(generateCourseLaunchData());
const courseId = '123';
const defaultProps = {
courseId,
data: testData,
dataHeading: 'Test checklist',
idPrefix: 'launchChecklist',
updateLinks: getUpdateLinks('courseId'),
isLoading: false,
};
const testChecklistData = checklistItems[defaultProps.idPrefix];
const completedItemIds = ['welcomeMessage', 'courseDates']
const completedItemIds = ['welcomeMessage', 'courseDates'];
const renderComponent = (props) => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ChecklistSection {...props} />
</AppProvider>
</IntlProvider>,
);
render(<ChecklistSection {...props} />);
};
let store;
describe('ChecklistSection', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
beforeEach(async () => {
const { axiosMock, reduxStore } = initializeMocks();
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {
useNewGradingPage: true,
useNewCertificatesPage: true,
useNewScheduleDetailsPage: true,
useNewCourseOutlinePage: true,
});
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
});
it('a heading using the dataHeading prop', () => {
@@ -64,6 +54,7 @@ describe('ChecklistSection', () => {
it('completion count text', () => {
renderComponent(defaultProps);
const completionText = `${completedItemIds.length}/6 completed`;
expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText);
});
@@ -122,7 +113,7 @@ describe('ChecklistSection', () => {
grades: {
...defaultProps.data.grades,
sumOfWeights: 1,
}
},
},
};
renderComponent(props);
@@ -154,7 +145,7 @@ describe('ChecklistSection', () => {
...defaultProps.data.assignments,
assignmentsWithDatesAfterEnd: [],
assignmentsWithOraDatesBeforeStart: [],
}
},
},
};
renderComponent(props);
@@ -183,73 +174,52 @@ describe('ChecklistSection', () => {
expect(assigmentLinks[1].textContent).toEqual('ORA subsection');
});
});
});
testChecklistData.forEach((check) => {
describe(`check with id '${check.id}'`, () => {
let checkItem;
describe('Checklist Component', () => {
let checklistData;
let updateLinks;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
renderComponent(defaultProps);
checkItem = screen.getAllByTestId(`checklist-item-${check.id}`);
checklistData = testChecklistData.map((item) => ({
itemId: item.id,
checklistItem: screen.getAllByTestId(`checklist-item-${item.id}`),
icon: screen.getAllByTestId(`icon-${item.id}`),
shortDescription: messages[`${item.id}ShortDescription`].defaultMessage,
longDescription: messages[`${item.id}LongDescription`].defaultMessage,
}));
updateLinks = screen.getAllByTestId('update-link');
});
it('renders', () => {
expect(checkItem).toHaveLength(1);
it('should display the correct icons based on completion status', () => {
checklistData.forEach(({ itemId, icon }) => {
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(itemId)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
});
});
it('has correct icon', () => {
const icon = screen.getAllByTestId(`icon-${check.id}`)
it('should display short and long descriptions for each checklist item', () => {
checklistData.forEach(({ checklistItem, shortDescription, longDescription }) => {
const { getByText } = within(checklistItem[0]);
expect(icon).toHaveLength(1);
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(check.id)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
expect(getByText(shortDescription)).toBeVisible();
expect(getByText(longDescription)).toBeVisible();
});
});
it('has correct short description', () => {
const { getByText } = within(checkItem[0]);
const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage;
expect(getByText(shortDescription)).toBeVisible();
});
it('has correct long description', () => {
const { getByText } = within(checkItem[0]);
const longDescription = messages[`${check.id}LongDescription`].defaultMessage;
expect(getByText(longDescription)).toBeVisible();
});
describe('has correct link', () => {
const links = getUpdateLinks('courseId')
const shouldShowLink = Object.keys(links).includes(check.id);
if (shouldShowLink) {
it('with a Hyperlink', () => {
const { getByRole, getByText } = within(checkItem[0]);
expect(getByText('Update')).toBeVisible();
expect(getByRole('link').href).toMatch(links[check.id]);
it('should have valid update links for each checklist item', () => {
checklistData.forEach(({ itemId }) => {
updateLinks.forEach((link) => {
expect(link).toHaveAttribute('href', updateLinks[itemId]);
});
} else {
it('without a Hyperlink', () => {
const { queryByText } = within(checkItem[0]);
expect(queryByText('Update')).toBeNull();
});
}
});
});
});
});

View File

@@ -13,7 +13,7 @@ import AriaLiveRegion from './AriaLiveRegion';
import { RequestStatus } from '../data/constants';
import ChecklistSection from './ChecklistSection';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import getUpdateLinks from './utils';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
const CourseChecklist = ({
courseId,
@@ -23,7 +23,6 @@ const CourseChecklist = ({
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
const updateLinks = getUpdateLinks(courseId);
useEffect(() => {
dispatch(fetchCourseLaunchQuery({ courseId }));
@@ -36,10 +35,19 @@ const CourseChecklist = ({
bestPracticeData,
} = useSelector(state => state.courseChecklist);
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus } = loadingStatus;
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
if (isLoadingDenied) {
return (
<Container size="xl" className="course-unit px-4 mt-4">
<ConnectionErrorAlert />
</Container>
);
}
return (
<>
@@ -66,19 +74,19 @@ const CourseChecklist = ({
/>
<Stack gap={4}>
<ChecklistSection
courseId={courseId}
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isCourseLaunchChecklistLoading}
updateLinks={updateLinks}
/>
{enableQuality && (
<ChecklistSection
courseId={courseId}
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isCourseBestPracticeChecklistLoading}
updateLinks={updateLinks}
/>
)}
</Stack>

View File

@@ -149,5 +149,20 @@ describe('CourseChecklistPage', () => {
});
});
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
const courseLaunchApiUrl = getCourseLaunchApiUrl({
courseId, gradedOnly: true, validateOras: true, all: true,
});
axiosMock.onGet(courseLaunchApiUrl).reply(403);
renderComponent();
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
});

View File

@@ -24,7 +24,11 @@ export function fetchCourseLaunchQuery({
dispatch(fetchLaunchChecklistSuccess({ data }));
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
if (error.response && error.response.status === 403) {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
}
}
};
}

View File

@@ -1,12 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
const getUpdateLinks = (courseId) => ({
welcomeMessage: `${getConfig().STUDIO_BASE_URL}/course_info/${courseId}`,
gradingPolicy: `${getConfig().STUDIO_BASE_URL}/settings/grading/${courseId}`,
certificate: `${getConfig().STUDIO_BASE_URL}/certificates/${courseId}`,
courseDates: `${getConfig().STUDIO_BASE_URL}/settings/details/${courseId}#schedule`,
proctoringEmail: 'pages-and-resources/proctoring/settings',
outline: `${getConfig().STUDIO_BASE_URL}/course/${courseId}`,
});
export default getUpdateLinks;

View File

@@ -0,0 +1,284 @@
import fetchMock from 'fetch-mock-jest';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { QueryClient } from '@tanstack/react-query';
import {
initializeMocks,
render,
screen,
waitFor,
within,
} from '../testUtils';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
mockGetEntityLinksSummaryByDownstreamContext,
mockFetchIndexDocuments,
mockUseLibBlockMetadata,
} from './data/api.mocks';
import { libraryBlockChangesUrl } from '../course-unit/data/api';
import { type ToastActionData } from '../generic/toast-context';
mockContentSearchConfig.applyMock();
mockGetEntityLinks.applyMock();
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
mockUseLibBlockMetadata.applyMock();
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
isLoadingPage: false,
isFailedLoadingPage: false,
librariesV2Enabled: true,
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useSearchParams: () => [{
get: searchParamsGetMock,
getAll: () => [],
}],
}));
describe('<CourseLibraries />', () => {
beforeEach(() => {
initializeMocks();
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
});
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state when no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKeyEmpty);
const emptyMsg = await screen.findByText('This course does not use any content from libraries.');
expect(emptyMsg).toBeInTheDocument();
});
it('shows alert when out of sync components are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
expect(allTab).toHaveAttribute('aria-selected', 'true');
const reviewBtn = await screen.findByRole('button', { name: 'Review' });
userEvent.click(reviewBtn);
expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
});
it('hide alert on dismiss', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
userEvent.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
userEvent.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true');
waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
});
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.findByRole('alert');
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.queryByRole('alert')).not.toBeInTheDocument();
});
});
describe('<CourseLibraries ReviewTab />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
});
const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state when no readyToSync links are present', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate);
const emptyMsg = await screen.findByText('All components are up to date');
expect(emptyMsg).toBeInTheDocument();
});
it('shows all readyToSync links', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
});
it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
userEvent.click(updateBtns[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const dialog = await screen.findByRole('dialog');
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
// Show confirmation modal on clicking ignore.
userEvent.click(ignoreBtns[0]);
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const previewDialog = await screen.findByRole('dialog');
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
userEvent.click(ignoreBtn);
// Show confirmation modal on clicking ignore.
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
});

View File

@@ -0,0 +1,247 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import { Helmet } from 'react-helmet';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
ActionRow,
Button,
Card,
Container,
Hyperlink,
Icon,
Stack,
Tab,
Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, Launch, Loop,
} from '@openedx/paragon/icons';
import sumBy from 'lodash/sumBy';
import { useSearchParams } from 'react-router-dom';
import getPageHeadTitle from '../generic/utils';
import { useModel } from '../generic/model-store';
import messages from './messages';
import SubHeader from '../generic/sub-header/SubHeader';
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
import type { PublishableEntityLinkSummary } from './data/api';
import Loading from '../generic/Loading';
import { useStudioHome } from '../studio-home/hooks';
import NewsstandIcon from '../generic/NewsstandIcon';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
interface Props {
courseId: string;
}
interface LibraryCardProps {
linkSummary: PublishableEntityLinkSummary;
}
export enum CourseLibraryTabs {
all = 'all',
review = 'review',
}
const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
const intl = useIntl();
return (
<Card className="my-3 border-light-500 border shadow-none">
<Card.Header
title={(
<Stack direction="horizontal" gap={2}>
<Icon src={NewsstandIcon} />
{linkSummary.upstreamContextTitle}
</Stack>
)}
actions={(
<ActionRow>
<Button
destination={`${getConfig().PUBLIC_PATH}library/${linkSummary.upstreamContextKey}`}
target="_blank"
className="border border-light-300"
variant="tertiary"
as={Hyperlink}
size="sm"
showLaunchIcon={false}
iconAfter={Launch}
>
View Library
</Button>
</ActionRow>
)}
size="sm"
/>
<Card.Section>
<Stack
direction="horizontal"
gap={4}
className="x-small"
>
<span>
{intl.formatMessage(messages.totalComponentLabel, { totalComponents: linkSummary.totalCount })}
</span>
{linkSummary.readyToSyncCount > 0 && (
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} size="xs" />
<span>
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount: linkSummary.readyToSyncCount })}
</span>
</Stack>
)}
</Stack>
</Card.Section>
</Card>
);
};
export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const [searchParams] = useSearchParams();
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(
() => searchParams.get('tab') as CourseLibraryTabs,
);
const [showReviewAlert, setShowReviewAlert] = useState(false);
const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = useMemo(() => sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
const {
isLoadingPage: isLoadingStudioHome,
isFailedLoadingPage: isFailedLoadingStudioHome,
librariesV2Enabled,
} = useStudioHome();
const onAlertReview = () => {
setTabKey(CourseLibraryTabs.review);
};
const tabChange = useCallback((selectedTab: CourseLibraryTabs) => {
setTabKey(selectedTab);
}, []);
useEffect(() => {
setTabKey((prev) => {
if (outOfSyncCount > 0) {
return CourseLibraryTabs.review;
}
if (prev) {
return prev;
}
/* istanbul ignore next */
return CourseLibraryTabs.all;
});
}, [outOfSyncCount]);
const renderLibrariesTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (libraries?.length === 0) {
return <small><FormattedMessage {...messages.homeTabDescriptionEmpty} /></small>;
}
return (
<>
<small><FormattedMessage {...messages.homeTabDescription} /></small>
{libraries?.map((library) => (
<LibraryCard
linkSummary={library}
key={library.upstreamContextKey}
/>
))}
</>
);
}, [libraries, isLoading]);
const renderReviewTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (tabKey !== CourseLibraryTabs.review) {
return null;
}
if (!outOfSyncCount) {
return (
<Stack direction="horizontal" gap={2}>
<Icon src={CheckCircle} size="xs" />
<small>
<FormattedMessage {...messages.reviewTabDescriptionEmpty} />
</small>
</Stack>
);
}
return <ReviewTabContent courseId={courseId} />;
}, [outOfSyncCount, isLoading, tabKey]);
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
return (
<Alert variant="danger">
{intl.formatMessage(messages.librariesV2DisabledError)}
</Alert>
);
}
return (
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4 mt-3">
<OutOfSyncAlert
courseId={courseId}
onReview={onAlertReview}
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
setShowAlert={setShowReviewAlert}
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && (
<Button
variant="primary"
onClick={onAlertReview}
iconBefore={Cached}
>
{intl.formatMessage(messages.reviewUpdatesBtn)}
</Button>
)}
hideBorder
/>
<section className="mb-4">
<Tabs
id="course-library-tabs"
activeKey={tabKey}
onSelect={tabChange}
>
<Tab
eventKey={CourseLibraryTabs.all}
title={intl.formatMessage(messages.homeTabTitle)}
className="px-2 mt-3"
>
{renderLibrariesTabContent()}
</Tab>
<Tab
eventKey={CourseLibraryTabs.review}
title={(
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} />
{intl.formatMessage(messages.reviewTabTitle)}
</Stack>
)}
notification={outOfSyncCount}
className="px-2 mt-3"
>
{renderReviewTabContent()}
</Tab>
</Tabs>
</section>
</Container>
</>
);
};

View File

@@ -0,0 +1,78 @@
import React, { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Loop } from '@openedx/paragon/icons';
import AlertMessage from '../generic/alert-message';
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
import messages from './messages';
interface OutOfSyncAlertProps {
showAlert: boolean,
setShowAlert: React.Dispatch<React.SetStateAction<boolean>>,
courseId: string,
onDismiss?: () => void;
onReview: () => void;
}
/**
* Shows an alert when library components used in the current course were updated and the blocks
* in course can be updated. Following are the conditions for displaying the alert.
*
* * The alert is displayed if components are out of sync.
* * If the user clicks on dismiss button, the state 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 there is a new published component upstream, the alert is displayed again.
*/
export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
showAlert,
setShowAlert,
courseId,
onDismiss,
onReview,
}) => {
const intl = useIntl();
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 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(() => {
if (isLoading) {
return;
}
if (outOfSyncCount === 0) {
localStorage.removeItem(alertKey);
setShowAlert(false);
return;
}
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => {
setShowAlert(false);
localStorage.setItem(alertKey, Date.now().toString());
onDismiss?.();
};
return (
<AlertMessage
title={intl.formatMessage(messages.outOfSyncCountAlertTitle, { outOfSyncCount })}
dismissible
show={showAlert}
icon={Loop}
variant="info"
onClose={dismissAlert}
actions={[
<Button
onClick={onReview}
>
{intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)}
</Button>,
]}
/>
);
};

View File

@@ -0,0 +1,330 @@
import React, {
useCallback, useContext, useMemo, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Breadcrumb,
Button,
Card,
Hyperlink,
Icon,
Stack,
useToggle,
} from '@openedx/paragon';
import { tail, keyBy } from 'lodash';
import { useQueryClient } from '@tanstack/react-query';
import { Loop } from '@openedx/paragon/icons';
import messages from './messages';
import previewChangesMessages from '../course-unit/preview-changes/messages';
import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks';
import {
SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget,
} from '../search-manager';
import { getItemIcon } from '../generic/block-type-utils';
import type { ContentHit } from '../search-manager/data/api';
import { SearchSortOption } from '../search-manager/data/api';
import Loading from '../generic/Loading';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../course-unit/data/apiHooks';
import { PreviewLibraryXBlockChanges, LibraryChangesMessageData } from '../course-unit/preview-changes';
import LoadingButton from '../generic/loading-button';
import { ToastContext } from '../generic/toast-context';
import { useLoadOnScroll } from '../hooks';
import DeleteModal from '../generic/delete-modal/DeleteModal';
import { PublishableEntityLink } from './data/api';
import AlertError from '../generic/alert-error';
interface Props {
courseId: string;
}
interface BlockCardProps {
info: ContentHit;
actions?: React.ReactNode;
}
const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
const intl = useIntl();
const componentIcon = getItemIcon(info.blockType);
const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
const getBlockLink = useCallback(() => {
let key = info.usageKey;
if (breadcrumbs?.length > 1) {
key = breadcrumbs[breadcrumbs.length - 1].usageKey || key;
}
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
}, [info]);
return (
<Card
className="my-3 border-light-500 border shadow-none"
orientation="horizontal"
>
<Card.Section
className="py-3"
>
<Stack direction="horizontal" gap={2}>
<Stack direction="vertical" gap={1}>
<Stack direction="horizontal" gap={1} className="micro text-gray-500">
<Icon src={componentIcon} size="xs" />
<BlockTypeLabel blockType={info.blockType} />
</Stack>
<Stack direction="horizontal" className="small" gap={1}>
<strong>
<Highlight text={info.formatted?.displayName ?? ''} />
</strong>
</Stack>
<Stack direction="horizontal" className="micro" gap={3}>
{intl.formatMessage(messages.breadcrumbLabel)}
<Hyperlink showLaunchIcon={false} destination={getBlockLink()} target="_blank">
<Breadcrumb
className="micro text-gray-700 border-bottom"
ariaLabel={intl.formatMessage(messages.breadcrumbLabel)}
links={breadcrumbs.map((breadcrumb) => ({ label: breadcrumb.displayName }))}
spacer={<span className="custom-spacer">/</span>}
linkAs="span"
/>
</Hyperlink>
</Stack>
</Stack>
{actions}
</Stack>
</Card.Section>
</Card>
);
};
const ComponentReviewList = ({
outOfSyncComponents,
}: {
outOfSyncComponents: PublishableEntityLink[];
}) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const {
hits,
isLoading: isIndexDataLoading,
hasError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useSearchContext();
const downstreamInfo = hits as ContentHit[];
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
true,
);
const outOfSyncComponentsByKey = useMemo(
() => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
[outOfSyncComponents],
);
const queryClient = useQueryClient();
// Toggle preview changes modal
const [isModalOpen, openModal, closeModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const setSelectedBlockData = useCallback((info: ContentHit) => {
setBlockData({
displayName: info.displayName,
downstreamBlockId: info.usageKey,
upstreamBlockId: outOfSyncComponentsByKey[info.usageKey].upstreamUsageKey,
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
isVertical: info.blockType === 'vertical',
});
}, [outOfSyncComponentsByKey]);
// Show preview changes on review
const onReview = useCallback((info: ContentHit) => {
setSelectedBlockData(info);
openModal();
}, [setSelectedBlockData, openModal]);
const onIgnoreClick = useCallback((info: ContentHit) => {
setSelectedBlockData(info);
openConfirmModal();
}, [setSelectedBlockData, openConfirmModal]);
const reloadLinks = useCallback((usageKey: string) => {
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey));
}, [outOfSyncComponentsByKey]);
const postChange = (accept: boolean) => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
}
reloadLinks(blockData.downstreamBlockId);
if (accept) {
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
{ name: blockData.displayName },
));
} else {
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,
{ name: blockData.displayName },
));
}
};
const updateBlock = useCallback(async (info: ContentHit) => {
try {
await acceptChangesMutation.mutateAsync(info.usageKey);
reloadLinks(info.usageKey);
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
{ name: info.displayName },
));
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
}
}, []);
const ignoreBlock = useCallback(async () => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
}
try {
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
reloadLinks(blockData.downstreamBlockId);
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,
{ name: blockData.displayName },
));
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
} finally {
closeConfirmModal();
}
}, [blockData]);
if (isIndexDataLoading) {
return <Loading />;
}
if (hasError) {
return <AlertError error={intl.formatMessage(messages.genericErrorMessage)} />;
}
return (
<>
{downstreamInfo?.map((info) => (
<BlockCard
key={info.usageKey}
info={info}
actions={(
<ActionRow>
<Button
size="sm"
variant="outline-primary border-light-300"
onClick={() => onReview(info)}
iconBefore={Loop}
className="mr-2"
>
{intl.formatMessage(messages.cardReviewContentBtn)}
</Button>
<span className="border border-dark py-3 ml-4 mr-3" />
<Button
variant="tertiary"
size="sm"
onClick={() => onIgnoreClick(info)}
>
{intl.formatMessage(messages.cardIgnoreContentBtn)}
</Button>
<LoadingButton
label={intl.formatMessage(messages.cardUpdateContentBtn)}
variant="primary"
size="sm"
onClick={() => updateBlock(info)}
className="rounded-0"
/>
</ActionRow>
)}
/>
))}
{blockData && (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={postChange}
/>
)}
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
variant="warning"
title={intl.formatMessage(previewChangesMessages.confirmationTitle)}
description={intl.formatMessage(previewChangesMessages.confirmationDescription)}
onDeleteSubmit={ignoreBlock}
btnLabel={intl.formatMessage(previewChangesMessages.confirmationConfirmBtn)}
/>
</>
);
};
const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: outOfSyncComponents,
isLoading: isSyncComponentsLoading,
isError,
error,
} = useEntityLinks({ courseId, readyToSync: true });
const downstreamKeys = useMemo(
() => outOfSyncComponents?.map(link => link.downstreamUsageKey),
[outOfSyncComponents],
);
const disableSortOptions = [
SearchSortOption.RELEVANCE,
SearchSortOption.OLDEST,
SearchSortOption.NEWEST,
SearchSortOption.RECENTLY_PUBLISHED,
];
if (isSyncComponentsLoading) {
return <Loading />;
}
if (isError) {
return <AlertError error={error} />;
}
return (
<SearchContextProvider
extraFilter={[`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys?.join('","')}"]`]}
skipUrlUpdate
skipBlockTypeFetch
>
<ActionRow>
<SearchKeywordsField
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
<SearchSortWidget disableOptions={disableSortOptions} />
<ActionRow.Spacer />
</ActionRow>
<ComponentReviewList
outOfSyncComponents={outOfSyncComponents}
/>
</SearchContextProvider>
);
};
export default ReviewTabContent;

View File

@@ -0,0 +1,374 @@
[
{
"filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef\"]",
"result": {
"hits": [
{
"display_name": "Dropdown",
"description": "asfd sdaf afd",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
"block_type": "problem",
"_formatted": {
"display_name": "Dropdown",
"description": "asfd sdaf afd",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
"block_type": "problem"
}
},
{
"display_name": "HTML 12",
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
"block_type": "html",
"_formatted": {
"display_name": "HTML 12",
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks…",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
"block_type": "html"
}
},
{
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
"block_type": "html",
"_formatted": {
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
"block_type": "html"
}
},
{
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
"block_type": "html",
"_formatted": {
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
"block_type": "html"
}
}
],
"query": "",
"processingTimeMs": 0,
"limit": 4,
"offset": 0,
"estimatedTotalHits": 4
}
},
{
"filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83\"]",
"result": {
"hits": [
{
"display_name": "Edited title",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
"block_type": "video",
"_formatted": {
"display_name": "Edited title",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
"block_type": "video"
}
},
{
"display_name": "Text 1",
"description": " 8¹⁺² 3² Accept change now!d",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
"block_type": "html",
"_formatted": {
"display_name": "Text 1",
"description": " 8¹⁺² 3² Accept change now!d",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
"block_type": "html"
}
},
{
"display_name": "Text 23",
"description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
"block_type": "html",
"_formatted": {
"display_name": "Text 23",
"description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
"block_type": "html"
}
}
],
"query": "",
"processingTimeMs": 0,
"limit": 3,
"offset": 0,
"estimatedTotalHits": 3
}
}
]

View File

@@ -0,0 +1,23 @@
{
"id": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"def_key": null,
"block_type": "problem",
"display_name": "Dropdown 123",
"last_published": "2025-02-19T13:58:49Z",
"published_by": "edx",
"last_draft_created": "2025-02-19T13:58:48Z",
"last_draft_created_by": null,
"has_unpublished_changes": false,
"created": "2024-10-30T10:48:35Z",
"modified": "2025-02-19T13:58:48Z",
"collections": [
{
"key": "second-collection",
"title": "Second collection"
},
{
"key": "test-collection-2",
"title": "Test collection 2"
}
]
}

View File

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

View File

@@ -0,0 +1,376 @@
{
"results": [
{
"indexUid": "tutor_studio_content",
"hits": [
{
"display_name": "Dropdown",
"block_id": "problem3",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "problem3",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Dropdown",
"block_id": "problem6",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "problem6",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Dropdown",
"block_id": "210e356cfa304b0aac591af53f6a6ae0",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "210e356cfa304b0aac591af53f6a6ae0",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "HTML 1",
"block_id": "257e68e3386d4a8f8739d45b67e76a9b",
"content": {
"html_content": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1"
},
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1",
"tags": {},
"id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"block_type": "html",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "HTML 1",
"block_id": "257e68e3386d4a8f8739d45b67e76a9b",
"content": {
"html_content": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1"
},
"description": "A step beyond the simplicity of the WYSIWYG editor is…",
"tags": {},
"id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"block_type": "html",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Dropdown",
"block_id": "a4455860b03647219ff8b01cde49cf37",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "a4455860b03647219ff8b01cde49cf37",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
}
],
"query": "",
"processingTimeMs": 8,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 5
}
]
}

View File

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

@@ -0,0 +1,111 @@
/* istanbul ignore file */
// eslint-disable-next-line import/no-extraneous-dependencies
import fetchMock from 'fetch-mock-jest';
import mockLinksResult from '../__mocks__/publishableEntityLinks.json';
import mockSummaryResult from '../__mocks__/linkCourseSummary.json';
import mockLinkDetailsFromIndex from '../__mocks__/linkDetailsFromIndex.json';
import mockLibBlockMetadata from '../__mocks__/libBlockMetadata.json';
import { createAxiosError } from '../../testUtils';
import * as api from './api';
import * as libApi from '../../library-authoring/data/api';
/**
* Mock for `getEntityLinks()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinks(
downstreamContextKey?: string,
readyToSync?: boolean,
): ReturnType<typeof api.getEntityLinks> {
switch (downstreamContextKey) {
case mockGetEntityLinks.invalidCourseKey:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(),
});
case mockGetEntityLinks.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinks.courseKeyEmpty:
return Promise.resolve([]);
default: {
let { response } = mockGetEntityLinks;
if (readyToSync !== undefined) {
response = response.filter((o) => o.readyToSync === readyToSync);
}
return Promise.resolve(response);
}
}
}
mockGetEntityLinks.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinks.invalidCourseKey = 'course_key_error';
mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinks.response = mockLinksResult;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetEntityLinks.applyMock = () => {
jest.spyOn(api, 'getEntityLinks').mockImplementation(mockGetEntityLinks);
};
/**
* Mock for `getEntityLinksSummaryByDownstreamContext()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinksSummaryByDownstreamContext(
courseId?: string,
): ReturnType<typeof api.getEntityLinksSummaryByDownstreamContext> {
switch (courseId) {
case mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(),
});
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty:
return Promise.resolve([]);
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate:
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response.filter(
(o: { readyToSyncCount: number }) => o.readyToSyncCount === 0,
));
default:
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response);
}
}
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate = 'courseKeyUpToDate';
mockGetEntityLinksSummaryByDownstreamContext.response = mockSummaryResult;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetEntityLinksSummaryByDownstreamContext.applyMock = () => {
jest.spyOn(api, 'getEntityLinksSummaryByDownstreamContext').mockImplementation(mockGetEntityLinksSummaryByDownstreamContext);
};
/**
* Mock for multi-search from meilisearch index for link details.
*/
export async function mockFetchIndexDocuments() {
return mockLinkDetailsFromIndex;
}
mockFetchIndexDocuments.applyMock = () => {
fetchMock.post(
'http://mock.meilisearch.local/multi-search',
mockFetchIndexDocuments,
{ overwriteRoutes: true },
);
};
/**
* Mock for library block metadata
*/
export async function mockUseLibBlockMetadata() {
return mockLibBlockMetadata;
}
mockUseLibBlockMetadata.applyMock = () => {
jest.spyOn(libApi, 'getLibraryBlockMetadata').mockImplementation(mockUseLibBlockMetadata);
};

View File

@@ -0,0 +1,67 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`;
export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`;
export interface PaginatedData<T> {
next: string | null;
previous: string | null;
nextPageNum: number | null;
previousPageNum: number | null;
count: number;
numPages: number;
currentPage: number;
results: T,
}
export interface PublishableEntityLink {
id: number;
upstreamUsageKey: string;
upstreamContextKey: string;
upstreamContextTitle: string;
upstreamVersion: number;
downstreamUsageKey: string;
downstreamContextKey: string;
versionSynced: number;
versionDeclined: number | null;
created: string;
updated: string;
readyToSync: boolean;
}
export interface PublishableEntityLinkSummary {
upstreamContextKey: string;
upstreamContextTitle: string;
readyToSyncCount: number;
totalCount: number;
lastPublishedAt: string;
}
export const getEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
): Promise<PublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_usage_key: upstreamUsageKey,
no_page: true,
},
});
return camelCaseObject(data);
};
export const getEntityLinksSummaryByDownstreamContext = async (
downstreamContextKey: string,
): Promise<PublishableEntityLinkSummary[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksSummaryByDownstreamContextUrl(downstreamContextKey));
return camelCaseObject(data);
};

View File

@@ -0,0 +1,58 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { renderHook, waitFor } from '@testing-library/react';
import { getEntityLinksByDownstreamContextUrl } from './api';
import { useEntityLinks } from './apiHooks';
let axiosMock: MockAdapter;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('course libraries api hooks', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
axiosMock.reset();
});
it('should return links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(axiosMock.history.get[0].url).toEqual(url);
expect(axiosMock.history.get[0].params).toEqual({
course_id: courseId,
ready_to_sync: undefined,
upstream_usage_key: undefined,
no_page: true,
});
});
});

View File

@@ -0,0 +1,65 @@
import {
useQuery,
} from '@tanstack/react-query';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId],
courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageSize?: number,
}) => {
const key: Array<string | boolean | number> = [...courseLibrariesQueryKeys.all];
if (courseId !== undefined) {
key.push(courseId);
}
if (readyToSync !== undefined) {
key.push(readyToSync);
}
if (upstreamUsageKey !== undefined) {
key.push(upstreamUsageKey);
}
return key;
},
courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'],
};
/**
* Hook to fetch 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,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
}) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamUsageKey,
}),
queryFn: () => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
),
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
})
);
/**
* Hook to fetch publishable entity links summary by course key.
*/
export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseLibrariesSummary(courseId),
queryFn: () => getEntityLinksSummaryByDownstreamContext(courseId!),
enabled: courseId !== undefined,
})
);

View File

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

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