Compare commits

..

2 Commits

Author SHA1 Message Date
Muhammad Abdullah Waheed
beb5f51e47 refactor: updated data dog config 2024-04-04 12:32:41 +05:00
Muhammad Abdullah Waheed
6a115797e6 refactor: testing datadog logging 2024-03-29 22:15:10 +05:00
1576 changed files with 18335 additions and 135616 deletions

11
.env
View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
@@ -31,19 +30,15 @@ USER_INFO_COOKIE_NAME=''
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_HOME_PAGE_COURSE_API_V2=true
AI_TRANSLATIONS_BASE_URL=''
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2001'
@@ -33,19 +32,15 @@ USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"

View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
@@ -29,16 +28,11 @@ SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

View File

@@ -1,6 +1,4 @@
coverage/*
dist/
node_modules/
jest.config.js
env.config.jsx
example.env.config.jsx
jest.config.js

View File

@@ -11,9 +11,8 @@ module.exports = createConfig(
}],
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: ['error', 2],
'no-restricted-exports': 'off',
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
'no-restricted-syntax': 'off',
},
settings: {
// Import URLs should be resolved using aliases

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -9,29 +9,15 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v4
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

4
.gitignore vendored
View File

@@ -1,7 +1,6 @@
.DS_Store
.eslintcache
.idea
.run
node_modules
npm-debug.log
coverage
@@ -27,6 +26,3 @@ temp/babel-plugin-react-intl
# Messages .json files fetched by atlas
src/i18n/messages/
# environment js config
env.config.jsx

2
.nvmrc
View File

@@ -1 +1 @@
20
18

View File

@@ -26,7 +26,6 @@
"scss/at-rule-no-unknown": true,
"scss/at-import-partial-extension": null,
"scss/comment-no-empty": null,
"import-notation": "string",
"property-no-unknown": [true, {
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
}],

View File

@@ -1,2 +1,2 @@
# The following users are the maintainers of all frontend-app-authoring files
# The following users are the maintainers of all frontend-app-course-authoring files
* @openedx/2u-tnl

View File

@@ -35,12 +35,13 @@ pull_translations:
cd src/i18n/messages \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
$(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
@@ -53,7 +54,7 @@ validate:
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run types
npm run test:ci
npm run test
npm run build
.PHONY: validate.ci

View File

@@ -1,5 +1,5 @@
frontend-app-authoring
######################
frontend-app-course-authoring
#############################
|license-badge| |status-badge| |codecov-badge|
@@ -7,9 +7,9 @@ frontend-app-authoring
Purpose
*******
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
A few parts of Studio still default to the `"legacy" pages defined in edx-platform <https://github.com/openedx/edx-platform/tree/master/cms>`_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here.
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
Getting Started
@@ -18,87 +18,51 @@ Getting Started
Prerequisites
=============
`Tutor`_ is currently recommended as a development environment for the Authoring
MFE. Most likely, it already has this MFE configured; however, you'll need to
make some changes in order to run it in development mode. You can refer
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
guide below.
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Configuration
=============
Cloning and Setup
=================
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
1. Clone your new repo:
Cloning and Startup
===================
.. code-block:: bash
git clone https://github.com/openedx/frontend-app-authoring.git
1. Clone the repo:
2. Use the version of Node specified in the ``.nvmrc`` file.
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
The current version of the micro-frontend build scripts supports node 20.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
2. Use node v18.x.
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm use`_.
4. Next, we need to tell Tutor that we're going to be running this repo in
development mode, and it should be excluded from the ``mfe`` container that
otherwise runs every MFE. Run this:
3. Install npm dependencies:
.. code-block:: bash
``cd frontend-app-course-authoring && npm install``
tutor mounts add /path/to/frontend-app-authoring
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start
the Authoring MFE, which we're going to run on the host instead of in a
container managed by Tutor. Run:
4. Start the dev server:
.. code-block:: bash
``npm start``
tutor dev start lms cms mfe
Startup
=======
1. Install npm dependencies:
.. code-block:: bash
cd frontend-app-authoring && npm ci
2. Start the dev server:
.. code-block:: bash
npm run dev
Then you can access the app at http://apps.local.openedx.io:2001/course-authoring/home
Troubleshooting
---------------
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
these commands to update your devstack's domain names:
.. code-block:: bash
tutor dev stop
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
tutor dev launch -I --skip-build
tutor dev stop authoring # We will run this MFE on the host
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
Features
@@ -176,11 +140,22 @@ Requirements
* ``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
Configuration
-------------
In additional to the standard settings, the following local configuration item is required:
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
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.
.. note::
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
Feature: New Proctoring Exams View
==================================
@@ -292,30 +267,13 @@ Configuration
In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
Tagging/Taxonomy functionality.
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
Feature: Libraries V2/Legacy Tabs
=================================
Configuration
-------------
In additional to the standard settings, the following local configurations can be set to switch between different library modes:
* ``MEILISEARCH_ENABLED``: Studio setting which is enabled when the `meilisearch plugin`_ is installed.
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.disable_legacy_libraries``: this feature flag must be OFF to show legacy Libraries V1
* ``contentstore.new_studio_mfe.disable_new_libraries``: this feature flag must be OFF to show Content Libraries V2
.. _meilisearch plugin: https://github.com/open-craft/tutor-contrib-meilisearch
Developing
**********
`Tutor <https://docs.tutor.edly.io/>`_ is the community-supported Open edX development environment. See the `tutor-mfe plugin README <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ for more information.
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
If your devstack includes the default Demo course, you can visit the following URLs to see content:
@@ -344,8 +302,8 @@ The production build is created with ``npm run build``.
:target: https://travis-ci.com/edx/frontend-app-course-authoring
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
:target: @edx/frontend-app-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
:target: @edx/frontend-app-course-authoring
Internationalization
====================

View File

@@ -4,15 +4,14 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-authoring'
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
name: 'frontend-app-course-authoring'
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
links:
- url: "https://github.com/openedx/frontend-app-authoring"
title: "Frontend app authoring"
- url: "https://github.com/openedx/frontend-app-course-authoring"
title: "Frontend app course authoring"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:2u-tnl
type: 'website'

View File

@@ -1,24 +0,0 @@
import WholeCourseTranslation from '@edx/course-app-translation-plugin';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
// Load environment variables from .env file
const config = {
...process.env,
pluginSlots: {
additional_course_plugin: {
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'whole-course-translation-plugin',
type: DIRECT_PLUGIN,
priority: 1,
RenderWidget: WholeCourseTranslation,
},
},
],
},
},
};
export default config;

11
openedx.yaml Normal file
View File

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

20578
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{
"name": "@edx/frontend-app-authoring",
"name": "@edx/frontend-app-course-authoring",
"version": "0.1.0",
"description": "Frontend application template",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
},
"browserslist": [
"extends @edx/browserslist-config"
@@ -12,43 +12,48 @@
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-authoring/issues"
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
},
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@datadog/browser-rum": "^5.13.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.3.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-ai-translations": "^2.0.0",
"@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-lib-content-components": "^2.1.4",
"@edx/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -59,66 +64,59 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.3.3",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-slot-footer": "^1.2.0",
"@openedx/paragon": "^22.16.0",
"@redux-devtools/extension": "^3.3.0",
"@openedx/paragon": "^21.5.7",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"broadcast-channel": "^7.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"fast-xml-parser": "^4.0.10",
"file-saver": "^2.0.5",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"npm": "^10.8.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"moment": "2.29.4",
"prop-types": "15.7.2",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.5.3",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.5",
"redux": "4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"start": "^5.1.0",
"tinymce": "^5.10.4",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"xmlchecker": "^0.1.0",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/browserslist-config": "1.2.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@edx/reactifex": "^1.0.3",
"@edx/stylelint-config-edx": "2.3.0",
"@edx/typescript-config": "^1.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@openedx/frontend-build": "13.0.27",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.2.1",
"@types/lodash": "^4.17.7",
"axios": "^0.27.2",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"fetch-mock-jest": "^1.5.1",
"glob": "7.2.3",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"ts-loader": "^9.5.0"
},
"peerDependencies": {
"decode-uri-component": ">=0.2.2"
}
}

View File

@@ -3,14 +3,14 @@
"version": "0.1.0",
"description": "Calculator configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,14 +3,14 @@
"version": "0.1.0",
"description": "edxnotes configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Learning Assistant configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
@@ -11,7 +11,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,10 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { Icon } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
import SelectableBox from 'CourseAuthoring/editors/sharedComponents/SelectableBox';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';

View File

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

View File

@@ -3,7 +3,8 @@
"version": "0.1.0",
"description": "Live course configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-lib-content-components": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
@@ -15,7 +16,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -1,176 +1,69 @@
import { useEffect, useState, useRef } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
import { Hyperlink } from '@openedx/paragon';
import { useModel } from 'CourseAuthoring/generic/model-store';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import Loading from 'CourseAuthoring/generic/Loading';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ORASettings = ({ onClose }) => {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const alertRef = useRef(null);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const isMobile = useIsMobile();
const modalVariant = isMobile ? 'dark' : 'default';
const ORASettings = ({ intl, onClose }) => {
const appId = 'ora_settings';
const appInfo = useModel('courseApps', appId);
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
'forceOnFlexiblePeerOpenassessments',
);
const initialFormValues = { enableFlexiblePeerGrade };
const [formValues, setFormValues] = useState(initialFormValues);
const [saveError, setSaveError] = useState(false);
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
const handleSubmit = async (event) => {
let success = true;
event.preventDefault();
success = success && await handleSettingsSave(formValues);
await setSaveError(!success);
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
success = await dispatch(updateModel({
modelType: 'courseApps',
model: {
id: appId, enabled: formValues.enableFlexiblePeerGrade,
},
}));
}
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
};
const handleChange = (e) => {
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
};
useEffect(() => {
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
onClose();
}
}, [updateSettingsRequestStatus]);
const renderBody = () => {
switch (loadingStatus) {
case RequestStatus.SUCCESSFUL:
return (
<>
{saveError && (
<Alert variant="danger" icon={Info} ref={alertRef}>
<Alert.Heading>
{formatMessage(messages.errorSavingTitle)}
</Alert.Heading>
{formatMessage(messages.errorSavingMessage)}
</Alert>
)}
<FormSwitchGroup
id="enable-flexible-peer-grade"
name="enableFlexiblePeerGrade"
label={(
<div className="d-flex align-items-center">
{formatMessage(messages.enableFlexPeerGradeLabel)}
{formValues.enableFlexiblePeerGrade && (
<Badge className="ml-2" variant="success" data-testid="enable-badge">
{formatMessage(messages.enabledBadgeLabel)}
</Badge>
)}
</div>
)}
helpText={(
<div>
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
<span className="py-3">
<Hyperlink
className="text-primary-500 small"
destination={appInfo.documentationLinks?.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{formatMessage(messages.ORASettingsHelpLink)}
</Hyperlink>
</span>
</div>
)}
onChange={handleChange}
checked={formValues.enableFlexiblePeerGrade}
/>
</>
);
case RequestStatus.DENIED:
return <PermissionDeniedAlert />;
case RequestStatus.FAILED:
return <ConnectionErrorAlert />;
default:
return <Loading />;
}
};
const title = (
<div>
<p>{intl.formatMessage(messages.heading)}</p>
<div className="pt-3">
<Hyperlink
className="text-primary-500 small"
destination={appInfo.documentationLinks?.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.ORASettingsHelpLink)}
</Hyperlink>
</div>
</div>
);
return (
<ModalDialog
title={formatMessage(messages.heading)}
isOpen
<AppSettingsModal
appId={appId}
title={title}
onClose={onClose}
size="lg"
variant={modalVariant}
hasCloseButton={isMobile}
isFullscreenScroll
isFullscreenOnMobile
initialValues={{ enableFlexiblePeerGrade }}
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
onSettingsSave={handleSettingsSave}
hideAppToggle
>
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
<ModalDialog.Header>
<ModalDialog.Title>
{formatMessage(messages.heading)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{renderBody()}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{formatMessage(messages.cancelLabel)}
</ModalDialog.CloseButton>
<StatefulButton
labels={{
default: formatMessage(messages.saveLabel),
pending: formatMessage(messages.pendingSaveLabel),
}}
description="Form save button"
data-testid="submissionButton"
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
state={submitButtonState}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
{({ values, handleChange, handleBlur }) => (
<FormSwitchGroup
id="enable-flexible-peer-grade"
name="enableFlexiblePeerGrade"
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
onChange={handleChange}
onBlur={handleBlur}
checked={values.enableFlexiblePeerGrade}
/>
)}
</AppSettingsModal>
);
};
ORASettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ORASettings;
export default injectIntl(ORASettings);

View File

@@ -1,152 +1,33 @@
import {
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
import { shallow } from '@edx/react-unit-test-utils';
import ORASettings from './Settings';
import messages from './messages';
import {
courseId,
inititalState,
} from './factories/mockData';
let axiosMock;
let store;
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
injectIntl: (component) => component,
intlShape: {},
}));
jest.mock('yup', () => ({
boolean: jest.fn().mockReturnValue('Yub.boolean'),
}));
jest.mock('CourseAuthoring/generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
}));
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('CourseAuthoring/utils', () => ({
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
}));
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => (
render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[oraSettingsUrl]}>
<Routes>
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
)
);
const mockStore = async ({
apiStatus,
enabled,
}) => {
const settings = ['forceOnFlexiblePeerOpenassessments'];
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
axiosMock.onGet(fetchCourseAppsUrl).reply(
200,
[{
allowed_operations: { enable: false, configure: true },
description: 'setting',
documentation_links: { learnMoreConfiguration: '' },
enabled,
id: 'ora_settings',
name: 'Flexible Peer Grading for ORAs',
}],
);
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
apiStatus,
{ force_on_flexible_peer_openassessments: { value: enabled } },
);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
const props = {
onClose: jest.fn().mockName('onClose'),
intl: {
formatMessage: (message) => message.defaultMessage,
},
};
describe('ORASettings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(inititalState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('Flexible peer grading configuration modal is visible', async () => {
renderComponent();
expect(screen.getByRole('dialog')).toBeVisible();
});
it('Displays "Configure Flexible Peer Grading" heading', async () => {
renderComponent();
const headingElement = screen.getByText(messages.heading.defaultMessage);
expect(headingElement).toBeVisible();
});
it('Displays loading component', () => {
renderComponent();
const loadingElement = screen.getByRole('status');
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
});
it('Displays Connection Error Alert', async () => {
await mockStore({ apiStatus: 404, enabled: true });
renderComponent();
const errorAlert = screen.getByRole('alert');
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
});
it('Displays Permissions Error Alert', async () => {
await mockStore({ apiStatus: 403, enabled: true });
renderComponent();
const errorAlert = screen.getByRole('alert');
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
});
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
renderComponent();
await mockStore({ apiStatus: 200, enabled: true });
waitFor(() => {
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.getByTestId('enable-badge');
expect(label).toBeVisible();
expect(enableBadge).toHaveTextContent('Enabled');
});
});
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
renderComponent();
await mockStore({ apiStatus: 200, enabled: false });
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.queryByTestId('enable-badge');
expect(label).toBeVisible();
expect(enableBadge).toBeNull();
it('should render', () => {
const wrapper = shallow(<ORASettings {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ORASettings should render 1`] = `
<AppSettingsModal
appId="ora_settings"
hideAppToggle={true}
initialValues={
Object {
"enableFlexiblePeerGrade": "abitrary value",
}
}
onClose={[MockFunction onClose]}
onSettingsSave={[Function]}
title={
<div>
<p>
Configure open response assessment
</p>
<div
className="pt-3"
>
<withDeprecatedProps(Hyperlink)
className="text-primary-500 small"
destination="https://learnmore.test"
rel="noreferrer noopener"
target="_blank"
>
Learn more about open response assessment settings
</withDeprecatedProps(Hyperlink)>
</div>
</div>
}
validationSchema={
Object {
"enableFlexiblePeerGrade": "Yub.boolean",
}
}
>
[Function]
</AppSettingsModal>
`;

View File

@@ -1,32 +0,0 @@
export const courseId = 'course-v1:org+num+run';
export const inititalState = {
courseDetail: {
courseId,
status: 'successful',
},
pagesAndResources: {
courseAppIds: ['ora_settings'],
loadingStatus: 'in-progress',
savingStatus: '',
courseAppsApiStatus: {},
courseAppSettings: {},
},
models: {
courseApps: {
ora_settings: {
id: 'ora_settings',
name: 'Flexible Peer Grading',
enabled: true,
description: 'Enable flexible peer grading',
allowedOperations: {
enable: false,
configure: true,
},
documentationLinks: {
learnMoreConfiguration: '',
},
},
},
},
};

View File

@@ -3,51 +3,19 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.ora.heading',
defaultMessage: 'Configure Flexible Peer Grading',
description: 'Title for the modal dialog header',
defaultMessage: 'Configure open response assessment',
},
ORASettingsHelpLink: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
defaultMessage: 'Learn more about open response assessment settings',
description: 'Descriptive text for the hyperlink to the docs site',
},
enableFlexPeerGradeLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
defaultMessage: 'Flex Peer Grading',
description: 'Label for form switch',
},
enableFlexPeerGradeHelp: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
description: 'Help text describing what happens when the switch is enabled',
},
enabledBadgeLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
defaultMessage: 'Enabled',
description: 'Label for badge that show users that a setting is enabled',
},
cancelLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
defaultMessage: 'Cancel',
description: 'Label for button that cancels user changes',
},
saveLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
defaultMessage: 'Save',
description: 'Label for button that saves user changes',
},
pendingSaveLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
defaultMessage: 'Saving',
description: 'Label for button that has pending api save calls',
},
errorSavingTitle: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
defaultMessage: 'We couldn\'t apply your changes.',
},
errorSavingMessage: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
defaultMessage: 'Please check your entries and try again.',
},
});

View File

@@ -3,16 +3,15 @@
"version": "0.1.0",
"description": "Open Response Assessment configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -64,8 +64,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
}
const { courseId } = useContext(PagesAndResourcesContext);
const courseDetails = useModel('courseDetails', courseId);
const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();
const saveStatusAlertRef = React.createRef();
@@ -148,9 +146,9 @@ const ProctoringSettings = ({ intl, onClose }) => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
}).catch((error) => {
}).catch(() => {
setSaveSuccess(false);
setSaveError(error);
setSaveError(true);
setSubmissionInProgress(false);
});
}
@@ -460,44 +458,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
}
function renderSaveError() {
let errorMessage = (
<FormattedMessage
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.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
);
if (saveError?.response.status === 403) {
errorMessage = (
<FormattedMessage
id="authoring.proctoring.alert.error.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.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
);
}
return (
<Alert
variant="danger"
@@ -507,7 +467,21 @@ const ProctoringSettings = ({ intl, onClose }) => {
onClose={() => setSaveError(false)}
dismissible
>
{errorMessage}
<FormattedMessage
id="authoring.examsettings.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.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
</Alert>
);
}
@@ -516,7 +490,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
Promise.all([
StudioApiService.getProctoredExamSettingsData(courseId),
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
])
.then(
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {

View File

@@ -15,9 +15,8 @@ import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import ProctoredExamSettings from './Settings';
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
const defaultProps = {
courseId,
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
onClose: () => {},
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
@@ -35,7 +34,7 @@ const intlWrapper = children => (
let axiosMock;
describe('ProctoredExamSettings', () => {
function setupApp(isAdmin = true, org = undefined) {
function setupApp(isAdmin = true) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
@@ -53,18 +52,12 @@ describe('ProctoredExamSettings', () => {
courseApps: {
proctoring: {},
},
courseDetails: {
[courseId]: {
start: Date(),
},
},
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(200, [
{
name: 'test_lti',
@@ -110,7 +103,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
@@ -120,7 +115,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
@@ -130,7 +127,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
@@ -177,7 +176,9 @@ describe('ProctoredExamSettings', () => {
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
expect(enabledProctoredExamCheck.checked).toEqual(true);
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
await act(async () => {
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
});
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
expect(enabledProctoredExamCheck.checked).toEqual(false);
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
@@ -192,7 +193,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
@@ -234,9 +237,13 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
@@ -245,7 +252,9 @@ describe('ProctoredExamSettings', () => {
// verify alert link links to offending input
const errorLink = screen.getByTestId('escalationEmailErrorLink');
fireEvent.click(errorLink);
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
@@ -256,12 +265,18 @@ describe('ProctoredExamSettings', () => {
});
const selectElement = screen.getByDisplayValue('proctortrack');
fireEvent.change(selectElement, { target: { value: provider } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: provider } });
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
const proctoringForm = screen.getByTestId('proctoringForm');
fireEvent.submit(proctoringForm);
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
@@ -271,7 +286,9 @@ describe('ProctoredExamSettings', () => {
// verify alert link links to offending input
const errorLink = screen.getByTestId('escalationEmailErrorLink');
fireEvent.click(errorLink);
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
@@ -281,11 +298,15 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const enableProctoringElement = screen.getByText('Proctored exams');
fireEvent.click(enableProctoringElement);
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
@@ -299,22 +320,24 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const enableProctoringElement = screen.getByText('Proctored exams');
fireEvent.click(enableProctoringElement);
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
@@ -322,20 +345,22 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
});
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
@@ -345,7 +370,9 @@ describe('ProctoredExamSettings', () => {
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
});
@@ -355,9 +382,13 @@ describe('ProctoredExamSettings', () => {
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
@@ -368,8 +399,12 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
fireEvent.submit(selectEscalationEmailElement);
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
await act(async () => {
fireEvent.submit(selectEscalationEmailElement);
});
// if the error appears, the form has been submitted
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
});
@@ -423,16 +458,6 @@ describe('ProctoredExamSettings', () => {
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Sends the org to the proctoring provider endpoint', async () => {
const isAdmin = false;
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
const isAdmin = true;
setupApp(isAdmin);
@@ -544,9 +569,12 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
await act(async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
const spinner = screen.getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
});
it('Show connection error message when we suffer studio server side error', async () => {
@@ -600,7 +628,9 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
act(() => {
fireEvent.click(submitButton);
});
submitButton = screen.getByTestId('submissionButton');
expect(submitButton).toHaveAttribute('disabled');
@@ -610,13 +640,19 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
});
expect(escalationEmail.value).toEqual('proctortrack@example.com');
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
@@ -628,13 +664,11 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
@@ -644,7 +678,9 @@ describe('ProctoredExamSettings', () => {
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
@@ -655,28 +691,32 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
});
expect(escalationEmail.value).toEqual('test_lti@example.com');
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
@@ -696,19 +736,19 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
@@ -726,13 +766,11 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Does not update exam service if lti is not enabled in studio', async () => {
@@ -752,7 +790,9 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
// does not update exam service config
expect(axiosMock.history.patch.length).toBe(0);
// does update studio
@@ -766,13 +806,11 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes studio API call generated error', async () => {
@@ -782,15 +820,15 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes exams API call generated error', async () => {
@@ -800,33 +838,15 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
await act(async () => {
fireEvent.click(submitButton);
});
});
test('Exams API permission error', async () => {
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('You do not have permission to edit proctored exam settings for this course'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Manages focus correctly after different save statuses', async () => {
@@ -837,30 +857,30 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
// now make a call that will allow for a successful save
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(2);
await waitFor(() => {
const successAlert = screen.getByTestId('saveSuccess');
expect(successAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(successAlert);
});
const successAlert = screen.getByTestId('saveSuccess');
expect(successAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(successAlert);
});
it('Include Zendesk ticket in post request if user is not an admin', async () => {
@@ -871,9 +891,13 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {

View File

@@ -1,16 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
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': {
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.',
description: 'Alert message for proctoring settings permission error.',
},
'authoring.proctoring.no': {
id: 'authoring.proctoring.no',
defaultMessage: 'No',

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Proctoring configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
@@ -13,7 +13,7 @@
"moment": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Progress configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
@@ -11,7 +11,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

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

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Teams configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
@@ -13,7 +13,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';

View File

@@ -26,8 +26,8 @@ const messages = defineMessages({
},
enablePublicWikiHelp: {
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
defaultMessage: `If enabled, any registered user can view the course wiki
even if they are not enrolled in the course`,
defaultMessage: `If enabled, edX users can view the course wiki even when
they're not enrolled in the course.`,
},
});

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Wiki configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
@@ -11,7 +11,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

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

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Xpert Unit Summaries configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
@@ -14,7 +14,7 @@
"react-router-dom": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -137,12 +137,12 @@ const ResetUnitsButton = ({
const getResetButtonState = () => {
switch (resetStatusRequestStatus) {
case RequestStatus.PENDING:
return 'pending';
case RequestStatus.SUCCESSFUL:
return 'finish';
default:
return 'default';
case RequestStatus.PENDING:
return 'pending';
case RequestStatus.SUCCESSFUL:
return 'finish';
default:
return 'default';
}
};
@@ -246,7 +246,7 @@ const SettingsModal = ({
success = success && await onSettingsSave(values);
}
setSaveError(!success);
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
};
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {

View File

@@ -19,6 +19,15 @@
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false
},
{
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false,
"schedule": [
"after 1am",
"before 11pm"
]
}
]
}

View File

@@ -5,29 +5,46 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId,
}) => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
AppHeader.propTypes = {
courseId: PropTypes.string.isRequired,
courseNumber: PropTypes.string,
courseOrg: PropTypes.string,
courseTitle: PropTypes.string.isRequired,
};
AppHeader.defaultProps = {
courseNumber: null,
courseOrg: null,
};
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
@@ -50,23 +67,23 @@ const CourseAuthoringPage = ({ courseId, children }) => {
);
}
return (
<div>
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
{/* While V2 Editors are temporarily served from their own pages
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
{inProgress ? !isEditor && <Loading />
: (!isEditor && (
<Header
number={courseNumber}
org={courseOrg}
title={courseTitle}
contextId={courseId}
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)
)}
{children}
{!inProgress && !isEditor && <StudioFooterSlot />}
{!inProgress && !isEditor && <StudioFooter />}
</div>
);
};

View File

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

View File

@@ -4,7 +4,6 @@ import {
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from 'CourseAuthoring/textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
@@ -18,15 +17,10 @@ import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -58,10 +52,6 @@ const CourseAuthoringRoutes = () => {
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
@@ -86,16 +76,16 @@ const CourseAuthoringRoutes = () => {
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
/>
<Route
path="settings/details"
@@ -109,10 +99,6 @@ const CourseAuthoringRoutes = () => {
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
@@ -125,22 +111,10 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);

View File

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

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
export default {
content: {
id: 69,
userId: 3,
created: '2024-01-16T13:33:21.314439Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'html',
blockTypeDisplay: 'Text',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
displayName: 'Blank HTML Page',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
};

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.accessibility.page.title',
id: 'course-authoring.import.page.title',
defaultMessage: 'Studio Accessibility Policy| {siteName}',
},
});

View File

@@ -6,7 +6,7 @@ import {
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '../editors/Placeholder';
import Placeholder from '@edx/frontend-lib-content-components';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,7 @@ const SettingCard = ({
iconAs={Icon}
alt={intl.formatMessage(messages.helpButtonText)}
variant="primary"
className="flex-shrink-0 ml-1 mr-2"
className=" ml-1 mr-2"
/>
<ModalPopup
hasArrow

View File

@@ -79,7 +79,3 @@
color: $black;
}
}
.react-datepicker-popper {
z-index: 3;
}

View File

@@ -9,7 +9,3 @@
.mw-300px {
max-width: 300px;
}
.right-0 {
right: 0;
}

View File

@@ -1,57 +0,0 @@
import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading';
import useCertificates from './hooks/useCertificates';
import CertificateWithoutModes from './certificate-without-modes/CertificateWithoutModes';
import EmptyCertificatesWithModes from './empty-certificates-with-modes/EmptyCertificatesWithModes';
import CertificatesList from './certificates-list/CertificatesList';
import CertificateCreateForm from './certificate-create-form/CertificateCreateForm';
import CertificateEditForm from './certificate-edit-form/CertificateEditForm';
import { MODE_STATES } from './data/constants';
import MainLayout from './layout/MainLayout';
const MODE_COMPONENTS = {
[MODE_STATES.noModes]: CertificateWithoutModes,
[MODE_STATES.noCertificates]: EmptyCertificatesWithModes,
[MODE_STATES.create]: CertificateCreateForm,
[MODE_STATES.view]: CertificatesList,
[MODE_STATES.editAll]: CertificateEditForm,
};
const Certificates = ({ courseId }) => {
const {
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
} = useCertificates({ courseId });
if (isLoading) {
return <Loading />;
}
if (loadingStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6" data-testid="request-denied-placeholder">
<Placeholder />
</div>
);
}
const ModeComponent = MODE_COMPONENTS[componentMode] || MODE_COMPONENTS[MODE_STATES.noModes];
return (
<>
<Helmet><title>{pageHeadTitle}</title></Helmet>
<MainLayout courseId={courseId} showHeaderButtons={hasCertificateModes && certificates?.length > 0}>
<ModeComponent courseId={courseId} />
</MainLayout>
</>
);
};
Certificates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default Certificates;

View File

@@ -1,189 +0,0 @@
import { render, waitFor } from '@testing-library/react';
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 { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
import initializeStore from '../store';
import { getCertificatesApiUrl } from './data/api';
import { fetchCertificates } from './data/thunks';
import { certificatesDataMock } from './__mocks__';
import Certificates from './Certificates';
import messages from './messages';
let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<Certificates courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);
describe('Certificates', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
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 () => {
const noModesMock = {
...certificatesDataMock,
courseModes: [],
hasCertificateModes: false,
};
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noModesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const { getByText, queryByRole } = renderComponent();
await waitFor(() => {
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
expect(queryByRole('button', { name: messages.headingActionsPreview.defaultMessage })).not.toBeInTheDocument();
});
});
it('renders WithoutModes when there are no certificate modes', async () => {
const noModesMock = {
...certificatesDataMock,
certificates: [],
courseModes: [],
hasCertificateModes: false,
};
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noModesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const { getByText, queryByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.noCertificatesText.defaultMessage)).not.toBeInTheDocument();
});
});
it('renders WithModesWithoutCertificates when there are modes but no certificates', async () => {
const noCertificatesMock = {
...certificatesDataMock,
certificates: [],
};
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noCertificatesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const { getByText, queryByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.noCertificatesText.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
});
});
it('renders CertificatesList when there are modes and certificates', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const { getByText, queryByText, getByTestId } = renderComponent();
await waitFor(() => {
expect(getByTestId('certificates-list')).toBeInTheDocument();
expect(getByText(certificatesDataMock.courseTitle)).toBeInTheDocument();
expect(getByText(certificatesDataMock.certificates[0].signatories[0].name)).toBeInTheDocument();
expect(queryByText(messages.noCertificatesText.defaultMessage)).not.toBeInTheDocument();
expect(queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
});
});
it('renders CertificateCreateForm when there is componentMode = MODE_STATES.create', async () => {
const noCertificatesMock = {
...certificatesDataMock,
certificates: [],
};
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, noCertificatesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const { queryByTestId, getByTestId, getByRole } = renderComponent();
await waitFor(() => {
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
userEvent.click(addCertificateButton);
});
expect(getByTestId('certificates-create-form')).toBeInTheDocument();
expect(getByTestId('certificate-details-form')).toBeInTheDocument();
expect(getByTestId('signatory-form')).toBeInTheDocument();
expect(queryByTestId('certificate-details')).not.toBeInTheDocument();
expect(queryByTestId('signatory')).not.toBeInTheDocument();
});
it('renders CertificateEditForm when there is componentMode = MODE_STATES.editAll', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
await waitFor(() => {
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
userEvent.click(editCertificateButton);
});
expect(getByTestId('certificates-edit-form')).toBeInTheDocument();
expect(getByTestId('certificate-details-form')).toBeInTheDocument();
expect(getByTestId('signatory-form')).toBeInTheDocument();
expect(queryByTestId('certificate-details')).not.toBeInTheDocument();
expect(queryByTestId('signatory')).not.toBeInTheDocument();
});
it('renders placeholder if request fails', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(403, certificatesDataMock);
const { getByTestId } = renderComponent();
await executeThunk(fetchCertificates(courseId), store.dispatch);
expect(getByTestId('request-denied-placeholder')).toBeInTheDocument();
});
it('updates loading status if request fails', async () => {
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(404, certificatesDataMock);
renderComponent();
await executeThunk(fetchCertificates(courseId), store.dispatch);
expect(store.getState().certificates.loadingStatus).toBe(RequestStatus.FAILED);
});
});

View File

@@ -1,20 +0,0 @@
module.exports = [
{
id: 1,
courseTitle: 'Course Title 1',
signatories: [
{
name: 'Signatory Name 1',
title: 'Signatory Title 1',
organization: 'Signatory Organization 1',
signatureImagePath: '/path/to/signature1/image.png',
},
{
name: 'Signatory Name 2',
title: 'Signatory Title 2',
organization: 'Signatory Organization 2',
signatureImagePath: '/path/to/signature2/image.png',
},
],
},
];

View File

@@ -1,32 +0,0 @@
module.exports = {
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
certificates: [
{
courseTitle: 'Course title',
description: 'Description of the certificate',
editing: false,
id: 1622146085,
isActive: false,
name: 'Name of the certificate',
signatories: [
{
id: 268550145,
name: 'name_sign',
organization: 'org',
signatureImagePath: '/asset-v1:org+101+101+type@asset+block@camera.png',
title: 'title_sign',
},
],
version: 1,
},
],
courseModes: ['honor', 'audit'],
hasCertificateModes: true,
isActive: false,
isGlobalStaff: true,
mfeProctoredExamSettingsUrl: '',
courseNumber: 'DemoX',
courseTitle: 'Demonstration Course',
courseNumberOverride: 'Course Number Display String',
};

View File

@@ -1,3 +0,0 @@
export { default as certificatesDataMock } from './certificatesData';
export { default as signatoriesMock } from './signatories';
export { default as certificatesMock } from './certificates';

View File

@@ -1,8 +0,0 @@
module.exports = [
{
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
},
{
id: '2', name: 'Jane Doe', title: 'CFO', organization: 'Company 2', signatureImagePath: '/path/to/signature2.png',
},
];

View File

@@ -1,70 +0,0 @@
import PropTypes from 'prop-types';
import { Card, Stack, Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik, Form, FieldArray } from 'formik';
import CertificateDetailsForm from '../certificate-details/CertificateDetailsForm';
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
import { defaultCertificate } from '../constants';
import messages from '../messages';
import useCertificateCreateForm from './hooks/useCertificateCreateForm';
const CertificateCreateForm = ({ courseId }) => {
const intl = useIntl();
const {
courseTitle, handleCertificateSubmit, handleFormCancel,
} = useCertificateCreateForm(courseId);
return (
<Formik initialValues={defaultCertificate} onSubmit={handleCertificateSubmit}>
{({
values, handleChange, handleBlur, resetForm, setFieldValue,
}) => (
<Form className="certificates-card-form" data-testid="certificates-create-form">
<Card>
<Card.Section>
<Stack gap="4">
<CertificateDetailsForm
courseTitleOverride={values.courseTitle}
detailsCourseTitle={courseTitle}
handleChange={handleChange}
handleBlur={handleBlur}
/>
<FieldArray
name="signatories"
render={arrayHelpers => (
<CertificateSignatories
isForm
signatories={values.signatories}
arrayHelpers={arrayHelpers}
handleChange={handleChange}
handleBlur={handleBlur}
setFieldValue={setFieldValue}
/>
)}
/>
</Stack>
</Card.Section>
<Card.Footer className="justify-content-start">
<Button type="submit">
{intl.formatMessage(messages.cardCreate)}
</Button>
<Button
variant="tertiary"
onClick={() => handleFormCancel(resetForm)}
>
{intl.formatMessage(messages.cardCancel)}
</Button>
</Card.Footer>
</Card>
</Form>
)}
</Formik>
);
};
CertificateCreateForm.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CertificateCreateForm;

View File

@@ -1,156 +0,0 @@
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { executeThunk } from '../../utils';
import initializeStore from '../../store';
import { MODE_STATES } from '../data/constants';
import { getCertificatesApiUrl, getCertificateApiUrl } from '../data/api';
import { fetchCertificates, createCourseCertificate } from '../data/thunks';
import { certificatesDataMock } from '../__mocks__';
import detailsMessages from '../certificate-details/messages';
import signatoryMessages from '../certificate-signatories/messages';
import messages from '../messages';
import CertificateCreateForm from './CertificateCreateForm';
const courseId = 'course-123';
let store;
let axiosMock;
const renderComponent = () => render(
<Provider store={store}>
<IntlProvider locale="en">
<CertificateCreateForm courseId={courseId} />
</IntlProvider>
</Provider>,
);
const initialState = {
certificates: {
certificatesData: {
certificates: [],
hasCertificateModes: true,
},
componentMode: MODE_STATES.create,
},
};
describe('CertificateCreateForm', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, {
...certificatesDataMock,
certificates: [],
});
await executeThunk(fetchCertificates(courseId), store.dispatch);
});
it('renders with empty fields', () => {
const { getByPlaceholderText } = renderComponent();
expect(getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage).value).toBe('');
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage).value).toBe('');
expect(getByPlaceholderText(signatoryMessages.titlePlaceholder.defaultMessage).value).toBe('');
expect(getByPlaceholderText(signatoryMessages.organizationPlaceholder.defaultMessage).value).toBe('');
expect(getByPlaceholderText(signatoryMessages.imagePlaceholder.defaultMessage).value).toBe('');
});
it('creates a new certificate', async () => {
const courseTitleOverrideValue = 'Create Course Title';
const signatoryNameValue = 'Create signatory name';
const newCertificateData = {
...certificatesDataMock,
courseTitle: courseTitleOverrideValue,
certificates: [{
...certificatesDataMock.certificates[0],
signatories: [{
...certificatesDataMock.certificates[0].signatories[0],
name: signatoryNameValue,
}],
}],
};
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
userEvent.type(
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
courseTitleOverrideValue,
);
userEvent.type(
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
signatoryNameValue,
);
userEvent.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
axiosMock.onPost(
getCertificateApiUrl(courseId),
).reply(200, newCertificateData);
await executeThunk(createCourseCertificate(courseId, newCertificateData), store.dispatch);
await waitFor(() => {
expect(getByDisplayValue(courseTitleOverrideValue)).toBeInTheDocument();
expect(getByDisplayValue(signatoryNameValue)).toBeInTheDocument();
});
});
it('cancel certificates creation', async () => {
const { getByRole } = renderComponent();
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
await waitFor(() => {
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
});
});
it('there is no delete signatory button if signatories length is less then 2', async () => {
const { queryAllByRole } = renderComponent();
const deleteIcons = queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
await waitFor(() => {
expect(deleteIcons.length).toBe(0);
});
});
it('add and delete signatory', async () => {
const {
getAllByRole, queryAllByRole, getByText, getByRole,
} = renderComponent();
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
userEvent.click(addSignatoryBtn);
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
await waitFor(() => {
expect(deleteIcons.length).toBe(2);
});
userEvent.click(deleteIcons[0]);
const confirModal = getByRole('dialog');
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
userEvent.click(deleteIcons[0]);
userEvent.click(deleteModalButton);
await waitFor(() => {
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);
});
});
});

View File

@@ -1,28 +0,0 @@
import { useSelector, useDispatch } from 'react-redux';
import { MODE_STATES } from '../../data/constants';
import { getCourseTitle } from '../../data/selectors';
import { setMode } from '../../data/slice';
import { createCourseCertificate } from '../../data/thunks';
const useCertificateCreateForm = (courseId) => {
const dispatch = useDispatch();
const courseTitle = useSelector(getCourseTitle);
const handleCertificateSubmit = (values) => {
const signatoriesWithoutIds = values.signatories.map(({ id, ...rest }) => rest);
const newValues = { ...values, signatories: signatoriesWithoutIds };
dispatch(createCourseCertificate(courseId, newValues));
};
const handleFormCancel = (resetForm) => {
dispatch(setMode(MODE_STATES.noCertificates));
resetForm();
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return {
courseTitle, handleCertificateSubmit, handleFormCancel,
};
};
export default useCertificateCreateForm;

View File

@@ -1,123 +0,0 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Icon, Stack, IconButtonWithTooltip,
} from '@openedx/paragon';
import {
EditOutline as EditOutlineIcon, DeleteOutline as DeleteOutlineIcon,
} from '@openedx/paragon/icons';
import CertificateSection from '../certificate-section/CertificateSection';
import ModalNotification from '../../generic/modal-notification';
import commonMessages from '../messages';
import messages from './messages';
import useCertificateDetails from './hooks/useCertificateDetails';
const CertificateDetails = ({
certificateId,
detailsCourseTitle,
courseTitleOverride,
detailsCourseNumber,
courseNumberOverride,
}) => {
const intl = useIntl();
const {
isConfirmOpen,
confirmOpen,
confirmClose,
isEditModalOpen,
editModalOpen,
editModalClose,
isCertificateActive,
handleEditAll,
handleDeleteCard,
} = useCertificateDetails(certificateId);
return (
<CertificateSection
title={intl.formatMessage(messages.detailsSectionTitle)}
className="certificate-details"
data-testid="certificate-details"
actions={(
<Stack direction="horizontal" gap="2">
<IconButtonWithTooltip
src={EditOutlineIcon}
iconAs={Icon}
tooltipContent={<div>{intl.formatMessage(commonMessages.editTooltip)}</div>}
alt={intl.formatMessage(commonMessages.editTooltip)}
onClick={isCertificateActive ? editModalOpen : handleEditAll}
/>
<IconButtonWithTooltip
src={DeleteOutlineIcon}
iconAs={Icon}
tooltipContent={<div>{intl.formatMessage(commonMessages.deleteTooltip)}</div>}
alt={intl.formatMessage(commonMessages.deleteTooltip)}
onClick={confirmOpen}
/>
</Stack>
)}
>
<Stack>
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
<p className="certificate-details__info-paragraph">
<strong>{intl.formatMessage(messages.detailsCourseTitle)}:</strong> {detailsCourseTitle}
</p>
<p className="certificate-details__info-paragraph-course-number">
<strong>{intl.formatMessage(messages.detailsCourseNumber)}:</strong> {detailsCourseNumber}
</p>
</Stack>
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
{courseTitleOverride && (
<p className="certificate-details__info-paragraph">
<strong>{intl.formatMessage(messages.detailsCourseTitleOverride)}:</strong> {courseTitleOverride}
</p>
)}
{courseNumberOverride && (
<p className="certificate-details__info-paragraph text-right">
<strong>{intl.formatMessage(messages.detailsCourseNumberOverride)}:</strong> {courseNumberOverride}
</p>
)}
</Stack>
</Stack>
<ModalNotification
isOpen={isEditModalOpen}
title={intl.formatMessage(messages.editCertificateConfirmationTitle)}
message={intl.formatMessage(messages.editCertificateMessage)}
actionButtonText={intl.formatMessage(commonMessages.editTooltip)}
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
handleCancel={editModalClose}
handleAction={() => {
editModalClose();
handleEditAll();
}}
/>
<ModalNotification
isOpen={isConfirmOpen}
title={intl.formatMessage(messages.deleteCertificateConfirmationTitle)}
message={intl.formatMessage(messages.deleteCertificateMessage)}
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
handleCancel={confirmClose}
handleAction={() => {
confirmClose();
handleDeleteCard();
}}
/>
</CertificateSection>
);
};
CertificateDetails.defaultProps = {
courseTitleOverride: '',
courseNumberOverride: '',
};
CertificateDetails.propTypes = {
certificateId: PropTypes.number.isRequired,
courseTitleOverride: PropTypes.string,
courseNumberOverride: PropTypes.string,
detailsCourseTitle: PropTypes.string.isRequired,
detailsCourseNumber: PropTypes.string.isRequired,
};
export default CertificateDetails;

View File

@@ -1,118 +0,0 @@
import { Provider, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { MODE_STATES } from '../data/constants';
import { deleteCourseCertificate } from '../data/thunks';
import commonMessages from '../messages';
import messages from './messages';
import CertificateDetails from './CertificateDetails';
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const certificateId = 123;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));
jest.mock('../data/thunks', () => ({
deleteCourseCertificate: jest.fn(),
}));
const renderComponent = (props) => render(
<Provider store={store}>
<IntlProvider locale="en">
<CertificateDetails {...props} />
</IntlProvider>
</Provider>,
);
const defaultProps = {
componentMode: MODE_STATES.view,
detailsCourseTitle: 'Course Title',
detailsCourseNumber: 'Course Number',
handleChange: jest.fn(),
handleBlur: jest.fn(),
};
const initialState = {
certificates: {
certificatesData: {
certificates: [],
hasCertificateModes: false,
},
},
};
describe('CertificateDetails', () => {
let mockDispatch;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
useParams.mockReturnValue({ courseId });
mockDispatch = jest.fn();
useDispatch.mockReturnValue(mockDispatch);
});
afterEach(() => {
useParams.mockClear();
mockDispatch.mockClear();
});
it('renders correctly in view mode', () => {
const { getByText } = renderComponent(defaultProps);
expect(getByText(messages.detailsSectionTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
});
it('opens confirm modal on delete button click', () => {
const { getByRole, getByText } = renderComponent(defaultProps);
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
userEvent.click(deleteButton);
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
});
it('dispatches delete action on confirm modal action', async () => {
const props = { ...defaultProps, courseId, certificateId };
const { getByRole } = renderComponent(props);
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
userEvent.click(deleteButton);
await waitFor(() => {
const confirmActionButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
userEvent.click(confirmActionButton);
});
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
});
it('shows course title override in view mode', () => {
const courseTitleOverride = 'Overridden Title';
const props = { ...defaultProps, courseTitleOverride };
const { getByText } = renderComponent(props);
expect(getByText(courseTitleOverride)).toBeInTheDocument();
});
});

View File

@@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Stack, Form } from '@openedx/paragon';
import CertificateSection from '../certificate-section/CertificateSection';
import messages from './messages';
const CertificateDetailsForm = ({
detailsCourseTitle,
courseTitleOverride,
handleChange,
handleBlur,
}) => {
const intl = useIntl();
return (
<CertificateSection
title={intl.formatMessage(messages.detailsSectionTitle)}
className="certificate-details"
data-testid="certificate-details-form"
>
<Stack>
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
<p className="certificate-details__info-paragraph">
<strong>{intl.formatMessage(messages.detailsCourseTitle)}:</strong> {detailsCourseTitle}
</p>
</Stack>
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
<Form.Group className="m-0 w-100">
<Form.Label>{intl.formatMessage(messages.detailsCourseTitleOverride)}</Form.Label>
<Form.Control
name="courseTitle"
value={courseTitleOverride}
onChange={handleChange}
onBlur={handleBlur}
placeholder={intl.formatMessage(messages.detailsCourseTitleOverride)}
/>
<Form.Control.Feedback>
<span className="x-small">{intl.formatMessage(messages.detailsCourseTitleOverrideDescription)}</span>
</Form.Control.Feedback>
</Form.Group>
</Stack>
</Stack>
</CertificateSection>
);
};
CertificateDetailsForm.propTypes = {
courseTitleOverride: PropTypes.string.isRequired,
detailsCourseTitle: PropTypes.string.isRequired,
handleChange: PropTypes.func.isRequired,
handleBlur: PropTypes.func.isRequired,
};
export default CertificateDetailsForm;

View File

@@ -1,77 +0,0 @@
import { Provider } from 'react-redux';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { MODE_STATES } from '../data/constants';
import commonMessages from '../messages';
import messages from './messages';
import CertificateDetailsForm from './CertificateDetailsForm';
let store;
const renderComponent = (props) => render(
<Provider store={store}>
<IntlProvider locale="en">
<CertificateDetailsForm {...props} />
</IntlProvider>
</Provider>,
);
const defaultProps = {
componentMode: MODE_STATES.view,
detailsCourseTitle: 'Course Title',
detailsCourseNumber: 'Course Number',
handleChange: jest.fn(),
handleBlur: jest.fn(),
};
const initialState = {
certificates: {
certificatesData: {
certificates: [],
hasCertificateModes: false,
},
},
};
describe('CertificateDetails', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
});
it('renders correctly in create mode', () => {
const { getByText, getByPlaceholderText } = renderComponent(defaultProps);
expect(getByText(messages.detailsSectionTitle.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage)).toBeInTheDocument();
});
it('handles input change in create mode', async () => {
const { getByPlaceholderText } = renderComponent(defaultProps);
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
const newInputValue = 'New Title';
userEvent.type(input, newInputValue);
waitFor(() => {
expect(input.value).toBe(newInputValue);
});
});
it('does not show delete button in create mode', () => {
const { queryByRole } = renderComponent(defaultProps);
expect(queryByRole('button', { name: commonMessages.deleteTooltip.defaultMessage })).not.toBeInTheDocument();
});
});

View File

@@ -1,40 +0,0 @@
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { setMode } from '../../data/slice';
import { deleteCourseCertificate } from '../../data/thunks';
import { getIsCertificateActive } from '../../data/selectors';
import { MODE_STATES } from '../../data/constants';
const useCertificateDetails = (certificateId) => {
const dispatch = useDispatch();
const { courseId } = useParams();
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
const [isEditModalOpen, editModalOpen, editModalClose] = useToggle(false);
const isCertificateActive = useSelector(getIsCertificateActive);
const handleEditAll = () => {
dispatch(setMode(MODE_STATES.editAll));
};
const handleDeleteCard = () => {
if (certificateId) {
dispatch(deleteCourseCertificate(courseId, certificateId));
}
};
return {
isConfirmOpen,
confirmOpen,
confirmClose,
isEditModalOpen,
editModalOpen,
editModalClose,
isCertificateActive,
handleEditAll,
handleDeleteCard,
};
};
export default useCertificateDetails;

View File

@@ -1,56 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
detailsSectionTitle: {
id: 'course-authoring.certificates.details.section.title',
defaultMessage: 'Certificate details',
description: 'Title for the section',
},
detailsCourseTitle: {
id: 'course-authoring.certificates.details.course.title',
defaultMessage: 'Course title',
description: 'Label for displaying the official course title in the certificate details section',
},
detailsCourseTitleOverride: {
id: 'course-authoring.certificates.details.course.title.override',
defaultMessage: 'Course title override',
description: 'Label for the course title override input field',
},
detailsCourseTitleOverrideDescription: {
id: 'course-authoring.certificates.details.course.title.override.description',
defaultMessage: 'Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.',
description: 'Helper text under the course title override input field',
},
detailsCourseNumber: {
id: 'course-authoring.certificates.details.course.number',
defaultMessage: 'Course number',
description: 'Label for displaying the official course number in the certificate details section',
},
detailsCourseNumberOverride: {
id: 'course-authoring.certificates.details.course.number.override',
defaultMessage: 'Course number override',
description: 'Label for the course number override input field',
},
deleteCertificateConfirmationTitle: {
id: 'course-authoring.certificates.details.confirm-modal',
defaultMessage: 'Delete this certificate?',
description: 'Title for the confirmation modal when a user attempts to delete a certificate',
},
deleteCertificateMessage: {
id: 'course-authoring.certificates.details.confirm-modal.message',
defaultMessage: 'Deleting this certificate is permanent and cannot be undone.',
description: 'Warning message within the delete confirmation modal, emphasizing the permanent nature of the action',
},
editCertificateConfirmationTitle: {
id: 'course-authoring.certificates.details.confirm.edit',
defaultMessage: 'Edit this certificate?',
description: 'Title for the confirmation modal when a user attempts to edit an already activated (live) certificate',
},
editCertificateMessage: {
id: 'course-authoring.certificates.details.confirm.edit.message',
defaultMessage: 'This certificate has already been activated and is live. Are you sure you want to continue editing?',
description: 'Message warning users about the implications of editing a certificate that is already live, prompting for confirmation',
},
});
export default messages;

View File

@@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import { Card, Stack, Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik, Form, FieldArray } from 'formik';
import ModalNotification from '../../generic/modal-notification';
import CertificateDetailsForm from '../certificate-details/CertificateDetailsForm';
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
import commonMessages from '../messages';
import messages from '../certificate-details/messages';
import useCertificateEditForm from './hooks/useCertificateEditForm';
const CertificateEditForm = ({ courseId }) => {
const intl = useIntl();
const {
confirmOpen,
courseTitle,
certificates,
confirmClose,
initialValues,
isConfirmOpen,
handleCertificateDelete,
handleCertificateSubmit,
handleCertificateUpdateCancel,
} = useCertificateEditForm(courseId);
return (
<>
{certificates.map((certificate, id) => (
<Formik initialValues={initialValues[id]} onSubmit={handleCertificateSubmit} key={certificate.id}>
{({
values, handleChange, handleBlur, resetForm, setFieldValue,
}) => (
<>
<Form className="certificates-card-form" data-testid="certificates-edit-form">
<Card>
<Card.Section>
<Stack gap="4">
<CertificateDetailsForm
courseTitleOverride={values.courseTitle}
detailsCourseTitle={courseTitle}
handleChange={handleChange}
handleBlur={handleBlur}
/>
<FieldArray
name="signatories"
render={arrayHelpers => (
<CertificateSignatories
isForm
signatories={values.signatories}
arrayHelpers={arrayHelpers}
handleChange={handleChange}
handleBlur={handleBlur}
setFieldValue={setFieldValue}
/>
)}
/>
</Stack>
</Card.Section>
<Card.Footer className="justify-content-start">
<Button type="submit">
{intl.formatMessage(commonMessages.saveTooltip)}
</Button>
<Button
variant="outline-primary"
onClick={() => handleCertificateUpdateCancel(resetForm)}
>
{intl.formatMessage(commonMessages.cardCancel)}
</Button>
<Button
className="ml-auto"
variant="tertiary"
onClick={() => confirmOpen(certificate.id)}
>
{intl.formatMessage(commonMessages.deleteTooltip)}
</Button>
</Card.Footer>
</Card>
</Form>
<ModalNotification
isOpen={isConfirmOpen}
title={intl.formatMessage(messages.deleteCertificateConfirmationTitle)}
message={intl.formatMessage(messages.deleteCertificateMessage)}
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
handleCancel={() => confirmClose()}
handleAction={() => {
confirmClose();
handleCertificateDelete(certificate.id);
}}
/>
</>
)}
</Formik>
))}
</>
);
};
CertificateEditForm.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CertificateEditForm;

View File

@@ -1,140 +0,0 @@
import { Provider } from 'react-redux';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { RequestStatus } from '../../data/constants';
import { executeThunk } from '../../utils';
import initializeStore from '../../store';
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api';
import { fetchCertificates, deleteCourseCertificate, updateCourseCertificate } from '../data/thunks';
import { certificatesDataMock } from '../__mocks__';
import { MODE_STATES } from '../data/constants';
import messagesDetails from '../certificate-details/messages';
import messages from '../messages';
import CertificateEditForm from './CertificateEditForm';
let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = () => render(
<Provider store={store}>
<IntlProvider locale="en">
<CertificateEditForm courseId="course-123" />
</IntlProvider>
</Provider>,
);
const initialState = {
certificates: {
certificatesData: {},
componentMode: MODE_STATES.editAll,
},
};
describe('CertificateEditForm Component', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, certificatesDataMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
});
it('submits the form with updated certificate details', async () => {
const courseTitleOverrideValue = 'Updated Course Title';
const signatoryNameValue = 'Updated signatory name';
const newCertificateData = {
...certificatesDataMock,
courseTitle: courseTitleOverrideValue,
certificates: [{
...certificatesDataMock.certificates[0],
signatories: [{
...certificatesDataMock.certificates[0].signatories[0],
name: signatoryNameValue,
}],
}],
};
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
userEvent.type(
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
courseTitleOverrideValue,
);
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
axiosMock.onPost(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
).reply(200, newCertificateData);
await executeThunk(updateCourseCertificate(courseId, newCertificateData), store.dispatch);
await waitFor(() => {
expect(getByDisplayValue(
certificatesDataMock.certificates[0].courseTitle + courseTitleOverrideValue,
)).toBeInTheDocument();
});
});
it('deletes a certificate and updates the store', async () => {
axiosMock.onDelete(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
).reply(200);
const { getByRole } = renderComponent();
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
const confirmDeleteModal = getByRole('dialog');
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
await waitFor(() => {
expect(store.getState().certificates.certificatesData.certificates.length).toBe(0);
});
});
it('updates loading status if delete fails', async () => {
axiosMock.onDelete(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
).reply(404);
const { getByRole } = renderComponent();
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
const confirmDeleteModal = getByRole('dialog');
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
await waitFor(() => {
expect(store.getState().certificates.savingStatus).toBe(RequestStatus.FAILED);
});
});
it('cancel edit form', async () => {
const { getByRole } = renderComponent();
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
});
});

View File

@@ -1,62 +0,0 @@
import { useSelector, useDispatch } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { MODE_STATES } from '../../data/constants';
import { getCourseTitle, getCertificates } from '../../data/selectors';
import { setMode } from '../../data/slice';
import { updateCourseCertificate, deleteCourseCertificate } from '../../data/thunks';
import { defaultCertificate } from '../../constants';
const useCertificateEditForm = (courseId) => {
const dispatch = useDispatch();
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
const courseTitle = useSelector(getCourseTitle);
const certificates = useSelector(getCertificates);
const handleCertificateSubmit = (values) => {
const signatoriesWithoutLocalIds = values.signatories.map(signatory => {
if (signatory.id && typeof signatory.id === 'string' && signatory.id.startsWith('local-')) {
const { id, ...rest } = signatory;
return rest;
}
return signatory;
});
const newValues = {
...values,
signatories: signatoriesWithoutLocalIds,
};
dispatch(updateCourseCertificate(courseId, newValues));
};
const handleCertificateUpdateCancel = (resetForm) => {
dispatch(setMode(MODE_STATES.view));
resetForm();
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCertificateDelete = (certificateId) => {
dispatch(deleteCourseCertificate(courseId, certificateId));
};
const initialValues = certificates.map((certificate) => ({
...certificate,
courseTitle: certificate.courseTitle || defaultCertificate.courseTitle,
signatories: certificate.signatories || defaultCertificate.signatories,
}));
return {
confirmOpen,
courseTitle,
certificates,
confirmClose,
initialValues,
isConfirmOpen,
handleCertificateDelete,
handleCertificateSubmit,
handleCertificateUpdateCancel,
};
};
export default useCertificateEditForm;

View File

@@ -1,29 +0,0 @@
import PropTypes from 'prop-types';
import { Stack } from '@openedx/paragon';
const CertificateSection = ({
title, actions, children, ...rest
}) => (
<section {...rest}>
<Stack className="justify-content-between mb-2.5" direction="horizontal">
<h2 className="lead section-title mb-0">{title}</h2>
{actions && actions}
</Stack>
<hr className="mt-0 mb-4" />
<div>
{children}
</div>
</section>
);
CertificateSection.defaultProps = {
children: null,
actions: null,
};
CertificateSection.propTypes = {
children: PropTypes.node,
actions: PropTypes.node,
title: PropTypes.string.isRequired,
};
export default CertificateSection;

View File

@@ -1,130 +0,0 @@
import PropTypes from 'prop-types';
import { Stack, Button, Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import CertificateSection from '../certificate-section/CertificateSection';
import Signatory from './signatory/Signatory';
import SignatoryForm from './signatory/SignatoryForm';
import useEditSignatory from './hooks/useEditSignatory';
import useCreateSignatory from './hooks/useCreateSignatory';
import messages from './messages';
const CertificateSignatories = ({
isForm,
editModes,
signatories,
arrayHelpers,
initialSignatoriesValues,
setFieldValue,
setEditModes,
handleBlur,
handleChange,
}) => {
const intl = useIntl();
const {
toggleEditSignatory,
handleDeleteSignatory,
handleCancelUpdateSignatory,
} = useEditSignatory({
arrayHelpers, editModes, setEditModes, setFieldValue, initialSignatoriesValues,
});
const { handleAddSignatory } = useCreateSignatory({ arrayHelpers });
return (
<CertificateSection
title={intl.formatMessage(messages.signatoriesSectionTitle)}
className="certificate-signatories"
>
<div>
<p className="mb-4.5">
{intl.formatMessage(messages.signatoriesRecommendation)}
</p>
<Stack gap="4.5">
{signatories.map(({
id, name, title, organization, signatureImagePath,
}, idx) => (
isForm || editModes[idx] ? (
<SignatoryForm
key={id}
index={idx}
isEdit={editModes[idx]}
name={name}
title={title}
organization={organization}
signatureImagePath={signatureImagePath}
handleChange={handleChange}
handleBlur={handleBlur}
setFieldValue={setFieldValue}
showDeleteButton={signatories.length > 1 && !editModes[idx]}
handleDeleteSignatory={() => handleDeleteSignatory(idx)}
{...(editModes[idx] && {
handleCancelUpdateSignatory: () => handleCancelUpdateSignatory(idx),
})}
/>
) : (
<Signatory
key={id}
index={idx}
name={name}
title={title}
organization={organization}
signatureImagePath={signatureImagePath}
handleEdit={() => toggleEditSignatory(idx)}
/>
)
))}
</Stack>
{isForm && (
<>
<Button variant="outline-primary" onClick={handleAddSignatory} className="w-100 mt-4">
{intl.formatMessage(messages.addSignatoryButton)}
</Button>
<Form.Control.Feedback>
<span className="x-small">{intl.formatMessage(messages.addSignatoryButtonDescription)}</span>
</Form.Control.Feedback>
</>
)}
</div>
</CertificateSection>
);
};
CertificateSignatories.defaultProps = {
handleChange: null,
handleBlur: null,
setFieldValue: null,
arrayHelpers: null,
isForm: false,
editModes: {},
setEditModes: null,
initialSignatoriesValues: null,
};
CertificateSignatories.propTypes = {
isForm: PropTypes.bool,
editModes: PropTypes.objectOf(PropTypes.bool),
initialSignatoriesValues: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
organization: PropTypes.string.isRequired,
signatureImagePath: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
})),
handleChange: PropTypes.func,
handleBlur: PropTypes.func,
setFieldValue: PropTypes.func,
setEditModes: PropTypes.func,
arrayHelpers: PropTypes.shape({
push: PropTypes.func,
remove: PropTypes.func,
}),
signatories: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
organization: PropTypes.string.isRequired,
signatureImagePath: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
})).isRequired,
};
export default CertificateSignatories;

View File

@@ -1,110 +0,0 @@
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { MODE_STATES } from '../data/constants';
import { signatoriesMock } from '../__mocks__';
import commonMessages from '../messages';
import messages from './messages';
import useEditSignatory from './hooks/useEditSignatory';
import useCreateSignatory from './hooks/useCreateSignatory';
import CertificateSignatories from './CertificateSignatories';
let store;
const mockArrayHelpers = {
push: jest.fn(),
remove: jest.fn(),
};
jest.mock('./hooks/useEditSignatory');
jest.mock('./hooks/useCreateSignatory');
const renderComponent = (props) => render(
<Provider store={store}>
<IntlProvider locale="en">
<CertificateSignatories {...props} />
</IntlProvider>,
</Provider>,
);
const defaultProps = {
signatories: signatoriesMock,
handleChange: jest.fn(),
handleBlur: jest.fn(),
setFieldValue: jest.fn(),
arrayHelpers: mockArrayHelpers,
isForm: true,
resetForm: jest.fn(),
editModes: {},
setEditModes: jest.fn(),
};
const initialState = {
certificates: {
certificatesData: {
certificates: [],
hasCertificateModes: true,
},
componentMode: MODE_STATES.create,
},
};
describe('CertificateSignatories', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
useEditSignatory.mockReturnValue({
toggleEditSignatory: jest.fn(),
handleDeleteSignatory: jest.fn(),
handleCancelUpdateSignatory: jest.fn(),
});
useCreateSignatory.mockReturnValue({
handleAddSignatory: jest.fn(),
});
});
afterEach(() => jest.clearAllMocks());
it('renders signatory components for each signatory', () => {
const { getByText } = renderComponent({ ...defaultProps, isForm: false });
signatoriesMock.forEach(signatory => {
expect(getByText(signatory.name)).toBeInTheDocument();
expect(getByText(signatory.title)).toBeInTheDocument();
expect(getByText(signatory.organization)).toBeInTheDocument();
});
});
it('adds a new signatory when add button is clicked', () => {
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
userEvent.click(getByText(messages.addSignatoryButton.defaultMessage));
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
});
it('calls remove for the correct signatory when delete icon is clicked', async () => {
const { getAllByRole } = renderComponent(defaultProps);
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
expect(deleteIcons.length).toBe(signatoriesMock.length);
userEvent.click(deleteIcons[0]);
waitFor(() => {
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
});
});
});

View File

@@ -1,15 +0,0 @@
import { v4 as uuid } from 'uuid';
const useCreateSignatory = ({ arrayHelpers }) => {
const handleAddSignatory = () => {
const getNewSignatory = () => ({
id: `local-${uuid()}`, name: '', title: '', organization: '', signatureImagePath: '',
});
arrayHelpers.push(getNewSignatory());
};
return { handleAddSignatory };
};
export default useCreateSignatory;

View File

@@ -1,33 +0,0 @@
const useEditSignatory = ({
arrayHelpers, editModes, setEditModes, setFieldValue, initialSignatoriesValues,
}) => {
const handleDeleteSignatory = (id) => {
arrayHelpers.remove(id);
if (editModes && setEditModes) {
const newEditModes = { ...editModes };
delete newEditModes[id];
setEditModes(newEditModes);
}
};
const toggleEditSignatory = (id) => {
setEditModes(prev => ({
...prev,
[id]: !prev[id],
}));
};
const handleCancelUpdateSignatory = (id) => {
const signatoryInitialValues = initialSignatoriesValues[id];
Object.keys(signatoryInitialValues).forEach(fieldKey => {
const fieldName = `signatories[${id}].${fieldKey}`;
setFieldValue(fieldName, signatoryInitialValues[fieldKey]);
});
toggleEditSignatory(id);
};
return { toggleEditSignatory, handleDeleteSignatory, handleCancelUpdateSignatory };
};
export default useEditSignatory;

View File

@@ -1,116 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
signatoryTitle: {
id: 'course-authoring.certificates.signatories.title',
defaultMessage: 'Signatory',
description: 'Title for a signatory',
},
signatoriesRecommendation: {
id: 'course-authoring.certificates.signatories.recommendation',
defaultMessage: 'It is strongly recommended that you include four or fewer signatories. If you include additional signatories, preview the certificate in Print View to ensure the certificate will print correctly on one page.',
description: 'A recommendation for the number of signatories to include on a certificate, emphasizing the importance of testing the print layout',
},
signatoriesSectionTitle: {
id: 'course-authoring.certificates.signatories.section.title',
defaultMessage: 'Certificate signatories',
description: 'Title for the section',
},
addSignatoryButton: {
id: 'course-authoring.certificates.signatories.add.signatory.button',
defaultMessage: 'Add additional signatory',
description: 'Button text for adding a new signatory to the certificate',
},
addSignatoryButtonDescription: {
id: 'course-authoring.certificates.signatories.add.signatory.button.description',
defaultMessage: '(Add signatories for a certificate)',
description: 'Helper text for the button used to add signatories',
},
nameLabel: {
id: 'course-authoring.certificates.signatories.name.label',
defaultMessage: 'Name',
description: 'Label for the input field where the signatory name is entered',
},
namePlaceholder: {
id: 'course-authoring.certificates.signatories.name.placeholder',
defaultMessage: 'Name of the signatory',
description: 'Placeholder text for the signatory name input field',
},
nameDescription: {
id: 'course-authoring.certificates.signatories.name.description',
defaultMessage: 'The name of this signatory as it should appear on certificates.',
description: 'Helper text under the name input field',
},
titleLabel: {
id: 'course-authoring.certificates.signatories.title.label',
defaultMessage: 'Title',
description: 'Label for the input field where the signatory title is entered',
},
titlePlaceholder: {
id: 'course-authoring.certificates.signatories.title.placeholder',
defaultMessage: 'Title of the signatory',
description: 'Placeholder text for the signatory title input field',
},
titleDescription: {
id: 'course-authoring.certificates.signatories.title.description',
defaultMessage: 'Titles more than 100 characters may prevent students from printing their certificate on a single page.',
description: 'Helper text under the title input field',
},
organizationLabel: {
id: 'course-authoring.certificates.signatories.organization.label',
defaultMessage: 'Organization',
description: 'Label for the input field where the signatory organization is entered',
},
organizationPlaceholder: {
id: 'course-authoring.certificates.signatories.organization.placeholder',
defaultMessage: 'Organization of the signatory',
description: 'Placeholder text for the signatory organization input field',
},
organizationDescription: {
id: 'course-authoring.certificates.signatories.organization.description',
defaultMessage: 'The organization that this signatory belongs to, as it should appear on certificates.',
description: 'Helper text under the organization input field',
},
imageLabel: {
id: 'course-authoring.certificates.signatories.image.label',
defaultMessage: 'Signature image',
description: 'Label for the input field where the signatory image is selected',
},
imagePlaceholder: {
id: 'course-authoring.certificates.signatories.image.placeholder',
defaultMessage: 'Path to signature image',
description: 'Placeholder text for the signatory image input field',
},
imageDescription: {
id: 'course-authoring.certificates.signatories.image.description',
defaultMessage: 'Image must be in PNG format',
description: 'Helper text under the image input field',
},
uploadImageButton: {
id: 'course-authoring.certificates.signatories.upload.image.button',
defaultMessage: '{uploadText} signature image',
description: 'Button text for adding or replacing a signature image',
},
uploadModal: {
id: 'course-authoring.certificates.signatories.upload.modal',
defaultMessage: 'Upload',
description: 'Option for button text for adding a new signature image',
},
uploadModalReplace: {
id: 'course-authoring.certificates.signatories.upload.modal.replace',
defaultMessage: 'Replace',
description: 'Option for button text for replacing an existing signature image',
},
deleteSignatoryConfirmation: {
id: 'course-authoring.certificates.signatories.confirm-modal',
defaultMessage: 'Delete "{name}" from the list of signatories?',
description: 'Title for the confirmation modal when a user attempts to delete a signatory, where "{name}" is the name of the signatory to be deleted',
},
deleteSignatoryConfirmationMessage: {
id: 'course-authoring.certificates.signatories.confirm-modal.message',
defaultMessage: 'This action cannot be undone.',
description: 'A warning message that emphasizes the permanence of the delete action for a signatory',
},
});
export default messages;

View File

@@ -1,66 +0,0 @@
import PropTypes from 'prop-types';
import {
Image, Icon, Stack, IconButtonWithTooltip,
} from '@openedx/paragon';
import {
EditOutline as EditOutlineIcon,
} from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import commonMessages from '../../messages';
import messages from '../messages';
const Signatory = ({
index,
name,
title,
organization,
signatureImagePath,
handleEdit,
}) => {
const intl = useIntl();
return (
<div className="bg-light-200 p-2.5 signatory" data-testid="signatory">
<Stack className="signatory__header" gap={3}>
<h3 className="section-title m-0">{`${intl.formatMessage(messages.signatoryTitle)} ${index + 1}`}</h3>
<Stack className="signatory__text-fields-stack">
<p className="signatory__text"><b>{intl.formatMessage(messages.nameLabel)}</b> {name}</p>
<p className="signatory__text"><b>{intl.formatMessage(messages.titleLabel)}</b> {title}</p>
<p className="signatory__text"><b>{intl.formatMessage(messages.organizationLabel)}</b> {organization}</p>
</Stack>
</Stack>
<IconButtonWithTooltip
className="signatory__action-button"
src={EditOutlineIcon}
iconAs={Icon}
alt={intl.formatMessage(commonMessages.editTooltip)}
tooltipContent={<div>{intl.formatMessage(commonMessages.editTooltip)}</div>}
onClick={handleEdit}
/>
<div className="signatory__image-container">
{signatureImagePath && (
<Image
src={`${getConfig().STUDIO_BASE_URL}${signatureImagePath}`}
fluid
alt={intl.formatMessage(messages.imageLabel)}
className="signatory__image"
/>
)}
</div>
</div>
);
};
Signatory.propTypes = {
name: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
organization: PropTypes.string.isRequired,
signatureImagePath: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
handleEdit: PropTypes.func.isRequired,
};
export default Signatory;

View File

@@ -1,45 +0,0 @@
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import { signatoriesMock } from '../../__mocks__';
import commonMessages from '../../messages';
import messages from '../messages';
import Signatory from './Signatory';
const mockHandleEdit = jest.fn();
const renderSignatory = (props) => render(
<IntlProvider locale="en">
<Signatory {...props} />
</IntlProvider>,
);
const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: 0 };
describe('Signatory Component', () => {
it('renders in MODE_STATES.view mode', () => {
const {
getByText, queryByText, getByAltText, getByRole,
} = renderSignatory(defaultProps);
const signatureImage = getByAltText(messages.imageLabel.defaultMessage);
const sectionTitle = getByRole('heading', { level: 3, name: `${messages.signatoryTitle.defaultMessage} ${defaultProps.index + 1}` });
expect(sectionTitle).toBeInTheDocument();
expect(getByText(defaultProps.name)).toBeInTheDocument();
expect(getByText(defaultProps.title)).toBeInTheDocument();
expect(getByText(defaultProps.organization)).toBeInTheDocument();
expect(signatureImage).toBeInTheDocument();
expect(signatureImage).toHaveAttribute('src', expect.stringContaining(defaultProps.signatureImagePath));
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
});
it('calls handleEdit when the edit button is clicked', () => {
const { getByRole } = renderSignatory(defaultProps);
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
userEvent.click(editButton);
expect(mockHandleEdit).toHaveBeenCalled();
});
});

View File

@@ -1,200 +0,0 @@
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import {
Image, Icon, Stack, IconButtonWithTooltip, FormLabel, Form, Button, useToggle,
} from '@openedx/paragon';
import { DeleteOutline as DeleteOutlineIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import ModalDropzone from '../../../generic/modal-dropzone/ModalDropzone';
import ModalNotification from '../../../generic/modal-notification';
import { updateSavingImageStatus } from '../../data/slice';
import commonMessages from '../../messages';
import messages from '../messages';
const SignatoryForm = ({
index,
name,
title,
isEdit,
handleBlur,
organization,
handleChange,
setFieldValue,
showDeleteButton,
signatureImagePath,
handleDeleteSignatory,
handleCancelUpdateSignatory,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const [isOpen, open, close] = useToggle(false);
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
const handleImageUpload = (newImagePath) => {
setFieldValue(`signatories[${index}].signatureImagePath`, newImagePath);
};
const handleSavingStatusDispatch = (status) => {
dispatch(updateSavingImageStatus(status));
};
const formData = [
{
labelText: intl.formatMessage(messages.nameLabel),
value: name,
name: `signatories[${index}].name`,
placeholder: intl.formatMessage(messages.namePlaceholder),
feedback: intl.formatMessage(messages.nameDescription),
onChange: handleChange,
onBlur: handleBlur,
},
{
as: 'textarea',
labelText: intl.formatMessage(messages.titleLabel),
value: title,
name: `signatories[${index}].title`,
placeholder: intl.formatMessage(messages.titlePlaceholder),
feedback: intl.formatMessage(messages.titleDescription),
onChange: handleChange,
onBlur: handleBlur,
},
{
labelText: intl.formatMessage(messages.organizationLabel),
value: organization,
name: `signatories[${index}].organization`,
placeholder: intl.formatMessage(messages.organizationPlaceholder),
feedback: intl.formatMessage(messages.organizationDescription),
onChange: handleChange,
onBlur: handleBlur,
},
];
const uploadReplaceText = intl.formatMessage(
messages.uploadImageButton,
{
uploadText: signatureImagePath
? intl.formatMessage(messages.uploadModalReplace)
: intl.formatMessage(messages.uploadModal),
},
);
return (
<div className="bg-light-200 p-2.5 signatory-form" data-testid="signatory-form">
<Stack className="justify-content-between mb-4" direction="horizontal">
<h3 className="section-title">{`${intl.formatMessage(messages.signatoryTitle)} ${index + 1}`}</h3>
<Stack direction="horizontal" gap="2">
{showDeleteButton && (
<IconButtonWithTooltip
src={DeleteOutlineIcon}
iconAs={Icon}
alt={intl.formatMessage(commonMessages.deleteTooltip)}
tooltipContent={<div>{intl.formatMessage(commonMessages.deleteTooltip)}</div>}
onClick={confirmOpen}
/>
)}
</Stack>
</Stack>
<Stack gap="4">
{formData.map(({ labelText, feedback, ...rest }) => (
<Form.Group className="m-0" key={labelText}>
<FormLabel>{labelText}</FormLabel>
<Form.Control {...rest} className="m-0" />
<Form.Control.Feedback>
<span className="x-small">{feedback}</span>
</Form.Control.Feedback>
</Form.Group>
))}
<Form.Group className="m-0">
<FormLabel> {intl.formatMessage(messages.imageLabel)}</FormLabel>
{signatureImagePath && (
<Image
src={`${getConfig().STUDIO_BASE_URL}${signatureImagePath}`}
fluid
alt={intl.formatMessage(messages.imageLabel)}
className="signatory__image"
/>
)}
<Stack direction="horizontal" className="align-items-baseline">
<Stack>
<Form.Control
readOnly
value={signatureImagePath}
name={`signatories[${index}].signatureImagePath`}
placeholder={intl.formatMessage(messages.imagePlaceholder)}
/>
<Form.Control.Feedback>
<span className="x-small">{intl.formatMessage(messages.imageDescription)}</span>
</Form.Control.Feedback>
</Stack>
<Button onClick={open}>{uploadReplaceText}</Button>
</Stack>
</Form.Group>
</Stack>
{isEdit && (
<Stack direction="horizontal" gap="2" className="mt-4">
<Button type="submit">
{intl.formatMessage(commonMessages.saveTooltip)}
</Button>
<Button
variant="outline-primary"
onClick={() => handleCancelUpdateSignatory()}
>
{intl.formatMessage(commonMessages.cardCancel)}
</Button>
</Stack>
)}
<ModalDropzone
isOpen={isOpen}
onClose={close}
onCancel={close}
onChange={handleImageUpload}
fileTypes={['png']}
onSavingStatus={handleSavingStatusDispatch}
imageHelpText={intl.formatMessage(messages.imageDescription)}
modalTitle={uploadReplaceText}
/>
<ModalNotification
isOpen={isConfirmOpen}
title={intl.formatMessage(messages.deleteSignatoryConfirmation, { name })}
message={intl.formatMessage(messages.deleteSignatoryConfirmationMessage)}
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
handleCancel={confirmClose}
handleAction={() => {
confirmClose();
handleDeleteSignatory();
}}
/>
</div>
);
};
SignatoryForm.defaultProps = {
isEdit: false,
handleChange: null,
handleBlur: null,
handleDeleteSignatory: null,
setFieldValue: null,
handleCancelUpdateSignatory: null,
};
SignatoryForm.propTypes = {
name: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
organization: PropTypes.string.isRequired,
showDeleteButton: PropTypes.bool.isRequired,
signatureImagePath: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
isEdit: PropTypes.bool,
handleChange: PropTypes.func,
handleBlur: PropTypes.func,
setFieldValue: PropTypes.func,
handleDeleteSignatory: PropTypes.func,
handleCancelUpdateSignatory: PropTypes.func,
};
export default SignatoryForm;

View File

@@ -1,161 +0,0 @@
import { render, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../../store';
import { signatoriesMock } from '../../__mocks__';
import commonMessages from '../../messages';
import messages from '../messages';
import SignatoryForm from './SignatoryForm';
let store;
const renderSignatory = (props) => render(
<Provider store={store}>
<IntlProvider locale="en">
<SignatoryForm {...props} />
</IntlProvider>,
</Provider>,
);
const initialState = {
certificates: {
certificatesData: {
certificates: [],
hasCertificateModes: true,
},
},
};
const defaultProps = {
...signatoriesMock[0],
showDeleteButton: true,
isEdit: true,
handleChange: jest.fn(),
handleBlur: jest.fn(),
setFieldValue: jest.fn(),
handleDeleteSignatory: jest.fn(),
handleCancelUpdateSignatory: jest.fn(),
};
describe('Signatory Component', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
});
it('renders in CREATE mode', () => {
const { queryByTestId, getByPlaceholderText } = renderSignatory(defaultProps);
expect(queryByTestId('signatory-view')).not.toBeInTheDocument();
expect(getByPlaceholderText(messages.namePlaceholder.defaultMessage)).toBeInTheDocument();
});
it('handles input change', async () => {
const handleChange = jest.fn();
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const newInputValue = 'Jane Doe';
userEvent.type(input, newInputValue, { name: 'signatories[0].name' });
waitFor(() => {
expect(handleChange).toHaveBeenCalledWith(expect.anything());
expect(input.value).toBe(newInputValue);
});
});
it('opens image upload modal on button click', () => {
const { getByRole, queryByRole } = renderSignatory(defaultProps);
const replaceButton = getByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
);
expect(queryByRole('presentation')).not.toBeInTheDocument();
userEvent.click(replaceButton);
expect(getByRole('presentation')).toBeInTheDocument();
});
it('shows confirm modal on delete icon click', async () => {
const { getByLabelText, getByText } = renderSignatory(defaultProps);
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
userEvent.click(deleteIcon);
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
});
it('cancels deletion of a signatory', () => {
const { getByRole } = renderSignatory(defaultProps);
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
userEvent.click(deleteIcon);
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
userEvent.click(cancelButton);
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
});
it('renders without save button with isEdit=false', () => {
const { queryByRole } = renderSignatory({ ...defaultProps, isEdit: false });
const deleteIcon = queryByRole('button', { name: commonMessages.saveTooltip.defaultMessage });
expect(deleteIcon).not.toBeInTheDocument();
});
it('renders button with Replace text if there is a signatureImagePath', () => {
const newProps = {
...defaultProps,
isEdit: false,
};
const { getByRole, queryByRole } = renderSignatory(newProps);
const replaceButton = getByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
);
const uploadButton = queryByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModal.defaultMessage) },
);
expect(replaceButton).toBeInTheDocument();
expect(uploadButton).not.toBeInTheDocument();
});
it('renders button with Upload text if there is no signatureImagePath', () => {
const newProps = {
...defaultProps,
signatureImagePath: '',
isEdit: false,
};
const { getByRole, queryByRole } = renderSignatory(newProps);
const uploadButton = getByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModal.defaultMessage) },
);
const replaceButton = queryByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
);
expect(uploadButton).toBeInTheDocument();
expect(replaceButton).not.toBeInTheDocument();
});
});

View File

@@ -1,17 +0,0 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Card } from '@openedx/paragon';
import messages from '../messages';
const CertificateWithoutModes = () => {
const intl = useIntl();
return (
<Card>
<Card.Section className="d-flex justify-content-center">
<span className="small">{intl.formatMessage(messages.withoutModesText)}</span>
</Card.Section>
</Card>
);
};
export default CertificateWithoutModes;

View File

@@ -1,43 +0,0 @@
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 messages from '../messages';
import WithoutModes from './CertificateWithoutModes';
const courseId = 'course-123';
let store;
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<WithoutModes courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);
describe('CertificateWithoutModes', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('renders correctly', async () => {
const { getByText, queryByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.headingActionsPreview.defaultMessage)).not.toBeInTheDocument();
expect(queryByText(messages.headingActionsDeactivate.defaultMessage)).not.toBeInTheDocument();
});
});
});

View File

@@ -1,69 +0,0 @@
import PropTypes from 'prop-types';
import { Card, Stack } from '@openedx/paragon';
import { Formik, Form, FieldArray } from 'formik';
import CertificateDetails from '../certificate-details/CertificateDetails';
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
import useCertificatesList from './hooks/useCertificatesList';
const CertificatesList = ({ courseId }) => {
const {
editModes,
courseTitle,
certificates,
courseNumber,
initialValues,
courseNumberOverride,
setEditModes,
handleSubmit,
} = useCertificatesList(courseId);
return (
<>
{certificates.map((certificate, idx) => (
<Formik initialValues={initialValues[idx]} onSubmit={handleSubmit} key={certificate.id}>
{({
values, handleChange, handleBlur, setFieldValue,
}) => (
<Form className="certificates-card-form" data-testid="certificates-list">
<Card>
<Card.Section>
<Stack gap="2">
<CertificateDetails
detailsCourseTitle={courseTitle}
detailsCourseNumber={courseNumber}
courseNumberOverride={courseNumberOverride}
courseTitleOverride={certificate.courseTitle}
certificateId={certificate.id}
/>
<FieldArray
name="signatories"
render={arrayHelpers => (
<CertificateSignatories
signatories={values.signatories}
arrayHelpers={arrayHelpers}
editModes={editModes}
initialSignatoriesValues={initialValues[idx].signatories}
handleChange={handleChange}
handleBlur={handleBlur}
setFieldValue={setFieldValue}
setEditModes={setEditModes}
/>
)}
/>
</Stack>
</Card.Section>
</Card>
</Form>
)}
</Formik>
))}
</>
);
};
CertificatesList.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CertificatesList;

View File

@@ -1,133 +0,0 @@
import { Provider } from 'react-redux';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { executeThunk } from '../../utils';
import initializeStore from '../../store';
import { MODE_STATES } from '../data/constants';
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api';
import { fetchCertificates, updateCourseCertificate } from '../data/thunks';
import { certificatesMock, certificatesDataMock } from '../__mocks__';
import signatoryMessages from '../certificate-signatories/messages';
import messages from '../messages';
import CertificatesList from './CertificatesList';
let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = () => render(
<Provider store={store}>
<IntlProvider locale="en">
<CertificatesList courseId="course-123" />
</IntlProvider>
</Provider>,
);
describe('CertificatesList Component', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, {
...certificatesDataMock,
certificates: certificatesMock,
});
await executeThunk(fetchCertificates(courseId), store.dispatch);
});
it('renders each certificate', () => {
const { getByText } = renderComponent();
certificatesMock.forEach((certificate) => {
certificate.signatories.forEach((signatory) => {
expect(getByText(signatory.name)).toBeInTheDocument();
expect(getByText(signatory.title)).toBeInTheDocument();
expect(getByText(signatory.organization)).toBeInTheDocument();
});
});
});
it('update certificate', async () => {
const {
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
} = renderComponent();
const signatoryNameValue = 'Updated signatory name';
const newCertificateData = {
...certificatesDataMock,
certificates: [{
...certificatesMock[0],
signatories: [{
...certificatesMock[0].signatories[0],
name: signatoryNameValue,
}],
}],
};
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
userEvent.click(editButtons[1]);
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
userEvent.clear(nameInput);
userEvent.type(nameInput, signatoryNameValue);
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
axiosMock
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
.reply(200, newCertificateData);
await executeThunk(updateCourseCertificate(courseId, newCertificateData), store.dispatch);
await waitFor(() => {
expect(getByText(newCertificateData.certificates[0].signatories[0].name)).toBeInTheDocument();
expect(queryByText(certificatesDataMock.certificates[0].signatories[0].name)).not.toBeInTheDocument();
});
});
it('toggle edit signatory', async () => {
const {
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
} = renderComponent();
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
expect(editButtons.length).toBe(3);
userEvent.click(editButtons[1]);
await waitFor(() => {
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
});
userEvent.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
await waitFor(() => {
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
});
});
it('toggle certificate edit all', async () => {
const { getByTestId } = renderComponent();
const detailsSection = getByTestId('certificate-details');
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
userEvent.click(editButton);
await waitFor(() => {
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
});
});
});

View File

@@ -1,45 +0,0 @@
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { MODE_STATES } from '../../data/constants';
import {
getCourseTitle, getCourseNumber, getCourseNumberOverride, getCertificates,
} from '../../data/selectors';
import { updateCourseCertificate } from '../../data/thunks';
import { setMode } from '../../data/slice';
import { defaultCertificate } from '../../constants';
const useCertificatesList = (courseId) => {
const dispatch = useDispatch();
const certificates = useSelector(getCertificates);
const courseTitle = useSelector(getCourseTitle);
const courseNumber = useSelector(getCourseNumber);
const courseNumberOverride = useSelector(getCourseNumberOverride);
const [editModes, setEditModes] = useState({});
const initialValues = certificates.map((certificate) => ({
...certificate,
courseTitle: certificate.courseTitle || defaultCertificate.courseTitle,
signatories: certificate.signatories || defaultCertificate.signatories,
}));
const handleSubmit = async (values) => {
await dispatch(updateCourseCertificate(courseId, values));
setEditModes({});
dispatch(setMode(MODE_STATES.view));
};
return {
editModes,
courseTitle,
certificates,
courseNumber,
initialValues,
courseNumberOverride,
setEditModes,
handleSubmit,
};
};
export default useCertificatesList;

View File

@@ -1,12 +0,0 @@
import { v4 as uuid } from 'uuid';
export const defaultCertificate = {
courseTitle: '',
signatories: [{
id: `local-${uuid()}`,
name: '',
title: '',
organization: '',
signatureImagePath: '',
}],
};

View File

@@ -1,88 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { prepareCertificatePayload } from '../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
/**
* Gets certificates for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCertificates(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCertificatesApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Create course certificate.
* @param {string} courseId
* @param {object} certificatesData
* @returns {Promise<Object>}
*/
export async function createCertificate(courseId, certificatesData) {
const { data } = await getAuthenticatedHttpClient()
.post(
getCertificateApiUrl(courseId),
prepareCertificatePayload(certificatesData),
);
return camelCaseObject(data);
}
/**
* Update course certificate.
* @param {string} courseId
* @param {object} certificateData
* @returns {Promise<Object>}
*/
export async function updateCertificate(courseId, certificateData) {
const { data } = await getAuthenticatedHttpClient()
.post(
getUpdateCertificateApiUrl(courseId, certificateData.id),
prepareCertificatePayload(certificateData),
);
return camelCaseObject(data);
}
/**
* Delete course certificate.
* @param {string} courseId
* @param {object} certificateId
* @returns {Promise<Object>}
*/
export async function deleteCertificate(courseId, certificateId) {
const { data } = await getAuthenticatedHttpClient()
.delete(
getUpdateCertificateApiUrl(courseId, certificateId),
);
return data;
}
/**
* Activate/deactivate course certificate.
* @param {string} courseId
* @param {object} activationStatus
* @returns {Promise<Object>}
*/
export async function updateActiveStatus(path, activationStatus) {
const body = {
is_active: activationStatus,
};
const { data } = await getAuthenticatedHttpClient()
.post(
getUpdateCertificateActiveStatusApiUrl(path),
body,
);
return camelCaseObject(data);
}

