Compare commits
247 Commits
teak-desig
...
chris/FAL-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c28de286 | ||
|
|
950bfee7c1 | ||
|
|
0f2dd4a88f | ||
|
|
6646c8ed0f | ||
|
|
9a9806ccad | ||
|
|
86e9c6b1fa | ||
|
|
2a787953ef | ||
|
|
c90195e0fd | ||
|
|
dfd3b93f0a | ||
|
|
ae449b914b | ||
|
|
5c006af6ec | ||
|
|
641fc589a4 | ||
|
|
0c88fd6da9 | ||
|
|
8e680dc8d4 | ||
|
|
2f6e510b09 | ||
|
|
87af7e8973 | ||
|
|
33c445ebc2 | ||
|
|
7825bcde75 | ||
|
|
2c9f90ba5a | ||
|
|
ada52c3169 | ||
|
|
38b0b6543b | ||
|
|
e6b72453b3 | ||
|
|
0820a1e7df | ||
|
|
be82e96e6f | ||
|
|
b2203f0ece | ||
|
|
53e925e07a | ||
|
|
25830a2130 | ||
|
|
6ce7b86e83 | ||
|
|
5bdef7cffa | ||
|
|
f0c5a513de | ||
|
|
da46fe0a48 | ||
|
|
7c4ef47da5 | ||
|
|
8003453b73 | ||
|
|
92c3a98a3d | ||
|
|
0e1550a45b | ||
|
|
15c79ceb21 | ||
|
|
43de4d1e32 | ||
|
|
591444d72d | ||
|
|
2f9566c4f5 | ||
|
|
915bd559e0 | ||
|
|
00ce3d7856 | ||
|
|
90ddc5e71c | ||
|
|
9ceebbe137 | ||
|
|
8326257938 | ||
|
|
30cfd269e2 | ||
|
|
082a1c6510 | ||
|
|
a146307a4f | ||
|
|
556bb1e56d | ||
|
|
bd2e2d8655 | ||
|
|
11de4022f0 | ||
|
|
9caa4351ba | ||
|
|
74e671d08b | ||
|
|
5b7efc65bc | ||
|
|
f9cd15eee6 | ||
|
|
bad66caadd | ||
|
|
2ae594f23c | ||
|
|
8e3ea89339 | ||
|
|
537b3292ee | ||
|
|
46d5917303 | ||
|
|
4f3904ea4c | ||
|
|
215f7280da | ||
|
|
60417a76cb | ||
|
|
cbec6505f5 | ||
|
|
654daa58ee | ||
|
|
bd18e874b5 | ||
|
|
2db6d89fca | ||
|
|
a51ff99042 | ||
|
|
966767ffd4 | ||
|
|
fc9ded432a | ||
|
|
a3e03dc12f | ||
|
|
77fe2d1086 | ||
|
|
efd967a42b | ||
|
|
eaba380417 | ||
|
|
f3332a214f | ||
|
|
bc11aaf5ce | ||
|
|
4ae9d3c8df | ||
|
|
3673c5f561 | ||
|
|
2fe24f39ac | ||
|
|
c4f565bf76 | ||
|
|
e8e5a3c4ce | ||
|
|
749c0022ec | ||
|
|
642be162d7 | ||
|
|
a969de4d90 | ||
|
|
8d86433748 | ||
|
|
90f375939a | ||
|
|
a1214f7fa9 | ||
|
|
88821077cb | ||
|
|
3ebd2372d2 | ||
|
|
6014da9a22 | ||
|
|
f355a17943 | ||
|
|
ae82486d72 | ||
|
|
dd2853900b | ||
|
|
2533724203 | ||
|
|
ffd430d321 | ||
|
|
ad62519af7 | ||
|
|
76b5dd5925 | ||
|
|
701e41b664 | ||
|
|
ac9faacc4d | ||
|
|
37313b30bd | ||
|
|
6f79e4475d | ||
|
|
e2da13d129 | ||
|
|
aeefcc639f | ||
|
|
4905f3bbc7 | ||
|
|
60cebf703d | ||
|
|
71fa247c61 | ||
|
|
31fc0453b4 | ||
|
|
75137bc651 | ||
|
|
085cd7d05c | ||
|
|
810dd420fd | ||
|
|
747f7b6133 | ||
|
|
ebf4b7c162 | ||
|
|
962b30bed9 | ||
|
|
75ea7500e1 | ||
|
|
08c3d123d8 | ||
|
|
920f4a54e1 | ||
|
|
d9e1a4dea6 | ||
|
|
8b9a80eb04 | ||
|
|
a5c17452e7 | ||
|
|
4b4ab92383 | ||
|
|
97710c262e | ||
|
|
b510b6f69f | ||
|
|
488173ebdb | ||
|
|
5a84d8c52f | ||
|
|
ac7f90065d | ||
|
|
19f81cc05d | ||
|
|
fa9d66c5e5 | ||
|
|
dc16b226f0 | ||
|
|
cba4e684ab | ||
|
|
96df339be5 | ||
|
|
eaee5257bd | ||
|
|
154b411ad8 | ||
|
|
284e9c7d68 | ||
|
|
fcf1e5cb33 | ||
|
|
2e9b5b7e78 | ||
|
|
8a423ebf10 | ||
|
|
b6db457c6f | ||
|
|
80dabca88e | ||
|
|
376414a653 | ||
|
|
ca85ca8e4b | ||
|
|
4a1f454855 | ||
|
|
4adf2ff087 | ||
|
|
569a981a85 | ||
|
|
3097976b7b | ||
|
|
acef2e70cc | ||
|
|
c1d874f94f | ||
|
|
dc16c42746 | ||
|
|
ea33c15b36 | ||
|
|
d440394067 | ||
|
|
73ac6d725a | ||
|
|
0e2cab2838 | ||
|
|
b3605fa1b8 | ||
|
|
be13c18e5d | ||
|
|
019eede7c2 | ||
|
|
5991fd3997 | ||
|
|
9a2dac6d4b | ||
|
|
061855c31e | ||
|
|
5df4cd941d | ||
|
|
7274316eb8 | ||
|
|
151b3e30bf | ||
|
|
dd6780ff41 | ||
|
|
99e11d3534 | ||
|
|
ee7992bde5 | ||
|
|
1dee2bba58 | ||
|
|
d806b6150d | ||
|
|
c7f3e26798 | ||
|
|
27a2b1235e | ||
|
|
3c69733170 | ||
|
|
cfb4944d43 | ||
|
|
17e514f937 | ||
|
|
0c7cef66ab | ||
|
|
c677e7fef3 | ||
|
|
50cb8608c4 | ||
|
|
5bb8a5d47c | ||
|
|
f02347dd71 | ||
|
|
0eaa7f6f88 | ||
|
|
e6c1c95260 | ||
|
|
a18444e691 | ||
|
|
5561c030e8 | ||
|
|
fffa9e2566 | ||
|
|
f18274533e | ||
|
|
3fc0f27d67 | ||
|
|
36e57a0cfb | ||
|
|
afd6afdbb9 | ||
|
|
01243afdd9 | ||
|
|
b0e194e512 | ||
|
|
260582b6f0 | ||
|
|
ce337aedef | ||
|
|
44d47f8783 | ||
|
|
65c8b8ba4b | ||
|
|
80dba704da | ||
|
|
9906901262 | ||
|
|
5167b167eb | ||
|
|
951b707c7d | ||
|
|
8e1e2fdb46 | ||
|
|
ca8ce5b253 | ||
|
|
c5f7d0cf3b | ||
|
|
ac5574d2c4 | ||
|
|
df3577241f | ||
|
|
65605bf937 | ||
|
|
9e978057bc | ||
|
|
279a900a10 | ||
|
|
f2d5bc4680 | ||
|
|
ee5e51d371 | ||
|
|
0e40aa295d | ||
|
|
e212e1a1ef | ||
|
|
1568067980 | ||
|
|
503642be8c | ||
|
|
6f3b7ab962 | ||
|
|
08ac1c0c4d | ||
|
|
6a3b0064ff | ||
|
|
1d45fa2e38 | ||
|
|
62bffc06d7 | ||
|
|
88aa4c1524 | ||
|
|
4ebc1590e7 | ||
|
|
97e5fbaa5e | ||
|
|
03a757de21 | ||
|
|
d8eda2494b | ||
|
|
db07092880 | ||
|
|
26c6a71624 | ||
|
|
d5dc8b5ebe | ||
|
|
7b2cc125a5 | ||
|
|
9a2dc8061a | ||
|
|
cc47616256 | ||
|
|
e8463f7a6a | ||
|
|
c77c4f3c91 | ||
|
|
36277d8ef5 | ||
|
|
cdb8016657 | ||
|
|
8c3fab3792 | ||
|
|
04e8f3a488 | ||
|
|
d7173036a5 | ||
|
|
208b0c9195 | ||
|
|
24e469542d | ||
|
|
0fdc460c5b | ||
|
|
a7b10495e6 | ||
|
|
fdfc30dbd5 | ||
|
|
75e0531c5b | ||
|
|
b697a44f36 | ||
|
|
db0f562d93 | ||
|
|
11835d28aa | ||
|
|
b023173ed4 | ||
|
|
bc18fffedf | ||
|
|
484154b9bd | ||
|
|
65aca04708 | ||
|
|
d92b27ee93 | ||
|
|
0f5c752eb0 | ||
|
|
3d2df5f4be | ||
|
|
dbb1a996e1 |
2
.env
2
.env
@@ -41,10 +41,10 @@ HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
|
||||
@@ -44,10 +44,10 @@ HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
|
||||
@@ -42,3 +42,4 @@ ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
PARAGON_THEME_URLS=
|
||||
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
|
||||
|
||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -2,26 +2,37 @@
|
||||
|
||||
Describe what this pull request changes, and why. Include implications for people using this change.
|
||||
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
|
||||
[OEP-19](https://open-edx-proposals.readthedocs.io/en/latest/oep-0019-bp-developer-documentation.html), and can be linked here.
|
||||
|
||||
Useful information to include:
|
||||
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
- Which user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
"Developer", and "Operator".
|
||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
||||
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
|
||||
changes.
|
||||
|
||||
## Supporting information
|
||||
|
||||
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
|
||||
Link to other information about the change, such as GitHub issues, or Discourse discussions.
|
||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
Please provide detailed step-by-step instructions for testing this change.
|
||||
|
||||
Please provide detailed step-by-step instructions for manually testing this change.
|
||||
|
||||
## Other information
|
||||
|
||||
Include anything else that will help reviewers and consumers understand the change.
|
||||
- Does this change depend on other changes elsewhere?
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
We're trying to move away from some deprecated patterns in this codebase. Please
|
||||
check if your PR meets these recommendations before asking for a review:
|
||||
|
||||
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
|
||||
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
|
||||
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
|
||||
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
|
||||
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
|
||||
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
|
||||
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
|
||||
18
.github/workflows/add-issue-to-btr-project.yml
vendored
Normal file
18
.github/workflows/add-issue-to-btr-project.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Run the workflow that adds new tickets that are labelled "release testing"
|
||||
# to the org-wide BTR project board
|
||||
|
||||
name: Add release testing issues to the BTR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
# This workflow is triggered when an issue is labeled with 'release testing'.
|
||||
# It adds the issue to the BTR project and applies the 'needs triage' label
|
||||
# if it doesn't already have it.
|
||||
|
||||
jobs:
|
||||
handle-release-testing:
|
||||
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
8
.github/workflows/validate.yml
vendored
8
.github/workflows/validate.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -25,13 +25,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# The following users are the maintainers of all frontend-app-authoring files
|
||||
* @openedx/2u-tnl
|
||||
69
README.rst
69
README.rst
@@ -165,21 +165,7 @@ Feature: New React XBlock Editors
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
|
||||
New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
@@ -193,10 +179,6 @@ Requirements
|
||||
|
||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
||||
|
||||
* ``edx-platform`` Feature flags:
|
||||
|
||||
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
|
||||
|
||||
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
|
||||
|
||||
Configuration
|
||||
@@ -221,16 +203,6 @@ Feature: Advanced Settings
|
||||
|
||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
|
||||
|
||||
Feature: Files & Uploads
|
||||
@@ -238,16 +210,6 @@ Feature: Files & Uploads
|
||||
|
||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
|
||||
|
||||
Feature: Course Updates
|
||||
@@ -255,26 +217,11 @@ Feature: Course Updates
|
||||
|
||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
|
||||
|
||||
Feature: Import/Export Pages
|
||||
============================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-export.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
|
||||
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
|
||||
|
||||
Feature: Tagging/Taxonomy Pages
|
||||
================================
|
||||
|
||||
@@ -380,6 +327,20 @@ For more information about these options, see the `Getting Help`_ page.
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
Legacy Studio
|
||||
*************
|
||||
|
||||
If you would like to use legacy studio for certain features, you can set the following waffle flags in ``edx-platform``:
|
||||
* ``legacy_studio.text_editor``: loads the legacy HTML Xblock editor when editing a text block
|
||||
* ``legacy_studio.video_editor``: loads the legacy Video editor when editing a video block
|
||||
* ``legacy_studio.problem_editor``: loads the legacy Problem editor when editing a problem block
|
||||
* ``legacy_studio.advanced_settings``: Advanced Settings page
|
||||
* ``legacy_studio.updates``: Updates page
|
||||
* ``legacy_studio.export``: Export page
|
||||
* ``legacy_studio.import``: Import page
|
||||
* ``legacy_studio.files_uploads``: Files page
|
||||
* ``legacy_studio.exam_settings``: loads the legacy Exam Settings
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
|
||||
@@ -14,6 +14,6 @@ metadata:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:2u-tnl
|
||||
owner: user:bradenmacdonald
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
|
||||
@@ -11,9 +11,11 @@ module.exports = createConfig('jest', {
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
// This alias is for any code in the src directory that wants to avoid '../../' style relative imports:
|
||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||
// This alias is used for plugins in the plugins/ folder only.
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
],
|
||||
});
|
||||
|
||||
402
package-lock.json
generated
402
package-lock.json
generated
@@ -41,7 +41,7 @@
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@tanstack/react-query": "4.40.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
@@ -64,12 +64,12 @@
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.10.1",
|
||||
"react-router": "6.30.1",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-select": "5.10.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
"redux": "4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
@@ -80,14 +80,15 @@
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
@@ -146,14 +147,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -435,9 +436,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1989,9 +1990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -2241,9 +2242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.2.tgz",
|
||||
"integrity": "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==",
|
||||
"version": "6.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.4.tgz",
|
||||
"integrity": "sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
@@ -2315,12 +2316,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.36.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.6.tgz",
|
||||
"integrity": "sha512-uxugGLet+Nzp0Jcit8Hn3LypM8ioMLKTsdf8FRoT3HWvZtb9GhaWMe0Cc15rz90Ljab4YFJiAulmIVB74OY0IQ==",
|
||||
"version": "6.38.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
|
||||
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
@@ -2576,21 +2578,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-6.4.0.tgz",
|
||||
"integrity": "sha512-RNV3XRXhhN9QlhAoP26CjzoRIPlLSYDp3PZCnK6g6kIHgxC9dCpu2PTZdxV2AVChqVuxtZK5zLbk9yeAtf4U/A==",
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-6.6.0.tgz",
|
||||
"integrity": "sha512-Qp4f0ZaKmwYdEXcjr24zePbN9oEdVFPuamxA4nH6dUGVi2whwX+US80yVJ1abQs3BX23zPpzYnqyc6/UhOqhLw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "^2.5.1",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"react-responsive": "8.2.0",
|
||||
"react-transition-group": "4.4.5"
|
||||
},
|
||||
@@ -2603,63 +2602,6 @@
|
||||
"react-router-dom": "^6.14.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
|
||||
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
|
||||
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
|
||||
"integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz",
|
||||
"integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
|
||||
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-component-header/node_modules/react-responsive": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz",
|
||||
@@ -2690,9 +2632,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-platform": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.4.0.tgz",
|
||||
"integrity": "sha512-toWMU7qVx56f5bLk6/Sl5WWqlKtGp602qDs22JYp5r2VBp5F/nzcrpXXWC925/kH0TP5hI2OMolmLq6n2N8a4Q==",
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.0.tgz",
|
||||
"integrity": "sha512-nzz46Pe0G1mFHoywzOWPhwy2m26CmD3o11FubeU8F94VsKfJsAexTETyWHRvwuVhQs6qaFzgobAjjEl8w0mu6A==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@cospired/i18n-iso-languages": "4.2.0",
|
||||
@@ -2754,31 +2696,6 @@
|
||||
"atlas": "atlas"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/react-unit-test-utils": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/react-unit-test-utils/-/react-unit-test-utils-4.0.0.tgz",
|
||||
"integrity": "sha512-QlVYhYD9L2bzx1eAtf8BbCJr00ek9rrHrG+/pW2bVSt+t0uvKHQpX1CNdMrDePv18DsMeC7IOB00t8ZIn4mi7w==",
|
||||
"dev": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@reduxjs/toolkit": "^1.5.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"lodash": "^4.17.21",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-test-renderer": "^18.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@openedx/frontend-build": "^14.3.0",
|
||||
"@openedx/paragon": "^22.0.0 || ^23.0.0",
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/stylelint-config-edx": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@edx/stylelint-config-edx/-/stylelint-config-edx-2.3.3.tgz",
|
||||
@@ -4406,9 +4323,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openedx/frontend-build": {
|
||||
"version": "14.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.5.0.tgz",
|
||||
"integrity": "sha512-HY0PdXvXBxrvJHj8HsRA+VNCHDePENFhqOIvbSz9Ke7HDwHpfDjg2CeKk41aU/8iTyj3eESfPwKQr5fTE0A3Ww==",
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.1.tgz",
|
||||
"integrity": "sha512-HPswCfxThP0F92fmKqOetQ+E7HNiXDmOE+vHkfrpdKYNUj6Sn+7jaBICn8pNfif8uq4tF2ZGRnAgfUphry2ORQ==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@babel/cli": "7.24.8",
|
||||
@@ -4490,9 +4407,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/frontend-build/node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -4634,19 +4551,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/frontend-plugin-framework/node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon": {
|
||||
"version": "23.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.5.0.tgz",
|
||||
"integrity": "sha512-Pb6JvRON/8wfdALy2z3PXyFADYsKbv+NXqL5pkVXr9wlSf2jSe/dcKXy4EgN/wKpUCphShZRQWakbLPWCC78hw==",
|
||||
"version": "23.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.14.2.tgz",
|
||||
"integrity": "sha512-mBsoH9nwt4VGkoE9y33BrSJsjTzWlKjooWGXeJng4LdFNnBy7bhtEvRENQ9/0L0/trWhEMZffAMP7h9HBfg5EQ==",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"example",
|
||||
@@ -4673,6 +4581,7 @@
|
||||
"js-toml": "^1.0.0",
|
||||
"lodash.uniqby": "^4.7.0",
|
||||
"log-update": "^4.0.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"mailto-link": "^2.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"ora": "^5.4.1",
|
||||
@@ -4720,9 +4629,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -5244,19 +5153,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
|
||||
"integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==",
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -5571,9 +5471,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "4.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz",
|
||||
"integrity": "sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==",
|
||||
"version": "4.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz",
|
||||
"integrity": "sha512-7MJTtZkCSuehMC7IxMOCGsLvHS3jHx4WjveSrGsG1Nc1UQLjaFwwkpLA2LmPfvOAxnH4mszMOBFD6LlZE+aB+Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5581,12 +5481,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "4.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz",
|
||||
"integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==",
|
||||
"version": "4.40.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.40.1.tgz",
|
||||
"integrity": "sha512-mgD07S5N8e5v81CArKDWrHE4LM7HxZ9k/KLeD3+NUD9WimGZgKIqojUZf/rXkfAMYZU9p0Chzj2jOXm7xpgHHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "4.36.1",
|
||||
"@tanstack/query-core": "4.40.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
@@ -5613,6 +5513,7 @@
|
||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -5628,18 +5529,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
|
||||
"integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz",
|
||||
"integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"chalk": "^3.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"lodash": "^4.17.21",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5648,20 +5548,6 @@
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/chalk": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
|
||||
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
@@ -5698,16 +5584,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5787,7 +5670,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -6097,9 +5981,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz",
|
||||
"integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==",
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
@@ -6177,15 +6061,25 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
|
||||
"integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==",
|
||||
"version": "18.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-redux": {
|
||||
"version": "7.1.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz",
|
||||
@@ -7601,9 +7495,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios-mock-adapter": {
|
||||
"version": "1.22.0",
|
||||
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.22.0.tgz",
|
||||
"integrity": "sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz",
|
||||
"integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -8161,9 +8056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -8403,9 +8298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001721",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz",
|
||||
"integrity": "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==",
|
||||
"version": "1.0.30001737",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz",
|
||||
"integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8721,9 +8616,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
|
||||
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
@@ -8852,16 +8747,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz",
|
||||
"integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==",
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.0.2",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
@@ -9941,7 +9836,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
@@ -10939,9 +10835,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-formatjs/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -11867,9 +11763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -12218,14 +12114,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -13666,6 +13563,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15411,9 +15309,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-toml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-toml/-/js-toml-1.0.1.tgz",
|
||||
"integrity": "sha512-rHd/IolpFm2V5BmHCEY8CckHs8NDsYZZ64H5RNgA6Opsr9vX4QyTiQPplgtqg7b3ztqYShZC38nl6CUg7QuhXg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/js-toml/-/js-toml-1.0.2.tgz",
|
||||
"integrity": "sha512-/7IQ//bzn2a/5IDazPUNzlW7bsjxS51cxciYZDR+Z+3Le60yzT0YfI8KOWqTtBcZkXXVklhWd2OuGd8ZksB0wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chevrotain": "^11.0.3",
|
||||
@@ -15953,7 +15851,6 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
@@ -16911,9 +16808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -18504,6 +18401,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -18519,6 +18417,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -18531,7 +18430,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
@@ -18679,9 +18579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/purgecss/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -19447,12 +19347,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz",
|
||||
"integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.20.0"
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -19462,13 +19362,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz",
|
||||
"integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==",
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.20.0",
|
||||
"react-router": "6.27.0"
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -19479,9 +19379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz",
|
||||
"integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==",
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
@@ -19829,13 +19729,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
|
||||
"integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"symbol-observable": "^1.2.0"
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/redux-logger": {
|
||||
@@ -21971,15 +21870,6 @@
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-observable": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
|
||||
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
||||
18
package.json
18
package.json
@@ -15,7 +15,6 @@
|
||||
"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 .",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
@@ -65,7 +64,7 @@
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@tanstack/react-query": "4.40.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
@@ -88,12 +87,12 @@
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.10.1",
|
||||
"react-router": "6.30.1",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-select": "5.10.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
"redux": "4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
@@ -104,14 +103,15 @@
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
|
||||
@@ -2,16 +2,12 @@ import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
|
||||
import { initializeMocks, render } from 'CourseAuthoring/testUtils';
|
||||
import LearningAssistantSettings from './Settings';
|
||||
|
||||
const onClose = () => { };
|
||||
|
||||
describe('Learning Assistant Settings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders', async () => {
|
||||
const initialState = {
|
||||
models: {
|
||||
@@ -38,14 +34,8 @@ describe('Learning Assistant Settings', () => {
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<LearningAssistantSettings
|
||||
onClose={onClose}
|
||||
/>,
|
||||
{
|
||||
preloadedState: initialState,
|
||||
},
|
||||
);
|
||||
initializeMocks({ initialState });
|
||||
render(<LearningAssistantSettings onClose={onClose} />);
|
||||
|
||||
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
|
||||
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
|
||||
|
||||
@@ -124,12 +124,13 @@ describe('BBB Settings', () => {
|
||||
);
|
||||
|
||||
test('free plans message is visible when free plan is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
await mockStore({ emailSharing: true, isFreeTier: true });
|
||||
renderComponent();
|
||||
const spinner = getByRole(container, 'status');
|
||||
await waitForElementToBeRemoved(spinner);
|
||||
const dropDown = container.querySelector('select[name="tierType"]');
|
||||
userEvent.selectOptions(
|
||||
await user.selectOptions(
|
||||
dropDown,
|
||||
getByRole(dropDown, 'option', { name: 'Free' }),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
@@ -21,7 +21,6 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchWaffleFlags(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
|
||||
@@ -26,7 +26,6 @@ beforeEach(async () => {
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -102,4 +101,20 @@ describe('Course authoring page', () => {
|
||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
const mockStoreDenied = async () => {
|
||||
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
|
||||
|
||||
axiosMock.onGet(
|
||||
`${courseAppsApiUrl}/${courseId}`,
|
||||
).reply(403);
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreDenied();
|
||||
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
|
||||
import { Certificates } from './certificates';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
||||
@@ -82,6 +82,10 @@ const CourseAuthoringRoutes = () => {
|
||||
path="custom-pages/*"
|
||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="/subsection/:subsectionId"
|
||||
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import { executeThunk } from './utils';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { fetchWaffleFlags } from './data/thunks';
|
||||
import {
|
||||
screen, initializeMocks, render, waitFor,
|
||||
} from './testUtils';
|
||||
@@ -11,7 +9,6 @@ const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const editorContainerMockText = 'Editor Container';
|
||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
||||
const customPagesMockText = 'Custom Pages';
|
||||
let store;
|
||||
const mockComponentFn = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -51,12 +48,10 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock, reduxStore } = initializeMocks();
|
||||
store = reduxStore;
|
||||
const { axiosMock } = initializeMocks();
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -95,4 +95,4 @@ AccessibilityBody.propTypes = {
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityBody);
|
||||
export default AccessibilityBody;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||
FormattedMessage, FormattedDate, FormattedTime, useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
@@ -15,9 +15,8 @@ import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
@@ -139,8 +138,6 @@ const AccessibilityForm = ({
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityForm);
|
||||
export default AccessibilityForm;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -74,22 +73,24 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
describe('statusAlert', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
let user;
|
||||
beforeEach(async () => {
|
||||
user = userEvent.setup();
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
|
||||
await user.type(formSections[0], 'email@email.com');
|
||||
await user.type(formSections[1], 'test name');
|
||||
await user.type(formSections[2], 'feedback message');
|
||||
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('shows correct success message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
@@ -104,9 +105,9 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
|
||||
it('shows correct rate limiting message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
@@ -123,23 +124,24 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
describe('input validation', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
let user;
|
||||
beforeEach(async () => {
|
||||
user = userEvent.setup();
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
|
||||
await user.type(formSections[0], 'email@email.com');
|
||||
await user.type(formSections[1], 'test name');
|
||||
await user.type(formSections[2], 'feedback message');
|
||||
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('adds validation checking on each input field', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
});
|
||||
await user.clear(formSections[0]);
|
||||
await user.clear(formSections[1]);
|
||||
await user.clear(formSections[2]);
|
||||
|
||||
const emailError = screen.getByTestId('error-feedback-email');
|
||||
expect(emailError).toBeVisible();
|
||||
|
||||
@@ -151,12 +153,10 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
});
|
||||
|
||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
await user.clear(formSections[0]);
|
||||
await user.clear(formSections[1]);
|
||||
await user.clear(formSections[2]);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(submitButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
@@ -9,12 +9,10 @@ import messages from './messages';
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
const AccessibilityPage = ({
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
|
||||
const email = 'accessibility@edx.org';
|
||||
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
|
||||
|
||||
const AccessibilityPage = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -26,17 +24,16 @@ const AccessibilityPage = ({
|
||||
</Helmet>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" classNamae="px-4">
|
||||
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||
<AccessibilityForm accessibilityEmail={email} />
|
||||
<AccessibilityBody
|
||||
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
|
||||
/>
|
||||
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
|
||||
</Container>
|
||||
<StudioFooterSlot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityPage.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
AccessibilityPage.propTypes = {};
|
||||
|
||||
export default injectIntl(AccessibilityPage);
|
||||
export default AccessibilityPage;
|
||||
|
||||
@@ -1,42 +1,13 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../store';
|
||||
// @ts-check
|
||||
import { initializeMocks, render, screen } from '../testUtils';
|
||||
import AccessibilityPage from './index';
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
status: {},
|
||||
},
|
||||
};
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityPage />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
const renderComponent = () => render(<AccessibilityPage />);
|
||||
|
||||
describe('<AccessibilityPolicyPage />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
initializeMocks();
|
||||
});
|
||||
it('contains the policy body', () => {
|
||||
renderComponent();
|
||||
|
||||
2
src/accessibility-page/constants.ts
Normal file
2
src/accessibility-page/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
|
||||
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';
|
||||
@@ -10,9 +10,11 @@ function submitAccessibilityForm({ email, name, message }) {
|
||||
await postAccessibilityForm({ email, name, message });
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
/* istanbul ignore else */
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
@@ -26,7 +26,8 @@ import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const AdvancedSettings = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
@@ -278,8 +279,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AdvancedSettings);
|
||||
export default AdvancedSettings;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import {
|
||||
render as baseRender,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
import { executeThunk } from '../utils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
@@ -28,39 +25,22 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
/>
|
||||
)));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
const render = () => baseRender(
|
||||
<AdvancedSettings courseId={courseId} />,
|
||||
{ path: mockPathname },
|
||||
);
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
});
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = render();
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
@@ -72,7 +52,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
const { getByText, queryByText } = render();
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
@@ -80,7 +60,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
const { getByLabelText } = render();
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
@@ -89,7 +69,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
const { getByLabelText, getByText } = render();
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
@@ -100,7 +80,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
const { getByLabelText, getByText } = render();
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
@@ -108,7 +88,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = render();
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
@@ -118,7 +98,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
const { getByLabelText, getByText } = render();
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
@@ -129,7 +109,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
const { getByLabelText, getByText } = render();
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
@@ -141,7 +121,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
const { getByLabelText, getByText } = render();
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
import messages from './messages';
|
||||
|
||||
const ModalError = ({
|
||||
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
ModalError.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isError: PropTypes.bool.isRequired,
|
||||
handleUndoChanges: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
@@ -60,4 +62,4 @@ ModalError.propTypes = {
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ModalError);
|
||||
export default ModalError;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -25,9 +25,8 @@ const SettingCard = ({
|
||||
saveSettingsPrompt,
|
||||
isEditableState,
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
@@ -115,7 +114,6 @@ const SettingCard = ({
|
||||
};
|
||||
|
||||
SettingCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
settingData: PropTypes.shape({
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
@@ -137,4 +135,4 @@ SettingCard.propTypes = {
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
export default SettingCard;
|
||||
|
||||
@@ -29,7 +29,6 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
@@ -58,7 +57,6 @@ describe('<SettingCard />', () => {
|
||||
const { getByText } = render(
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
@@ -79,11 +77,12 @@ describe('<SettingCard />', () => {
|
||||
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||
});
|
||||
it('calls setEdited on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
const inputBox = getByLabelText(/Setting Name/i);
|
||||
fireEvent.focus(inputBox);
|
||||
userEvent.clear(inputBox);
|
||||
userEvent.type(inputBox, '3, 2, 1');
|
||||
await user.clear(inputBox);
|
||||
await user.type(inputBox, '3, 2, 1');
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
||||
<HelpSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||
showOtherSettings
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.about)}
|
||||
<FormattedMessage {...messages.about} />
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.aboutDescription1)}
|
||||
<FormattedMessage {...messages.aboutDescription1} />
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.aboutDescription2)}
|
||||
<FormattedMessage {...messages.aboutDescription2} />
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage
|
||||
@@ -34,14 +31,9 @@ const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
</HelpSidebar>
|
||||
);
|
||||
|
||||
SettingsSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
};
|
||||
|
||||
SettingsSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingsSidebar);
|
||||
export default SettingsSidebar;
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
// @ts-check
|
||||
import { initializeMocks, render } from '../../testUtils';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<SettingsSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
initializeMocks();
|
||||
});
|
||||
it('renders about and other sidebar titles correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
||||
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders about descriptions correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
||||
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘).');
|
||||
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
// @ts-check
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { initializeMocks, render, waitFor } from '../testUtils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { executeThunk } from '../utils';
|
||||
import initializeStore from '../store';
|
||||
import { getCertificatesApiUrl } from './data/api';
|
||||
import { fetchCertificates } from './data/thunks';
|
||||
import { certificatesDataMock } from './__mocks__';
|
||||
@@ -19,26 +14,13 @@ let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-123';
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<Certificates courseId={courseId} {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
|
||||
|
||||
describe('Certificates', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
it('renders WithoutModes when there are certificates but no certificate modes', async () => {
|
||||
@@ -129,11 +111,13 @@ describe('Certificates', () => {
|
||||
.reply(200, noCertificatesMock);
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { queryByTestId, getByTestId, getByRole } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
await waitFor(async () => {
|
||||
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
|
||||
userEvent.click(addCertificateButton);
|
||||
await user.click(addCertificateButton);
|
||||
});
|
||||
|
||||
expect(getByTestId('certificates-create-form')).toBeInTheDocument();
|
||||
@@ -149,11 +133,13 @@ describe('Certificates', () => {
|
||||
.reply(200, certificatesDataMock);
|
||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
await waitFor(async () => {
|
||||
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
|
||||
userEvent.click(editCertificateButton);
|
||||
await user.click(editCertificateButton);
|
||||
});
|
||||
|
||||
expect(getByTestId('certificates-edit-form')).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
render, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -85,17 +87,19 @@ describe('CertificateCreateForm', () => {
|
||||
}],
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
|
||||
|
||||
userEvent.type(
|
||||
await user.type(
|
||||
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
|
||||
courseTitleOverrideValue,
|
||||
);
|
||||
userEvent.type(
|
||||
await user.type(
|
||||
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
|
||||
signatoryNameValue,
|
||||
);
|
||||
userEvent.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
|
||||
|
||||
axiosMock.onPost(
|
||||
getCertificateApiUrl(courseId),
|
||||
@@ -109,8 +113,9 @@ describe('CertificateCreateForm', () => {
|
||||
});
|
||||
|
||||
it('cancel certificates creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent();
|
||||
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
|
||||
@@ -127,13 +132,14 @@ describe('CertificateCreateForm', () => {
|
||||
});
|
||||
|
||||
it('add and delete signatory', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
getAllByRole, queryAllByRole, getByText, getByRole,
|
||||
} = renderComponent();
|
||||
|
||||
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
|
||||
|
||||
userEvent.click(addSignatoryBtn);
|
||||
await user.click(addSignatoryBtn);
|
||||
|
||||
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||
|
||||
@@ -141,13 +147,13 @@ describe('CertificateCreateForm', () => {
|
||||
expect(deleteIcons.length).toBe(2);
|
||||
});
|
||||
|
||||
userEvent.click(deleteIcons[0]);
|
||||
await user.click(deleteIcons[0]);
|
||||
|
||||
const confirModal = getByRole('dialog');
|
||||
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||
|
||||
userEvent.click(deleteIcons[0]);
|
||||
userEvent.click(deleteModalButton);
|
||||
await user.click(deleteIcons[0]);
|
||||
await user.click(deleteModalButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Provider, useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
@@ -86,24 +86,24 @@ describe('CertificateDetails', () => {
|
||||
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens confirm modal on delete button click', () => {
|
||||
it('opens confirm modal on delete button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, getByText } = renderComponent(defaultProps);
|
||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(deleteButton);
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches delete action on confirm modal action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = { ...defaultProps, courseId, certificateId };
|
||||
const { getByRole } = renderComponent(props);
|
||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(deleteButton);
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmActionButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(confirmActionButton);
|
||||
});
|
||||
const confirmActionButton = await screen.findByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
await user.click(confirmActionButton);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
|
||||
});
|
||||
|
||||
@@ -58,11 +58,12 @@ describe('CertificateDetails', () => {
|
||||
});
|
||||
|
||||
it('handles input change in create mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByPlaceholderText } = renderComponent(defaultProps);
|
||||
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
|
||||
const newInputValue = 'New Title';
|
||||
|
||||
userEvent.type(input, newInputValue);
|
||||
await user.type(input, newInputValue);
|
||||
|
||||
waitFor(() => {
|
||||
expect(input.value).toBe(newInputValue);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
render, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -68,15 +70,15 @@ describe('CertificateEditForm Component', () => {
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
|
||||
|
||||
userEvent.type(
|
||||
await user.type(
|
||||
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
|
||||
courseTitleOverrideValue,
|
||||
);
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
@@ -91,16 +93,17 @@ describe('CertificateEditForm Component', () => {
|
||||
});
|
||||
|
||||
it('deletes a certificate and updates the store', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onDelete(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(200);
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
const confirmDeleteModal = getByRole('dialog');
|
||||
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
await user.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
||||
|
||||
@@ -110,16 +113,17 @@ describe('CertificateEditForm Component', () => {
|
||||
});
|
||||
|
||||
it('updates loading status if delete fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onDelete(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
).reply(404);
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
const confirmDeleteModal = getByRole('dialog');
|
||||
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
await user.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||
|
||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
||||
|
||||
@@ -129,11 +133,12 @@ describe('CertificateEditForm Component', () => {
|
||||
});
|
||||
|
||||
it('cancel edit form', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
|
||||
});
|
||||
|
||||
@@ -88,20 +88,22 @@ describe('CertificateSignatories', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a new signatory when add button is clicked', () => {
|
||||
it('adds a new signatory when add button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
|
||||
|
||||
userEvent.click(getByText(messages.addSignatoryButton.defaultMessage));
|
||||
await user.click(getByText(messages.addSignatoryButton.defaultMessage));
|
||||
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getAllByRole } = renderComponent(defaultProps);
|
||||
|
||||
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
expect(deleteIcons.length).toBe(signatoriesMock.length);
|
||||
|
||||
userEvent.click(deleteIcons[0]);
|
||||
await user.click(deleteIcons[0]);
|
||||
|
||||
waitFor(() => {
|
||||
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
||||
|
||||
@@ -34,11 +34,12 @@ describe('Signatory Component', () => {
|
||||
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleEdit when the edit button is clicked', () => {
|
||||
it('calls handleEdit when the edit button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderSignatory(defaultProps);
|
||||
|
||||
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
|
||||
userEvent.click(editButton);
|
||||
await user.click(editButton);
|
||||
|
||||
expect(mockHandleEdit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -60,12 +60,13 @@ describe('Signatory Component', () => {
|
||||
});
|
||||
|
||||
it('handles input change', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = jest.fn();
|
||||
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
|
||||
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||
const newInputValue = 'Jane Doe';
|
||||
|
||||
userEvent.type(input, newInputValue, { name: 'signatories[0].name' });
|
||||
await user.type(input, newInputValue, { name: 'signatories[0].name' });
|
||||
|
||||
waitFor(() => {
|
||||
expect(handleChange).toHaveBeenCalledWith(expect.anything());
|
||||
@@ -73,7 +74,8 @@ describe('Signatory Component', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('opens image upload modal on button click', () => {
|
||||
it('opens image upload modal on button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, queryByRole } = renderSignatory(defaultProps);
|
||||
const replaceButton = getByRole(
|
||||
'button',
|
||||
@@ -82,28 +84,30 @@ describe('Signatory Component', () => {
|
||||
|
||||
expect(queryByRole('presentation')).not.toBeInTheDocument();
|
||||
|
||||
userEvent.click(replaceButton);
|
||||
await user.click(replaceButton);
|
||||
|
||||
expect(getByRole('presentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirm modal on delete icon click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByLabelText, getByText } = renderSignatory(defaultProps);
|
||||
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
|
||||
|
||||
userEvent.click(deleteIcon);
|
||||
await user.click(deleteIcon);
|
||||
|
||||
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels deletion of a signatory', () => {
|
||||
it('cancels deletion of a signatory', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderSignatory(defaultProps);
|
||||
|
||||
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||
userEvent.click(deleteIcon);
|
||||
await user.click(deleteIcon);
|
||||
|
||||
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
|
||||
userEvent.click(cancelButton);
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
render, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -62,6 +64,7 @@ describe('CertificatesList Component', () => {
|
||||
});
|
||||
|
||||
it('update certificate', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
|
||||
} = renderComponent();
|
||||
@@ -80,13 +83,13 @@ describe('CertificatesList Component', () => {
|
||||
|
||||
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
||||
|
||||
userEvent.click(editButtons[1]);
|
||||
await user.click(editButtons[1]);
|
||||
|
||||
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
|
||||
userEvent.clear(nameInput);
|
||||
userEvent.type(nameInput, signatoryNameValue);
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, signatoryNameValue);
|
||||
|
||||
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||
await user.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||
|
||||
axiosMock
|
||||
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
|
||||
@@ -100,6 +103,7 @@ describe('CertificatesList Component', () => {
|
||||
});
|
||||
|
||||
it('toggle edit signatory', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
|
||||
} = renderComponent();
|
||||
@@ -107,13 +111,13 @@ describe('CertificatesList Component', () => {
|
||||
|
||||
expect(editButtons.length).toBe(3);
|
||||
|
||||
userEvent.click(editButtons[1]);
|
||||
await user.click(editButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
await user.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
||||
@@ -121,10 +125,11 @@ describe('CertificatesList Component', () => {
|
||||
});
|
||||
|
||||
it('toggle certificate edit all', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = renderComponent();
|
||||
const detailsSection = getByTestId('certificate-details');
|
||||
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
|
||||
userEvent.click(editButton);
|
||||
await user.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function createCertificate(courseId, certificatesData) {
|
||||
getCertificateApiUrl(courseId),
|
||||
prepareCertificatePayload(certificatesData),
|
||||
);
|
||||
|
||||
/* istanbul ignore next */
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function updateCertificate(courseId, certificateData) {
|
||||
getUpdateCertificateApiUrl(courseId, certificateData.id),
|
||||
prepareCertificatePayload(certificateData),
|
||||
);
|
||||
/* istanbul ignore next */
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,11 @@ const slice = createSlice({
|
||||
fetchCertificatesSuccess: (state, { payload }) => {
|
||||
Object.assign(state.certificatesData, payload);
|
||||
},
|
||||
createCertificateSuccess: (state, action) => {
|
||||
createCertificateSuccess: /* istanbul ignore next */ (state, action) => {
|
||||
state.certificatesData.certificates.push(action.payload);
|
||||
},
|
||||
updateCertificateSuccess: (state, action) => {
|
||||
updateCertificateSuccess: /* istanbul ignore next */ (state, action) => {
|
||||
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
|
||||
|
||||
if (index !== -1) {
|
||||
state.certificatesData.certificates[index] = action.payload;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* istanbul ignore file */
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../../store';
|
||||
// @ts-check
|
||||
import CertificatesSidebar from './CertificatesSidebar';
|
||||
import messages from './messages';
|
||||
import { initializeMocks, render, waitFor } from '../../../testUtils';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
@@ -17,25 +12,11 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<CertificatesSidebar courseId={courseId} {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
const renderComponent = (props) => render(<CertificatesSidebar courseId={courseId} {...props} />);
|
||||
|
||||
describe('CertificatesSidebar', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
|
||||
@@ -53,16 +53,17 @@ describe('HeaderButtons Component', () => {
|
||||
});
|
||||
|
||||
it('updates preview URL param based on selected dropdown item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent();
|
||||
const previewLink = getByRole('link', { name: messages.headingActionsPreview.defaultMessage });
|
||||
|
||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
|
||||
|
||||
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
||||
userEvent.click(dropdownButton);
|
||||
await user.click(dropdownButton);
|
||||
|
||||
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
userEvent.click(verifiedMode);
|
||||
await user.click(verifiedMode);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
|
||||
@@ -70,6 +71,7 @@ describe('HeaderButtons Component', () => {
|
||||
});
|
||||
|
||||
it('activates certificate when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const newCertificateData = {
|
||||
...certificatesDataMock,
|
||||
isActive: true,
|
||||
@@ -78,7 +80,7 @@ describe('HeaderButtons Component', () => {
|
||||
const { getByRole, queryByRole } = renderComponent();
|
||||
|
||||
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
|
||||
userEvent.click(activationButton);
|
||||
await user.click(activationButton);
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
@@ -97,6 +99,7 @@ describe('HeaderButtons Component', () => {
|
||||
});
|
||||
|
||||
it('deactivates certificate when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getCertificatesApiUrl(courseId))
|
||||
.reply(200, { ...certificatesDataMock, isActive: true });
|
||||
@@ -110,7 +113,7 @@ describe('HeaderButtons Component', () => {
|
||||
const { getByRole, queryByRole } = renderComponent();
|
||||
|
||||
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
|
||||
userEvent.click(deactivateButton);
|
||||
await user.click(deactivateButton);
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
|
||||
@@ -43,7 +43,7 @@ export const COURSE_CREATOR_STATES = {
|
||||
granted: 'granted',
|
||||
denied: 'denied',
|
||||
disallowedForThisSite: 'disallowed_for_this_site',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const DECODED_ROUTES = {
|
||||
COURSE_UNIT: [
|
||||
@@ -105,4 +105,5 @@ export const iframeMessageTypes = {
|
||||
resize: 'plugin.resize',
|
||||
videoFullScreen: 'plugin.videoFullScreen',
|
||||
xblockEvent: 'xblock-event',
|
||||
xblockScroll: 'xblock-scroll',
|
||||
};
|
||||
@@ -112,7 +112,6 @@ const CustomLoadingIndicator = () => {
|
||||
return (
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -508,6 +508,7 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should handle search term change', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const {
|
||||
getByText, getByRole, getByDisplayValue,
|
||||
} = await getComponent();
|
||||
@@ -523,7 +524,7 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
const searchTerm = 'memo';
|
||||
|
||||
// Trigger a change in the search field
|
||||
userEvent.type(searchField, searchTerm);
|
||||
await user.type(searchField, searchTerm);
|
||||
|
||||
await act(async () => {
|
||||
// Fast-forward time by 500 milliseconds (for the debounce delay)
|
||||
@@ -535,14 +536,14 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
|
||||
|
||||
// Clear search
|
||||
userEvent.clear(searchField);
|
||||
fireEvent.change(searchField, { target: { value: '' } });
|
||||
|
||||
// Check that the search term has been cleared
|
||||
expect(searchField).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should close dropdown selector when clicking away', async () => {
|
||||
const { getByText, queryByText } = await getComponent();
|
||||
const { container, getByText, queryByText } = await getComponent();
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
@@ -554,10 +555,9 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Simulate clicking outside the dropdown remove focus
|
||||
userEvent.click(document.body);
|
||||
|
||||
// Simulate clicking outside the dropdown again to close it
|
||||
userEvent.click(document.body);
|
||||
const outsideElement = container.querySelector('.taxonomy-tags-count-chip');
|
||||
const selectElement = container.querySelector('.react-select-add-tags__input');
|
||||
fireEvent.blur(selectElement, { relatedTarget: outsideElement });
|
||||
|
||||
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
|
||||
// the page
|
||||
@@ -565,6 +565,7 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should test keyboard navigation of add tags widget', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const {
|
||||
getByText,
|
||||
queryByText,
|
||||
@@ -598,59 +599,61 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
*/
|
||||
|
||||
// Press tab to focus on first element in dropdown, Tag 1 should be focused
|
||||
userEvent.tab();
|
||||
await user.keyboard('{Tab}');
|
||||
|
||||
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||
userEvent.keyboard('{arrowright}');
|
||||
await user.keyboard('{arrowright}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||
|
||||
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
|
||||
userEvent.keyboard('{arrowleft}');
|
||||
await user.keyboard('{arrowleft}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(1);
|
||||
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
|
||||
|
||||
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||
userEvent.keyboard('{enter}');
|
||||
await user.keyboard('{enter}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||
|
||||
// Press down arrow to navigate to Tag 1.1, it should be focused
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
await user.keyboard('{arrowdown}');
|
||||
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||
|
||||
// Press down arrow again to navigate to Tag 1.2, it should be fouced
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
await user.keyboard('{arrowdown}');
|
||||
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press down arrow again to navigate to Tag 2, it should be fouced
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
await user.keyboard('{arrowdown}');
|
||||
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag2Div).toHaveFocus();
|
||||
|
||||
// Press up arrow to navigate back to Tag 1.2, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
await user.keyboard('{arrowup}');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press up arrow to navigate back to Tag 1.1, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
await user.keyboard('{arrowup}');
|
||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||
|
||||
// Press up arrow again to navigate to Tag 1, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
await user.keyboard('{arrowup}');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
await user.keyboard('{arrowdown}');
|
||||
await user.keyboard('{arrowdown}');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press space key to check Tag 1.2, it should be staged
|
||||
userEvent.keyboard('{space}');
|
||||
await user.keyboard('[Space]');
|
||||
|
||||
const taxonomyId = 123;
|
||||
const addedStagedTag = {
|
||||
value: 'Tag%201,Tag%201.2',
|
||||
@@ -659,35 +662,35 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
|
||||
|
||||
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
|
||||
userEvent.keyboard('{enter}');
|
||||
await user.keyboard('{enter}');
|
||||
const tagValue = 'Tag%201,Tag%201.2';
|
||||
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
|
||||
|
||||
// Press left arrow to navigate back to Tag 1, it should be focused
|
||||
userEvent.keyboard('{arrowleft}');
|
||||
await user.keyboard('{arrowleft}');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press tab key it should jump to cancel button, it should be focused
|
||||
userEvent.tab();
|
||||
await user.keyboard('{Tab}');
|
||||
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
||||
expect(dropdownCancel).toHaveFocus();
|
||||
|
||||
// Press tab again, it should exit and close the select menu, since there are not staged tags
|
||||
userEvent.tab();
|
||||
await user.keyboard('{Tab}');
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
|
||||
// Press shift tab, focus back on select menu input, it should open the menu
|
||||
userEvent.tab({ shift: true });
|
||||
await user.tab({ shift: true });
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Press shift tab again, it should focus out and close the select menu
|
||||
userEvent.tab({ shift: true });
|
||||
await user.tab({ shift: true });
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
|
||||
// Press tab again, the select menu should open, then press escape, it should close
|
||||
userEvent.tab();
|
||||
await user.keyboard('{Tab}');
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
userEvent.keyboard('{escape}');
|
||||
await user.keyboard('{escape}');
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -699,7 +702,7 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
|
||||
name: /delete/i,
|
||||
});
|
||||
await userEvent.click(xButtonAppliedTag);
|
||||
fireEvent.click(xButtonAppliedTag);
|
||||
|
||||
// Check that the applied tag has been removed
|
||||
expect(appliedTag).not.toBeInTheDocument();
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
languageWithoutTagsId,
|
||||
largeTagsId,
|
||||
emptyTagsId,
|
||||
containerTagsId,
|
||||
} = mockContentTaxonomyTagsData;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -46,14 +47,15 @@ jest.mock('../library-authoring/common/context/SidebarContext', () => ({
|
||||
useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }),
|
||||
}));
|
||||
|
||||
const renderDrawer = (contentId, drawerParams = {}) => (
|
||||
render(
|
||||
const renderDrawer = (contentId, drawerParams = {}, renderPath = path, containerId = '') => {
|
||||
const params = { contentId, containerId };
|
||||
return render(
|
||||
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
|
||||
<ContentTagsDrawer {...drawerParams} />
|
||||
</ContentTagsDrawerSheetContext.Provider>,
|
||||
{ path, params: { contentId } },
|
||||
)
|
||||
);
|
||||
{ path: renderPath, params },
|
||||
);
|
||||
};
|
||||
|
||||
describe('<ContentTagsDrawer />', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -692,6 +694,42 @@ describe('<ContentTagsDrawer />', () => {
|
||||
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
|
||||
});
|
||||
|
||||
[
|
||||
'lct:org:lib:unit:1',
|
||||
'lib-collection:org:lib:1',
|
||||
'lb:org:lib:html:1',
|
||||
].forEach((containerId) => {
|
||||
it(`should invalidate children query when update child tag when containerId is ${containerId}`, async () => {
|
||||
const newPath = '/container/:containerId/';
|
||||
const { axiosMock, queryClient } = initializeMocks();
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const url = getContentTaxonomyTagsApiUrl(containerTagsId);
|
||||
axiosMock.onPut(url).reply(200);
|
||||
renderDrawer(containerTagsId, { id: containerTagsId }, newPath, containerId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
const editTagsButton = screen.getByRole('button', {
|
||||
name: /edit tags/i,
|
||||
});
|
||||
fireEvent.click(editTagsButton);
|
||||
|
||||
const saveButton = screen.getByRole('button', {
|
||||
name: /save/i,
|
||||
});
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
|
||||
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
|
||||
'contentLibrary',
|
||||
'lib:org:lib',
|
||||
'content',
|
||||
'container',
|
||||
containerId,
|
||||
'children',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should taxonomies must be ordered', async () => {
|
||||
renderDrawer(largeTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
@@ -89,7 +89,6 @@ const ContentTagsDrawerTitle = () => {
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
</div>
|
||||
@@ -149,7 +148,6 @@ const ContentTagsDrawerVariantFooter = ({ onClose, readOnly }: ContentTagsDrawer
|
||||
: (
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
)}
|
||||
@@ -197,7 +195,6 @@ const ContentTagsComponentVariantFooter = ({ readOnly = false }: ContentTagsComp
|
||||
<div className="d-flex justify-content-center">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -234,7 +234,6 @@ const ContentTagsDropDownSelector = ({
|
||||
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -205,7 +205,7 @@ mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+typ
|
||||
mockContentTaxonomyTagsData.emptyTags = {
|
||||
taxonomies: [],
|
||||
};
|
||||
mockContentTaxonomyTagsData.containerTagsId = 'lct:org:lib:unit:container_tags';
|
||||
mockContentTaxonomyTagsData.containerTagsId = 'lct:StagedTagsOrg:lib:unit:container_tags';
|
||||
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,7 +112,7 @@ 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 {string} contentId The id of the content object
|
||||
* @param {boolean} enabled Flag to enable/disable the query
|
||||
*/
|
||||
export const useContentData = (contentId, enabled) => (
|
||||
@@ -130,7 +130,7 @@ export const useContentData = (contentId, enabled) => (
|
||||
export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
const queryClient = useQueryClient();
|
||||
const unitIframe = window.frames['xblock-iframe'];
|
||||
const { unitId } = useParams();
|
||||
const { containerId } = useParams();
|
||||
|
||||
return useMutation({
|
||||
/**
|
||||
@@ -143,7 +143,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
* >}
|
||||
*/
|
||||
mutationFn: ({ tagsData }) => updateContentTaxonomyTags(contentId, tagsData),
|
||||
onSettled: /* istanbul ignore next */ () => {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
/// Invalidate query with pattern on course outline
|
||||
let contentPattern;
|
||||
@@ -160,9 +160,10 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
|
||||
// Invalidate content search to update tags count
|
||||
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
// If the tags for a compoent were edited from Unit page, invalidate children query to fetch count again.
|
||||
if (unitId) {
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
|
||||
// If the tags for an item were edited from a container page (Unit, Subsection, Section),
|
||||
// invalidate children query to fetch count again.
|
||||
if (containerId) {
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(containerId));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -33,4 +33,4 @@ AriaLiveRegion.propTypes = {
|
||||
enableQuality: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AriaLiveRegion);
|
||||
export default AriaLiveRegion;
|
||||
|
||||
@@ -2,11 +2,10 @@ import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, Button, Icon } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { getWaffleFlags } from '../../data/selectors';
|
||||
import { useWaffleFlags } from '../../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const getUpdateLinks = (courseId, waffleFlags) => {
|
||||
@@ -35,7 +34,7 @@ const ChecklistItemBody = ({
|
||||
isCompleted,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const waffleFlags = useSelector(getWaffleFlags);
|
||||
const waffleFlags = useWaffleFlags(courseId);
|
||||
const updateLinks = getUpdateLinks(courseId, waffleFlags);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ModeComment } from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getWaffleFlags } from '../../data/selectors';
|
||||
import { useWaffleFlags } from '../../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const ChecklistItemComment = ({
|
||||
@@ -13,7 +12,7 @@ const ChecklistItemComment = ({
|
||||
checkId,
|
||||
data,
|
||||
}) => {
|
||||
const waffleFlags = useSelector(getWaffleFlags);
|
||||
const waffleFlags = useWaffleFlags(courseId);
|
||||
|
||||
const getPathToCourseOutlinePage = (assignmentId) => (waffleFlags.useNewCourseOutlinePage
|
||||
? `/course/${courseId}#${assignmentId}` : `${getConfig().STUDIO_BASE_URL}/course/${courseId}#${assignmentId}`);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Container, Stack } from '@openedx/paragon';
|
||||
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import { getCompletionCount, useChecklistState } from './hooks';
|
||||
import ChecklistItemBody from './ChecklistItemBody';
|
||||
@@ -129,4 +127,4 @@ ChecklistSection.propTypes = {
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistSection);
|
||||
export default ChecklistSection;
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
initializeMocks, render, screen, within,
|
||||
} from '../../testUtils';
|
||||
import { getApiWaffleFlagsUrl } from '../../data/api';
|
||||
import { fetchWaffleFlags } from '../../data/thunks';
|
||||
import { generateCourseLaunchData } from '../factories/mockApiResponses';
|
||||
import { executeThunk } from '../../utils';
|
||||
import { checklistItems } from './utils/courseChecklistData';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -34,7 +32,7 @@ const renderComponent = (props) => {
|
||||
|
||||
describe('ChecklistSection', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock, reduxStore } = initializeMocks();
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {
|
||||
@@ -43,7 +41,6 @@ describe('ChecklistSection', () => {
|
||||
useNewScheduleDetailsPage: true,
|
||||
useNewCourseOutlinePage: true,
|
||||
});
|
||||
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
|
||||
});
|
||||
|
||||
it('a heading using the dataHeading prop', () => {
|
||||
|
||||
@@ -71,16 +71,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Learners engage best with short videos followed by opportunities to practice. Ensure that 80% or more of course videos are less than 10 minutes long.',
|
||||
description: 'Description for a section that prompts a user to follow best practices for video length',
|
||||
},
|
||||
mobileFriendlyVideoShortDescription: {
|
||||
id: 'mobileFriendlyVideoShortDescription',
|
||||
defaultMessage: 'Create mobile-friendly video',
|
||||
description: 'Label for a section that describes mobile friendly videos',
|
||||
},
|
||||
mobileFriendlyVideoLongDescription: {
|
||||
id: 'mobileFriendlyVideoLongDescription',
|
||||
defaultMessage: 'Mobile-friendly videos can be viewed across all supported devices. Ensure that at least 90% of course videos are mobile friendly by uploading course videos to the edX video pipeline.',
|
||||
description: 'Description for a section that prompts a user to follow best practices for mobile friendly videos',
|
||||
},
|
||||
diverseSequencesShortDescription: {
|
||||
id: 'diverseSequencesShortDescription',
|
||||
defaultMessage: 'Build diverse learning sequences',
|
||||
|
||||
@@ -36,10 +36,6 @@ export const checklistItems = {
|
||||
id: 'videoDuration',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'mobileFriendlyVideo',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
},
|
||||
{
|
||||
id: 'diverseSequences',
|
||||
pacingTypeFilter: filters.ALL,
|
||||
|
||||
@@ -35,18 +35,8 @@ export const hasAssignmentDeadlines = (assignments, dates) => {
|
||||
|
||||
export const hasShortVideoDuration = (videos) => {
|
||||
if (videos.totalNumber === 0) {
|
||||
return true;
|
||||
} if (videos.totalNumber > 0 && videos.durations.median <= 600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasMobileFriendlyVideos = (videos) => {
|
||||
if (videos.totalNumber === 0) {
|
||||
return true;
|
||||
} if (videos.totalNumber > 0 && (videos.numMobileEncoded / videos.totalNumber) >= 0.9) {
|
||||
return false;
|
||||
} if (videos.totalNumber > 0 && videos.durations.median !== null && videos.durations.median <= 600) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -57,7 +47,7 @@ export const hasDiverseSequences = (subsections) => {
|
||||
if (subsections.totalVisible === 0) {
|
||||
return false;
|
||||
} if (subsections.totalVisible > 0) {
|
||||
return ((subsections.numWithOneBlockType / subsections.totalVisible) < 0.2);
|
||||
return ((subsections.numWithOneBlockType / subsections.totalVisible) <= 0.2);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -68,7 +58,7 @@ export const hasWeeklyHighlights = sections => (
|
||||
);
|
||||
|
||||
export const hasShortUnitDepth = units => (
|
||||
units.numBlocks.median <= 3
|
||||
units.numBlocks.median <= 3 && units.totalVisible > 0
|
||||
);
|
||||
|
||||
export const hasProctoringEscalationEmail = proctoring => (
|
||||
|
||||
@@ -189,8 +189,8 @@ describe('courseCheckValidators utility functions', () => {
|
||||
);
|
||||
|
||||
describe('hasShortVideoDuration', () => {
|
||||
it('returns true if course run has no videos', () => {
|
||||
expect(validators.hasShortVideoDuration({ totalNumber: 0 })).toEqual(true);
|
||||
it('returns false if course run has no videos', () => {
|
||||
expect(validators.hasShortVideoDuration({ totalNumber: 0 })).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true if course run videos have a median duration <= to 600', () => {
|
||||
@@ -204,22 +204,6 @@ describe('courseCheckValidators utility functions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMobileFriendlyVideos', () => {
|
||||
it('returns true if course run has no videos', () => {
|
||||
expect(validators.hasMobileFriendlyVideos({ totalNumber: 0 })).toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true if course run videos are >= 90% mobile friendly', () => {
|
||||
expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 9 }))
|
||||
.toEqual(true);
|
||||
});
|
||||
|
||||
it('returns true if course run videos are < 90% mobile friendly', () => {
|
||||
expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 8 }))
|
||||
.toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDiverseSequences', () => {
|
||||
it('returns true if < 20% of visible subsections have more than one block type', () => {
|
||||
expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 1 }))
|
||||
@@ -264,6 +248,7 @@ describe('courseCheckValidators utility functions', () => {
|
||||
describe('hasShortUnitDepth', () => {
|
||||
it('returns true when course run has median number of blocks <= 3', () => {
|
||||
const units = {
|
||||
totalVisible: 2,
|
||||
numBlocks: {
|
||||
median: 3,
|
||||
},
|
||||
@@ -274,6 +259,7 @@ describe('courseCheckValidators utility functions', () => {
|
||||
|
||||
it('returns false when course run has median number of blocks > 3', () => {
|
||||
const units = {
|
||||
totalVisible: 2,
|
||||
numBlocks: {
|
||||
median: 4,
|
||||
},
|
||||
|
||||
@@ -14,8 +14,6 @@ const getValidatedValue = (data, id) => {
|
||||
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
|
||||
case 'videoDuration':
|
||||
return healthValidators.hasShortVideoDuration(data.videos);
|
||||
case 'mobileFriendlyVideo':
|
||||
return healthValidators.hasMobileFriendlyVideos(data.videos);
|
||||
case 'diverseSequences':
|
||||
return healthValidators.hasDiverseSequences(data.subsections);
|
||||
case 'weeklyHighlights':
|
||||
|
||||
@@ -88,20 +88,6 @@ describe('getValidatedValue utility function', () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mobile friendly video', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasMobileFriendlyVideos = spy;
|
||||
|
||||
const props = {
|
||||
data: {
|
||||
videos: {},
|
||||
},
|
||||
};
|
||||
|
||||
getValidatedValue(props, 'mobileFriendlyVideo');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('diverse sequences', () => {
|
||||
const spy = jest.fn();
|
||||
localValidators.hasDiverseSequences = spy;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Container, Stack } from '@openedx/paragon';
|
||||
@@ -17,9 +17,8 @@ import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
|
||||
const CourseChecklist = ({
|
||||
courseId,
|
||||
// injected,
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
|
||||
@@ -97,8 +96,6 @@ const CourseChecklist = ({
|
||||
|
||||
CourseChecklist.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseChecklist);
|
||||
export default CourseChecklist;
|
||||
|
||||
@@ -75,51 +75,54 @@ describe('<CourseLibraries />', () => {
|
||||
});
|
||||
|
||||
it('shows alert when out of sync components are present', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
|
||||
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
|
||||
// review tab should be open by default as outOfSyncCount is greater than 0
|
||||
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
userEvent.click(allTab);
|
||||
await user.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',
|
||||
'7 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);
|
||||
await user.click(reviewBtn);
|
||||
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'false');
|
||||
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(alert).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hide alert on dismiss', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
|
||||
// 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);
|
||||
await user.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',
|
||||
'7 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);
|
||||
await user.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');
|
||||
await user.click(reviewActionBtn);
|
||||
expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('show alert if max lastPublishedDate is greated than the local storage value', async () => {
|
||||
const user = userEvent.setup();
|
||||
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
|
||||
localStorage.setItem(
|
||||
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
|
||||
@@ -128,18 +131,19 @@ describe('<CourseLibraries />', () => {
|
||||
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
|
||||
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
|
||||
// review tab should be open by default as outOfSyncCount is greater than 0
|
||||
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
userEvent.click(allTab);
|
||||
await user.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',
|
||||
'7 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 user = userEvent.setup();
|
||||
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
|
||||
localStorage.setItem(
|
||||
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
|
||||
@@ -148,14 +152,12 @@ describe('<CourseLibraries />', () => {
|
||||
|
||||
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
|
||||
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
|
||||
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
|
||||
// review tab should be open by default as outOfSyncCount is greater than 0
|
||||
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
|
||||
userEvent.click(allTab);
|
||||
await user.click(allTab);
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
screen.logTestingPlaygroundURL();
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -191,94 +193,138 @@ describe('<CourseLibraries ReviewTab />', () => {
|
||||
});
|
||||
|
||||
it('shows all readyToSync links', async () => {
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
await renderCourseLibrariesReviewPage();
|
||||
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
|
||||
expect(updateBtns.length).toEqual(5);
|
||||
expect(updateBtns.length).toEqual(7);
|
||||
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
|
||||
expect(ignoreBtns.length).toEqual(5);
|
||||
expect(ignoreBtns.length).toEqual(7);
|
||||
});
|
||||
|
||||
it('update changes works', async () => {
|
||||
test.each([
|
||||
{
|
||||
label: 'update changes works with components',
|
||||
itemIndex: 0,
|
||||
expectedToastMsg: 'Success! "Dropdown" is updated',
|
||||
},
|
||||
{
|
||||
label: 'update changes works with containers',
|
||||
itemIndex: 5,
|
||||
expectedToastMsg: 'Success! "Unit 1" is updated',
|
||||
},
|
||||
])('$label', async ({ itemIndex, expectedToastMsg }) => {
|
||||
const user = userEvent.setup();
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
|
||||
const usageKey = mockGetEntityLinks.response[itemIndex].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]);
|
||||
expect(updateBtns.length).toEqual(7);
|
||||
await user.click(updateBtns[itemIndex]);
|
||||
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']);
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
|
||||
});
|
||||
|
||||
it('update changes works in preview modal', async () => {
|
||||
test.each([
|
||||
{
|
||||
label: 'update changes works in preview modal with components',
|
||||
itemIndex: 0,
|
||||
expectedToastMsg: 'Success! "Dropdown" is updated',
|
||||
},
|
||||
{
|
||||
label: 'update changes works in preview modal with containers',
|
||||
itemIndex: 5,
|
||||
expectedToastMsg: 'Success! "Unit 1" is updated',
|
||||
},
|
||||
])('$label', async ({ itemIndex, expectedToastMsg }) => {
|
||||
const user = userEvent.setup();
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
|
||||
const usageKey = mockGetEntityLinks.response[itemIndex].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]);
|
||||
expect(previewBtns.length).toEqual(7);
|
||||
await user.click(previewBtns[itemIndex]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
|
||||
userEvent.click(confirmBtn);
|
||||
await user.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']);
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
|
||||
});
|
||||
|
||||
it('ignore change works', async () => {
|
||||
test.each([
|
||||
{
|
||||
label: 'ignore change works with components',
|
||||
itemIndex: 0,
|
||||
expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
},
|
||||
{
|
||||
label: 'ignore change works with containers',
|
||||
itemIndex: 5,
|
||||
expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
},
|
||||
])('$label', async ({ itemIndex, expectedToastMsg }) => {
|
||||
const user = userEvent.setup();
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
|
||||
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
|
||||
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
|
||||
expect(ignoreBtns.length).toEqual(5);
|
||||
expect(ignoreBtns.length).toEqual(7);
|
||||
// Show confirmation modal on clicking ignore.
|
||||
userEvent.click(ignoreBtns[0]);
|
||||
await user.click(ignoreBtns[itemIndex]);
|
||||
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 user.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']);
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
|
||||
});
|
||||
|
||||
it('ignore change works in preview', async () => {
|
||||
test.each([
|
||||
{
|
||||
label: 'ignore change works with components',
|
||||
itemIndex: 0,
|
||||
expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
},
|
||||
{
|
||||
label: 'ignore change works with containers',
|
||||
itemIndex: 5,
|
||||
expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.',
|
||||
},
|
||||
])('$label', async ({ itemIndex, expectedToastMsg }) => {
|
||||
const user = userEvent.setup();
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
|
||||
const usageKey = mockGetEntityLinks.response[itemIndex].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]);
|
||||
expect(previewBtns.length).toEqual(7);
|
||||
await user.click(previewBtns[itemIndex]);
|
||||
const previewDialog = await screen.findByRole('dialog');
|
||||
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
|
||||
userEvent.click(ignoreBtn);
|
||||
await user.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 user.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']);
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 { invalidateLinksQuery, useEntityLinks } from './data/apiHooks';
|
||||
import {
|
||||
SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget,
|
||||
} from '../search-manager';
|
||||
@@ -35,27 +35,45 @@ import { useLoadOnScroll } from '../hooks';
|
||||
import DeleteModal from '../generic/delete-modal/DeleteModal';
|
||||
import { PublishableEntityLink } from './data/api';
|
||||
import AlertError from '../generic/alert-error';
|
||||
import NewsstandIcon from '../generic/NewsstandIcon';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
interface BlockCardProps {
|
||||
interface ItemCardProps {
|
||||
info: ContentHit;
|
||||
itemType: 'component' | 'container';
|
||||
actions?: React.ReactNode;
|
||||
libraryName?: string;
|
||||
}
|
||||
|
||||
const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
|
||||
const ItemCard: React.FC<ItemCardProps> = ({
|
||||
info,
|
||||
itemType,
|
||||
actions,
|
||||
libraryName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const componentIcon = getItemIcon(info.blockType);
|
||||
const itemIcon = getItemIcon(info.blockType);
|
||||
const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
|
||||
|
||||
const getBlockLink = useCallback(() => {
|
||||
const getItemLink = useCallback(() => {
|
||||
let key = info.usageKey;
|
||||
if (breadcrumbs?.length > 1) {
|
||||
key = breadcrumbs[breadcrumbs.length - 1].usageKey || key;
|
||||
}
|
||||
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
|
||||
|
||||
if (itemType === 'component') {
|
||||
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
|
||||
}
|
||||
if (itemType === 'container') {
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
return `${getConfig().STUDIO_BASE_URL}/course/${info.contextKey}?show=${encodedKey}`;
|
||||
}
|
||||
|
||||
// istanbul ignore next
|
||||
return '';
|
||||
}, [info]);
|
||||
|
||||
return (
|
||||
@@ -69,7 +87,7 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
|
||||
<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" />
|
||||
<Icon src={itemIcon} size="xs" />
|
||||
<BlockTypeLabel blockType={info.blockType} />
|
||||
</Stack>
|
||||
<Stack direction="horizontal" className="small" gap={1}>
|
||||
@@ -78,15 +96,27 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
|
||||
</strong>
|
||||
</Stack>
|
||||
<Stack direction="horizontal" className="micro" gap={3}>
|
||||
{libraryName && (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={NewsstandIcon} size="xs" />
|
||||
{libraryName}
|
||||
</Stack>
|
||||
)}
|
||||
{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 showLaunchIcon={false} destination={getItemLink()} target="_blank">
|
||||
{info.blockType === 'chapter' ? (
|
||||
<div className="micro text-gray-700 border-bottom">
|
||||
{intl.formatMessage(messages.viewSectionInCourseLabel)}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
@@ -97,16 +127,21 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ComponentReviewList = ({
|
||||
outOfSyncComponents,
|
||||
const ItemReviewList = ({
|
||||
outOfSyncItems,
|
||||
}: {
|
||||
outOfSyncComponents: PublishableEntityLink[];
|
||||
outOfSyncItems: 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);
|
||||
// Toggle preview changes modal
|
||||
const [isPreviewModalOpen, openPreviewModal, closePreviewModal] = useToggle(false);
|
||||
const acceptChangesMutation = useAcceptLibraryBlockChanges();
|
||||
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
|
||||
|
||||
const {
|
||||
hits,
|
||||
isLoading: isIndexDataLoading,
|
||||
@@ -125,32 +160,27 @@ const ComponentReviewList = ({
|
||||
true,
|
||||
);
|
||||
|
||||
const outOfSyncComponentsByKey = useMemo(
|
||||
() => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
|
||||
[outOfSyncComponents],
|
||||
const outOfSyncItemsByKey = useMemo(
|
||||
() => keyBy(outOfSyncItems, 'downstreamUsageKey'),
|
||||
[outOfSyncItems],
|
||||
);
|
||||
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',
|
||||
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
|
||||
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
|
||||
isContainer: info.blockType === 'vertical' || info.blockType === 'sequential' || info.blockType === 'chapter',
|
||||
});
|
||||
}, [outOfSyncComponentsByKey]);
|
||||
}, [outOfSyncItemsByKey]);
|
||||
|
||||
// Show preview changes on review
|
||||
const onReview = useCallback((info: ContentHit) => {
|
||||
setSelectedBlockData(info);
|
||||
openModal();
|
||||
}, [setSelectedBlockData, openModal]);
|
||||
openPreviewModal();
|
||||
}, [setSelectedBlockData, openPreviewModal]);
|
||||
|
||||
const onIgnoreClick = useCallback((info: ContentHit) => {
|
||||
setSelectedBlockData(info);
|
||||
@@ -158,9 +188,9 @@ const ComponentReviewList = ({
|
||||
}, [setSelectedBlockData, openConfirmModal]);
|
||||
|
||||
const reloadLinks = useCallback((usageKey: string) => {
|
||||
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
|
||||
queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey));
|
||||
}, [outOfSyncComponentsByKey]);
|
||||
const courseKey = outOfSyncItemsByKey[usageKey].downstreamContextKey;
|
||||
invalidateLinksQuery(queryClient, courseKey);
|
||||
}, [outOfSyncItemsByKey]);
|
||||
|
||||
const postChange = (accept: boolean) => {
|
||||
// istanbul ignore if: this should never happen
|
||||
@@ -224,9 +254,11 @@ const ComponentReviewList = ({
|
||||
return (
|
||||
<>
|
||||
{downstreamInfo?.map((info) => (
|
||||
<BlockCard
|
||||
<ItemCard
|
||||
key={info.usageKey}
|
||||
info={info}
|
||||
itemType={outOfSyncItemsByKey[info.usageKey]?.upstreamType}
|
||||
libraryName={outOfSyncItemsByKey[info.usageKey]?.upstreamContextTitle}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
@@ -260,8 +292,8 @@ const ComponentReviewList = ({
|
||||
{blockData && (
|
||||
<PreviewLibraryXBlockChanges
|
||||
blockData={blockData}
|
||||
isModalOpen={isModalOpen}
|
||||
closeModal={closeModal}
|
||||
isModalOpen={isPreviewModalOpen}
|
||||
closeModal={closePreviewModal}
|
||||
postChange={postChange}
|
||||
/>
|
||||
)}
|
||||
@@ -281,15 +313,19 @@ const ComponentReviewList = ({
|
||||
const ReviewTabContent = ({ courseId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
data: outOfSyncComponents,
|
||||
isLoading: isSyncComponentsLoading,
|
||||
data: outOfSyncItems,
|
||||
isLoading: isSyncItemsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useEntityLinks({ courseId, readyToSync: true });
|
||||
} = useEntityLinks({
|
||||
courseId,
|
||||
readyToSync: true,
|
||||
useTopLevelParents: true,
|
||||
});
|
||||
|
||||
const downstreamKeys = useMemo(
|
||||
() => outOfSyncComponents?.map(link => link.downstreamUsageKey),
|
||||
[outOfSyncComponents],
|
||||
() => outOfSyncItems?.map(link => link.downstreamUsageKey),
|
||||
[outOfSyncItems],
|
||||
);
|
||||
|
||||
const disableSortOptions = [
|
||||
@@ -299,7 +335,7 @@ const ReviewTabContent = ({ courseId }: Props) => {
|
||||
SearchSortOption.RECENTLY_PUBLISHED,
|
||||
];
|
||||
|
||||
if (isSyncComponentsLoading) {
|
||||
if (isSyncItemsLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -320,8 +356,8 @@ const ReviewTabContent = ({ courseId }: Props) => {
|
||||
<SearchSortWidget disableOptions={disableSortOptions} />
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
<ComponentReviewList
|
||||
outOfSyncComponents={outOfSyncComponents}
|
||||
<ItemReviewList
|
||||
outOfSyncItems={outOfSyncItems}
|
||||
/>
|
||||
</SearchContextProvider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"readyToSyncCount": 5,
|
||||
"readyToSyncCount": 7,
|
||||
"totalCount": 14,
|
||||
"lastPublishedAt": "2025-05-01T20:20:44.989042Z"
|
||||
},
|
||||
|
||||
@@ -364,13 +364,125 @@
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Unit 1",
|
||||
"block_id": "20f2c12b8b7f4b4bab7a900576c78ad8",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "This is a super unit"
|
||||
},
|
||||
"description": "This is a super unit",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeverticalblocka20f2c12b8b7f4b4bab7a900576c78ad8-efa48aff",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8",
|
||||
"block_type": "vertical",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "Unit 1",
|
||||
"block_id": "20f2c12b8b7f4b4bab7a900576c78ad8",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "This is a super unit"
|
||||
},
|
||||
"description": "This is a super unit",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypeverticalblocka20f2c12b8b7f4b4bab7a900576c78ad8-efa48aff",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8",
|
||||
"block_type": "vertical",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Section 1",
|
||||
"block_id": "20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "This is a super section"
|
||||
},
|
||||
"description": "This is a super section",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypechapterblocka20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"block_type": "chapter",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": 4,
|
||||
"_formatted": {
|
||||
"display_name": "Section 1",
|
||||
"block_id": "20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"content": {
|
||||
"problem_types": [
|
||||
"optionresponse"
|
||||
],
|
||||
"capa_content": "This is a super section"
|
||||
},
|
||||
"description": "This is a super section",
|
||||
"tags": {},
|
||||
"id": "block-v1itcracydemoxcoursextypechapterblocka20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "OpenedX Demo Course"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"block_type": "chapter",
|
||||
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"org": "OpenEdx",
|
||||
"access_id": "4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 8,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 5
|
||||
"estimatedTotalHits": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamType": "component",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
@@ -18,7 +19,8 @@
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamType": "component",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
@@ -32,7 +34,8 @@
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 26,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
|
||||
"upstreamKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
|
||||
"upstreamType": "component",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
@@ -46,7 +49,8 @@
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamType": "component",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
@@ -60,7 +64,8 @@
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 10,
|
||||
"readyToSync": true,
|
||||
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
|
||||
"upstreamType": "component",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
@@ -68,5 +73,35 @@
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
{
|
||||
"id": 891,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 17,
|
||||
"readyToSync": true,
|
||||
"upstreamKey": "lct:OpenedX:CSPROB3:unit:cb367f92-bf7d-4d08-86cd-aae9efa48aff",
|
||||
"upstreamType": "container",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8",
|
||||
"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": 892,
|
||||
"upstreamContextTitle": "CS problems 3",
|
||||
"upstreamVersion": 3,
|
||||
"readyToSync": true,
|
||||
"upstreamKey": "lct:OpenedX:CSPROB3:section:9a90e4e4-cdf9-48ce-89b5-a8f9f07ccbfc",
|
||||
"upstreamType": "container",
|
||||
"upstreamContextKey": "lib:OpenedX:CSPROB3",
|
||||
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5",
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/* istanbul ignore file */
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
import * as libApi from '@src/library-authoring/data/api';
|
||||
import { createAxiosError } from '@src/testUtils';
|
||||
|
||||
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,
|
||||
@@ -61,7 +57,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext(
|
||||
throw createAxiosError({
|
||||
code: 404,
|
||||
message: 'Not found.',
|
||||
path: api.getEntityLinksByDownstreamContextUrl(),
|
||||
path: api.getEntityLinksSummaryByDownstreamContextUrl(courseId),
|
||||
});
|
||||
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading:
|
||||
return new Promise(() => {});
|
||||
|
||||
@@ -4,7 +4,6 @@ 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> {
|
||||
@@ -18,9 +17,8 @@ export interface PaginatedData<T> {
|
||||
results: T,
|
||||
}
|
||||
|
||||
export interface PublishableEntityLink {
|
||||
export interface BasePublishableEntityLink {
|
||||
id: number;
|
||||
upstreamUsageKey: string;
|
||||
upstreamContextKey: string;
|
||||
upstreamContextTitle: string;
|
||||
upstreamVersion: number;
|
||||
@@ -33,6 +31,19 @@ export interface PublishableEntityLink {
|
||||
readyToSync: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {
|
||||
upstreamUsageKey: string;
|
||||
}
|
||||
|
||||
export interface ContainerPublishableEntityLink extends BasePublishableEntityLink {
|
||||
upstreamContainerKey: string;
|
||||
}
|
||||
|
||||
export interface PublishableEntityLink extends BasePublishableEntityLink {
|
||||
upstreamKey: string;
|
||||
upstreamType: 'component' | 'container';
|
||||
}
|
||||
|
||||
export interface PublishableEntityLinkSummary {
|
||||
upstreamContextKey: string;
|
||||
upstreamContextTitle: string;
|
||||
@@ -44,14 +55,18 @@ export interface PublishableEntityLinkSummary {
|
||||
export const getEntityLinks = async (
|
||||
downstreamContextKey?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
useTopLevelParents?: boolean,
|
||||
upstreamKey?: string,
|
||||
contentType?: 'all' | 'components' | 'containers',
|
||||
): Promise<PublishableEntityLink[]> => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getEntityLinksByDownstreamContextUrl(), {
|
||||
params: {
|
||||
course_id: downstreamContextKey,
|
||||
ready_to_sync: readyToSync,
|
||||
upstream_usage_key: upstreamUsageKey,
|
||||
upstream_key: upstreamKey,
|
||||
use_top_level_parents: useTopLevelParents,
|
||||
item_type: contentType,
|
||||
no_page: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,6 +39,24 @@ describe('course libraries api hooks', () => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('should return component links for course', async () => {
|
||||
const courseId = 'course-v1:some+key';
|
||||
const url = getEntityLinksByDownstreamContextUrl();
|
||||
axiosMock.onGet(url).reply(200, []);
|
||||
const { result } = renderHook(() => useEntityLinks({ courseId, contentType: 'components' }), { 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_key: undefined,
|
||||
no_page: true,
|
||||
item_type: 'components',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return links for course', async () => {
|
||||
const courseId = 'course-v1:some+key';
|
||||
const url = getEntityLinksByDownstreamContextUrl();
|
||||
@@ -53,6 +71,7 @@ describe('course libraries api hooks', () => {
|
||||
ready_to_sync: undefined,
|
||||
upstream_usage_key: undefined,
|
||||
no_page: true,
|
||||
content_type: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,55 +1,63 @@
|
||||
import {
|
||||
type QueryClient,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
|
||||
import { getEntityLinksSummaryByDownstreamContext, getEntityLinks } from './api';
|
||||
|
||||
export const courseLibrariesQueryKeys = {
|
||||
all: ['courseLibraries'],
|
||||
courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId],
|
||||
courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: {
|
||||
courseReadyToSyncLibraries: ({
|
||||
contentType, courseId, readyToSync, upstreamKey,
|
||||
}: {
|
||||
contentType?: 'all' | 'components' | 'containers',
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
upstreamKey?: string,
|
||||
pageSize?: number,
|
||||
}) => {
|
||||
const key: Array<string | boolean | number> = [...courseLibrariesQueryKeys.all];
|
||||
if (courseId !== undefined) {
|
||||
key.push(courseId);
|
||||
}
|
||||
if (contentType !== undefined) {
|
||||
key.push(contentType);
|
||||
}
|
||||
if (readyToSync !== undefined) {
|
||||
key.push(readyToSync);
|
||||
}
|
||||
if (upstreamUsageKey !== undefined) {
|
||||
key.push(upstreamUsageKey);
|
||||
if (upstreamKey !== undefined) {
|
||||
key.push(upstreamKey);
|
||||
}
|
||||
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, readyToSync, useTopLevelParents, upstreamKey, contentType,
|
||||
}: {
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
useTopLevelParents?: boolean,
|
||||
upstreamKey?: string,
|
||||
contentType?: 'all' | 'components' | 'containers',
|
||||
}) => (
|
||||
useQuery({
|
||||
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
|
||||
contentType: contentType ?? 'all',
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamUsageKey,
|
||||
upstreamKey,
|
||||
}),
|
||||
queryFn: () => getEntityLinks(
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamUsageKey,
|
||||
useTopLevelParents,
|
||||
upstreamKey,
|
||||
contentType,
|
||||
),
|
||||
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
|
||||
enabled: courseId !== undefined || upstreamKey !== undefined || readyToSync !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -63,3 +71,12 @@ export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => (
|
||||
enabled: courseId !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Ivalidates the downstream links query for a course
|
||||
*/
|
||||
export const invalidateLinksQuery = (queryClient: QueryClient, courseId: string) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseLibrariesQueryKeys.courseLibraries(courseId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { CourseLibraries } from './CourseLibraries';
|
||||
export { courseLibrariesQueryKeys } from './data/apiHooks';
|
||||
|
||||
@@ -39,7 +39,7 @@ const messages = defineMessages({
|
||||
breadcrumbLabel: {
|
||||
id: 'course-authoring.course-libraries.downstream-block.breadcrumb.label',
|
||||
defaultMessage: 'Location:',
|
||||
description: 'label for breadcrumb in component cards in course libraries page.',
|
||||
description: 'Label for breadcrumb in component cards in course libraries page.',
|
||||
},
|
||||
totalComponentLabel: {
|
||||
id: 'course-authoring.course-libraries.libcard.total-component.label',
|
||||
@@ -79,17 +79,17 @@ const messages = defineMessages({
|
||||
cardReviewContentBtn: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.review-btn-text',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Card review button for component in review tab',
|
||||
description: 'Card review button for component/container in review tab',
|
||||
},
|
||||
cardUpdateContentBtn: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.update-btn-text',
|
||||
defaultMessage: 'Update',
|
||||
description: 'Card update button for component in review tab',
|
||||
description: 'Card update button for component/container in review tab',
|
||||
},
|
||||
cardIgnoreContentBtn: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.ignore-btn-text',
|
||||
defaultMessage: 'Ignore',
|
||||
description: 'Card ignore button for component in review tab',
|
||||
description: 'Card ignore button for component/container in review tab',
|
||||
},
|
||||
updateSingleBlockSuccess: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.update-success-toast',
|
||||
@@ -116,6 +116,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Something went wrong! Could not fetch results.',
|
||||
description: 'Generic error message displayed when fetching link data fails.',
|
||||
},
|
||||
viewSectionInCourseLabel: {
|
||||
id: 'course-authoring.course-libraries.review-tab.libcard.view-section.label',
|
||||
defaultMessage: 'View Section in Course',
|
||||
description: 'Label of the button to see the section in the course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { clipboardUnit } from '@src/__mocks__';
|
||||
import { executeThunk } from '@src/utils';
|
||||
import configureModalMessages from '@src/generic/configure-modal/messages';
|
||||
import pasteButtonMessages from '@src/generic/clipboard/paste-component/messages';
|
||||
import { getApiBaseUrl, getClipboardUrl } from '@src/generic/data/api';
|
||||
import { postXBlockBaseApiUrl } from '@src/course-unit/data/api';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
|
||||
import {
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor, within,
|
||||
} from '@src/testUtils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
@@ -21,15 +26,14 @@ import {
|
||||
getCourseItemApiUrl,
|
||||
getXBlockBaseApiUrl,
|
||||
exportTags,
|
||||
createDiscussionsTopicsUrl,
|
||||
} from './data/api';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
fetchCourseOutlineIndexQuery, syncDiscussionsTopics,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
} from './data/thunk';
|
||||
import initializeStore from '../store';
|
||||
import {
|
||||
courseOutlineIndexMock,
|
||||
courseOutlineIndexWithoutSections,
|
||||
@@ -38,15 +42,10 @@ import {
|
||||
courseSectionMock,
|
||||
courseSubsectionMock,
|
||||
} from './__mocks__';
|
||||
import { clipboardUnit } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
|
||||
import CourseOutline from './CourseOutline';
|
||||
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import pasteButtonMessages from '../generic/clipboard/paste-component/messages';
|
||||
import messages from './messages';
|
||||
import { getClipboardUrl } from '../generic/data/api';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
import cardHeaderMessages from './card-header/messages';
|
||||
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
|
||||
@@ -59,14 +58,13 @@ import {
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
} from './drag-helper/utils';
|
||||
import { postXBlockBaseApiUrl } from '../course-unit/data/api';
|
||||
import { COMPONENT_TYPES } from '../generic/block-type-utils/constants';
|
||||
|
||||
let axiosMock;
|
||||
let axiosMock: import('axios-mock-adapter/types');
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const containerKey = 'lct:org:lib:unit:1';
|
||||
const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1');
|
||||
const getContainerType = jest.fn().mockReturnValue('unit');
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
@@ -75,7 +73,7 @@ jest.mock('react-router-dom', () => ({
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../help-urls/hooks', () => ({
|
||||
jest.mock('@src/help-urls/hooks', () => ({
|
||||
useHelpUrls: () => ({
|
||||
contentHighlights: 'some',
|
||||
visibility: 'some',
|
||||
@@ -97,13 +95,13 @@ jest.mock('./data/api', () => ({
|
||||
}));
|
||||
|
||||
// Mock ComponentPicker to call onComponentSelected on click
|
||||
jest.mock('../library-authoring/component-picker', () => ({
|
||||
jest.mock('@src/library-authoring/component-picker', () => ({
|
||||
ComponentPicker: (props) => {
|
||||
const onClick = () => {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
props.onComponentSelected({
|
||||
usageKey: containerKey,
|
||||
blockType: 'unti',
|
||||
usageKey: getContainerKey(),
|
||||
blockType: getContainerType(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
@@ -114,7 +112,9 @@ jest.mock('../library-authoring/component-picker', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
@@ -125,38 +125,34 @@ jest.mock('@dnd-kit/core', () => ({
|
||||
closestCorners: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@src/studio-home/data/selectors', () => ({
|
||||
...jest.requireActual('@src/studio-home/data/selectors'),
|
||||
getStudioHomeData: jest.fn().mockReturnValue({
|
||||
librariesV2Enabled: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseOutline courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>
|
||||
const renderComponent = () => render(
|
||||
<CourseOutline courseId={courseId} />,
|
||||
);
|
||||
|
||||
describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
const mocks = initializeMocks();
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
state: undefined,
|
||||
key: '',
|
||||
search: '',
|
||||
hash: '',
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
studioHome: { studioHomeData: { librariesV2Enabled: true } },
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexMock);
|
||||
@@ -171,7 +167,11 @@ describe('<CourseOutline />', () => {
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
}))
|
||||
.reply(200, courseLaunchMock);
|
||||
axiosMock
|
||||
.onPost(`${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`)
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
|
||||
await executeThunk(syncDiscussionsTopics(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -179,7 +179,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('render CourseOutline component correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
@@ -187,12 +187,22 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('logs an error when syncDiscussionsTopics encounters an API failure', async () => {
|
||||
axiosMock
|
||||
.onPost(createDiscussionsTopicsUrl(courseId))
|
||||
.reply(500, 'some internal error');
|
||||
|
||||
await executeThunk(syncDiscussionsTopics(courseId), store.dispatch);
|
||||
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles course outline fetch api errors', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(500, 'some internal error');
|
||||
|
||||
const { findByText, queryByRole } = render(<RootWrapper />);
|
||||
const { findByText, queryByRole } = renderComponent();
|
||||
expect(await findByText('"some internal error"')).toBeInTheDocument();
|
||||
// check errors in store
|
||||
expect(store.getState().courseOutline.errors).toEqual({
|
||||
@@ -211,7 +221,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check reindex and render success alert is correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
@@ -223,7 +233,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check video sharing option udpates correctly', async () => {
|
||||
const { findByLabelText } = render(<RootWrapper />);
|
||||
const { findByLabelText } = renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
@@ -237,8 +247,8 @@ describe('<CourseOutline />', () => {
|
||||
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||
);
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||
},
|
||||
@@ -246,7 +256,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check video sharing option shows error on failure', async () => {
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
@@ -260,8 +270,8 @@ describe('<CourseOutline />', () => {
|
||||
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||
);
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||
},
|
||||
@@ -276,7 +286,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('render error alert after failed reindex correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
@@ -288,7 +298,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new section list is saved when dragged', async () => {
|
||||
const { findAllByRole, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByRole, findByTestId } = renderComponent();
|
||||
const expandAllButton = await findByTestId('expand-collapse-all-button');
|
||||
fireEvent.click(expandAllButton);
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
@@ -300,7 +310,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
closestCorners.mockReturnValue([{ id: section1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: section1 }]);
|
||||
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
@@ -315,7 +325,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check section list is restored to original order when API call fails', async () => {
|
||||
const { findAllByRole, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByRole, findByTestId } = renderComponent();
|
||||
const expandAllButton = await findByTestId('expand-collapse-all-button');
|
||||
fireEvent.click(expandAllButton);
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
@@ -327,7 +337,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(500);
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
closestCorners.mockReturnValue([{ id: section1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: section1 }]);
|
||||
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
@@ -342,11 +352,18 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds new section correctly', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
let elements = await findAllByTestId('section-card');
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
height: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
expect(elements.length).toBe(4);
|
||||
|
||||
@@ -358,22 +375,29 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSectionMock.id))
|
||||
.reply(200, courseSectionMock);
|
||||
const newSectionButton = await findByTestId('new-section-button');
|
||||
const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0];
|
||||
await act(async () => fireEvent.click(newSectionButton));
|
||||
|
||||
elements = await findAllByTestId('section-card');
|
||||
expect(elements.length).toBe(5);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds new subsection correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [section] = await findAllByTestId('section-card');
|
||||
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
height: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
|
||||
axiosMock
|
||||
@@ -384,18 +408,18 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
|
||||
.reply(200, courseSubsectionMock);
|
||||
const newSubsectionButton = await within(section).findByTestId('new-subsection-button');
|
||||
const newSubsectionButton = await within(section).findByRole('button', { name: 'New subsection' });
|
||||
await act(async () => {
|
||||
fireEvent.click(newSubsectionButton);
|
||||
});
|
||||
|
||||
subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(3);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds new unit correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
@@ -406,12 +430,12 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
});
|
||||
const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button');
|
||||
const newUnitButton = await within(subsectionElement).findByRole('button', { name: 'New unit' });
|
||||
await act(async () => fireEvent.click(newUnitButton));
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [subsection] = section.childInfo.children;
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
parent_locator: subsection.id,
|
||||
category: COURSE_BLOCK_NAMES.vertical.id,
|
||||
display_name: COURSE_BLOCK_NAMES.vertical.name,
|
||||
@@ -419,7 +443,9 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds a unit from library correctly', async () => {
|
||||
render(<RootWrapper />);
|
||||
getContainerKey.mockReturnValue('lct:org:lib:unit:1');
|
||||
getContainerKey.mockReturnValue('unit');
|
||||
renderComponent();
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
@@ -429,6 +455,7 @@ describe('<CourseOutline />', () => {
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
const addUnitFromLibraryButton = within(subsectionElement).getByRole('button', {
|
||||
@@ -440,20 +467,95 @@ describe('<CourseOutline />', () => {
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
fireEvent.click(dummyBtn);
|
||||
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(1));
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(3));
|
||||
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [subsection] = section.childInfo.children;
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'vertical',
|
||||
parent_locator: subsection.id,
|
||||
library_content_key: containerKey,
|
||||
}));
|
||||
waitFor(() => {
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'vertical',
|
||||
parent_locator: subsection.id,
|
||||
library_content_key: getContainerKey(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a subsection from library correctly', async () => {
|
||||
getContainerKey.mockReturnValue('lct:org:lib:subsection:1');
|
||||
getContainerKey.mockReturnValue('subsection');
|
||||
renderComponent();
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
const subsections = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
const addSubsectionFromLibraryButton = within(sectionElement).getByRole('button', {
|
||||
name: /use subsection from library/i,
|
||||
});
|
||||
fireEvent.click(addSubsectionFromLibraryButton);
|
||||
|
||||
// click dummy button to execute onComponentSelected prop.
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
fireEvent.click(dummyBtn);
|
||||
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(3));
|
||||
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
waitFor(() => {
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'sequential',
|
||||
parent_locator: section.id,
|
||||
library_content_key: getContainerKey(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a section from library correctly', async () => {
|
||||
getContainerKey.mockReturnValue('lct:org:lib:section:1');
|
||||
getContainerKey.mockReturnValue('section');
|
||||
renderComponent();
|
||||
const sections = await screen.findAllByTestId('section-card');
|
||||
expect(sections.length).toBe(4);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
const addSectionFromLibraryButton = await screen.findByRole('button', {
|
||||
name: /use section from library/i,
|
||||
});
|
||||
fireEvent.click(addSectionFromLibraryButton);
|
||||
|
||||
// click dummy button to execute onComponentSelected prop.
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
fireEvent.click(dummyBtn);
|
||||
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(3));
|
||||
|
||||
const courseUsageKey = courseOutlineIndexMock.courseStructure.id;
|
||||
waitFor(() => {
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'chapter',
|
||||
parent_locator: courseUsageKey,
|
||||
library_content_key: getContainerKey(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('render checklist value correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
await executeThunk(fetchCourseLaunchQuery({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
@@ -462,7 +564,7 @@ describe('<CourseOutline />', () => {
|
||||
courseId, excludeGraded: true, all: true,
|
||||
}), store.dispatch);
|
||||
|
||||
expect(getByText('4/9 completed')).toBeInTheDocument();
|
||||
expect(getByText('3/8 completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render alerts if checklist api fails', async () => {
|
||||
@@ -471,7 +573,7 @@ describe('<CourseOutline />', () => {
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
}))
|
||||
.reply(500);
|
||||
const { findByText, findByRole } = render(<RootWrapper />);
|
||||
const { findByText, findByRole } = renderComponent();
|
||||
|
||||
await executeThunk(fetchCourseLaunchQuery({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
@@ -501,7 +603,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check highlights are enabled after enable highlights query is successful', async () => {
|
||||
const { findByTestId, findByText } = render(<RootWrapper />);
|
||||
const { findByTestId, findByText } = renderComponent();
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
@@ -530,7 +632,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('should expand and collapse subsections, after click on subheader buttons', async () => {
|
||||
const { queryAllByTestId, findByText } = render(<RootWrapper />);
|
||||
const { queryAllByTestId, findByText } = renderComponent();
|
||||
|
||||
const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage);
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
@@ -554,7 +656,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexWithoutSections);
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('empty-placeholder')).toBeInTheDocument();
|
||||
@@ -569,7 +671,7 @@ describe('<CourseOutline />', () => {
|
||||
notificationDismissUrl: '/some/url',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
const alert = await screen.findByText(pageAlertMessages.configurationErrorTitle.defaultMessage);
|
||||
expect(alert).toBeInTheDocument();
|
||||
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
@@ -582,15 +684,11 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const checkEditTitle = async (section, element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id, {
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}))
|
||||
.onPost(getCourseItemApiUrl(item.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
@@ -624,14 +722,13 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.blur(editField));
|
||||
expect(
|
||||
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||
`Failed for ${elementName}!`,
|
||||
).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}));
|
||||
const results = await within(element).findAllByText(newName);
|
||||
expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
// check section
|
||||
@@ -651,7 +748,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
@@ -662,7 +759,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
const checkDeleteBtn = async (item, element, elementName) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||
expect(screen.queryByText(item.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
|
||||
@@ -675,7 +772,7 @@ describe('<CourseOutline />', () => {
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(item.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -689,9 +786,9 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is duplicated successfully', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children as unknown as XBlock[];
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -703,12 +800,10 @@ describe('<CourseOutline />', () => {
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
}
|
||||
|
||||
@@ -739,12 +834,10 @@ describe('<CourseOutline />', () => {
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
}
|
||||
};
|
||||
@@ -759,8 +852,8 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check section, subsection & unit is published when publish button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const { findAllByTestId, findByTestId } = renderComponent();
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children as unknown as XBlock[];
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -769,8 +862,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
const checkPublishBtn = async (item, element, elementName) => {
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
(await within(element).findAllByRole('status'))[0],
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage);
|
||||
|
||||
axiosMock
|
||||
@@ -800,6 +892,7 @@ describe('<CourseOutline />', () => {
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
childInfo: {
|
||||
displayName: 'Unit Tests',
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
@@ -827,8 +920,7 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
(await within(element).findAllByRole('status'))[0],
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeLive.defaultMessage);
|
||||
};
|
||||
|
||||
@@ -841,7 +933,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check configure modal for section', async () => {
|
||||
const { findByTestId, findAllByTestId } = render(<RootWrapper />);
|
||||
const { findByTestId, findAllByTestId } = renderComponent();
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const newReleaseDateIso = '2025-09-10T22:00:00Z';
|
||||
const newReleaseDate = '09/10/2025';
|
||||
@@ -877,8 +969,8 @@ describe('<CourseOutline />', () => {
|
||||
const saveButton = await findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: true,
|
||||
@@ -897,8 +989,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -934,7 +1026,7 @@ describe('<CourseOutline />', () => {
|
||||
subsection.format = expectedRequestData.graderType;
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue;
|
||||
subsection.hideAfterDue = expectedRequestData.metadata.hide_after_due;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
@@ -977,8 +1069,8 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
@@ -990,7 +1082,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2025');
|
||||
releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
|
||||
expect(releaseDateTimePicker).toHaveValue('00:00');
|
||||
dueDateStack = await await within(configureModal).findByTestId('due-date-stack');
|
||||
dueDateStack = await within(configureModal).findByTestId('due-date-stack');
|
||||
dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(dueDatePicker).toHaveValue('09/10/2025');
|
||||
dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
|
||||
@@ -1012,8 +1104,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [subsection, secondSubsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1113,8 +1205,8 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
@@ -1157,8 +1249,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1233,8 +1325,8 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
@@ -1257,8 +1349,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [, subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1333,8 +1425,8 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
@@ -1357,8 +1449,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]) as unknown as XBlock;
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1429,8 +1521,8 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
@@ -1447,7 +1539,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check configure modal for unit', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId, findByTestId } = renderComponent();
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [unit] = subsection.childInfo.children;
|
||||
@@ -1511,7 +1603,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(unitDropdownButton);
|
||||
const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button');
|
||||
const configureBtn = await within(firstUnit).findByTestId('unit-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
@@ -1556,7 +1648,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check update highlights when update highlights query is successfully', async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const highlights = [
|
||||
@@ -1591,7 +1683,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section element
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
@@ -1620,7 +1712,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get first, second and last section element
|
||||
const {
|
||||
0: firstSection, 1: secondSection, length, [length - 1]: lastSection,
|
||||
@@ -1663,7 +1755,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1676,7 +1768,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveSubsection([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 0, 0, 1)[0][0];
|
||||
] as unknown as XBlock[], 0, 0, 1)[0][0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
@@ -1702,7 +1794,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move up to prev section if it is on top of its parent section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [firstSection, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
@@ -1714,7 +1806,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveSubsectionOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 1, 0, 0, firstSection.childInfo.children.length + 1)[0];
|
||||
] as unknown as XBlock[], 1, 0, 0, firstSection.childInfo.children.length + 1)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(firstSection.id))
|
||||
.reply(200, expectedSections[0]);
|
||||
@@ -1738,7 +1830,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move down to next section if it is in bottom position of its parent section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [section, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const lastSubsectionIdx = section.childInfo.children.length - 1;
|
||||
@@ -1751,7 +1843,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveSubsectionOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 0, lastSubsectionIdx, 1, 0)[0];
|
||||
] as unknown as XBlock[], 0, lastSubsectionIdx, 1, 0)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[0]);
|
||||
@@ -1775,7 +1867,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// using first section
|
||||
const sectionElements = await findAllByTestId('section-card');
|
||||
const firstSectionElement = sectionElements[0];
|
||||
@@ -1825,7 +1917,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> second unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1838,7 +1930,9 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveUnit([...courseOutlineIndexMock.courseStructure.childInfo.children], 1, 1, 0, 1)[0][1];
|
||||
const expectedSection = moveUnit([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
] as unknown as XBlock[], 1, 1, 0, 1)[0][1];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
@@ -1864,7 +1958,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves up to previous subsection if it is in top position in parent subsection', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1879,7 +1973,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 1, 1, 0, 1, 0, firstSubsection.childInfo.children.length)[0];
|
||||
] as unknown as XBlock[], 1, 1, 0, 1, 0, firstSubsection.childInfo.children.length)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
@@ -1898,7 +1992,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves up to previous subsection of prev section if it is in top position in parent subsection & section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [firstSection, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1913,7 +2007,7 @@ describe('<CourseOutline />', () => {
|
||||
.onPut(getCourseItemApiUrl(firstSectionLastSubsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver(
|
||||
[...courseOutlineIndexMock.courseStructure.childInfo.children],
|
||||
[...courseOutlineIndexMock.courseStructure.childInfo.children] as unknown as XBlock[],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
@@ -1943,7 +2037,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves down to next subsection if it is in last position in parent subsection', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1959,7 +2053,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 1, 0, lastUnitIdx, 1, 1, 0)[0];
|
||||
] as unknown as XBlock[], 1, 0, lastUnitIdx, 1, 1, 0)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
@@ -1978,7 +2072,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves down to next subsection of next section if it is in last position in parent subsection & section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, secondSection, thirdSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1995,7 +2089,7 @@ describe('<CourseOutline />', () => {
|
||||
.onPut(getCourseItemApiUrl(thirdSectionFirstSubsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver(
|
||||
[...courseOutlineIndexMock.courseStructure.childInfo.children],
|
||||
[...courseOutlineIndexMock.courseStructure.childInfo.children] as unknown as XBlock[],
|
||||
1,
|
||||
lastSubIndex,
|
||||
lastUnitIdx,
|
||||
@@ -2025,7 +2119,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// using first section -> first subsection -> first unit
|
||||
const sections = await findAllByTestId('section-card');
|
||||
const [sectionElement] = sections;
|
||||
@@ -2066,7 +2160,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new subsection list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2076,13 +2170,13 @@ describe('<CourseOutline />', () => {
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: subsection1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: subsection1 }]);
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveSubsection([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 0, 1, 0)[0][0];
|
||||
] as unknown as XBlock[], 0, 1, 0)[0][0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
@@ -2100,7 +2194,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new subsection list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2110,7 +2204,7 @@ describe('<CourseOutline />', () => {
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: subsection1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: subsection1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
@@ -2129,7 +2223,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new unit list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get third section
|
||||
const [, , sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2140,12 +2234,12 @@ describe('<CourseOutline />', () => {
|
||||
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: unit1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: unit1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveUnit([...sections], 2, 0, 1, 0)[0][2];
|
||||
const expectedSection = moveUnit([...sections] as unknown as XBlock[], 2, 0, 1, 0)[0][2];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
@@ -2163,7 +2257,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new unit list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get third section
|
||||
const [, , sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2174,12 +2268,12 @@ describe('<CourseOutline />', () => {
|
||||
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: unit1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: unit1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(500);
|
||||
const expectedSection = moveUnit([...sections], 2, 0, 1, 0)[0][2];
|
||||
const expectedSection = moveUnit([...sections] as unknown as XBlock[], 2, 0, 1, 0)[0][2];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
@@ -2197,7 +2291,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
@@ -2232,7 +2326,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// find clipboard content popover link
|
||||
const popoverContent = screen.queryByTestId('popover-content');
|
||||
expect(popoverContent.tagName).toBe('A');
|
||||
expect(popoverContent?.tagName).toBe('A');
|
||||
expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`);
|
||||
|
||||
// check paste button functionality
|
||||
@@ -2289,18 +2383,22 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// Delay to ensure we see "Please wait."
|
||||
// Without the delay the success message renders too quickly
|
||||
const delayedResponse = axiosMock
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.withDelayInMs(500);
|
||||
delayedResponse(200, expectedResponse);
|
||||
.withDelayInMs(500)
|
||||
.reply(200, expectedResponse);
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
state: undefined,
|
||||
key: '',
|
||||
search: '',
|
||||
});
|
||||
|
||||
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
|
||||
window.URL.revokeObjectURL = jest.fn();
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
await screen.findByText('Please wait. Creating export file for course tags...');
|
||||
|
||||
const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
|
||||
@@ -2312,17 +2410,20 @@ describe('<CourseOutline />', () => {
|
||||
it('should show toast on export tags error', async () => {
|
||||
// Delay to ensure we see "Please wait."
|
||||
// Without the delay the error renders too quickly
|
||||
const delayedResponse = axiosMock
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.withDelayInMs(500);
|
||||
delayedResponse(404);
|
||||
.withDelayInMs(500)
|
||||
.reply(404);
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
state: undefined,
|
||||
key: '',
|
||||
search: '',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
await screen.findByText('Please wait. Creating export file for course tags...');
|
||||
await screen.findByText('An error has occurred creating the file');
|
||||
});
|
||||
@@ -2332,7 +2433,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(403);
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('redux-provider')).toBeInTheDocument();
|
||||
@@ -2340,4 +2441,46 @@ describe('<CourseOutline />', () => {
|
||||
expect(outlineIndexLoadingStatus).toEqual(RequestStatus.DENIED);
|
||||
});
|
||||
});
|
||||
|
||||
it('can unlink library block', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexWithoutSections);
|
||||
|
||||
renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: courseSectionMock.id,
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSectionMock.id))
|
||||
.reply(200, {
|
||||
...courseSectionMock,
|
||||
actions: {
|
||||
...courseSectionMock.actions,
|
||||
unlinkable: true,
|
||||
},
|
||||
});
|
||||
const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0];
|
||||
fireEvent.click(newSectionButton);
|
||||
|
||||
const element = await screen.findByTestId('section-card');
|
||||
expect(element).toBeInTheDocument();
|
||||
|
||||
axiosMock.onDelete(getDownstreamApiUrl(courseSectionMock.id)).reply(200);
|
||||
|
||||
const menu = await within(element).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
const unlinkButton = await within(element).findByRole('button', { name: 'Unlink from Library' });
|
||||
fireEvent.click(unlinkButton);
|
||||
const confirmButton = await screen.findByRole('button', { name: 'Confirm Unlink' });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete).toHaveLength(1);
|
||||
});
|
||||
expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id));
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,15 @@
|
||||
// @ts-check
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
Row,
|
||||
TransitionReplace,
|
||||
Toast,
|
||||
StandardModal,
|
||||
} from '@openedx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Add as IconAdd,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { CheckCircle as CheckCircleIcon } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
arrayMove,
|
||||
@@ -22,19 +17,31 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CourseAuthoringOutlineSidebarSlot } from '../plugin-slots/CourseAuthoringOutlineSidebarSlot';
|
||||
import { CourseAuthoringOutlineSidebarSlot } from '@src/plugin-slots/CourseAuthoringOutlineSidebarSlot';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import DeleteModal from '../generic/delete-modal/DeleteModal';
|
||||
import ConfigureModal from '../generic/configure-modal/ConfigureModal';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { getCurrentItem, getProctoredExamsFlag } from './data/selectors';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
import ProcessingNotification from '@src/generic/processing-notification';
|
||||
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
|
||||
import { UnlinkModal } from '@src/generic/unlink-modal';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import getPageHeadTitle from '@src/generic/utils';
|
||||
import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import {
|
||||
getCurrentItem,
|
||||
getProctoredExamsFlag,
|
||||
getTimedExamsFlag,
|
||||
} from './data/selectors';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
|
||||
@@ -54,13 +61,18 @@ import {
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
import CourseOutlineHeaderActionsSlot from '../plugin-slots/CourseOutlineHeaderActionsSlot';
|
||||
import OutlineAddChildButtons from './OutlineAddChildButtons';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
interface CourseOutlineProps {
|
||||
courseId: string,
|
||||
}
|
||||
|
||||
const CourseOutline = ({ courseId }: CourseOutlineProps) => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
courseUsageKey,
|
||||
courseName,
|
||||
savingStatus,
|
||||
statusBarData,
|
||||
@@ -79,16 +91,22 @@ const CourseOutline = ({ courseId }) => {
|
||||
isPublishModalOpen,
|
||||
isConfigureModalOpen,
|
||||
isDeleteModalOpen,
|
||||
isUnlinkModalOpen,
|
||||
closeHighlightsModal,
|
||||
closePublishModal,
|
||||
handleConfigureModalClose,
|
||||
closeDeleteModal,
|
||||
closeUnlinkModal,
|
||||
openPublishModal,
|
||||
openConfigureModal,
|
||||
openDeleteModal,
|
||||
openUnlinkModal,
|
||||
headerNavigationsActions,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
isAddLibrarySectionModalOpen,
|
||||
openAddLibrarySectionModal,
|
||||
closeAddLibrarySectionModal,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
@@ -97,6 +115,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleUnlinkItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
@@ -104,6 +123,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleNewSubsectionSubmit,
|
||||
handleNewUnitSubmit,
|
||||
handleAddUnitFromLibrary,
|
||||
handleAddSubsectionFromLibrary,
|
||||
handleAddSectionFromLibrary,
|
||||
getUnitUrl,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
@@ -119,10 +140,11 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
// Use `setToastMessage` to show the toast.
|
||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the course data to load before exporting tags.
|
||||
@@ -139,7 +161,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
}
|
||||
}, [location, courseId, courseName]);
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
const [sections, setSections] = useState<XBlock[]>(sectionsList);
|
||||
|
||||
const restoreSectionList = () => {
|
||||
setSections(() => [...sectionsList]);
|
||||
@@ -151,16 +173,17 @@ const CourseOutline = ({ courseId }) => {
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
const currentItemData = useSelector(getCurrentItem);
|
||||
const deleteCategory = COURSE_BLOCK_NAMES[currentItemData.category]?.name.toLowerCase();
|
||||
|
||||
const itemCategory = currentItemData?.category;
|
||||
const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase();
|
||||
|
||||
const enableProctoredExams = useSelector(getProctoredExamsFlag);
|
||||
const enableTimedExams = useSelector(getTimedExamsFlag);
|
||||
|
||||
/**
|
||||
* Move section to new index
|
||||
* @param {any} currentIndex
|
||||
* @param {any} newIndex
|
||||
*/
|
||||
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
|
||||
const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
@@ -173,11 +196,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
/**
|
||||
* Uses details from move information and moves subsection
|
||||
* @param {any} section
|
||||
* @param {any} moveDetails
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateSubsectionOrderByIndex = (section, moveDetails) => {
|
||||
const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => {
|
||||
const { fn, args, sectionId } = moveDetails;
|
||||
if (!args) {
|
||||
return;
|
||||
@@ -196,11 +216,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
/**
|
||||
* Uses details from move information and moves unit
|
||||
* @param {any} section
|
||||
* @param {any} moveDetails
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateUnitOrderByIndex = (section, moveDetails) => {
|
||||
const updateUnitOrderByIndex = (section: XBlock, moveDetails) => {
|
||||
const {
|
||||
fn, args, sectionId, subsectionId,
|
||||
} = moveDetails;
|
||||
@@ -220,6 +237,16 @@ const CourseOutline = ({ courseId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectLibrarySection = useCallback((selectedSection: SelectedComponent) => {
|
||||
handleAddSectionFromLibrary.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
libraryContentKey: selectedSection.usageKey,
|
||||
});
|
||||
closeAddLibrarySectionModal();
|
||||
}, [closeAddLibrarySectionModal, handleAddSectionFromLibrary.mutateAsync, courseId, courseUsageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setSections(sectionsList);
|
||||
}, [sectionsList]);
|
||||
@@ -352,11 +379,14 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onOpenUnlinkModal={openUnlinkModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={section.id}
|
||||
@@ -381,13 +411,15 @@ const CourseOutline = ({ courseId }) => {
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onOpenUnlinkModal={openUnlinkModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onAddUnitFromLibrary={handleAddUnitFromLibrary}
|
||||
onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync}
|
||||
onOrderChange={updateSubsectionOrderByIndex}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={subsection.id}
|
||||
@@ -415,6 +447,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onOpenUnlinkModal={openUnlinkModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
@@ -431,23 +464,25 @@ const CourseOutline = ({ courseId }) => {
|
||||
</SortableContext>
|
||||
</DraggableList>
|
||||
{courseActions.childAddable && (
|
||||
<Button
|
||||
data-testid="new-section-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
onClick={handleNewSectionSubmit}
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.newSectionButton)}
|
||||
</Button>
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewSectionSubmit}
|
||||
handleUseFromLibraryClick={openAddLibrarySectionModal}
|
||||
childType={ContainerType.Section}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPlaceholder
|
||||
onCreateNewSection={handleNewSectionSubmit}
|
||||
childAddable={courseActions.childAddable}
|
||||
/>
|
||||
<EmptyPlaceholder>
|
||||
{courseActions.childAddable && (
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewSectionSubmit}
|
||||
handleUseFromLibraryClick={openAddLibrarySectionModal}
|
||||
childType={ContainerType.Section}
|
||||
btnVariant="primary"
|
||||
btnClasses="mt-1"
|
||||
/>
|
||||
)}
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -485,19 +520,49 @@ const CourseOutline = ({ courseId }) => {
|
||||
onConfigureSubmit={handleConfigureItemSubmit}
|
||||
currentItemData={currentItemData}
|
||||
enableProctoredExams={enableProctoredExams}
|
||||
enableTimedExams={enableTimedExams}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
/>
|
||||
<DeleteModal
|
||||
category={deleteCategory}
|
||||
category={itemCategoryName}
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
onDeleteSubmit={handleDeleteItemSubmit}
|
||||
/>
|
||||
<UnlinkModal
|
||||
displayName={currentItemData?.displayName}
|
||||
category={itemCategory}
|
||||
isOpen={isUnlinkModalOpen}
|
||||
close={closeUnlinkModal}
|
||||
onUnlinkSubmit={handleUnlinkItemSubmit}
|
||||
/>
|
||||
<StandardModal
|
||||
title={intl.formatMessage(messages.sectionPickerModalTitle)}
|
||||
isOpen={isAddLibrarySectionModalOpen}
|
||||
onClose={closeAddLibrarySectionModal}
|
||||
isOverflowVisible={false}
|
||||
size="xl"
|
||||
>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['block_type = "section"']}
|
||||
componentPickerMode="single"
|
||||
onComponentSelected={handleSelectLibrarySection}
|
||||
visibleTabs={[ContentType.sections]}
|
||||
/>
|
||||
</StandardModal>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
// Show processing toast if any mutation is running
|
||||
isShow={
|
||||
isShowProcessingNotification
|
||||
|| handleAddUnitFromLibrary.isPending
|
||||
|| handleAddSubsectionFromLibrary.isPending
|
||||
|| handleAddSectionFromLibrary.isPending
|
||||
}
|
||||
// HACK: Use saving as default title till we have a need for better messages
|
||||
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}
|
||||
/>
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
@@ -518,8 +583,4 @@ const CourseOutline = ({ courseId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutline.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseOutline;
|
||||
42
src/course-outline/OutlineAddChildButtons.test.tsx
Normal file
42
src/course-outline/OutlineAddChildButtons.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import {
|
||||
initializeMocks, render, screen, waitFor,
|
||||
} from '@src/testUtils';
|
||||
import OutlineAddChildButtons from './OutlineAddChildButtons';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }),
|
||||
}));
|
||||
|
||||
[
|
||||
{ containerType: ContainerType.Section },
|
||||
{ containerType: ContainerType.Subsection },
|
||||
{ containerType: ContainerType.Unit },
|
||||
].forEach(({ containerType }) => {
|
||||
describe(`<OutlineAddChildButtons> for ${containerType}`, () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('renders and behaves correctly', async () => {
|
||||
const newClickHandler = jest.fn();
|
||||
const useFromLibClickHandler = jest.fn();
|
||||
render(<OutlineAddChildButtons
|
||||
handleNewButtonClick={newClickHandler}
|
||||
handleUseFromLibraryClick={useFromLibClickHandler}
|
||||
childType={containerType}
|
||||
/>);
|
||||
|
||||
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
|
||||
expect(newBtn).toBeInTheDocument();
|
||||
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
|
||||
expect(useBtn).toBeInTheDocument();
|
||||
userEvent.click(newBtn);
|
||||
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
|
||||
userEvent.click(useBtn);
|
||||
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
});
|
||||
89
src/course-outline/OutlineAddChildButtons.tsx
Normal file
89
src/course-outline/OutlineAddChildButtons.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Button, Stack } from '@openedx/paragon';
|
||||
import { Add as IconAdd, Newsstand } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getStudioHomeData } from '@src/studio-home/data/selectors';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import messages from './messages';
|
||||
|
||||
interface NewChildButtonsProps {
|
||||
handleNewButtonClick: () => void;
|
||||
handleUseFromLibraryClick: () => void;
|
||||
childType: ContainerType;
|
||||
btnVariant?: string;
|
||||
btnClasses?: string;
|
||||
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
|
||||
}
|
||||
|
||||
const OutlineAddChildButtons = ({
|
||||
handleNewButtonClick,
|
||||
handleUseFromLibraryClick,
|
||||
childType,
|
||||
btnVariant = 'outline-primary',
|
||||
btnClasses = 'mt-4 border-gray-500 rounded-0',
|
||||
btnSize,
|
||||
}: NewChildButtonsProps) => {
|
||||
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
|
||||
// as it has a useEffect that fetches course waffle flags whenever
|
||||
// location.search is updated. Course search updates location.search when
|
||||
// user types, which will then trigger the useEffect and reload the page.
|
||||
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
|
||||
const { librariesV2Enabled } = useSelector(getStudioHomeData);
|
||||
const intl = useIntl();
|
||||
let messageMap = {
|
||||
newButton: messages.newUnitButton,
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
};
|
||||
|
||||
switch (childType) {
|
||||
case ContainerType.Section:
|
||||
messageMap = {
|
||||
newButton: messages.newSectionButton,
|
||||
importButton: messages.useSectionFromLibraryButton,
|
||||
};
|
||||
break;
|
||||
case ContainerType.Subsection:
|
||||
messageMap = {
|
||||
newButton: messages.newSubsectionButton,
|
||||
importButton: messages.useSubsectionFromLibraryButton,
|
||||
};
|
||||
break;
|
||||
case ContainerType.Unit:
|
||||
messageMap = {
|
||||
newButton: messages.newUnitButton,
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<Button
|
||||
className={btnClasses}
|
||||
variant={btnVariant}
|
||||
iconBefore={IconAdd}
|
||||
size={btnSize}
|
||||
block
|
||||
onClick={handleNewButtonClick}
|
||||
>
|
||||
{intl.formatMessage(messageMap.newButton)}
|
||||
</Button>
|
||||
{librariesV2Enabled && (
|
||||
<Button
|
||||
className={btnClasses}
|
||||
variant={btnVariant}
|
||||
iconBefore={Newsstand}
|
||||
block
|
||||
size={btnSize}
|
||||
onClick={handleUseFromLibraryClick}
|
||||
>
|
||||
{intl.formatMessage(messageMap.importButton)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineAddChildButtons;
|
||||
@@ -3149,6 +3149,7 @@ module.exports = {
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
createdOn: new Date(),
|
||||
deprecatedBlocksInfo: {
|
||||
deprecatedEnabledBlockTypes: [],
|
||||
blocks: [],
|
||||
|
||||
@@ -5,11 +5,21 @@
|
||||
.item-card-header__title-btn {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
flex: 1 1 0%;
|
||||
flex: 1 1 0;
|
||||
height: 1.5rem;
|
||||
margin-right: .25rem;
|
||||
background: transparent;
|
||||
color: var(--pgn-color-black);
|
||||
|
||||
.truncate-1-line {
|
||||
-webkit-line-clamp: 1; /* stylelint-disable-line value-no-vendor-prefix */
|
||||
line-clamp: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */
|
||||
min-width: 100px;
|
||||
-webkit-box-orient: vertical; /* stylelint-disable-line value-no-vendor-prefix */
|
||||
}
|
||||
}
|
||||
|
||||
.item-card-button-icon {
|
||||
@@ -27,4 +37,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.allow-hover-on-disabled {
|
||||
&.disabled {
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, waitFor, screen,
|
||||
} from '@testing-library/react';
|
||||
import { setConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { ITEM_BADGE_STATUS } from '@src/course-outline/constants';
|
||||
import {
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor,
|
||||
} from '@src/testUtils';
|
||||
import CardHeader from './CardHeader';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
@@ -16,6 +13,7 @@ const onClickMenuButtonMock = jest.fn();
|
||||
const onClickPublishMock = jest.fn();
|
||||
const onClickEditMock = jest.fn();
|
||||
const onClickDeleteMock = jest.fn();
|
||||
const onClickUnlinkMock = jest.fn();
|
||||
const onClickDuplicateMock = jest.fn();
|
||||
const onClickConfigureMock = jest.fn();
|
||||
const onClickMoveUpMock = jest.fn();
|
||||
@@ -42,6 +40,7 @@ const cardHeaderProps = {
|
||||
closeForm: closeFormMock,
|
||||
isDisabledEditField: false,
|
||||
onClickDelete: onClickDeleteMock,
|
||||
onClickUnlink: onClickUnlinkMock,
|
||||
onClickDuplicate: onClickDuplicateMock,
|
||||
onClickConfigure: onClickConfigureMock,
|
||||
onClickMoveUp: onClickMoveUpMock,
|
||||
@@ -53,12 +52,11 @@ const cardHeaderProps = {
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
unlinkable: true,
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props, entry = '/') => {
|
||||
const renderComponent = (props?: object, entry = '/') => {
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
isExpanded
|
||||
@@ -70,113 +68,117 @@ const renderComponent = (props, entry = '/') => {
|
||||
);
|
||||
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>,
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>,
|
||||
{
|
||||
path: '/',
|
||||
routerProps: {
|
||||
initialEntries: [entry],
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('<CardHeader />', () => {
|
||||
it('render CardHeader component correctly', async () => {
|
||||
const { findByText, findByTestId, queryByTestId } = renderComponent();
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
|
||||
expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
|
||||
expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument();
|
||||
it('render CardHeader component correctly', async () => {
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('edit field')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit field')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('render status badge as live', async () => {
|
||||
const { findByText } = renderComponent();
|
||||
expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
renderComponent();
|
||||
expect(await screen.findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as published_not_live', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as staff_only', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.staffOnly,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as draft', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
|
||||
expect(await screen.findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('calls handleExpanded when button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
|
||||
const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandButton);
|
||||
expect(onExpandMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickMenuButton when menu is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(onClickMenuButtonMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickPublish when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
|
||||
const publishMenuItem = await screen.findByText(messages.menuPublish.defaultMessage);
|
||||
await act(async () => fireEvent.click(publishMenuItem));
|
||||
expect(onClickPublishMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -210,119 +212,124 @@ describe('<CardHeader />', () => {
|
||||
});
|
||||
|
||||
it('calls onClickEdit when the button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const editButton = await findByTestId('subsection-edit-button');
|
||||
const editButton = await screen.findByTestId('subsection-edit-button');
|
||||
await act(async () => fireEvent.click(editButton));
|
||||
expect(onClickEditMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check is field visible when isFormOpen is true', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
});
|
||||
|
||||
expect(await findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check is field disabled when isDisabledEditField is true', async () => {
|
||||
const { findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
|
||||
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('check editing is enabled when isDisabledEditField is false', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
});
|
||||
renderComponent({ ...cardHeaderProps });
|
||||
|
||||
expect(getByTestId('subsection-edit-button')).toBeEnabled();
|
||||
expect(screen.getByTestId('subsection-edit-button')).toBeEnabled();
|
||||
|
||||
// Ensure menu items related to editing are enabled
|
||||
const menuButton = getByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = screen.getByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(await getByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled');
|
||||
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check editing is disabled when isDisabledEditField is true', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
|
||||
|
||||
expect(await getByTestId('subsection-edit-button')).toBeDisabled();
|
||||
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();
|
||||
|
||||
// Ensure menu items related to editing are disabled
|
||||
const menuButton = getByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(await getByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('calls onClickDelete when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
|
||||
const deleteMenuItem = await screen.findByText(messages.menuDelete.defaultMessage);
|
||||
await act(async () => fireEvent.click(deleteMenuItem));
|
||||
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClickDuplicate when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
it('calls onClickUnlink when item is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage);
|
||||
await act(async () => fireEvent.click(unlinkMenuItem));
|
||||
expect(onClickUnlinkMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClickDuplicate when item is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
|
||||
const duplicateMenuItem = await screen.findByText(messages.menuDuplicate.defaultMessage);
|
||||
fireEvent.click(duplicateMenuItem);
|
||||
await act(async () => fireEvent.click(duplicateMenuItem));
|
||||
expect(onClickDuplicateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check if proctoringExamConfigurationLink is visible', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
proctoringExamConfigurationLink: 'proctoringlink',
|
||||
isSequential: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
|
||||
const element = await findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
const element = await screen.findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/proctoringlink`);
|
||||
});
|
||||
|
||||
it('check if proctoringExamConfigurationLink is absolute', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
proctoringExamConfigurationLink: 'http://localhost:9000/proctoringlink',
|
||||
isSequential: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
|
||||
const element = await findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
const element = await screen.findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.getAttribute('href')).toBe('http://localhost:9000/proctoringlink');
|
||||
});
|
||||
|
||||
it('check if discussion enabled badge is visible', async () => {
|
||||
const { queryByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isVertical: true,
|
||||
discussionEnabled: true,
|
||||
@@ -336,7 +343,7 @@ describe('<CardHeader />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tag count if is not zero and the waffle flag is enabled', async () => {
|
||||
@@ -383,4 +390,54 @@ describe('<CardHeader />', () => {
|
||||
|
||||
expect(mockClickSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
[null, undefined].forEach((unlinkable) => (
|
||||
it(`should not render unlink button if unlinkable action is ${unlinkable}`, async () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
actions: {
|
||||
...cardHeaderProps.actions,
|
||||
unlinkable,
|
||||
},
|
||||
});
|
||||
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
expect(screen.queryByText(messages.menuUnlink.defaultMessage)).not.toBeInTheDocument();
|
||||
})
|
||||
));
|
||||
|
||||
it('should render unlink button disabled if unlinkable action is False', async () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
actions: {
|
||||
...cardHeaderProps.actions,
|
||||
unlinkable: false,
|
||||
},
|
||||
});
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage);
|
||||
expect(unlinkMenuItem).toBeInTheDocument();
|
||||
expect(unlinkMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('should render unlink button disabled if unlinkable action is False', async () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
actions: {
|
||||
...cardHeaderProps.actions,
|
||||
unlinkable: true,
|
||||
},
|
||||
});
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const unlinkMenuItem = await screen.findByText(messages.menuUnlink.defaultMessage);
|
||||
fireEvent.click(unlinkMenuItem);
|
||||
await act(async () => fireEvent.click(unlinkMenuItem));
|
||||
expect(onClickUnlinkMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ReactNode, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Hyperlink,
|
||||
Icon,
|
||||
IconButton,
|
||||
IconButtonWithTooltip,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
@@ -18,15 +19,58 @@ import {
|
||||
Sync as SyncIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import { ContentTagsDrawerSheet } from '../../content-tags-drawer';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { useEscapeClick } from '../../hooks';
|
||||
import { useContentTagsCount } from '@src/generic/data/apiHooks';
|
||||
import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
|
||||
import TagCount from '@src/generic/tag-count';
|
||||
import { useEscapeClick } from '@src/hooks';
|
||||
import { XBlockActions } from '@src/data/types';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
import messages from './messages';
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
status: string;
|
||||
cardId?: string,
|
||||
hasChanges: boolean;
|
||||
onClickPublish: () => void;
|
||||
onClickConfigure: () => void;
|
||||
onClickMenuButton: () => void;
|
||||
onClickEdit: () => void;
|
||||
isFormOpen: boolean;
|
||||
onEditSubmit: (titleValue: string) => void;
|
||||
closeForm: () => void;
|
||||
isDisabledEditField: boolean;
|
||||
onClickDelete: () => void;
|
||||
onClickUnlink: () => void;
|
||||
onClickDuplicate: () => void;
|
||||
onClickMoveUp: () => void;
|
||||
onClickMoveDown: () => void;
|
||||
onClickCopy?: () => void;
|
||||
titleComponent: ReactNode;
|
||||
namePrefix: string;
|
||||
proctoringExamConfigurationLink?: string,
|
||||
actions: XBlockActions,
|
||||
enableCopyPasteUnits?: boolean;
|
||||
isVertical?: boolean;
|
||||
isSequential?: boolean;
|
||||
discussionEnabled?: boolean;
|
||||
discussionsSettings?: {
|
||||
providerType: string;
|
||||
enableGradedUnits: boolean;
|
||||
};
|
||||
parentInfo?: {
|
||||
graded: boolean;
|
||||
isTimeLimited?: boolean;
|
||||
},
|
||||
// An optional component that is rendered before the dropdown. This is used by the Subsection
|
||||
// and Unit card components to render their plugin slots.
|
||||
extraActionsComponent?: ReactNode,
|
||||
onClickSync?: () => void;
|
||||
readyToSync?: boolean;
|
||||
}
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
status,
|
||||
@@ -41,6 +85,7 @@ const CardHeader = ({
|
||||
closeForm,
|
||||
isDisabledEditField,
|
||||
onClickDelete,
|
||||
onClickUnlink,
|
||||
onClickDuplicate,
|
||||
onClickMoveUp,
|
||||
onClickMoveDown,
|
||||
@@ -58,7 +103,7 @@ const CardHeader = ({
|
||||
extraActionsComponent,
|
||||
onClickSync,
|
||||
readyToSync,
|
||||
}) => {
|
||||
}: CardHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [titleValue, setTitleValue] = useState(title);
|
||||
@@ -93,7 +138,7 @@ const CardHeader = ({
|
||||
&& discussionsSettings?.providerType === 'openedx'
|
||||
&& (
|
||||
discussionsSettings?.enableGradedUnits
|
||||
|| (!discussionsSettings?.enableGradedUnits && !parentInfo.graded)
|
||||
|| (!discussionsSettings?.enableGradedUnits && !parentInfo?.graded)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -120,7 +165,7 @@ const CardHeader = ({
|
||||
value={titleValue}
|
||||
name="displayName"
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
aria-label="edit field"
|
||||
aria-label={intl.formatMessage(messages.editFieldAriaLabel)}
|
||||
onBlur={() => onEditSubmit(titleValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -133,19 +178,11 @@ const CardHeader = ({
|
||||
) : (
|
||||
<>
|
||||
{titleComponent}
|
||||
{readyToSync && (
|
||||
<IconButton
|
||||
className="item-card-button-icon"
|
||||
data-testid={`${namePrefix}-sync-button`}
|
||||
alt={intl.formatMessage(messages.readyToSyncButtonAlt)}
|
||||
iconAs={SyncIcon}
|
||||
onClick={onClickSync}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
<IconButtonWithTooltip
|
||||
className="item-card-button-icon"
|
||||
data-testid={`${namePrefix}-edit-button`}
|
||||
alt={intl.formatMessage(messages.altButtonEdit)}
|
||||
alt={intl.formatMessage(messages.altButtonRename)}
|
||||
tooltipContent={<div>{intl.formatMessage(messages.altButtonRename)}</div>}
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
// @ts-ignore
|
||||
@@ -155,12 +192,21 @@ const CardHeader = ({
|
||||
)}
|
||||
<div className="ml-auto d-flex">
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge || false} />
|
||||
)}
|
||||
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
|
||||
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
|
||||
)}
|
||||
{extraActionsComponent}
|
||||
{readyToSync && (
|
||||
<IconButtonWithTooltip
|
||||
data-testid={`${namePrefix}-sync-button`}
|
||||
alt={intl.formatMessage(messages.readyToSyncButtonAlt)}
|
||||
iconAs={SyncIcon}
|
||||
tooltipContent={<div>{intl.formatMessage(messages.readyToSyncButtonAlt)}</div>}
|
||||
onClick={onClickSync}
|
||||
/>
|
||||
)}
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="item-card-header__menu"
|
||||
@@ -238,9 +284,20 @@ const CardHeader = ({
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
{((actions.unlinkable ?? null) !== null || actions.deletable) && <Dropdown.Divider />}
|
||||
{(actions.unlinkable ?? null) !== null && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-unlink-button`}
|
||||
onClick={onClickUnlink}
|
||||
disabled={!actions.unlinkable}
|
||||
className="allow-hover-on-disabled"
|
||||
title={!actions.unlinkable ? intl.formatMessage(messages.menuUnlinkDisabledTooltip) : undefined}
|
||||
>
|
||||
{intl.formatMessage(messages.menuUnlink)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.deletable && (
|
||||
<Dropdown.Item
|
||||
className="border-top border-light"
|
||||
data-testid={`${namePrefix}-card-header__menu-delete-button`}
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
@@ -260,67 +317,4 @@ const CardHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
CardHeader.defaultProps = {
|
||||
enableCopyPasteUnits: false,
|
||||
isVertical: false,
|
||||
isSequential: false,
|
||||
onClickCopy: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
discussionEnabled: false,
|
||||
discussionsSettings: {},
|
||||
parentInfo: {},
|
||||
cardId: '',
|
||||
extraActionsComponent: null,
|
||||
readyToSync: false,
|
||||
onClickSync: null,
|
||||
};
|
||||
|
||||
CardHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
cardId: PropTypes.string,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
onClickMenuButton: PropTypes.func.isRequired,
|
||||
onClickEdit: PropTypes.func.isRequired,
|
||||
isFormOpen: PropTypes.bool.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
closeForm: PropTypes.func.isRequired,
|
||||
isDisabledEditField: PropTypes.bool.isRequired,
|
||||
onClickDelete: PropTypes.func.isRequired,
|
||||
onClickDuplicate: PropTypes.func.isRequired,
|
||||
onClickMoveUp: PropTypes.func.isRequired,
|
||||
onClickMoveDown: PropTypes.func.isRequired,
|
||||
onClickCopy: PropTypes.func,
|
||||
titleComponent: PropTypes.node.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
proctoringExamConfigurationLink: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
allowMoveUp: PropTypes.bool,
|
||||
allowMoveDown: PropTypes.bool,
|
||||
}).isRequired,
|
||||
enableCopyPasteUnits: PropTypes.bool,
|
||||
isVertical: PropTypes.bool,
|
||||
isSequential: PropTypes.bool,
|
||||
discussionEnabled: PropTypes.bool,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
}),
|
||||
parentInfo: PropTypes.shape({
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}),
|
||||
// An optional component that is rendered before the dropdown. This is used by the Subsection
|
||||
// and Unit card components to render their plugin slots.
|
||||
extraActionsComponent: PropTypes.node,
|
||||
onClickSync: PropTypes.func,
|
||||
readyToSync: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
@@ -1,10 +1,8 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
Truncate,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
ArrowDropDown as ArrowDownIcon,
|
||||
@@ -12,12 +10,21 @@ import {
|
||||
} from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
interface TitleButtonProps {
|
||||
title: string;
|
||||
prefixIcon?: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
onTitleClick: () => void;
|
||||
namePrefix: string;
|
||||
}
|
||||
|
||||
const TitleButton = ({
|
||||
title,
|
||||
prefixIcon,
|
||||
isExpanded,
|
||||
onTitleClick,
|
||||
namePrefix,
|
||||
}) => {
|
||||
}: TitleButtonProps) => {
|
||||
const intl = useIntl();
|
||||
const titleTooltipMessage = intl.formatMessage(messages.expandTooltip);
|
||||
|
||||
@@ -38,18 +45,15 @@ const TitleButton = ({
|
||||
data-testid={`${namePrefix}-card-header__expanded-btn`}
|
||||
className="item-card-header__title-btn"
|
||||
onClick={onTitleClick}
|
||||
title={title}
|
||||
>
|
||||
<Truncate.Deprecated lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate.Deprecated>
|
||||
{prefixIcon}
|
||||
<span className={`${namePrefix}-card-title mb-0 truncate-1-line`}>
|
||||
{title}
|
||||
</span>
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
TitleButton.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
onTitleClick: PropTypes.func.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default TitleButton;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Truncate } from '@openedx/paragon';
|
||||
|
||||
const TitleLink = ({
|
||||
title,
|
||||
titleLink,
|
||||
namePrefix,
|
||||
}) => (
|
||||
<Button
|
||||
as={Link}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__title-link`}
|
||||
className="item-card-header__title-btn"
|
||||
to={titleLink}
|
||||
>
|
||||
<Truncate.Deprecated lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate.Deprecated>
|
||||
</Button>
|
||||
);
|
||||
|
||||
TitleLink.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
titleLink: PropTypes.string.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default TitleLink;
|
||||
32
src/course-outline/card-header/TitleLink.tsx
Normal file
32
src/course-outline/card-header/TitleLink.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@openedx/paragon';
|
||||
|
||||
interface TitleLinkProps {
|
||||
title: string;
|
||||
titleLink: string;
|
||||
namePrefix: string;
|
||||
prefixIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const TitleLink = ({
|
||||
title,
|
||||
titleLink,
|
||||
namePrefix,
|
||||
prefixIcon,
|
||||
}: TitleLinkProps) => (
|
||||
<Button
|
||||
as={Link}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__title-link`}
|
||||
className="item-card-header__title-btn align-items-end"
|
||||
to={titleLink}
|
||||
title={title}
|
||||
>
|
||||
{prefixIcon}
|
||||
<span className={`${namePrefix}-card-title mb-0 truncate-1-line`}>
|
||||
{title}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default TitleLink;
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
editFieldAriaLabel: {
|
||||
id: 'course-authoring.course-outline.card.edit-field.aria-label',
|
||||
defaultMessage: 'Edit field',
|
||||
},
|
||||
expandTooltip: {
|
||||
id: 'course-authoring.course-outline.card.expandTooltip',
|
||||
defaultMessage: 'Collapse/Expand this card',
|
||||
@@ -29,9 +33,9 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.card.status-badge.draft-unpublished-changes',
|
||||
defaultMessage: 'Draft (Unpublished changes)',
|
||||
},
|
||||
altButtonEdit: {
|
||||
altButtonRename: {
|
||||
id: 'course-authoring.course-outline.card.button.edit.alt',
|
||||
defaultMessage: 'Edit',
|
||||
defaultMessage: 'Rename',
|
||||
},
|
||||
menuPublish: {
|
||||
id: 'course-authoring.course-outline.card.menu.publish',
|
||||
@@ -57,6 +61,16 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.card.menu.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
menuUnlink: {
|
||||
id: 'course-authoring.course-outline.card.menu.unlink',
|
||||
defaultMessage: 'Unlink from Library',
|
||||
description: 'Unlink an item from the library',
|
||||
},
|
||||
menuUnlinkDisabledTooltip: {
|
||||
id: 'course-authoring.course-outline.card.menu.unlink.disabled-tooltip',
|
||||
defaultMessage: 'Only the highest level library reference can be unlinked.',
|
||||
description: 'Tooltip for disabled unlink option',
|
||||
},
|
||||
menuCopy: {
|
||||
id: 'course-authoring.course-outline.card.menu.copy',
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
|
||||
@@ -58,10 +58,6 @@ export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({
|
||||
id: 'videoDuration',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'mobileFriendlyVideo',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'diverseSequences',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getCourseOutlineIndexApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
export const getCourseOutlineIndexApiUrl = (
|
||||
courseId: string,
|
||||
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}: {
|
||||
courseId: string,
|
||||
excludeGraded: boolean,
|
||||
all: boolean,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
|
||||
|
||||
export const getCourseLaunchApiUrl = ({
|
||||
@@ -17,48 +24,48 @@ export const getCourseLaunchApiUrl = ({
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}:{
|
||||
courseId: string,
|
||||
gradedOnly: boolean,
|
||||
validateOras: boolean,
|
||||
all: boolean,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
|
||||
|
||||
export const getCourseBlockApiUrl = (courseId) => {
|
||||
export const getCourseBlockApiUrl = (courseId: string) => {
|
||||
const formattedCourseId = courseId.split('course-v1:')[1];
|
||||
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`;
|
||||
};
|
||||
|
||||
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
|
||||
export const getCourseReindexApiUrl = (reindexLink: string) => `${getApiBaseUrl()}${reindexLink}`;
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
* @property {string} courseReleaseDate
|
||||
* @property {Object} courseStructure
|
||||
* @property {Object} deprecatedBlocksInfo
|
||||
* @property {string} discussionsIncontextLearnmoreUrl
|
||||
* @property {Object} initialState
|
||||
* @property {Object} initialUserClipboard
|
||||
* @property {string} languageCode
|
||||
* @property {string} lmsLink
|
||||
* @property {string} mfeProctoredExamSettingsUrl
|
||||
* @property {string} notificationDismissUrl
|
||||
* @property {string[]} proctoringErrors
|
||||
* @property {string} reindexLink
|
||||
* @property {null} rerunNotificationId
|
||||
*/
|
||||
export const getCourseItemApiUrl = (itemId: string) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId: string) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const exportTags = (courseId: string) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`;
|
||||
|
||||
/**
|
||||
* Get course outline index.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<courseOutline>}
|
||||
*/
|
||||
export async function getCourseOutlineIndex(courseId) {
|
||||
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseOutlineIndexApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param courseId
|
||||
* @returns {Promise<Array|Object>}
|
||||
*/
|
||||
export async function createDiscussionsTopics(courseId: string): Promise<Array<any> | object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(createDiscussionsTopicsUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course best practices.
|
||||
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
|
||||
@@ -68,35 +75,46 @@ export async function getCourseBestPractices({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}) {
|
||||
}: {
|
||||
courseId: string;
|
||||
excludeGraded: boolean;
|
||||
all: boolean;
|
||||
}): Promise<{
|
||||
isSelfPaced: boolean;
|
||||
sections: any;
|
||||
subsection: any;
|
||||
units: any;
|
||||
videos: any;
|
||||
}> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/** @typedef {object} courseLaunchData
|
||||
* @property {boolean} isSelfPaced
|
||||
* @property {object} dates
|
||||
* @property {object} assignments
|
||||
* @property {object} grades
|
||||
* @property {number} grades.sum_of_weights
|
||||
* @property {object} certificates
|
||||
* @property {object} updates
|
||||
* @property {object} proctoring
|
||||
*/
|
||||
interface CourseLaunchData {
|
||||
isSelfPaced: boolean;
|
||||
dates: object;
|
||||
assignments: object;
|
||||
grades: {
|
||||
sum_of_weights: number;
|
||||
};
|
||||
certificates: object;
|
||||
updates: object;
|
||||
proctoring: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course launch.
|
||||
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
|
||||
* @returns {Promise<courseLaunchData>}
|
||||
* @returns {Promise<CourseLaunchData>}
|
||||
*/
|
||||
export async function getCourseLaunch({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}) {
|
||||
}: { courseId: string; gradedOnly: boolean; validateOras: boolean; all: boolean; }): Promise<CourseLaunchData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
@@ -110,7 +128,7 @@ export async function getCourseLaunch({
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function enableCourseHighlightsEmails(courseId) {
|
||||
export async function enableCourseHighlightsEmails(courseId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseBlockApiUrl(courseId), {
|
||||
publish: 'republish',
|
||||
@@ -127,7 +145,7 @@ export async function enableCourseHighlightsEmails(courseId) {
|
||||
* @param {string} reindexLink
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function restartIndexingOnCourse(reindexLink) {
|
||||
export async function restartIndexingOnCourse(reindexLink: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseReindexApiUrl(reindexLink));
|
||||
|
||||
@@ -135,49 +153,11 @@ export async function restartIndexingOnCourse(reindexLink) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} section
|
||||
* @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} releaseDate
|
||||
* @property {string} visibilityState
|
||||
* @property {boolean} hasExplicitStaffLock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} dueDate
|
||||
* @property {null} due
|
||||
* @property {null} relativeWeeksDue
|
||||
* @property {null} format
|
||||
* @property {string[]} courseGraders
|
||||
* @property {boolean} hasChanges
|
||||
* @property {object} actions
|
||||
* @property {null} explanatoryMessage
|
||||
* @property {object[]} userPartitions
|
||||
* @property {string} showCorrectness
|
||||
* @property {string[]} highlights
|
||||
* @property {boolean} highlightsEnabled
|
||||
* @property {boolean} highlightsPreviewOnly
|
||||
* @property {string} highlightsDocUrl
|
||||
* @property {object} childInfo
|
||||
* @property {boolean} ancestorHasStaffLock
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
* @property {object} userPartitionInfo
|
||||
* @property {boolean} enableCopyPasteUnits
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get course section
|
||||
* Get course Xblock
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<section>}
|
||||
* @returns {Promise<XBlock>}
|
||||
*/
|
||||
export async function getCourseItem(itemId) {
|
||||
export async function getCourseItem(itemId: string): Promise<XBlock> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXBlockApiUrl(itemId));
|
||||
return camelCaseObject(data);
|
||||
@@ -189,7 +169,10 @@ export async function getCourseItem(itemId) {
|
||||
* @param {Array<string>} highlights
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
export async function updateCourseSectionHighlights(
|
||||
sectionId: string,
|
||||
highlights: Array<string>,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
@@ -206,7 +189,7 @@ export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
* @param {string} sectionId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function publishCourseSection(sectionId) {
|
||||
export async function publishCourseSection(sectionId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'make_public',
|
||||
@@ -222,7 +205,11 @@ export async function publishCourseSection(sectionId) {
|
||||
* @param {string} startDatetime
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
export async function configureCourseSection(
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
startDatetime: string,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
@@ -258,24 +245,24 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
) {
|
||||
itemId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
publish: 'republish',
|
||||
@@ -307,9 +294,15 @@ export async function configureCourseSubsection(
|
||||
* @param {string} unitId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {object} groupAccess
|
||||
* @param {boolean} discussionEnabled
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
export async function configureCourseUnit(
|
||||
unitId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
publish: 'republish',
|
||||
@@ -330,7 +323,10 @@ export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAcc
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function editItemDisplayName(itemId, displayName) {
|
||||
export async function editItemDisplayName(
|
||||
itemId: string,
|
||||
displayName: string,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
metadata: {
|
||||
@@ -346,7 +342,7 @@ export async function editItemDisplayName(itemId, displayName) {
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteCourseItem(itemId) {
|
||||
export async function deleteCourseItem(itemId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getCourseItemApiUrl(itemId));
|
||||
|
||||
@@ -357,9 +353,9 @@ export async function deleteCourseItem(itemId) {
|
||||
* Duplicate course section
|
||||
* @param {string} itemId
|
||||
* @param {string} parentId
|
||||
* @returns {Promise<Object>}
|
||||
* @returns {Promise<XBlock>}
|
||||
*/
|
||||
export async function duplicateCourseItem(itemId, parentId) {
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<XBlock> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
duplicate_source_locator: itemId,
|
||||
@@ -376,7 +372,7 @@ export async function duplicateCourseItem(itemId, parentId) {
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
@@ -393,7 +389,7 @@ export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setSectionOrderList(courseId, children) {
|
||||
export async function setSectionOrderList(courseId: string, children: Array<string>): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getCourseBlockApiUrl(courseId), {
|
||||
children,
|
||||
@@ -408,7 +404,7 @@ export async function setSectionOrderList(courseId, children) {
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setCourseItemOrderList(itemId, children) {
|
||||
export async function setCourseItemOrderList(itemId: string, children: Array<string>): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getCourseItemApiUrl(itemId), {
|
||||
children,
|
||||
@@ -423,7 +419,10 @@ export async function setCourseItemOrderList(itemId, children) {
|
||||
* @param {string} videoSharingOption
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setVideoSharingOption(courseId, videoSharingOption) {
|
||||
export async function setVideoSharingOption(
|
||||
courseId: string,
|
||||
videoSharingOption: string,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseBlockApiUrl(courseId), {
|
||||
metadata: {
|
||||
@@ -439,7 +438,7 @@ export async function setVideoSharingOption(courseId, videoSharingOption) {
|
||||
* @param {string} parentLocator
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function pasteBlock(parentLocator) {
|
||||
export async function pasteBlock(parentLocator: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
@@ -454,7 +453,7 @@ export async function pasteBlock(parentLocator) {
|
||||
* @param {string} url
|
||||
* @returns void
|
||||
*/
|
||||
export async function dismissNotification(url) {
|
||||
export async function dismissNotification(url: string) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
@@ -462,9 +461,10 @@ export async function dismissNotification(url) {
|
||||
/**
|
||||
* Downloads the file of the exported tags
|
||||
* @param {string} courseId The ID of the content
|
||||
* @param {string} courseName
|
||||
* @returns void
|
||||
*/
|
||||
export async function getTagsExportFile(courseId, courseName) {
|
||||
export async function getTagsExportFile(courseId: string, courseName: string) {
|
||||
// Gets exported tags and builds the blob to download CSV file.
|
||||
// This can be done with this code:
|
||||
// `window.location.href = exportTags(contentId);`
|
||||
35
src/course-outline/data/apiHooks.ts
Normal file
35
src/course-outline/data/apiHooks.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
import { getCourseItem } from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
all: ['courseOutline'],
|
||||
/**
|
||||
* Base key for data specific to a course in outline
|
||||
*/
|
||||
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create an XBLOCK in a course .
|
||||
* The `locator` is the ID of the parent block where this new XBLOCK should be created.
|
||||
* Can also be used to import block from library by passing `libraryContentKey` in request body
|
||||
*/
|
||||
export const useCreateCourseBlock = (
|
||||
callback?: ((locator?: string, parentLocator?: string) => void),
|
||||
) => useMutation({
|
||||
mutationFn: createCourseXblock,
|
||||
onSettled: async (data) => {
|
||||
callback?.(data.locator, data.parent_locator);
|
||||
},
|
||||
});
|
||||
|
||||
export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
|
||||
useQuery({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
|
||||
queryFn: () => getCourseItem(itemId!),
|
||||
enabled: enabled && itemId !== undefined,
|
||||
})
|
||||
);
|
||||
41
src/course-outline/data/selectors.test.js
Normal file
41
src/course-outline/data/selectors.test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getTimedExamsFlag, getProctoredExamsFlag } from './selectors';
|
||||
|
||||
const mockState = {
|
||||
courseOutline: {
|
||||
enableTimedExams: true,
|
||||
enableProctoredExams: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('course-outline selectors', () => {
|
||||
describe('getTimedExamsFlag', () => {
|
||||
it('returns enableTimedExams value from state', () => {
|
||||
expect(getTimedExamsFlag(mockState)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when enableTimedExams is false', () => {
|
||||
const stateWithDisabledExams = {
|
||||
courseOutline: {
|
||||
...mockState.courseOutline,
|
||||
enableTimedExams: false,
|
||||
},
|
||||
};
|
||||
expect(getTimedExamsFlag(stateWithDisabledExams)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns undefined when enableTimedExams is not set', () => {
|
||||
const stateWithoutProperty = {
|
||||
courseOutline: {
|
||||
enableProctoredExams: false,
|
||||
},
|
||||
};
|
||||
expect(getTimedExamsFlag(stateWithoutProperty)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProctoredExamsFlag', () => {
|
||||
it('returns enableProctoredExams value from state', () => {
|
||||
expect(getProctoredExamsFlag(mockState)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,5 +9,7 @@ export const getCurrentSubsection = (state) => state.courseOutline.currentSubsec
|
||||
export const getCourseActions = (state) => state.courseOutline.actions;
|
||||
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
export const getTimedExamsFlag = (state) => state.courseOutline.enableTimedExams;
|
||||
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
|
||||
export const getErrors = (state) => state.courseOutline.errors;
|
||||
export const getCreatedOn = (state) => state.courseOutline.createdOn;
|
||||
79
src/course-outline/data/slice.test.js
Normal file
79
src/course-outline/data/slice.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { reducer, fetchOutlineIndexSuccess } from './slice';
|
||||
|
||||
describe('course-outline slice', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = configureStore({
|
||||
reducer: {
|
||||
courseOutline: reducer,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOutlineIndexSuccess action', () => {
|
||||
it('sets enableTimedExams from payload', () => {
|
||||
const mockPayload = {
|
||||
courseStructure: {
|
||||
enableProctoredExams: true,
|
||||
enableTimedExams: false,
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
isCustomRelativeDatesActive: false,
|
||||
createdOn: null,
|
||||
};
|
||||
|
||||
store.dispatch(fetchOutlineIndexSuccess(mockPayload));
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseOutline.enableTimedExams).toBe(false);
|
||||
expect(state.courseOutline.enableProctoredExams).toBe(true);
|
||||
});
|
||||
|
||||
it('sets enableTimedExams to true when provided', () => {
|
||||
const mockPayload = {
|
||||
courseStructure: {
|
||||
enableProctoredExams: false,
|
||||
enableTimedExams: true,
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
isCustomRelativeDatesActive: false,
|
||||
createdOn: null,
|
||||
};
|
||||
|
||||
store.dispatch(fetchOutlineIndexSuccess(mockPayload));
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseOutline.enableTimedExams).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing enableTimedExams field gracefully', () => {
|
||||
const mockPayload = {
|
||||
courseStructure: {
|
||||
enableProctoredExams: true,
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
isCustomRelativeDatesActive: false,
|
||||
createdOn: null,
|
||||
};
|
||||
|
||||
store.dispatch(fetchOutlineIndexSuccess(mockPayload));
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseOutline.enableTimedExams).toBeUndefined();
|
||||
expect(state.courseOutline.enableProctoredExams).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes with enableTimedExams false by default', () => {
|
||||
const state = store.getState();
|
||||
expect(state.courseOutline.enableTimedExams).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +1,176 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { VIDEO_SHARING_OPTIONS } from '../constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { CourseOutlineState } from './types';
|
||||
|
||||
const initialState = {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseLaunchQueryStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
errors: {
|
||||
outlineIndexApi: null,
|
||||
reindexApi: null,
|
||||
sectionLoadingApi: null,
|
||||
courseLaunchApi: null,
|
||||
},
|
||||
outlineIndexData: {},
|
||||
savingStatus: '',
|
||||
statusBarData: {
|
||||
courseReleaseDate: '',
|
||||
highlightsEnabledForMessaging: false,
|
||||
isSelfPaced: false,
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: 0,
|
||||
completedCourseLaunchChecks: 0,
|
||||
totalCourseBestPracticesChecks: 0,
|
||||
completedCourseBestPracticesChecks: 0,
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
unlinkable: false,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
allowMoveUp: false,
|
||||
allowMoveDown: false,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
enableTimedExams: false,
|
||||
pasteFileNotices: {},
|
||||
createdOn: null,
|
||||
} satisfies CourseOutlineState as unknown as CourseOutlineState;
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseOutline',
|
||||
initialState: {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseLaunchQueryStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
errors: {
|
||||
outlineIndexApi: null,
|
||||
reindexApi: null,
|
||||
sectionLoadingApi: null,
|
||||
courseLaunchApi: null,
|
||||
},
|
||||
outlineIndexData: {},
|
||||
savingStatus: '',
|
||||
statusBarData: {
|
||||
courseReleaseDate: '',
|
||||
highlightsEnabledForMessaging: false,
|
||||
isSelfPaced: false,
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: 0,
|
||||
completedCourseLaunchChecks: 0,
|
||||
totalCourseBestPracticesChecks: 0,
|
||||
completedCourseBestPracticesChecks: 0,
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
pasteFileNotices: {},
|
||||
},
|
||||
initialState,
|
||||
reducers: {
|
||||
fetchOutlineIndexSuccess: (state, { payload }) => {
|
||||
fetchOutlineIndexSuccess: (state: CourseOutlineState, { payload }) => {
|
||||
state.outlineIndexData = payload;
|
||||
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
|
||||
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
|
||||
state.enableProctoredExams = payload.courseStructure?.enableProctoredExams;
|
||||
state.enableTimedExams = payload.courseStructure?.enableTimedExams;
|
||||
state.createdOn = payload.createdOn;
|
||||
},
|
||||
updateOutlineIndexLoadingStatus: (state, { payload }) => {
|
||||
updateOutlineIndexLoadingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
outlineIndexLoadingStatus: payload.status,
|
||||
};
|
||||
state.errors.outlineIndexApi = payload.errors || null;
|
||||
},
|
||||
updateReindexLoadingStatus: (state, { payload }) => {
|
||||
updateReindexLoadingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
reIndexLoadingStatus: payload.status,
|
||||
};
|
||||
state.errors.reindexApi = payload.errors || null;
|
||||
},
|
||||
updateFetchSectionLoadingStatus: (state, { payload }) => {
|
||||
updateFetchSectionLoadingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
fetchSectionLoadingStatus: payload.status,
|
||||
};
|
||||
state.errors.sectionLoadingApi = payload.errors || null;
|
||||
},
|
||||
updateCourseLaunchQueryStatus: (state, { payload }) => {
|
||||
updateCourseLaunchQueryStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
courseLaunchQueryStatus: payload.status,
|
||||
};
|
||||
state.errors.courseLaunchApi = payload.errors || null;
|
||||
},
|
||||
dismissError: (state, { payload }) => {
|
||||
dismissError: (state: CourseOutlineState, { payload }) => {
|
||||
state.errors[payload] = null;
|
||||
},
|
||||
updateStatusBar: (state, { payload }) => {
|
||||
updateStatusBar: (state: CourseOutlineState, { payload }) => {
|
||||
state.statusBarData = {
|
||||
...state.statusBarData,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
updateCourseActions: (state, { payload }) => {
|
||||
updateCourseActions: (state: CourseOutlineState, { payload }) => {
|
||||
state.actions = {
|
||||
...state.actions,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarChecklistSuccess: (state, { payload }) => {
|
||||
fetchStatusBarChecklistSuccess: (state: CourseOutlineState, { payload }) => {
|
||||
state.statusBarData.checklist = {
|
||||
...state.statusBarData.checklist,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarSelfPacedSuccess: (state, { payload }) => {
|
||||
fetchStatusBarSelfPacedSuccess: (state: CourseOutlineState, { payload }) => {
|
||||
state.statusBarData.isSelfPaced = payload.isSelfPaced;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
updateSavingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateSectionList: (state, { payload }) => {
|
||||
updateSectionList: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section));
|
||||
},
|
||||
setCurrentItem: (state, { payload }) => {
|
||||
setCurrentItem: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentItem = payload;
|
||||
},
|
||||
reorderSectionList: (state, { payload }) => {
|
||||
reorderSectionList: (state: CourseOutlineState, { payload }) => {
|
||||
const sectionsList = [...state.sectionsList];
|
||||
sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id));
|
||||
|
||||
state.sectionsList = [...sectionsList];
|
||||
},
|
||||
setCurrentSection: (state, { payload }) => {
|
||||
setCurrentSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
setCurrentSubsection: (state, { payload }) => {
|
||||
setCurrentSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentSubsection = payload;
|
||||
},
|
||||
addSection: (state, { payload }) => {
|
||||
addSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = [
|
||||
...state.sectionsList,
|
||||
payload,
|
||||
];
|
||||
},
|
||||
addSubsection: (state, { payload }) => {
|
||||
resetScrollField: (state) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.shouldScroll = false;
|
||||
section.childInfo.children.map((subsection) => {
|
||||
subsection.shouldScroll = false;
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
addSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id === payload.parentLocator) {
|
||||
section.childInfo.children = [
|
||||
...section.childInfo.children,
|
||||
...section.childInfo.children.filter(child => child.id !== payload.data.id), // Filter to avoid duplicates
|
||||
payload.data,
|
||||
];
|
||||
}
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteSection: (state, { payload }) => {
|
||||
deleteSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
},
|
||||
deleteSubsection: (state, { payload }) => {
|
||||
deleteSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
@@ -161,7 +181,7 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state, { payload }) => {
|
||||
deleteUnit: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
@@ -178,7 +198,7 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
duplicateSection: (state, { payload }) => {
|
||||
duplicateSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
|
||||
if (currentValue.id === payload.id) {
|
||||
return [...result, currentValue, payload.duplicatedItem];
|
||||
@@ -186,12 +206,12 @@ const slice = createSlice({
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
},
|
||||
setPasteFileNotices: (state, { payload }) => {
|
||||
setPasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
state.pasteFileNotices = payload;
|
||||
},
|
||||
removePasteFileNotices: (state, { payload }) => {
|
||||
removePasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
const pasteFileNotices = { ...state.pasteFileNotices };
|
||||
payload.forEach((key) => delete pasteFileNotices[key]);
|
||||
payload.forEach((key: string | number) => delete pasteFileNotices[key]);
|
||||
state.pasteFileNotices = pasteFileNotices;
|
||||
},
|
||||
},
|
||||
@@ -219,11 +239,10 @@ export const {
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
setPasteFileNotices,
|
||||
removePasteFileNotices,
|
||||
dismissError,
|
||||
resetScrollField,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
} from '@src/generic/processing-notification/data/slice';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
getCourseBestPracticesChecklist,
|
||||
getCourseLaunchChecklist,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
setVideoSharingOption,
|
||||
setCourseItemOrderList,
|
||||
pasteBlock,
|
||||
dismissNotification,
|
||||
dismissNotification, createDiscussionsTopics,
|
||||
} from './api';
|
||||
import {
|
||||
addSection,
|
||||
@@ -53,9 +55,14 @@ import {
|
||||
setPasteFileNotices,
|
||||
updateCourseLaunchQueryStatus,
|
||||
} from './slice';
|
||||
import { createCourseXblock } from '../../course-unit/data/api';
|
||||
|
||||
export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
/**
|
||||
* Action to fetch course outline.
|
||||
*
|
||||
* @param {string} courseId - ID of the course
|
||||
* @returns {Object} - Object containing fetch course outline index query success or failure status
|
||||
*/
|
||||
export function fetchCourseOutlineIndexQuery(courseId: string): object {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
@@ -80,7 +87,7 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
dispatch(updateCourseActions(actions));
|
||||
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateOutlineIndexLoadingStatus({
|
||||
status: RequestStatus.DENIED,
|
||||
@@ -95,6 +102,16 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function syncDiscussionsTopics(courseId: string) {
|
||||
return async () => {
|
||||
try {
|
||||
await createDiscussionsTopics(courseId);
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseLaunchQuery({
|
||||
courseId,
|
||||
gradedOnly = true,
|
||||
@@ -137,7 +154,7 @@ export function fetchCourseBestPracticesQuery({
|
||||
};
|
||||
}
|
||||
|
||||
export function enableCourseHighlightsEmailsQuery(courseId) {
|
||||
export function enableCourseHighlightsEmailsQuery(courseId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -154,7 +171,7 @@ export function enableCourseHighlightsEmailsQuery(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function setVideoSharingOptionQuery(courseId, option) {
|
||||
export function setVideoSharingOptionQuery(courseId: string, option: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -172,7 +189,7 @@ export function setVideoSharingOptionQuery(courseId, option) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
export function fetchCourseReindexQuery(reindexLink: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
@@ -188,16 +205,36 @@ export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) {
|
||||
/**
|
||||
* Fetches course sections and optionally scrolls to a specific subsection/unit.
|
||||
*/
|
||||
export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
|
||||
subsectionId: string,
|
||||
unitId?: string,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const sections = {};
|
||||
const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId)));
|
||||
results.forEach((data) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
data.shouldScroll = shouldScroll;
|
||||
sections[data.id] = data;
|
||||
results.forEach(section => {
|
||||
if (scrollToId) {
|
||||
const targetSubsection = section?.childInfo?.children?.find(
|
||||
subsection => subsection.id === scrollToId.subsectionId,
|
||||
);
|
||||
|
||||
if (targetSubsection) {
|
||||
if (scrollToId.unitId) {
|
||||
const targetUnit = targetSubsection?.childInfo?.children?.find(unit => unit.id === scrollToId.unitId);
|
||||
if (targetUnit) {
|
||||
targetUnit.shouldScroll = true;
|
||||
}
|
||||
} else {
|
||||
targetSubsection.shouldScroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
sections[section.id] = section;
|
||||
});
|
||||
dispatch(updateSectionList(sections));
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
@@ -210,7 +247,7 @@ export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
export function updateCourseSectionHighlightsQuery(sectionId: string, highlights: string[]) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -230,7 +267,7 @@ export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
};
|
||||
}
|
||||
|
||||
export function publishCourseItemQuery(itemId, sectionId) {
|
||||
export function publishCourseItemQuery(itemId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -250,7 +287,7 @@ export function publishCourseItemQuery(itemId, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseItemQuery(sectionId, configureFn) {
|
||||
export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise<any>) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -270,7 +307,7 @@ export function configureCourseItemQuery(sectionId, configureFn) {
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
export function configureCourseSectionQuery(sectionId: string, isVisibleToStaffOnly: boolean, startDatetime: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -280,24 +317,24 @@ export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, sta
|
||||
}
|
||||
|
||||
export function configureCourseSubsectionQuery(
|
||||
itemId,
|
||||
sectionId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
@@ -325,7 +362,13 @@ export function configureCourseSubsectionQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
export function configureCourseUnitQuery(
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -334,7 +377,7 @@ export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
export function editCourseItemQuery(itemId: string, sectionId: string, displayName: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -358,9 +401,8 @@ export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
* Generic function to delete course item, see below wrapper funcs for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {() => {}} deleteItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function deleteCourseItemQuery(itemId, deleteItemFn) {
|
||||
function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||
@@ -377,7 +419,7 @@ function deleteCourseItemQuery(itemId, deleteItemFn) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSectionQuery(sectionId) {
|
||||
export function deleteCourseSectionQuery(sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -386,7 +428,7 @@ export function deleteCourseSectionQuery(sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
export function deleteCourseSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
subsectionId,
|
||||
@@ -395,7 +437,7 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
export function deleteCourseUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
unitId,
|
||||
@@ -409,9 +451,12 @@ export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
* @param {string} itemId
|
||||
* @param {string} parentLocator
|
||||
* @param {(locator) => Promise<any>} duplicateFn
|
||||
* @returns {}
|
||||
*/
|
||||
function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
|
||||
function duplicateCourseItemQuery(
|
||||
itemId: string,
|
||||
parentLocator: string,
|
||||
duplicateFn: (locator: string) => Promise<any>,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
||||
@@ -431,7 +476,7 @@ function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
export function duplicateSectionQuery(sectionId: string, courseBlockId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -446,35 +491,40 @@ export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
export function duplicateSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery([sectionId], true)),
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId: itemId, // To scroll to the newly duplicated subsection
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateUnitQuery(unitId, subsectionId, sectionId) {
|
||||
export function duplicateUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery([sectionId], true)),
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId,
|
||||
unitId: itemId, // To scroll to the newly duplicated unit
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to add any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} parentLocator
|
||||
* @param {string} category
|
||||
* @param {string} displayName
|
||||
* @param {(data) => {}} addItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) {
|
||||
function addNewCourseItemQuery(
|
||||
parentLocator: string,
|
||||
category: string,
|
||||
displayName: string,
|
||||
addItemFn: (data: any) => Promise<any>,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -498,7 +548,7 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn)
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSectionQuery(parentLocator) {
|
||||
export function addNewSectionQuery(parentLocator: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
@@ -514,7 +564,7 @@ export function addNewSectionQuery(parentLocator) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSubsectionQuery(parentLocator) {
|
||||
export function addNewSubsectionQuery(parentLocator: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
@@ -530,7 +580,7 @@ export function addNewSubsectionQuery(parentLocator) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewUnitQuery(parentLocator, callback) {
|
||||
export function addNewUnitQuery(parentLocator: string, callback: { (locator: any): void }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
@@ -541,7 +591,15 @@ export function addNewUnitQuery(parentLocator, callback) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addUnitFromLibrary(body, callback) {
|
||||
export function addUnitFromLibrary(body: {
|
||||
type: string;
|
||||
category?: string;
|
||||
parentLocator: string;
|
||||
displayName?: string;
|
||||
boilerplate?: string;
|
||||
stagedContent?: string;
|
||||
libraryContentKey?: string;
|
||||
}, callback: (arg0: any) => void) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -562,11 +620,16 @@ export function addUnitFromLibrary(body, callback) {
|
||||
}
|
||||
|
||||
function setBlockOrderListQuery(
|
||||
parentId,
|
||||
blockIds,
|
||||
apiFn,
|
||||
restoreCallback,
|
||||
successCallback,
|
||||
parentId: string,
|
||||
blockIds: string[],
|
||||
apiFn: {
|
||||
(courseId: string, children: string[]): Promise<object>;
|
||||
(itemId: string, children: string[]): Promise<object>;
|
||||
(itemId: string, children: string[]): Promise<object>;
|
||||
(arg0: any, arg1: any): Promise<any>;
|
||||
},
|
||||
restoreCallback: () => void,
|
||||
successCallback: { (): any; (): void; (): void; (): void; },
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
@@ -588,7 +651,11 @@ function setBlockOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) {
|
||||
export function setSectionOrderListQuery(
|
||||
courseId: string,
|
||||
sectionListIds: string[],
|
||||
restoreCallback: () => void,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(setBlockOrderListQuery(
|
||||
courseId,
|
||||
@@ -601,10 +668,10 @@ export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallba
|
||||
}
|
||||
|
||||
export function setSubsectionOrderListQuery(
|
||||
sectionId,
|
||||
prevSectionId,
|
||||
subsectionListIds,
|
||||
restoreCallback,
|
||||
sectionId: string,
|
||||
prevSectionId: string,
|
||||
subsectionListIds: string[],
|
||||
restoreCallback: () => void,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(setBlockOrderListQuery(
|
||||
@@ -624,11 +691,11 @@ export function setSubsectionOrderListQuery(
|
||||
}
|
||||
|
||||
export function setUnitOrderListQuery(
|
||||
sectionId,
|
||||
subsectionId,
|
||||
prevSectionId,
|
||||
unitListIds,
|
||||
restoreCallback,
|
||||
sectionId: string,
|
||||
subsectionId: string,
|
||||
prevSectionId: string,
|
||||
unitListIds: string[],
|
||||
restoreCallback: () => void,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(setBlockOrderListQuery(
|
||||
@@ -647,15 +714,15 @@ export function setUnitOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteClipboardContent(parentLocator, sectionId) {
|
||||
export function pasteClipboardContent(parentLocator: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||
|
||||
try {
|
||||
await pasteBlock(parentLocator).then(async (result) => {
|
||||
await pasteBlock(parentLocator).then(async (result: any) => {
|
||||
if (result) {
|
||||
dispatch(fetchCourseSectionQuery([sectionId], true));
|
||||
dispatch(fetchCourseSectionQuery([sectionId], { subsectionId: parentLocator, unitId: result.locator }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
@@ -668,7 +735,7 @@ export function pasteClipboardContent(parentLocator, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissNotificationQuery(url) {
|
||||
export function dismissNotificationQuery(url: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
65
src/course-outline/data/types.ts
Normal file
65
src/course-outline/data/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { XBlock, XBlockActions } from '@src/data/types';
|
||||
|
||||
export interface CourseStructure {
|
||||
highlightsEnabledForMessaging: boolean,
|
||||
videoSharingEnabled: boolean,
|
||||
videoSharingOptions: string,
|
||||
actions: XBlockActions,
|
||||
}
|
||||
|
||||
// TODO: Create interface for all `Object` fields in courseOutline
|
||||
export interface CourseOutline {
|
||||
courseReleaseDate: string;
|
||||
courseStructure: CourseStructure;
|
||||
deprecatedBlocksInfo: Object;
|
||||
discussionsIncontextLearnmoreUrl: string;
|
||||
initialState: Object;
|
||||
initialUserClipboard: Object;
|
||||
languageCode: string;
|
||||
lmsLink: string;
|
||||
mfeProctoredExamSettingsUrl: string;
|
||||
notificationDismissUrl: string;
|
||||
proctoringErrors: string[];
|
||||
reindexLink: string;
|
||||
rerunNotificationId: null;
|
||||
}
|
||||
|
||||
export interface CourseOutlineState {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: string;
|
||||
reIndexLoadingStatus: string;
|
||||
fetchSectionLoadingStatus: string;
|
||||
courseLaunchQueryStatus: string;
|
||||
};
|
||||
errors: {
|
||||
outlineIndexApi: null | object;
|
||||
reindexApi: null | object;
|
||||
sectionLoadingApi: null | object;
|
||||
courseLaunchApi: null | object;
|
||||
};
|
||||
outlineIndexData: object;
|
||||
savingStatus: string;
|
||||
statusBarData: {
|
||||
courseReleaseDate: string;
|
||||
highlightsEnabledForMessaging: boolean;
|
||||
isSelfPaced: boolean;
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: number;
|
||||
completedCourseLaunchChecks: number;
|
||||
totalCourseBestPracticesChecks: number;
|
||||
completedCourseBestPracticesChecks: number;
|
||||
};
|
||||
videoSharingEnabled: boolean;
|
||||
videoSharingOptions: string;
|
||||
};
|
||||
sectionsList: Array<XBlock>;
|
||||
isCustomRelativeDatesActive: boolean;
|
||||
currentSection: XBlock | {};
|
||||
currentSubsection: XBlock | {};
|
||||
currentItem: XBlock | {};
|
||||
actions: XBlockActions;
|
||||
enableProctoredExams: boolean;
|
||||
enableTimedExams: boolean;
|
||||
pasteFileNotices: object;
|
||||
createdOn: null | Date;
|
||||
}
|
||||
117
src/course-outline/drag-helper/CourseItemOverlay.tsx
Normal file
117
src/course-outline/drag-helper/CourseItemOverlay.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Col, Icon, Row } from '@openedx/paragon';
|
||||
import { ArrowRight, DragIndicator } from '@openedx/paragon/icons';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { getItemStatusBorder } from '../utils';
|
||||
|
||||
interface ItemProps {
|
||||
displayName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CourseItemOverlayProps extends ItemProps {
|
||||
category: string;
|
||||
}
|
||||
|
||||
const commonStyle = {
|
||||
padding: '1rem 1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderRadius: '0.35rem',
|
||||
boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)',
|
||||
};
|
||||
|
||||
const DragIndicatorBtn = () => (
|
||||
<button
|
||||
key="drag-to-reorder-icon"
|
||||
className="btn-icon btn-icon-secondary btn-icon-md"
|
||||
type="button"
|
||||
>
|
||||
<span className="btn-icon__icon-container">
|
||||
<Icon src={DragIndicator} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const SectionCard = ({ status, displayName }: ItemProps) => {
|
||||
const style = {
|
||||
...commonStyle,
|
||||
paddingTop: '2rem',
|
||||
paddingBottom: '5rem',
|
||||
...getItemStatusBorder(status),
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={style}
|
||||
className="mx-0 bg-white"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
<div className="item-card-header h3">
|
||||
<Icon src={ArrowRight} className="mr-2" />
|
||||
{displayName}
|
||||
</div>
|
||||
</Col>
|
||||
<DragIndicatorBtn />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const SubsectionCard = ({ status, displayName }: ItemProps) => {
|
||||
const style = {
|
||||
...commonStyle,
|
||||
paddingTop: '1rem',
|
||||
paddingBottom: '2.5rem',
|
||||
...getItemStatusBorder(status),
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={style}
|
||||
className="mx-0 bg-light-200"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
<div className="item-card-header h4 pt-2">
|
||||
<Icon src={ArrowRight} className="mr-2" />
|
||||
{displayName}
|
||||
</div>
|
||||
</Col>
|
||||
<DragIndicatorBtn />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const UnitCard = ({ status, displayName }: ItemProps) => {
|
||||
const style = {
|
||||
...commonStyle,
|
||||
paddingBottom: '1.5rem',
|
||||
...getItemStatusBorder(status),
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={style}
|
||||
className="mx-0 bg-white"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
<div className="item-card-header h5 pt-3">
|
||||
{displayName}
|
||||
</div>
|
||||
</Col>
|
||||
<DragIndicatorBtn />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseItemOverlay = ({ category, displayName, status }: CourseItemOverlayProps) => {
|
||||
switch (category) {
|
||||
case ContainerType.Chapter:
|
||||
return <SectionCard displayName={displayName} status={status} />;
|
||||
case ContainerType.Sequential:
|
||||
return <SubsectionCard displayName={displayName} status={status} />;
|
||||
case ContainerType.Vertical:
|
||||
return <UnitCard displayName={displayName} status={status} />;
|
||||
default:
|
||||
throw new Error(`Invalid course item type: ${category}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default CourseItemOverlay;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user