feat: implemented discussion restriction UI (#494)

* feat: implemented discussion restriction UI

* refactor: fixed UI figma design issues

* refactor: fixed 2nd review points

* refactor: fixed review issues regarding confirmation popup

* refactor: changed tab component to button group

* perf: performance improvement changes

* refactor: fixed memorization issues

* refactor: fixed memo issues

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
This commit is contained in:
sundasnoreen12
2023-05-19 05:16:48 -07:00
committed by GitHub
parent 738f501cf9
commit 0d67c2588d
18 changed files with 516 additions and 325 deletions

View File

@@ -79,4 +79,4 @@ CollapsableEditor.defaultProps = {
},
};
export default CollapsableEditor;
export default React.memo(CollapsableEditor);

View File

@@ -9,6 +9,10 @@ const ConfirmationPopup = ({
confirmLabel,
onCancel,
cancelLabel,
confirmVariant,
confirmButtonClass,
cancelButtonClass,
sectionClasses,
}) => (
<Card className="rounded mb-3 px-1">
<Card.Header
@@ -17,12 +21,12 @@ const ConfirmationPopup = ({
size="sm"
/>
<Card.Body>
<Card.Section className="text-justify text-muted pt-2 pb-3">{bodyText}</Card.Section>
<Card.Section className={`text-justify text-muted pt-2 pb-3 ${sectionClasses}`}>{bodyText}</Card.Section>
<Card.Footer>
<Button variant="tertiary" onClick={onCancel}>
<Button variant="tertiary" className={cancelButtonClass} onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="outline-brand" className="ml-2" onClick={onConfirm}>
<Button variant={confirmVariant} className={`ml-2 ${confirmButtonClass}`} onClick={onConfirm}>
{confirmLabel}
</Button>
</Card.Footer>
@@ -37,6 +41,16 @@ ConfirmationPopup.propTypes = {
onCancel: PropTypes.func.isRequired,
confirmLabel: PropTypes.string.isRequired,
cancelLabel: PropTypes.string.isRequired,
confirmButtonClass: PropTypes.string,
cancelButtonClass: PropTypes.string,
confirmVariant: PropTypes.string,
sectionClasses: PropTypes.string,
};
ConfirmationPopup.defaultProps = {
confirmVariant: 'outline-brand',
confirmButtonClass: '',
cancelButtonClass: '',
sectionClasses: '',
};
export default ConfirmationPopup;
export default React.memo(ConfirmationPopup);

View File

@@ -50,4 +50,4 @@ FieldFeedback.defaultProps = {
errorMessage: '',
};
export default FieldFeedback;
export default React.memo(FieldFeedback);

View File

@@ -12,7 +12,7 @@ import messages from '../../messages';
import { checkFieldErrors } from '../../utils';
import AnonymousPostingFields from '../shared/AnonymousPostingFields';
import AppConfigFormDivider from '../shared/AppConfigFormDivider';
import BlackoutDatesField from '../shared/BlackoutDatesField';
import DiscussionRestriction from '../shared/DiscussionRestriction';
import DiscussionTopics from '../shared/discussion-topics/DiscussionTopics';
import DivisionByGroupFields from '../shared/DivisionByGroupFields';
import ReportedContentEmailNotifications from '../shared/ReportedContentEmailNotifications';
@@ -38,7 +38,7 @@ const OpenedXConfigForm = ({
allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
reportedContentEmailNotifications: appConfigObj?.reportedContentEmailNotifications || false,
enableReportedContentEmailNotifications: Boolean(appConfigObj?.enableReportedContentEmailNotifications) || false,
blackoutDates: appConfigObj?.blackoutDates || [],
restrictedDates: appConfigObj?.restrictedDates || [],
discussionTopics: discussionTopicsModel || [],
divideByCohorts: appConfigObj?.divideByCohorts || false,
divideCourseTopicsByCohorts: appConfigObj?.divideCourseTopicsByCohorts || false,
@@ -53,27 +53,27 @@ const OpenedXConfigForm = ({
};
const validationSchema = Yup.object().shape({
// eslint-disable-next-line react/forbid-prop-types
blackoutDates: Yup.array(
restrictedDates: Yup.array(
Yup.object().shape({
startDate: Yup.string()
.checkFormat(intl.formatMessage(messages.blackoutStartDateInValidFormat), 'date')
.required(intl.formatMessage(messages.blackoutStartDateRequired)),
.checkFormat(intl.formatMessage(messages.restrictedStartDateInValidFormat), 'date')
.required(intl.formatMessage(messages.restrictedStartDateRequired)),
endDate: Yup.string()
.checkFormat(intl.formatMessage(messages.blackoutEndDateInValidFormat), 'date')
.required(intl.formatMessage(messages.blackoutEndDateRequired))
.checkFormat(intl.formatMessage(messages.restrictedEndDateInValidFormat), 'date')
.required(intl.formatMessage(messages.restrictedEndDateRequired))
.when('startDate', {
is: (startDate) => startDate,
then: Yup.string().compare(intl.formatMessage(messages.blackoutEndDateInPast), 'date'),
then: Yup.string().compare(intl.formatMessage(messages.restrictedEndDateInPast), 'date'),
}),
startTime: Yup.string().checkFormat(
intl.formatMessage(messages.blackoutStartTimeInValidFormat),
intl.formatMessage(messages.restrictedStartTimeInValidFormat),
'time',
),
endTime: Yup.string()
.checkFormat(intl.formatMessage(messages.blackoutEndTimeInValidFormat), 'time')
.checkFormat(intl.formatMessage(messages.restrictedEndTimeInValidFormat), 'time')
.when('startTime', {
is: (startTime) => startTime,
then: Yup.string().compare(intl.formatMessage(messages.blackoutEndTimeInPast), 'time'),
then: Yup.string().compare(intl.formatMessage(messages.restrictedEndTimeInPast), 'time'),
}),
}),
),
@@ -96,23 +96,23 @@ const OpenedXConfigForm = ({
{({
handleSubmit, handleChange, handleBlur, values, errors, touched,
}) => {
const { discussionTopics, blackoutDates } = values;
const { discussionTopics, restrictedDates } = values;
const discussionTopicErrors = discussionTopics.map((value, index) => checkFieldErrors(touched, errors, `discussionTopics.${index}`, 'name'));
const blackoutDatesErrors = blackoutDates.map(
(value, index) => checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'startDate')
|| checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'endDate')
|| checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'startTime')
|| checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'endTime'),
const restrictedDatesErrors = restrictedDates.map(
(value, index) => checkFieldErrors(touched, errors, `restrictedDates.${index}`, 'startDate')
|| checkFieldErrors(touched, errors, `restrictedDates.${index}`, 'endDate')
|| checkFieldErrors(touched, errors, `restrictedDates.${index}`, 'startTime')
|| checkFieldErrors(touched, errors, `restrictedDates.${index}`, 'endTime'),
);
const contextValue = {
validDiscussionTopics,
setValidDiscussionTopics,
discussionTopicErrors,
blackoutDatesErrors,
restrictedDatesErrors,
isFormInvalid:
discussionTopicErrors.some((error) => error)
|| blackoutDatesErrors.some((error) => error),
|| restrictedDatesErrors.some((error) => error),
};
return (
@@ -139,7 +139,7 @@ const OpenedXConfigForm = ({
<DivisionByGroupFields />
<AppConfigFormDivider thick />
<ReportedContentEmailNotifications />
<BlackoutDatesField />
<DiscussionRestriction />
</Form>
</Card>
</OpenedXConfigFormProvider>

View File

@@ -49,7 +49,7 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
reportedContentEmailNotifications: false,
enableReportedContentEmailNotifications: false,
allowDivisionByUnit: false,
blackoutDates: [],
restrictedDates: [],
cohortsEnabled: false,
});
describe('OpenedXConfigForm', () => {
@@ -169,8 +169,8 @@ describe('OpenedXConfigForm', () => {
expect(container.querySelector('#reportedContentEmailNotifications')).toBeInTheDocument();
expect(container.querySelector('#reportedContentEmailNotifications')).not.toBeChecked();
// BlackoutDatesField
expect(queryByText(container, messages.blackoutDatesLabel.defaultMessage)).toBeInTheDocument();
// Discussion Restriction Field
expect(queryByText(container, messages.discussionRestrictionLabel.defaultMessage)).toBeInTheDocument();
});
test('folded sub-fields are in the DOM when parents are enabled', async () => {

View File

@@ -1,95 +0,0 @@
import React, { useCallback } from 'react';
import { injectIntl, intlShape } 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 BlackoutDatesItem from './blackout-dates/BlackoutDatesItem';
import { checkStatus } from '../../utils';
import { denormalizeBlackoutDate } from '../../../data/api';
import { blackoutDatesStatus as STATUS } from '../../../data/constants';
const BlackoutDatesField = ({ intl }) => {
const {
values: appConfig,
setFieldValue,
errors,
validateForm,
} = useFormikContext();
const { blackoutDates } = appConfig;
const handleOnClose = useCallback((index) => {
const updatedBlackoutDates = [...blackoutDates];
updatedBlackoutDates[index] = {
...updatedBlackoutDates[index],
status: checkStatus(denormalizeBlackoutDate(updatedBlackoutDates[index])),
};
setFieldValue('blackoutDates', updatedBlackoutDates);
}, [blackoutDates]);
const newBlackoutDateItem = {
id: uuid(),
startDate: '',
startTime: '',
endDate: '',
endTime: '',
status: STATUS.UPCOMING,
};
const onAddNewItem = async (push) => {
await push(newBlackoutDateItem);
validateForm();
};
return (
<>
<h5 className="text-gray-500 mt-4 mb-2">
{intl.formatMessage(messages.blackoutDatesLabel)}
</h5>
<label className="text-primary-500 mb-1 h4">
{intl.formatMessage(messages.blackoutDates)}
</label>
<div className="small mb-4 text-muted">
{intl.formatMessage(messages.blackoutDatesHelp)}
</div>
<div>
<FieldArray
name="blackoutDates"
render={({ push, remove }) => (
<div>
{blackoutDates.map((blackoutDate, index) => (
<BlackoutDatesItem
fieldNameCommonBase={`blackoutDates.${index}`}
blackoutDate={blackoutDate}
key={`date-${blackoutDate.id}`}
id={blackoutDate.id}
onDelete={() => remove(index)}
onClose={() => handleOnClose(index)}
hasError={Boolean(errors?.blackoutDates?.[index])}
/>
))}
<div className="mb-4">
<Button
onClick={() => onAddNewItem(push)}
variant="link"
iconBefore={Add}
className="text-primary-500 p-0"
>
{intl.formatMessage(messages.addBlackoutDatesButton)}
</Button>
</div>
</div>
)}
/>
</div>
</>
);
};
BlackoutDatesField.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BlackoutDatesField);

View File

@@ -0,0 +1,137 @@
import React, { useCallback, useState } from 'react';
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import { Button, ButtonGroup } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
import { FieldArray, useFormikContext } from 'formik';
import { v4 as uuid } from 'uuid';
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';
const DiscussionRestriction = () => {
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 handleClick = useCallback((value) => {
setSelectedOption(value);
}, []);
const handleCancel = useCallback(() => {
setSelectedOption('');
}, []);
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>
{(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>
)}
</div>
);
};
export default injectIntl(React.memo(DiscussionRestriction));

View File

@@ -14,9 +14,9 @@ const CollapseCardHeading = ({
}
return (
<div className="py-2">
{badgeStatus && <Badge variant={badgeVariant}>{badgeStatus}</Badge>}
<div className="mt-2">{collapseHeadingText}</div>
<div style={{ height: 72 }}>
{badgeStatus && <Badge variant={badgeVariant} style={{ padding: '2px 8px' }}>{badgeStatus}</Badge>}
<div className="mt-2 font-size-14" style={{ lineHeight: '24px' }}>{collapseHeadingText}</div>
</div>
);
};
@@ -34,4 +34,4 @@ CollapseCardHeading.defaultProps = {
badgeStatus: '',
};
export default CollapseCardHeading;
export default React.memo(CollapseCardHeading);

View File

@@ -1,106 +1,116 @@
import React, { useState } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import React, { useState, useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import _ from 'lodash';
import messages from '../../../messages';
import BlackoutDatesInput from './BlackoutDatesInput';
import { formatBlackoutDates } from '../../../utils';
import RestrictDatesInput from './RestrictDatesInput';
import { formatRestrictedDates } from '../../../utils';
import {
blackoutDatesStatus as constants,
deleteHelperText,
restrictedDatesStatus as constants,
deleteRestrictedDatesHelperText,
badgeVariant,
} from '../../../../data/constants';
import CollapsableEditor from '../../../../../../generic/CollapsableEditor';
import ConfirmationPopup from '../../../../../../generic/ConfirmationPopup';
import CollapseCardHeading from './CollapseCardHeading';
const BlackoutDatesItem = ({
intl,
blackoutDate,
const DiscussionRestrictionItem = ({
restrictedDate,
onDelete,
hasError,
onClose,
fieldNameCommonBase,
}) => {
const blackoutDateError = !blackoutDate.startDate || !blackoutDate.endDate || hasError;
const restrictedDateError = !restrictedDate.startDate || !restrictedDate.endDate || hasError;
const [showDeletePopup, setShowDeletePopup] = useState(false);
const [collapseIsOpen, setCollapseOpen] = useState(blackoutDateError);
const [collapseIsOpen, setCollapseOpen] = useState(restrictedDateError);
const { setFieldTouched } = useFormikContext();
const intl = useIntl();
const handleToggle = (isOpen) => {
const handleToggle = useCallback((isOpen) => {
if (!isOpen && hasError) {
return setCollapseOpen(true);
}
return setCollapseOpen(isOpen);
};
}, [hasError]);
const getHeading = (isOpen) => (
<CollapseCardHeading
isOpen={isOpen}
expandHeadingText={intl.formatMessage(messages.configureBlackoutDates)}
collapseHeadingText={formatBlackoutDates(blackoutDate)}
badgeVariant={badgeVariant[blackoutDate.status]}
badgeStatus={intl.formatMessage(messages.blackoutDatesStatus, {
status: _.startCase(_.toLower(blackoutDate.status)),
})}
/>
);
if (showDeletePopup) {
return (
<ConfirmationPopup
label={blackoutDate.status === constants.ACTIVE
? intl.formatMessage(messages.activeBlackoutDatesDeletionLabel)
: intl.formatMessage(messages.blackoutDatesDeletionLabel)}
bodyText={intl.formatMessage(deleteHelperText[blackoutDate.status])}
onConfirm={onDelete}
confirmLabel={intl.formatMessage(messages.deleteButton)}
onCancel={() => setShowDeletePopup(false)}
cancelLabel={intl.formatMessage(messages.cancelButton)}
/>
);
}
const handleOnClose = () => {
const handleOnClose = useCallback(() => {
['startDate', 'startTime', 'endDate', 'endTime'].forEach(field => (
setFieldTouched(`${fieldNameCommonBase}.${field}`, true)
));
if (!hasError) {
onClose();
}
};
}, [hasError, onClose]);
const getHeading = useCallback((isOpen) => (
<CollapseCardHeading
isOpen={isOpen}
expandHeadingText={intl.formatMessage(messages.configureRestrictedDates)}
collapseHeadingText={formatRestrictedDates(restrictedDate)}
badgeVariant={badgeVariant[restrictedDate.status]}
badgeStatus={intl.formatMessage(messages.restrictedDatesStatus, {
status: _.startCase(_.toLower(restrictedDate.status)),
})}
/>
), [restrictedDate]);
const handleShowDeletePopup = useCallback(() => {
setShowDeletePopup(true);
}, []);
const handleCancelDeletePopup = useCallback(() => {
setShowDeletePopup(false);
}, []);
if (showDeletePopup) {
return (
<ConfirmationPopup
label={restrictedDate.status === constants.ACTIVE
? intl.formatMessage(messages.activeRestrictedDatesDeletionLabel)
: intl.formatMessage(messages.restrictedDatesDeletionLabel)}
bodyText={intl.formatMessage(deleteRestrictedDatesHelperText[restrictedDate.status])}
onConfirm={onDelete}
confirmLabel={intl.formatMessage(messages.deleteButton)}
onCancel={handleCancelDeletePopup}
cancelLabel={intl.formatMessage(messages.cancelButton)}
confirmVariant="plain"
confirmButtonClass="text-danger-500 border-gray-300 rounded-0"
/>
);
}
return (
<CollapsableEditor
open={collapseIsOpen}
onToggle={handleToggle}
title={getHeading(collapseIsOpen)}
onDelete={() => setShowDeletePopup(true)}
onDelete={handleShowDeletePopup}
expandAlt={intl.formatMessage(messages.expandAltText)}
collapseAlt={intl.formatMessage(messages.collapseAltText)}
deleteAlt={intl.formatMessage(messages.deleteAltText)}
data-testid={blackoutDate.id}
onClose={() => handleOnClose()}
data-testid={restrictedDate.id}
onClose={handleOnClose}
>
<Form.Row className="mx-2 pt-3">
<BlackoutDatesInput
value={blackoutDate.startDate}
<RestrictDatesInput
value={restrictedDate.startDate}
type="date"
label={intl.formatMessage(messages.startDateLabel)}
helpText={intl.formatMessage(messages.blackoutStartDateHelp)}
helpText={intl.formatMessage(messages.restrictedStartDateHelp)}
fieldName="startDate"
formGroupClasses="pl-md-0"
fieldClasses="pr-md-2"
fieldNameCommonBase={fieldNameCommonBase}
/>
<BlackoutDatesInput
value={blackoutDate.startTime}
<RestrictDatesInput
value={restrictedDate.startTime}
type="time"
label={intl.formatMessage(messages.startTimeLabel, { zone: 'UTC' })}
helpText={intl.formatMessage(messages.blackoutStartTimeHelp)}
helpText={intl.formatMessage(messages.restrictedStartTimeHelp)}
fieldName="startTime"
formGroupClasses="pr-md-0"
fieldClasses="ml-md-2"
@@ -110,21 +120,21 @@ const BlackoutDatesItem = ({
</Form.Row>
<hr className="mx-2 my-2 border-light-400" />
<Form.Row className="mx-2 pt-4">
<BlackoutDatesInput
value={blackoutDate.endDate}
<RestrictDatesInput
value={restrictedDate.endDate}
type="date"
label={intl.formatMessage(messages.endDateLabel)}
helpText={intl.formatMessage(messages.blackoutEndDateHelp)}
helpText={intl.formatMessage(messages.restrictedEndDateHelp)}
fieldName="endDate"
formGroupClasses="pl-md-0"
fieldClasses="pr-md-2"
fieldNameCommonBase={fieldNameCommonBase}
/>
<BlackoutDatesInput
value={blackoutDate.endTime}
<RestrictDatesInput
value={restrictedDate.endTime}
type="time"
label={intl.formatMessage(messages.endTimeLabel, { zone: 'UTC' })}
helpText={intl.formatMessage(messages.blackoutEndTimeHelp)}
helpText={intl.formatMessage(messages.restrictedEndTimeHelp)}
fieldName="endTime"
formGroupClasses="pr-md-0"
fieldClasses="ml-md-2"
@@ -136,13 +146,12 @@ const BlackoutDatesItem = ({
);
};
BlackoutDatesItem.propTypes = {
intl: intlShape.isRequired,
DiscussionRestrictionItem.propTypes = {
onDelete: PropTypes.func.isRequired,
hasError: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
fieldNameCommonBase: PropTypes.string.isRequired,
blackoutDate: PropTypes.shape({
restrictedDate: PropTypes.shape({
id: PropTypes.string,
startDate: PropTypes.string,
endDate: PropTypes.string,
@@ -152,4 +161,4 @@ BlackoutDatesItem.propTypes = {
}).isRequired,
};
export default injectIntl(BlackoutDatesItem);
export default React.memo(DiscussionRestrictionItem);

View File

@@ -0,0 +1,29 @@
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

@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { Form } from '@edx/paragon';
import { useFormikContext, getIn } from 'formik';
import PropTypes from 'prop-types';
import FieldFeedback from '../../../../../../generic/FieldFeedback';
const BlackoutDatesInput = ({
const RestictDatesInput = ({
value,
type,
label,
@@ -25,10 +25,14 @@ const BlackoutDatesInput = ({
const fieldTouched = getIn(touched, `${fieldNameCommonBase}.${fieldName}`);
const isInvalidInput = Boolean(!inFocus && fieldError && fieldTouched);
const handleFocusOut = (event) => {
const handleFocusOut = useCallback((event) => {
handleBlur(event);
setInFocus(false);
};
}, [handleBlur, setInFocus]);
const handleSetFocus = useCallback(() => {
setInFocus(true);
}, [setInFocus]);
return (
<Form.Group
@@ -43,8 +47,8 @@ const BlackoutDatesInput = ({
onChange={handleChange}
floatingLabel={label}
className={fieldClasses}
onBlur={(event) => handleFocusOut(event)}
onFocus={() => setInFocus(true)}
onBlur={handleFocusOut}
onFocus={handleSetFocus}
/>
<FieldFeedback
feedbackCondition={inFocus}
@@ -58,7 +62,7 @@ const BlackoutDatesInput = ({
);
};
BlackoutDatesInput.propTypes = {
RestictDatesInput.propTypes = {
value: PropTypes.string.isRequired,
fieldName: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
@@ -70,11 +74,11 @@ BlackoutDatesInput.propTypes = {
fieldNameCommonBase: PropTypes.string.isRequired,
};
BlackoutDatesInput.defaultProps = {
RestictDatesInput.defaultProps = {
fieldClasses: '',
helpText: '',
feedbackClasses: '',
formGroupClasses: '',
};
export default BlackoutDatesInput;
export default React.memo(RestictDatesInput);

View File

@@ -35,7 +35,7 @@ const appConfig = {
reportedContentEmailNotifications: false,
enableReportedContentEmailNotifications: false,
allowDivisionByUnit: false,
blackoutDates: [],
restrictedDates: [],
};
const contextValue = {

View File

@@ -287,128 +287,141 @@ const messages = defineMessages({
defaultMessage: 'Choose a unique name for your topic',
description: 'Help text for input field in adding a discussion topic',
},
// Blackout dates
blackoutDates: {
id: 'authoring.discussions.blackoutDates',
defaultMessage: 'Discussion blackout dates',
},
blackoutDatesLabel: {
id: 'authoring.discussions.builtIn.blackoutDates.label',
defaultMessage: 'Blackout dates',
},
blackoutDatesHelp: {
id: 'authoring.discussions.builtIn.blackoutDates.help',
defaultMessage: 'If added, learners will not be able to post in discussions between these dates.',
},
addBlackoutDatesButton: {
id: 'authoring.discussions.addBlackoutDatesButton',
defaultMessage: 'Add blackout date range',
description: 'Button label when Add a new blackout date.',
},
configureBlackoutDates: {
id: 'authoring.discussions.builtIn.configureBlackoutDates.label',
defaultMessage: 'Configure blackout date range',
description: 'Label for blockout dates allowing user to configure blackout dates',
},
blackoutStartDateHelp: {
id: 'authoring.discussions.blackoutStartDate.help',
// Restricted dates
restrictedStartDateHelp: {
id: 'authoring.discussions.restrictedStartDate.help',
defaultMessage: 'Enter a start date, e.g. 12/10/2023',
},
blackoutEndDateHelp: {
id: 'authoring.discussions.blackoutEndDate.help',
restrictedEndDateHelp: {
id: 'authoring.discussions.restrictedEndDate.help',
defaultMessage: 'Enter an end date, e.g. 12/17/2023',
},
blackoutStartTimeHelp: {
id: 'authoring.discussions.blackoutStartTime.help',
restrictedStartTimeHelp: {
id: 'authoring.discussions.restrictedStartTime.help',
defaultMessage: 'Enter a start time, e.g. 09:00 AM',
},
blackoutEndTimeHelp: {
id: 'authoring.discussions.blackoutEndTime.help',
restrictedEndTimeHelp: {
id: 'authoring.discussions.restrictedEndTime.help',
defaultMessage: 'Enter an end time, e.g. 05:00 PM',
},
activeBlackoutDatesDeletionHelp: {
id: 'authoring.discussions.activeBlackoutDatesDeletion.help',
defaultMessage: 'These blackout dates are currently active. If deleted, learners will be able to post in discussions during these dates. Are you sure you want to proceed?',
description: 'Help text for delete a active blackout dates from blackout dates section.',
},
blackoutDatesDeletionHelp: {
id: 'authoring.discussions.blackoutDatesDeletion.help',
defaultMessage: 'If deleted, learners will be able to post in discussions during these dates.',
description: 'Help text for delete a upcoming blackout dates from blackout dates section.',
},
completeBlackoutDatesDeletionHelp: {
id: 'authoring.discussions.completeBlackoutDatesDeletion.help',
defaultMessage: 'Are you sure you want to delete these blackout dates?',
description: 'Help text for delete a complete blackout dates from blackout dates section.',
},
activeBlackoutDatesDeletionLabel: {
id: 'authoring.discussions.activeBlackoutDatesDeletion.label',
defaultMessage: 'Delete active blackout dates?',
description: 'Label for active blackout dates delete popup allowing a user to delete a blackout date range.',
},
blackoutDatesDeletionLabel: {
id: 'authoring.discussions.blackoutDatesDeletion.label',
defaultMessage: 'Delete blackout dates?',
description: 'Label for blackout dates delete popup allowing a user to delete a blackout date range.',
},
deleteBlackoutDatesAltText: {
id: 'authoring.blackoutDates.delete',
defaultMessage: 'Delete Blackout Dates',
},
blackoutDatesStatus: {
id: 'authoring.blackoutDates.status',
restrictedDatesStatus: {
id: 'authoring.restrictedDates.status',
defaultMessage: '{status}',
},
blackoutStartDateRequired: {
id: 'authoring.blackoutDates.startDate.required',
restrictedStartDateRequired: {
id: 'authoring.restrictedDates.startDate.required',
defaultMessage: 'Start date is a required field',
description: 'Tells the user that the blackout dates must have start date and it is required.',
description: 'Tells the user that the restricted dates must have start date and it is required.',
},
blackoutEndDateRequired: {
id: 'authoring.blackoutDates.endDate.required',
restrictedEndDateRequired: {
id: 'authoring.restrictedDates.endDate.required',
defaultMessage: 'End date is a required field',
description: 'Tells the user that the blackout dates must have end date and it is required.',
description: 'Tells the user that the restricted dates must have end date and it is required.',
},
blackoutStartDateInPast: {
id: 'authoring.blackoutDates.startDate.inPast',
restrictedStartDateInPast: {
id: 'authoring.restrictedDates.startDate.inPast',
defaultMessage: 'Start date cannot be after end date',
description: 'Tells the user that the blackout start date cannot be in past and cannot be after end date',
description: 'Tells the user that the restricted start date cannot be in past and cannot be after end date',
},
blackoutEndDateInPast: {
id: 'authoring.blackoutDates.endDate.inPast',
restrictedEndDateInPast: {
id: 'authoring.restrictedDates.endDate.inPast',
defaultMessage: 'End date cannot be before start date',
description: 'Tells the user that the blackout end date cannot be in past and cannot be before start date',
description: 'Tells the user that the restricted end date cannot be in past and cannot be before start date',
},
blackoutStartTimeInPast: {
id: 'authoring.blackoutDates.startTime.inPast',
restrictedStartTimeInPast: {
id: 'authoring.restrictedDates.startTime.inPast',
defaultMessage: 'Start time cannot be after end time',
description: 'Tells the user that the blackout start time cannot be in past and cannot be after end time',
description: 'Tells the user that the restricted start time cannot be in past and cannot be after end time',
},
blackoutEndTimeInPast: {
id: 'authoring.blackoutDates.endTime.inPast',
restrictedEndTimeInPast: {
id: 'authoring.restrictedDates.endTime.inPast',
defaultMessage: 'End time cannot be before start time',
description: 'Tells the user that the blackout end time cannot be in past and cannot be before start time',
description: 'Tells the user that the restricted end time cannot be in past and cannot be before start time',
},
blackoutStartTimeInValidFormat: {
id: 'authoring.blackoutDates.startTime.inValidFormat',
restrictedStartTimeInValidFormat: {
id: 'authoring.restrictedDates.startTime.inValidFormat',
defaultMessage: 'Enter a valid start time',
description: 'Tells the user that the blackout start time format is in valid',
description: 'Tells the user that the restricted start time format is in valid',
},
blackoutEndTimeInValidFormat: {
id: 'authoring.blackoutDates.endTime.inValidFormat',
restrictedEndTimeInValidFormat: {
id: 'authoring.restrictedDates.endTime.inValidFormat',
defaultMessage: 'Enter a valid end time',
description: 'Tells the user that the blackout end time format is in valid',
description: 'Tells the user that the restricted end time format is in valid',
},
blackoutStartDateInValidFormat: {
id: 'authoring.blackoutDates.startDate.inValidFormat',
restrictedStartDateInValidFormat: {
id: 'authoring.restrictedDates.startDate.inValidFormat',
defaultMessage: 'Enter a valid start Date',
description: 'Tells the user that the blackout start date format is in valid',
description: 'Tells the user that the restricted start date format is in valid',
},
blackoutEndDateInValidFormat: {
id: 'authoring.blackoutDates.endDate.inValidFormat',
restrictedEndDateInValidFormat: {
id: 'authoring.restrictedDates.endDate.inValidFormat',
defaultMessage: 'Enter a valid end date',
description: 'Tells the user that the blackout end date format is in valid',
description: 'Tells the user that the restricted end date format is in valid',
},
discussionRestrictionLabel: {
id: 'authoring.discussions.builtIn.discussionRestriction.label',
defaultMessage: 'Discussion restrictions',
},
discussionRestrictionHelp: {
id: 'authoring.discussions.discussionRestriction.help',
defaultMessage: 'If enabled, learners will not be able to post in discussions.',
},
discussionRestrictionDatesHelp: {
id: 'authoring.discussions.discussionRestrictionDates.help',
defaultMessage: 'If added, learners will not be able to post in discussions between these dates.',
},
addRestrictedDatesButton: {
id: 'authoring.discussions.addRestrictedDatesButton',
defaultMessage: 'Add restricted dates',
},
configureRestrictedDates: {
id: 'authoring.discussions.builtIn.configureRestrictedDates.label',
defaultMessage: 'Configure restricted date range',
},
activeRestrictedDatesDeletionLabel: {
id: 'authoring.discussions.activeRestrictedDatesDeletion.label',
defaultMessage: 'Delete active restricted dates?',
description: 'Label for active restricted dates delete popup allowing a user to delete a restricted date range.',
},
activeRestrictedDatesDeletionHelp: {
id: 'authoring.discussions.activeRestrictedDatesDeletion.help',
defaultMessage: 'These restricted dates are currently active. If deleted, learners will be able to post in discussions during these dates. Are you sure you want to proceed?',
description: 'Help text for delete a active restricted dates from restricted dates section.',
},
completeRestrictedDatesDeletionHelp: {
id: 'authoring.discussions.completeRestrictedDatesDeletion.help',
defaultMessage: 'Are you sure you want to delete these restricted dates?',
description: 'Help text for delete a complete restricted dates from restricted dates section.',
},
restrictedDatesDeletionLabel: {
id: 'authoring.discussions.restrictedDatesDeletion.label',
defaultMessage: 'Delete restricted dates?',
description: 'Label for restricted dates delete popup allowing a user to delete a restricted date range.',
},
restrictedDatesDeletionHelp: {
id: 'authoring.discussions.restrictedDatesDeletion.help',
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: {
id: 'authoring.discussions.discussionRestrictionOff.label',
defaultMessage: 'Off',
},
discussionRestrictionOnLabel: {
id: 'authoring.discussions.discussionRestrictionOn.label',
defaultMessage: 'On',
},
discussionRestrictionScheduledLabel: {
id: 'authoring.discussions.discussionRestrictionScheduled.label',
defaultMessage: 'Scheduled',
},
enableRestrictedDatesConfirmationLabel: {
id: 'authoring.discussions.enableRestrictedDatesConfirmation.label',
defaultMessage: 'Enable restricted dates?',
},
enableRestrictedDatesConfirmationHelp: {
id: 'authoring.discussions.enableRestrictedDatesConfirmation.help',
defaultMessage: 'Learners will not be able to post in discussions.',
},
deleteAltText: {
id: 'authoring.topics.delete',
@@ -423,23 +436,23 @@ const messages = defineMessages({
defaultMessage: 'Collapse',
},
startDateLabel: {
id: 'authoring.blackoutDates.start.date',
id: 'authoring.restrictedDates.start.date',
defaultMessage: 'Start date',
description: 'Label for start date field',
},
startTimeLabel: {
id: 'authoring.blackoutDates.start.time',
defaultMessage: 'Start time (optional) ({zone})',
id: 'authoring.restrictedDates.start.time',
defaultMessage: 'Start time (optional)',
description: 'label for start time field',
},
endDateLabel: {
id: 'authoring.blackoutDates.end.date',
id: 'authoring.restrictedDates.end.date',
defaultMessage: 'End date',
description: 'label for end date field',
},
endTimeLabel: {
id: 'authoring.blackoutDates.end.time',
defaultMessage: 'End time (optional) ({zone})',
id: 'authoring.restrictedDates.end.time',
defaultMessage: 'End time (optional)',
description: 'label for end time field',
},
});

View File

@@ -1,7 +1,7 @@
import moment from 'moment';
import _ from 'lodash';
import { getIn } from 'formik';
import { blackoutDatesStatus as constants } from '../data/constants';
import { restrictedDatesStatus as constants } from '../data/constants';
export const filterItemFromObject = (array, key, value) => (
array.filter(item => item[key] !== value)
@@ -47,7 +47,7 @@ export const decodeDateTime = (date, time) => {
return moment(mergeDateTime(nDate, nTime));
};
export const sortBlackoutDatesByStatus = (data, status, order) => (
export const sortRestrictedDatesByStatus = (data, status, order) => (
_.orderBy(
data.filter(date => date.status === status),
[(obj) => decodeDateTime(obj.startDate, startOfDayTime(obj.startTime))],
@@ -55,7 +55,7 @@ data.filter(date => date.status === status),
)
);
export const formatBlackoutDates = ({
export const formatRestrictedDates = ({
startDate, startTime, endDate, endTime,
}) => {
let formattedDate;

View File

@@ -25,3 +25,53 @@
}
}
}
.height-36{
height: 2.25rem !important;
}
.line-height-20{
line-height: 1.25rem !important;
}
.font-size-14{
font-size: 14px !important;
}
.discussion-restriction{
.unselected-button{
&:hover{
background: #e9e6e4 !important;
}
}
.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: 0px !important;
font-weight: 400;
font-size: 14px;
line-height: 24px;
}
.collapsible-card{
padding: 14px 14px 14px 24px !important;
min-height:100px;
.collapsible-trigger{
padding: 0px !important;
.badge{
font-size: 12px;
line-height: 20px;
}
}
}
}

View File

@@ -10,10 +10,10 @@ import {
mergeDateTime,
normalizeDate,
normalizeTime,
sortBlackoutDatesByStatus,
sortRestrictedDatesByStatus,
startOfDayTime,
} from '../app-config-form/utils';
import { blackoutDatesStatus as constants } from './constants';
import { restrictedDatesStatus as constants } from './constants';
ensureConfig([
'STUDIO_BASE_URL',
@@ -31,7 +31,7 @@ function normalizeLtiConfig(data) {
};
}
export function normalizeBlackoutDates(data) {
export function normalizeRestrictedDates(data) {
if (!data || Object.keys(data).length < 1) {
return [];
}
@@ -46,9 +46,9 @@ export function normalizeBlackoutDates(data) {
}));
return [
...sortBlackoutDatesByStatus(normalizeData, constants.ACTIVE, 'desc'),
...sortBlackoutDatesByStatus(normalizeData, constants.UPCOMING, 'asc'),
...sortBlackoutDatesByStatus(normalizeData, constants.COMPLETE, 'desc'),
...sortRestrictedDatesByStatus(normalizeData, constants.ACTIVE, 'desc'),
...sortRestrictedDatesByStatus(normalizeData, constants.UPCOMING, 'asc'),
...sortRestrictedDatesByStatus(normalizeData, constants.COMPLETE, 'desc'),
];
}
@@ -66,7 +66,7 @@ function normalizePluginConfig(data) {
enableReportedContentEmailNotifications: data.reported_content_email_notifications_flag,
divisionScheme: data.division_scheme,
alwaysDivideInlineDiscussions: data.always_divide_inline_discussions,
blackoutDates: normalizeBlackoutDates(data.discussion_blackouts),
restrictedDates: normalizeRestrictedDates(data.discussion_blackouts),
allowDivisionByUnit: false,
divideByCohorts: enableDivideByCohorts,
divideCourseTopicsByCohorts: enableDivideCourseTopicsByCohorts,
@@ -155,15 +155,15 @@ function normalizeSettings(data) {
};
}
export function denormalizeBlackoutDate(blackoutPeriod) {
export function denormalizeRestrictedDate(restrictedPeriod) {
return [
mergeDateTime(
normalizeDate(blackoutPeriod.startDate),
normalizeTime(startOfDayTime(blackoutPeriod.startTime)),
normalizeDate(restrictedPeriod.startDate),
normalizeTime(startOfDayTime(restrictedPeriod.startTime)),
),
mergeDateTime(
normalizeDate(blackoutPeriod.endDate),
normalizeTime(endOfDayTime(blackoutPeriod.endTime)),
normalizeDate(restrictedPeriod.endDate),
normalizeTime(endOfDayTime(restrictedPeriod.endTime)),
),
];
}
@@ -187,11 +187,11 @@ function denormalizeData(courseId, appId, data) {
if ('groupAtSubsection' in data) {
pluginConfiguration.group_at_subsection = data.groupAtSubsection;
}
if (data.blackoutDates?.length) {
pluginConfiguration.discussion_blackouts = data.blackoutDates.map((blackoutDates) => (
denormalizeBlackoutDate(blackoutDates)
if (data.restrictedDates?.length) {
pluginConfiguration.discussion_blackouts = data.restrictedDates.map((restrictedDates) => (
denormalizeRestrictedDate(restrictedDates)
));
} else if (data.blackoutDates?.length === 0) {
} else if (data.restrictedDates?.length === 0) {
pluginConfiguration.discussion_blackouts = [];
}
if (data.discussionTopics?.length) {

View File

@@ -2,7 +2,7 @@ import moment from 'moment';
import messages from '../app-config-form/messages';
export const blackoutDatesStatus = {
export const restrictedDatesStatus = {
UPCOMING: 'UPCOMING',
COMPLETE: 'COMPLETE',
ACTIVE: 'ACTIVE',
@@ -14,12 +14,42 @@ export const badgeVariant = {
ACTIVE: 'success',
};
export const deleteHelperText = {
UPCOMING: messages.blackoutDatesDeletionHelp,
COMPLETE: messages.completeBlackoutDatesDeletionHelp,
ACTIVE: messages.activeBlackoutDatesDeletionHelp,
export const deleteRestrictedDatesHelperText = {
UPCOMING: messages.restrictedDatesDeletionHelp,
COMPLETE: messages.completeRestrictedDatesDeletionHelp,
ACTIVE: messages.activeRestrictedDatesDeletionHelp,
};
export const discussionRestriction = {
OFF: 'off',
ON: 'on',
SCHEDULED: 'scheduled',
};
export const discussionRestrictionLabel = {
[discussionRestriction.OFF]: 'Off',
[discussionRestriction.ON]: 'On',
[discussionRestriction.SCHEDULED]: 'Scheduled',
};
export const discussionRestrictionOptions = [
{
value: discussionRestriction.OFF,
description: messages.discussionRestrictionOffLabel,
label: discussionRestrictionLabel[discussionRestriction.OFF],
},
{
value: discussionRestriction.ON,
description: messages.discussionRestrictionOnLabel,
label: discussionRestrictionLabel[discussionRestriction.ON],
},
{
value: discussionRestriction.SCHEDULED,
description: messages.discussionRestrictionScheduledLabel,
label: discussionRestrictionLabel[discussionRestriction.SCHEDULED],
},
];
export const today = moment();
export const active = [today.format('YYYY-MM-DDTHH:mm'), today.add(5, 'hours').format('YYYY-MM-DDTHH:mm')];
export const upcoming = [today.add(2, 'days').format('YYYY-MM-DD'), today.add(5, 'days').format('YYYY-MM-DD')];

View File

@@ -244,7 +244,7 @@ describe('Data layer integration tests', () => {
allowAnonymousPostsPeers: false,
reportedContentEmailNotifications: false,
enableReportedContentEmailNotifications: false,
blackoutDates: [],
restrictedDates: [],
// TODO: Note! As of this writing, all the data below this line is NOT returned in the API
// but we add it in during normalization.
divisionScheme: DivisionSchemes.COHORT,
@@ -449,7 +449,7 @@ describe('Data layer integration tests', () => {
allowAnonymousPosts: true,
allowAnonymousPostsPeers: true,
reportedContentEmailNotifications: true,
blackoutDates: [],
restrictedDates: [],
// TODO: Note! As of this writing, all the data below this line is NOT returned in the API
// but we technically send it to the thunk, so here it is.
divideByCohorts: true,
@@ -486,7 +486,7 @@ describe('Data layer integration tests', () => {
allowAnonymousPostsPeers: true,
reportedContentEmailNotifications: true,
alwaysDivideInlineDiscussions: true,
blackoutDates: [],
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,