View File

@@ -1,12 +0,0 @@
export const MODE_STATES = {
noModes: 'no_modes',
noCertificates: 'no_certificates',
view: 'view',
editAll: 'edit_all',
create: 'create',
};
export const ACTIVATION_MESSAGES = {
activating: 'Activating',
deactivating: 'Deactivating',
};

View File

@@ -1,22 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
export const getLoadingStatus = (state) => state.certificates.loadingStatus;
export const getSavingStatus = (state) => state.certificates.savingStatus;
export const getSavingImageStatus = (state) => state.certificates.savingImageStatus;
export const getErrorMessage = (state) => state.certificates.errorMessage;
export const getSendRequestErrors = (state) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = state => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = state => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = state => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = state => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = state => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = state => state.certificates.certificatesData.isActive;
export const getComponentMode = state => state.certificates.componentMode;
export const getCourseNumber = state => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = state => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = state => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -1,62 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
import { MODE_STATES } from './constants';
const slice = createSlice({
name: 'certificates',
initialState: {
certificatesData: {},
componentMode: MODE_STATES.noModes,
loadingStatus: RequestStatus.PENDING,
savingStatus: '',
savingImageStatus: '',
errorMessage: '',
},
reducers: {
updateSavingStatus: (state, { payload }) => {
const { status, errorMessage } = payload;
state.savingStatus = status;
state.errorMessage = errorMessage;
},
updateSavingImageStatus: (state, { payload }) => {
state.savingImageStatus = payload.status;
},
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
fetchCertificatesSuccess: (state, { payload }) => {
Object.assign(state.certificatesData, payload);
},
createCertificateSuccess: (state, action) => {
state.certificatesData.certificates.push(action.payload);
},
updateCertificateSuccess: (state, action) => {
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
if (index !== -1) {
state.certificatesData.certificates[index] = action.payload;
}
},
setMode: (state, action) => {
state.componentMode = action.payload;
},
deleteCertificateSuccess: (state) => {
state.certificatesData.certificates = [];
},
},
});
export const {
setMode,
updateSavingStatus,
updateLoadingStatus,
updateSavingImageStatus,
fetchCertificatesSuccess,
createCertificateSuccess,
updateCertificateSuccess,
deleteCertificateSuccess,
} = slice.actions;
export const { reducer } = slice;

