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 }) => {