diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx index 3941362fd7..3d9cf98b71 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.test.jsx @@ -171,10 +171,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 3358c9120a..24bfa1ec64 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 } 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 { useModels } from '../../../generic/model-store'; @@ -13,15 +17,27 @@ 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' + )), [activeAppId]); // This could be a bit confusing. activeAppId is the ID of the app that is currently configured // according to the server. selectedAppId is the ID of the app that we _want_ to configure here @@ -36,9 +52,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 ( @@ -55,10 +110,21 @@ const AppList = ({ intl }) => { } return ( -
-

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

+
+
+

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

+ + Hide discussion tab + +
{ lg: 4, xl: 4, }} + className={!isOnSmallcreen && 'mt-5'} > {apps.map(app => ( { />
+ + + + + )} + > +

+ {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 40e2a3c736..99f23125db 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.scss +++ b/src/pages-and-resources/discussions/app-list/AppList.scss @@ -20,7 +20,102 @@ .pgn__data-table-cell-wrap { max-width: unset; } + } + } + } +} + +.height-36 { + height: 2.25rem !important; +} + +.line-height-20 { + line-height: 1.25rem !important; +} + +.font-size-14 { + font-size: 14px !important; +} + +.font-weight-500 { + font-weight: 500 !important; +} + +.py-7px { + 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; + } + } + + .action-btn { + padding: 10px 16px; + width: 80px; + height: 44px; + font-weight: 500; + font-size: 18px; + line-height: 24px; + } + + .w-92 { + width: 92px; + } + + .card-body-section { + padding-top: 12px !important; + padding-bottom: 20px !important; + } + + .form-control { + border-radius: 0 !important; + font-weight: 400; + font-size: 14px; + line-height: 24px; + } + + .collapsible-card { + padding: 14px 14px 14px 24px !important; + min-height: 100px; + + .collapsible-trigger { + padding: 0 !important; + .badge { + font-size: 12px; + line-height: 20px; } } } 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 bc5625235f..4e98d53f77 100644 --- a/src/pages-and-resources/discussions/app-list/AppList.test.jsx +++ b/src/pages-and-resources/discussions/app-list/AppList.test.jsx @@ -53,8 +53,89 @@ describe('AppList', () => { }, }); - store = await initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + test('Successfully shows the disable toggle state of the hide discussion tab by default.', async () => { + renderComponent(); + + await waitFor(async () => { + const hideDiscussionTab = screen.getByTestId('hide-discussion'); + + expect(hideDiscussionTab).not.toBeChecked(); + }); + }); + + test.each([ + { title: 'Ok', description: 'Enable the toggle state by clicking on OK button' }, + { title: 'Cancel', description: 'Disable the toggle state by clicking on Cancel button' }, + ])('%s of the hide discussion tab', async ({ title }) => { + renderComponent(); + + await waitFor(async () => { + let hideDiscussionTab = screen.getByTestId('hide-discussion'); + + await act(async () => { + fireEvent.click(hideDiscussionTab); + }); + + const actionButton = screen.queryByText(title); + + await act(async () => { + fireEvent.click(actionButton); + }); + + hideDiscussionTab = screen.getByTestId('hide-discussion'); + + if (title === 'OK') { + expect(hideDiscussionTab).toBeChecked(); + } else { + expect(hideDiscussionTab).not.toBeChecked(); + } + }); + }); + + test('display a card for each available app', async () => { + renderComponent(); + + 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(); + await waitFor(() => expect(screen.queryByRole('table')).toBeInTheDocument()); + }); + + test('hides the FeaturesTable at mobile sizes', async () => { + renderComponent(breakpoints.extraSmall.maxWidth); + await waitFor(() => expect(screen.queryByRole('table')).not.toBeInTheDocument()); + }); + + test('hides the FeaturesList at desktop sizes', async () => { + renderComponent(); + await waitFor(() => expect(screen.queryByText(messages['supportedFeatureList-mobile-show'].defaultMessage)) + .not.toBeInTheDocument()); + }); + + test('displays the FeaturesList at mobile sizes', async () => { + renderComponent(breakpoints.extraSmall.maxWidth); + + 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(); + + await waitFor(() => { + userEvent.click(screen.getByLabelText('Select Piazza')); + const clickedCard = screen.getByRole('radio', { checked: true }); + expect(within(clickedCard).queryByLabelText('Select Piazza')).toBeInTheDocument(); + }); + }); }); const mockStore = async (mockResponse, screenWidth = breakpoints.extraLarge.minWidth) => { 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 0b272c4745..cfd4e9b46b 100644 --- a/src/pages-and-resources/discussions/data/api.js +++ b/src/pages-and-resources/discussions/data/api.js @@ -232,7 +232,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 c63c23c049..304522d993 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', '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', '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, - blackoutDates: [], - // 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 174b31e12e..8608d30fe4 100644 --- a/src/pages-and-resources/discussions/data/slice.js +++ b/src/pages-and-resources/discussions/data/slice.js @@ -28,6 +28,8 @@ const slice = createSlice({ enableInContext: false, enableGradedUnits: false, unitLevelVisibility: false, + postingRestrictions: null, + enabled: true, }, reducers: { loadApps: (state, { payload }) => {