Compare commits

..

1 Commits

Author SHA1 Message Date
Saad Yousaf
f81670547b fix: remove unnecessary call to fetch courses on course authoring page 2025-03-05 20:25:35 +05:00
1215 changed files with 40163 additions and 52305 deletions

7
.env
View File

@@ -41,10 +41,7 @@ HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2"

View File

@@ -44,10 +44,7 @@ HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2"

View File

@@ -39,7 +39,5 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
# "other" is used to test the workflow for creating blocks that aren't supported by the built-in editors
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2,other"

View File

@@ -2,37 +2,26 @@
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",
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
"Developer", and "Operator".
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
changes.
## Supporting information
Link to other information about the change, such as GitHub issues, or Discourse discussions.
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
Be sure to check they are publicly readable, or if not, repeat the information here.
## Testing instructions
Please provide detailed step-by-step instructions for manually testing this change.
Please provide detailed step-by-step instructions for testing this change.
## Other information
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`).
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.

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

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
@@ -25,13 +25,13 @@ jobs:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Download code coverage results
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

2
CODEOWNERS Normal file
View File

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

View File

@@ -38,7 +38,7 @@ Cloning and Setup
git clone https://github.com/openedx/frontend-app-authoring.git
2. Use the version of Node specified in the ``.nvmrc`` file.
2. Use node v20.x.
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
@@ -165,7 +165,21 @@ 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
Feature Description
-------------------
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
Feature: New Proctoring Exams View
==================================
@@ -179,6 +193,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 +221,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 +238,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 +255,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
================================
@@ -262,7 +315,7 @@ In additional to the standard settings, the following local configurations can b
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:
@@ -327,20 +380,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

@@ -14,6 +14,6 @@ metadata:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: user:bradenmacdonald
owner: group:2u-tnl
type: 'website'
lifecycle: 'production'

View File

@@ -10,5 +10,4 @@ coverage:
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/index.js"

View File

@@ -11,11 +11,9 @@ 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',
],
});

19658
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,11 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"i18n_extract": "fedx-scripts formatjs extract",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
@@ -22,6 +23,11 @@
"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",
@@ -33,7 +39,6 @@
},
"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",
@@ -43,12 +48,12 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.1.0",
"@edx/frontend-component-header": "^5.8.3",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^8.0.3",
"@edx/openedx-atlas": "^0.6.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -59,12 +64,12 @@
"@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.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0",
"@openedx/frontend-build": "^14.2.0",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/paragon": "^22.8.1",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.40.1",
"@tanstack/react-query": "4.36.1",
"@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
@@ -78,45 +83,48 @@
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"npm": "^10.8.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "^18.3.1",
"react-dom": "17.0.2",
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.30.1",
"react-router-dom": "6.30.1",
"react-select": "5.10.2",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",
"redux": "4.2.1",
"redux": "4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"start": "^5.1.0",
"tinymce": "^5.10.4",
"universal-cookie": "^4.0.4",
"uuid": "^11.1.0",
"uuid": "^3.4.0",
"xmlchecker": "^0.1.0",
"yup": "0.32.11"
"yup": "0.31.1"
},
"devDependencies": {
"@edx/react-unit-test-utils": "3.0.0",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/typescript-config": "^1.0.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.17",
"@types/react": "^18",
"@types/react-dom": "^18",
"axios-mock-adapter": "2.1.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.2.1",
"@types/lodash": "^4.17.7",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"fetch-mock-jest": "^1.5.1",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "^18.3.1",
"react-test-renderer": "17.0.2",
"redux-mock-store": "^1.5.4"
}
}

View File

@@ -2,12 +2,16 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { initializeMocks, render } from 'CourseAuthoring/testUtils';
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
import LearningAssistantSettings from './Settings';
const onClose = () => { };
describe('Learning Assistant Settings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
const initialState = {
models: {
@@ -34,8 +38,14 @@ describe('Learning Assistant Settings', () => {
},
};
initializeMocks({ initialState });
render(<LearningAssistantSettings onClose={onClose} />);
render(
<LearningAssistantSettings
onClose={onClose}
/>,
{
preloadedState: initialState,
},
);
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
@@ -11,10 +11,10 @@ import LiveCommonFields from './LiveCommonFields';
import messages from './messages';
const BbbSettings = ({
intl,
values,
setFieldValue,
}) => {
const intl = useIntl();
const [bbbPlan, setBbbPlan] = useState(values.tierType);
useEffect(() => {
@@ -107,10 +107,12 @@ const BbbSettings = ({
)}
</>
</>
);
};
BbbSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -125,4 +127,4 @@ BbbSettings.propTypes = {
setFieldValue: PropTypes.func.isRequired,
};
export default BbbSettings;
export default injectIntl(BbbSettings);

View File

@@ -124,13 +124,12 @@ describe('BBB Settings', () => {
);
test('free plans message is visible when free plan is selected', async () => {
const user = userEvent.setup();
await mockStore({ emailSharing: true, isFreeTier: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const dropDown = container.querySelector('select[name="tierType"]');
await user.selectOptions(
userEvent.selectOptions(
dropDown,
getByRole(dropDown, 'option', { name: 'Free' }),
);

View File

@@ -1,43 +1,42 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
const LiveCommonFields = ({
intl,
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"
/>
</>
);
};
}) => (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
LiveCommonFields.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
@@ -46,4 +45,4 @@ LiveCommonFields.propTypes = {
}).isRequired,
};
export default LiveCommonFields;
export default injectIntl(LiveCommonFields);

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { Icon } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
@@ -20,9 +20,9 @@ import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';
const LiveSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const courseId = useSelector(state => state.courseDetail.courseId);
@@ -130,7 +130,8 @@ const LiveSettings = ({
};
LiveSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default LiveSettings;
export default injectIntl(LiveSettings);

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
@@ -25,8 +25,7 @@ import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/Pa
import messages from './messages';
const ProctoringSettings = ({ onClose }) => {
const intl = useIntl();
const ProctoringSettings = ({ intl, onClose }) => {
const initialFormValues = {
enableProctoredExams: false,
proctoringProvider: false,
@@ -653,9 +652,10 @@ const ProctoringSettings = ({ onClose }) => {
};
ProctoringSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
ProctoringSettings.defaultProps = {};
export default ProctoringSettings;
export default injectIntl(ProctoringSettings);

View File

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

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ onClose }) => {
const intl = useIntl();
const ProgressSettings = ({ intl, onClose }) => {
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
@@ -49,7 +48,8 @@ const ProgressSettings = ({ onClose }) => {
};
ProgressSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ProgressSettings;
export default injectIntl(ProgressSettings);

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form, TransitionReplace } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
@@ -30,9 +30,8 @@ const TeamTypeNameMessage = {
};
const GroupEditor = ({
group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
}) => {
const intl = useIntl();
const [isDeleting, setDeleting] = useState(false);
const [isOpen, setOpen] = useState(group.id === null);
const initiateDeletion = () => setDeleting(true);
@@ -150,6 +149,7 @@ export const groupShape = PropTypes.shape({
});
GroupEditor.propTypes = {
intl: intlShape.isRequired,
fieldNameCommonBase: PropTypes.string.isRequired,
errors: PropTypes.shape({
name: PropTypes.string,
@@ -170,4 +170,4 @@ GroupEditor.defaultProps = {
},
};
export default GroupEditor;
export default injectIntl(GroupEditor);

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
@@ -17,16 +17,15 @@ import messages from './messages';
setupYupExtensions();
const TeamSettings = ({
intl,
onClose,
}) => {
const intl = useIntl();
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
const blankNewGroup = {
name: '',
description: '',
type: GroupTypes.OPEN,
maxTeamSize: null,
userPartitionId: null,
id: null,
key: uuid(),
};
@@ -39,7 +38,6 @@ const TeamSettings = ({
type: group.type,
description: group.description,
max_team_size: group.maxTeamSize,
user_partition_id: group.userPartitionId,
}));
return saveSettings({
team_sets: groups,
@@ -166,7 +164,8 @@ const TeamSettings = ({
};
TeamSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default TeamSettings;
export default injectIntl(TeamSettings);

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ onClose }) => {
const intl = useIntl();
const WikiSettings = ({ intl, onClose }) => {
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
@@ -33,7 +32,7 @@ const WikiSettings = ({ onClose }) => {
label={intl.formatMessage(messages.enablePublicWikiLabel)}
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
onChange={handleChange}
onBlur={handleBlur}
onBlue={handleBlur}
checked={values.enablePublicWiki}
/>
)
@@ -43,7 +42,8 @@ const WikiSettings = ({ onClose }) => {
};
WikiSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default WikiSettings;
export default injectIntl(WikiSettings);

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useNavigate } from 'react-router-dom';
@@ -9,8 +10,7 @@ import messages from './messages';
import { fetchXpertSettings } from './data/thunks';
const XpertUnitSummarySettings = () => {
const intl = useIntl();
const XpertUnitSummarySettings = ({ intl }) => {
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -38,4 +38,8 @@ const XpertUnitSummarySettings = () => {
);
};
export default XpertUnitSummarySettings;
XpertUnitSummarySettings.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(XpertUnitSummarySettings);

View File

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

View File

@@ -1,4 +1,4 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
@@ -70,40 +70,38 @@ AppSettingsForm.defaultProps = {
};
const SettingsModalBase = ({
title, onClose, variant, isMobile, children, footer,
}) => {
const intl = useIntl();
return (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
intl, title, onClose, variant, isMobile, children, footer,
}) => (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
SettingsModalBase.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
@@ -117,11 +115,11 @@ SettingsModalBase.defaultProps = {
};
const ResetUnitsButton = ({
intl,
courseId,
checked,
visible,
}) => {
const intl = useIntl();
const resetStatusRequestStatus = useSelector(getResetStatus);
const dispatch = useDispatch();
@@ -187,6 +185,7 @@ const ResetUnitsButton = ({
};
ResetUnitsButton.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
checked: PropTypes.oneOf(['true', 'false']).isRequired,
visible: PropTypes.bool,
@@ -197,6 +196,7 @@ ResetUnitsButton.defaultProps = {
};
const SettingsModal = ({
intl,
appId,
title,
children,
@@ -213,7 +213,6 @@ const SettingsModal = ({
allUnitsEnabledText,
noUnitsEnabledText,
}) => {
const intl = useIntl();
const { courseId } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
@@ -373,6 +372,7 @@ const SettingsModal = ({
>
{allUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'true'}
@@ -385,6 +385,7 @@ const SettingsModal = ({
>
{noUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'false'}
@@ -422,6 +423,7 @@ const SettingsModal = ({
};
SettingsModal.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
appId: PropTypes.string.isRequired,
children: PropTypes.func,
@@ -448,4 +450,4 @@ SettingsModal.defaultProps = {
enableReinitialize: false,
};
export default SettingsModal;
export default injectIntl(SettingsModal);

View File

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

View File

@@ -5,13 +5,12 @@ 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 { fetchCourseDetail, fetchWaffleFlags } 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';
@@ -21,12 +20,9 @@ const CourseAuthoringPage = ({ courseId, children }) => {
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
@@ -65,7 +61,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
)
)}
{children}
{!inProgress && !isEditor && <StudioFooterSlot />}
{!inProgress && !isEditor && <StudioFooter />}
</div>
);
};

View File

@@ -4,7 +4,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
@@ -26,6 +26,7 @@ beforeEach(async () => {
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
describe('Editor Pages Load no header', () => {
@@ -101,20 +102,4 @@ describe('Course authoring page', () => {
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
const mockStoreDenied = async () => {
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
axiosMock.onGet(
`${courseAppsApiUrl}/${courseId}`,
).reply(403);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
mockPathname = '/editor/';
await mockStoreDenied();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,7 @@ import {
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import { Textbooks } from 'CourseAuthoring/textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
@@ -17,7 +17,7 @@ import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { CourseUnit, IframeProvider } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
@@ -25,8 +25,7 @@ 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';
import CourseLibraries from './course-libraries';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -82,10 +81,6 @@ 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}

View File

@@ -1,5 +1,7 @@
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import { executeThunk } from './utils';
import { getApiWaffleFlagsUrl } from './data/api';
import { fetchWaffleFlags } from './data/thunks';
import {
screen, initializeMocks, render, waitFor,
} from './testUtils';
@@ -9,6 +11,7 @@ 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', () => ({
@@ -48,10 +51,12 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const { axiosMock } = initializeMocks();
const { axiosMock, reduxStore } = initializeMocks();
store = reduxStore;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
it('renders the PagesAndResources component when the pages and resources route is active', async () => {

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

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
import messages from './messages';
@@ -95,4 +95,4 @@ AccessibilityBody.propTypes = {
email: PropTypes.string.isRequired,
};
export default AccessibilityBody;
export default injectIntl(AccessibilityBody);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage, FormattedDate, FormattedTime, useIntl,
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
} from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Form, Stack, StatefulButton,
@@ -15,8 +15,9 @@ import messages from './messages';
const AccessibilityForm = ({
accessibilityEmail,
// injected
intl,
}) => {
const intl = useIntl();
const {
errors,
values,
@@ -138,6 +139,8 @@ const AccessibilityForm = ({
AccessibilityForm.propTypes = {
accessibilityEmail: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default AccessibilityForm;
export default injectIntl(AccessibilityForm);

View File

@@ -1,5 +1,6 @@
import {
render,
act,
screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -73,24 +74,22 @@ describe('<AccessibilityPolicyForm />', () => {
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');
await act(async () => {
userEvent.type(formSections[0], 'email@email.com');
userEvent.type(formSections[1], 'test name');
userEvent.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);
await act(async () => {
userEvent.click(submitButton);
});
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
@@ -105,9 +104,9 @@ describe('<AccessibilityPolicyForm />', () => {
it('shows correct rate limiting message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(429);
await user.click(submitButton);
await act(async () => {
userEvent.click(submitButton);
});
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
@@ -124,24 +123,23 @@ describe('<AccessibilityPolicyForm />', () => {
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');
await act(async () => {
userEvent.type(formSections[0], 'email@email.com');
userEvent.type(formSections[1], 'test name');
userEvent.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]);
await act(async () => {
userEvent.clear(formSections[0]);
userEvent.clear(formSections[1]);
userEvent.clear(formSections[2]);
});
const emailError = screen.getByTestId('error-feedback-email');
expect(emailError).toBeVisible();
@@ -153,10 +151,12 @@ describe('<AccessibilityPolicyForm />', () => {
});
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);
await act(async () => {
userEvent.clear(formSections[0]);
userEvent.clear(formSections[1]);
userEvent.clear(formSections[2]);
userEvent.click(submitButton);
});
expect(submitButton.closest('button')).toBeDisabled();
});

View File

@@ -1,18 +1,20 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { StudioFooter } 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();
const AccessibilityPage = ({
// injected
intl,
}) => {
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
const email = 'accessibility@edx.org';
return (
<>
<Helmet>
@@ -24,16 +26,17 @@ const AccessibilityPage = () => {
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" classNamae="px-4">
<AccessibilityBody
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
/>
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
<AccessibilityForm accessibilityEmail={email} />
</Container>
<StudioFooterSlot />
<StudioFooter />
</>
);
};
AccessibilityPage.propTypes = {};
AccessibilityPage.propTypes = {
// injected
intl: intlShape.isRequired,
};
export default AccessibilityPage;
export default injectIntl(AccessibilityPage);

View File

@@ -1,13 +1,42 @@
// @ts-check
import { initializeMocks, render, screen } from '../testUtils';
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 AccessibilityPage from './index';
const renderComponent = () => render(<AccessibilityPage />);
const initialState = {
accessibilityPage: {
status: {},
},
};
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityPage />
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityPolicyPage />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
});
it('contains the policy body', () => {
renderComponent();

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

@@ -10,11 +10,9 @@ function submitAccessibilityForm({ email, name, message }) {
await postAccessibilityForm({ email, name, message });
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
/* istanbul ignore else */
if (error.response && error.response.status === 429) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
} else {
/* istanbul ignore next */
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
}

View File

@@ -5,7 +5,7 @@ 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 { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '../editors/Placeholder';
import AlertProctoringError from '../generic/AlertProctoringError';
@@ -26,8 +26,7 @@ import messages from './messages';
import ModalError from './modal-error/ModalError';
import getPageHeadTitle from '../generic/utils';
const AdvancedSettings = ({ courseId }) => {
const intl = useIntl();
const AdvancedSettings = ({ intl, courseId }) => {
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
@@ -279,7 +278,8 @@ const AdvancedSettings = ({ courseId }) => {
};
AdvancedSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default AdvancedSettings;
export default injectIntl(AdvancedSettings);

View File

@@ -1,9 +1,12 @@
import {
render as baseRender,
fireEvent,
initializeMocks,
waitFor,
} from '../testUtils';
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
@@ -25,22 +28,39 @@ 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>
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
});
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 +72,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 +80,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 +89,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 +100,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 +108,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 +118,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 +129,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 +141,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);

View File

@@ -1,10 +1,5 @@
/* eslint-disable import/prefer-default-export */
import {
camelCaseObject,
getConfig,
} from '@edx/frontend-platform';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -19,19 +14,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 +26,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 +36,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

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

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

@@ -11,7 +11,7 @@ import {
import { InfoOutline, Warning } from '@openedx/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,8 +25,9 @@ const SettingCard = ({
saveSettingsPrompt,
isEditableState,
setIsEditableState,
// injected
intl,
}) => {
const intl = useIntl();
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
@@ -114,6 +115,7 @@ const SettingCard = ({
};
SettingCard.propTypes = {
intl: intlShape.isRequired,
settingData: PropTypes.shape({
deprecated: PropTypes.bool,
help: PropTypes.string,
@@ -135,4 +137,4 @@ SettingCard.propTypes = {
setIsEditableState: PropTypes.func.isRequired,
};
export default SettingCard;
export default injectIntl(SettingCard);

View File

@@ -29,6 +29,7 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
const RootWrapper = () => (
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
@@ -57,6 +58,7 @@ describe('<SettingCard />', () => {
const { getByText } = render(
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
@@ -77,12 +79,11 @@ 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');
});

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

@@ -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,7 +76,7 @@
right: 1.188rem;
top: 50%;
transform: translateY(-50%);
color: var(--pgn-color-black);
color: $black;
}
}

View File

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

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,9 +1,14 @@
// @ts-check
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
import initializeStore from '../store';
import { getCertificatesApiUrl } from './data/api';
import { fetchCertificates } from './data/thunks';
import { certificatesDataMock } from './__mocks__';
@@ -14,13 +19,26 @@ let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<Certificates courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);
describe('Certificates', () => {
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders WithoutModes when there are certificates but no certificate modes', async () => {
@@ -111,13 +129,11 @@ describe('Certificates', () => {
.reply(200, noCertificatesMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const user = userEvent.setup();
const { queryByTestId, getByTestId, getByRole } = renderComponent();
await waitFor(async () => {
await waitFor(() => {
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
await user.click(addCertificateButton);
userEvent.click(addCertificateButton);
});
expect(getByTestId('certificates-create-form')).toBeInTheDocument();
@@ -133,13 +149,11 @@ describe('Certificates', () => {
.reply(200, certificatesDataMock);
await executeThunk(fetchCertificates(courseId), store.dispatch);
const user = userEvent.setup();
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
await waitFor(async () => {
await waitFor(() => {
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
await user.click(editCertificateButton);
userEvent.click(editCertificateButton);
});
expect(getByTestId('certificates-edit-form')).toBeInTheDocument();

View File

@@ -1,6 +1,4 @@
import {
render, waitFor, within,
} from '@testing-library/react';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -87,19 +85,17 @@ describe('CertificateCreateForm', () => {
}],
};
const user = userEvent.setup();
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
await user.type(
userEvent.type(
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
courseTitleOverrideValue,
);
await user.type(
userEvent.type(
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
signatoryNameValue,
);
await user.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
axiosMock.onPost(
getCertificateApiUrl(courseId),
@@ -113,9 +109,8 @@ describe('CertificateCreateForm', () => {
});
it('cancel certificates creation', async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent();
await user.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
await waitFor(() => {
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
@@ -132,14 +127,13 @@ describe('CertificateCreateForm', () => {
});
it('add and delete signatory', async () => {
const user = userEvent.setup();
const {
getAllByRole, queryAllByRole, getByText, getByRole,
} = renderComponent();
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
await user.click(addSignatoryBtn);
userEvent.click(addSignatoryBtn);
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
@@ -147,13 +141,13 @@ describe('CertificateCreateForm', () => {
expect(deleteIcons.length).toBe(2);
});
await user.click(deleteIcons[0]);
userEvent.click(deleteIcons[0]);
const confirModal = getByRole('dialog');
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
await user.click(deleteIcons[0]);
await user.click(deleteModalButton);
userEvent.click(deleteIcons[0]);
userEvent.click(deleteModalButton);
await waitFor(() => {
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);

View File

@@ -1,6 +1,6 @@
import { Provider, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -86,24 +86,24 @@ describe('CertificateDetails', () => {
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
});
it('opens confirm modal on delete button click', async () => {
const user = userEvent.setup();
it('opens confirm modal on delete button click', () => {
const { getByRole, getByText } = renderComponent(defaultProps);
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
await user.click(deleteButton);
userEvent.click(deleteButton);
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
});
it('dispatches delete action on confirm modal action', async () => {
const user = userEvent.setup();
const props = { ...defaultProps, courseId, certificateId };
const { getByRole } = renderComponent(props);
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
await user.click(deleteButton);
userEvent.click(deleteButton);
const confirmActionButton = await screen.findByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
await user.click(confirmActionButton);
await waitFor(() => {
const confirmActionButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
userEvent.click(confirmActionButton);
});
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
});

View File

@@ -58,12 +58,11 @@ describe('CertificateDetails', () => {
});
it('handles input change in create mode', async () => {
const user = userEvent.setup();
const { getByPlaceholderText } = renderComponent(defaultProps);
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
const newInputValue = 'New Title';
await user.type(input, newInputValue);
userEvent.type(input, newInputValue);
waitFor(() => {
expect(input.value).toBe(newInputValue);

View File

@@ -1,7 +1,5 @@
import { Provider } from 'react-redux';
import {
render, waitFor, within,
} from '@testing-library/react';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -70,15 +68,15 @@ describe('CertificateEditForm Component', () => {
}],
}],
};
const user = userEvent.setup();
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
await user.type(
userEvent.type(
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
courseTitleOverrideValue,
);
await user.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
axiosMock.onPost(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
@@ -93,17 +91,16 @@ describe('CertificateEditForm Component', () => {
});
it('deletes a certificate and updates the store', async () => {
const user = userEvent.setup();
axiosMock.onDelete(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
).reply(200);
const { getByRole } = renderComponent();
await user.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
const confirmDeleteModal = getByRole('dialog');
await user.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
@@ -113,17 +110,16 @@ describe('CertificateEditForm Component', () => {
});
it('updates loading status if delete fails', async () => {
const user = userEvent.setup();
axiosMock.onDelete(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
).reply(404);
const { getByRole } = renderComponent();
await user.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
const confirmDeleteModal = getByRole('dialog');
await user.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
@@ -133,12 +129,11 @@ describe('CertificateEditForm Component', () => {
});
it('cancel edit form', async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent();
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
await user.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
});

View File

@@ -88,22 +88,20 @@ describe('CertificateSignatories', () => {
});
});
it('adds a new signatory when add button is clicked', async () => {
const user = userEvent.setup();
it('adds a new signatory when add button is clicked', () => {
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
await user.click(getByText(messages.addSignatoryButton.defaultMessage));
userEvent.click(getByText(messages.addSignatoryButton.defaultMessage));
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
});
it('calls remove for the correct signatory when delete icon is clicked', async () => {
const user = userEvent.setup();
const { getAllByRole } = renderComponent(defaultProps);
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
expect(deleteIcons.length).toBe(signatoriesMock.length);
await user.click(deleteIcons[0]);
userEvent.click(deleteIcons[0]);
waitFor(() => {
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);

View File

@@ -34,12 +34,11 @@ describe('Signatory Component', () => {
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
});
it('calls handleEdit when the edit button is clicked', async () => {
const user = userEvent.setup();
it('calls handleEdit when the edit button is clicked', () => {
const { getByRole } = renderSignatory(defaultProps);
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
await user.click(editButton);
userEvent.click(editButton);
expect(mockHandleEdit).toHaveBeenCalled();
});

View File

@@ -60,13 +60,12 @@ describe('Signatory Component', () => {
});
it('handles input change', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const newInputValue = 'Jane Doe';
await user.type(input, newInputValue, { name: 'signatories[0].name' });
userEvent.type(input, newInputValue, { name: 'signatories[0].name' });
waitFor(() => {
expect(handleChange).toHaveBeenCalledWith(expect.anything());
@@ -74,8 +73,7 @@ describe('Signatory Component', () => {
});
});
it('opens image upload modal on button click', async () => {
const user = userEvent.setup();
it('opens image upload modal on button click', () => {
const { getByRole, queryByRole } = renderSignatory(defaultProps);
const replaceButton = getByRole(
'button',
@@ -84,30 +82,28 @@ describe('Signatory Component', () => {
expect(queryByRole('presentation')).not.toBeInTheDocument();
await user.click(replaceButton);
userEvent.click(replaceButton);
expect(getByRole('presentation')).toBeInTheDocument();
});
it('shows confirm modal on delete icon click', async () => {
const user = userEvent.setup();
const { getByLabelText, getByText } = renderSignatory(defaultProps);
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
await user.click(deleteIcon);
userEvent.click(deleteIcon);
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
});
it('cancels deletion of a signatory', async () => {
const user = userEvent.setup();
it('cancels deletion of a signatory', () => {
const { getByRole } = renderSignatory(defaultProps);
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
await user.click(deleteIcon);
userEvent.click(deleteIcon);
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
await user.click(cancelButton);
userEvent.click(cancelButton);
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
});

View File

@@ -1,7 +1,5 @@
import { Provider } from 'react-redux';
import {
render, waitFor, within,
} from '@testing-library/react';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -64,7 +62,6 @@ describe('CertificatesList Component', () => {
});
it('update certificate', async () => {
const user = userEvent.setup();
const {
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
} = renderComponent();
@@ -83,13 +80,13 @@ describe('CertificatesList Component', () => {
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
await user.click(editButtons[1]);
userEvent.click(editButtons[1]);
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
await user.clear(nameInput);
await user.type(nameInput, signatoryNameValue);
userEvent.clear(nameInput);
userEvent.type(nameInput, signatoryNameValue);
await user.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
axiosMock
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
@@ -103,7 +100,6 @@ describe('CertificatesList Component', () => {
});
it('toggle edit signatory', async () => {
const user = userEvent.setup();
const {
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
} = renderComponent();
@@ -111,13 +107,13 @@ describe('CertificatesList Component', () => {
expect(editButtons.length).toBe(3);
await user.click(editButtons[1]);
userEvent.click(editButtons[1]);
await waitFor(() => {
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
});
await user.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
userEvent.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
await waitFor(() => {
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
@@ -125,11 +121,10 @@ describe('CertificatesList Component', () => {
});
it('toggle certificate edit all', async () => {
const user = userEvent.setup();
const { getByTestId } = renderComponent();
const detailsSection = getByTestId('certificate-details');
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
await user.click(editButton);
userEvent.click(editButton);
await waitFor(() => {
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);

View File

@@ -35,7 +35,7 @@ export async function createCertificate(courseId, certificatesData) {
getCertificateApiUrl(courseId),
prepareCertificatePayload(certificatesData),
);
/* istanbul ignore next */
return camelCaseObject(data);
}
@@ -51,7 +51,6 @@ export async function updateCertificate(courseId, certificateData) {
getUpdateCertificateApiUrl(courseId, certificateData.id),
prepareCertificatePayload(certificateData),
);
/* istanbul ignore next */
return camelCaseObject(data);
}

View File

@@ -29,11 +29,12 @@ const slice = createSlice({
fetchCertificatesSuccess: (state, { payload }) => {
Object.assign(state.certificatesData, payload);
},
createCertificateSuccess: /* istanbul ignore next */ (state, action) => {
createCertificateSuccess: (state, action) => {
state.certificatesData.certificates.push(action.payload);
},
updateCertificateSuccess: /* istanbul ignore next */ (state, action) => {
updateCertificateSuccess: (state, action) => {
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
if (index !== -1) {
state.certificatesData.certificates[index] = action.payload;
}

View File

@@ -1,4 +1,3 @@
/* istanbul ignore file */
import { RequestStatus } from '../../data/constants';
import {
hideProcessingNotification,

View File

@@ -1,9 +1,14 @@
// @ts-check
import { render, waitFor } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../../store';
import CertificatesSidebar from './CertificatesSidebar';
import messages from './messages';
import { initializeMocks, render, waitFor } from '../../../testUtils';
const courseId = 'course-123';
let store;
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
@@ -12,11 +17,25 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
const renderComponent = (props) => render(<CertificatesSidebar courseId={courseId} {...props} />);
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<CertificatesSidebar courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);
describe('CertificatesSidebar', () => {
beforeEach(() => {
initializeMocks();
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('renders correctly', async () => {

View File

@@ -53,17 +53,16 @@ describe('HeaderButtons Component', () => {
});
it('updates preview URL param based on selected dropdown item', async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent();
const previewLink = getByRole('link', { name: messages.headingActionsPreview.defaultMessage });
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
await user.click(dropdownButton);
userEvent.click(dropdownButton);
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
await user.click(verifiedMode);
userEvent.click(verifiedMode);
await waitFor(() => {
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
@@ -71,7 +70,6 @@ describe('HeaderButtons Component', () => {
});
it('activates certificate when button is clicked', async () => {
const user = userEvent.setup();
const newCertificateData = {
...certificatesDataMock,
isActive: true,
@@ -80,7 +78,7 @@ describe('HeaderButtons Component', () => {
const { getByRole, queryByRole } = renderComponent();
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
await user.click(activationButton);
userEvent.click(activationButton);
axiosMock.onPost(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
@@ -99,7 +97,6 @@ describe('HeaderButtons Component', () => {
});
it('deactivates certificate when button is clicked', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCertificatesApiUrl(courseId))
.reply(200, { ...certificatesDataMock, isActive: true });
@@ -113,7 +110,7 @@ describe('HeaderButtons Component', () => {
const { getByRole, queryByRole } = renderComponent();
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
await user.click(deactivateButton);
userEvent.click(deactivateButton);
axiosMock.onPost(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),

View File

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

View File

@@ -43,7 +43,7 @@ export const COURSE_CREATOR_STATES = {
granted: 'granted',
denied: 'denied',
disallowedForThisSite: 'disallowed_for_this_site',
} as const;
};
export const DECODED_ROUTES = {
COURSE_UNIT: [
@@ -59,7 +59,6 @@ export const COURSE_BLOCK_NAMES = ({
sequential: { id: 'sequential', name: 'Subsection' },
vertical: { id: 'vertical', name: 'Unit' },
libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' },
});
@@ -92,18 +91,3 @@ export const REGEX_RULES = {
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *'
);
export const iframeStateKeys = {
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
};
export const iframeMessageTypes = {
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
xblockEvent: 'xblock-event',
xblockScroll: 'xblock-scroll',
};

View File

@@ -112,6 +112,7 @@ const CustomLoadingIndicator = () => {
return (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
);

View File

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

View File

@@ -508,7 +508,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should handle search term change', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const {
getByText, getByRole, getByDisplayValue,
} = await getComponent();
@@ -524,7 +523,7 @@ describe('<ContentTagsCollapsible />', () => {
const searchTerm = 'memo';
// Trigger a change in the search field
await user.type(searchField, searchTerm);
userEvent.type(searchField, searchTerm);
await act(async () => {
// Fast-forward time by 500 milliseconds (for the debounce delay)
@@ -536,14 +535,14 @@ describe('<ContentTagsCollapsible />', () => {
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
// Clear search
fireEvent.change(searchField, { target: { value: '' } });
userEvent.clear(searchField);
// Check that the search term has been cleared
expect(searchField).toHaveValue('');
});
it('should close dropdown selector when clicking away', async () => {
const { container, getByText, queryByText } = await getComponent();
const { getByText, queryByText } = await getComponent();
// Click on "Add a tag" button to open dropdown
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
@@ -555,9 +554,10 @@ describe('<ContentTagsCollapsible />', () => {
expect(queryByText('Tag 3')).toBeInTheDocument();
// Simulate clicking outside the dropdown remove focus
const outsideElement = container.querySelector('.taxonomy-tags-count-chip');
const selectElement = container.querySelector('.react-select-add-tags__input');
fireEvent.blur(selectElement, { relatedTarget: outsideElement });
userEvent.click(document.body);
// Simulate clicking outside the dropdown again to close it
userEvent.click(document.body);
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
// the page
@@ -565,7 +565,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should test keyboard navigation of add tags widget', async () => {
const user = userEvent.setup({ delay: null });
const {
getByText,
queryByText,
@@ -599,61 +598,59 @@ describe('<ContentTagsCollapsible />', () => {
*/
// Press tab to focus on first element in dropdown, Tag 1 should be focused
await user.keyboard('{Tab}');
userEvent.tab();
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1Div).toHaveFocus();
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
await user.keyboard('{arrowright}');
userEvent.keyboard('{arrowright}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
await user.keyboard('{arrowleft}');
userEvent.keyboard('{arrowleft}');
expect(queryAllByText('Tag 1.1').length).toBe(1);
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
await user.keyboard('{enter}');
userEvent.keyboard('{enter}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press down arrow to navigate to Tag 1.1, it should be focused
await user.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press down arrow again to navigate to Tag 1.2, it should be fouced
await user.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press down arrow again to navigate to Tag 2, it should be fouced
await user.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.2, it should be focused
await user.keyboard('{arrowup}');
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.1, it should be focused
await user.keyboard('{arrowup}');
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press up arrow again to navigate to Tag 1, it should be focused
await user.keyboard('{arrowup}');
userEvent.keyboard('{arrowup}');
expect(dropdownTag1Div).toHaveFocus();
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
await user.keyboard('{arrowdown}');
await user.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press space key to check Tag 1.2, it should be staged
await user.keyboard('[Space]');
userEvent.keyboard('{space}');
const taxonomyId = 123;
const addedStagedTag = {
value: 'Tag%201,Tag%201.2',
@@ -662,35 +659,35 @@ describe('<ContentTagsCollapsible />', () => {
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
await user.keyboard('{enter}');
userEvent.keyboard('{enter}');
const tagValue = 'Tag%201,Tag%201.2';
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
// Press left arrow to navigate back to Tag 1, it should be focused
await user.keyboard('{arrowleft}');
userEvent.keyboard('{arrowleft}');
expect(dropdownTag1Div).toHaveFocus();
// Press tab key it should jump to cancel button, it should be focused
await user.keyboard('{Tab}');
userEvent.tab();
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
expect(dropdownCancel).toHaveFocus();
// Press tab again, it should exit and close the select menu, since there are not staged tags
await user.keyboard('{Tab}');
userEvent.tab();
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press shift tab, focus back on select menu input, it should open the menu
await user.tab({ shift: true });
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).toBeInTheDocument();
// Press shift tab again, it should focus out and close the select menu
await user.tab({ shift: true });
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press tab again, the select menu should open, then press escape, it should close
await user.keyboard('{Tab}');
userEvent.tab();
expect(queryByText('Tag 3')).toBeInTheDocument();
await user.keyboard('{escape}');
userEvent.keyboard('{escape}');
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
@@ -702,7 +699,7 @@ describe('<ContentTagsCollapsible />', () => {
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
name: /delete/i,
});
fireEvent.click(xButtonAppliedTag);
xButtonAppliedTag.click();
// Check that the applied tag has been removed
expect(appliedTag).not.toBeInTheDocument();

View File

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

View File

@@ -1,4 +1,5 @@
import {
act,
fireEvent,
initializeMocks,
render,
@@ -21,7 +22,6 @@ const path = '/content/:contentId?/*';
const mockOnClose = jest.fn();
const mockSetBlockingSheet = jest.fn();
const mockNavigate = jest.fn();
const mockSidebarAction = jest.fn();
mockContentTaxonomyTagsData.applyMock();
mockTaxonomyListData.applyMock();
mockTaxonomyTagsData.applyMock();
@@ -34,7 +34,6 @@ const {
languageWithoutTagsId,
largeTagsId,
emptyTagsId,
containerTagsId,
} = mockContentTaxonomyTagsData;
jest.mock('react-router-dom', () => ({
@@ -42,20 +41,14 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
jest.mock('../library-authoring/common/context/SidebarContext', () => ({
...jest.requireActual('../library-authoring/common/context/SidebarContext'),
useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }),
}));
const renderDrawer = (contentId, drawerParams = {}, renderPath = path, containerId = '') => {
const params = { contentId, containerId };
return render(
const renderDrawer = (contentId, drawerParams = {}) => (
render(
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
<ContentTagsDrawer {...drawerParams} />
</ContentTagsDrawerSheetContext.Provider>,
{ path: renderPath, params },
);
};
{ path, params: { contentId } },
)
);
describe('<ContentTagsDrawer />', () => {
beforeEach(async () => {
@@ -68,15 +61,19 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the content data query is complete', async () => {
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
});
it('shows spinner before the taxonomy tags query is complete', async () => {
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
});
it('shows the content display name after the query is complete in drawer variant', async () => {
@@ -101,12 +98,15 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
const { container } = renderDrawer(largeTagsId);
await screen.findByText('Taxonomy 1');
await screen.findByText('Taxonomy 2');
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
expect(tagCountBadges[0].textContent).toBe('3');
expect(tagCountBadges[1].textContent).toBe('2');
await act(async () => {
const { container } = renderDrawer(largeTagsId);
await waitFor(() => { expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(screen.getByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
expect(tagCountBadges[0].textContent).toBe('3');
expect(tagCountBadges[1].textContent).toBe('2');
});
});
it('should be read only on first render on drawer variant', async () => {
@@ -192,26 +192,6 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to edit mode sidebar action is set to JumpToManageTags', async () => {
mockSidebarAction.mockReturnValueOnce('jump-to-manage-tags');
renderDrawer(stagedTagsId, { variant: 'component' });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// Show delete tag buttons
expect(screen.getAllByRole('button', {
name: /delete/i,
}).length).toBe(2);
// Show add a tag select
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
// Show cancel button
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
// Show save button
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to read mode when click on `Cancel` on drawer variant', async () => {
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
@@ -694,42 +674,6 @@ describe('<ContentTagsDrawer />', () => {
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
});
[
'lct:org:lib:unit:1',
'lib-collection:org:lib:1',
'lb:org:lib:html:1',
].forEach((containerId) => {
it(`should invalidate children query when update child tag when containerId is ${containerId}`, async () => {
const newPath = '/container/:containerId/';
const { axiosMock, queryClient } = initializeMocks();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const url = getContentTaxonomyTagsApiUrl(containerTagsId);
axiosMock.onPut(url).reply(200);
renderDrawer(containerTagsId, { id: containerTagsId }, newPath, containerId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
const editTagsButton = screen.getByRole('button', {
name: /edit tags/i,
});
fireEvent.click(editTagsButton);
const saveButton = screen.getByRole('button', {
name: /save/i,
});
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
'contentLibrary',
'lib:org:lib',
'content',
'container',
containerId,
'children',
]);
});
});
it('should taxonomies must be ordered', async () => {
renderDrawer(largeTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();

View File

@@ -14,7 +14,6 @@ import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading';
import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext';
interface TaxonomyListProps {
contentId: string;
@@ -89,6 +88,7 @@ const ContentTagsDrawerTitle = () => {
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
@@ -148,6 +148,7 @@ const ContentTagsDrawerVariantFooter = ({ onClose, readOnly }: ContentTagsDrawer
: (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
)}
@@ -195,6 +196,7 @@ const ContentTagsComponentVariantFooter = ({ readOnly = false }: ContentTagsComp
<div className="d-flex justify-content-center">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
@@ -225,6 +227,7 @@ interface ContentTagsDrawerProps {
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
* Functions to close the drawer are handled internally.
* TODO: We can delete this method when is no longer used on edx-platform.
* - If you want to use it as react component, you need to pass the content id and the close functions
* through the component parameters.
*/
@@ -242,9 +245,8 @@ const ContentTagsDrawer = ({
if (contentId === undefined) {
throw new Error('Error: contentId cannot be null.');
}
const { sidebarAction } = useSidebarContext();
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
const context = useCreateContentTagsDrawerContext(contentId, !readOnly);
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {
@@ -259,7 +261,6 @@ const ContentTagsDrawer = ({
closeToast,
setCollapsibleToInitalState,
otherTaxonomies,
toEditMode,
} = context;
let onCloseDrawer: () => void;
@@ -302,13 +303,8 @@ const ContentTagsDrawer = ({
// First call of the initial collapsible states
React.useEffect(() => {
// Open tag edit mode when sidebarAction is JumpToManageTags
if (sidebarAction === SidebarActions.JumpToManageTags) {
toEditMode();
} else {
setCollapsibleToInitalState();
}
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded, sidebarAction, toEditMode]);
setCollapsibleToInitalState();
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
const renderFooter = () => {
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {

View File

@@ -20,11 +20,9 @@ import { ContentTagsDrawerSheetContext } from './common/context';
* To *use* the context, just use `useContext(ContentTagsDrawerContext)`
* @param {string} contentId
* @param {boolean} canTagObject
* @param {boolean} fetchMetadata=false If true, fetches metadata for the contentId. This is used on `edx-platform`
* and the Course/Unit Outline to show the content name as the drawer title.
* @returns {ContentTagsDrawerContextData}
*/
export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetchMetadata = false) => {
export const useCreateContentTagsDrawerContext = (contentId, canTagObject) => {
const intl = useIntl();
const org = extractOrgFromContentId(contentId);
@@ -50,7 +48,7 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
const updateTags = useContentTaxonomyTagsUpdater(contentId);
// Fetch from database
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId, fetchMetadata);
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,

View File

@@ -234,6 +234,7 @@ const ContentTagsDropDownSelector = ({
<div className="d-flex justify-content-center align-items-center flex-row">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>

View File

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

View File

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

View File

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

View File

@@ -70,12 +70,17 @@ export async function getContentTaxonomyTagsCount(contentId) {
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/component)
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* @param {string} contentId The id of the content object (unit/component)
* @returns {Promise<import("./types.js").ContentData>}
* @returns {Promise<import("./types.js").ContentData | null>}
*/
export async function getContentData(contentId) {
let url;
if (contentId.startsWith('lib-collection:')) {
// This type of usage_key is not used to obtain collections
// is only used in tagging.
return null;
}
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);

View File

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

View File

@@ -7,7 +7,6 @@ import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useParams } from 'react-router';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
@@ -15,7 +14,7 @@ import {
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
@@ -112,14 +111,12 @@ export const useContentTaxonomyTagsData = (contentId) => (
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object
* @param {boolean} enabled Flag to enable/disable the query
* @param {string} contentId The id of the content object (unit/component)
*/
export const useContentData = (contentId, enabled) => (
export const useContentData = (contentId) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled,
queryFn: () => getContentData(contentId),
})
);
@@ -130,7 +127,6 @@ export const useContentData = (contentId, enabled) => (
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe'];
const { containerId } = useParams();
return useMutation({
/**
@@ -143,7 +139,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
* >}
*/
mutationFn: ({ tagsData }) => updateContentTaxonomyTags(contentId, tagsData),
onSettled: () => {
onSettled: /* istanbul ignore next */ () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
let contentPattern;
@@ -153,18 +149,13 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:') || contentId.startsWith('lct:')) {
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:')) {
// Obtain library id from contentId
const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
// Invalidate content search to update tags count
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
// If the tags for an item were edited from a container page (Unit, Subsection, Section),
// invalidate children query to fetch count again.
if (containerId) {
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(containerId));
}
}
},
onSuccess: /* istanbul ignore next */ () => {

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react';
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import {
useTaxonomyTagsData,
useContentTaxonomyTagsData,
@@ -157,7 +158,7 @@ describe('useContentTaxonomyTagsUpdater', () => {
const contentId = 'testerContent';
const taxonomyId = 123;
const mutation = renderHook(() => useContentTaxonomyTagsUpdater(contentId)).result.current;
const mutation = useContentTaxonomyTagsUpdater(contentId);
const tagsData = [{
taxonomy: taxonomyId,
tags: ['tag1', 'tag2'],

View File

@@ -1,3 +1,2 @@
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
export { default as ContentTagsDrawerSheet } from './ContentTagsDrawerSheet';
export { useContentTaxonomyTagsData } from './data/apiHooks';

View File

@@ -1,4 +1,5 @@
import { useState, useMemo } from 'react';
// @ts-check
import React, { useState, useMemo } from 'react';
import {
Card, Stack, Button, Collapsible, Icon,
} from '@openedx/paragon';
@@ -9,19 +10,10 @@ import { ContentTagsDrawerSheet } from '..';
import messages from '../messages';
import { useContentTaxonomyTagsData } from '../data/apiHooks';
import type { ContentTaxonomyTagData, Tag } from '../data/types';
import { LoadingSpinner } from '../../generic/Loading';
import TagsTree from '../TagsTree';
interface TagsSidebarBodyProps {
readOnly: boolean
}
type TagTree = {
[key: string]: { children: TagTree, canChangeObjecttag: boolean, canDeleteObjecttag: boolean }
};
const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
const TagsSidebarBody = () => {
const intl = useIntl();
const [showManageTags, setShowManageTags] = useState(false);
const contentId = useParams().blockId;
@@ -32,8 +24,8 @@ const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId || '');
const buildTagsTree = (contentTags: Tag[]) => {
const resultTree: TagTree = {};
const buildTagsTree = (contentTags) => {
const resultTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;
@@ -54,7 +46,7 @@ const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
};
const tree = useMemo(() => {
const result: (Omit<ContentTaxonomyTagData, 'tags'> & { tags: TagTree })[] = [];
const result = [];
if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
result.push({
@@ -96,13 +88,7 @@ const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
</div>
)}
<Button
className="mt-3 ml-2"
variant="outline-primary"
size="sm"
onClick={() => setShowManageTags(true)}
disabled={readOnly}
>
<Button className="mt-3 ml-2" variant="outline-primary" size="sm" onClick={() => setShowManageTags(true)}>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
</Stack>
@@ -116,4 +102,6 @@ const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
);
};
TagsSidebarBody.propTypes = {};
export default TagsSidebarBody;

View File

@@ -1,14 +1,10 @@
import TagsSidebarHeader from './TagsSidebarHeader';
import TagsSidebarBody from './TagsSidebarBody';
interface TagsSidebarControlsProps {
readOnly: boolean,
}
const TagsSidebarControls = ({ readOnly }: TagsSidebarControlsProps) => (
const TagsSidebarControls = () => (
<>
<TagsSidebarHeader />
<TagsSidebarBody readOnly={readOnly} />
<TagsSidebarBody />
</>
);

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
@@ -33,4 +33,4 @@ AriaLiveRegion.propTypes = {
enableQuality: PropTypes.bool.isRequired,
};
export default AriaLiveRegion;
export default injectIntl(AriaLiveRegion);

View File

@@ -2,10 +2,11 @@ import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button, Icon } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { useWaffleFlags } from '../../data/apiHooks';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const getUpdateLinks = (courseId, waffleFlags) => {
@@ -34,7 +35,7 @@ const ChecklistItemBody = ({
isCompleted,
}) => {
const intl = useIntl();
const waffleFlags = useWaffleFlags(courseId);
const waffleFlags = useSelector(getWaffleFlags);
const updateLinks = getUpdateLinks(courseId, waffleFlags);
return (

View File

@@ -1,10 +1,11 @@
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { ModeComment } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { useWaffleFlags } from '../../data/apiHooks';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const ChecklistItemComment = ({
@@ -12,7 +13,7 @@ const ChecklistItemComment = ({
checkId,
data,
}) => {
const waffleFlags = useWaffleFlags(courseId);
const waffleFlags = useSelector(getWaffleFlags);
const getPathToCourseOutlinePage = (assignmentId) => (waffleFlags.useNewCourseOutlinePage
? `/course/${courseId}#${assignmentId}` : `${getConfig().STUDIO_BASE_URL}/course/${courseId}#${assignmentId}`);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Container, Stack } from '@openedx/paragon';
import { LoadingSpinner } from '../../generic/Loading';
import { getCompletionCount, useChecklistState } from './hooks';
import ChecklistItemBody from './ChecklistItemBody';
@@ -127,4 +129,4 @@ ChecklistSection.propTypes = {
isLoading: PropTypes.bool.isRequired,
};
export default ChecklistSection;
export default injectIntl(ChecklistSection);