View File

@@ -1,117 +0,0 @@
import { RequestStatus } from '../../data/constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../../generic/processing-notification/data/slice';
import { handleResponseErrors } from '../../generic/saving-error-alert';
import { NOTIFICATION_MESSAGES } from '../../constants';
import {
getCertificates,
createCertificate,
updateCertificate,
deleteCertificate,
updateActiveStatus,
} from './api';
import {
fetchCertificatesSuccess,
updateLoadingStatus,
updateSavingStatus,
createCertificateSuccess,
updateCertificateSuccess,
deleteCertificateSuccess,
} from './slice';
import { ACTIVATION_MESSAGES } from './constants';
export function fetchCertificates(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const certificates = await getCertificates(courseId);
dispatch(fetchCertificatesSuccess(certificates));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}
export function createCourseCertificate(courseId, certificate) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
const certificateValues = await createCertificate(courseId, certificate);
dispatch(createCertificateSuccess(certificateValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
return handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {
dispatch(hideProcessingNotification());
}
};
}
export function updateCourseCertificate(courseId, certificate) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
const certificatesValues = await updateCertificate(courseId, certificate);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateCertificateSuccess(certificatesValues));
return true;
} catch (error) {
return handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {
dispatch(hideProcessingNotification());
}
};
}
export function deleteCourseCertificate(courseId, certificateId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
try {
const certificatesValues = await deleteCertificate(courseId, certificateId);
dispatch(deleteCertificateSuccess(certificatesValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
return handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {
dispatch(hideProcessingNotification());
}
};
}
export function updateCertificateActiveStatus(courseId, path, activationStatus) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(
activationStatus ? ACTIVATION_MESSAGES.activating : ACTIVATION_MESSAGES.deactivating,
));
try {
await updateActiveStatus(path, activationStatus);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(fetchCertificates(courseId));
return true;
} catch (error) {
return handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {
dispatch(hideProcessingNotification());
}
};
}

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