feat: integrated backend discussions restriction with UI (#507)

* feat: integrated backend discussions restriction with UI

* refactor: code refactoring

* test: fixes test cases

* refactor: discussion restriction component

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
This commit is contained in:
ayesha waris
2023-06-06 14:55:19 +05:00
committed by GitHub
parent 788f671626
commit 1b1afcf195
13 changed files with 191 additions and 162 deletions

View File

@@ -14,7 +14,7 @@ const ConfirmationPopup = ({
cancelButtonClass,
sectionClasses,
}) => (
<Card className="rounded mb-3 px-1">
<Card className="rounded px-1 mt-4">
<Card.Header
className="text-primary-500"
title={label}

View File

@@ -25,7 +25,7 @@ const OpenedXConfigForm = ({
onSubmit, formRef, intl, legacy,
}) => {
const {
selectedAppId, enableGradedUnits, discussionTopicIds, divideDiscussionIds,
selectedAppId, enableGradedUnits, discussionTopicIds, divideDiscussionIds, postingRestrictions,
} = useSelector(state => state.discussions);
const appConfigObj = useModel('appConfigs', selectedAppId);
const discussionTopicsModel = useModels('discussionTopics', discussionTopicIds);
@@ -34,6 +34,7 @@ const OpenedXConfigForm = ({
divideDiscussionIds,
enableInContext: true,
enableGradedUnits,
postingRestrictions,
unitLevelVisibility: true,
allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
reportedContentEmailNotifications: appConfigObj?.reportedContentEmailNotifications || false,
@@ -109,6 +110,7 @@ const OpenedXConfigForm = ({
validDiscussionTopics,
setValidDiscussionTopics,
discussionTopicErrors,
postingRestrictions,
restrictedDatesErrors,
isFormInvalid:
discussionTopicErrors.some((error) => error)
@@ -117,7 +119,7 @@ const OpenedXConfigForm = ({
return (
<OpenedXConfigFormProvider value={contextValue}>
<Card className="mb-5 px-4 px-sm-5 pb-5" data-testid="legacyConfigForm">
<Card className="mb-5 px-4 px-sm-5 pb-4" data-testid="legacyConfigForm">
<Form ref={formRef} onSubmit={handleSubmit}>
<h3 className="text-primary-500 my-3">{intl.formatMessage(messages[`appName-${selectedAppId}`])}</h3>
<AppConfigFormDivider thick />

View File

@@ -40,6 +40,7 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
{ name: 'General', id: 'course' },
],
divideDiscussionIds,
postingRestrictions: 'scheduled',
enableGradedUnits: undefined,
enableInContext: true,
groupAtSubsection: false,

View File

@@ -1,137 +1,89 @@
import React, { useCallback, useState } from 'react';
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import React, { useCallback, useState, useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, ButtonGroup } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
import classNames from 'classnames';
import { FieldArray, useFormikContext } from 'formik';
import { v4 as uuid } from 'uuid';
import { useFormikContext } from 'formik';
import ConfirmationPopup from '../../../../../generic/ConfirmationPopup';
import messages from '../../messages';
import DiscussionRestrictionItem from './discussion-restrictions/DiscussionRestrictionItem';
import { checkStatus } from '../../utils';
import { denormalizeRestrictedDate } from '../../../data/api';
import { restrictedDatesStatus as STATUS, discussionRestrictionOptions } from '../../../data/constants';
import DiscussionRestrictionOption from './discussion-restrictions/DiscussionRestrictionOption';
import { discussionRestrictionOptions, discussionRestriction } from '../../../data/constants';
import RestrictionSchedules from './discussion-restrictions/RestrictionSchedules';
const DiscussionRestriction = () => {
const intl = useIntl();
const {
values: appConfig,
setFieldValue,
errors,
validateForm,
} = useFormikContext();
const intl = useIntl();
const { restrictedDates } = appConfig;
const [selectedOption, setSelectedOption] = useState('');
const handleOnClose = useCallback((index) => {
const updatedRestrictedDates = [...restrictedDates];
updatedRestrictedDates[index] = {
...updatedRestrictedDates[index],
status: checkStatus(denormalizeRestrictedDate(updatedRestrictedDates[index])),
};
setFieldValue('restrictedDates', updatedRestrictedDates);
}, [restrictedDates]);
const newRestrictedDateItem = {
id: uuid(),
startDate: '',
startTime: '',
endDate: '',
endTime: '',
status: STATUS.UPCOMING,
};
const onAddNewItem = useCallback(async (push) => {
await push(newRestrictedDateItem);
validateForm();
}, []);
const { postingRestrictions } = appConfig;
const [selectedRestrictionOption, setSelectedRestrictionOption] = useState(postingRestrictions);
const handleClick = useCallback((value) => {
setSelectedOption(value);
setSelectedRestrictionOption(value);
if (value !== discussionRestriction.ENABLED) {
setFieldValue('postingRestrictions', value);
}
}, []);
const handleConfirmation = useCallback(() => {
setSelectedRestrictionOption(discussionRestriction.ENABLED);
setFieldValue('postingRestrictions', discussionRestriction.ENABLED);
}, []);
const handleCancel = useCallback(() => {
setSelectedOption('');
}, []);
setSelectedRestrictionOption(postingRestrictions);
}, [postingRestrictions]);
const discussionRestrictionButtons = useMemo(() => discussionRestrictionOptions.map((restriction) => (
<Button
key={`restriction-${restriction.value}`}
variant="plain"
className={classNames('w-100 font-size-14 font-weight-500 line-height-20 py-7px border-light-400 unselected-button', {
'text-white bg-primary-500 selected-button': selectedRestrictionOption === restriction.value,
})}
onClick={() => handleClick(restriction.value)}
>
{restriction.label}
</Button>
)), [selectedRestrictionOption]);
const selectedRestrictionMessage = useMemo(() => (
discussionRestrictionOptions.find(option => option.value === selectedRestrictionOption).message
), [selectedRestrictionOption]);
return (
<div className="discussion-restriction">
<h5 className="text-gray-500 mt-4 mb-3 line-height-20">
{intl.formatMessage(messages.discussionRestrictionLabel)}
</h5>
<ButtonGroup className="mb-3 w-100 d-flex flex-row height-36">
{discussionRestrictionOptions.map((option) => (
<DiscussionRestrictionOption
label={option.label}
value={option.value}
selectedOption={selectedOption}
onClick={handleClick}
>{option.label}
</DiscussionRestrictionOption>
))}
<ButtonGroup className="mb-2 w-100" toggle size="sm">
{discussionRestrictionButtons}
</ButtonGroup>
{(selectedOption === 'on' || selectedOption === 'off') && (
<div className="small text-muted font-size-14 height-24 mb-4">
{intl.formatMessage(messages.discussionRestrictionHelp)}
</div>
)}
{selectedOption === 'on' && (
<ConfirmationPopup
label={intl.formatMessage(messages.enableRestrictedDatesConfirmationLabel)}
bodyText={intl.formatMessage(messages.enableRestrictedDatesConfirmationHelp)}
onCancel={handleCancel}
confirmLabel={intl.formatMessage(messages.ok)}
cancelLabel={intl.formatMessage(messages.cancelButton)}
confirmVariant="plain"
confirmButtonClass="bg-primary-500 text-white rounded-0 action-btn"
cancelButtonClass="rounded-0 action-btn w-92"
sectionClasses="card-body-section"
/>
)}
{selectedOption === 'scheduled' && (
<div>
<div className="small mb-3 text-muted font-size-14 height-24">
{intl.formatMessage(messages.discussionRestrictionDatesHelp)}
</div>
<FieldArray
name="restrictedDates"
render={({ push, remove }) => (
<div>
{restrictedDates.map((restrictedDate, index) => (
<DiscussionRestrictionItem
fieldNameCommonBase={`restrictedDates.${index}`}
restrictedDate={restrictedDate}
key={`date-${restrictedDate.id}`}
id={restrictedDate.id}
onDelete={() => remove(index)}
onClose={() => handleOnClose(index)}
hasError={Boolean(errors?.restrictedDates?.[index])}
/>
))}
<div className="mb-4 mt-4 height-36">
<Button
onClick={() => onAddNewItem(push)}
variant="link"
iconBefore={Add}
className="text-primary-500 p-0"
style={{ height: 28 }}
>
{intl.formatMessage(messages.addRestrictedDatesButton)}
</Button>
</div>
</div>
)}
/>
<div className="small text-muted font-size-14 height-24">
{intl.formatMessage(selectedRestrictionMessage)}
</div>
{(postingRestrictions !== discussionRestriction.ENABLED
&& selectedRestrictionOption === discussionRestriction.ENABLED
) && (
<ConfirmationPopup
label={intl.formatMessage(messages.enableRestrictedDatesConfirmationLabel)}
bodyText={intl.formatMessage(messages.enableRestrictedDatesConfirmationHelp)}
onCancel={handleCancel}
onConfirm={handleConfirmation}
confirmLabel={intl.formatMessage(messages.ok)}
cancelLabel={intl.formatMessage(messages.cancelButton)}
confirmVariant="plain"
confirmButtonClass="bg-primary-500 text-white rounded-0 action-btn"
cancelButtonClass="rounded-0 action-btn w-92"
sectionClasses="card-body-section"
/>
)}
{selectedRestrictionOption === discussionRestriction.SCHEDULED && <RestrictionSchedules />}
</div>
);
};
export default injectIntl(React.memo(DiscussionRestriction));
export default React.memo(DiscussionRestriction);

View File

@@ -18,7 +18,6 @@ const InContextDiscussionFields = ({
} = useFormikContext();
const [showPopup, setShowPopup] = useState(false);
const handleConfirmation = () => {
setFieldValue('enableGradedUnits', !values.enableGradedUnits);
setShowPopup(false);

View File

@@ -1,29 +0,0 @@
import React from 'react';
import { Button } from '@edx/paragon';
import PropTypes from 'prop-types';
const DiscussionRestrictionOption = ({
value,
label,
onClick,
selectedOption,
}) => (
<Button
variant="plain"
className={`w-100 font-size-14 line-height-20 border border-light-400 rounded-0
${selectedOption === value ? 'text-white bg-primary-500' : 'unselected-button'}`}
onClick={() => onClick(value)}
style={{ padding: '8px 12px', fontWeight: 500 }}
>
{label}
</Button>
);
DiscussionRestrictionOption.propTypes = {
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
selectedOption: PropTypes.string.isRequired,
};
export default React.memo(DiscussionRestrictionOption);

View File

@@ -0,0 +1,84 @@
import React, { useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
import { FieldArray, useFormikContext } from 'formik';
import { v4 as uuid } from 'uuid';
import messages from '../../../messages';
import DiscussionRestrictionItem from './DiscussionRestrictionItem';
import { checkStatus } from '../../../utils';
import { denormalizeRestrictedDate } from '../../../../data/api';
import { restrictedDatesStatus as STATUS } from '../../../../data/constants';
const RestrictionSchedules = () => {
const intl = useIntl();
const {
values: appConfig,
setFieldValue,
errors,
validateForm,
} = useFormikContext();
const { restrictedDates } = appConfig;
const handleOnClose = useCallback((index) => {
const updatedRestrictedDates = [...restrictedDates];
updatedRestrictedDates[index] = {
...updatedRestrictedDates[index],
status: checkStatus(denormalizeRestrictedDate(updatedRestrictedDates[index])),
};
setFieldValue('restrictedDates', updatedRestrictedDates);
}, [restrictedDates]);
const newRestrictedDateItem = {
id: uuid(),
startDate: '',
startTime: '',
endDate: '',
endTime: '',
status: STATUS.UPCOMING,
};
const onAddNewItem = useCallback(async (push) => {
await push(newRestrictedDateItem);
validateForm();
}, []);
return (
<div>
<FieldArray
name="restrictedDates"
render={({ push, remove }) => (
<div className="mt-4">
{restrictedDates.map((restrictedDate, index) => (
<DiscussionRestrictionItem
fieldNameCommonBase={`restrictedDates.${index}`}
restrictedDate={restrictedDate}
key={`date-${restrictedDate.id}`}
id={restrictedDate.id}
onDelete={() => remove(index)}
onClose={() => handleOnClose(index)}
hasError={Boolean(errors?.restrictedDates?.[index])}
/>
))}
<div className="mb-4 mt-4 height-36">
<Button
onClick={() => onAddNewItem(push)}
variant="link"
iconBefore={Add}
className="text-primary-500 p-0"
style={{ height: 28 }}
>
{intl.formatMessage(messages.addRestrictedDatesButton)}
</Button>
</div>
</div>
)}
/>
</div>
);
};
export default React.memo(RestrictionSchedules);

View File

@@ -403,17 +403,17 @@ const messages = defineMessages({
defaultMessage: 'If deleted, learners will be able to post in discussions during these dates.',
description: 'Help text for delete a upcoming restricted dates from restricted dates section.',
},
discussionRestrictionOffLabel: {
discussionRestrictionOffLabelHelpText: {
id: 'authoring.discussions.discussionRestrictionOff.label',
defaultMessage: 'Off',
defaultMessage: 'If enabled, learners will be able to post in discussions',
},
discussionRestrictionOnLabel: {
discussionRestrictionOnLabelHelpText: {
id: 'authoring.discussions.discussionRestrictionOn.label',
defaultMessage: 'On',
defaultMessage: 'If enabled, learners will not be able to post in discussions',
},
discussionRestrictionScheduledLabel: {
discussionRestrictionScheduledLabelHelpText: {
id: 'authoring.discussions.discussionRestrictionScheduled.label',
defaultMessage: 'Scheduled',
defaultMessage: 'If added, learners will not be able to post in discussions between these dates.',
},
enableRestrictedDatesConfirmationLabel: {
id: 'authoring.discussions.enableRestrictedDatesConfirmation.label',

View File

@@ -29,19 +29,30 @@
.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 0 7px ;
}
.discussion-restriction{
.unselected-button{
&:hover{
background: #e9e6e4 !important;
background: #e9e6e4 ;
}
}
.action-btn{
padding: 10px 16px;
width: 80px;
@@ -50,19 +61,23 @@
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: 0px !important;
font-weight: 400;
font-size: 14px;
line-height: 24px;
}
.collapsible-card{
padding: 14px 14px 14px 24px !important;
min-height:100px;

View File

@@ -143,6 +143,7 @@ function normalizeSettings(data) {
enableInContext: data.enable_in_context,
enableGradedUnits: data.enable_graded_units,
unitLevelVisibility: data.unit_level_visibility,
postingRestrictions: data.posting_restrictions,
appConfig: normalizeAppConfig(data),
piiConfig: normalizePiiSharing(data.lti_configuration),
discussionTopicIds: data.plugin_configuration.discussion_topics
@@ -246,6 +247,9 @@ function denormalizeData(courseId, appId, data) {
if ('unitLevelVisibility' in data) {
apiData.unit_level_visibility = data.unitLevelVisibility;
}
if ('postingRestrictions' in data) {
apiData.posting_restrictions = data.postingRestrictions;
}
return apiData;
}
@@ -272,7 +276,6 @@ export async function getDiscussionsSettings(courseId, providerId = null) {
const url = getDiscussionsSettingsUrl(courseId);
const { data } = await getAuthenticatedHttpClient()
.get(url, params);
return normalizeSettings(data);
}

View File

@@ -21,32 +21,32 @@ export const deleteRestrictedDatesHelperText = {
};
export const discussionRestriction = {
OFF: 'off',
ON: 'on',
DISABLED: 'disabled',
ENABLED: 'enabled',
SCHEDULED: 'scheduled',
};
export const discussionRestrictionLabel = {
[discussionRestriction.OFF]: 'Off',
[discussionRestriction.ON]: 'On',
[discussionRestriction.SCHEDULED]: 'Scheduled',
export const discussionRestrictionLabels = {
OFF: 'Off',
ON: 'On',
SCHEDULED: 'Scheduled',
};
export const discussionRestrictionOptions = [
{
value: discussionRestriction.OFF,
description: messages.discussionRestrictionOffLabel,
label: discussionRestrictionLabel[discussionRestriction.OFF],
value: discussionRestriction.DISABLED,
message: messages.discussionRestrictionOffLabelHelpText,
label: discussionRestrictionLabels.OFF,
},
{
value: discussionRestriction.ON,
description: messages.discussionRestrictionOnLabel,
label: discussionRestrictionLabel[discussionRestriction.ON],
value: discussionRestriction.ENABLED,
message: messages.discussionRestrictionOnLabelHelpText,
label: discussionRestrictionLabels.ON,
},
{
value: discussionRestriction.SCHEDULED,
description: messages.discussionRestrictionScheduledLabel,
label: discussionRestrictionLabel[discussionRestriction.SCHEDULED],
message: messages.discussionRestrictionScheduledLabelHelpText,
label: discussionRestrictionLabels.SCHEDULED,
},
];

View File

@@ -28,6 +28,7 @@ const slice = createSlice({
enableInContext: false,
enableGradedUnits: false,
unitLevelVisibility: false,
postingRestrictions: null,
},
reducers: {
loadApps: (state, { payload }) => {

View File

@@ -103,6 +103,7 @@ export const generateProvidersApiResponse = (piazzaAdminOnlyConfig = false, acti
export const generateLegacyApiResponse = () => ({
context_key: 'course-v1:edX+DemoX+Demo_Course',
enabled: true,
posting_restrictions: 'scheduled',
provider_type: 'legacy',
lti_configuration: {},
plugin_configuration: {