View File

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

View File

@@ -4,7 +4,9 @@ import {
initializeMocks, render, screen, within,
} from '../../testUtils';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { generateCourseLaunchData } from '../factories/mockApiResponses';
import { executeThunk } from '../../utils';
import { checklistItems } from './utils/courseChecklistData';
import messages from './messages';
@@ -32,7 +34,7 @@ const renderComponent = (props) => {
describe('ChecklistSection', () => {
beforeEach(async () => {
const { axiosMock } = initializeMocks();
const { axiosMock, reduxStore } = initializeMocks();
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {
@@ -41,6 +43,7 @@ describe('ChecklistSection', () => {
useNewScheduleDetailsPage: true,
useNewCourseOutlinePage: true,
});
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
});
it('a heading using the dataHeading prop', () => {

View File

@@ -71,6 +71,16 @@ const messages = defineMessages({
defaultMessage: 'Learners engage best with short videos followed by opportunities to practice. Ensure that 80% or more of course videos are less than 10 minutes long.',
description: 'Description for a section that prompts a user to follow best practices for video length',
},
mobileFriendlyVideoShortDescription: {
id: 'mobileFriendlyVideoShortDescription',
defaultMessage: 'Create mobile-friendly video',
description: 'Label for a section that describes mobile friendly videos',
},
mobileFriendlyVideoLongDescription: {
id: 'mobileFriendlyVideoLongDescription',
defaultMessage: 'Mobile-friendly videos can be viewed across all supported devices. Ensure that at least 90% of course videos are mobile friendly by uploading course videos to the edX video pipeline.',
description: 'Description for a section that prompts a user to follow best practices for mobile friendly videos',
},
diverseSequencesShortDescription: {
id: 'diverseSequencesShortDescription',
defaultMessage: 'Build diverse learning sequences',

View File

@@ -36,6 +36,10 @@ export const checklistItems = {
id: 'videoDuration',
pacingTypeFilter: filters.ALL,
},
{
id: 'mobileFriendlyVideo',
pacingTypeFilter: filters.ALL,
},
{
id: 'diverseSequences',
pacingTypeFilter: filters.ALL,

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