Compare commits

..

7 Commits

Author SHA1 Message Date
Lucas Calviño
a2bfb1fb7b [ROLES-47] Permissions definitions for Schedule & Details (#854) 2024-03-08 15:07:56 -03:00
hilary sinkoff
c754a5e519 feat: Add permissions checks for group_configuration, grading, outline (#829)
* feat: update header options for access control, course outline access checks, grade-settings access checks, and view only for grading page
2024-02-16 18:21:43 +00:00
hsinkoff
1e9146a5b9 fix: update tests missed on rebase 2024-02-16 18:21:43 +00:00
hilary sinkoff
a518fada29 feat: Access for Import/Export Pages Based on Permissions (#804)
* feat: import/export page access based on permissions
2024-02-16 18:21:43 +00:00
Lucas Calviño
69d9ea318e docs: Add permissions check architecture 2024-02-16 18:21:43 +00:00
Lucas Calviño
e74e1ff5aa feat: [ROLES-41] Permission checks (#718)
* feat: Permission check (#718)

This feature allows to fetch the User Permissions and check on every
page for the right permission to allow the user to make actions or even
to see the content depending on the page and the permission.

Co-authored-by: hsinkoff <hsinkoff@2u.com>
2024-02-16 18:21:43 +00:00
Lucas Calviño
1137dae97a feat: [ROLES-26] Helper function for ingesting permission data (#670)
* feat: Add UserPermissions api, specs, feature flag api
2024-02-16 18:21:43 +00:00
2058 changed files with 51103 additions and 177830 deletions

16
.env
View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
@@ -31,22 +30,13 @@ 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_COURSE_IMPORT_IN_LIBRARY=false
ENABLE_TAGGING_TAXONOMY_PAGES=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
AI_TRANSLATIONS_BASE_URL=''

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,23 +32,13 @@ 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_COURSE_IMPORT_IN_LIBRARY=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_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'

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,17 +28,9 @@ 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_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
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"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'

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

@@ -1,6 +1,5 @@
const path = require('path');
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig(
'eslint',
@@ -11,34 +10,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',
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@edx/frontend-platform/i18n'],
importNames: ['injectIntl'],
message: "Use 'useIntl' hook instead of injectIntl.",
},
],
}],
},
settings: {
// Import URLs should be resolved using aliases
'import/resolver': {
webpack: {
config: path.resolve(__dirname, 'webpack.dev.config.js'),
},
},
},
overrides: [
{
files: ['plugins/**/*.test.jsx'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
],
},
);

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

@@ -1,38 +0,0 @@
## Description
Describe what this pull request changes, and why. Include implications for people using this change.
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
[OEP-19](https://open-edx-proposals.readthedocs.io/en/latest/oep-0019-bp-developer-documentation.html), and can be linked here.
Useful information to include:
- Which user roles will this change impact? Common user roles are "Learner", "Course Author",
"Developer", and "Operator".
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
## Supporting information
Link to other information about the change, such as GitHub issues, or Discourse discussions.
Be sure to check they are publicly readable, or if not, repeat the information here.
## Testing instructions
Please provide detailed step-by-step instructions for manually testing this change.
## Other information
Include anything else that will help reviewers and consumers understand the change.
- Does this change depend on other changes elsewhere?
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
## Best Practices Checklist
We're trying to move away from some deprecated patterns in this codebase. Please
check if your PR meets these recommendations before asking for a review:
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code.
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`

View File

@@ -1,18 +0,0 @@
# Run the workflow that adds new tickets that are labelled "release testing"
# to the org-wide BTR project board
name: Add release testing issues to the BTR project board
on:
issues:
types: [labeled]
# This workflow is triggered when an issue is labeled with 'release testing'.
# It adds the issue to the BTR project and applies the 'needs triage' label
# if it doesn't already have it.
jobs:
handle-release-testing:
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}

View File

@@ -1,15 +0,0 @@
name: Trigger to add Issue or PR to a Core Contributor project board
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
add-to-cc-board:
if: github.event.label.name == 'Core Contributor assignee'
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
with:
board_name: cc-frontend-apps
secrets:
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}

View File

@@ -9,31 +9,15 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- 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@v5
with:
name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v5
- name: Download code coverage results
uses: actions/download-artifact@v6
with:
pattern: code-coverage-report
path: coverage
merge-multiple: true
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

7
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24
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"]
}],

9
.tx/config Normal file
View File

@@ -0,0 +1,9 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-course-authoring]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -1,3 +1,7 @@
transifex_resource = frontend-app-course-authoring
export TRANSIFEX_RESOURCE = ${transifex_resource}
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
@@ -29,18 +33,37 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Pulls translations using atlas.
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
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
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
@@ -53,7 +76,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
@@ -165,7 +129,32 @@ Feature: New React XBlock Editors
.. image:: ./docs/readme-images/feature-problem-editor.png
New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
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
==================================
@@ -179,6 +168,10 @@ Requirements
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* ``edx-platform`` Feature flags:
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
Configuration
@@ -203,6 +196,16 @@ Feature: Advanced Settings
.. image:: ./docs/readme-images/feature-advanced-settings.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
Feature: Files & Uploads
@@ -210,6 +213,16 @@ Feature: Files & Uploads
.. image:: ./docs/readme-images/feature-files-uploads.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
Feature: Course Updates
@@ -217,11 +230,26 @@ Feature: Course Updates
.. image:: ./docs/readme-images/feature-course-updates.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
Feature: Import/Export Pages
============================
.. image:: ./docs/readme-images/feature-export.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
Feature: Tagging/Taxonomy Pages
================================
@@ -239,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:
@@ -291,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
====================
@@ -327,20 +338,6 @@ For more information about these options, see the `Getting Help`_ page.
.. _Getting Help: https://openedx.org/community/connect
Legacy Studio
*************
If you would like to use legacy studio for certain features, you can set the following waffle flags in ``edx-platform``:
* ``legacy_studio.text_editor``: loads the legacy HTML Xblock editor when editing a text block
* ``legacy_studio.video_editor``: loads the legacy Video editor when editing a video block
* ``legacy_studio.problem_editor``: loads the legacy Problem editor when editing a problem block
* ``legacy_studio.advanced_settings``: Advanced Settings page
* ``legacy_studio.updates``: Updates page
* ``legacy_studio.export``: Export page
* ``legacy_studio.import``: Import page
* ``legacy_studio.files_uploads``: Files page
* ``legacy_studio.exam_settings``: loads the legacy Exam Settings
License
*******

View File

@@ -1,19 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-authoring'
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
links:
- url: "https://github.com/openedx/frontend-app-authoring"
title: "Frontend app authoring"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: user:bradenmacdonald
type: 'website'
lifecycle: 'production'

View File

@@ -10,6 +10,4 @@ coverage:
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/container-comparison/data/api.mock.ts"
- "src/index.js"

View File

@@ -0,0 +1,21 @@
Background
==========
This is a summary of the technical decisions made for the Roles & Permissions
project as we implemented the permissions check system in the ``frontend-app-course-authoring``.
The ``frontend-app-course-authoring`` was already created when the
Permissions project started, so it already had a coding style, store
management and its own best practices.
We aligned to these requirements.
Frontend Architecture
---------------------
* `Readme <https://github.com/openedx/frontend-app-course-authoring#readme>`__
* Developing locally:
https://github.com/openedx/frontend-app-course-authoring#readme
* **React.js** application ``version: 17.0.2``
* **Redux** store management ``version: 4.0.5``
* It uses **Thunk** for adding to Redux the ability of returning
functions.

View File

@@ -0,0 +1,66 @@
Local Development & Testing
===========================
Backend
~~~~~~~
The backend endpoints lives in the ``edx-platform`` repo, specifically
in this file: ``openedx/core/djangoapps/course_roles/views.py``
For quickly testing the different permissions and the flag change you
can tweak the values directly in the above file.
* ``UserPermissionsView`` is in charge of returning the permissions, so
for sending the permissions you want to check, you could do something
like this:
.. code-block:: python
permissions = {
'user_id': user_id,
'course_key': str(course_key),
#'permissions': sorted(permission.value.name for permission in permissions_set),
'permissions': ['the_permissions_being_tested']
}
return Response(permissions)
By making this change, the permissions object will be bypassed and
send a plain array with the specific permissions being tested.
* ``UserPermissionsFlagView`` is in charge of returning the flag value
(boolean), so you can easily turn the boolean like this:
.. code-block:: python
#payload = {'enabled': use_permission_checks()}
payload = {'enabled': true}
return Response(payload)
Flags
~~~~~
Youll need at least 2 flags to start:
* The basic flag for enabling the backend permissions system: ``course_roles.use_permission_checks``.
* The flag for enabling the page you want to test, for instance Course Team: ``contentstore.new_studio_mfe.use_new_course_team_page``.
All flags for enabling pages in the Studio MFE are listed
`here <https://2u-internal.atlassian.net/wiki/x/CQCcHQ>`__.
Flags can be added by:
^^^^^^^^^^^^^^^^^^^^^^
* Enter to ``http://localhost:18000/admin/``.
* Log in as an admin.
* Go to ``http://localhost:18000/admin/waffle/flag/``.
* Click on ``+ADD FLAG`` button at the top right of the page and add
the flag you need.
Testing
~~~~~~~
For unit testing you run the npm script included in the ``package.json``, you can use it plainly for testing all components at once: ``npm run test``.
Or you can test one file at a time: ``npm run test path-to-file``.

View File

@@ -0,0 +1,62 @@
Permissions Check implementation
================================
For the permissions checks we basically hit 2 endpoints from the
``edx-platform`` repo:
* **Permissions**:
``/api/course_roles/v1/user_permissions/?course_id=[course_key]&user_id=[user_id]``
Which will return this structure:
.. code-block:: js
permissions = {
'user_id': [user_id],
'course_key': [course_key],
'permissions': ['permission_1', 'permission_2']
}
* **Permissions enabled** (which returns the boolean flag value): ``/api/course_roles/v1/user_permissions/enabled/``
The basic scaffolding for *fetching* and *storing* the permissions is located in the ``src/generic/data`` folder:
* ``api.js``: Exposes the ``getUserPermissions(courseId)`` and ``getUserPermissionsEnabledFlag()`` methods.
* ``selectors.js``: Exposes the selectors ``getUserPermissions`` and ``getUserPermissionsEnabled`` to be used by ``useSelector()``.
* ``slice.js``: Exposes the ``updateUserPermissions`` and ``updateUserPermissionsEnabled`` methods that will be used by the ``thunks.js`` file for dispatching and storing.
* ``thunks.js``: Exposes the ``fetchUserPermissionsQuery(courseId)`` and ``fetchUserPermissionsEnabledFlag()`` methods for fetching.
In the ``src/generic/hooks.jsx`` we created a custom hook for exposing the ``checkPermission`` method, so that way we can call
this method from any page and pass the permission we want to check for the current logged in user.
In this example on the ``src/course-team/CourseTeam.jsx`` page, we use the hook for checking if the current user has the ``manage_all_users``
permission:
1. First, we import the hook (line 1).
2. Then we call the ``checkPermission`` method and assign it to a const (line 2).
3. Finally we use the const for showing or hiding a button (line 8).
.. code-block:: js
1. import { useUserPermissions } from '../generic/hooks';
2. const hasManageAllUsersPerm = checkPermission('manage_all_users');
3. <SubHeader
4. title={intl.formatMessage(messages.headingTitle)}
5. subtitle={intl.formatMessage(messages.headingSubtitle)}
6. headerActions={(
7. isAllowActions ||
8. hasManageAllUsersPerm
9. ) && (
10. <Button
11. variant="primary"
12. iconBefore={IconAdd}
13. size="sm"
14. onClick={openForm}
15. >
16. {intl.formatMessage(messages.addNewMemberButton)}
17. </Button>
18. )}
19. />

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;

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
@@ -11,11 +11,8 @@ module.exports = createConfig('jest', {
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
// This alias is for any code in the src directory that wants to avoid '../../' style relative imports:
'^@src/(.*)$': '<rootDir>/src/$1',
// This alias is used for plugins in the plugins/ folder only.
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',
],
});

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

34540
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +1,105 @@
{
"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"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"i18n_extract": "fedx-scripts formatjs extract",
"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-markdown": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.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.5.0",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
"@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.6.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-ai-translations": "^1.4.0",
"@edx/frontend-component-footer": "^12.3.0",
"@edx/frontend-component-header": "^4.7.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-lib-content-components": "^1.178.2",
"@edx/frontend-platform": "5.6.1",
"@edx/openedx-atlas": "^0.6.0",
"@edx/paragon": "^21.5.6",
"@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",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "5.90.7",
"@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0",
"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",
"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.30.1",
"react-router-dom": "6.30.1",
"react-select": "5.10.2",
"react-textarea-autosize": "^8.5.3",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.5",
"redux": "4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"tinymce": "^5.10.4",
"universal-cookie": "^8.0.0",
"uuid": "^11.1.0",
"xmlchecker": "^0.1.0",
"yup": "0.32.11"
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/stylelint-config-edx": "2.3.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "13.0.5",
"@edx/react-unit-test-utils": "^1.7.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",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.17",
"@types/react": "^18",
"@types/react-dom": "^18",
"axios-mock-adapter": "2.1.0",
"eslint-import-resolver-webpack": "^0.13.8",
"fetch-mock-jest": "^1.5.1",
"@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",
"axios-mock-adapter": "1.22.0",
"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

@@ -1,31 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "calculator" Course App.
* @param {{onClose: () => void}} props
*/
const CalculatorSettings = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="calculator"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
onClose={onClose}
/>
);
};
CalculatorSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default CalculatorSettings;

View File

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

View File

@@ -1,31 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "edxnotes" Course App.
* @param {{onClose: () => void}} props
*/
const NotesSettings = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="edxnotes"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
onClose={onClose}
/>
);
};
NotesSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default NotesSettings;

