Compare commits
1 Commits
release/ul
...
test_hyper
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16ccfe9cf |
8
.env
8
.env
@@ -41,11 +41,9 @@ HOTJAR_APP_ID=''
|
|||||||
HOTJAR_VERSION=6
|
HOTJAR_VERSION=6
|
||||||
HOTJAR_DEBUG=false
|
HOTJAR_DEBUG=false
|
||||||
INVITE_STUDENTS_EMAIL_TO=''
|
INVITE_STUDENTS_EMAIL_TO=''
|
||||||
|
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||||
ENABLE_CHECKLIST_QUALITY=''
|
ENABLE_CHECKLIST_QUALITY=''
|
||||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||||
# "Multi-level" blocks are unsupported in libraries
|
# "Multi-level" blocks are unsupported in libraries
|
||||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
# TODO: Missing support for ORA2
|
||||||
# Fallback in local style files
|
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||||
PARAGON_THEME_URLS={}
|
|
||||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
|
||||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
|
||||||
|
|||||||
@@ -44,11 +44,8 @@ HOTJAR_APP_ID=''
|
|||||||
HOTJAR_VERSION=6
|
HOTJAR_VERSION=6
|
||||||
HOTJAR_DEBUG=true
|
HOTJAR_DEBUG=true
|
||||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||||
|
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||||
ENABLE_CHECKLIST_QUALITY=true
|
ENABLE_CHECKLIST_QUALITY=true
|
||||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||||
# "Multi-level" blocks are unsupported in libraries
|
# "Multi-level" blocks are unsupported in libraries
|
||||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||||
# Fallback in local style files
|
|
||||||
PARAGON_THEME_URLS={}
|
|
||||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
|
||||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ ENABLE_CERTIFICATE_PAGE=true
|
|||||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||||
|
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||||
ENABLE_CHECKLIST_QUALITY=true
|
ENABLE_CHECKLIST_QUALITY=true
|
||||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||||
# "Multi-level" blocks are unsupported in libraries
|
# "Multi-level" blocks are unsupported in libraries
|
||||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
# TODO: Missing support for ORA2
|
||||||
PARAGON_THEME_URLS=
|
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||||
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
|
|
||||||
|
|||||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -2,37 +2,26 @@
|
|||||||
|
|
||||||
Describe what this pull request changes, and why. Include implications for people using this change.
|
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
|
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:
|
Useful information to include:
|
||||||
- Which user roles will this change impact? Common user roles are "Learner", "Course Author",
|
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||||
"Developer", and "Operator".
|
"Developer", and "Operator".
|
||||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
- 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
|
## Supporting information
|
||||||
|
|
||||||
Link to other information about the change, such as GitHub issues, or Discourse discussions.
|
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
|
||||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||||
|
|
||||||
## Testing instructions
|
## Testing instructions
|
||||||
|
|
||||||
Please provide detailed step-by-step instructions for manually testing this change.
|
Please provide detailed step-by-step instructions for testing this change.
|
||||||
|
|
||||||
|
|
||||||
## Other information
|
## Other information
|
||||||
|
|
||||||
Include anything else that will help reviewers and consumers understand the change.
|
Include anything else that will help reviewers and consumers understand the change.
|
||||||
- Does this change depend on other changes elsewhere?
|
- 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
18
.github/workflows/add-issue-to-btr-project.yml
vendored
@@ -1,18 +0,0 @@
|
|||||||
# 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 }}
|
|
||||||
15
.github/workflows/add-to-cc-board.yml
vendored
15
.github/workflows/add-to-cc-board.yml
vendored
@@ -1,15 +0,0 @@
|
|||||||
name: Trigger to add Issue or PR to a Core Contributor project board
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [labeled]
|
|
||||||
pull_request:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-to-cc-board:
|
|
||||||
if: github.event.label.name == 'Core Contributor assignee'
|
|
||||||
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
|
|
||||||
with:
|
|
||||||
board_name: cc-frontend-apps
|
|
||||||
secrets:
|
|
||||||
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}
|
|
||||||
16
.github/workflows/validate.yml
vendored
16
.github/workflows/validate.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
- run: make validate.ci
|
- run: make validate.ci
|
||||||
- name: Archive code coverage results
|
- name: Archive code coverage results
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: code-coverage-report
|
name: code-coverage-report
|
||||||
path: coverage/*.*
|
path: coverage/*.*
|
||||||
@@ -25,15 +25,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: tests
|
needs: tests
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Download code coverage results
|
- name: Download code coverage results
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
pattern: code-coverage-report
|
name: code-coverage-report
|
||||||
path: coverage
|
|
||||||
merge-multiple: true
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
2
CODEOWNERS
Normal file
2
CODEOWNERS
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# The following users are the maintainers of all frontend-app-authoring files
|
||||||
|
* @openedx/2u-tnl
|
||||||
69
README.rst
69
README.rst
@@ -165,7 +165,21 @@ Feature: New React XBlock Editors
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Feature: New Proctoring Exams View
|
Feature: New Proctoring Exams View
|
||||||
==================================
|
==================================
|
||||||
@@ -179,6 +193,10 @@ Requirements
|
|||||||
|
|
||||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
* ``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
|
* `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
|
Configuration
|
||||||
@@ -203,6 +221,16 @@ Feature: Advanced Settings
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
.. 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.
|
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
|
Feature: Files & Uploads
|
||||||
@@ -210,6 +238,16 @@ Feature: Files & Uploads
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
.. 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.
|
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
|
Feature: Course Updates
|
||||||
@@ -217,11 +255,26 @@ Feature: Course Updates
|
|||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
.. 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
|
Feature: Import/Export Pages
|
||||||
============================
|
============================
|
||||||
|
|
||||||
.. image:: ./docs/readme-images/feature-export.png
|
.. 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
|
Feature: Tagging/Taxonomy Pages
|
||||||
================================
|
================================
|
||||||
|
|
||||||
@@ -327,20 +380,6 @@ For more information about these options, see the `Getting Help`_ page.
|
|||||||
.. _Getting Help: https://openedx.org/community/connect
|
.. _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
|
License
|
||||||
*******
|
*******
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ metadata:
|
|||||||
openedx.org/arch-interest-groups: ""
|
openedx.org/arch-interest-groups: ""
|
||||||
openedx.org/release: "master"
|
openedx.org/release: "master"
|
||||||
spec:
|
spec:
|
||||||
owner: user:bradenmacdonald
|
owner: group:2u-tnl
|
||||||
type: 'website'
|
type: 'website'
|
||||||
lifecycle: 'production'
|
lifecycle: 'production'
|
||||||
|
|||||||
@@ -10,6 +10,4 @@ coverage:
|
|||||||
threshold: 0%
|
threshold: 0%
|
||||||
ignore:
|
ignore:
|
||||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
|
||||||
- "src/container-comparison/data/api.mock.ts"
|
|
||||||
- "src/index.js"
|
- "src/index.js"
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ module.exports = createConfig('jest', {
|
|||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^lodash-es$': 'lodash',
|
'^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',
|
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
modulePathIgnorePatterns: [
|
modulePathIgnorePatterns: [
|
||||||
|
'/src/pages-and-resources/utils.test.jsx',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
13879
package-lock.json
generated
13879
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@@ -11,10 +11,11 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
|
"i18n_extract": "fedx-scripts formatjs extract",
|
||||||
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
"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": "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 .",
|
"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": "fedx-scripts webpack-dev-server --progress",
|
||||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
"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",
|
"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",
|
||||||
@@ -33,7 +34,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-html": "^6.0.0",
|
"@codemirror/lang-html": "^6.0.0",
|
||||||
"@codemirror/lang-markdown": "^6.0.0",
|
|
||||||
"@codemirror/lang-xml": "^6.0.0",
|
"@codemirror/lang-xml": "^6.0.0",
|
||||||
"@codemirror/lint": "^6.2.1",
|
"@codemirror/lint": "^6.2.1",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -43,12 +43,12 @@
|
|||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||||
"@edx/browserslist-config": "1.5.0",
|
"@edx/browserslist-config": "1.2.0",
|
||||||
"@edx/frontend-component-footer": "^14.9.0",
|
"@edx/frontend-component-footer": "^14.3.0",
|
||||||
"@edx/frontend-component-header": "^8.1.0",
|
"@edx/frontend-component-header": "^6.2.0",
|
||||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||||
"@edx/frontend-platform": "^8.4.0",
|
"@edx/frontend-platform": "^8.3.1",
|
||||||
"@edx/openedx-atlas": "^0.7.0",
|
"@edx/openedx-atlas": "^0.6.0",
|
||||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||||
@@ -59,17 +59,18 @@
|
|||||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||||
"@openedx/frontend-build": "^14.6.2",
|
"@openedx/frontend-build": "^14.3.3",
|
||||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||||
"@openedx/paragon": "^23.5.0",
|
"@openedx/frontend-slot-footer": "^1.2.0",
|
||||||
|
"@openedx/paragon": "^22.16.0",
|
||||||
"@redux-devtools/extension": "^3.3.0",
|
"@redux-devtools/extension": "^3.3.0",
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@tanstack/react-query": "5.90.5",
|
"@tanstack/react-query": "4.36.1",
|
||||||
"@tinymce/tinymce-react": "^6.0.0",
|
"@tinymce/tinymce-react": "^3.14.0",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"codemirror": "^6.0.0",
|
"codemirror": "^6.0.0",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"fast-xml-parser": "^5.0.0",
|
"fast-xml-parser": "^4.0.10",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"meilisearch": "^0.41.0",
|
"meilisearch": "^0.41.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"moment-shortformat": "^2.1.0",
|
"moment-shortformat": "^2.1.0",
|
||||||
|
"npm": "^10.8.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-datepicker": "^4.13.0",
|
"react-datepicker": "^4.13.0",
|
||||||
@@ -87,31 +89,31 @@
|
|||||||
"react-onclickoutside": "^6.13.0",
|
"react-onclickoutside": "^6.13.0",
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "9.0.2",
|
"react-responsive": "9.0.2",
|
||||||
"react-router": "6.30.1",
|
"react-router": "6.27.0",
|
||||||
"react-router-dom": "6.30.1",
|
"react-router-dom": "6.27.0",
|
||||||
"react-select": "5.10.2",
|
"react-select": "5.8.0",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
"redux": "4.2.1",
|
"redux": "4.0.5",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "^2.4.1",
|
"redux-thunk": "^2.4.1",
|
||||||
"reselect": "^4.1.5",
|
"reselect": "^4.1.5",
|
||||||
|
"start": "^5.1.0",
|
||||||
"tinymce": "^5.10.4",
|
"tinymce": "^5.10.4",
|
||||||
"universal-cookie": "^8.0.0",
|
"universal-cookie": "^4.0.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^3.4.0",
|
||||||
"xmlchecker": "^0.1.0",
|
"xmlchecker": "^0.1.0",
|
||||||
"yup": "0.32.11"
|
"yup": "0.31.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@edx/react-unit-test-utils": "^4.0.0",
|
||||||
"@edx/stylelint-config-edx": "2.3.3",
|
"@edx/stylelint-config-edx": "2.3.3",
|
||||||
"@edx/typescript-config": "^1.0.1",
|
"@edx/typescript-config": "^1.0.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
"@types/lodash": "^4.17.17",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/react": "^18",
|
"axios-mock-adapter": "1.22.0",
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"axios-mock-adapter": "2.1.0",
|
|
||||||
"eslint-import-resolver-webpack": "^0.13.8",
|
"eslint-import-resolver-webpack": "^0.13.8",
|
||||||
"fetch-mock-jest": "^1.5.1",
|
"fetch-mock-jest": "^1.5.1",
|
||||||
"jest-canvas-mock": "^2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import React from 'react';
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||||
import { initializeMocks, render } from 'CourseAuthoring/testUtils';
|
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
|
||||||
import LearningAssistantSettings from './Settings';
|
import LearningAssistantSettings from './Settings';
|
||||||
|
|
||||||
const onClose = () => { };
|
const onClose = () => { };
|
||||||
|
|
||||||
describe('Learning Assistant Settings', () => {
|
describe('Learning Assistant Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders', async () => {
|
it('renders', async () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
models: {
|
models: {
|
||||||
@@ -34,8 +38,14 @@ describe('Learning Assistant Settings', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeMocks({ initialState });
|
render(
|
||||||
render(<LearningAssistantSettings onClose={onClose} />);
|
<LearningAssistantSettings
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
preloadedState: initialState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
|
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 '
|
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Form, Hyperlink } from '@openedx/paragon';
|
import { Form, Hyperlink } from '@openedx/paragon';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||||
@@ -11,10 +11,10 @@ import LiveCommonFields from './LiveCommonFields';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const BbbSettings = ({
|
const BbbSettings = ({
|
||||||
|
intl,
|
||||||
values,
|
values,
|
||||||
setFieldValue,
|
setFieldValue,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -107,10 +107,12 @@ const BbbSettings = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
BbbSettings.propTypes = {
|
BbbSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
consumerKey: PropTypes.string,
|
consumerKey: PropTypes.string,
|
||||||
consumerSecret: PropTypes.string,
|
consumerSecret: PropTypes.string,
|
||||||
@@ -125,4 +127,4 @@ BbbSettings.propTypes = {
|
|||||||
setFieldValue: PropTypes.func.isRequired,
|
setFieldValue: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BbbSettings;
|
export default injectIntl(BbbSettings);
|
||||||
|
|||||||
@@ -124,13 +124,12 @@ describe('BBB Settings', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test('free plans message is visible when free plan is selected', async () => {
|
test('free plans message is visible when free plan is selected', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
await mockStore({ emailSharing: true, isFreeTier: true });
|
await mockStore({ emailSharing: true, isFreeTier: true });
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const spinner = getByRole(container, 'status');
|
const spinner = getByRole(container, 'status');
|
||||||
await waitForElementToBeRemoved(spinner);
|
await waitForElementToBeRemoved(spinner);
|
||||||
const dropDown = container.querySelector('select[name="tierType"]');
|
const dropDown = container.querySelector('select[name="tierType"]');
|
||||||
await user.selectOptions(
|
userEvent.selectOptions(
|
||||||
dropDown,
|
dropDown,
|
||||||
getByRole(dropDown, 'option', { name: 'Free' }),
|
getByRole(dropDown, 'option', { name: 'Free' }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,42 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const LiveCommonFields = ({
|
const LiveCommonFields = ({
|
||||||
|
intl,
|
||||||
values,
|
values,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
<>
|
||||||
return (
|
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||||
<>
|
<FormikControl
|
||||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
name="consumerKey"
|
||||||
<FormikControl
|
value={values.consumerKey}
|
||||||
name="consumerKey"
|
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||||
value={values.consumerKey}
|
className="pb-1"
|
||||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
type="input"
|
||||||
className="pb-1"
|
/>
|
||||||
type="input"
|
<FormikControl
|
||||||
/>
|
name="consumerSecret"
|
||||||
<FormikControl
|
value={values.consumerSecret}
|
||||||
name="consumerSecret"
|
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||||
value={values.consumerSecret}
|
className="pb-1"
|
||||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
type="password"
|
||||||
className="pb-1"
|
/>
|
||||||
type="password"
|
<FormikControl
|
||||||
/>
|
name="launchUrl"
|
||||||
<FormikControl
|
value={values.launchUrl}
|
||||||
name="launchUrl"
|
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||||
value={values.launchUrl}
|
className="pb-1"
|
||||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
type="input"
|
||||||
className="pb-1"
|
/>
|
||||||
type="input"
|
</>
|
||||||
/>
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
LiveCommonFields.propTypes = {
|
LiveCommonFields.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
consumerKey: PropTypes.string,
|
consumerKey: PropTypes.string,
|
||||||
consumerSecret: PropTypes.string,
|
consumerSecret: PropTypes.string,
|
||||||
@@ -46,4 +45,4 @@ LiveCommonFields.propTypes = {
|
|||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LiveCommonFields;
|
export default injectIntl(LiveCommonFields);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { camelCase } from 'lodash';
|
import { camelCase } from 'lodash';
|
||||||
import { Icon } from '@openedx/paragon';
|
import { Icon } from '@openedx/paragon';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -20,9 +20,9 @@ import ZoomSettings from './ZoomSettings';
|
|||||||
import BBBSettings from './BBBSettings';
|
import BBBSettings from './BBBSettings';
|
||||||
|
|
||||||
const LiveSettings = ({
|
const LiveSettings = ({
|
||||||
|
intl,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const courseId = useSelector(state => state.courseDetail.courseId);
|
const courseId = useSelector(state => state.courseDetail.courseId);
|
||||||
@@ -130,7 +130,8 @@ const LiveSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
LiveSettings.propTypes = {
|
LiveSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LiveSettings;
|
export default injectIntl(LiveSettings);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||||
|
|
||||||
@@ -8,38 +8,37 @@ import { providerNames } from './constants';
|
|||||||
import LiveCommonFields from './LiveCommonFields';
|
import LiveCommonFields from './LiveCommonFields';
|
||||||
|
|
||||||
const ZoomSettings = ({
|
const ZoomSettings = ({
|
||||||
|
intl,
|
||||||
values,
|
values,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
return (
|
<>
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
{!values.piiSharingEnable ? (
|
||||||
<>
|
<p data-testid="request-pii-sharing">
|
||||||
{!values.piiSharingEnable ? (
|
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||||
<p data-testid="request-pii-sharing">
|
</p>
|
||||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
) : (
|
||||||
</p>
|
<>
|
||||||
) : (
|
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||||
<>
|
&& (
|
||||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
<p data-testid="helper-text">
|
||||||
&& (
|
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
||||||
<p data-testid="helper-text">
|
</p>
|
||||||
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
)}
|
||||||
</p>
|
<LiveCommonFields values={values} />
|
||||||
)}
|
<FormikControl
|
||||||
<LiveCommonFields values={values} />
|
name="launchEmail"
|
||||||
<FormikControl
|
value={values.launchEmail}
|
||||||
name="launchEmail"
|
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||||
value={values.launchEmail}
|
type="input"
|
||||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
/>
|
||||||
type="input"
|
</>
|
||||||
/>
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ZoomSettings.propTypes = {
|
ZoomSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
values: PropTypes.shape({
|
values: PropTypes.shape({
|
||||||
consumerKey: PropTypes.string,
|
consumerKey: PropTypes.string,
|
||||||
consumerSecret: PropTypes.string,
|
consumerSecret: PropTypes.string,
|
||||||
@@ -52,4 +51,4 @@ ZoomSettings.propTypes = {
|
|||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ZoomSettings;
|
export default injectIntl(ZoomSettings);
|
||||||
|
|||||||
@@ -125,13 +125,10 @@ describe('ORASettings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||||
await mockStore({ apiStatus: 200, enabled: true });
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
await mockStore({ apiStatus: 200, enabled: true });
|
||||||
|
|
||||||
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
waitFor(() => {
|
||||||
expect(checkbox).toBeChecked();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||||
const enableBadge = screen.getByTestId('enable-badge');
|
const enableBadge = screen.getByTestId('enable-badge');
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -25,8 +25,7 @@ import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/Pa
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ProctoringSettings = ({ onClose }) => {
|
const ProctoringSettings = ({ intl, onClose }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const initialFormValues = {
|
const initialFormValues = {
|
||||||
enableProctoredExams: false,
|
enableProctoredExams: false,
|
||||||
proctoringProvider: false,
|
proctoringProvider: false,
|
||||||
@@ -653,9 +652,10 @@ const ProctoringSettings = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ProctoringSettings.propTypes = {
|
ProctoringSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
ProctoringSettings.defaultProps = {};
|
ProctoringSettings.defaultProps = {};
|
||||||
|
|
||||||
export default ProctoringSettings;
|
export default injectIntl(ProctoringSettings);
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
'authoring.proctoring.alert.error': {
|
||||||
|
id: 'authoring.proctoring.alert.error',
|
||||||
|
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
|
||||||
|
description: 'Alert message for proctoring settings save error.',
|
||||||
|
},
|
||||||
'authoring.proctoring.alert.forbidden': {
|
'authoring.proctoring.alert.forbidden': {
|
||||||
id: 'authoring.proctoring.alert.forbidden',
|
id: 'authoring.proctoring.alert.forbidden',
|
||||||
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
|
|||||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ProgressSettings = ({ onClose }) => {
|
const ProgressSettings = ({ intl, onClose }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
||||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
||||||
|
|
||||||
@@ -49,7 +48,8 @@ const ProgressSettings = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ProgressSettings.propTypes = {
|
ProgressSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProgressSettings;
|
export default injectIntl(ProgressSettings);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Form, TransitionReplace } from '@openedx/paragon';
|
import { Button, Form, TransitionReplace } from '@openedx/paragon';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
@@ -30,9 +30,8 @@ const TeamTypeNameMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GroupEditor = ({
|
const GroupEditor = ({
|
||||||
group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [isDeleting, setDeleting] = useState(false);
|
const [isDeleting, setDeleting] = useState(false);
|
||||||
const [isOpen, setOpen] = useState(group.id === null);
|
const [isOpen, setOpen] = useState(group.id === null);
|
||||||
const initiateDeletion = () => setDeleting(true);
|
const initiateDeletion = () => setDeleting(true);
|
||||||
@@ -150,6 +149,7 @@ export const groupShape = PropTypes.shape({
|
|||||||
});
|
});
|
||||||
|
|
||||||
GroupEditor.propTypes = {
|
GroupEditor.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
fieldNameCommonBase: PropTypes.string.isRequired,
|
fieldNameCommonBase: PropTypes.string.isRequired,
|
||||||
errors: PropTypes.shape({
|
errors: PropTypes.shape({
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
@@ -170,4 +170,4 @@ GroupEditor.defaultProps = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GroupEditor;
|
export default injectIntl(GroupEditor);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Form } from '@openedx/paragon';
|
import { Button, Form } from '@openedx/paragon';
|
||||||
import { Add } from '@openedx/paragon/icons';
|
import { Add } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ import messages from './messages';
|
|||||||
setupYupExtensions();
|
setupYupExtensions();
|
||||||
|
|
||||||
const TeamSettings = ({
|
const TeamSettings = ({
|
||||||
|
intl,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
||||||
const blankNewGroup = {
|
const blankNewGroup = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -166,7 +166,8 @@ const TeamSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
TeamSettings.propTypes = {
|
TeamSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TeamSettings;
|
export default injectIntl(TeamSettings);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
|
|||||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const WikiSettings = ({ onClose }) => {
|
const WikiSettings = ({ intl, onClose }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
||||||
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ const WikiSettings = ({ onClose }) => {
|
|||||||
label={intl.formatMessage(messages.enablePublicWikiLabel)}
|
label={intl.formatMessage(messages.enablePublicWikiLabel)}
|
||||||
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
|
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlue={handleBlur}
|
||||||
checked={values.enablePublicWiki}
|
checked={values.enablePublicWiki}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -43,7 +42,8 @@ const WikiSettings = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
WikiSettings.propTypes = {
|
WikiSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WikiSettings;
|
export default injectIntl(WikiSettings);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useContext, useEffect } from 'react';
|
import React, { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -9,8 +10,7 @@ import messages from './messages';
|
|||||||
|
|
||||||
import { fetchXpertSettings } from './data/thunks';
|
import { fetchXpertSettings } from './data/thunks';
|
||||||
|
|
||||||
const XpertUnitSummarySettings = () => {
|
const XpertUnitSummarySettings = ({ intl }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -38,4 +38,8 @@ const XpertUnitSummarySettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default XpertUnitSummarySettings;
|
XpertUnitSummarySettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(XpertUnitSummarySettings);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow,
|
ActionRow,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -70,40 +70,38 @@ AppSettingsForm.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SettingsModalBase = ({
|
const SettingsModalBase = ({
|
||||||
title, onClose, variant, isMobile, children, footer,
|
intl, title, onClose, variant, isMobile, children, footer,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
<ModalDialog
|
||||||
return (
|
title={title}
|
||||||
<ModalDialog
|
isOpen
|
||||||
title={title}
|
onClose={onClose}
|
||||||
isOpen
|
size="lg"
|
||||||
onClose={onClose}
|
variant={variant}
|
||||||
size="lg"
|
hasCloseButton={isMobile}
|
||||||
variant={variant}
|
isFullscreenOnMobile
|
||||||
hasCloseButton={isMobile}
|
>
|
||||||
isFullscreenOnMobile
|
<ModalDialog.Header>
|
||||||
>
|
<ModalDialog.Title data-testid="modal-title">
|
||||||
<ModalDialog.Header>
|
{title}
|
||||||
<ModalDialog.Title data-testid="modal-title">
|
</ModalDialog.Title>
|
||||||
{title}
|
</ModalDialog.Header>
|
||||||
</ModalDialog.Title>
|
<ModalDialog.Body>
|
||||||
</ModalDialog.Header>
|
{children}
|
||||||
<ModalDialog.Body>
|
</ModalDialog.Body>
|
||||||
{children}
|
<ModalDialog.Footer className="p-4">
|
||||||
</ModalDialog.Body>
|
<ActionRow>
|
||||||
<ModalDialog.Footer className="p-4">
|
<ModalDialog.CloseButton variant="tertiary">
|
||||||
<ActionRow>
|
{intl.formatMessage(messages.cancel)}
|
||||||
<ModalDialog.CloseButton variant="tertiary">
|
</ModalDialog.CloseButton>
|
||||||
{intl.formatMessage(messages.cancel)}
|
{footer}
|
||||||
</ModalDialog.CloseButton>
|
</ActionRow>
|
||||||
{footer}
|
</ModalDialog.Footer>
|
||||||
</ActionRow>
|
</ModalDialog>
|
||||||
</ModalDialog.Footer>
|
);
|
||||||
</ModalDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SettingsModalBase.propTypes = {
|
SettingsModalBase.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
||||||
@@ -117,11 +115,11 @@ SettingsModalBase.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ResetUnitsButton = ({
|
const ResetUnitsButton = ({
|
||||||
|
intl,
|
||||||
courseId,
|
courseId,
|
||||||
checked,
|
checked,
|
||||||
visible,
|
visible,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const resetStatusRequestStatus = useSelector(getResetStatus);
|
const resetStatusRequestStatus = useSelector(getResetStatus);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -187,6 +185,7 @@ const ResetUnitsButton = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ResetUnitsButton.propTypes = {
|
ResetUnitsButton.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
||||||
visible: PropTypes.bool,
|
visible: PropTypes.bool,
|
||||||
@@ -197,6 +196,7 @@ ResetUnitsButton.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SettingsModal = ({
|
const SettingsModal = ({
|
||||||
|
intl,
|
||||||
appId,
|
appId,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
@@ -213,7 +213,6 @@ const SettingsModal = ({
|
|||||||
allUnitsEnabledText,
|
allUnitsEnabledText,
|
||||||
noUnitsEnabledText,
|
noUnitsEnabledText,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const { courseId } = useContext(PagesAndResourcesContext);
|
const { courseId } = useContext(PagesAndResourcesContext);
|
||||||
const loadingStatus = useSelector(getLoadingStatus);
|
const loadingStatus = useSelector(getLoadingStatus);
|
||||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||||
@@ -373,6 +372,7 @@ const SettingsModal = ({
|
|||||||
>
|
>
|
||||||
{allUnitsEnabledText}
|
{allUnitsEnabledText}
|
||||||
<ResetUnitsButton
|
<ResetUnitsButton
|
||||||
|
intl={intl}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
checked={formikProps.values.checked}
|
checked={formikProps.values.checked}
|
||||||
visible={formikProps.values.checked === 'true'}
|
visible={formikProps.values.checked === 'true'}
|
||||||
@@ -385,6 +385,7 @@ const SettingsModal = ({
|
|||||||
>
|
>
|
||||||
{noUnitsEnabledText}
|
{noUnitsEnabledText}
|
||||||
<ResetUnitsButton
|
<ResetUnitsButton
|
||||||
|
intl={intl}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
checked={formikProps.values.checked}
|
checked={formikProps.values.checked}
|
||||||
visible={formikProps.values.checked === 'false'}
|
visible={formikProps.values.checked === 'false'}
|
||||||
@@ -422,6 +423,7 @@ const SettingsModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
SettingsModal.propTypes = {
|
SettingsModal.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
appId: PropTypes.string.isRequired,
|
appId: PropTypes.string.isRequired,
|
||||||
children: PropTypes.func,
|
children: PropTypes.func,
|
||||||
@@ -448,4 +450,4 @@ SettingsModal.defaultProps = {
|
|||||||
enableReinitialize: false,
|
enableReinitialize: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsModal;
|
export default injectIntl(SettingsModal);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "~@openedx/paragon/styles/scss/core/utilities-only";
|
@import "~@edx/brand/paragon/variables";
|
||||||
|
@import "~@openedx/paragon/scss/core/utilities-only";
|
||||||
|
|
||||||
.summary-radio {
|
.summary-radio {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import {
|
import {
|
||||||
useLocation,
|
useLocation,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||||
import Header from './header';
|
import Header from './header';
|
||||||
import { fetchCourseDetail } from './data/thunks';
|
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||||
import { useModel } from './generic/model-store';
|
import { useModel } from './generic/model-store';
|
||||||
import NotFoundAlert from './generic/NotFoundAlert';
|
import NotFoundAlert from './generic/NotFoundAlert';
|
||||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||||
@@ -21,6 +21,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchCourseDetail(courseId));
|
dispatch(fetchCourseDetail(courseId));
|
||||||
|
dispatch(fetchWaffleFlags(courseId));
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
|
|||||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||||
import { executeThunk } from './utils';
|
import { executeThunk } from './utils';
|
||||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||||
import { fetchCourseDetail } from './data/thunks';
|
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
import { getApiWaffleFlagsUrl } from './data/api';
|
||||||
import { initializeMocks, render } from './testUtils';
|
import { initializeMocks, render } from './testUtils';
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ beforeEach(async () => {
|
|||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||||
.reply(200, {});
|
.reply(200, {});
|
||||||
|
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Editor Pages Load no header', () => {
|
describe('Editor Pages Load no header', () => {
|
||||||
@@ -101,20 +102,4 @@ describe('Course authoring page', () => {
|
|||||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||||
expect(wrapper.queryByTestId('notFoundAlert')).not.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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { PageWrap } from '@edx/frontend-platform/react';
|
import { PageWrap } from '@edx/frontend-platform/react';
|
||||||
import { Textbooks } from './textbooks';
|
import { Textbooks } from 'CourseAuthoring/textbooks';
|
||||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||||
import { PagesAndResources } from './pages-and-resources';
|
import { PagesAndResources } from './pages-and-resources';
|
||||||
import EditorContainer from './editors/EditorContainer';
|
import EditorContainer from './editors/EditorContainer';
|
||||||
@@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details';
|
|||||||
import { GradingSettings } from './grading-settings';
|
import { GradingSettings } from './grading-settings';
|
||||||
import CourseTeam from './course-team/CourseTeam';
|
import CourseTeam from './course-team/CourseTeam';
|
||||||
import { CourseUpdates } from './course-updates';
|
import { CourseUpdates } from './course-updates';
|
||||||
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
|
import { CourseUnit } from './course-unit';
|
||||||
import { Certificates } from './certificates';
|
import { Certificates } from './certificates';
|
||||||
import CourseExportPage from './export-page/CourseExportPage';
|
import CourseExportPage from './export-page/CourseExportPage';
|
||||||
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
||||||
@@ -82,10 +82,6 @@ const CourseAuthoringRoutes = () => {
|
|||||||
path="custom-pages/*"
|
path="custom-pages/*"
|
||||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/subsection/:subsectionId"
|
|
||||||
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
|
|
||||||
/>
|
|
||||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||||
<Route
|
<Route
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||||
|
import { executeThunk } from './utils';
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
import { getApiWaffleFlagsUrl } from './data/api';
|
||||||
|
import { fetchWaffleFlags } from './data/thunks';
|
||||||
import {
|
import {
|
||||||
screen, initializeMocks, render, waitFor,
|
screen, initializeMocks, render, waitFor,
|
||||||
} from './testUtils';
|
} from './testUtils';
|
||||||
@@ -9,6 +11,7 @@ const pagesAndResourcesMockText = 'Pages And Resources';
|
|||||||
const editorContainerMockText = 'Editor Container';
|
const editorContainerMockText = 'Editor Container';
|
||||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
const videoSelectorContainerMockText = 'Video Selector Container';
|
||||||
const customPagesMockText = 'Custom Pages';
|
const customPagesMockText = 'Custom Pages';
|
||||||
|
let store;
|
||||||
const mockComponentFn = jest.fn();
|
const mockComponentFn = jest.fn();
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
@@ -48,10 +51,12 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
|||||||
|
|
||||||
describe('<CourseAuthoringRoutes>', () => {
|
describe('<CourseAuthoringRoutes>', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { axiosMock } = initializeMocks();
|
const { axiosMock, reduxStore } = initializeMocks();
|
||||||
|
store = reduxStore;
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||||
.reply(200, {});
|
.reply(200, {});
|
||||||
|
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
content: {
|
|
||||||
id: 67,
|
|
||||||
userId: 3,
|
|
||||||
created: '2024-01-16T13:09:11.540615Z',
|
|
||||||
purpose: 'clipboard',
|
|
||||||
status: 'ready',
|
|
||||||
blockType: 'chapter',
|
|
||||||
blockTypeDisplay: 'Section',
|
|
||||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
|
||||||
displayName: 'Chapter 1',
|
|
||||||
},
|
|
||||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
|
||||||
sourceContextTitle: 'Demonstration Course',
|
|
||||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export { default as clipboardUnit } from './clipboardUnit';
|
export { default as clipboardUnit } from './clipboardUnit';
|
||||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||||
export { default as clipboardSection } from './clipboardSection';
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -95,4 +95,4 @@ AccessibilityBody.propTypes = {
|
|||||||
email: PropTypes.string.isRequired,
|
email: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AccessibilityBody;
|
export default injectIntl(AccessibilityBody);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
FormattedMessage, FormattedDate, FormattedTime, useIntl,
|
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||||
} from '@edx/frontend-platform/i18n';
|
} from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||||
@@ -15,8 +15,9 @@ import messages from './messages';
|
|||||||
|
|
||||||
const AccessibilityForm = ({
|
const AccessibilityForm = ({
|
||||||
accessibilityEmail,
|
accessibilityEmail,
|
||||||
|
// injected
|
||||||
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
values,
|
values,
|
||||||
@@ -138,6 +139,8 @@ const AccessibilityForm = ({
|
|||||||
|
|
||||||
AccessibilityForm.propTypes = {
|
AccessibilityForm.propTypes = {
|
||||||
accessibilityEmail: PropTypes.string.isRequired,
|
accessibilityEmail: PropTypes.string.isRequired,
|
||||||
|
// injected
|
||||||
|
intl: intlShape.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AccessibilityForm;
|
export default injectIntl(AccessibilityForm);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
|
act,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@@ -73,24 +74,22 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
describe('statusAlert', () => {
|
describe('statusAlert', () => {
|
||||||
let formSections;
|
let formSections;
|
||||||
let submitButton;
|
let submitButton;
|
||||||
let user;
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = userEvent.setup();
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
formSections = screen.getAllByRole('textbox');
|
formSections = screen.getAllByRole('textbox');
|
||||||
|
await act(async () => {
|
||||||
await user.type(formSections[0], 'email@email.com');
|
userEvent.type(formSections[0], 'email@email.com');
|
||||||
await user.type(formSections[1], 'test name');
|
userEvent.type(formSections[1], 'test name');
|
||||||
await user.type(formSections[2], 'feedback message');
|
userEvent.type(formSections[2], 'feedback message');
|
||||||
|
});
|
||||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows correct success message', async () => {
|
it('shows correct success message', async () => {
|
||||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||||
|
await act(async () => {
|
||||||
await user.click(submitButton);
|
userEvent.click(submitButton);
|
||||||
|
});
|
||||||
const { savingStatus } = store.getState().accessibilityPage;
|
const { savingStatus } = store.getState().accessibilityPage;
|
||||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||||
|
|
||||||
@@ -105,9 +104,9 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
|
|
||||||
it('shows correct rate limiting message', async () => {
|
it('shows correct rate limiting message', async () => {
|
||||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||||
|
await act(async () => {
|
||||||
await user.click(submitButton);
|
userEvent.click(submitButton);
|
||||||
|
});
|
||||||
const { savingStatus } = store.getState().accessibilityPage;
|
const { savingStatus } = store.getState().accessibilityPage;
|
||||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||||
|
|
||||||
@@ -124,24 +123,23 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
describe('input validation', () => {
|
describe('input validation', () => {
|
||||||
let formSections;
|
let formSections;
|
||||||
let submitButton;
|
let submitButton;
|
||||||
let user;
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = userEvent.setup();
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
formSections = screen.getAllByRole('textbox');
|
formSections = screen.getAllByRole('textbox');
|
||||||
|
await act(async () => {
|
||||||
await user.type(formSections[0], 'email@email.com');
|
userEvent.type(formSections[0], 'email@email.com');
|
||||||
await user.type(formSections[1], 'test name');
|
userEvent.type(formSections[1], 'test name');
|
||||||
await user.type(formSections[2], 'feedback message');
|
userEvent.type(formSections[2], 'feedback message');
|
||||||
|
});
|
||||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds validation checking on each input field', async () => {
|
it('adds validation checking on each input field', async () => {
|
||||||
await user.clear(formSections[0]);
|
await act(async () => {
|
||||||
await user.clear(formSections[1]);
|
userEvent.clear(formSections[0]);
|
||||||
await user.clear(formSections[2]);
|
userEvent.clear(formSections[1]);
|
||||||
|
userEvent.clear(formSections[2]);
|
||||||
|
});
|
||||||
const emailError = screen.getByTestId('error-feedback-email');
|
const emailError = screen.getByTestId('error-feedback-email');
|
||||||
expect(emailError).toBeVisible();
|
expect(emailError).toBeVisible();
|
||||||
|
|
||||||
@@ -153,10 +151,12 @@ describe('<AccessibilityPolicyForm />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||||
await user.clear(formSections[0]);
|
await act(async () => {
|
||||||
await user.clear(formSections[1]);
|
userEvent.clear(formSections[0]);
|
||||||
await user.clear(formSections[2]);
|
userEvent.clear(formSections[1]);
|
||||||
await user.click(submitButton);
|
userEvent.clear(formSections[2]);
|
||||||
|
userEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(submitButton.closest('button')).toBeDisabled();
|
expect(submitButton.closest('button')).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Container } from '@openedx/paragon';
|
import { Container } from '@openedx/paragon';
|
||||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||||
|
|
||||||
import Header from '../header';
|
import Header from '../header';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import AccessibilityBody from './AccessibilityBody';
|
import AccessibilityBody from './AccessibilityBody';
|
||||||
import AccessibilityForm from './AccessibilityForm';
|
import AccessibilityForm from './AccessibilityForm';
|
||||||
|
|
||||||
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
|
const AccessibilityPage = ({
|
||||||
|
// injected
|
||||||
const AccessibilityPage = () => {
|
intl,
|
||||||
const intl = useIntl();
|
}) => {
|
||||||
|
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
|
||||||
|
const email = 'accessibility@edx.org';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -24,16 +26,17 @@ const AccessibilityPage = () => {
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
<Header isHiddenMainMenu />
|
<Header isHiddenMainMenu />
|
||||||
<Container size="xl" classNamae="px-4">
|
<Container size="xl" classNamae="px-4">
|
||||||
<AccessibilityBody
|
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||||
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
|
<AccessibilityForm accessibilityEmail={email} />
|
||||||
/>
|
|
||||||
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
|
|
||||||
</Container>
|
</Container>
|
||||||
<StudioFooterSlot />
|
<StudioFooterSlot />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AccessibilityPage.propTypes = {};
|
AccessibilityPage.propTypes = {
|
||||||
|
// injected
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
export default AccessibilityPage;
|
export default injectIntl(AccessibilityPage);
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
// @ts-check
|
import {
|
||||||
import { initializeMocks, render, screen } from '../testUtils';
|
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';
|
||||||
import AccessibilityPage from './index';
|
import AccessibilityPage from './index';
|
||||||
|
|
||||||
const renderComponent = () => render(<AccessibilityPage />);
|
const initialState = {
|
||||||
|
accessibilityPage: {
|
||||||
|
status: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let store;
|
||||||
|
|
||||||
|
const renderComponent = () => {
|
||||||
|
render(
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<AccessibilityPage />
|
||||||
|
</AppProvider>
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('<AccessibilityPolicyPage />', () => {
|
describe('<AccessibilityPolicyPage />', () => {
|
||||||
describe('renders', () => {
|
describe('renders', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
initializeMocks();
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: false,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore(initialState);
|
||||||
});
|
});
|
||||||
it('contains the policy body', () => {
|
it('contains the policy body', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
|
|
||||||
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';
|
|
||||||
@@ -10,11 +10,9 @@ function submitAccessibilityForm({ email, name, message }) {
|
|||||||
await postAccessibilityForm({ email, name, message });
|
await postAccessibilityForm({ email, name, message });
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/* istanbul ignore else */
|
|
||||||
if (error.response && error.response.status === 429) {
|
if (error.response && error.response.status === 429) {
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
} else {
|
} else {
|
||||||
/* istanbul ignore next */
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import Placeholder from '../editors/Placeholder';
|
import Placeholder from '../editors/Placeholder';
|
||||||
|
|
||||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||||
@@ -26,8 +26,7 @@ import messages from './messages';
|
|||||||
import ModalError from './modal-error/ModalError';
|
import ModalError from './modal-error/ModalError';
|
||||||
import getPageHeadTitle from '../generic/utils';
|
import getPageHeadTitle from '../generic/utils';
|
||||||
|
|
||||||
const AdvancedSettings = ({ courseId }) => {
|
const AdvancedSettings = ({ intl, courseId }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||||
@@ -279,7 +278,8 @@ const AdvancedSettings = ({ courseId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
AdvancedSettings.propTypes = {
|
AdvancedSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdvancedSettings;
|
export default injectIntl(AdvancedSettings);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import {
|
import React from 'react';
|
||||||
render as baseRender,
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
fireEvent,
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
initializeMocks,
|
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
waitFor,
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
} from '../testUtils';
|
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
|
import initializeStore from '../store';
|
||||||
import { executeThunk } from '../utils';
|
import { executeThunk } from '../utils';
|
||||||
import { advancedSettingsMock } from './__mocks__';
|
import { advancedSettingsMock } from './__mocks__';
|
||||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||||
@@ -25,22 +28,39 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
|||||||
/>
|
/>
|
||||||
)));
|
)));
|
||||||
|
|
||||||
const render = () => baseRender(
|
jest.mock('react-router-dom', () => ({
|
||||||
<AdvancedSettings courseId={courseId} />,
|
...jest.requireActual('react-router-dom'),
|
||||||
{ path: mockPathname },
|
useLocation: () => ({
|
||||||
|
pathname: mockPathname,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('<AdvancedSettings />', () => {
|
describe('<AdvancedSettings />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mocks = initializeMocks();
|
initializeMockApp({
|
||||||
store = mocks.reduxStore;
|
authenticatedUser: {
|
||||||
axiosMock = mocks.axiosMock;
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||||
.reply(200, advancedSettingsMock);
|
.reply(200, advancedSettingsMock);
|
||||||
});
|
});
|
||||||
it('should render without errors', async () => {
|
it('should render without errors', async () => {
|
||||||
const { getByText } = render();
|
const { getByText } = render(<RootWrapper />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||||
@@ -52,7 +72,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should render setting element', async () => {
|
it('should render setting element', async () => {
|
||||||
const { getByText, queryByText } = render();
|
const { getByText, queryByText } = render(<RootWrapper />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||||
@@ -60,7 +80,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should change to onСhange', async () => {
|
it('should change to onСhange', async () => {
|
||||||
const { getByLabelText } = render();
|
const { getByLabelText } = render(<RootWrapper />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const textarea = getByLabelText(/Advanced Module List/i);
|
const textarea = getByLabelText(/Advanced Module List/i);
|
||||||
expect(textarea).toBeInTheDocument();
|
expect(textarea).toBeInTheDocument();
|
||||||
@@ -69,7 +89,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should display a warning alert', async () => {
|
it('should display a warning alert', async () => {
|
||||||
const { getByLabelText, getByText } = render();
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const textarea = getByLabelText(/Advanced Module List/i);
|
const textarea = getByLabelText(/Advanced Module List/i);
|
||||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||||
@@ -80,7 +100,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should display a tooltip on clicking on the icon', async () => {
|
it('should display a tooltip on clicking on the icon', async () => {
|
||||||
const { getByLabelText, getByText } = render();
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const button = getByLabelText(/Show help text/i);
|
const button = getByLabelText(/Show help text/i);
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
@@ -88,7 +108,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should change deprecated button text ', async () => {
|
it('should change deprecated button text ', async () => {
|
||||||
const { getByText } = render();
|
const { getByText } = render(<RootWrapper />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||||
@@ -98,7 +118,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('should reset to default value on click on Cancel button', async () => {
|
it('should reset to default value on click on Cancel button', async () => {
|
||||||
const { getByLabelText, getByText } = render();
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
let textarea;
|
let textarea;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
textarea = getByLabelText(/Advanced Module List/i);
|
textarea = getByLabelText(/Advanced Module List/i);
|
||||||
@@ -109,7 +129,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
expect(textarea.value).toBe('[]');
|
expect(textarea.value).toBe('[]');
|
||||||
});
|
});
|
||||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||||
const { getByLabelText, getByText } = render();
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
let textarea;
|
let textarea;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
textarea = getByLabelText(/Advanced Module List/i);
|
textarea = getByLabelText(/Advanced Module List/i);
|
||||||
@@ -121,7 +141,7 @@ describe('<AdvancedSettings />', () => {
|
|||||||
expect(textarea.value).toBe('[3, 2, 1,');
|
expect(textarea.value).toBe('[3, 2, 1,');
|
||||||
});
|
});
|
||||||
it('should show success alert after save', async () => {
|
it('should show success alert after save', async () => {
|
||||||
const { getByLabelText, getByText } = render();
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
let textarea;
|
let textarea;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
textarea = getByLabelText(/Advanced Module List/i);
|
textarea = getByLabelText(/Advanced Module List/i);
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||||
import {
|
|
||||||
camelCaseObject,
|
|
||||||
getConfig,
|
|
||||||
} from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { camelCase } from 'lodash';
|
|
||||||
import { convertObjectToSnakeCase } from '../../utils';
|
import { convertObjectToSnakeCase } from '../../utils';
|
||||||
|
|
||||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||||
@@ -19,19 +14,7 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
|
|||||||
export async function getCourseAdvancedSettings(courseId) {
|
export async function getCourseAdvancedSettings(courseId) {
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||||
const keepValues = {};
|
return camelCaseObject(data);
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
keepValues[camelCase(key)] = { value: data[key].value };
|
|
||||||
});
|
|
||||||
const formattedData = {};
|
|
||||||
const formattedCamelCaseData = camelCaseObject(data);
|
|
||||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
|
||||||
formattedData[key] = {
|
|
||||||
...formattedCamelCaseData[key],
|
|
||||||
value: keepValues[key]?.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return formattedData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,19 +26,7 @@ export async function getCourseAdvancedSettings(courseId) {
|
|||||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||||
const keepValues = {};
|
return camelCaseObject(data);
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
keepValues[camelCase(key)] = { value: data[key].value };
|
|
||||||
});
|
|
||||||
const formattedData = {};
|
|
||||||
const formattedCamelCaseData = camelCaseObject(data);
|
|
||||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
|
||||||
formattedData[key] = {
|
|
||||||
...formattedCamelCaseData[key],
|
|
||||||
value: keepValues[key]?.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return formattedData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,17 +36,5 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
|
|||||||
*/
|
*/
|
||||||
export async function getProctoringExamErrors(courseId) {
|
export async function getProctoringExamErrors(courseId) {
|
||||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||||
const keepValues = {};
|
return camelCaseObject(data);
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
keepValues[camelCase(key)] = { value: data[key].value };
|
|
||||||
});
|
|
||||||
const formattedData = {};
|
|
||||||
const formattedCamelCaseData = camelCaseObject(data);
|
|
||||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
|
||||||
formattedData[key] = {
|
|
||||||
...formattedCamelCaseData[key],
|
|
||||||
value: keepValues[key]?.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return formattedData;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import {
|
|
||||||
getCourseAdvancedSettings,
|
|
||||||
updateCourseAdvancedSettings,
|
|
||||||
getProctoringExamErrors,
|
|
||||||
} from './api';
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
|
||||||
getAuthenticatedHttpClient: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('courseSettings API', () => {
|
|
||||||
const mockHttpClient = {
|
|
||||||
get: jest.fn(),
|
|
||||||
patch: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCourseAdvancedSettings', () => {
|
|
||||||
it('should fetch and unformat course advanced settings', async () => {
|
|
||||||
const fakeData = {
|
|
||||||
key_snake_case: {
|
|
||||||
display_name: 'To come camelCase',
|
|
||||||
testCamelCase: 'This key must not be formatted',
|
|
||||||
PascalCase: 'To come camelCase',
|
|
||||||
'kebab-case': 'To come camelCase',
|
|
||||||
UPPER_CASE: 'To come camelCase',
|
|
||||||
lowercase: 'This key must not be formatted',
|
|
||||||
UPPERCASE: 'To come lowercase',
|
|
||||||
'Title Case': 'To come camelCase',
|
|
||||||
'dot.case': 'To come camelCase',
|
|
||||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
|
||||||
MixedCase: 'To come camelCase',
|
|
||||||
'Train-Case': 'To come camelCase',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'To come camelCase',
|
|
||||||
},
|
|
||||||
// value is an object with various cases
|
|
||||||
// this contain must not be formatted to camelCase
|
|
||||||
value: {
|
|
||||||
snake_case: 'snake_case',
|
|
||||||
camelCase: 'camelCase',
|
|
||||||
PascalCase: 'PascalCase',
|
|
||||||
'kebab-case': 'kebab-case',
|
|
||||||
UPPER_CASE: 'UPPER_CASE',
|
|
||||||
lowercase: 'lowercase',
|
|
||||||
UPPERCASE: 'UPPERCASE',
|
|
||||||
'Title Case': 'Title Case',
|
|
||||||
'dot.case': 'dot.case',
|
|
||||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
|
||||||
MixedCase: 'MixedCase',
|
|
||||||
'Train-Case': 'Train-Case',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'nestedContent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const expected = {
|
|
||||||
keySnakeCase: {
|
|
||||||
displayName: 'To come camelCase',
|
|
||||||
testCamelCase: 'This key must not be formatted',
|
|
||||||
pascalCase: 'To come camelCase',
|
|
||||||
kebabCase: 'To come camelCase',
|
|
||||||
upperCase: 'To come camelCase',
|
|
||||||
lowercase: 'This key must not be formatted',
|
|
||||||
uppercase: 'To come lowercase',
|
|
||||||
titleCase: 'To come camelCase',
|
|
||||||
dotCase: 'To come camelCase',
|
|
||||||
screamingSnakeCase: 'To come camelCase',
|
|
||||||
mixedCase: 'To come camelCase',
|
|
||||||
trainCase: 'To come camelCase',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'To come camelCase',
|
|
||||||
},
|
|
||||||
value: fakeData.key_snake_case.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockHttpClient.get.mockResolvedValue({ data: fakeData });
|
|
||||||
|
|
||||||
const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024');
|
|
||||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
|
||||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`,
|
|
||||||
);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateCourseAdvancedSettings', () => {
|
|
||||||
it('should update and unformat course advanced settings', async () => {
|
|
||||||
const fakeData = {
|
|
||||||
key_snake_case: {
|
|
||||||
display_name: 'To come camelCase',
|
|
||||||
testCamelCase: 'This key must not be formatted', // because already be camelCase
|
|
||||||
PascalCase: 'To come camelCase',
|
|
||||||
'kebab-case': 'To come camelCase',
|
|
||||||
UPPER_CASE: 'To come camelCase',
|
|
||||||
lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted
|
|
||||||
UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase
|
|
||||||
'Title Case': 'To come camelCase',
|
|
||||||
'dot.case': 'To come camelCase',
|
|
||||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
|
||||||
MixedCase: 'To come camelCase',
|
|
||||||
'Train-Case': 'To come camelCase',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'To come camelCase',
|
|
||||||
},
|
|
||||||
// value is an object with various cases
|
|
||||||
// this contain must not be formatted to camelCase
|
|
||||||
value: {
|
|
||||||
snake_case: 'snake_case',
|
|
||||||
camelCase: 'camelCase',
|
|
||||||
PascalCase: 'PascalCase',
|
|
||||||
'kebab-case': 'kebab-case',
|
|
||||||
UPPER_CASE: 'UPPER_CASE',
|
|
||||||
lowercase: 'lowercase',
|
|
||||||
UPPERCASE: 'UPPERCASE',
|
|
||||||
'Title Case': 'Title Case',
|
|
||||||
'dot.case': 'dot.case',
|
|
||||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
|
||||||
MixedCase: 'MixedCase',
|
|
||||||
'Train-Case': 'Train-Case',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'nestedContent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const expected = {
|
|
||||||
keySnakeCase: {
|
|
||||||
displayName: 'To come camelCase',
|
|
||||||
testCamelCase: 'This key must not be formatted',
|
|
||||||
pascalCase: 'To come camelCase',
|
|
||||||
kebabCase: 'To come camelCase',
|
|
||||||
upperCase: 'To come camelCase',
|
|
||||||
lowercase: 'This key must not be formatted',
|
|
||||||
uppercase: 'To come lowercase',
|
|
||||||
titleCase: 'To come camelCase',
|
|
||||||
dotCase: 'To come camelCase',
|
|
||||||
screamingSnakeCase: 'To come camelCase',
|
|
||||||
mixedCase: 'To come camelCase',
|
|
||||||
trainCase: 'To come camelCase',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'To come camelCase',
|
|
||||||
},
|
|
||||||
value: fakeData.key_snake_case.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockHttpClient.patch.mockResolvedValue({ data: fakeData });
|
|
||||||
|
|
||||||
const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {});
|
|
||||||
expect(mockHttpClient.patch).toHaveBeenCalledWith(
|
|
||||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`,
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getProctoringExamErrors', () => {
|
|
||||||
it('should fetch proctoring errors and return unformat object', async () => {
|
|
||||||
const fakeData = {
|
|
||||||
key_snake_case: {
|
|
||||||
display_name: 'To come camelCase',
|
|
||||||
testCamelCase: 'This key must not be formatted',
|
|
||||||
PascalCase: 'To come camelCase',
|
|
||||||
'kebab-case': 'To come camelCase',
|
|
||||||
UPPER_CASE: 'To come camelCase',
|
|
||||||
lowercase: 'This key must not be formatted',
|
|
||||||
UPPERCASE: 'To come lowercase',
|
|
||||||
'Title Case': 'To come camelCase',
|
|
||||||
'dot.case': 'To come camelCase',
|
|
||||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
|
||||||
MixedCase: 'To come camelCase',
|
|
||||||
'Train-Case': 'To come camelCase',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'To come camelCase',
|
|
||||||
},
|
|
||||||
// value is an object with various cases
|
|
||||||
// this contain must not be formatted to camelCase
|
|
||||||
value: {
|
|
||||||
snake_case: 'snake_case',
|
|
||||||
camelCase: 'camelCase',
|
|
||||||
PascalCase: 'PascalCase',
|
|
||||||
'kebab-case': 'kebab-case',
|
|
||||||
UPPER_CASE: 'UPPER_CASE',
|
|
||||||
lowercase: 'lowercase',
|
|
||||||
UPPERCASE: 'UPPERCASE',
|
|
||||||
'Title Case': 'Title Case',
|
|
||||||
'dot.case': 'dot.case',
|
|
||||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
|
||||||
MixedCase: 'MixedCase',
|
|
||||||
'Train-Case': 'Train-Case',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'nestedContent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const expected = {
|
|
||||||
keySnakeCase: {
|
|
||||||
displayName: 'To come camelCase',
|
|
||||||
testCamelCase: 'This key must not be formatted',
|
|
||||||
pascalCase: 'To come camelCase',
|
|
||||||
kebabCase: 'To come camelCase',
|
|
||||||
upperCase: 'To come camelCase',
|
|
||||||
lowercase: 'This key must not be formatted',
|
|
||||||
uppercase: 'To come lowercase',
|
|
||||||
titleCase: 'To come camelCase',
|
|
||||||
dotCase: 'To come camelCase',
|
|
||||||
screamingSnakeCase: 'To come camelCase',
|
|
||||||
mixedCase: 'To come camelCase',
|
|
||||||
trainCase: 'To come camelCase',
|
|
||||||
nestedOption: {
|
|
||||||
anotherOption: 'To come camelCase',
|
|
||||||
},
|
|
||||||
value: fakeData.key_snake_case.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockHttpClient.get.mockResolvedValue({ data: fakeData });
|
|
||||||
|
|
||||||
const result = await getProctoringExamErrors('course-v1:Test+T101+2024');
|
|
||||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
|
||||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`,
|
|
||||||
);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +1,55 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import ModalErrorListItem from './ModalErrorListItem';
|
import ModalErrorListItem from './ModalErrorListItem';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ModalError = ({
|
const ModalError = ({
|
||||||
isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||||
}) => {
|
}) => (
|
||||||
const intl = useIntl();
|
<AlertModal
|
||||||
return (
|
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||||
<AlertModal
|
isOpen={isError}
|
||||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
variant="danger"
|
||||||
isOpen={isError}
|
footerNode={(
|
||||||
variant="danger"
|
<ActionRow>
|
||||||
footerNode={(
|
<Button
|
||||||
<ActionRow>
|
variant="tertiary"
|
||||||
<Button
|
onClick={() => showErrorModal(!isError)}
|
||||||
variant="tertiary"
|
>
|
||||||
onClick={() => showErrorModal(!isError)}
|
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||||
>
|
</Button>
|
||||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
<Button onClick={handleUndoChanges}>
|
||||||
</Button>
|
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||||
<Button onClick={handleUndoChanges}>
|
</Button>
|
||||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
</ActionRow>
|
||||||
</Button>
|
|
||||||
</ActionRow>
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="course-authoring.advanced-settings.modal.error.description"
|
id="course-authoring.advanced-settings.modal.error.description"
|
||||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
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:"
|
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
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}
|
||||||
/>
|
/>
|
||||||
</p>
|
))}
|
||||||
<hr />
|
</ul>
|
||||||
<ul className="p-0">
|
</AlertModal>
|
||||||
{errorList.map((settingName) => (
|
);
|
||||||
<ModalErrorListItem
|
|
||||||
key={settingName.key}
|
|
||||||
settingName={settingName}
|
|
||||||
settingsData={settingsData}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ModalError.propTypes = {
|
ModalError.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
isError: PropTypes.bool.isRequired,
|
isError: PropTypes.bool.isRequired,
|
||||||
handleUndoChanges: PropTypes.func.isRequired,
|
handleUndoChanges: PropTypes.func.isRequired,
|
||||||
showErrorModal: PropTypes.func.isRequired,
|
showErrorModal: PropTypes.func.isRequired,
|
||||||
@@ -62,4 +60,4 @@ ModalError.propTypes = {
|
|||||||
settingsData: PropTypes.shape({}).isRequired,
|
settingsData: PropTypes.shape({}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ModalError;
|
export default injectIntl(ModalError);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 .625rem;
|
padding: 0 .625rem;
|
||||||
z-index: var(--pgn-elevation-modal-zindex);
|
z-index: $zindex-modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-proctoring-error {
|
.alert-proctoring-error {
|
||||||
@@ -66,13 +66,13 @@
|
|||||||
.setting-sidebar-supplementary {
|
.setting-sidebar-supplementary {
|
||||||
.setting-sidebar-supplementary-about {
|
.setting-sidebar-supplementary-about {
|
||||||
.setting-sidebar-supplementary-about-title {
|
.setting-sidebar-supplementary-about-title {
|
||||||
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
|
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||||
color: var(--pgn-color-headings-base);
|
color: $headings-color;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-sidebar-supplementary-about-descriptions {
|
.setting-sidebar-supplementary-about-descriptions {
|
||||||
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
|
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||||
color: $text-color-base;
|
color: $text-color-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,16 +81,16 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
.setting-sidebar-supplementary-other-link {
|
.setting-sidebar-supplementary-other-link {
|
||||||
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
|
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
color: var(--pgn-color-info-500);
|
color: $info-500;
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-sidebar-supplementary-other-title {
|
.setting-sidebar-supplementary-other-title {
|
||||||
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
|
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||||
color: var(--pgn-color-headings-base);
|
color: $headings-color;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
color: var(--pgn-color-danger-base);
|
color: $danger;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-error-item-title {
|
.modal-error-item-title {
|
||||||
@@ -113,12 +113,12 @@
|
|||||||
|
|
||||||
.modal-popup-content {
|
.modal-popup-content {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
color: var(--pgn-color-white);
|
color: $white;
|
||||||
background-color: var(--pgn-color-black);
|
background-color: $black;
|
||||||
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
|
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pgn__modal-popup__arrow::after {
|
.pgn__modal-popup__arrow::after {
|
||||||
border-top-color: var(--pgn-color-black);
|
border-top-color: $black;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
$text-color-base: var(--pgn-color-gray-700);
|
$text-color-base: $gray-700;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { capitalize } from 'lodash';
|
import { capitalize } from 'lodash';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -25,12 +25,13 @@ const SettingCard = ({
|
|||||||
saveSettingsPrompt,
|
saveSettingsPrompt,
|
||||||
isEditableState,
|
isEditableState,
|
||||||
setIsEditableState,
|
setIsEditableState,
|
||||||
|
// injected
|
||||||
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
const { deprecated, help, displayName } = settingData;
|
const { deprecated, help, displayName } = settingData;
|
||||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||||
const [isOpen, open, close] = useToggle(false);
|
const [isOpen, open, close] = useToggle(false);
|
||||||
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
|
const [target, setTarget] = useState(null);
|
||||||
const [newValue, setNewValue] = useState(initialValue);
|
const [newValue, setNewValue] = useState(initialValue);
|
||||||
|
|
||||||
const handleSettingChange = (e) => {
|
const handleSettingChange = (e) => {
|
||||||
@@ -114,11 +115,12 @@ const SettingCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
SettingCard.propTypes = {
|
SettingCard.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
settingData: PropTypes.shape({
|
settingData: PropTypes.shape({
|
||||||
deprecated: PropTypes.bool,
|
deprecated: PropTypes.bool,
|
||||||
help: PropTypes.string,
|
help: PropTypes.string,
|
||||||
displayName: PropTypes.string,
|
displayName: PropTypes.string,
|
||||||
value: PropTypes.oneOfType([
|
value: PropTypes.PropTypes.oneOfType([
|
||||||
PropTypes.string,
|
PropTypes.string,
|
||||||
PropTypes.bool,
|
PropTypes.bool,
|
||||||
PropTypes.number,
|
PropTypes.number,
|
||||||
@@ -135,4 +137,4 @@ SettingCard.propTypes = {
|
|||||||
setIsEditableState: PropTypes.func.isRequired,
|
setIsEditableState: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingCard;
|
export default injectIntl(SettingCard);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
@@ -21,12 +22,14 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
|||||||
<textarea
|
<textarea
|
||||||
{...props}
|
{...props}
|
||||||
onFocus={() => {}}
|
onFocus={() => {}}
|
||||||
|
onBlur={() => {}}
|
||||||
/>
|
/>
|
||||||
)));
|
)));
|
||||||
|
|
||||||
const RootWrapper = () => (
|
const RootWrapper = () => (
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<SettingCard
|
<SettingCard
|
||||||
|
intl={{}}
|
||||||
isOn
|
isOn
|
||||||
name="settingName"
|
name="settingName"
|
||||||
setEdited={setEdited}
|
setEdited={setEdited}
|
||||||
@@ -55,6 +58,7 @@ describe('<SettingCard />', () => {
|
|||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<SettingCard
|
<SettingCard
|
||||||
|
intl={{}}
|
||||||
isOn
|
isOn
|
||||||
name="settingName"
|
name="settingName"
|
||||||
setEdited={setEdited}
|
setEdited={setEdited}
|
||||||
@@ -75,19 +79,18 @@ describe('<SettingCard />', () => {
|
|||||||
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||||
});
|
});
|
||||||
it('calls setEdited on blur', async () => {
|
it('calls setEdited on blur', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByLabelText } = render(<RootWrapper />);
|
const { getByLabelText } = render(<RootWrapper />);
|
||||||
const inputBox = getByLabelText(/Setting Name/i);
|
const inputBox = getByLabelText(/Setting Name/i);
|
||||||
fireEvent.focus(inputBox);
|
fireEvent.focus(inputBox);
|
||||||
await user.clear(inputBox);
|
userEvent.clear(inputBox);
|
||||||
await user.type(inputBox, '3, 2, 1');
|
userEvent.type(inputBox, '3, 2, 1');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(inputBox).toHaveValue('3, 2, 1');
|
expect(inputBox).toHaveValue('3, 2, 1');
|
||||||
});
|
});
|
||||||
await user.tab(); // blur off of the input.
|
await (async () => {
|
||||||
await waitFor(() => {
|
|
||||||
expect(setEdited).toHaveBeenCalled();
|
expect(setEdited).toHaveBeenCalled();
|
||||||
expect(handleBlur).toHaveBeenCalled();
|
expect(handleBlur).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
fireEvent.focusOut(inputBox);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
// @ts-check
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import {
|
||||||
|
FormattedMessage,
|
||||||
|
injectIntl,
|
||||||
|
intlShape,
|
||||||
|
} from '@edx/frontend-platform/i18n';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||||
<HelpSidebar
|
<HelpSidebar
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||||
showOtherSettings
|
showOtherSettings
|
||||||
>
|
>
|
||||||
<h4 className="help-sidebar-about-title">
|
<h4 className="help-sidebar-about-title">
|
||||||
<FormattedMessage {...messages.about} />
|
{intl.formatMessage(messages.about)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="help-sidebar-about-descriptions">
|
<p className="help-sidebar-about-descriptions">
|
||||||
<FormattedMessage {...messages.aboutDescription1} />
|
{intl.formatMessage(messages.aboutDescription1)}
|
||||||
</p>
|
</p>
|
||||||
<p className="help-sidebar-about-descriptions">
|
<p className="help-sidebar-about-descriptions">
|
||||||
<FormattedMessage {...messages.aboutDescription2} />
|
{intl.formatMessage(messages.aboutDescription2)}
|
||||||
</p>
|
</p>
|
||||||
<p className="help-sidebar-about-descriptions">
|
<p className="help-sidebar-about-descriptions">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@@ -31,9 +34,14 @@ const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
|||||||
</HelpSidebar>
|
</HelpSidebar>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
SettingsSidebar.defaultProps = {
|
||||||
|
proctoredExamSettingsUrl: '',
|
||||||
|
};
|
||||||
|
|
||||||
SettingsSidebar.propTypes = {
|
SettingsSidebar.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
proctoredExamSettingsUrl: PropTypes.string,
|
proctoredExamSettingsUrl: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsSidebar;
|
export default injectIntl(SettingsSidebar);
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
// @ts-check
|
import React from 'react';
|
||||||
import { initializeMocks, render } from '../../testUtils';
|
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';
|
||||||
import SettingsSidebar from './SettingsSidebar';
|
import SettingsSidebar from './SettingsSidebar';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const courseId = 'course-123';
|
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 />', () => {
|
describe('<SettingsSidebar />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initializeMocks();
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
});
|
});
|
||||||
it('renders about and other sidebar titles correctly', () => {
|
it('renders about and other sidebar titles correctly', () => {
|
||||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
const { getByText } = render(<RootWrapper />);
|
||||||
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||||
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('renders about descriptions correctly', () => {
|
it('renders about descriptions correctly', () => {
|
||||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
const { getByText } = render(<RootWrapper />);
|
||||||
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 (‘).');
|
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.aboutDescription1.defaultMessage)).toBeInTheDocument();
|
||||||
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
.form-group-custom {
|
.form-group-custom {
|
||||||
.pgn__form-label {
|
.pgn__form-label {
|
||||||
font: normal var(--pgn-typography-font-weight-bold) .75rem/1.25rem var(--pgn-typography-font-family-base);
|
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
|
||||||
color: var(--pgn-color-gray-500);
|
color: $gray-500;
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pgn__form-control-description,
|
.pgn__form-control-description,
|
||||||
.pgn__form-text {
|
.pgn__form-text {
|
||||||
font: normal var(--pgn-typography-font-weight-normal) .75rem/1.25rem var(--pgn-typography-font-family-base);
|
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
|
||||||
color: var(--pgn-color-gray-500);
|
color: $gray-500;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
.form-group-custom_isInvalid {
|
.form-group-custom_isInvalid {
|
||||||
input {
|
input {
|
||||||
border-color: var(--pgn-color-form-feedback-invalid);
|
border-color: $form-feedback-invalid-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedback-error {
|
.feedback-error {
|
||||||
color: var(--pgn-color-form-feedback-invalid);
|
color: $form-feedback-invalid-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,40 +34,40 @@
|
|||||||
.datepicker-custom-control {
|
.datepicker-custom-control {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: var(--pgn-typography-form-input-font-size-base);
|
font-size: $input-font-size;
|
||||||
font-weight: var(--pgn-typography-form-input-font-weight);
|
font-weight: $input-font-weight;
|
||||||
line-height: var(--pgn-typography-form-input-line-height-base);
|
line-height: $input-line-height;
|
||||||
background: var(--pgn-color-form-input-bg-base);
|
background: $input-bg;
|
||||||
border-color: var(--pgn-color-form-input-border);
|
border-color: $input-border-color;
|
||||||
border-width: var(--pgn-size-form-input-width-border);
|
border-width: $input-border-width;
|
||||||
box-shadow: var(--pgn-elevation-form-input-base);
|
box-shadow: $input-box-shadow;
|
||||||
border-radius: var(--pgn-size-form-input-radius-border-base);
|
border-radius: $input-border-radius;
|
||||||
color: var(--pgn-color-form-input-base);
|
color: $input-color;
|
||||||
padding: var(--pgn-spacing-form-input-padding-y-base) var(--pgn-spacing-form-input-padding-x-base);
|
padding: $input-padding-y $input-padding-x;
|
||||||
height: var(--pgn-size-form-input-height-base);
|
height: $input-height;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
color: var(--pgn-color-form-input-focus-base);
|
color: $input-focus-color;
|
||||||
background-color: var(--pgn-color-form-input-bg-base);
|
background-color: $input-bg;
|
||||||
border-color: var(--pgn-color-form-input-focus-border);
|
border-color: $input-focus-border-color;
|
||||||
box-shadow: var(--pgn-elevation-form-input-focus);
|
box-shadow: $input-focus-box-shadow;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--pgn-color-form-input-placeholder);
|
color: $input-placeholder-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.datepicker-custom-control_readonly {
|
.datepicker-custom-control_readonly {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: var(--pgn-color-form-input-bg-disabled);
|
background: $input-disabled-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datepicker-custom-control_isInvalid {
|
.datepicker-custom-control_isInvalid {
|
||||||
border-color: var(--pgn-color-form-feedback-invalid);
|
border-color: $form-feedback-invalid-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datepicker-custom-control-icon {
|
.datepicker-custom-control-icon {
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
right: 1.188rem;
|
right: 1.188rem;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--pgn-color-black);
|
color: $black;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.text-black {
|
.text-black {
|
||||||
color: var(--pgn-color-black);
|
color: $black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-200px {
|
.h-200px {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
$text-color-base: var(--pgn-color-gray-700);
|
$text-color-base: $gray-700;
|
||||||
$text-color-weak: #3E3E3C;
|
$text-color-weak: #3E3E3C;
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export const CONTENT_LIBRARY_PERMISSIONS = {
|
|
||||||
DELETE_LIBRARY: 'content_libraries.delete_library',
|
|
||||||
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
|
|
||||||
VIEW_LIBRARY: 'content_libraries.view_library',
|
|
||||||
|
|
||||||
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
|
|
||||||
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
|
|
||||||
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
|
|
||||||
|
|
||||||
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
|
|
||||||
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
|
|
||||||
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
|
|
||||||
|
|
||||||
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
|
|
||||||
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import {
|
|
||||||
PermissionValidationAnswer,
|
|
||||||
PermissionValidationQuery,
|
|
||||||
PermissionValidationRequestItem,
|
|
||||||
PermissionValidationResponseItem,
|
|
||||||
} from '@src/authz/types';
|
|
||||||
import { getApiUrl } from './utils';
|
|
||||||
|
|
||||||
export const validateUserPermissions = async (
|
|
||||||
query: PermissionValidationQuery,
|
|
||||||
): Promise<PermissionValidationAnswer> => {
|
|
||||||
// Convert the validations query object into an array for the API request
|
|
||||||
const request: PermissionValidationRequestItem[] = Object.values(query);
|
|
||||||
|
|
||||||
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
|
|
||||||
getApiUrl('/api/authz/v1/permissions/validate/me'),
|
|
||||||
request,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert the API response back into the expected answer format
|
|
||||||
const result: PermissionValidationAnswer = {};
|
|
||||||
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
|
|
||||||
const key = Object.keys(query).find(
|
|
||||||
(k) => query[k].action === item.action
|
|
||||||
&& query[k].scope === item.scope,
|
|
||||||
);
|
|
||||||
if (key) {
|
|
||||||
result[key] = item.allowed;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fill any missing keys with false
|
|
||||||
Object.keys(query).forEach((key) => {
|
|
||||||
if (!(key in result)) {
|
|
||||||
result[key] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { act, ReactNode } from 'react';
|
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { useUserPermissions } from './apiHooks';
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
|
||||||
getAuthenticatedHttpClient: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createWrapper = () => {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
};
|
|
||||||
|
|
||||||
const singlePermission = {
|
|
||||||
canRead: {
|
|
||||||
action: 'example.read',
|
|
||||||
scope: 'lib:example-org:test-lib',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockValidSinglePermission = [
|
|
||||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockInvalidSinglePermission = [
|
|
||||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockEmptyPermissions = [
|
|
||||||
// No permissions returned
|
|
||||||
];
|
|
||||||
|
|
||||||
const multiplePermissions = {
|
|
||||||
canRead: {
|
|
||||||
action: 'example.read',
|
|
||||||
scope: 'lib:example-org:test-lib',
|
|
||||||
},
|
|
||||||
canWrite: {
|
|
||||||
action: 'example.write',
|
|
||||||
scope: 'lib:example-org:test-lib',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockValidMultiplePermissions = [
|
|
||||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
|
||||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockInvalidMultiplePermissions = [
|
|
||||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
|
||||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('useUserPermissions', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns allowed true when permission is valid', async () => {
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue({
|
|
||||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current).toBeDefined());
|
|
||||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
||||||
|
|
||||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
|
||||||
expect(result.current.data!.canRead).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns allowed false when permission is invalid', async () => {
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue({
|
|
||||||
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current).toBeDefined());
|
|
||||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
||||||
|
|
||||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
|
||||||
expect(result.current.data!.canRead).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns allowed true when multiple permissions are valid', async () => {
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue({
|
|
||||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current).toBeDefined());
|
|
||||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
||||||
|
|
||||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
|
||||||
expect(result.current.data!.canRead).toBe(true);
|
|
||||||
expect(result.current.data!.canWrite).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns allowed false when multiple permissions are invalid', async () => {
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue({
|
|
||||||
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current).toBeDefined());
|
|
||||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
||||||
|
|
||||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
|
||||||
expect(result.current.data!.canRead).toBe(false);
|
|
||||||
expect(result.current.data!.canWrite).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns allowed false when the permission is not included in the server response', async () => {
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue({
|
|
||||||
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(result.current).toBeDefined());
|
|
||||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
||||||
|
|
||||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
|
||||||
expect(result.current.data!.canRead).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles error when the API call fails', async () => {
|
|
||||||
const mockError = new Error('API Error');
|
|
||||||
|
|
||||||
getAuthenticatedHttpClient.mockReturnValue({
|
|
||||||
post: jest.fn().mockRejectedValue(new Error('API Error')),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
act(() => {
|
|
||||||
renderHook(() => useUserPermissions(singlePermission), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toEqual(mockError); // Check for the expected error
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
|
|
||||||
import { validateUserPermissions } from './api';
|
|
||||||
|
|
||||||
const adminConsoleQueryKeys = {
|
|
||||||
all: ['authz'],
|
|
||||||
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Query hook to validate if the current user has permissions over a certain object in the instance.
|
|
||||||
* It helps to:
|
|
||||||
* - Determine whether the current user can access certain object.
|
|
||||||
* - Provide role-based rendering logic for UI components.
|
|
||||||
*
|
|
||||||
* @param permissions - A key/value map of objects and actions to validate.
|
|
||||||
* The key is an arbitrary string to identify the permission check,
|
|
||||||
* and the value is an object containing the action and optional scope.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { isLoading, data } = useUserPermissions({
|
|
||||||
* canRead: {
|
|
||||||
* action: "content_libraries.view_library",
|
|
||||||
* scope: "lib:OpenedX:CSPROB"
|
|
||||||
* }
|
|
||||||
* });
|
|
||||||
* if (data.canRead) { ... }
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const useUserPermissions = (
|
|
||||||
permissions: PermissionValidationQuery,
|
|
||||||
) => useQuery<PermissionValidationAnswer, Error>({
|
|
||||||
queryKey: adminConsoleQueryKeys.permissions(permissions),
|
|
||||||
queryFn: () => validateUserPermissions(permissions),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
|
|
||||||
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export interface PermissionValidationRequestItem {
|
|
||||||
action: string;
|
|
||||||
scope?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
|
|
||||||
allowed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionValidationQuery {
|
|
||||||
[permissionKey: string]: PermissionValidationRequestItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionValidationAnswer {
|
|
||||||
[permissionKey: string]: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
// @ts-check
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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 { RequestStatus } from '../data/constants';
|
||||||
import { executeThunk } from '../utils';
|
import { executeThunk } from '../utils';
|
||||||
|
import initializeStore from '../store';
|
||||||
import { getCertificatesApiUrl } from './data/api';
|
import { getCertificatesApiUrl } from './data/api';
|
||||||
import { fetchCertificates } from './data/thunks';
|
import { fetchCertificates } from './data/thunks';
|
||||||
import { certificatesDataMock } from './__mocks__';
|
import { certificatesDataMock } from './__mocks__';
|
||||||
@@ -14,13 +19,26 @@ let axiosMock;
|
|||||||
let store;
|
let store;
|
||||||
const courseId = 'course-123';
|
const courseId = 'course-123';
|
||||||
|
|
||||||
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
|
const renderComponent = (props) => render(
|
||||||
|
<AppProvider store={store} messages={{}}>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<Certificates courseId={courseId} {...props} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
describe('Certificates', () => {
|
describe('Certificates', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mocks = initializeMocks();
|
initializeMockApp({
|
||||||
store = mocks.reduxStore;
|
authenticatedUser: {
|
||||||
axiosMock = mocks.axiosMock;
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders WithoutModes when there are certificates but no certificate modes', async () => {
|
it('renders WithoutModes when there are certificates but no certificate modes', async () => {
|
||||||
@@ -111,13 +129,11 @@ describe('Certificates', () => {
|
|||||||
.reply(200, noCertificatesMock);
|
.reply(200, noCertificatesMock);
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
const { queryByTestId, getByTestId, getByRole } = renderComponent();
|
const { queryByTestId, getByTestId, getByRole } = renderComponent();
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(() => {
|
||||||
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
|
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
|
||||||
await user.click(addCertificateButton);
|
userEvent.click(addCertificateButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('certificates-create-form')).toBeInTheDocument();
|
expect(getByTestId('certificates-create-form')).toBeInTheDocument();
|
||||||
@@ -133,13 +149,11 @@ describe('Certificates', () => {
|
|||||||
.reply(200, certificatesDataMock);
|
.reply(200, certificatesDataMock);
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
|
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(() => {
|
||||||
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
|
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
|
||||||
await user.click(editCertificateButton);
|
userEvent.click(editCertificateButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('certificates-edit-form')).toBeInTheDocument();
|
expect(getByTestId('certificates-edit-form')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import { render, waitFor, within } from '@testing-library/react';
|
||||||
render, waitFor, within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
@@ -87,19 +85,17 @@ describe('CertificateCreateForm', () => {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
|
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
|
||||||
|
|
||||||
await user.type(
|
userEvent.type(
|
||||||
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
|
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
|
||||||
courseTitleOverrideValue,
|
courseTitleOverrideValue,
|
||||||
);
|
);
|
||||||
await user.type(
|
userEvent.type(
|
||||||
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
|
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
|
||||||
signatoryNameValue,
|
signatoryNameValue,
|
||||||
);
|
);
|
||||||
await user.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
|
||||||
|
|
||||||
axiosMock.onPost(
|
axiosMock.onPost(
|
||||||
getCertificateApiUrl(courseId),
|
getCertificateApiUrl(courseId),
|
||||||
@@ -113,9 +109,8 @@ describe('CertificateCreateForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cancel certificates creation', async () => {
|
it('cancel certificates creation', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByRole } = renderComponent();
|
const { getByRole } = renderComponent();
|
||||||
await user.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
|
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
|
||||||
@@ -132,14 +127,13 @@ describe('CertificateCreateForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('add and delete signatory', async () => {
|
it('add and delete signatory', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const {
|
const {
|
||||||
getAllByRole, queryAllByRole, getByText, getByRole,
|
getAllByRole, queryAllByRole, getByText, getByRole,
|
||||||
} = renderComponent();
|
} = renderComponent();
|
||||||
|
|
||||||
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
|
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
|
||||||
|
|
||||||
await user.click(addSignatoryBtn);
|
userEvent.click(addSignatoryBtn);
|
||||||
|
|
||||||
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||||
|
|
||||||
@@ -147,13 +141,13 @@ describe('CertificateCreateForm', () => {
|
|||||||
expect(deleteIcons.length).toBe(2);
|
expect(deleteIcons.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(deleteIcons[0]);
|
userEvent.click(deleteIcons[0]);
|
||||||
|
|
||||||
const confirModal = getByRole('dialog');
|
const confirModal = getByRole('dialog');
|
||||||
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
||||||
|
|
||||||
await user.click(deleteIcons[0]);
|
userEvent.click(deleteIcons[0]);
|
||||||
await user.click(deleteModalButton);
|
userEvent.click(deleteModalButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);
|
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Provider, useDispatch } from 'react-redux';
|
import { Provider, useDispatch } from 'react-redux';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
@@ -86,24 +86,24 @@ describe('CertificateDetails', () => {
|
|||||||
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
|
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens confirm modal on delete button click', async () => {
|
it('opens confirm modal on delete button click', () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByRole, getByText } = renderComponent(defaultProps);
|
const { getByRole, getByText } = renderComponent(defaultProps);
|
||||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||||
await user.click(deleteButton);
|
userEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches delete action on confirm modal action', async () => {
|
it('dispatches delete action on confirm modal action', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = { ...defaultProps, courseId, certificateId };
|
const props = { ...defaultProps, courseId, certificateId };
|
||||||
const { getByRole } = renderComponent(props);
|
const { getByRole } = renderComponent(props);
|
||||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||||
await user.click(deleteButton);
|
userEvent.click(deleteButton);
|
||||||
|
|
||||||
const confirmActionButton = await screen.findByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
await waitFor(() => {
|
||||||
await user.click(confirmActionButton);
|
const confirmActionButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||||
|
userEvent.click(confirmActionButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
|
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,14 +58,13 @@ describe('CertificateDetails', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles input change in create mode', async () => {
|
it('handles input change in create mode', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByPlaceholderText } = renderComponent(defaultProps);
|
const { getByPlaceholderText } = renderComponent(defaultProps);
|
||||||
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
|
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
|
||||||
const newInputValue = 'New Title';
|
const newInputValue = 'New Title';
|
||||||
|
|
||||||
await user.type(input, newInputValue);
|
userEvent.type(input, newInputValue);
|
||||||
|
|
||||||
await waitFor(() => {
|
waitFor(() => {
|
||||||
expect(input.value).toBe(newInputValue);
|
expect(input.value).toBe(newInputValue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import {
|
import { render, waitFor, within } from '@testing-library/react';
|
||||||
render, waitFor, within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
@@ -70,15 +68,15 @@ describe('CertificateEditForm Component', () => {
|
|||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
|
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
|
||||||
|
|
||||||
await user.type(
|
userEvent.type(
|
||||||
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
|
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
|
||||||
courseTitleOverrideValue,
|
courseTitleOverrideValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||||
|
|
||||||
axiosMock.onPost(
|
axiosMock.onPost(
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||||
@@ -93,17 +91,16 @@ describe('CertificateEditForm Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deletes a certificate and updates the store', async () => {
|
it('deletes a certificate and updates the store', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
axiosMock.onDelete(
|
axiosMock.onDelete(
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||||
).reply(200);
|
).reply(200);
|
||||||
|
|
||||||
const { getByRole } = renderComponent();
|
const { getByRole } = renderComponent();
|
||||||
|
|
||||||
await user.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||||
|
|
||||||
const confirmDeleteModal = getByRole('dialog');
|
const confirmDeleteModal = getByRole('dialog');
|
||||||
await user.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||||
|
|
||||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
||||||
|
|
||||||
@@ -113,17 +110,16 @@ describe('CertificateEditForm Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates loading status if delete fails', async () => {
|
it('updates loading status if delete fails', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
axiosMock.onDelete(
|
axiosMock.onDelete(
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||||
).reply(404);
|
).reply(404);
|
||||||
|
|
||||||
const { getByRole } = renderComponent();
|
const { getByRole } = renderComponent();
|
||||||
|
|
||||||
await user.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||||
|
|
||||||
const confirmDeleteModal = getByRole('dialog');
|
const confirmDeleteModal = getByRole('dialog');
|
||||||
await user.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
||||||
|
|
||||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
||||||
|
|
||||||
@@ -133,12 +129,11 @@ describe('CertificateEditForm Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('cancel edit form', async () => {
|
it('cancel edit form', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByRole } = renderComponent();
|
const { getByRole } = renderComponent();
|
||||||
|
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
||||||
|
|
||||||
await user.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||||
|
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
|
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,26 +88,22 @@ describe('CertificateSignatories', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds a new signatory when add button is clicked', async () => {
|
it('adds a new signatory when add button is clicked', () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
|
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
|
||||||
|
|
||||||
await user.click(getByText(messages.addSignatoryButton.defaultMessage));
|
userEvent.click(getByText(messages.addSignatoryButton.defaultMessage));
|
||||||
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('calls remove for the correct signatory when delete icon is clicked', async () => {
|
it('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getAllByRole } = renderComponent(defaultProps);
|
const { getAllByRole } = renderComponent(defaultProps);
|
||||||
|
|
||||||
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||||
expect(deleteIcons.length).toBe(signatoriesMock.length);
|
expect(deleteIcons.length).toBe(signatoriesMock.length);
|
||||||
|
|
||||||
await user.click(deleteIcons[0]);
|
userEvent.click(deleteIcons[0]);
|
||||||
|
|
||||||
// FIXME: this isn't called because the whole 'useEditSignatory' hook
|
waitFor(() => {
|
||||||
// which calls it is mocked out.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ describe('Signatory Component', () => {
|
|||||||
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls handleEdit when the edit button is clicked', async () => {
|
it('calls handleEdit when the edit button is clicked', () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByRole } = renderSignatory(defaultProps);
|
const { getByRole } = renderSignatory(defaultProps);
|
||||||
|
|
||||||
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
|
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
|
||||||
await user.click(editButton);
|
userEvent.click(editButton);
|
||||||
|
|
||||||
expect(mockHandleEdit).toHaveBeenCalled();
|
expect(mockHandleEdit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
@@ -30,7 +30,6 @@ const initialState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
index: 0,
|
|
||||||
...signatoriesMock[0],
|
...signatoriesMock[0],
|
||||||
showDeleteButton: true,
|
showDeleteButton: true,
|
||||||
isEdit: true,
|
isEdit: true,
|
||||||
@@ -61,59 +60,50 @@ describe('Signatory Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles input change', async () => {
|
it('handles input change', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
renderSignatory({ ...defaultProps, handleChange });
|
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
|
||||||
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||||
const newInputValue = 'Jane Doe';
|
const newInputValue = 'Jane Doe';
|
||||||
|
|
||||||
expect(handleChange).not.toHaveBeenCalled();
|
userEvent.type(input, newInputValue, { name: 'signatories[0].name' });
|
||||||
expect(input.value).not.toBe(newInputValue);
|
|
||||||
await user.type(input, newInputValue);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
waitFor(() => {
|
||||||
// This is not a great test; handleChange() gets called for each key press:
|
expect(handleChange).toHaveBeenCalledWith(expect.anything());
|
||||||
expect(handleChange).toHaveBeenCalledTimes(newInputValue.length);
|
expect(input.value).toBe(newInputValue);
|
||||||
// And the input value never actually changes because it's a controlled component
|
|
||||||
// and we pass the name in as a prop, which hasn't changed.
|
|
||||||
// expect(input.value).toBe(newInputValue);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens image upload modal on button click', async () => {
|
it('opens image upload modal on button click', () => {
|
||||||
const user = userEvent.setup();
|
const { getByRole, queryByRole } = renderSignatory(defaultProps);
|
||||||
const { getByRole, queryByTestId } = renderSignatory(defaultProps);
|
|
||||||
const replaceButton = getByRole(
|
const replaceButton = getByRole(
|
||||||
'button',
|
'button',
|
||||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument();
|
expect(queryByRole('presentation')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(replaceButton);
|
userEvent.click(replaceButton);
|
||||||
|
|
||||||
expect(queryByTestId('dropzone-container')).toBeInTheDocument();
|
expect(getByRole('presentation')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows confirm modal on delete icon click', async () => {
|
it('shows confirm modal on delete icon click', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByLabelText, getByText } = renderSignatory(defaultProps);
|
const { getByLabelText, getByText } = renderSignatory(defaultProps);
|
||||||
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
|
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
|
||||||
|
|
||||||
await user.click(deleteIcon);
|
userEvent.click(deleteIcon);
|
||||||
|
|
||||||
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
|
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels deletion of a signatory', async () => {
|
it('cancels deletion of a signatory', () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByRole } = renderSignatory(defaultProps);
|
const { getByRole } = renderSignatory(defaultProps);
|
||||||
|
|
||||||
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
||||||
await user.click(deleteIcon);
|
userEvent.click(deleteIcon);
|
||||||
|
|
||||||
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
|
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
|
||||||
await user.click(cancelButton);
|
userEvent.click(cancelButton);
|
||||||
|
|
||||||
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
|
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import {
|
import { render, waitFor, within } from '@testing-library/react';
|
||||||
render, waitFor, within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
@@ -64,7 +62,6 @@ describe('CertificatesList Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('update certificate', async () => {
|
it('update certificate', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const {
|
const {
|
||||||
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
|
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
|
||||||
} = renderComponent();
|
} = renderComponent();
|
||||||
@@ -83,13 +80,13 @@ describe('CertificatesList Component', () => {
|
|||||||
|
|
||||||
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
||||||
|
|
||||||
await user.click(editButtons[1]);
|
userEvent.click(editButtons[1]);
|
||||||
|
|
||||||
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
|
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
|
||||||
await user.clear(nameInput);
|
userEvent.clear(nameInput);
|
||||||
await user.type(nameInput, signatoryNameValue);
|
userEvent.type(nameInput, signatoryNameValue);
|
||||||
|
|
||||||
await user.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
||||||
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
|
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
|
||||||
@@ -103,7 +100,6 @@ describe('CertificatesList Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('toggle edit signatory', async () => {
|
it('toggle edit signatory', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const {
|
const {
|
||||||
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
|
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
|
||||||
} = renderComponent();
|
} = renderComponent();
|
||||||
@@ -111,13 +107,13 @@ describe('CertificatesList Component', () => {
|
|||||||
|
|
||||||
expect(editButtons.length).toBe(3);
|
expect(editButtons.length).toBe(3);
|
||||||
|
|
||||||
await user.click(editButtons[1]);
|
userEvent.click(editButtons[1]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
userEvent.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
||||||
@@ -125,11 +121,10 @@ describe('CertificatesList Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('toggle certificate edit all', async () => {
|
it('toggle certificate edit all', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByTestId } = renderComponent();
|
const { getByTestId } = renderComponent();
|
||||||
const detailsSection = getByTestId('certificate-details');
|
const detailsSection = getByTestId('certificate-details');
|
||||||
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
|
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
|
||||||
await user.click(editButton);
|
userEvent.click(editButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function createCertificate(courseId, certificatesData) {
|
|||||||
getCertificateApiUrl(courseId),
|
getCertificateApiUrl(courseId),
|
||||||
prepareCertificatePayload(certificatesData),
|
prepareCertificatePayload(certificatesData),
|
||||||
);
|
);
|
||||||
/* istanbul ignore next */
|
|
||||||
return camelCaseObject(data);
|
return camelCaseObject(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ export async function updateCertificate(courseId, certificateData) {
|
|||||||
getUpdateCertificateApiUrl(courseId, certificateData.id),
|
getUpdateCertificateApiUrl(courseId, certificateData.id),
|
||||||
prepareCertificatePayload(certificateData),
|
prepareCertificatePayload(certificateData),
|
||||||
);
|
);
|
||||||
/* istanbul ignore next */
|
|
||||||
return camelCaseObject(data);
|
return camelCaseObject(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ const slice = createSlice({
|
|||||||
fetchCertificatesSuccess: (state, { payload }) => {
|
fetchCertificatesSuccess: (state, { payload }) => {
|
||||||
Object.assign(state.certificatesData, payload);
|
Object.assign(state.certificatesData, payload);
|
||||||
},
|
},
|
||||||
createCertificateSuccess: /* istanbul ignore next */ (state, action) => {
|
createCertificateSuccess: (state, action) => {
|
||||||
state.certificatesData.certificates.push(action.payload);
|
state.certificatesData.certificates.push(action.payload);
|
||||||
},
|
},
|
||||||
updateCertificateSuccess: /* istanbul ignore next */ (state, action) => {
|
updateCertificateSuccess: (state, action) => {
|
||||||
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
|
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
state.certificatesData.certificates[index] = action.payload;
|
state.certificatesData.certificates[index] = action.payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* istanbul ignore file */
|
|
||||||
import { RequestStatus } from '../../data/constants';
|
import { RequestStatus } from '../../data/constants';
|
||||||
import {
|
import {
|
||||||
hideProcessingNotification,
|
hideProcessingNotification,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
// @ts-check
|
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';
|
||||||
import CertificatesSidebar from './CertificatesSidebar';
|
import CertificatesSidebar from './CertificatesSidebar';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { initializeMocks, render, waitFor } from '../../../testUtils';
|
|
||||||
|
|
||||||
const courseId = 'course-123';
|
const courseId = 'course-123';
|
||||||
|
let store;
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||||
@@ -12,11 +17,25 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = (props) => render(<CertificatesSidebar courseId={courseId} {...props} />);
|
const renderComponent = (props) => render(
|
||||||
|
<AppProvider store={store} messages={{}}>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<CertificatesSidebar courseId={courseId} {...props} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
describe('CertificatesSidebar', () => {
|
describe('CertificatesSidebar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initializeMocks();
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
|
|||||||
@@ -53,17 +53,16 @@ describe('HeaderButtons Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates preview URL param based on selected dropdown item', async () => {
|
it('updates preview URL param based on selected dropdown item', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const { getByRole } = renderComponent();
|
const { getByRole } = renderComponent();
|
||||||
const previewLink = getByRole('link', { name: messages.headingActionsPreview.defaultMessage });
|
const previewLink = getByRole('link', { name: messages.headingActionsPreview.defaultMessage });
|
||||||
|
|
||||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
|
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
|
||||||
|
|
||||||
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
||||||
await user.click(dropdownButton);
|
userEvent.click(dropdownButton);
|
||||||
|
|
||||||
const verifiedMode = getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||||
await user.click(verifiedMode);
|
userEvent.click(verifiedMode);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
|
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
|
||||||
@@ -71,7 +70,6 @@ describe('HeaderButtons Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('activates certificate when button is clicked', async () => {
|
it('activates certificate when button is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const newCertificateData = {
|
const newCertificateData = {
|
||||||
...certificatesDataMock,
|
...certificatesDataMock,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -80,7 +78,7 @@ describe('HeaderButtons Component', () => {
|
|||||||
const { getByRole, queryByRole } = renderComponent();
|
const { getByRole, queryByRole } = renderComponent();
|
||||||
|
|
||||||
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
|
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
|
||||||
await user.click(activationButton);
|
userEvent.click(activationButton);
|
||||||
|
|
||||||
axiosMock.onPost(
|
axiosMock.onPost(
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||||
@@ -99,7 +97,6 @@ describe('HeaderButtons Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deactivates certificate when button is clicked', async () => {
|
it('deactivates certificate when button is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
axiosMock
|
axiosMock
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
.onGet(getCertificatesApiUrl(courseId))
|
||||||
.reply(200, { ...certificatesDataMock, isActive: true });
|
.reply(200, { ...certificatesDataMock, isActive: true });
|
||||||
@@ -113,7 +110,7 @@ describe('HeaderButtons Component', () => {
|
|||||||
const { getByRole, queryByRole } = renderComponent();
|
const { getByRole, queryByRole } = renderComponent();
|
||||||
|
|
||||||
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
|
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
|
||||||
await user.click(deactivateButton);
|
userEvent.click(deactivateButton);
|
||||||
|
|
||||||
axiosMock.onPost(
|
axiosMock.onPost(
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.certificates {
|
.certificates {
|
||||||
.section-title {
|
.section-title {
|
||||||
color: var(--pgn-color-black);
|
color: $black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-header-actions {
|
.sub-header-actions {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
.certificate-details {
|
.certificate-details {
|
||||||
.certificate-details__info {
|
.certificate-details__info {
|
||||||
color: var(--pgn-color-black);
|
color: $black;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
.certificate-details__info-paragraph-course-number {
|
.certificate-details__info-paragraph-course-number {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: var(--pgn-color-gray-700);
|
color: $gray-700;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (--pgn-size-breakpoint-max-width-xl) {
|
@media (max-width: map-get($grid-breakpoints, "xl")) {
|
||||||
.signatory {
|
.signatory {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const STATEFUL_BUTTON_STATES = {
|
|||||||
default: 'default',
|
default: 'default',
|
||||||
pending: 'pending',
|
pending: 'pending',
|
||||||
error: 'error',
|
error: 'error',
|
||||||
disable: 'disable',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const USER_ROLES = {
|
export const USER_ROLES = {
|
||||||
@@ -44,7 +43,7 @@ export const COURSE_CREATOR_STATES = {
|
|||||||
granted: 'granted',
|
granted: 'granted',
|
||||||
denied: 'denied',
|
denied: 'denied',
|
||||||
disallowedForThisSite: 'disallowed_for_this_site',
|
disallowedForThisSite: 'disallowed_for_this_site',
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const DECODED_ROUTES = {
|
export const DECODED_ROUTES = {
|
||||||
COURSE_UNIT: [
|
COURSE_UNIT: [
|
||||||
@@ -62,8 +61,6 @@ export const COURSE_BLOCK_NAMES = ({
|
|||||||
libraryContent: { id: 'library_content', name: 'Library content' },
|
libraryContent: { id: 'library_content', name: 'Library content' },
|
||||||
splitTest: { id: 'split_test', name: 'Split Test' },
|
splitTest: { id: 'split_test', name: 'Split Test' },
|
||||||
component: { id: 'component', name: 'Component' },
|
component: { id: 'component', name: 'Component' },
|
||||||
itembank: { id: 'itembank', name: 'Problem Bank' },
|
|
||||||
legacyLibraryContent: { id: 'library_content', name: 'Randomized Content Block' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
|
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
|
||||||
@@ -108,11 +105,4 @@ export const iframeMessageTypes = {
|
|||||||
resize: 'plugin.resize',
|
resize: 'plugin.resize',
|
||||||
videoFullScreen: 'plugin.videoFullScreen',
|
videoFullScreen: 'plugin.videoFullScreen',
|
||||||
xblockEvent: 'xblock-event',
|
xblockEvent: 'xblock-event',
|
||||||
xblockScroll: 'xblock-scroll',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BROKEN = 'broken';
|
|
||||||
|
|
||||||
export const LOCKED = 'locked';
|
|
||||||
|
|
||||||
export const MANUAL = 'manual';
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Stack } from '@openedx/paragon';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
|
||||||
side: 'Before' | 'After';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChildrenPreview = ({ title, children, side }: Props) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const sideTitle = side === 'Before'
|
|
||||||
? intl.formatMessage(messages.diffBeforeTitle)
|
|
||||||
: intl.formatMessage(messages.diffAfterTitle);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction="vertical">
|
|
||||||
<span className="text-center">{sideTitle}</span>
|
|
||||||
<span className="mt-2 mb-3 text-md text-gray-800">{title}</span>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChildrenPreview;
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import MockAdapter from 'axios-mock-adapter/types';
|
|
||||||
import { getLibraryContainerApiUrl } from '@src/library-authoring/data/api';
|
|
||||||
import { mockGetContainerChildren, mockGetContainerMetadata } from '@src/library-authoring/data/api.mocks';
|
|
||||||
import { initializeMocks, render, screen } from '@src/testUtils';
|
|
||||||
import { CompareContainersWidget } from './CompareContainersWidget';
|
|
||||||
import { mockGetCourseContainerChildren } from './data/api.mock';
|
|
||||||
|
|
||||||
mockGetCourseContainerChildren.applyMock();
|
|
||||||
mockGetContainerChildren.applyMock();
|
|
||||||
let axiosMock: MockAdapter;
|
|
||||||
|
|
||||||
describe('CompareContainersWidget', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
({ axiosMock } = initializeMocks());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders the component with a title', async () => {
|
|
||||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
|
|
||||||
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
|
||||||
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
|
|
||||||
/>);
|
|
||||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
|
||||||
expect((await screen.findAllByText('subsection block 0')).length).toEqual(1);
|
|
||||||
expect((await screen.findAllByText('subsection block 00')).length).toEqual(1);
|
|
||||||
expect((await screen.findAllByText('This subsection will be modified')).length).toEqual(3);
|
|
||||||
expect((await screen.findAllByText('This subsection was modified')).length).toEqual(3);
|
|
||||||
expect((await screen.findAllByText('subsection block 1')).length).toEqual(1);
|
|
||||||
expect((await screen.findAllByText('subsection block 2')).length).toEqual(1);
|
|
||||||
expect((await screen.findAllByText('subsection block 11')).length).toEqual(1);
|
|
||||||
expect((await screen.findAllByText('subsection block 22')).length).toEqual(1);
|
|
||||||
expect(screen.queryByText(
|
|
||||||
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
|
|
||||||
)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders loading spinner when data is pending', async () => {
|
|
||||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionIdLoading);
|
|
||||||
axiosMock.onGet(url).reply(() => new Promise(() => {}));
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionIdLoading}
|
|
||||||
downstreamBlockId={mockGetCourseContainerChildren.sectionIdLoading}
|
|
||||||
/>);
|
|
||||||
const spinner = await screen.findAllByRole('status');
|
|
||||||
expect(spinner.length).toEqual(4);
|
|
||||||
expect(spinner[0].textContent).toEqual('Loading...');
|
|
||||||
expect(spinner[1].textContent).toEqual('Loading...');
|
|
||||||
expect(spinner[2].textContent).toEqual('Loading...');
|
|
||||||
expect(spinner[3].textContent).toEqual('Loading...');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('calls onRowClick when a row is clicked and updates diff view', async () => {
|
|
||||||
// mocks title
|
|
||||||
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
|
|
||||||
axiosMock.onGet(
|
|
||||||
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
|
|
||||||
).reply(200, { publishedDisplayName: 'subsection block 0' });
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
|
||||||
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
|
|
||||||
/>);
|
|
||||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
|
||||||
// left i.e. before side block
|
|
||||||
let block = await screen.findByText('subsection block 00');
|
|
||||||
await user.click(block);
|
|
||||||
// Breadcrumbs - shows old and new name
|
|
||||||
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
|
|
||||||
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Back breadcrumb
|
|
||||||
const backbtns = await screen.findAllByRole('button', { name: 'Back' });
|
|
||||||
expect(backbtns.length).toEqual(2);
|
|
||||||
|
|
||||||
// Go back
|
|
||||||
await user.click(backbtns[0]);
|
|
||||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
|
||||||
// right i.e. after side block
|
|
||||||
block = await screen.findByText('subsection block 0');
|
|
||||||
|
|
||||||
// After side click also works
|
|
||||||
await user.click(block);
|
|
||||||
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
|
|
||||||
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show removed container diff state', async () => {
|
|
||||||
// mocks title
|
|
||||||
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
|
|
||||||
axiosMock.onGet(
|
|
||||||
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
|
|
||||||
).reply(200, { publishedDisplayName: 'subsection block 0' });
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
|
||||||
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
|
|
||||||
/>);
|
|
||||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
|
||||||
// left i.e. before side block
|
|
||||||
const block = await screen.findByText('subsection block 00');
|
|
||||||
await user.click(block);
|
|
||||||
|
|
||||||
const removedRows = await screen.findAllByText('This unit was removed');
|
|
||||||
await user.click(removedRows[0]);
|
|
||||||
|
|
||||||
expect(await screen.findByText('This unit has been removed')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show new added container diff state', async () => {
|
|
||||||
// mocks title
|
|
||||||
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
|
|
||||||
axiosMock.onGet(
|
|
||||||
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
|
|
||||||
).reply(200, { publishedDisplayName: 'subsection block 0' });
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
|
||||||
downstreamBlockId="block-v1:UNIX+UX1+2025_T3+type@section+block@0-new"
|
|
||||||
/>);
|
|
||||||
const blocks = await screen.findAllByText('This subsection will be added in the new version');
|
|
||||||
await user.click(blocks[0]);
|
|
||||||
|
|
||||||
expect(await screen.findByText(/this subsection is new/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show alert if the only change is a single text component with local overrides', async () => {
|
|
||||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
|
|
||||||
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
|
||||||
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertSingleText}
|
|
||||||
/>);
|
|
||||||
|
|
||||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
|
||||||
|
|
||||||
expect(screen.getByText(
|
|
||||||
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Html block 11/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show alert if the only changes is multiple text components with local overrides', async () => {
|
|
||||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
|
|
||||||
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
|
|
||||||
render(<CompareContainersWidget
|
|
||||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
|
||||||
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertMultipleText}
|
|
||||||
/>);
|
|
||||||
|
|
||||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
|
||||||
|
|
||||||
expect(screen.getByText(
|
|
||||||
/the only change is to which have been edited in this course\. accepting will not remove local edits\./i,
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/2 text blocks/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Breadcrumb, Button, Card, Icon, Stack,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
import { ArrowBack, Add, Delete } from '@openedx/paragon/icons';
|
|
||||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
|
||||||
import ErrorAlert from '@src/generic/alert-error';
|
|
||||||
import { LoadingSpinner } from '@src/generic/Loading';
|
|
||||||
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
|
|
||||||
import { BoldText } from '@src/utils';
|
|
||||||
|
|
||||||
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
|
||||||
import ChildrenPreview from './ChildrenPreview';
|
|
||||||
import ContainerRow from './ContainerRow';
|
|
||||||
import { useCourseContainerChildren } from './data/apiHooks';
|
|
||||||
import {
|
|
||||||
ContainerChild, ContainerChildBase, ContainerState, WithState,
|
|
||||||
} from './types';
|
|
||||||
import { diffPreviewContainerChildren, isRowClickable } from './utils';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
interface ContainerInfoProps {
|
|
||||||
upstreamBlockId: string;
|
|
||||||
downstreamBlockId: string;
|
|
||||||
isReadyToSyncIndividually?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props extends ContainerInfoProps {
|
|
||||||
parent: ContainerInfoProps[];
|
|
||||||
onRowClick: (row: WithState<ContainerChild>) => void;
|
|
||||||
onBackBtnClick: () => void;
|
|
||||||
state?: ContainerState;
|
|
||||||
// This two props are used to show an alert for the changes to text components with local overrides.
|
|
||||||
// They may be removed in the future.
|
|
||||||
localUpdateAlertCount: number;
|
|
||||||
localUpdateAlertBlockName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actual implementation of the displaying diff between children of containers.
|
|
||||||
*/
|
|
||||||
const CompareContainersWidgetInner = ({
|
|
||||||
upstreamBlockId,
|
|
||||||
downstreamBlockId,
|
|
||||||
parent,
|
|
||||||
state,
|
|
||||||
onRowClick,
|
|
||||||
onBackBtnClick,
|
|
||||||
localUpdateAlertCount,
|
|
||||||
localUpdateAlertBlockName,
|
|
||||||
}: Props) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const { data, isError, error } = useCourseContainerChildren(downstreamBlockId, parent.length === 0);
|
|
||||||
// There is the case in which the item is removed, but it still exists
|
|
||||||
// in the library, for that case, we avoid bringing the children.
|
|
||||||
const {
|
|
||||||
data: libData,
|
|
||||||
isError: isLibError,
|
|
||||||
error: libError,
|
|
||||||
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
|
|
||||||
const {
|
|
||||||
data: containerData,
|
|
||||||
isError: isContainerTitleError,
|
|
||||||
error: containerTitleError,
|
|
||||||
} = useContainer(upstreamBlockId);
|
|
||||||
|
|
||||||
const result = useMemo(() => {
|
|
||||||
if ((!data || !libData) && !['added', 'removed'].includes(state || '')) {
|
|
||||||
return [undefined, undefined];
|
|
||||||
}
|
|
||||||
|
|
||||||
return diffPreviewContainerChildren(data?.children || [], libData as ContainerChildBase[] || []);
|
|
||||||
}, [data, libData]);
|
|
||||||
|
|
||||||
const renderBeforeChildren = useCallback(() => {
|
|
||||||
if (!result[0] && state !== 'added') {
|
|
||||||
return <div className="m-auto"><LoadingSpinner /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'added') {
|
|
||||||
return (
|
|
||||||
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
|
|
||||||
<Icon src={Add} className="big-icon" />
|
|
||||||
<FormattedMessage
|
|
||||||
{...messages.newContainer}
|
|
||||||
values={{
|
|
||||||
containerType: getBlockType(upstreamBlockId),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result[0]?.map((child) => (
|
|
||||||
<ContainerRow
|
|
||||||
key={child.id}
|
|
||||||
title={child.name}
|
|
||||||
containerType={child.blockType}
|
|
||||||
state={child.state}
|
|
||||||
originalName={child.originalName}
|
|
||||||
side="Before"
|
|
||||||
onClick={() => onRowClick(child)}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
const renderAfterChildren = useCallback(() => {
|
|
||||||
if (!result[1] && state !== 'removed') {
|
|
||||||
return <div className="m-auto"><LoadingSpinner /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'removed') {
|
|
||||||
return (
|
|
||||||
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
|
|
||||||
<Icon src={Delete} className="big-icon" />
|
|
||||||
<FormattedMessage
|
|
||||||
{...messages.deletedContainer}
|
|
||||||
values={{
|
|
||||||
containerType: getBlockType(upstreamBlockId),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result[1]?.map((child) => (
|
|
||||||
<ContainerRow
|
|
||||||
key={child.id}
|
|
||||||
title={child.name}
|
|
||||||
containerType={child.blockType}
|
|
||||||
state={child.state}
|
|
||||||
side="After"
|
|
||||||
onClick={() => onRowClick(child)}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
const getTitleComponent = useCallback((title?: string | null) => {
|
|
||||||
if (!title) {
|
|
||||||
return <div className="m-auto"><LoadingSpinner /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parent.length === 0) {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Breadcrumb
|
|
||||||
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
|
|
||||||
links={[
|
|
||||||
{
|
|
||||||
// This raises failed prop-type error as label expects a string but it works without any issues
|
|
||||||
label: <Stack direction="horizontal" gap={1}><Icon size="xs" src={ArrowBack} />Back</Stack>,
|
|
||||||
onClick: onBackBtnClick,
|
|
||||||
variant: 'link',
|
|
||||||
className: 'px-0 text-gray-900',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: title,
|
|
||||||
variant: 'link',
|
|
||||||
className: 'px-0 text-gray-900',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
linkAs={Button}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}, [parent]);
|
|
||||||
|
|
||||||
let beforeTitle: string | undefined | null = data?.displayName;
|
|
||||||
let afterTitle = containerData?.publishedDisplayName;
|
|
||||||
if (!data && state === 'added') {
|
|
||||||
beforeTitle = containerData?.publishedDisplayName;
|
|
||||||
}
|
|
||||||
if (!containerData && state === 'removed') {
|
|
||||||
afterTitle = data?.displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || (isLibError && state !== 'removed') || (isContainerTitleError && state !== 'removed')) {
|
|
||||||
return <ErrorAlert error={error || libError || containerTitleError} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="compare-changes-widget row justify-content-center">
|
|
||||||
{localUpdateAlertCount > 0 && (
|
|
||||||
<Alert variant="info">
|
|
||||||
<FormattedMessage
|
|
||||||
{...messages.localChangeInTextAlert}
|
|
||||||
values={{
|
|
||||||
blockName: localUpdateAlertBlockName,
|
|
||||||
count: localUpdateAlertCount,
|
|
||||||
b: BoldText,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<div className="col col-6 p-1">
|
|
||||||
<Card className="compare-card p-4">
|
|
||||||
<ChildrenPreview title={getTitleComponent(beforeTitle)} side="Before">
|
|
||||||
{renderBeforeChildren()}
|
|
||||||
</ChildrenPreview>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<div className="col col-6 p-1">
|
|
||||||
<Card className="compare-card p-4">
|
|
||||||
<ChildrenPreview title={getTitleComponent(afterTitle)} side="After">
|
|
||||||
{renderAfterChildren()}
|
|
||||||
</ChildrenPreview>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CompareContainersWidget component. Displays a diff of set of child containers from two different sources
|
|
||||||
* and allows the user to select the container to view. This is a wrapper component that maintains current
|
|
||||||
* source state. Actual implementation of the diff view is done by CompareContainersWidgetInner.
|
|
||||||
*/
|
|
||||||
export const CompareContainersWidget = ({
|
|
||||||
upstreamBlockId,
|
|
||||||
downstreamBlockId,
|
|
||||||
isReadyToSyncIndividually = false,
|
|
||||||
}: ContainerInfoProps) => {
|
|
||||||
const [currentContainerState, setCurrentContainerState] = useState<ContainerInfoProps & {
|
|
||||||
state?: ContainerState;
|
|
||||||
parent:(ContainerInfoProps & { state?: ContainerState })[];
|
|
||||||
}>({
|
|
||||||
upstreamBlockId,
|
|
||||||
downstreamBlockId,
|
|
||||||
parent: [],
|
|
||||||
state: 'modified',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data } = useCourseContainerChildren(downstreamBlockId, true);
|
|
||||||
let localUpdateAlertBlockName = '';
|
|
||||||
let localUpdateAlertCount = 0;
|
|
||||||
|
|
||||||
// Show this alert if the only change is text components with local overrides.
|
|
||||||
// We decided not to put this in `CompareContainersWidgetInner` because if you enter a child,
|
|
||||||
// the alert would disappear. By keeping this call in CompareContainersWidget,
|
|
||||||
// the alert remains in the modal regardless of whether you navigate within the children.
|
|
||||||
if (!isReadyToSyncIndividually && data?.upstreamReadyToSyncChildrenInfo
|
|
||||||
&& data.upstreamReadyToSyncChildrenInfo.every(value => value.downstreamCustomized.length > 0 && value.blockType === 'html')
|
|
||||||
) {
|
|
||||||
localUpdateAlertCount = data.upstreamReadyToSyncChildrenInfo.length;
|
|
||||||
if (localUpdateAlertCount === 1) {
|
|
||||||
localUpdateAlertBlockName = data.upstreamReadyToSyncChildrenInfo[0].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRowClick = (row: WithState<ContainerChild>) => {
|
|
||||||
if (!isRowClickable(row.state, row.blockType as ContainerType)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentContainerState((prev) => ({
|
|
||||||
upstreamBlockId: row.id!,
|
|
||||||
downstreamBlockId: row.downstreamId!,
|
|
||||||
state: row.state,
|
|
||||||
parent: [...prev.parent, {
|
|
||||||
upstreamBlockId: prev.upstreamBlockId,
|
|
||||||
downstreamBlockId: prev.downstreamBlockId,
|
|
||||||
state: prev.state,
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBackBtnClick = () => {
|
|
||||||
setCurrentContainerState((prev) => {
|
|
||||||
// istanbul ignore if: this should never happen
|
|
||||||
if (prev.parent.length < 1) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const prevParent = prev.parent[prev.parent.length - 1];
|
|
||||||
return {
|
|
||||||
upstreamBlockId: prevParent!.upstreamBlockId,
|
|
||||||
downstreamBlockId: prevParent!.downstreamBlockId,
|
|
||||||
state: prevParent!.state,
|
|
||||||
parent: prev.parent.slice(0, -1),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CompareContainersWidgetInner
|
|
||||||
upstreamBlockId={currentContainerState.upstreamBlockId}
|
|
||||||
downstreamBlockId={currentContainerState.downstreamBlockId}
|
|
||||||
parent={currentContainerState.parent}
|
|
||||||
state={currentContainerState.state}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
onBackBtnClick={onBackBtnClick}
|
|
||||||
localUpdateAlertCount={localUpdateAlertCount}
|
|
||||||
localUpdateAlertBlockName={localUpdateAlertBlockName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import {
|
|
||||||
fireEvent, initializeMocks, render, screen,
|
|
||||||
} from '../testUtils';
|
|
||||||
import ContainerRow from './ContainerRow';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
describe('<ContainerRow />', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with default props', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="subsection" side="Before" />);
|
|
||||||
expect(await screen.findByText('Test title')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with modified state', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="modified" />);
|
|
||||||
expect(await screen.findByText(
|
|
||||||
messages.modifiedDiffBeforeMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with removed state', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="removed" />);
|
|
||||||
expect(await screen.findByText(
|
|
||||||
messages.removedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('calls onClick when clicked', async () => {
|
|
||||||
const onClick = jest.fn();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ContainerRow
|
|
||||||
title="Test title"
|
|
||||||
containerType="subsection"
|
|
||||||
side="Before"
|
|
||||||
state="modified"
|
|
||||||
onClick={onClick}
|
|
||||||
/>);
|
|
||||||
const titleDiv = await screen.findByText('Test title');
|
|
||||||
const card = titleDiv.closest('.clickable');
|
|
||||||
expect(card).not.toBe(null);
|
|
||||||
await user.click(card!);
|
|
||||||
expect(onClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('calls onClick when pressed enter or space', async () => {
|
|
||||||
const onClick = jest.fn();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ContainerRow
|
|
||||||
title="Test title"
|
|
||||||
containerType="subsection"
|
|
||||||
side="Before"
|
|
||||||
state="modified"
|
|
||||||
onClick={onClick}
|
|
||||||
/>);
|
|
||||||
const titleDiv = await screen.findByText('Test title');
|
|
||||||
const card = titleDiv.closest('.clickable');
|
|
||||||
expect(card).not.toBe(null);
|
|
||||||
fireEvent.select(card!);
|
|
||||||
await user.keyboard('{enter}');
|
|
||||||
expect(onClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with originalName', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="locallyRenamed" originalName="Modified name" />);
|
|
||||||
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with local content update', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyContentUpdated" />);
|
|
||||||
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with rename and local content update', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyRenamedAndContentUpdated" originalName="Modified name" />);
|
|
||||||
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with moved state', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="moved" />);
|
|
||||||
expect(await screen.findByText(
|
|
||||||
messages.movedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders with added state', async () => {
|
|
||||||
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="added" />);
|
|
||||||
expect(await screen.findByText(
|
|
||||||
messages.addedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionRow, Card, Icon, Stack,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
import type { MessageDescriptor } from 'react-intl';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Cached, ChevronRight, Delete, Done, Plus,
|
|
||||||
} from '@openedx/paragon/icons';
|
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
|
||||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
|
||||||
import { ContainerType } from '@src/generic/key-utils';
|
|
||||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
|
||||||
import messages from './messages';
|
|
||||||
import { ContainerState } from './types';
|
|
||||||
import { isRowClickable } from './utils';
|
|
||||||
|
|
||||||
export interface ContainerRowProps {
|
|
||||||
title: string;
|
|
||||||
containerType: ContainerType | keyof typeof COMPONENT_TYPES | string;
|
|
||||||
state?: ContainerState;
|
|
||||||
side: 'Before' | 'After';
|
|
||||||
originalName?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateContext {
|
|
||||||
className: string;
|
|
||||||
icon: React.ComponentType;
|
|
||||||
message?: MessageDescriptor;
|
|
||||||
message2?: MessageDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContainerRow = ({
|
|
||||||
title, containerType, state, side, originalName, onClick,
|
|
||||||
}: ContainerRowProps) => {
|
|
||||||
const isClickable = isRowClickable(state, containerType as ContainerType);
|
|
||||||
const stateContext: StateContext = useMemo(() => {
|
|
||||||
let message: MessageDescriptor | undefined;
|
|
||||||
let message2: MessageDescriptor | undefined;
|
|
||||||
switch (state) {
|
|
||||||
case 'added':
|
|
||||||
message = side === 'Before' ? messages.addedDiffBeforeMessage : messages.addedDiffAfterMessage;
|
|
||||||
return { className: 'text-white bg-success-500', icon: Plus, message };
|
|
||||||
case 'modified':
|
|
||||||
message = side === 'Before' ? messages.modifiedDiffBeforeMessage : messages.modifiedDiffAfterMessage;
|
|
||||||
return { className: 'text-white bg-warning-900', icon: Cached, message };
|
|
||||||
case 'removed':
|
|
||||||
message = side === 'Before' ? messages.removedDiffBeforeMessage : messages.removedDiffAfterMessage;
|
|
||||||
return { className: 'text-white bg-danger-600', icon: Delete, message };
|
|
||||||
case 'locallyRenamed':
|
|
||||||
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
|
|
||||||
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
|
|
||||||
case 'locallyContentUpdated':
|
|
||||||
message = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
|
|
||||||
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
|
|
||||||
case 'locallyRenamedAndContentUpdated':
|
|
||||||
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
|
|
||||||
message2 = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
|
|
||||||
return {
|
|
||||||
className: 'bg-light-300 text-light-300 ', icon: Done, message, message2,
|
|
||||||
};
|
|
||||||
case 'moved':
|
|
||||||
message = side === 'Before' ? messages.movedDiffBeforeMessage : messages.movedDiffAfterMessage;
|
|
||||||
return { className: 'bg-light-300 text-light-300', icon: Done, message };
|
|
||||||
default:
|
|
||||||
return { className: 'bg-light-300 text-light-300', icon: Done, message };
|
|
||||||
}
|
|
||||||
}, [state, side]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
isClickable={isClickable}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
onClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mb-2 rounded shadow-sm border border-light-100"
|
|
||||||
>
|
|
||||||
<Stack direction="horizontal" gap={0}>
|
|
||||||
<div
|
|
||||||
className={`px-1 align-self-stretch align-content-center rounded-left ${stateContext.className}`}
|
|
||||||
>
|
|
||||||
<Icon size="sm" src={stateContext.icon} />
|
|
||||||
</div>
|
|
||||||
<ActionRow className="p-2">
|
|
||||||
<Stack direction="vertical" gap={2}>
|
|
||||||
<Stack direction="horizontal" gap={2}>
|
|
||||||
<Icon
|
|
||||||
src={getItemIcon(containerType)}
|
|
||||||
screenReaderText={containerType}
|
|
||||||
title={title}
|
|
||||||
/>
|
|
||||||
<span className="small font-weight-bold">{title}</span>
|
|
||||||
</Stack>
|
|
||||||
{stateContext.message ? (
|
|
||||||
<div className="d-flex flex-column">
|
|
||||||
<span className="micro">
|
|
||||||
<FormattedMessage
|
|
||||||
{...stateContext.message}
|
|
||||||
values={{
|
|
||||||
blockType: containerType,
|
|
||||||
name: originalName,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{stateContext.message2 && (
|
|
||||||
<span className="micro">
|
|
||||||
<FormattedMessage
|
|
||||||
{...stateContext.message2}
|
|
||||||
values={{
|
|
||||||
blockType: containerType,
|
|
||||||
name: originalName,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="micro"> </span>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<ActionRow.Spacer />
|
|
||||||
{isClickable && <Icon size="md" src={ChevronRight} />}
|
|
||||||
</ActionRow>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContainerRow;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/* istanbul ignore file */
|
|
||||||
import { CourseContainerChildrenData, type UpstreamReadyToSyncChildrenInfo } from '@src/course-unit/data/types';
|
|
||||||
import * as unitApi from '@src/course-unit/data/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock for `getLibraryContainerChildren()`
|
|
||||||
*
|
|
||||||
* This mock returns a fixed response for the given container ID.
|
|
||||||
*/
|
|
||||||
export async function mockGetCourseContainerChildren(containerId: string): Promise<CourseContainerChildrenData> {
|
|
||||||
let numChildren: number = 3;
|
|
||||||
let blockType: string;
|
|
||||||
let displayName: string;
|
|
||||||
let upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[] = [];
|
|
||||||
switch (containerId) {
|
|
||||||
case mockGetCourseContainerChildren.unitId:
|
|
||||||
blockType = 'text';
|
|
||||||
displayName = 'unit block 00';
|
|
||||||
break;
|
|
||||||
case mockGetCourseContainerChildren.sectionId:
|
|
||||||
blockType = 'subsection';
|
|
||||||
displayName = 'Test Title';
|
|
||||||
break;
|
|
||||||
case mockGetCourseContainerChildren.subsectionId:
|
|
||||||
blockType = 'unit';
|
|
||||||
displayName = 'subsection block 00';
|
|
||||||
break;
|
|
||||||
case mockGetCourseContainerChildren.sectionShowsAlertSingleText:
|
|
||||||
blockType = 'subsection';
|
|
||||||
displayName = 'Test Title';
|
|
||||||
upstreamReadyToSyncChildrenInfo = [{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
|
|
||||||
name: 'Html block 11',
|
|
||||||
blockType: 'html',
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
upstream: 'upstream-id',
|
|
||||||
}];
|
|
||||||
break;
|
|
||||||
case mockGetCourseContainerChildren.sectionShowsAlertMultipleText:
|
|
||||||
blockType = 'subsection';
|
|
||||||
displayName = 'Test Title';
|
|
||||||
upstreamReadyToSyncChildrenInfo = [
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
|
|
||||||
name: 'Html block 11',
|
|
||||||
blockType: 'html',
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
upstream: 'upstream-id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@2',
|
|
||||||
name: 'Html block 22',
|
|
||||||
blockType: 'html',
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
upstream: 'upstream-id',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
break;
|
|
||||||
case mockGetCourseContainerChildren.unitIdLoading:
|
|
||||||
case mockGetCourseContainerChildren.sectionIdLoading:
|
|
||||||
case mockGetCourseContainerChildren.subsectionIdLoading:
|
|
||||||
return new Promise(() => { });
|
|
||||||
default:
|
|
||||||
blockType = 'section';
|
|
||||||
displayName = 'section block 00';
|
|
||||||
numChildren = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const children = Array(numChildren).fill(mockGetCourseContainerChildren.childTemplate).map((child, idx) => (
|
|
||||||
{
|
|
||||||
...child,
|
|
||||||
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
|
||||||
id: `block-v1:UNIX+UX1+2025_T3+type@${blockType}+block@${idx}`,
|
|
||||||
name: `${blockType} block ${idx}${idx}`,
|
|
||||||
blockType,
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: `lct:org1:Demo_course_generated:${blockType}:${blockType}-${idx}`,
|
|
||||||
versionSynced: 1,
|
|
||||||
versionAvailable: 2,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
));
|
|
||||||
return Promise.resolve({
|
|
||||||
canPasteComponent: true,
|
|
||||||
isPublished: false,
|
|
||||||
children,
|
|
||||||
displayName,
|
|
||||||
upstreamReadyToSyncChildrenInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
mockGetCourseContainerChildren.unitId = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0';
|
|
||||||
mockGetCourseContainerChildren.subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0';
|
|
||||||
mockGetCourseContainerChildren.sectionId = 'block-v1:UNIX+UX1+2025_T3+type@section+block@0';
|
|
||||||
mockGetCourseContainerChildren.sectionShowsAlertSingleText = 'block-v1:UNIX+UX1+2025_T3+type@section2+block@0';
|
|
||||||
mockGetCourseContainerChildren.sectionShowsAlertMultipleText = 'block-v1:UNIX+UX1+2025_T3+type@section3+block@0';
|
|
||||||
mockGetCourseContainerChildren.unitIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@loading';
|
|
||||||
mockGetCourseContainerChildren.subsectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@loading';
|
|
||||||
mockGetCourseContainerChildren.sectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@section+block@loading';
|
|
||||||
mockGetCourseContainerChildren.childTemplate = {
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1',
|
|
||||||
name: 'Unit 1 remote edit - local edit',
|
|
||||||
blockType: 'unit',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
versionSynced: 1,
|
|
||||||
versionAvailable: 2,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
|
||||||
mockGetCourseContainerChildren.applyMock = () => {
|
|
||||||
jest.spyOn(unitApi, 'getCourseContainerChildren').mockImplementation(mockGetCourseContainerChildren);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { getCourseContainerChildren } from '@src/course-unit/data/api';
|
|
||||||
import { getCourseKey } from '@src/generic/key-utils';
|
|
||||||
|
|
||||||
export const containerComparisonQueryKeys = {
|
|
||||||
all: ['containerComparison'],
|
|
||||||
/**
|
|
||||||
* Base key for a course
|
|
||||||
*/
|
|
||||||
course: (courseKey: string) => [...containerComparisonQueryKeys.all, courseKey],
|
|
||||||
/**
|
|
||||||
* Key for a single container
|
|
||||||
*/
|
|
||||||
container: (getUpstreamInfo: boolean, usageKey?: string) => {
|
|
||||||
if (usageKey === undefined) {
|
|
||||||
return [undefined, undefined, getUpstreamInfo.toString()];
|
|
||||||
}
|
|
||||||
const courseKey = getCourseKey(usageKey);
|
|
||||||
return [...containerComparisonQueryKeys.course(courseKey), usageKey, getUpstreamInfo.toString()];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCourseContainerChildren = (usageKey?: string, getUpstreamInfo?: boolean) => (
|
|
||||||
useQuery({
|
|
||||||
enabled: !!usageKey,
|
|
||||||
queryFn: () => getCourseContainerChildren(usageKey!, getUpstreamInfo),
|
|
||||||
// If we first get data with a valid `usageKey` and then the `usageKey` changes to undefined, an error occurs.
|
|
||||||
queryKey: containerComparisonQueryKeys.container(getUpstreamInfo || false, usageKey),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.compare-changes-widget {
|
|
||||||
.compare-card {
|
|
||||||
min-height: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-icon {
|
|
||||||
height: 68px;
|
|
||||||
width: 68px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
error: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.error.message',
|
|
||||||
defaultMessage: 'Unexpected error: Failed to fetch container data',
|
|
||||||
description: 'Generic error message',
|
|
||||||
},
|
|
||||||
removedDiffBeforeMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.removed-message',
|
|
||||||
defaultMessage: 'This {blockType} will be removed in the new version',
|
|
||||||
description: 'Description for removed component in before section of diff preview',
|
|
||||||
},
|
|
||||||
removedDiffAfterMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.removed-message',
|
|
||||||
defaultMessage: 'This {blockType} was removed',
|
|
||||||
description: 'Description for removed component in after section of diff preview',
|
|
||||||
},
|
|
||||||
modifiedDiffBeforeMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.modified-message',
|
|
||||||
defaultMessage: 'This {blockType} will be modified',
|
|
||||||
description: 'Description for modified component in before section of diff preview',
|
|
||||||
},
|
|
||||||
modifiedDiffAfterMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.modified-message',
|
|
||||||
defaultMessage: 'This {blockType} was modified',
|
|
||||||
description: 'Description for modified component in after section of diff preview',
|
|
||||||
},
|
|
||||||
addedDiffBeforeMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.added-message',
|
|
||||||
defaultMessage: 'This {blockType} will be added in the new version',
|
|
||||||
description: 'Description for added component in before section of diff preview',
|
|
||||||
},
|
|
||||||
addedDiffAfterMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.added-message',
|
|
||||||
defaultMessage: 'This {blockType} was added',
|
|
||||||
description: 'Description for added component in after section of diff preview',
|
|
||||||
},
|
|
||||||
renamedDiffBeforeMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.locally-updated-message',
|
|
||||||
defaultMessage: 'Library Name: {name}',
|
|
||||||
description: 'Description for locally updated component in before section of diff preview',
|
|
||||||
},
|
|
||||||
renamedUpdatedDiffAfterMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.locally-updated-message',
|
|
||||||
defaultMessage: 'Library name remains overwritten',
|
|
||||||
description: 'Description for locally updated component in after section of diff preview',
|
|
||||||
},
|
|
||||||
locallyContentUpdatedBeforeMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.locally-content-updated-message',
|
|
||||||
defaultMessage: 'This {blockType} was edited locally',
|
|
||||||
description: 'Description for locally content updated component in before section of diff preview',
|
|
||||||
},
|
|
||||||
locallyContentUpdatedAfterMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.locally-content-updated-message',
|
|
||||||
defaultMessage: 'Local edit will remain',
|
|
||||||
description: 'Description for locally content updated component in after section of diff preview',
|
|
||||||
},
|
|
||||||
movedDiffBeforeMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.moved-message',
|
|
||||||
defaultMessage: 'This {blockType} will be moved in the new version',
|
|
||||||
description: 'Description for moved component in before section of diff preview',
|
|
||||||
},
|
|
||||||
movedDiffAfterMessage: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.moved-message',
|
|
||||||
defaultMessage: 'This {blockType} was moved',
|
|
||||||
description: 'Description for moved component in after section of diff preview',
|
|
||||||
},
|
|
||||||
breadcrumbAriaLabel: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.breadcrumb.ariaLabel',
|
|
||||||
defaultMessage: 'Title breadcrumb',
|
|
||||||
description: 'Aria label text for breadcrumb in diff preview',
|
|
||||||
},
|
|
||||||
diffBeforeTitle: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.before.title',
|
|
||||||
defaultMessage: 'Before',
|
|
||||||
description: 'Before section title text',
|
|
||||||
},
|
|
||||||
diffAfterTitle: {
|
|
||||||
id: 'course-authoring.container-comparison.diff.after.title',
|
|
||||||
defaultMessage: 'After',
|
|
||||||
description: 'After section title text',
|
|
||||||
},
|
|
||||||
localChangeInTextAlert: {
|
|
||||||
id: 'course-authoring.container-comparison.text-with-local-change.alert',
|
|
||||||
defaultMessage: 'The only change is to {count, plural, one {text block <b>{blockName}</b> which has been edited} other {<b>{count} text blocks</b> which have been edited}} in this course. Accepting will not remove local edits.',
|
|
||||||
description: 'Alert to show if the only change is on text components with local overrides.',
|
|
||||||
},
|
|
||||||
newContainer: {
|
|
||||||
id: 'course-authoring.container-comparison.new-container.text',
|
|
||||||
defaultMessage: 'This {containerType} is new',
|
|
||||||
description: 'Text to show in the comparison when a container is new.',
|
|
||||||
},
|
|
||||||
deletedContainer: {
|
|
||||||
id: 'course-authoring.container-comparison.deleted-container.text',
|
|
||||||
defaultMessage: 'This {containerType} has been removed',
|
|
||||||
description: 'Text to show in the comparison when a container is removed.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { UpstreamInfo } from '@src/data/types';
|
|
||||||
|
|
||||||
export type ContainerState = 'removed' | 'added' | 'modified' | 'childrenModified' | 'locallyContentUpdated' | 'locallyRenamed' | 'locallyRenamedAndContentUpdated' | 'moved';
|
|
||||||
|
|
||||||
export type WithState<T> = T & { state?: ContainerState, originalName?: string };
|
|
||||||
export type WithIndex<T> = T & { index: number };
|
|
||||||
|
|
||||||
export type CourseContainerChildBase = {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
upstreamLink: UpstreamInfo;
|
|
||||||
blockType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContainerChildBase = {
|
|
||||||
displayName: string;
|
|
||||||
id: string;
|
|
||||||
containerType?: string;
|
|
||||||
blockType?: string;
|
|
||||||
} & ({
|
|
||||||
containerType: string;
|
|
||||||
} | {
|
|
||||||
blockType: string;
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ContainerChild = {
|
|
||||||
name: string;
|
|
||||||
id?: string;
|
|
||||||
downstreamId?: string;
|
|
||||||
blockType: string;
|
|
||||||
};
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import { ContainerChildBase, CourseContainerChildBase } from './types';
|
|
||||||
import { diffPreviewContainerChildren } from './utils';
|
|
||||||
|
|
||||||
export const getMockCourseContainerData = (
|
|
||||||
type: 'added|deleted' | 'moved|deleted' | 'all' | 'locallyEdited',
|
|
||||||
): [CourseContainerChildBase[], ContainerChildBase[]] => {
|
|
||||||
switch (type) {
|
|
||||||
case 'moved|deleted':
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
|
||||||
name: 'Unit 1 remote edit - local edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
versionSynced: 11,
|
|
||||||
versionAvailable: 11,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
|
||||||
name: 'New unit remote edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
|
||||||
versionSynced: 7,
|
|
||||||
versionAvailable: 7,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
|
||||||
name: 'Unit with tags',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
versionSynced: 2,
|
|
||||||
versionAvailable: 2,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
|
|
||||||
name: 'One more unit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
|
||||||
versionSynced: 1,
|
|
||||||
versionAvailable: 1,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
displayName: 'Unit with tags',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
displayName: 'Unit 1 remote edit 2',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
|
||||||
displayName: 'One more unit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
|
||||||
case 'added|deleted':
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
|
||||||
name: 'Unit 1 remote edit - local edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
versionSynced: 11,
|
|
||||||
versionAvailable: 11,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
|
||||||
name: 'New unit remote edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
|
||||||
versionSynced: 7,
|
|
||||||
versionAvailable: 7,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
|
||||||
name: 'Unit with tags',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
versionSynced: 2,
|
|
||||||
versionAvailable: 2,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
|
|
||||||
name: 'One more unit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
|
||||||
versionSynced: 1,
|
|
||||||
versionAvailable: 1,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
displayName: 'Unit 1 remote edit 2',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
displayName: 'Unit with tags',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:added-unit-1',
|
|
||||||
displayName: 'Added unit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
|
||||||
displayName: 'One more unit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
|
||||||
case 'all':
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
|
||||||
name: 'Unit 1 remote edit - local edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
versionSynced: 11,
|
|
||||||
versionAvailable: 11,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
|
||||||
name: 'New unit remote edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
|
||||||
versionSynced: 7,
|
|
||||||
versionAvailable: 7,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
|
||||||
name: 'Unit with tags',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
versionSynced: 2,
|
|
||||||
versionAvailable: 2,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
|
|
||||||
name: 'One more unit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
|
||||||
versionSynced: 1,
|
|
||||||
versionAvailable: 1,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
displayName: 'Unit with tags',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:added-unit-1',
|
|
||||||
displayName: 'Added unit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
|
||||||
displayName: 'One more unit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
displayName: 'Unit 1 remote edit 2',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
|
||||||
case 'locallyEdited':
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
|
||||||
name: 'Unit 1 remote edit - local edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
versionSynced: 11,
|
|
||||||
versionAvailable: 11,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: ['display_name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
|
||||||
name: 'New unit remote edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
|
||||||
versionSynced: 7,
|
|
||||||
versionAvailable: 7,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: ['data'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
|
||||||
name: 'Unit with tags - local edit',
|
|
||||||
blockType: 'vertical',
|
|
||||||
upstreamLink: {
|
|
||||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
versionSynced: 2,
|
|
||||||
versionAvailable: 2,
|
|
||||||
versionDeclined: null,
|
|
||||||
downstreamCustomized: ['display_name', 'data'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
|
||||||
displayName: 'Unit 1 remote edit - remote edit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
|
||||||
displayName: 'New unit remote edit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
|
||||||
displayName: 'Unit with tags - remote edit',
|
|
||||||
containerType: 'unit',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
|
||||||
default:
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('diffPreviewContainerChildren', () => {
|
|
||||||
it('should handle moved and deleted', () => {
|
|
||||||
const [a, b] = getMockCourseContainerData('moved|deleted');
|
|
||||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
|
||||||
expect(result[0].length).toEqual(result[1].length);
|
|
||||||
// renamed takes precendence over moved
|
|
||||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[1][2].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[0][1].state).toEqual('removed');
|
|
||||||
expect(result[1][1].state).toEqual('removed');
|
|
||||||
expect(result[1][2].name).toEqual(a[0].name);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle add and delete', () => {
|
|
||||||
const [a, b] = getMockCourseContainerData('added|deleted');
|
|
||||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
|
||||||
expect(result[0].length).toEqual(result[1].length);
|
|
||||||
// No change, state=undefined
|
|
||||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[0][0].originalName).toEqual(b[0].displayName);
|
|
||||||
expect(result[1][0].state).toEqual('locallyRenamed');
|
|
||||||
|
|
||||||
// Deleted entry
|
|
||||||
expect(result[0][1].state).toEqual('removed');
|
|
||||||
expect(result[1][1].state).toEqual('removed');
|
|
||||||
expect(result[1][0].name).toEqual(a[0].name);
|
|
||||||
expect(result[0][3].name).toEqual(result[1][3].name);
|
|
||||||
expect(result[0][3].state).toEqual('added');
|
|
||||||
expect(result[1][3].state).toEqual('added');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle add, delete and moved', () => {
|
|
||||||
const [a, b] = getMockCourseContainerData('all');
|
|
||||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
|
||||||
expect(result[0].length).toEqual(result[1].length);
|
|
||||||
// renamed takes precendence over moved
|
|
||||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[1][4].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[1][4].id).toEqual(result[0][0].id);
|
|
||||||
|
|
||||||
// Deleted entry
|
|
||||||
expect(result[0][1].state).toEqual('removed');
|
|
||||||
expect(result[1][1].state).toEqual('removed');
|
|
||||||
expect(result[1][1].name).toEqual(result[0][1].name);
|
|
||||||
|
|
||||||
// added entry
|
|
||||||
expect(result[0][2].state).toEqual('added');
|
|
||||||
expect(result[1][2].state).toEqual('added');
|
|
||||||
expect(result[1][2].id).toEqual(result[0][2].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle locally edited content', () => {
|
|
||||||
const [a, b] = getMockCourseContainerData('locallyEdited');
|
|
||||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
|
||||||
expect(result[0].length).toEqual(result[1].length);
|
|
||||||
// renamed
|
|
||||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[1][0].state).toEqual('locallyRenamed');
|
|
||||||
expect(result[1][0].id).toEqual(result[0][0].id);
|
|
||||||
// content updated
|
|
||||||
expect(result[0][1].state).toEqual('locallyContentUpdated');
|
|
||||||
expect(result[1][1].state).toEqual('locallyContentUpdated');
|
|
||||||
expect(result[1][1].id).toEqual(result[0][1].id);
|
|
||||||
// renamed and content updated
|
|
||||||
expect(result[0][2].state).toEqual('locallyRenamedAndContentUpdated');
|
|
||||||
expect(result[1][2].state).toEqual('locallyRenamedAndContentUpdated');
|
|
||||||
expect(result[1][2].id).toEqual(result[0][2].id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { UpstreamInfo } from '@src/data/types';
|
|
||||||
import { ContainerType, normalizeContainerType } from '@src/generic/key-utils';
|
|
||||||
import {
|
|
||||||
ContainerChild,
|
|
||||||
ContainerChildBase,
|
|
||||||
ContainerState,
|
|
||||||
CourseContainerChildBase,
|
|
||||||
WithIndex,
|
|
||||||
WithState,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export function checkIsReadyToSync(link: UpstreamInfo): boolean {
|
|
||||||
return (link.versionSynced < (link.versionAvailable || 0))
|
|
||||||
|| (link.versionSynced < (link.versionDeclined || 0))
|
|
||||||
|| ((link.readyToSyncChildren?.length || 0) > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares two arrays of container children (`a` and `b`) to determine the differences between them.
|
|
||||||
* It generates two lists indicating which elements have been added, modified, moved, or removed.
|
|
||||||
*/
|
|
||||||
export function diffPreviewContainerChildren<A extends CourseContainerChildBase, B extends ContainerChildBase>(
|
|
||||||
a: A[],
|
|
||||||
b: B[],
|
|
||||||
idKey: string = 'id',
|
|
||||||
): [WithState<ContainerChild>[], WithState<ContainerChild>[]] {
|
|
||||||
const mapA = new Map<any, WithIndex<A>>();
|
|
||||||
const mapB = new Map<any, WithIndex<ContainerChild>>();
|
|
||||||
for (let index = 0; index < a.length; index++) {
|
|
||||||
const element = a[index];
|
|
||||||
mapA.set(element.upstreamLink?.upstreamRef, { ...element, index });
|
|
||||||
}
|
|
||||||
const updatedA: WithState<ContainerChild>[] = Array(a.length);
|
|
||||||
const addedA: Array<WithIndex<ContainerChild>> = [];
|
|
||||||
const updatedB: WithState<ContainerChild>[] = [];
|
|
||||||
for (let index = 0; index < b.length; index++) {
|
|
||||||
const newVersion = b[index];
|
|
||||||
const oldVersion = mapA.get(newVersion.id);
|
|
||||||
|
|
||||||
if (!oldVersion) {
|
|
||||||
// This is a newly added component
|
|
||||||
addedA.push({
|
|
||||||
id: newVersion.id,
|
|
||||||
name: newVersion.displayName,
|
|
||||||
blockType: (newVersion.containerType || newVersion.blockType)!,
|
|
||||||
index,
|
|
||||||
});
|
|
||||||
updatedB.push({
|
|
||||||
name: newVersion.displayName,
|
|
||||||
blockType: (newVersion.blockType || newVersion.containerType)!,
|
|
||||||
id: newVersion.id,
|
|
||||||
state: 'added',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// It was present in previous version
|
|
||||||
let state: ContainerState | undefined;
|
|
||||||
const displayName = oldVersion.upstreamLink.downstreamCustomized.includes('display_name') ? oldVersion.name : newVersion.displayName;
|
|
||||||
let originalName: string | undefined;
|
|
||||||
// FIXME: This logic doesn't work when the content is updated locally and the upstream display name is updated.
|
|
||||||
// `isRenamed` becomes true.
|
|
||||||
// We probably need to differentiate between `contentModified` and `rename` in the backend or
|
|
||||||
// send `downstream_customized` field to the frontend and use it here.
|
|
||||||
const isRenamed = displayName !== newVersion.displayName && displayName === oldVersion.name;
|
|
||||||
const isContentModified = oldVersion.upstreamLink.downstreamCustomized.includes('data');
|
|
||||||
if (index !== oldVersion.index) {
|
|
||||||
// has moved from its position
|
|
||||||
state = 'moved';
|
|
||||||
}
|
|
||||||
if ((oldVersion.upstreamLink.downstreamCustomized.length || 0) > 0) {
|
|
||||||
if (isRenamed) {
|
|
||||||
state = 'locallyRenamed';
|
|
||||||
originalName = newVersion.displayName;
|
|
||||||
}
|
|
||||||
if (isContentModified) {
|
|
||||||
state = 'locallyContentUpdated';
|
|
||||||
}
|
|
||||||
if (isRenamed && isContentModified) {
|
|
||||||
state = 'locallyRenamedAndContentUpdated';
|
|
||||||
}
|
|
||||||
} else if (checkIsReadyToSync(oldVersion.upstreamLink)) {
|
|
||||||
// has a new version ready to sync
|
|
||||||
state = 'modified';
|
|
||||||
}
|
|
||||||
// Insert in its original index
|
|
||||||
updatedA.splice(oldVersion.index, 1, {
|
|
||||||
name: oldVersion.name,
|
|
||||||
blockType: normalizeContainerType(oldVersion.blockType),
|
|
||||||
id: oldVersion.upstreamLink.upstreamRef,
|
|
||||||
downstreamId: oldVersion.id,
|
|
||||||
state,
|
|
||||||
originalName,
|
|
||||||
});
|
|
||||||
updatedB.push({
|
|
||||||
name: displayName,
|
|
||||||
blockType: (newVersion.blockType || newVersion.containerType)!,
|
|
||||||
id: newVersion.id,
|
|
||||||
downstreamId: oldVersion.id,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
// Delete it from mapA as it is processed.
|
|
||||||
mapA.delete(newVersion.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are remaining items in mapA, it means they were deleted in newVersion;
|
|
||||||
mapA.forEach((oldVersion) => {
|
|
||||||
updatedA.splice(oldVersion.index, 1, {
|
|
||||||
name: oldVersion.name,
|
|
||||||
blockType: normalizeContainerType(oldVersion.blockType),
|
|
||||||
id: oldVersion.upstreamLink.upstreamRef,
|
|
||||||
downstreamId: oldVersion.id,
|
|
||||||
state: 'removed',
|
|
||||||
});
|
|
||||||
updatedB.splice(oldVersion.index, 0, {
|
|
||||||
id: oldVersion.upstreamLink.upstreamRef,
|
|
||||||
name: oldVersion.name,
|
|
||||||
blockType: normalizeContainerType(oldVersion.blockType),
|
|
||||||
downstreamId: oldVersion.id,
|
|
||||||
state: 'removed',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a map for id with index of newly updatedB array
|
|
||||||
for (let index = 0; index < updatedB.length; index++) {
|
|
||||||
const element = updatedB[index];
|
|
||||||
mapB.set(element[idKey], { ...element, index });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use new mapB for getting new index for added elements
|
|
||||||
addedA.forEach((addedRow) => {
|
|
||||||
updatedA.splice(mapB.get(addedRow.id)?.index!, 0, { ...addedRow, state: 'added' });
|
|
||||||
});
|
|
||||||
|
|
||||||
return [updatedA, updatedB];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRowClickable(state?: ContainerState, blockType?: ContainerType) {
|
|
||||||
return state && blockType && ['modified', 'added', 'removed'].includes(state) && [
|
|
||||||
ContainerType.Section,
|
|
||||||
ContainerType.Subsection,
|
|
||||||
ContainerType.Unit,
|
|
||||||
].includes(blockType);
|
|
||||||
}
|
|
||||||
@@ -112,6 +112,7 @@ const CustomLoadingIndicator = () => {
|
|||||||
return (
|
return (
|
||||||
<Spinner
|
<Spinner
|
||||||
animation="border"
|
animation="border"
|
||||||
|
size="xl"
|
||||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -435,8 +436,8 @@ const ContentTagsCollapsible = ({
|
|||||||
onKeyDown={handleSelectOnKeyDown}
|
onKeyDown={handleSelectOnKeyDown}
|
||||||
ref={/** @type {React.RefObject} */(selectRef)}
|
ref={/** @type {React.RefObject} */(selectRef)}
|
||||||
isMulti
|
isMulti
|
||||||
isLoading={updateTags.isPending}
|
isLoading={updateTags.isLoading}
|
||||||
isDisabled={updateTags.isPending}
|
isDisabled={updateTags.isLoading}
|
||||||
name="tags-select"
|
name="tags-select"
|
||||||
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
|
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
|
||||||
isSearchable
|
isSearchable
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
.add-tags-button:not([disabled]):hover {
|
.add-tags-button:not([disabled]):hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--pgn-color-info-900) !important;
|
color: $info-900 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-select-add-tags__control {
|
.react-select-add-tags__control {
|
||||||
|
|||||||
@@ -508,7 +508,6 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle search term change', async () => {
|
it('should handle search term change', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
|
||||||
const {
|
const {
|
||||||
getByText, getByRole, getByDisplayValue,
|
getByText, getByRole, getByDisplayValue,
|
||||||
} = await getComponent();
|
} = await getComponent();
|
||||||
@@ -524,7 +523,7 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
const searchTerm = 'memo';
|
const searchTerm = 'memo';
|
||||||
|
|
||||||
// Trigger a change in the search field
|
// Trigger a change in the search field
|
||||||
await user.type(searchField, searchTerm);
|
userEvent.type(searchField, searchTerm);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
// Fast-forward time by 500 milliseconds (for the debounce delay)
|
// Fast-forward time by 500 milliseconds (for the debounce delay)
|
||||||
@@ -536,14 +535,14 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
|
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
|
||||||
|
|
||||||
// Clear search
|
// Clear search
|
||||||
fireEvent.change(searchField, { target: { value: '' } });
|
userEvent.clear(searchField);
|
||||||
|
|
||||||
// Check that the search term has been cleared
|
// Check that the search term has been cleared
|
||||||
expect(searchField).toHaveValue('');
|
expect(searchField).toHaveValue('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close dropdown selector when clicking away', async () => {
|
it('should close dropdown selector when clicking away', async () => {
|
||||||
const { container, getByText, queryByText } = await getComponent();
|
const { getByText, queryByText } = await getComponent();
|
||||||
|
|
||||||
// Click on "Add a tag" button to open dropdown
|
// Click on "Add a tag" button to open dropdown
|
||||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||||
@@ -555,9 +554,10 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||||
|
|
||||||
// Simulate clicking outside the dropdown remove focus
|
// Simulate clicking outside the dropdown remove focus
|
||||||
const outsideElement = container.querySelector('.taxonomy-tags-count-chip');
|
userEvent.click(document.body);
|
||||||
const selectElement = container.querySelector('.react-select-add-tags__input');
|
|
||||||
fireEvent.blur(selectElement, { relatedTarget: outsideElement });
|
// Simulate clicking outside the dropdown again to close it
|
||||||
|
userEvent.click(document.body);
|
||||||
|
|
||||||
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
|
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
|
||||||
// the page
|
// the page
|
||||||
@@ -565,7 +565,6 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should test keyboard navigation of add tags widget', async () => {
|
it('should test keyboard navigation of add tags widget', async () => {
|
||||||
const user = userEvent.setup({ delay: null });
|
|
||||||
const {
|
const {
|
||||||
getByText,
|
getByText,
|
||||||
queryByText,
|
queryByText,
|
||||||
@@ -599,61 +598,59 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Press tab to focus on first element in dropdown, Tag 1 should be focused
|
// Press tab to focus on first element in dropdown, Tag 1 should be focused
|
||||||
await user.keyboard('{Tab}');
|
userEvent.tab();
|
||||||
|
|
||||||
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
|
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
|
||||||
expect(dropdownTag1Div).toHaveFocus();
|
expect(dropdownTag1Div).toHaveFocus();
|
||||||
|
|
||||||
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||||
await user.keyboard('{arrowright}');
|
userEvent.keyboard('{arrowright}');
|
||||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||||
|
|
||||||
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
|
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
|
||||||
await user.keyboard('{arrowleft}');
|
userEvent.keyboard('{arrowleft}');
|
||||||
expect(queryAllByText('Tag 1.1').length).toBe(1);
|
expect(queryAllByText('Tag 1.1').length).toBe(1);
|
||||||
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
|
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||||
await user.keyboard('{enter}');
|
userEvent.keyboard('{enter}');
|
||||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||||
|
|
||||||
// Press down arrow to navigate to Tag 1.1, it should be focused
|
// Press down arrow to navigate to Tag 1.1, it should be focused
|
||||||
await user.keyboard('{arrowdown}');
|
userEvent.keyboard('{arrowdown}');
|
||||||
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
|
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
|
||||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||||
|
|
||||||
// Press down arrow again to navigate to Tag 1.2, it should be fouced
|
// Press down arrow again to navigate to Tag 1.2, it should be fouced
|
||||||
await user.keyboard('{arrowdown}');
|
userEvent.keyboard('{arrowdown}');
|
||||||
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
|
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
|
||||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||||
|
|
||||||
// Press down arrow again to navigate to Tag 2, it should be fouced
|
// Press down arrow again to navigate to Tag 2, it should be fouced
|
||||||
await user.keyboard('{arrowdown}');
|
userEvent.keyboard('{arrowdown}');
|
||||||
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
|
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
|
||||||
expect(dropdownTag2Div).toHaveFocus();
|
expect(dropdownTag2Div).toHaveFocus();
|
||||||
|
|
||||||
// Press up arrow to navigate back to Tag 1.2, it should be focused
|
// Press up arrow to navigate back to Tag 1.2, it should be focused
|
||||||
await user.keyboard('{arrowup}');
|
userEvent.keyboard('{arrowup}');
|
||||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||||
|
|
||||||
// Press up arrow to navigate back to Tag 1.1, it should be focused
|
// Press up arrow to navigate back to Tag 1.1, it should be focused
|
||||||
await user.keyboard('{arrowup}');
|
userEvent.keyboard('{arrowup}');
|
||||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||||
|
|
||||||
// Press up arrow again to navigate to Tag 1, it should be focused
|
// Press up arrow again to navigate to Tag 1, it should be focused
|
||||||
await user.keyboard('{arrowup}');
|
userEvent.keyboard('{arrowup}');
|
||||||
expect(dropdownTag1Div).toHaveFocus();
|
expect(dropdownTag1Div).toHaveFocus();
|
||||||
|
|
||||||
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
|
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
|
||||||
await user.keyboard('{arrowdown}');
|
userEvent.keyboard('{arrowdown}');
|
||||||
await user.keyboard('{arrowdown}');
|
userEvent.keyboard('{arrowdown}');
|
||||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||||
|
|
||||||
// Press space key to check Tag 1.2, it should be staged
|
// Press space key to check Tag 1.2, it should be staged
|
||||||
await user.keyboard('[Space]');
|
userEvent.keyboard('{space}');
|
||||||
|
|
||||||
const taxonomyId = 123;
|
const taxonomyId = 123;
|
||||||
const addedStagedTag = {
|
const addedStagedTag = {
|
||||||
value: 'Tag%201,Tag%201.2',
|
value: 'Tag%201,Tag%201.2',
|
||||||
@@ -662,35 +659,35 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
|
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
|
||||||
|
|
||||||
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
|
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
|
||||||
await user.keyboard('{enter}');
|
userEvent.keyboard('{enter}');
|
||||||
const tagValue = 'Tag%201,Tag%201.2';
|
const tagValue = 'Tag%201,Tag%201.2';
|
||||||
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
|
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
|
||||||
|
|
||||||
// Press left arrow to navigate back to Tag 1, it should be focused
|
// Press left arrow to navigate back to Tag 1, it should be focused
|
||||||
await user.keyboard('{arrowleft}');
|
userEvent.keyboard('{arrowleft}');
|
||||||
expect(dropdownTag1Div).toHaveFocus();
|
expect(dropdownTag1Div).toHaveFocus();
|
||||||
|
|
||||||
// Press tab key it should jump to cancel button, it should be focused
|
// Press tab key it should jump to cancel button, it should be focused
|
||||||
await user.keyboard('{Tab}');
|
userEvent.tab();
|
||||||
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
||||||
expect(dropdownCancel).toHaveFocus();
|
expect(dropdownCancel).toHaveFocus();
|
||||||
|
|
||||||
// Press tab again, it should exit and close the select menu, since there are not staged tags
|
// Press tab again, it should exit and close the select menu, since there are not staged tags
|
||||||
await user.keyboard('{Tab}');
|
userEvent.tab();
|
||||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Press shift tab, focus back on select menu input, it should open the menu
|
// Press shift tab, focus back on select menu input, it should open the menu
|
||||||
await user.tab({ shift: true });
|
userEvent.tab({ shift: true });
|
||||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||||
|
|
||||||
// Press shift tab again, it should focus out and close the select menu
|
// Press shift tab again, it should focus out and close the select menu
|
||||||
await user.tab({ shift: true });
|
userEvent.tab({ shift: true });
|
||||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Press tab again, the select menu should open, then press escape, it should close
|
// Press tab again, the select menu should open, then press escape, it should close
|
||||||
await user.keyboard('{Tab}');
|
userEvent.tab();
|
||||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||||
await user.keyboard('{escape}');
|
userEvent.keyboard('{escape}');
|
||||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -702,7 +699,7 @@ describe('<ContentTagsCollapsible />', () => {
|
|||||||
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
|
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
|
||||||
name: /delete/i,
|
name: /delete/i,
|
||||||
});
|
});
|
||||||
fireEvent.click(xButtonAppliedTag);
|
await userEvent.click(xButtonAppliedTag);
|
||||||
|
|
||||||
// Check that the applied tag has been removed
|
// Check that the applied tag has been removed
|
||||||
expect(appliedTag).not.toBeInTheDocument();
|
expect(appliedTag).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.tags-drawer-cancel-button:hover {
|
.tags-drawer-cancel-button:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--pgn-color-gray-300) !important;
|
color: $gray-300 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.other-description {
|
.other-description {
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
.enable-taxonomies-button:not([disabled]):hover {
|
.enable-taxonomies-button:not([disabled]):hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--pgn-color-info-900) !important;
|
color: $info-900 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +37,3 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
|
|
||||||
// https://github.com/openedx/frontend-app-authoring/issues/1898
|
|
||||||
#toast-root[data-focus-on-hidden] {
|
|
||||||
pointer-events: initial !important;
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user