diff --git a/.env b/.env index d906240d6..2874b4a97 100644 --- a/.env +++ b/.env @@ -34,7 +34,6 @@ ENABLE_NEW_HOME_PAGE = false ENABLE_NEW_COURSE_OUTLINE_PAGE = false ENABLE_NEW_UPDATES_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false -ENABLE_NEW_SCHEDULE_DETAILS_PAGE = false ENABLE_NEW_GRADING_PAGE = false ENABLE_NEW_COURSE_TEAM_PAGE = false ENABLE_NEW_IMPORT_PAGE = false @@ -45,3 +44,4 @@ BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=false +INVITE_STUDENTS_EMAIL_TO='' diff --git a/.env.development b/.env.development index 391cd08fb..b17611fad 100644 --- a/.env.development +++ b/.env.development @@ -36,7 +36,6 @@ ENABLE_NEW_HOME_PAGE = false ENABLE_NEW_COURSE_OUTLINE_PAGE = false ENABLE_NEW_UPDATES_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false -ENABLE_NEW_SCHEDULE_DETAILS_PAGE = false ENABLE_NEW_GRADING_PAGE = false ENABLE_NEW_COURSE_TEAM_PAGE = false ENABLE_NEW_IMPORT_PAGE = false @@ -47,3 +46,4 @@ BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=true +INVITE_STUDENTS_EMAIL_TO="someone@domain.com" diff --git a/.env.test b/.env.test index 495c6754c..8a5740394 100644 --- a/.env.test +++ b/.env.test @@ -32,7 +32,6 @@ ENABLE_NEW_HOME_PAGE = false ENABLE_NEW_COURSE_OUTLINE_PAGE = true ENABLE_NEW_UPDATES_PAGE = true ENABLE_NEW_VIDEO_UPLOAD_PAGE = true -ENABLE_NEW_SCHEDULE_DETAILS_PAGE = true ENABLE_NEW_GRADING_PAGE = true ENABLE_NEW_COURSE_TEAM_PAGE = true ENABLE_NEW_IMPORT_PAGE = true @@ -40,3 +39,4 @@ ENABLE_NEW_EXPORT_PAGE = true ENABLE_UNIT_PAGE = true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true BBB_LEARN_MORE_URL='' +INVITE_STUDENTS_EMAIL_TO="someone@domain.com" diff --git a/package-lock.json b/package-lock.json index 51adac5b4..f1eb55834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-component-footer": "12.0.0", "@edx/frontend-enterprise-hotjar": "^1.2.1", - "@edx/frontend-lib-content-components": "^1.163.1", + "@edx/frontend-lib-content-components": "^1.167.0", "@edx/frontend-platform": "4.2.0", "@edx/paragon": "^20.45.4", "@fortawesome/fontawesome-svg-core": "1.2.28", @@ -30,6 +30,7 @@ "moment": "2.29.2", "prop-types": "15.7.2", "react": "16.14.0", + "react-datepicker": "^4.13.0", "react-dom": "16.14.0", "react-helmet": "^6.1.0", "react-redux": "7.1.3", @@ -1884,16 +1885,19 @@ } }, "node_modules/@codemirror/lang-html": { - "version": "6.1.0", - "license": "MIT", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.4.tgz", + "integrity": "sha512-NbrqEp0GUOSvhZbG6BxVcS4SzM4SvN5vkkD2sEoETHIyHLZDb9pO1z+r1L2heb6LuF4bUeBCXKjHXoSeDJHO1w==", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/language": "^6.0.0", + "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.2.2", "@lezer/common": "^1.0.0", - "@lezer/html": "^1.0.0" + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" } }, "node_modules/@codemirror/lang-javascript": { @@ -1951,14 +1955,16 @@ } }, "node_modules/@codemirror/state": { - "version": "6.1.1", - "license": "MIT" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.1.tgz", + "integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==" }, "node_modules/@codemirror/view": { - "version": "6.2.0", - "license": "MIT", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.13.2.tgz", + "integrity": "sha512-XA/jUuu1H+eTja49654QkrQwx2CuDMdjciHcdqyasfTVo4HRlvj87rD/Qmm4HfnhwX8234FQSSA8HxEzxihX/Q==", "dependencies": { - "@codemirror/state": "^6.0.0", + "@codemirror/state": "^6.1.4", "style-mod": "^4.0.0", "w3c-keyname": "^2.2.4" } @@ -2291,9 +2297,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.163.1", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.163.1.tgz", - "integrity": "sha512-GrEsEh+AkO+9slCU+PK0I+w2/7OkJS08rRZpuLxNF2g7lv4IC6YcCuyhCSR99lIcTPmqsceKvuiLy8ehpMobDg==", + "version": "1.167.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.167.0.tgz", + "integrity": "sha512-fISyyUiZhdAkEhYsz8hJOIt5w3vpBAH5J0pf4UpxyzqzpxU6anTI3VIkF3i78yJMZgIQ6Sl4eW75KXqIXAijPw==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", @@ -2331,8 +2337,8 @@ "@edx/frontend-platform": "^4.0.0", "@edx/paragon": "^20.27.0", "prop-types": "^15.5.10", - "react": "^16.14.0", - "react-dom": "^16.14.0" + "react": "^16.14.0 || ^17.0.0", + "react-dom": "^16.14.0 || ^17.0.0" } }, "node_modules/@edx/frontend-lib-content-components/node_modules/@edx/browserslist-config": { @@ -3797,8 +3803,9 @@ "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.0.0", - "license": "MIT", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.2.tgz", + "integrity": "sha512-5TKMAReXukfEmIiZprDlGfZVfOOCyEStFi1YLzxclm9H3G/HHI49/2wzlRT6bQw5r7PoZVEtjTItEkb/UuZQyg==", "dependencies": { "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" @@ -3812,8 +3819,9 @@ } }, "node_modules/@lezer/html": { - "version": "1.0.1", - "license": "MIT", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.4.tgz", + "integrity": "sha512-HdJYMVZcT4YsMo7lW3ipL4NoyS2T67kMPuSVS5TgLGqmaCjEU/D6xv7zsa1ktvTK5lwk7zzF1e3eU6gBZIPm5g==", "dependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", @@ -7662,6 +7670,21 @@ "node": ">=10" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "dev": true, @@ -17443,6 +17466,23 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-datepicker": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.13.0.tgz", + "integrity": "sha512-1S8yAqzcHE+LjCjMrTXJfUkTVijTPogxUYrmQmSpmRJ23fdC2w8cg04jzaEAyesTzyUa06JzayZJKk85QHbvcw==", + "dependencies": { + "@popperjs/core": "^2.9.2", + "classnames": "^2.2.6", + "date-fns": "^2.24.0", + "prop-types": "^15.7.2", + "react-onclickoutside": "^6.12.2", + "react-popper": "^2.3.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18", + "react-dom": "^16.9.0 || ^17 || ^18" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "dev": true, @@ -17808,6 +17848,19 @@ "react": ">=16.8.0" } }, + "node_modules/react-onclickoutside": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz", + "integrity": "sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/react-onclickoutside/blob/master/FUNDING.md" + }, + "peerDependencies": { + "react": "^15.5.x || ^16.x || ^17.x || ^18.x", + "react-dom": "^15.5.x || ^16.x || ^17.x || ^18.x" + } + }, "node_modules/react-overlays": { "version": "5.2.0", "license": "MIT", diff --git a/package.json b/package.json index 4f13c3845..03fbe8378 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-component-footer": "12.0.0", "@edx/frontend-enterprise-hotjar": "^1.2.1", - "@edx/frontend-lib-content-components": "^1.163.1", + "@edx/frontend-lib-content-components": "^1.167.0", "@edx/frontend-platform": "4.2.0", "@edx/paragon": "^20.45.4", "@fortawesome/fontawesome-svg-core": "1.2.28", @@ -55,6 +55,7 @@ "moment": "2.29.2", "prop-types": "15.7.2", "react": "16.14.0", + "react-datepicker": "^4.13.0", "react-dom": "16.14.0", "react-helmet": "^6.1.0", "react-redux": "7.1.3", diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 016043568..657ddd6c0 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -11,6 +11,7 @@ import VideoSelectorContainer from './selectors/VideoSelectorContainer'; import CustomPages from './custom-pages'; import FilesAndUploads from './files-and-uploads'; import { AdvancedSettings } from './advanced-settings'; +import ScheduleAndDetails from './schedule-and-details'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -86,10 +87,7 @@ const CourseAuthoringRoutes = ({ courseId }) => { )} - {process.env.ENABLE_NEW_SCHEDULE_DETAILS_PAGE === 'true' - && ( - - )} + {process.env.ENABLE_NEW_GRADING_PAGE === 'true' diff --git a/src/CourseAuthoringRoutes.test.jsx b/src/CourseAuthoringRoutes.test.jsx index 99adaeb8e..8e1bd6fd1 100644 --- a/src/CourseAuthoringRoutes.test.jsx +++ b/src/CourseAuthoringRoutes.test.jsx @@ -14,6 +14,17 @@ const videoSelectorContainerMockText = 'Video Selector Container'; const customPagesMockText = 'Custom Pages'; let store; const mockComponentFn = jest.fn(); + +// Mock the TinyMceWidget from frontend-lib-content-components +jest.mock('@edx/frontend-lib-content-components', () => ({ + TinyMceWidget: () => Widget, + Footer: () => Footer, + prepareEditorRef: jest.fn(() => ({ + refReady: true, + setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'), + })), +})); + jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), useRouteMatch: () => ({ diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index 29ef4639f..9b9f8b720 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -55,6 +55,10 @@ const AdvancedSettings = ({ intl, courseId }) => { }, disabledStates: ['pending'], }; + const { + proctoringErrors, + mfeProctoredExamSettingsUrl, + } = proctoringExamErrors; useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { @@ -123,10 +127,10 @@ const AdvancedSettings = ({ intl, courseId }) => { <> - {(proctoringExamErrors?.length > 0) && ( + {(proctoringErrors?.length > 0) && ( { - + diff --git a/src/advanced-settings/data/selectors.js b/src/advanced-settings/data/selectors.js index b3a973a9b..933939df7 100644 --- a/src/advanced-settings/data/selectors.js +++ b/src/advanced-settings/data/selectors.js @@ -1,5 +1,5 @@ export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus; export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings; export const getSavingStatus = (state) => state.advancedSettings.savingStatus; -export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors.proctoringErrors; +export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors; export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message; diff --git a/src/advanced-settings/setting-alert/SettingAlert.jsx b/src/advanced-settings/setting-alert/SettingAlert.jsx deleted file mode 100644 index a7520f390..000000000 --- a/src/advanced-settings/setting-alert/SettingAlert.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { Alert } from '@edx/paragon'; -import PropTypes from 'prop-types'; - -const SettingAlert = ({ - title, description, ...props -}) => ( - - {title} - {description} - -); - -SettingAlert.propTypes = { - title: PropTypes.string, - description: PropTypes.string, -}; - -SettingAlert.defaultProps = { - title: undefined, - description: undefined, -}; - -export default SettingAlert; diff --git a/src/advanced-settings/setting-alert/SettingsAlert.test.jsx b/src/advanced-settings/setting-alert/SettingsAlert.test.jsx deleted file mode 100644 index 8bc8d6d9b..000000000 --- a/src/advanced-settings/setting-alert/SettingsAlert.test.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import SettingAlert from './SettingAlert'; - -const alertTitle = 'Test Title'; -const alertDescription = 'Test Description'; -const alertClassName = 'custom-class'; - -const RootWrapper = () => ( - - - -); - -describe('', () => { - it('renders the title and description correctly', () => { - const { getByText } = render(); - const titleElement = getByText(alertTitle); - const descriptionElement = getByText(alertDescription); - expect(titleElement).toBeInTheDocument(); - expect(descriptionElement).toBeInTheDocument(); - }); - it('renders the alert with additional props', () => { - const { getByRole } = render(); - const alertElement = getByRole('alert'); - const classNameExists = alertElement.classList.contains(alertClassName); - expect(alertElement).toBeInTheDocument(); - expect(classNameExists).toBe(true); - }); -}); diff --git a/src/advanced-settings/settings-sidebar/SettingsSidebar.jsx b/src/advanced-settings/settings-sidebar/SettingsSidebar.jsx index a324e8c8e..494bac0de 100644 --- a/src/advanced-settings/settings-sidebar/SettingsSidebar.jsx +++ b/src/advanced-settings/settings-sidebar/SettingsSidebar.jsx @@ -1,87 +1,47 @@ -import React, { useContext } from 'react'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { AppContext } from '@edx/frontend-platform/react'; +import React from 'react'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; -import { Hyperlink } from '@edx/paragon'; -import { getPagePath } from '../../utils'; +import HelpSidebar from '../../generic/help-sidebar'; import messages from './messages'; -const SettingsSidebar = ({ intl, courseId }) => { - const { config } = useContext(AppContext); - return ( - - ); +const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => ( + + + {intl.formatMessage(messages.about)} + + + {intl.formatMessage(messages.aboutDescription1)} + + + {intl.formatMessage(messages.aboutDescription2)} + + + Note: }} + /> + + +); + +SettingsSidebar.defaultProps = { + proctoredExamSettingsUrl: '', }; SettingsSidebar.propTypes = { intl: intlShape.isRequired, courseId: PropTypes.string.isRequired, + proctoredExamSettingsUrl: PropTypes.string, }; export default injectIntl(SettingsSidebar); diff --git a/src/assets/scss/_form.scss b/src/assets/scss/_form.scss new file mode 100644 index 000000000..622513d14 --- /dev/null +++ b/src/assets/scss/_form.scss @@ -0,0 +1,81 @@ +.form-group-custom { + .pgn__form-label { + 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 $font-weight-normal .75rem/1.25rem $font-family-base; + color: $gray-500; + margin-top: .5rem; + } + + .dropdown-toggle { + width: 100%; + justify-content: space-between; + } + + .form-group-custom_isInvalid { + input { + border-color: $form-feedback-invalid-color; + } + } + + .feedback-error { + color: $form-feedback-invalid-color; + } +} + +.datepicker-custom { + margin: 0; + + .datepicker-custom-control { + display: block; + width: 100%; + 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: $input-focus-color; + background-color: $input-bg; + border-color: $input-focus-border-color; + box-shadow: $input-focus-box-shadow; + outline: 0; + } + + &::placeholder { + color: $input-placeholder-color; + } + } + + .datepicker-custom-control_readonly { + border-color: transparent; + background: $input-disabled-bg; + } + + .datepicker-custom-control_isInvalid { + border-color: $form-feedback-invalid-color; + } + + .datepicker-custom-control-icon { + position: absolute; + z-index: 2; + right: 1.188rem; + top: 50%; + transform: translateY(-50%); + color: $black; + } +} diff --git a/src/assets/scss/_utilities.scss b/src/assets/scss/_utilities.scss new file mode 100644 index 000000000..441fe804d --- /dev/null +++ b/src/assets/scss/_utilities.scss @@ -0,0 +1,3 @@ +.text-black { + color: $black; +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..ad20d0d69 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,8 @@ +export const DATE_FORMAT = 'MM/dd/yyyy'; +export const TIME_FORMAT = 'HH:mm'; +export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z'; +export const DEFAULT_EMPTY_WYSIWYG_VALUE = ' '; +export const STATEFUL_BUTTON_STATES = { + pending: 'pending', + default: 'default', +}; diff --git a/src/generic/ApplyWrapper.jsx b/src/generic/ApplyWrapper.jsx new file mode 100644 index 000000000..ffb7ca388 --- /dev/null +++ b/src/generic/ApplyWrapper.jsx @@ -0,0 +1,3 @@ +const ApplyWrapper = ({ condition, wrapper, children }) => (condition ? wrapper(children) : children); + +export default ApplyWrapper; diff --git a/src/generic/WysiwygEditor.jsx b/src/generic/WysiwygEditor.jsx new file mode 100644 index 000000000..0f6c7d087 --- /dev/null +++ b/src/generic/WysiwygEditor.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect, Provider } from 'react-redux'; +import { createStore } from 'redux'; +import { getConfig } from '@edx/frontend-platform'; +import { + prepareEditorRef, + TinyMceWidget, +} from '@edx/frontend-lib-content-components'; + +import { DEFAULT_EMPTY_WYSIWYG_VALUE } from '../constants'; + +const store = createStore(() => ({})); + +export const SUPPORTED_TEXT_EDITORS = { + text: 'text', + expandable: 'expandable', +}; + +const mapStateToProps = () => ({ + assets: {}, + lmsEndpointUrl: getConfig().LMS_BASE_URL, + studioEndpointUrl: getConfig().STUDIO_BASE_URL, + isLibrary: true, + onEditorChange: () => ({}), +}); + +const Editor = connect(mapStateToProps)(TinyMceWidget); + +export const WysiwygEditor = ({ + initialValue, editorType, onChange, minHeight, +}) => { + const { editorRef, refReady, setEditorRef } = prepareEditorRef(); + + const isEquivalentCodeExtraSpaces = (first, second) => { + // Utils allows to compare code extra spaces + const removeWhitespace = (str) => str.replace(/\s/g, ''); + return removeWhitespace(first) === removeWhitespace(second); + }; + + const isEquivalentCodeQuotes = (first, second) => { + // Utils allows to compare code with single quotes and double quotes + const normalizeQuotes = (section) => section.replace(/'/g, '"'); + return normalizeQuotes(first) === normalizeQuotes(second); + }; + + // default initial string returned onEditorChange if empty input + const needToChange = (value) => !isEquivalentCodeQuotes(initialValue, value) + && !isEquivalentCodeExtraSpaces(initialValue, value) + && (initialValue !== DEFAULT_EMPTY_WYSIWYG_VALUE || value !== ''); + + const handleUpdate = (value, editor) => { + // With bookmarks keep the current cursor position at the end of the line + // and it inserts new content only at the end of the line. + const bm = editor.selection.getBookmark(); + const existingContent = editor.getContent({ format: 'raw' }); + if (needToChange(value)) { onChange(value); } + editor.setContent(existingContent); + editor.selection.moveToBookmark(bm); + }; + + if (!refReady) { + return null; + } + + return ( + + ({})} + /> + + ); +}; + +WysiwygEditor.defaultProps = { + initialValue: '', + editorType: SUPPORTED_TEXT_EDITORS.text, + minHeight: 200, +}; + +WysiwygEditor.propTypes = { + initialValue: PropTypes.string, + editorType: PropTypes.oneOf(Object.values(SUPPORTED_TEXT_EDITORS)), + onChange: PropTypes.func.isRequired, + minHeight: PropTypes.number, +}; diff --git a/src/generic/alert-message/AlertMessage.test.jsx b/src/generic/alert-message/AlertMessage.test.jsx index f68f89254..35196dede 100644 --- a/src/generic/alert-message/AlertMessage.test.jsx +++ b/src/generic/alert-message/AlertMessage.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import SettingAlert from '.'; +import AlertMessage from '.'; const alertTitle = 'Test Title'; const alertDescription = 'Test Description'; @@ -10,7 +10,7 @@ const alertClassName = 'custom-class'; const RootWrapper = () => ( - ( ); -describe('', () => { +describe('', () => { it('renders the title and description correctly', () => { const { getByText } = render(); const titleElement = getByText(alertTitle); diff --git a/src/generic/course-upload-image/CourseUploadImage.scss b/src/generic/course-upload-image/CourseUploadImage.scss new file mode 100644 index 000000000..966f592be --- /dev/null +++ b/src/generic/course-upload-image/CourseUploadImage.scss @@ -0,0 +1,26 @@ +.image-preview { + @include pgn-box-shadow(1, "down"); + + display: block; + width: 23.4375rem; + height: 12.5rem; + overflow: hidden; + margin: 0 auto; + border: .0625rem solid $gray-300; + padding: .625rem; + background: $white; + + img { + display: block; + width: 100%; + min-height: 100%; + } +} + +.image-body { + text-align: center; + + .pgn__dropzone { + background: $white; + } +} diff --git a/src/generic/course-upload-image/CourseUploadImage.test.jsx b/src/generic/course-upload-image/CourseUploadImage.test.jsx new file mode 100644 index 000000000..0cbf22530 --- /dev/null +++ b/src/generic/course-upload-image/CourseUploadImage.test.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { fireEvent, render, act } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../../store'; +import messages from './messages'; +import CourseUploadImage from '.'; + +let store; + +const onChangeMock = jest.fn(); +const RootWrapper = (props) => ( + + + + + +); + +const props = { + label: 'foo-label-field', + customHelpText: 'custom-help-text', + assetImagePath: '/asset-v1:edX+M12+2T2023+type@asset+block@image_1.png', + imageNameField: 'cardImageName', + assetImageField: 'cardImageAssetPath', + identifierFieldText: 'some identified field', + showImageBodyText: true, + customInputPlaceholder: 'custom-input-placeholder', + onChange: onChangeMock, +}; + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + }); + + it('renders successfully', () => { + const { getByText, getByPlaceholderText } = render( + , + ); + expect(getByText(props.label)).toBeInTheDocument(); + expect(getByText(props.customHelpText)).toBeInTheDocument(); + expect(getByPlaceholderText(props.customInputPlaceholder)).toBeInTheDocument(); + }); + + it('should call onChange if input value changed', async () => { + const { getByPlaceholderText } = render(); + const input = getByPlaceholderText(props.customInputPlaceholder); + await act(() => { + fireEvent.change(input, { target: { value: '/assets' } }); + }); + expect(onChangeMock).toHaveBeenCalledWith( + '/assets', + props.assetImageField, + ); + }); + + it('should change body text if input cleared', () => { + const initialProps = { ...props, assetImagePath: '' }; + const { getByText } = render(); + expect( + getByText(messages.uploadImageEmpty.defaultMessage), + ).toBeInTheDocument(); + }); + + it('should hide body text if showImageBodyText disabled', () => { + const initialProps = { ...props, showImageBodyText: false }; + const { queryAllByText } = render(); + expect(queryAllByText(messages.uploadImageEmpty.defaultMessage).length).toBe(0); + expect(queryAllByText(messages.uploadImageBodyFilled.defaultMessage).length).toBe(0); + }); +}); diff --git a/src/generic/course-upload-image/data/api.js b/src/generic/course-upload-image/data/api.js new file mode 100644 index 000000000..3d38c1446 --- /dev/null +++ b/src/generic/course-upload-image/data/api.js @@ -0,0 +1,21 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export const getUploadAssetsUrl = (courseId) => new URL( + `/assets/${courseId}/`, + getConfig().STUDIO_BASE_URL, +); + +/** + * Upload assets. + * @param {string} courseId + * @param {binary} formData + * @returns {Promise} + */ +export async function uploadAssets(courseId, fileData) { + const { data } = await getAuthenticatedHttpClient().post( + `${getUploadAssetsUrl(courseId).href}`, + fileData, + ); + return camelCaseObject(data); +} diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx new file mode 100644 index 000000000..a8dfa2c98 --- /dev/null +++ b/src/generic/course-upload-image/index.jsx @@ -0,0 +1,167 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { useParams } from 'react-router-dom'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { FileUpload as FileUploadIcon } from '@edx/paragon/icons'; +import { getConfig } from '@edx/frontend-platform'; +import { + Form, + Dropzone, + Image, + Hyperlink, + Card, + Icon, + IconButton, +} from '@edx/paragon'; + +import { uploadAssets } from './data/api'; +import messages from './messages'; + +const CourseUploadImage = ({ + label, + customHelpText, + assetImagePath, + imageNameField, + assetImageField, + identifierFieldText, + showImageBodyText, + customInputPlaceholder, + onChange, +}) => { + const { courseId } = useParams(); + const intl = useIntl(); + const imageAbsolutePath = new URL(assetImagePath, getConfig().LMS_BASE_URL); + const assetsUrl = new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL); + + const handleChangeImageAsset = (path) => { + const assetPath = _.last(path.split('/')); + // If image path is entered directly, we need to strip the asset prefix + const imageName = _.last(assetPath.split('block@')); + onChange(path, assetImageField); + if (imageNameField) { + onChange(imageName, imageNameField); + } + }; + + const handleProcessUpload = async ({ fileData, handleError }) => { + try { + const response = await uploadAssets(courseId, fileData); + const url = response?.asset?.url; + if (url) { + handleChangeImageAsset(url); + } + } catch (error) { + handleError(error); + } + }; + + const inputComponent = assetImagePath ? ( + + + + ) : ( + <> + + + {intl.formatMessage(messages.uploadImageDropzoneText, { + identifierFieldText, + })} + + > + ); + + const cardImageTextBody = assetImagePath ? ( + + + {intl.formatMessage(messages.uploadImageFilesAndUploads)} + + ), + }} + /> + + ) : ( + + {intl.formatMessage(messages.uploadImageEmpty)} + + ); + + return ( + + {label} + + + + {showImageBodyText && cardImageTextBody} + + + + handleChangeImageAsset(e.target.value)} + placeholder={ + customInputPlaceholder + || intl.formatMessage(messages.uploadImageInputPlaceholder, { + identifierFieldText, + }) + } + /> + + + + {customHelpText + || intl.formatMessage(messages.uploadImageHelpText, { + identifierFieldText, + })} + + + ); +}; + +CourseUploadImage.defaultProps = { + assetImagePath: '', + customHelpText: '', + imageNameField: '', + assetImageField: '', + showImageBodyText: false, + identifierFieldText: '', + customInputPlaceholder: '', +}; + +CourseUploadImage.propTypes = { + label: PropTypes.string.isRequired, + assetImagePath: PropTypes.string, + customHelpText: PropTypes.string, + imageNameField: PropTypes.string, + assetImageField: PropTypes.string, + showImageBodyText: PropTypes.bool, + identifierFieldText: PropTypes.string, + customInputPlaceholder: PropTypes.string, + onChange: PropTypes.func.isRequired, +}; + +export default CourseUploadImage; diff --git a/src/generic/course-upload-image/messages.js b/src/generic/course-upload-image/messages.js new file mode 100644 index 000000000..ec2111207 --- /dev/null +++ b/src/generic/course-upload-image/messages.js @@ -0,0 +1,38 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + uploadImageHelpText: { + id: 'course-authoring.schedule-section.introducing.upload-image.help-text', + defaultMessage: 'Please provide a valid path and name to your {identifierFieldText} (Note: only JPEG or PNG format supported)', + }, + uploadImageFilesAndUploads: { + id: 'course-authoring.schedule-section.introducing.upload-image.file-and-uploads', + defaultMessage: 'files and uploads', + }, + uploadImageDropzoneText: { + id: 'course-authoring.schedule-section.introducing.upload-image.dropzone-text', + defaultMessage: 'Drag and drop your {identifierFieldText} here or click to upload.', + }, + uploadImageDropzoneAlt: { + id: 'course-authoring.schedule-section.introducing.upload-image.dropzone-alt', + defaultMessage: 'Uploaded image for course', + }, + uploadImageEmpty: { + id: 'course-authoring.schedule-section.introducing.upload-image.empty', + defaultMessage: 'Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)', + }, + uploadImageIconAlt: { + id: 'course-authoring.schedule-section.introducing.upload-image.icon-alt', + defaultMessage: 'File upload icon', + }, + uploadImageBodyFilled: { + id: 'course-authoring.schedule-section.introducing.upload-image.manage', + defaultMessage: 'You can manage this image along with all of your other {hyperlink}', + }, + uploadImageInputPlaceholder: { + id: 'course-authoring.schedule-section.introducing.upload-image.input.placeholder', + defaultMessage: 'Your {identifierFieldText} URL', + }, +}); + +export default messages; diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx new file mode 100644 index 000000000..75ae346cf --- /dev/null +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import DatePicker from 'react-datepicker/dist'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Form, Icon } from '@edx/paragon'; +import { Calendar } from '@edx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { convertToDateFromString, convertToStringFromDate, isValidDate } from '../../utils'; +import { DATE_FORMAT, TIME_FORMAT } from '../../constants'; +import messages from './messages'; + +export const DATEPICKER_TYPES = { + date: 'date', + time: 'time', +}; + +const DatepickerControl = ({ + type, + label, + value, + showUTC, + readonly, + helpText, + isInvalid, + controlName, + onChange, +}) => { + const intl = useIntl(); + const formattedDate = convertToDateFromString(value); + const inputFormat = { + [DATEPICKER_TYPES.date]: DATE_FORMAT, + [DATEPICKER_TYPES.time]: TIME_FORMAT, + }; + + return ( + + + {label} + {showUTC && ( + + ({intl.formatMessage(messages.datepickerUTC)}) + + )} + + + {type === DATEPICKER_TYPES.date && !readonly && ( + + )} + { + if (isValidDate(date)) { + onChange(convertToStringFromDate(date)); + } + }} + /> + + {helpText && {helpText}} + + ); +}; + +DatepickerControl.defaultProps = { + helpText: '', + showUTC: false, + value: '', + readonly: false, + isInvalid: false, +}; + +DatepickerControl.propTypes = { + type: PropTypes.oneOf(Object.values(DATEPICKER_TYPES)).isRequired, + value: PropTypes.string, + label: PropTypes.string.isRequired, + showUTC: PropTypes.bool, + helpText: PropTypes.string, + readonly: PropTypes.bool, + isInvalid: PropTypes.bool, + controlName: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default DatepickerControl; diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx new file mode 100644 index 000000000..509fc0cf8 --- /dev/null +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { convertToStringFromDate } from '../../utils'; +import { DatepickerControl, DATEPICKER_TYPES } from '.'; +import messages from './messages'; +import { DATE_FORMAT } from '../../constants'; + +describe('', () => { + const onChangeMock = jest.fn(); + const RootWrapper = (props) => ( + + + + ); + + const props = { + intl: {}, + type: DATEPICKER_TYPES.date, + label: 'fooLabel', + value: '', + showUTC: false, + readonly: false, + helpText: 'barHelpText', + isInvalid: false, + controlName: 'fooControlName', + onChange: onChangeMock, + }; + + it('renders without crashing', () => { + const { getByText, queryAllByText, getByPlaceholderText } = render( + , + ); + expect(getByText(props.label)).toBeInTheDocument(); + expect(getByText(props.helpText)).toBeInTheDocument(); + expect(queryAllByText(messages.datepickerUTC.defaultMessage).length).toBe(0); + expect( + getByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()), + ).toBeInTheDocument(); + }); + + it('calls onChange on datepicker input change', () => { + const { getByPlaceholderText } = render(); + const input = getByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()); + fireEvent.change(input, { target: { value: '06/16/2023' } }); + expect(onChangeMock).toHaveBeenCalledWith( + convertToStringFromDate('06/16/2023'), + ); + }); +}); diff --git a/src/generic/datepicker-control/index.js b/src/generic/datepicker-control/index.js new file mode 100644 index 000000000..4ebfef2cc --- /dev/null +++ b/src/generic/datepicker-control/index.js @@ -0,0 +1,2 @@ +export { default as DatepickerControl } from './DatepickerControl'; +export { DATEPICKER_TYPES } from './DatepickerControl'; diff --git a/src/generic/datepicker-control/messages.js b/src/generic/datepicker-control/messages.js new file mode 100644 index 000000000..b6139f7b5 --- /dev/null +++ b/src/generic/datepicker-control/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + calendarAltText: { + id: 'course-authoring.schedule.schedule-section.alt-text', + defaultMessage: 'Calendar for datepicker input', + }, + datepickerUTC: { + id: 'course-authoring.schedule.schedule-section.datepicker.utc', + defaultMessage: 'UTC', + }, +}); + +export default messages; diff --git a/src/generic/help-sidebar/HelpSidebar.scss b/src/generic/help-sidebar/HelpSidebar.scss index 7458a99fd..cf3701330 100644 --- a/src/generic/help-sidebar/HelpSidebar.scss +++ b/src/generic/help-sidebar/HelpSidebar.scss @@ -11,17 +11,14 @@ font: normal $font-weight-normal .875rem/1.5rem $font-family-base; color: $text-color-base; } + + .help-sidebar-about-link { + font: normal $font-weight-normal .875rem/1.5rem $font-family-base; + } } .help-sidebar-other-links ul { list-style: none; - - .help-sidebar-other-link { - font: normal $font-weight-normal .875rem/1.5rem $font-family-base; - line-height: 1.5rem; - color: $info-500; - margin-bottom: .5rem; - } } .help-sidebar-other-title { @@ -29,4 +26,11 @@ color: $black; margin-bottom: 1.25rem; } + + .sidebar-link { + font: normal $font-weight-normal .875rem/1.5rem $font-family-base; + line-height: 1.5rem; + color: $info-500; + margin-bottom: .5rem; + } } diff --git a/src/generic/help-sidebar/HelpSidebar.test.jsx b/src/generic/help-sidebar/HelpSidebar.test.jsx index cadaca06c..410ae631b 100644 --- a/src/generic/help-sidebar/HelpSidebar.test.jsx +++ b/src/generic/help-sidebar/HelpSidebar.test.jsx @@ -14,31 +14,51 @@ jest.mock('react-router-dom', () => ({ }), })); -const RootWrapper = () => ( +const RootWrapper = (props) => ( - + Test children ); +const props = { + courseId: 'course123', + showOtherSettings: true, + proctoredExamSettingsUrl: '', +}; + describe('HelpSidebar', () => { it('renders children correctly', () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText('Test children')).toBeTruthy(); }); + it('should render all sidebar links with correct text', () => { - const { getByText } = render(); + const { getByText, queryByText } = render(); expect(getByText(messages.sidebarTitleOther.defaultMessage)).toBeTruthy(); expect(getByText(messages.sidebarLinkToScheduleAndDetails.defaultMessage)).toBeTruthy(); expect(getByText(messages.sidebarLinkToGrading.defaultMessage)).toBeTruthy(); expect(getByText(messages.sidebarLinkToCourseTeam.defaultMessage)).toBeTruthy(); expect(getByText(messages.sidebarLinkToGroupConfigurations.defaultMessage)).toBeTruthy(); expect(getByText(messages.sidebarLinkToAdvancedSettings.defaultMessage)).toBeTruthy(); + expect(queryByText(messages.sidebarLinkToProctoredExamSettings.defaultMessage)).toBeFalsy(); + }); + + it('should hide other settings url if showOtherSettings disabled', () => { + const initialProps = { ...props, showOtherSettings: false }; + const { queryByText } = render(); + expect(queryByText(messages.sidebarTitleOther.defaultMessage)).toBeFalsy(); + expect(queryByText(messages.sidebarLinkToScheduleAndDetails.defaultMessage)).toBeFalsy(); + expect(queryByText(messages.sidebarLinkToGrading.defaultMessage)).toBeFalsy(); + expect(queryByText(messages.sidebarLinkToCourseTeam.defaultMessage)).toBeFalsy(); + expect(queryByText(messages.sidebarLinkToGroupConfigurations.defaultMessage)).toBeFalsy(); + expect(queryByText(messages.sidebarLinkToAdvancedSettings.defaultMessage)).toBeFalsy(); + }); + + it('should render proctored mfe url only if passed not empty value', () => { + const initialProps = { ...props, proctoredExamSettingsUrl: 'http:/link-to' }; + const { getByText } = render(); expect(getByText(messages.sidebarLinkToProctoredExamSettings.defaultMessage)).toBeTruthy(); }); }); diff --git a/src/generic/help-sidebar/HelpSidebarLink.jsx b/src/generic/help-sidebar/HelpSidebarLink.jsx new file mode 100644 index 000000000..709ef175e --- /dev/null +++ b/src/generic/help-sidebar/HelpSidebarLink.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Hyperlink } from '@edx/paragon'; + +const HelpSidebarLink = ({ as, pathToPage, title }) => { + const TagElement = as; + return ( + + + {title} + + + ); +}; + +HelpSidebarLink.propTypes = { + pathToPage: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + as: PropTypes.string, +}; + +HelpSidebarLink.defaultProps = { + as: 'li', +}; + +export default HelpSidebarLink; diff --git a/src/generic/help-sidebar/constants.js b/src/generic/help-sidebar/constants.js new file mode 100644 index 000000000..68ce38a19 --- /dev/null +++ b/src/generic/help-sidebar/constants.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/prefer-default-export +export const otherLinkURLParams = { + scheduleAndDetails: 'settings/details', + grading: 'settings/grading', + courseTeam: 'course_team', + advancedSettings: 'settings/advanced', + groupConfigurations: 'group_configurations', + proctoredExamSettings: 'proctored-exam-settings', +}; diff --git a/src/generic/help-sidebar/index.jsx b/src/generic/help-sidebar/index.jsx index 246bbfd4d..5337bb509 100644 --- a/src/generic/help-sidebar/index.jsx +++ b/src/generic/help-sidebar/index.jsx @@ -4,9 +4,9 @@ import { useLocation } from 'react-router-dom'; import classNames from 'classnames'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; -import { Hyperlink } from '@edx/paragon'; -import { getPagePath } from '../../utils'; +import HelpSidebarLink from './HelpSidebarLink'; +import { otherLinkURLParams } from './constants'; import messages from './messages'; const HelpSidebar = ({ @@ -18,34 +18,25 @@ const HelpSidebar = ({ className, }) => { const { pathname } = useLocation(); - const scheduleAndDetailsDestination = getPagePath( - courseId, - process.env.ENABLE_NEW_SCHEDULE_DETAILS_PAGE, - 'settings/details', - ); - const gradingDestination = getPagePath( - courseId, - process.env.ENABLE_NEW_GRADING_PAGE, - 'settings/grading', - ); - const courseTeamDestination = getPagePath( - courseId, - process.env.ENABLE_NEW_COURSE_TEAM_PAGE, - 'course_team', - ); - const advancedSettingsDestination = getPagePath( - courseId, - process.env.ENABLE_NEW_ADVANCED_SETTINGS_PAGE, - 'settings/advanced', - ); - const groupConfigurationsDestination = new URL( - `/group_configurations/${courseId}`, - getConfig().STUDIO_BASE_URL, - ); - const proctoredExamSettingsDestination = new URL( - `/course/${courseId}/proctored-exam-settings`, - getConfig().BASE_URL, - ); + const { + grading, + courseTeam, + advancedSettings, + scheduleAndDetails, + groupConfigurations, + } = otherLinkURLParams; + + const showOtherLink = (params) => !pathname.includes(params); + const generateLegacyURL = (urlParameter) => { + const referObj = new URL(`${urlParameter}/${courseId}`, getConfig().STUDIO_BASE_URL); + return referObj.href; + }; + + const scheduleAndDetailsDestination = generateLegacyURL(scheduleAndDetails); + const gradingDestination = generateLegacyURL(grading); + const courseTeamDestination = generateLegacyURL(courseTeam); + const advancedSettingsDestination = generateLegacyURL(advancedSettings); + const groupConfigurationsDestination = generateLegacyURL(groupConfigurations); return (
{description}
+ {intl.formatMessage(messages.aboutDescription1)} +
+ {intl.formatMessage(messages.aboutDescription2)} +
+ Note: }} + /> +
+ {intl.formatMessage(messages.uploadImageDropzoneText, { + identifierFieldText, + })} +
Test children