View File

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

View File

@@ -1,19 +0,0 @@
{
"name": "@openedx-plugins/course-app-learning_assistant",
"version": "0.1.0",
"description": "Learning Assistant configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
{
"name": "@openedx-plugins/course-app-live",
"version": "0.1.0",
"description": "Live course configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
"lodash": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -1,176 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
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 { 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 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 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 />;
}
};
return (
<ModalDialog
title={formatMessage(messages.heading)}
isOpen
onClose={onClose}
size="lg"
variant={modalVariant}
hasCloseButton={isMobile}
isFullscreenScroll
isFullscreenOnMobile
>
<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>
);
};
ORASettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default ORASettings;

View File

@@ -1,155 +0,0 @@
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 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`;
// 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);
};
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 () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent();
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked();
await 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();
});
});

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

@@ -1,54 +0,0 @@
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',
},
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.',
},
});
export default messages;

View File

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

View File

@@ -1,20 +0,0 @@
{
"name": "@openedx-plugins/course-app-proctoring",
"version": "0.1.0",
"description": "Proctoring configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
"email-validator": "*",
"react": "*",
"prop-types": "*",
"moment": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -1,18 +0,0 @@
{
"name": "@openedx-plugins/course-app-progress",
"version": "0.1.0",
"description": "Progress configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -1,102 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { useFormikContext } from 'formik';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import GroupEditor from './GroupEditor';
import messages from './messages';
jest.mock('formik', () => ({
...jest.requireActual('formik'),
useFormikContext: jest.fn(),
}));
describe('GroupEditor', () => {
const mockIntl = { formatMessage: jest.fn() };
const mockGroup = {
id: '1',
name: 'Test Group',
description: 'Test Group Description',
type: 'open',
maxTeamSize: 5,
};
const mockProps = {
intl: mockIntl,
fieldNameCommonBase: 'test',
group: mockGroup,
onDelete: jest.fn(),
onChange: jest.fn(),
onBlur: jest.fn(),
errors: {},
};
const renderComponent = (overrideProps = {}) => render(
<IntlProvider locale="en" messages={{}}>
<GroupEditor {...mockProps} {...overrideProps} />
</IntlProvider>,
);
beforeEach(() => {
useFormikContext.mockReturnValue({
touched: {},
errors: {},
handleChange: jest.fn(),
handleBlur: jest.fn(),
setFieldError: jest.fn(),
});
jest.clearAllMocks();
});
test('renders without errors', () => {
renderComponent();
});
test('renders the group name and description', () => {
const { getByText } = renderComponent();
expect(getByText('Test Group')).toBeInTheDocument();
expect(getByText('Test Group Description')).toBeInTheDocument();
});
describe('group types messages', () => {
test('group type open message', () => {
const { getByLabelText, getByText } = renderComponent();
const expandButton = getByLabelText('Expand group editor');
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
});
test('group type public_managed message', () => {
const publicManagedGroupMock = {
id: '2',
name: 'Test Group',
description: 'Test Group Description',
type: 'public_managed',
maxTeamSize: 5,
};
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
const expandButton = getByLabelText('Expand group editor');
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
});
test('group type private_managed message', () => {
const privateManagedGroupMock = {
id: '3',
name: 'Test Group',
description: 'Test Group Description',
type: 'private_managed',
maxTeamSize: 5,
};
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
const expandButton = getByLabelText('Expand group editor');
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
});
});
});

View File

@@ -1,20 +0,0 @@
{
"name": "@openedx-plugins/course-app-teams",
"version": "0.1.0",
"description": "Teams configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"react": "*",
"uuid": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -1,22 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';
/**
* Check if a group type is enabled by the current configuration.
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
* @param {string} groupType - the group type to check
* @returns {boolean} - true if the group type is enabled
*/
export const isGroupTypeEnabled = (groupType) => {
const enabledTypesByDefault = [
GroupTypes.OPEN,
GroupTypes.PUBLIC_MANAGED,
GroupTypes.PRIVATE_MANAGED,
];
const enabledTypesByConfig = {
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
};
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
};

View File

@@ -1,39 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';
import { isGroupTypeEnabled } from './utils';
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
describe('teams utils', () => {
describe('isGroupTypeEnabled', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('returns true if the group type is enabled', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
});
test('returns false if the OPEN_MANAGED group is not enabled', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
});
test('returns true if the OPEN_MANAGED group is enabled', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
});
test('returns false if the group is invalid', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
expect(isGroupTypeEnabled('FOO')).toBe(false);
});
test('returns false if the group is null', () => {
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
expect(isGroupTypeEnabled(null)).toBe(false);
});
});
});

View File

@@ -1,18 +0,0 @@
{
"name": "@openedx-plugins/course-app-wiki",
"version": "0.1.0",
"description": "Wiki configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -1,4 +0,0 @@
Xpert Unit Summaries Configuration Plugin
=========================================
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.

View File

@@ -1,21 +0,0 @@
{
"name": "@openedx-plugins/course-app-xpert_unit_summary",
"version": "0.1.0",
"description": "Xpert Unit Summaries configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"yup": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

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,28 +5,53 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
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';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
import { getUserPermissions } from './generic/data/selectors';
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();
const userPermissions = useSelector(getUserPermissions);
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchUserPermissionsEnabledFlag());
if (!userPermissions) {
dispatch(fetchUserPermissionsQuery(courseId));
}
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
@@ -49,23 +74,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 } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -19,13 +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, {});
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('Editor Pages Load no header', () => {
@@ -41,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();
@@ -52,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();
@@ -82,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 () => {
@@ -93,28 +118,16 @@ 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();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
const mockStoreDenied = async () => {
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
axiosMock.onGet(
`${courseAppsApiUrl}/${courseId}`,
).reply(403);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
mockPathname = '/editor/';
await mockStoreDenied();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

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 './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
@@ -17,16 +16,10 @@ import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import { CourseUnit } from './course-unit';
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 +51,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>}
@@ -82,24 +71,19 @@ const CourseAuthoringRoutes = () => {
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
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"
@@ -113,10 +97,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>}
@@ -129,22 +109,6 @@ 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,14 +1,17 @@
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 { getApiWaffleFlagsUrl } from './data/api';
import {
screen, initializeMocks, render, waitFor,
} from './testUtils';
import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
let store;
const mockComponentFn = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -18,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,
@@ -47,57 +49,68 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
});
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const { axiosMock } = initializeMocks();
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
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: 'chapter',
blockTypeDisplay: 'Section',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Chapter 1',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
};

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,4 +0,0 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';
export { default as clipboardSection } from './clipboardSection';

View File

@@ -1,98 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
import messages from './messages';
const AccessibilityBody = ({
communityAccessibilityLink,
email,
}) => (
<div className="mt-5">
<header>
<h2 className="mb-4 pb-1">
<FormattedMessage {...messages.a11yBodyPageHeader} />
</h2>
</header>
<Stack gap={2.5}>
<div className="small">
<FormattedMessage
{...messages.a11yBodyIntroGraph}
values={{
communityAccessibilityLink: (
<Hyperlink
destination={communityAccessibilityLink}
data-testid="accessibility-page-link"
>
Website Accessibility Policy
</Hyperlink>
),
}}
/>
</div>
<div className="small">
<FormattedMessage {...messages.a11yBodyStepsHeader} />
</div>
<ol className="small m-0">
<li>
<FormattedMessage
{...messages.a11yBodyEmailHeading}
values={{
emailElement: (
<MailtoLink
to={email}
data-testid="email-element"
>
{email}
</MailtoLink>
),
}}
/>
<ul>
<li>
<FormattedMessage {...messages.a11yBodyNameEmail} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyInstitution} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyBarrier} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyTimeConstraints} />
</li>
</ul>
</li>
<li>
<FormattedMessage {...messages.a11yBodyReceipt} />
</li>
<li>
<FormattedMessage {...messages.a11yBodyExtraInfo} />
</li>
</ol>
<div className="small">
<FormattedMessage
{...messages.a11yBodyA11yFeedback}
values={{
emailElement: (
<MailtoLink
to={email}
data-testid="email-element"
>
{email}
</MailtoLink>
),
}}
/>
</div>
</Stack>
</div>
);
AccessibilityBody.propTypes = {
communityAccessibilityLink: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
};
export default AccessibilityBody;

View File

@@ -1,46 +0,0 @@
import {
render,
screen,
} from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import AccessibilityBody from './index';
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({});
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -1,3 +0,0 @@
import AccessibilityBody from './AccessibilityBody';
export default AccessibilityBody;

View File

@@ -1,111 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
a11yBodyPolicyLink: {
id: 'a11yBodyPolicyLink',
defaultMessage: 'Website Accessibility Policy',
description: 'Title for link to full accessibility policy.',
},
a11yBodyPageHeader: {
id: 'a11yBodyPageHeader',
defaultMessage: 'Individualized Accessibility Process for Course Creators',
description: 'Heading for studio\'s accessibility policy page.',
},
a11yBodyIntroGraph: {
id: 'a11yBodyIntroGraph',
defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community.
We value every course team and are committed to expanding access to all, including course team creators and authors with
disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators
and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their
disabilities.`,
description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.',
},
a11yBodyStepsHeader: {
id: 'a11yBodyStepsHeader',
defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:',
description: 'Heading for list of steps authors can take for accessibility requests.',
},
a11yBodyEdxResponse: {
id: 'a11yBodyEdxResponse',
defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although
the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing
an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for
you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`,
description: 'Paragraph outlining how we will select an accessibility solution.',
},
a11yBodyEdxFollowUp: {
id: 'a11yBodyEdxFollowUp',
defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in
implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and
will follow-up with you as may be necessary to see if the solution was effective.`,
description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.',
},
a11yBodyOngoingSupport: {
id: 'a11yBodyOngoingSupport',
defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.',
description: 'A statement of ongoing support.',
},
a11yBodyA11yFeedback: {
id: 'a11yBodyA11yFeedback',
defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.',
description: 'Contact information heading for those with accessibility issues or suggestions.',
},
a11yBodyEmailHeading: {
id: 'a11yBodyEmailHeading',
defaultMessage: 'Send an email to {emailElement} with the following information:',
description: 'Heading for list of information required when you email us.',
},
a11yBodyNameEmail: {
id: 'a11yBodyNameEmail',
defaultMessage: 'your name and email address;',
description: 'Your contact information.',
},
a11yBodyInstitution: {
id: 'a11yBodyInstitution',
defaultMessage: 'the edX member institution that you are affiliated with;',
description: 'edX affiliate information.',
},
a11yBodyBarrier: {
id: 'a11yBodyBarrier',
defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and',
description: 'Accessibility problem information.',
},
a11yBodyTimeConstraints: {
id: 'a11yBodyTimeConstraints',
defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).',
description: 'Time contstraint information.',
},
a11yBodyReceipt: {
id: 'a11yBodyReceipt',
defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.',
description: 'Paragraph outlining what steps edX will take immediately.',
},
a11yBodyExtraInfo: {
id: 'a11yBodyExtraInfo',
defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather
additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`,
description: 'Paragraph outlining how and when edX will reach out to you.',
},
a11yBodyFixesListHeader: {
id: 'a11yBodyFixesListHeader',
defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:',
description: 'Heading for list of ways we might be able to assist.',
},
a11yBodyThirdParty: {
id: 'a11yBodyThirdParty',
defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;',
description: 'Buy third-party software.',
},
a11yBodyContractor: {
id: 'a11yBodyContractor',
defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or',
description: 'Hire a contractor.',
},
a11yBodyCodeFix: {
id: 'a11yBodyCodeFix',
defaultMessage: 'Developing new code to implement a technical fix.',
description: 'Make a technical fix.',
},
});
export default messages;

View File

@@ -1,143 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage, FormattedDate, FormattedTime, useIntl,
} from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Form, Stack, StatefulButton,
} from '@openedx/paragon';
import { RequestStatus } from '../../data/constants';
import { STATEFUL_BUTTON_STATES } from '../../constants';
import submitAccessibilityForm from '../data/thunks';
import useAccessibility from './hooks';
import messages from './messages';
const AccessibilityForm = ({
accessibilityEmail,
}) => {
const intl = useIntl();
const {
errors,
values,
isFormFilled,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
} = useAccessibility({ name: '', email: '', message: '' }, intl);
const formFields = [
{
label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel),
name: 'email',
value: values.email,
},
{
label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel),
name: 'name',
value: values.name,
},
{
label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel),
name: 'message',
value: values.message,
},
];
const createButtonState = {
labels: {
default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel),
pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel),
},
disabledStates: [STATEFUL_BUTTON_STATES.pending],
};
const handleSubmit = () => {
dispatch(submitAccessibilityForm(values));
};
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)');
return (
<>
<h2 className="my-4">
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
</h2>
{savingStatus === RequestStatus.SUCCESSFUL && (
<Alert variant="success">
<Stack gap={2}>
<div className="mb-2">
<FormattedMessage {...messages.accessibilityPolicyFormSuccess} />
</div>
<div>
<FormattedMessage
{...messages.accessibilityPolicyFormSuccessDetails}
values={{
day_start: (<FormattedDate value={start} weekday="long" />),
time_start: (<FormattedTime value={start} timeZoneName="short" />),
day_end: (<FormattedDate value={end} weekday="long" />),
time_end: (<FormattedTime value={end} timeZoneName="short" />),
}}
/>
</div>
</Stack>
</Alert>
)}
{savingStatus === RequestStatus.FAILED && (
<Alert variant="danger">
<div data-testid="rate-limit-alert">
<FormattedMessage
{...messages.accessibilityPolicyFormErrorHighVolume}
values={{
emailLink: <a href={`mailto:${accessibilityEmail}`}>{accessibilityEmail}</a>,
}}
/>
</div>
</Alert>
)}
<Form>
{formFields.map((field) => (
<Form.Group size="sm" key={field.label}>
<Form.Control
value={field.value}
name={field.name}
isInvalid={hasErrorField(field.name)}
type={field.name === 'email' ? 'email' : null}
as={field.name === 'message' ? 'textarea' : 'input'}
onChange={handleChange}
onBlur={handleBlur}
floatingLabel={field.label}
/>
{hasErrorField(field.name) && (
<Form.Control.Feedback type="invalid" data-testid={`error-feedback-${field.name}`}>
{errors[field.name]}
</Form.Control.Feedback>
)}
</Form.Group>
))}
</Form>
<ActionRow>
<StatefulButton
key="save-button"
onClick={handleSubmit}
disabled={!isFormFilled}
state={
savingStatus === RequestStatus.IN_PROGRESS
? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default
}
{...createButtonState}
/>
</ActionRow>
</>
);
};
AccessibilityForm.propTypes = {
accessibilityEmail: PropTypes.string.isRequired,
};
export default AccessibilityForm;

View File

@@ -1,164 +0,0 @@
import {
render,
screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { 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 { RequestStatus } from '../../data/constants';
import AccessibilityForm from './index';
import { getZendeskrUrl } from '../data/api';
import messages from './messages';
let axiosMock;
let store;
const defaultProps = {
accessibilityEmail: 'accessibilityTest@test.com',
};
const initialState = {
accessibilityPage: {
savingStatus: '',
},
};
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityForm {...defaultProps} />
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityPolicyForm />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('renders', () => {
beforeEach(() => {
renderComponent();
});
it('correct number of form fields', () => {
const formSections = screen.getAllByRole('textbox');
const formButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
expect(formSections).toHaveLength(3);
expect(formButton).toBeVisible();
});
it('hides StatusAlert on initial load', () => {
expect(screen.queryAllByRole('alert')).toHaveLength(0);
});
});
describe('statusAlert', () => {
let formSections;
let submitButton;
let user;
beforeEach(async () => {
user = userEvent.setup();
renderComponent();
formSections = screen.getAllByRole('textbox');
await user.type(formSections[0], 'email@email.com');
await user.type(formSections[1], 'test name');
await user.type(formSections[2], 'feedback message');
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('shows correct success message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(200);
await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
formSections.forEach(input => {
expect(input.value).toBe('');
});
});
it('shows correct rate limiting message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(429);
await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();
formSections.forEach(input => {
expect(input.value).not.toBe('');
});
});
});
describe('input validation', () => {
let formSections;
let submitButton;
let user;
beforeEach(async () => {
user = userEvent.setup();
renderComponent();
formSections = screen.getAllByRole('textbox');
await user.type(formSections[0], 'email@email.com');
await user.type(formSections[1], 'test name');
await user.type(formSections[2], 'feedback message');
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('adds validation checking on each input field', async () => {
await user.clear(formSections[0]);
await user.clear(formSections[1]);
await user.clear(formSections[2]);
const emailError = screen.getByTestId('error-feedback-email');
expect(emailError).toBeVisible();
const fullNameError = screen.getByTestId('error-feedback-email');
expect(fullNameError).toBeVisible();
const messageError = screen.getByTestId('error-feedback-message');
expect(messageError).toBeVisible();
});
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
await user.clear(formSections[0]);
await user.clear(formSections[1]);
await user.clear(formSections[2]);
await user.click(submitButton);
expect(submitButton.closest('button')).toBeDisabled();
});
});
});

View File

@@ -1,58 +0,0 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
const useAccessibility = (initialValues, intl) => {
const dispatch = useDispatch();
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
const [isFormFilled, setFormFilled] = useState(false);
const validationSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.accessibilityPolicyFormValidName),
),
email: Yup.string()
.email(intl.formatMessage(messages.accessibilityPolicyFormValidEmail))
.required(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)),
message: Yup.string().required(
intl.formatMessage(messages.accessibilityPolicyFormValidMessage),
),
});
const {
values, errors, touched, handleChange, handleBlur, handleReset,
} = useFormik({
initialValues,
enableReinitialize: true,
validateOnBlur: false,
validationSchema,
});
useEffect(() => {
setFormFilled(Object.values(values).every((i) => i));
}, [values]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
handleReset();
}
}, [savingStatus]);
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
return {
errors,
values,
isFormFilled,
dispatch,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
};
};
export default useAccessibility;

View File

@@ -1,3 +0,0 @@
import AccessibilityForm from './AccessibilityForm';
export default AccessibilityForm;

View File

@@ -1,76 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
accessibilityPolicyFormEmailLabel: {
id: 'accessibilityPolicyFormEmailLabel',
defaultMessage: 'Email Address',
description: 'Label for the email form field',
},
accessibilityPolicyFormErrorHighVolume: {
id: 'accessibilityPolicyFormErrorHighVolume',
defaultMessage: 'We are currently experiencing high volume. Try again later today or send an email message to {emailLink}.',
description: 'Error message when site is experiencing high volume that will include an email link',
},
accessibilityPolicyFormErrorMissingFields: {
id: 'accessibilityPolicyFormErrorMissingFields',
defaultMessage: 'Make sure to fill in all fields.',
description: 'Error message to instruct user to fill in all fields',
},
accessibilityPolicyFormHeader: {
id: 'accessibilityPolicyFormHeader',
defaultMessage: 'Studio Accessibility Feedback',
description: 'The heading for the form',
},
accessibilityPolicyFormMessageLabel: {
id: 'accessibilityPolicyFormMessageLabel',
defaultMessage: 'Message',
description: 'Label for the message form field',
},
accessibilityPolicyFormNameLabel: {
id: 'accessibilityPolicyFormNameLabel',
defaultMessage: 'Name',
description: 'Label for the name form field',
},
accessibilityPolicyFormSubmitAria: {
id: 'accessibilityPolicyFormSubmitAria',
defaultMessage: 'Submit Accessibility Feedback Form',
description: 'Detailed aria-label for the submit button',
},
accessibilityPolicyFormSubmitLabel: {
id: 'accessibilityPolicyFormSubmitLabel',
defaultMessage: 'Submit',
description: 'General label for the submit button',
},
accessibilityPolicyFormSubmittingFeedbackLabel: {
id: 'accessibilityPolicyFormSubmittingFeedbackLabel',
defaultMessage: 'Submitting',
description: 'Loading message while form feedback is being submitted',
},
accessibilityPolicyFormSuccess: {
id: 'accessibilityPolicyFormSuccess',
defaultMessage: 'Thank you for contacting edX!',
description: 'Simple thank you message when form submission is successful',
},
accessibilityPolicyFormSuccessDetails: {
id: 'accessibilityPolicyFormSuccessDetails',
defaultMessage: 'Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day ({day_start} to {day_end}, {time_start} to {time_end}).',
description: 'Detailed thank you message when form submission is successful',
},
accessibilityPolicyFormValidEmail: {
id: 'accessibilityPolicyFormValidEmail',
defaultMessage: 'Enter a valid email address.',
description: 'Error message for when an invalid email is entered into the form',
},
accessibilityPolicyFormValidMessage: {
id: 'accessibilityPolicyFormValidMessage',
defaultMessage: 'Enter a message.',
description: 'Error message an invalid message is entered into the form',
},
accessibilityPolicyFormValidName: {
id: 'accessibilityPolicyFormValidName',
defaultMessage: 'Enter a name.',
description: 'Error message an invalid name is entered into the form',
},
});
export default messages;

View File

@@ -1,39 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from '../header';
import messages from './messages';
import AccessibilityBody from './AccessibilityBody';
import AccessibilityForm from './AccessibilityForm';
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
const AccessibilityPage = () => {
const intl = useIntl();
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" classNamae="px-4">
<AccessibilityBody
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
/>
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
</Container>
<StudioFooterSlot />
</>
);
};
AccessibilityPage.propTypes = {};
export default AccessibilityPage;

View File

@@ -1,17 +0,0 @@
// @ts-check
import { initializeMocks, render, screen } from '../testUtils';
import AccessibilityPage from './index';
const renderComponent = () => render(<AccessibilityPage />);
describe('<AccessibilityPolicyPage />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMocks();
});
it('contains the policy body', () => {
renderComponent();
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
});
});
});

View File

@@ -1,2 +0,0 @@
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';

View File

@@ -1,28 +0,0 @@
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
/**
* Posts the form data to zendesk endpoint
* @param {string} courseId
* @returns {Promise<[{}]>}
*/
export async function postAccessibilityForm({ name, email, message }) {
const data = {
name,
tags: ['studio_a11y'],
email: {
from: email,
subject: 'Studio Accessibility Request',
message,
},
};
await getAuthenticatedHttpClient().post(getZendeskrUrl(), data);
}

View File

@@ -1,23 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const slice = createSlice({
name: 'accessibilityPage',
initialState: {
savingStatus: '',
},
reducers: {
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,24 +0,0 @@
import { RequestStatus } from '../../data/constants';
import { postAccessibilityForm } from './api';
import { updateSavingStatus } from './slice';
function submitAccessibilityForm({ email, name, message }) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await postAccessibilityForm({ email, name, message });
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
/* istanbul ignore else */
if (error.response && error.response.status === 429) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
} else {
/* istanbul ignore next */
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
}
};
}
export default submitAccessibilityForm;

View File

@@ -1,3 +0,0 @@
import AccessibilityPage from './AccessibilityPage';
export default AccessibilityPage;

View File

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

View File

@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import Placeholder from '../editors/Placeholder';
} from '@edx/paragon';
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '@edx/frontend-lib-content-components';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
@@ -25,9 +25,11 @@ import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import getPageHeadTitle from '../generic/utils';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const AdvancedSettings = ({ courseId }) => {
const intl = useIntl();
const AdvancedSettings = ({ intl, courseId }) => {
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
@@ -42,6 +44,13 @@ const AdvancedSettings = ({ courseId }) => {
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const viewOnly = checkPermission('view_course_settings');
const showPermissionDeniedAlert = userPermissionsEnabled && (
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
@@ -84,6 +93,11 @@ const AdvancedSettings = ({ courseId }) => {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
@@ -216,6 +230,7 @@ const AdvancedSettings = ({ courseId }) => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disableForm={viewOnly}
/>
);
})}
@@ -279,7 +294,8 @@ const AdvancedSettings = ({ courseId }) => {
};
AdvancedSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default AdvancedSettings;
export default injectIntl(AdvancedSettings);

View File

@@ -1,20 +1,31 @@
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import {
render as baseRender,
render,
fireEvent,
initializeMocks,
waitFor,
} from '../testUtils';
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
@@ -25,22 +36,53 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
/>
)));
const render = () => baseRender(
<AdvancedSettings courseId={courseId} />,
{ path: mockPathname },
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<AdvancedSettings intl={injectIntl} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
const permissionsMockStore = async (permissions) => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const permissionDisabledMockStore = async () => {
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
permissionsMockStore(userPermissionsData);
});
it('should render without errors', async () => {
const { getByText } = render();
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
@@ -52,7 +94,7 @@ describe('<AdvancedSettings />', () => {
});
});
it('should render setting element', async () => {
const { getByText, queryByText } = render();
const { getByText, queryByText } = render(<RootWrapper />);
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
@@ -60,7 +102,7 @@ describe('<AdvancedSettings />', () => {
});
});
it('should change to onСhange', async () => {
const { getByLabelText } = render();
const { getByLabelText } = render(<RootWrapper />);
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
@@ -69,7 +111,7 @@ describe('<AdvancedSettings />', () => {
});
});
it('should display a warning alert', async () => {
const { getByLabelText, getByText } = render();
const { getByLabelText, getByText } = render(<RootWrapper />);
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
@@ -80,7 +122,7 @@ describe('<AdvancedSettings />', () => {
});
});
it('should display a tooltip on clicking on the icon', async () => {
const { getByLabelText, getByText } = render();
const { getByLabelText, getByText } = render(<RootWrapper />);
await waitFor(() => {
const button = getByLabelText(/Show help text/i);
fireEvent.click(button);
@@ -88,7 +130,7 @@ describe('<AdvancedSettings />', () => {
});
});
it('should change deprecated button text ', async () => {
const { getByText } = render();
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
@@ -98,7 +140,7 @@ describe('<AdvancedSettings />', () => {
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render();
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
@@ -109,7 +151,7 @@ describe('<AdvancedSettings />', () => {
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render();
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
@@ -121,7 +163,7 @@ describe('<AdvancedSettings />', () => {
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render();
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
@@ -141,4 +183,29 @@ describe('<AdvancedSettings />', () => {
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
const permissionsData = { permissions: ['view'] };
await permissionsMockStore(permissionsData);
const { queryByText } = render(<RootWrapper />);
await waitFor(() => {
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).toBeInTheDocument();
});
});
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
await permissionDisabledMockStore();
const { queryByText } = render(<RootWrapper />);
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).not.toBeInTheDocument();
});
it('should be view only if the permission is set for viewOnly', async () => {
const permissions = { permissions: ['view_course_settings'] };
await permissionsMockStore(permissions);
const { getByLabelText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByLabelText('Advanced Module List')).toBeDisabled();
});
});
});

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ export function updateCourseAppSetting(courseId, settings) {
try {
const { customAttributes: { httpErrorResponseData } } = error;
errorData = JSON.parse(httpErrorResponseData);
} catch {
} catch (err) {
errorData = {};
}
@@ -77,7 +77,7 @@ export function fetchProctoringExamErrors(courseId) {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}

View File

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

View File

@@ -1,57 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';
import messages from './messages';
const ModalError = ({
isError, handleUndoChanges, showErrorModal, errorList, settingsData,
}) => {
const intl = useIntl();
return (
<AlertModal
title={intl.formatMessage(messages.modalErrorTitle)}
isOpen={isError}
variant="danger"
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => showErrorModal(!isError)}
>
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
</Button>
<Button onClick={handleUndoChanges}>
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
</Button>
</ActionRow>
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
}) => (
<AlertModal
title={intl.formatMessage(messages.modalErrorTitle)}
isOpen={isError}
variant="danger"
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => showErrorModal(!isError)}
>
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
</Button>
<Button onClick={handleUndoChanges}>
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
</Button>
</ActionRow>
)}
>
<p>
<FormattedMessage
id="course-authoring.advanced-settings.modal.error.description"
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
>
<p>
<FormattedMessage
id="course-authoring.advanced-settings.modal.error.description"
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
Please check the following validation feedbacks and reflect them in your course settings:"
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
/>
</p>
<hr />
<ul className="p-0">
{errorList.map((settingName) => (
<ModalErrorListItem
key={settingName.key}
settingName={settingName}
settingsData={settingsData}
/>
</p>
<hr />
<ul className="p-0">
{errorList.map((settingName) => (
<ModalErrorListItem
key={settingName.key}
settingName={settingName}
settingsData={settingsData}
/>
))}
</ul>
</AlertModal>
);
};
))}
</ul>
</AlertModal>
);
ModalError.propTypes = {
intl: intlShape.isRequired,
isError: PropTypes.bool.isRequired,
handleUndoChanges: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired,
@@ -62,4 +60,4 @@ ModalError.propTypes = {
settingsData: PropTypes.shape({}).isRequired,
};
export default ModalError;
export default injectIntl(ModalError);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Icon } from '@openedx/paragon';
import { Error } from '@openedx/paragon/icons';
import { Alert, Icon } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { capitalize } from 'lodash';
import { transformKeysToCamelCase } from '../../utils';

View File

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

View File

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

View File

@@ -7,11 +7,11 @@ import {
IconButton,
ModalPopup,
useToggle,
} from '@openedx/paragon';
import { InfoOutline, Warning } from '@openedx/paragon/icons';
} from '@edx/paragon';
import { InfoOutline, Warning } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { capitalize } from 'lodash';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import TextareaAutosize from 'react-textarea-autosize';
import messages from './messages';
@@ -25,12 +25,14 @@ const SettingCard = ({
saveSettingsPrompt,
isEditableState,
setIsEditableState,
// injected
intl,
disableForm,
}) => {
const intl = useIntl();
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
const [target, setTarget] = useState(null);
const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => {
@@ -70,7 +72,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
@@ -99,6 +101,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
disabled={disableForm}
/>
</Form.Group>
</Card.Section>
@@ -114,11 +117,12 @@ const SettingCard = ({
};
SettingCard.propTypes = {
intl: intlShape.isRequired,
settingData: PropTypes.shape({
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.oneOfType([
value: PropTypes.PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
@@ -133,6 +137,7 @@ SettingCard.propTypes = {
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
disableForm: PropTypes.bool.isRequired,
};
export default SettingCard;
export default injectIntl(SettingCard);

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -21,12 +22,14 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
const RootWrapper = () => (
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
@@ -55,6 +58,7 @@ describe('<SettingCard />', () => {
const { getByText } = render(
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
@@ -75,19 +79,18 @@ describe('<SettingCard />', () => {
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
});
it('calls setEdited on blur', async () => {
const user = userEvent.setup();
const { getByLabelText } = render(<RootWrapper />);
const inputBox = getByLabelText(/Setting Name/i);
fireEvent.focus(inputBox);
await user.clear(inputBox);
await user.type(inputBox, '3, 2, 1');
userEvent.clear(inputBox);
userEvent.type(inputBox, '3, 2, 1');
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await user.tab(); // blur off of the input.
await waitFor(() => {
await (async () => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -1,25 +1,28 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { HelpSidebar } from '../../generic/help-sidebar';
import messages from './messages';
const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
<HelpSidebar
courseId={courseId}
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
showOtherSettings
>
<h4 className="help-sidebar-about-title">
<FormattedMessage {...messages.about} />
{intl.formatMessage(messages.about)}
</h4>
<p className="help-sidebar-about-descriptions">
<FormattedMessage {...messages.aboutDescription1} />
{intl.formatMessage(messages.aboutDescription1)}
</p>
<p className="help-sidebar-about-descriptions">
<FormattedMessage {...messages.aboutDescription2} />
{intl.formatMessage(messages.aboutDescription2)}
</p>
<p className="help-sidebar-about-descriptions">
<FormattedMessage
@@ -31,9 +34,14 @@ const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
</HelpSidebar>
);
SettingsSidebar.defaultProps = {
proctoredExamSettingsUrl: '',
};
SettingsSidebar.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
proctoredExamSettingsUrl: PropTypes.string,
};
export default SettingsSidebar;
export default injectIntl(SettingsSidebar);

View File

@@ -1,21 +1,43 @@
// @ts-check
import { initializeMocks, render } from '../../testUtils';
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import SettingsSidebar from './SettingsSidebar';
import messages from './messages';
const courseId = 'course-123';
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<SettingsSidebar />', () => {
beforeEach(() => {
initializeMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('renders about and other sidebar titles correctly', () => {
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
});
it('renders about descriptions correctly', () => {
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
const { getByText } = render(<RootWrapper />);
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks ().');
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();

View File

@@ -17,7 +17,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
try {
JSON.parse(settingValue);
} catch {
} catch (e) {
let targetSettingValue = settingValue;
const firstNonWhite = settingValue.substring(0, 1);
const isValid = !['{', '[', "'"].includes(firstNonWhite);
@@ -30,7 +30,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
...prevEditedSettings,
[settingName]: targetSettingValue,
}));
} catch { /* empty */ }
} catch (quotedE) { /* empty */ }
}
pushDataToErrorArray(settingName);

View File

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

View File

@@ -1,5 +1,5 @@
.text-black {
color: var(--pgn-color-black);
color: $black;
}
.h-200px {
@@ -9,7 +9,3 @@
.mw-300px {
max-width: 300px;
}
.right-0 {
right: 0;
}

View File

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

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,175 +0,0 @@
// @ts-check
import userEvent from '@testing-library/user-event';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
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(<Certificates courseId={courseId} {...props} />);
describe('Certificates', () => {
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
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 user = userEvent.setup();
const { queryByTestId, getByTestId, getByRole } = renderComponent();
await waitFor(async () => {
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
await user.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 user = userEvent.setup();
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
await waitFor(async () => {
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
await user.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',
},
];

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