diff --git a/.env b/.env index 1b4eb025be..f6d76a4e4e 100644 --- a/.env +++ b/.env @@ -37,12 +37,11 @@ ENABLE_NEW_FILES_UPLOADS_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false ENABLE_NEW_SCHEDULE_DETAILS_PAGE = false ENABLE_NEW_GRADING_PAGE = false -ENABLE_NEW_COURSE_TEAM_PAGE = true +ENABLE_NEW_COURSE_TEAM_PAGE = false ENABLE_NEW_ADVANCED_SETTINGS_PAGE = false ENABLE_NEW_IMPORT_PAGE = false ENABLE_NEW_EXPORT_PAGE = false ENABLE_UNIT_PAGE = false -ENABLE_NEW_CUSTOM_PAGES = false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false ENABLE_CREDIT_ELIGIBILITY = false BBB_LEARN_MORE_URL='' diff --git a/.env.development b/.env.development index 3214387409..ada35c6a48 100644 --- a/.env.development +++ b/.env.development @@ -44,7 +44,6 @@ ENABLE_NEW_ADVANCED_SETTINGS_PAGE = true ENABLE_NEW_IMPORT_PAGE = false ENABLE_NEW_EXPORT_PAGE = false ENABLE_UNIT_PAGE = false -ENABLE_NEW_CUSTOM_PAGES = true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false ENABLE_CREDIT_ELIGIBILITY = true BBB_LEARN_MORE_URL='' diff --git a/.env.test b/.env.test index 5b9dfc20ee..b532797e2f 100644 --- a/.env.test +++ b/.env.test @@ -36,11 +36,9 @@ 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_ADVANCED_SETTINGS_PAGE = true ENABLE_NEW_IMPORT_PAGE = true ENABLE_NEW_EXPORT_PAGE = true ENABLE_UNIT_PAGE = true -ENABLE_NEW_CUSTOM_PAGES = true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true ENABLE_CREDIT_ELIGIBILITY = true BBB_LEARN_MORE_URL='' diff --git a/Makefile b/Makefile index a9e46cab47..f576f0fee4 100644 --- a/Makefile +++ b/Makefile @@ -55,10 +55,11 @@ pull_translations: mkdir src/i18n/messages cd src/i18n/messages \ && atlas pull --filter=$(transifex_langs) \ + translations/paragon/src/i18n/messages:paragon \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring - $(intl_imports) frontend-component-footer frontend-app-course-authoring + $(intl_imports) paragon frontend-component-footer frontend-app-course-authoring endif # This target is used by Travis. diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index cca83f4432..508119c47a 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -67,10 +67,7 @@ const CourseAuthoringRoutes = ({ courseId }) => { - {process.env.ENABLE_NEW_CUSTOM_PAGES === 'true' - && ( - - )} + {process.env.ENABLE_UNIT_PAGE === 'true' @@ -113,10 +110,7 @@ const CourseAuthoringRoutes = ({ courseId }) => { )} - {process.env.ENABLE_NEW_ADVANCED_SETTINGS_PAGE === 'true' - && ( - - )} + {process.env.ENABLE_NEW_IMPORT_PAGE === 'true' diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index e46f573ad9..2d72cf9c6f 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -2,10 +2,11 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { - Container, Button, Layout, StatefulButton, + Container, Button, Layout, StatefulButton, TransitionReplace, } from '@edx/paragon'; import { CheckCircle, Info, Warning } from '@edx/paragon/icons'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import Placeholder from '@edx/frontend-lib-content-components'; import AlertProctoringError from '../generic/AlertProctoringError'; import InternetConnectionAlert from '../generic/internet-connection-alert'; @@ -24,10 +25,6 @@ import messages from './messages'; import ModalError from './modal-error/ModalError'; const AdvancedSettings = ({ intl, courseId }) => { - const advancedSettingsData = useSelector(getCourseAppSettings); - const savingStatus = useSelector(getSavingStatus); - const proctoringExamErrors = useSelector(getProctoringExamErrors); - const settingsWithSendErrors = useSelector(getSendRequestErrors) || {}; const dispatch = useDispatch(); const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false); const [showDeprecated, setShowDeprecated] = useState(false); @@ -35,10 +32,21 @@ const AdvancedSettings = ({ intl, courseId }) => { const [editedSettings, setEditedSettings] = useState({}); const [errorFields, setErrorFields] = useState([]); const [showSuccessAlert, setShowSuccessAlert] = useState(false); - const loadingSettingsStatus = useSelector(getLoadingStatus); const [isQueryPending, setIsQueryPending] = useState(false); const [isEditableState, setIsEditableState] = useState(false); const [hasInternetConnectionError, setInternetConnectionError] = useState(false); + + useEffect(() => { + dispatch(fetchCourseAppSettings(courseId)); + dispatch(fetchProctoringExamErrors(courseId)); + }, [courseId]); + + const advancedSettingsData = useSelector(getCourseAppSettings); + const savingStatus = useSelector(getSavingStatus); + const proctoringExamErrors = useSelector(getProctoringExamErrors); + const settingsWithSendErrors = useSelector(getSendRequestErrors) || {}; + const loadingSettingsStatus = useSelector(getLoadingStatus); + const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS; const updateSettingsButtonState = { labels: { @@ -48,20 +56,14 @@ const AdvancedSettings = ({ intl, courseId }) => { disabledStates: ['pending'], }; - useEffect(() => { - dispatch(fetchCourseAppSettings(courseId)); - dispatch(fetchProctoringExamErrors(courseId)); - }, [courseId]); - useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { setIsQueryPending(false); setShowSuccessAlert(true); + setIsEditableState(false); + setTimeout(() => setShowSuccessAlert(false), 15000); window.scrollTo({ top: 0, behavior: 'smooth' }); - - if (!isEditableState) { - showSaveSettingsPrompt(false); - } + showSaveSettingsPrompt(false); } else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) { setErrorFields(settingsWithSendErrors); showErrorModal(true); @@ -72,27 +74,19 @@ const AdvancedSettings = ({ intl, courseId }) => { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; } - - const handleSettingChange = (e, settingName) => { - const { value } = e.target; - if (!saveSettingsPrompt) { - showSaveSettingsPrompt(true); - } - setIsEditableState(true); - setShowSuccessAlert(false); - setEditedSettings((prevEditedSettings) => ({ - ...prevEditedSettings, - [settingName]: value, - })); - }; + if (loadingSettingsStatus === RequestStatus.DENIED) { + return ( +
+ +
+ ); + } const handleResetSettingsValues = () => { setIsEditableState(false); showErrorModal(false); setEditedSettings({}); showSaveSettingsPrompt(false); - setInternetConnectionError(false); - setIsQueryPending(false); }; const handleSettingBlur = () => { @@ -103,9 +97,7 @@ const AdvancedSettings = ({ intl, courseId }) => { const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings); if (isValid) { setIsQueryPending(true); - setIsEditableState(false); } else { - setIsQueryPending(false); showSaveSettingsPrompt(false); showErrorModal(!errorModal); } @@ -115,7 +107,6 @@ const AdvancedSettings = ({ intl, courseId }) => { setInternetConnectionError(true); showSaveSettingsPrompt(false); setShowSuccessAlert(false); - setIsQueryPending(false); }; const handleQueryProcessing = () => { @@ -124,15 +115,13 @@ const AdvancedSettings = ({ intl, courseId }) => { }; const handleManuallyChangeClick = (setToState) => { - setIsEditableState(true); showErrorModal(setToState); showSaveSettingsPrompt(true); - setIsQueryPending(false); }; return ( <> - +
{(proctoringExamErrors?.length > 0) && ( { aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)} /> )} -
+
{
- Warning: }} - /> - )} - /> +
+ Warning: }} + /> +
    - {Object.keys(advancedSettingsData).sort().map((settingName) => { + {Object.keys(advancedSettingsData).map((settingName) => { const settingData = advancedSettingsData[settingName]; - const editedValue = editedSettings[settingName] !== undefined - ? editedSettings[settingName] : JSON.stringify(settingData.value, null, 4); - + if (settingData.deprecated && !showDeprecated) { + return null; + } return ( handleSettingChange(e, settingName)} - showDeprecated={showDeprecated} name={settingName} - value={editedValue} + showSaveSettingsPrompt={showSaveSettingsPrompt} + saveSettingsPrompt={saveSettingsPrompt} + setEdited={setEditedSettings} handleBlur={handleSettingBlur} + isEditableState={isEditableState} + setIsEditableState={setIsEditableState} /> ); })} @@ -225,7 +221,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
- {!isEditableState && ( + {isQueryPending && ( ', () => { }); }); it('should render setting element', async () => { - const { getByText } = render(); + const { getByText, queryByText } = render(); await waitFor(() => { const advancedModuleListTitle = getByText(/Advanced Module List/i); expect(advancedModuleListTitle).toBeInTheDocument(); + expect(queryByText('Certificate web/html view enabled')).toBeNull(); }); }); it('should change to onŠ”hange', async () => { @@ -112,24 +115,50 @@ describe('', () => { fireEvent.click(showDeprecatedItemsBtn); expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument(); }); + expect(getByText('Certificate web/html view enabled')).toBeInTheDocument(); }); it('should reset to default value on click on Cancel button', async () => { const { getByLabelText, getByText } = render(); + let textarea; await waitFor(() => { - const textarea = getByLabelText(/Advanced Module List/i); - fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } }); - fireEvent.click(getByText(messages.buttonCancelText.defaultMessage)); - expect(textarea.value).toBe('[]'); + textarea = getByLabelText(/Advanced Module List/i); }); + fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } }); + expect(textarea.value).toBe('[3, 2, 1]'); + fireEvent.click(getByText(messages.buttonCancelText.defaultMessage)); + expect(textarea.value).toBe('[]'); }); it('should update the textarea value and display the updated value after clicking "Change manually"', async () => { const { getByLabelText, getByText } = render(); + let textarea; await waitFor(() => { - const textarea = getByLabelText(/Advanced Module List/i); - fireEvent.change(textarea, { target: { value: '[3, 2, 1' } }); - fireEvent.click(getByText(messages.buttonSaveText.defaultMessage)); - fireEvent.click(getByText(/Change manually/i)); - expect(textarea.value).toBe('[3, 2, 1'); + textarea = getByLabelText(/Advanced Module List/i); + }); + fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } }); + expect(textarea.value).toBe('[3, 2, 1,'); + fireEvent.click(getByText('Save changes')); + fireEvent.click(getByText('Change manually')); + expect(textarea.value).toBe('[3, 2, 1,'); + }); + it('should show success alert after save', async () => { + const { getByLabelText, getByText } = render(); + let textarea; + await waitFor(() => { + textarea = getByLabelText(/Advanced Module List/i); }); + fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } }); + expect(textarea.value).toBe('[3, 2, 1]'); + axiosMock + .onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`) + .reply(200, { + ...advancedSettingsMock, + advancedModules: { + ...advancedSettingsMock.advancedModules, + value: [3, 2, 1], + }, + }); + fireEvent.click(getByText('Save changes')); + await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch); + expect(getByText('Your policy changes have been saved.')).toBeInTheDocument(); }); }); diff --git a/src/advanced-settings/__mocks__/advancedSettings.js b/src/advanced-settings/__mocks__/advancedSettings.js index 52d5bed6b0..ead6c3528b 100644 --- a/src/advanced-settings/__mocks__/advancedSettings.js +++ b/src/advanced-settings/__mocks__/advancedSettings.js @@ -6,4 +6,11 @@ module.exports = { hideOnEnabledPublisher: false, value: [], }, + certHtmlViewEnabled: { + deprecated: true, + display_name: 'Certificate web/html view enabled', + help: 'If true, certificate Web/HTML views are enabled for the course.', + hide_on_enabled_publisher: false, + value: true, + }, }; diff --git a/src/advanced-settings/data/thunks.js b/src/advanced-settings/data/thunks.js index 8686582093..db9030fa39 100644 --- a/src/advanced-settings/data/thunks.js +++ b/src/advanced-settings/data/thunks.js @@ -19,10 +19,27 @@ export function fetchCourseAppSettings(courseId) { try { const settingValues = await getCourseAdvancedSettings(courseId); - dispatch(fetchCourseAppsSettingsSuccess(settingValues)); + const sortedDisplayName = []; + Object.values(settingValues).forEach(value => { + const { displayName } = value; + sortedDisplayName.push(displayName); + }); + const sortedSettingValues = {}; + sortedDisplayName.sort().forEach((displayName => { + Object.entries(settingValues).forEach(([key, value]) => { + if (value.displayName === displayName) { + sortedSettingValues[key] = value; + } + }); + })); + dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues)); dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); + } } }; } diff --git a/src/advanced-settings/scss/AdvancedSettings.scss b/src/advanced-settings/scss/AdvancedSettings.scss index 3922addf1a..676c6dc81c 100644 --- a/src/advanced-settings/scss/AdvancedSettings.scss +++ b/src/advanced-settings/scss/AdvancedSettings.scss @@ -8,8 +8,8 @@ .instructions, strong { - font: normal $font-weight-normal .875rem/1.5rem $font-family-base; color: $text-color-base; + font-weight: 400; } } @@ -18,11 +18,6 @@ .pgn__card-header .pgn__card-header-title-md { font-size: 1.125rem; - padding-top: .625rem; - } - - .pgn__icon { - color: $headings-color; } } @@ -44,7 +39,6 @@ } .form-control { - resize: none; min-height: 2.75rem; width: $setting-form-control-width; } @@ -54,7 +48,8 @@ } .pgn__card-header { - padding: 0 1.5rem; + padding: 0 0 0 1.5rem; + flex-grow: 1; } .pgn__card-status { @@ -66,12 +61,39 @@ margin-bottom: 1.438rem; display: flex; flex-direction: revert; + } +} + +.setting-sidebar-supplementary { + .setting-sidebar-supplementary-about { + .setting-sidebar-supplementary-about-title { + 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 $font-weight-normal .875rem/1.5rem $font-family-base; + color: $text-color-base; + } + } + + .setting-sidebar-supplementary-other-links ul { + list-style: none; - .pgn__card-header-subtitle-md { - margin-left: .375rem; - margin-top: .125rem; + .setting-sidebar-supplementary-other-link { + font: normal $font-weight-normal .875rem/1.5rem $font-family-base; + line-height: 1.5rem; + color: $info-500; + margin-bottom: .5rem; } } + + .setting-sidebar-supplementary-other-title { + font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base; + color: $headings-color; + margin-bottom: 1.25rem; + } } .modal-error-item { @@ -89,3 +111,15 @@ align-items: center; } } + +.modal-popup-content { + max-width: 200px; + 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: $black; +} diff --git a/src/advanced-settings/setting-card/SettingCard.jsx b/src/advanced-settings/setting-card/SettingCard.jsx index b4ff22aad8..15cef8ad9f 100644 --- a/src/advanced-settings/setting-card/SettingCard.jsx +++ b/src/advanced-settings/setting-card/SettingCard.jsx @@ -1,10 +1,15 @@ -import React from 'react'; +import React, { useState } from 'react'; import { - Card, Form, Icon, IconButton, OverlayTrigger, Popover, + ActionRow, + Card, + Form, + Icon, + IconButton, + ModalPopup, + useToggle, } from '@edx/paragon'; -import { Info, Warning } from '@edx/paragon/icons'; +import { InfoOutline, Warning } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; import { capitalize } from 'lodash'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import TextareaAutosize from 'react-textarea-autosize'; @@ -12,47 +17,87 @@ import TextareaAutosize from 'react-textarea-autosize'; import messages from './messages'; const SettingCard = ({ - intl, showDeprecated, name, onChange, value, settingData, handleBlur, + name, + settingData, + handleBlur, + setEdited, + showSaveSettingsPrompt, + saveSettingsPrompt, + isEditableState, + setIsEditableState, + // injected + intl, }) => { const { deprecated, help, displayName } = settingData; + const initialValue = JSON.stringify(settingData.value, null, 4); + const [isOpen, open, close] = useToggle(false); + const [target, setTarget] = useState(null); + const [newValue, setNewValue] = useState(initialValue); + + const handleSettingChange = (e) => { + const { value } = e.target; + setNewValue(e.target.value); + if (value !== initialValue) { + if (!saveSettingsPrompt) { + showSaveSettingsPrompt(true); + } + if (!isEditableState) { + setIsEditableState(true); + } + } + }; + + const handleCardBlur = () => { + setEdited((prevEditedSettings) => ({ + ...prevEditedSettings, + [name]: newValue, + })); + handleBlur(); + }; + return ( -
  • +
  • - + - - {/* eslint-disable-next-line react/no-danger */} -
    - - - )} - > + title={( + + {capitalize(displayName)} - + +
    + + )} /> @@ -73,22 +118,21 @@ SettingCard.propTypes = { deprecated: PropTypes.bool, help: PropTypes.string, displayName: PropTypes.string, + value: PropTypes.PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + PropTypes.number, + PropTypes.object, + PropTypes.array, + ]), }).isRequired, - value: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.number, - PropTypes.object, - PropTypes.array, - ]), - onChange: PropTypes.func.isRequired, - showDeprecated: PropTypes.bool.isRequired, + setEdited: PropTypes.func.isRequired, + showSaveSettingsPrompt: PropTypes.func.isRequired, name: PropTypes.string.isRequired, handleBlur: PropTypes.func.isRequired, -}; - -SettingCard.defaultProps = { - value: undefined, + saveSettingsPrompt: PropTypes.bool.isRequired, + isEditableState: PropTypes.bool.isRequired, + setIsEditableState: PropTypes.func.isRequired, }; export default injectIntl(SettingCard); diff --git a/src/advanced-settings/setting-card/SettingCard.test.jsx b/src/advanced-settings/setting-card/SettingCard.test.jsx index cb3aef74a3..a03f51a985 100644 --- a/src/advanced-settings/setting-card/SettingCard.test.jsx +++ b/src/advanced-settings/setting-card/SettingCard.test.jsx @@ -1,16 +1,21 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import SettingCard from './SettingCard'; import messages from './messages'; -const handleChange = jest.fn(); +const setEdited = jest.fn(); +const showSaveSettingsPrompt = jest.fn(); +const setIsEditableState = jest.fn(); +const handleBlur = jest.fn(); const settingData = { deprecated: false, help: 'This is a help message', displayName: 'Setting Name', + value: 'Setting Value', }; jest.mock('react-textarea-autosize', () => jest.fn((props) => ( @@ -27,9 +32,13 @@ const RootWrapper = () => ( intl={{}} isOn name="settingName" - onChange={handleChange} - value="Setting Value" + setEdited={setEdited} + setIsEditableState={setIsEditableState} + showSaveSettingsPrompt={showSaveSettingsPrompt} settingData={settingData} + handleBlur={handleBlur} + isEditableState + saveSettingsPrompt={false} /> ); @@ -42,7 +51,7 @@ describe('', () => { const input = getByLabelText(/Setting Name/i); expect(cardTitle).toBeInTheDocument(); expect(input).toBeInTheDocument(); - expect(input.value).toBe('Setting Value'); + expect(input.value).toBe(JSON.stringify(settingData.value, null, 4)); }); it('displays the deprecated status when the setting is deprecated', () => { const deprecatedSettingData = { ...settingData, deprecated: true }; @@ -52,9 +61,13 @@ describe('', () => { intl={{}} isOn name="settingName" - onChange={handleChange} - value="Setting Value" + setEdited={setEdited} + setIsEditableState={setIsEditableState} + showSaveSettingsPrompt={showSaveSettingsPrompt} settingData={deprecatedSettingData} + handleBlur={handleBlur} + isEditable={false} + saveSettingsPrompt /> , ); @@ -65,4 +78,19 @@ describe('', () => { const { queryByText } = render(); expect(queryByText(messages.deprecated.defaultMessage)).toBeNull(); }); + it('calls setEdited on blur', async () => { + const { getByLabelText } = render(); + const inputBox = getByLabelText(/Setting Name/i); + fireEvent.focus(inputBox); + userEvent.clear(inputBox); + userEvent.type(inputBox, '3, 2, 1'); + await waitFor(() => { + expect(inputBox).toHaveValue('3, 2, 1'); + }); + await (async () => { + expect(setEdited).toHaveBeenCalled(); + expect(handleBlur).toHaveBeenCalled(); + }); + fireEvent.focusOut(inputBox); + }); }); diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.jsx index 87e3ead4c1..b7019ba10e 100644 --- a/src/custom-pages/CustomPages.jsx +++ b/src/custom-pages/CustomPages.jsx @@ -16,9 +16,10 @@ import { useToggle, Image, ModalDialog, + Container, } from '@edx/paragon'; import { Add, SpinnerSimple } from '@edx/paragon/icons'; -import { +import Placeholder, { DraggableList, SortableItem, ErrorAlert, @@ -96,16 +97,21 @@ const CustomPages = ({ }, disabledStates: ['pending'], }; - useEffect(() => { setOrderedPages(pages); }, [customPagesIds, savingStatus]); if (loadingStatus === RequestStatus.IN_PROGRESS) { // eslint-disable-next-line react/jsx-no-useless-fragment return (<>); } - + if (loadingStatus === RequestStatus.DENIED) { + return ( +
    + +
    + ); + } return ( -
    +
    -
    +
    ); }; diff --git a/src/custom-pages/CustomPages.test.jsx b/src/custom-pages/CustomPages.test.jsx index 2ef77a0ab5..979145082a 100644 --- a/src/custom-pages/CustomPages.test.jsx +++ b/src/custom-pages/CustomPages.test.jsx @@ -19,6 +19,7 @@ import CustomPages from './CustomPages'; import { generateFetchPageApiResponse, generateNewPageApiResponse, + getStatusValue, courseId, initialState, } from './factories/mockApiResponses'; @@ -45,12 +46,12 @@ const renderComponent = () => { ); }; -const mockStore = async () => { +const mockStore = async (status) => { const xblockAddUrl = `${getApiBaseUrl()}/xblock/`; const reorderUrl = `${getTabHandlerUrl(courseId)}/reorder`; const fetchPagesUrl = `${getTabHandlerUrl(courseId)}`; - axiosMock.onGet(fetchPagesUrl).reply(200, generateFetchPageApiResponse()); + axiosMock.onGet(fetchPagesUrl).reply(getStatusValue(status), generateFetchPageApiResponse()); axiosMock.onPost(reorderUrl).reply(204); axiosMock.onPut(xblockAddUrl).reply(200, generateNewPageApiResponse()); @@ -72,21 +73,26 @@ describe('CustomPages', () => { store = initializeStore(initialState); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); + it('should ', async () => { + renderComponent(); + await mockStore(RequestStatus.DENIED); + expect(screen.getByTestId('under-construction-placeholder')).toBeVisible(); + }); it('should have breadecrumbs', async () => { renderComponent(); - await mockStore(); + await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByLabelText('Custom Page breadcrumbs')).toBeVisible(); }); it('should contain header row with title, add button and view live button', async () => { renderComponent(); - await mockStore(); + await mockStore(RequestStatus.SUCCESSFUL); expect(screen.getByText(messages.heading.defaultMessage)).toBeVisible(); expect(screen.getByTestId('header-add-button')).toBeVisible(); expect(screen.getByTestId('header-view-live-button')).toBeVisible(); }); it('should add new page when "add a new page button" is clicked', async () => { renderComponent(); - await mockStore(); + await mockStore(RequestStatus.SUCCESSFUL); const addButton = screen.getByTestId('body-add-button'); expect(addButton).toBeVisible(); await act(async () => { fireEvent.click(addButton); }); @@ -95,7 +101,7 @@ describe('CustomPages', () => { }); it('should open student view modal when "add a new page button" is clicked', async () => { renderComponent(); - await mockStore(); + await mockStore(RequestStatus.SUCCESSFUL); const viewButton = screen.getByTestId('student-view-example-button'); expect(viewButton).toBeVisible(); expect(screen.queryByLabelText(messages.studentViewModalTitle.defaultMessage)).toBeNull(); @@ -104,7 +110,7 @@ describe('CustomPages', () => { }); it('should update page order on drag', async () => { renderComponent(); - await mockStore(); + await mockStore(RequestStatus.SUCCESSFUL); const buttons = await screen.queryAllByRole('button'); const draggableButton = buttons[9]; expect(draggableButton).toBeVisible(); diff --git a/src/custom-pages/data/slice.js b/src/custom-pages/data/slice.js index cc1f247285..1e30f93187 100644 --- a/src/custom-pages/data/slice.js +++ b/src/custom-pages/data/slice.js @@ -11,7 +11,6 @@ const slice = createSlice({ savingStatus: '', addingStatus: 'default', deletingStatus: '', - customPagesApiStatus: {}, }, reducers: { setPageIds: (state, { payload }) => { diff --git a/src/custom-pages/data/thunks.js b/src/custom-pages/data/thunks.js index 23458efd9a..7950e3750a 100644 --- a/src/custom-pages/data/thunks.js +++ b/src/custom-pages/data/thunks.js @@ -39,9 +39,10 @@ export function fetchCustomPages(courseId) { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response && error.response.status === 403) { - dispatch(updateCustomPagesApiStatus({ status: RequestStatus.DENIED })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); } - dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); } }; } diff --git a/src/custom-pages/factories/mockApiResponses.jsx b/src/custom-pages/factories/mockApiResponses.jsx index 7acdf68b45..15f3882f6a 100644 --- a/src/custom-pages/factories/mockApiResponses.jsx +++ b/src/custom-pages/factories/mockApiResponses.jsx @@ -1,3 +1,5 @@ +import { RequestStatus } from '../../data/constants'; + export const courseId = 'course-v1:edX+DemoX+Demo_Course'; export const initialState = { @@ -62,3 +64,12 @@ export const generateNewPageApiResponse = () => ({ locator: 'mOckID2', courseKey: courseId, }); + +export const getStatusValue = (status) => { + switch (status) { + case RequestStatus.DENIED: + return 403; + default: + return 200; + } +}; diff --git a/src/i18n/index.js b/src/i18n/index.js index d0dc802c3c..2abb2b7cf4 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -1,5 +1,5 @@ import { messages as footerMessages } from '@edx/frontend-component-footer'; - +import { messages as paragonMessages } from '@edx/paragon'; import arMessages from './messages/ar.json'; import frMessages from './messages/fr.json'; import es419Messages from './messages/es_419.json'; @@ -35,5 +35,6 @@ const appMessages = { export default [ footerMessages, + paragonMessages, appMessages, ]; diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx index 73af4b9e0f..310fca7690 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx @@ -162,10 +162,12 @@ describe('DiscussionsSettings', () => { expect(queryByTestId(container, 'appConfigForm')).toBeInTheDocument(); - userEvent.click(queryByText(container, appMessages.backButton.defaultMessage)); + await act(() => userEvent.click(queryByText(container, appMessages.backButton.defaultMessage))); - expect(queryByTestId(container, 'appList')).toBeInTheDocument(); - expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument(); + await waitFor(() => { + expect(queryByTestId(container, 'appList')).toBeInTheDocument(); + expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument(); + }); }); test('successfully closes the modal', async () => { diff --git a/src/pages-and-resources/discussions/app-list/AppList.jsx b/src/pages-and-resources/discussions/app-list/AppList.jsx index 04509d3a21..4b1ad4eb2c 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.jsx +++ b/src/pages-and-resources/discussions/app-list/AppList.jsx @@ -1,6 +1,10 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { + useCallback, useEffect, useMemo, useState, useContext, +} from 'react'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { CardGrid, Container, breakpoints } from '@edx/paragon'; +import { + CardGrid, Container, breakpoints, Form, ActionRow, AlertModal, Button, +} from '@edx/paragon'; import { useDispatch, useSelector } from 'react-redux'; import Responsive from 'react-responsive'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; @@ -14,16 +18,23 @@ import messages from './messages'; import FeaturesTable from './FeaturesTable'; import AppListNextButton from './AppListNextButton'; import Loading from '../../../generic/Loading'; +import useIsOnSmallScreen from '../data/hook'; +import { saveProviderConfig, fetchDiscussionSettings } from '../data/thunks'; +import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider'; +import { discussionRestriction } from '../data/constants'; const AppList = ({ intl }) => { const dispatch = useDispatch(); + const { courseId } = useContext(PagesAndResourcesContext); const { - appIds, featureIds, status, activeAppId, selectedAppId, + appIds, featureIds, status, activeAppId, selectedAppId, enabled, postingRestrictions, } = useSelector(state => state.discussions); + const [discussionEnabled, setDiscussionEnabled] = useState(enabled); const apps = useModels('apps', appIds); const features = useModels('features', featureIds); const isGlobalStaff = getAuthenticatedUser().administrator; const ltiProvider = !['openedx', 'legacy'].includes(activeAppId); + const isOnSmallcreen = useIsOnSmallScreen(); const showOneEdxProvider = useMemo(() => apps.filter(app => ( activeAppId === 'openedx' ? app.id !== 'legacy' : app.id !== 'openedx' @@ -42,9 +53,48 @@ const AppList = ({ intl }) => { dispatch(updateValidationStatus({ hasError: false })); }, [selectedAppId, activeAppId]); + useEffect(() => { + setDiscussionEnabled(enabled); + }, [enabled]); + + useEffect(() => { + if (!postingRestrictions) { + dispatch(fetchDiscussionSettings(courseId, selectedAppId)); + } + }, [courseId]); + const handleSelectApp = useCallback((appId) => { dispatch(selectApp({ appId })); - }, [selectedAppId]); + }, []); + + const updateSettings = useCallback((enabledDiscussion) => { + dispatch(saveProviderConfig( + courseId, + selectedAppId, + { + enabled: enabledDiscussion, + postingRestrictions: + enabledDiscussion ? postingRestrictions : discussionRestriction.ENABLED, + }, + )); + }, [courseId, selectedAppId, postingRestrictions]); + + const handleClose = useCallback(() => { + setDiscussionEnabled(enabled); + }, [enabled]); + + const handleOk = useCallback(() => { + setDiscussionEnabled(false); + updateSettings(false); + }, [updateSettings]); + + const handleChange = useCallback((e) => { + const toggleVal = e.target.checked; + setDiscussionEnabled(!toggleVal); + if (!toggleVal) { + updateSettings(!toggleVal); + } + }, [updateSettings]); if (!selectedAppId || status === LOADING) { return ( @@ -71,10 +121,21 @@ const AppList = ({ intl }) => { )); return ( -
    -

    - {intl.formatMessage(messages.heading)} -

    +
    +
    +

    + {intl.formatMessage(messages.heading)} +

    + + Hide discussion tab + +
    { lg: 4, xl: 4, }} + className={!isOnSmallcreen && 'mt-5'} > {(isGlobalStaff || ltiProvider) ? showAppCard(apps) : showAppCard(showOneEdxProvider)} @@ -96,6 +158,23 @@ const AppList = ({ intl }) => { />
    + + + + + )} + > +

    + {intl.formatMessage(messages.hideDiscussionTabMessage)} +

    +
    ); }; diff --git a/src/pages-and-resources/discussions/app-list/AppList.scss b/src/pages-and-resources/discussions/app-list/AppList.scss index 0182483c85..83d3c3c915 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.scss +++ b/src/pages-and-resources/discussions/app-list/AppList.scss @@ -46,10 +46,39 @@ padding: 0 7px; } +.line-height-24 { + line-height: 24px !important; +} + +.hide-discussion-modal { + .pgn__modal-header { + padding-top: 24px; + + h2 { + color: $primary-500; + line-height: 28px; + font-size: 22px; + } + } + + .bg-black { + color: #000000; + } + + .pgn__modal-footer { + padding-top: 8px; + padding-bottom: 24px; + } + + button { + font-weight: 500; + } +} + .discussion-restriction { .unselected-button { &:hover { - background: #E9E6E4 !important; + background: #E9E6E4; } } diff --git a/src/pages-and-resources/discussions/app-list/AppList.test.jsx b/src/pages-and-resources/discussions/app-list/AppList.test.jsx index 08a1b3e421..9bfa74d8f7 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.test.jsx +++ b/src/pages-and-resources/discussions/app-list/AppList.test.jsx @@ -1,7 +1,7 @@ /* eslint-disable react/jsx-no-constructed-context-values */ import React from 'react'; import { - render, screen, within, queryAllByRole, + render, screen, within, queryAllByRole, waitFor, } from '@testing-library/react'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -69,36 +69,47 @@ describe('AppList', () => { test('display a card for each available app', async () => { renderComponent(); - const appCount = store.getState().discussions.appIds.length; - expect(screen.queryAllByRole('radio')).toHaveLength(appCount); + + await waitFor(async () => { + const appCount = await store.getState().discussions.appIds.length; + expect(screen.queryAllByRole('radio')).toHaveLength(appCount); + }); }); test('displays the FeaturesTable at desktop sizes', async () => { renderComponent(); - expect(screen.queryByRole('table')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('table')).toBeInTheDocument()); }); test('hides the FeaturesTable at mobile sizes', async () => { renderComponent(breakpoints.extraSmall.maxWidth); - expect(screen.queryByRole('table')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('table')).not.toBeInTheDocument()); }); test('hides the FeaturesList at desktop sizes', async () => { renderComponent(); - expect(screen.queryByText(messages['supportedFeatureList-mobile-show'].defaultMessage)).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(messages['supportedFeatureList-mobile-show'].defaultMessage)) + .not.toBeInTheDocument()); }); test('displays the FeaturesList at mobile sizes', async () => { renderComponent(breakpoints.extraSmall.maxWidth); - const appCount = store.getState().discussions.appIds.length; - expect(screen.queryAllByText(messages['supportedFeatureList-mobile-show'].defaultMessage)).toHaveLength(appCount); + + await waitFor(async () => { + const appCount = await store.getState().discussions.appIds.length; + expect(screen.queryAllByText(messages['supportedFeatureList-mobile-show'].defaultMessage)) + .toHaveLength(appCount); + }); }); test('selectApp is called when an app is clicked', async () => { renderComponent(); - userEvent.click(screen.getByLabelText('Select Piazza')); - const clickedCard = screen.getByRole('radio', { checked: true }); - expect(within(clickedCard).queryByLabelText('Select Piazza')).toBeInTheDocument(); + + await waitFor(() => { + userEvent.click(screen.getByLabelText('Select Piazza')); + const clickedCard = screen.getByRole('radio', { checked: true }); + expect(within(clickedCard).queryByLabelText('Select Piazza')).toBeInTheDocument(); + }); }); }); @@ -121,7 +132,7 @@ describe('AppList', () => { test('does not display two edx providers card for non admin role', async () => { renderComponent(); const appCount = store.getState().discussions.appIds.length; - expect(queryAllByRole(container, 'radio')).toHaveLength(appCount - 1); + await waitFor(() => expect(queryAllByRole(container, 'radio')).toHaveLength(appCount - 1)); }); }); }); diff --git a/src/pages-and-resources/discussions/app-list/messages.js b/src/pages-and-resources/discussions/app-list/messages.js index 531d333cbb..96b882e3cf 100644 --- a/src/pages-and-resources/discussions/app-list/messages.js +++ b/src/pages-and-resources/discussions/app-list/messages.js @@ -239,6 +239,26 @@ const messages = defineMessages({ defaultMessage: 'Commonly requested', description: 'The type of a discussions feature.', }, + hideDiscussionTabTitle: { + id: 'authoring.discussions.hide-tab-title', + defaultMessage: 'Hide the discussion tab?', + description: 'Title message to hide discussion tab', + }, + hideDiscussionTabMessage: { + id: 'authoring.discussions.hide-tab-message', + defaultMessage: 'The discussion tab will no longer be visible to learners in the LMS. Additionally, posting to the discussion forums will be disabled. Are you sure you want to proceed?', + description: 'Help message to hide discussion tab', + }, + hideDiscussionOkButton: { + id: 'authoring.discussions.hide-ok-button', + defaultMessage: 'Ok', + description: 'Ok button title', + }, + hideDiscussionCancelButton: { + id: 'authoring.discussions.hide-cancel-button', + defaultMessage: 'Cancel', + description: 'Cancel button title', + }, }); export default messages; diff --git a/src/pages-and-resources/discussions/data/api.js b/src/pages-and-resources/discussions/data/api.js index a960b6a272..12d0177f87 100644 --- a/src/pages-and-resources/discussions/data/api.js +++ b/src/pages-and-resources/discussions/data/api.js @@ -233,7 +233,7 @@ function denormalizeData(courseId, appId, data) { const apiData = { context_key: courseId, - enabled: true, + enabled: data.enabled, lti_configuration: ltiConfiguration, plugin_configuration: pluginConfiguration, provider_type: appId, diff --git a/src/pages-and-resources/discussions/data/hook.js b/src/pages-and-resources/discussions/data/hook.js new file mode 100644 index 0000000000..f6b45f40ba --- /dev/null +++ b/src/pages-and-resources/discussions/data/hook.js @@ -0,0 +1,6 @@ +import { breakpoints, useWindowSize } from '@edx/paragon'; + +export default function useIsOnSmallScreen() { + const windowSize = useWindowSize(); + return windowSize.width < breakpoints.medium.minWidth; +} diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js index 9eb72e0232..baf6ff3d6f 100644 --- a/src/pages-and-resources/discussions/data/redux.test.js +++ b/src/pages-and-resources/discussions/data/redux.test.js @@ -2,6 +2,7 @@ import { history } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { initializeMockApp } from '@edx/frontend-platform/testing'; import MockAdapter from 'axios-mock-adapter'; +import { waitFor } from '@testing-library/react'; import { DivisionSchemes } from '../../../data/constants'; import { LOADED } from '../../../data/slice'; import initializeStore from '../../../store'; @@ -371,23 +372,25 @@ describe('Data layer integration tests', () => { pagesAndResourcesPath, ), store.dispatch); - expect(window.location.pathname).toEqual(pagesAndResourcesPath); - expect(store.getState().discussions).toEqual( - expect.objectContaining({ - appIds: ['legacy', 'openedx', 'piazza', 'discourse'], - featureIds, - activeAppId: 'piazza', - selectedAppId: 'piazza', - status: LOADED, - saveStatus: SAVED, - hasValidationError: false, - }), - ); - expect(store.getState().models.appConfigs.piazza).toEqual({ - id: 'piazza', - consumerKey: 'new_consumer_key', - consumerSecret: 'new_consumer_secret', - launchUrl: 'https://localhost/new_launch_url', + waitFor(() => { + expect(window.location.pathname).toEqual(pagesAndResourcesPath); + expect(store.getState().discussions).toEqual( + expect.objectContaining({ + appIds: ['legacy', 'openedx', 'piazza', 'discourse'], + featureIds, + activeAppId: 'piazza', + selectedAppId: 'piazza', + status: LOADED, + saveStatus: SAVED, + hasValidationError: false, + }), + ); + expect(store.getState().models.appConfigs.piazza).toEqual({ + id: 'piazza', + consumerKey: 'new_consumer_key', + consumerSecret: 'new_consumer_secret', + launchUrl: 'https://localhost/new_launch_url', + }); }); }); @@ -465,35 +468,37 @@ describe('Data layer integration tests', () => { }, pagesAndResourcesPath, ), store.dispatch); - expect(window.location.pathname).toEqual(pagesAndResourcesPath); - expect(store.getState().discussions).toEqual( - expect.objectContaining({ - appIds: ['legacy', 'openedx', 'piazza', 'discourse'], - featureIds, - activeAppId: 'legacy', - selectedAppId: 'legacy', - status: LOADED, - saveStatus: SAVED, - hasValidationError: false, - divideDiscussionIds, - discussionTopicIds, - }), - ); - expect(store.getState().models.appConfigs.legacy).toEqual({ - id: 'legacy', - // These three fields should be updated. - allowAnonymousPosts: true, - allowAnonymousPostsPeers: true, - reportedContentEmailNotifications: true, - alwaysDivideInlineDiscussions: true, - restrictedDates: [], - // TODO: Note! The values we tried to save were ignored, this test reflects what currently - // happens, but NOT what we want to have happen! - divideByCohorts: true, - divisionScheme: DivisionSchemes.COHORT, - cohortsEnabled: false, - allowDivisionByUnit: false, - divideCourseTopicsByCohorts: true, + waitFor(() => { + expect(window.location.pathname).toEqual(pagesAndResourcesPath); + expect(store.getState().discussions).toEqual( + expect.objectContaining({ + appIds: ['legacy', 'openedx', 'piazza', 'discourse'], + featureIds, + activeAppId: 'legacy', + selectedAppId: 'legacy', + status: LOADED, + saveStatus: SAVED, + hasValidationError: false, + divideDiscussionIds, + discussionTopicIds, + }), + ); + expect(store.getState().models.appConfigs.legacy).toEqual({ + id: 'legacy', + // These three fields should be updated. + allowAnonymousPosts: true, + allowAnonymousPostsPeers: true, + reportedContentEmailNotifications: true, + alwaysDivideInlineDiscussions: true, + restrictedDates: [], + // TODO: Note! The values we tried to save were ignored, this test reflects what currently + // happens, but NOT what we want to have happen! + divideByCohorts: true, + divisionScheme: DivisionSchemes.COHORT, + cohortsEnabled: false, + allowDivisionByUnit: false, + divideCourseTopicsByCohorts: true, + }); }); }); }); diff --git a/src/pages-and-resources/discussions/data/slice.js b/src/pages-and-resources/discussions/data/slice.js index 3029b00ecf..8608d30fe4 100644 --- a/src/pages-and-resources/discussions/data/slice.js +++ b/src/pages-and-resources/discussions/data/slice.js @@ -29,6 +29,7 @@ const slice = createSlice({ enableGradedUnits: false, unitLevelVisibility: false, postingRestrictions: null, + enabled: true, }, reducers: { loadApps: (state, { payload }) => { diff --git a/src/pages-and-resources/pages/PageCard.jsx b/src/pages-and-resources/pages/PageCard.jsx index 5891b01ce6..4d7458bbdb 100644 --- a/src/pages-and-resources/pages/PageCard.jsx +++ b/src/pages-and-resources/pages/PageCard.jsx @@ -35,18 +35,6 @@ const PageCard = ({ // eslint-disable-next-line react/no-unstable-nested-components const SettingsButton = () => { if (page.legacyLink) { - if (process.env.ENABLE_NEW_CUSTOM_PAGES === 'true' && page.name === 'Custom pages') { - return ( - - - - ); - } return ( { renderComponent(); expect(queryAllByRole(container, 'button')).toHaveLength(3); }); - it('should navigate to custom-pages', async () => { - renderComponent(); - const [customPagesSettingsButton] = queryAllByRole(container, 'link'); - expect(customPagesSettingsButton).toHaveAttribute('href', 'custom-pages'); - }); it('should navigate to legacyLink', async () => { renderComponent(); const textbookSettingsButton = queryAllByRole(container, 'link')[1]; diff --git a/src/studio-header/Header.jsx b/src/studio-header/Header.jsx index 500937d65e..650bb4e5b0 100644 --- a/src/studio-header/Header.jsx +++ b/src/studio-header/Header.jsx @@ -41,7 +41,7 @@ const Header = ({ {intl.formatMessage(messages['header.links.updates'])}