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:
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -40,6 +40,7 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
|
||||
{ name: 'General', id: 'course' },
|
||||
],
|
||||
divideDiscussionIds,
|
||||
postingRestrictions: 'scheduled',
|
||||
enableGradedUnits: undefined,
|
||||
enableInContext: true,
|
||||
groupAtSubsection: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,6 @@ const InContextDiscussionFields = ({
|
||||
} = useFormikContext();
|
||||
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
|
||||
const handleConfirmation = () => {
|
||||
setFieldValue('enableGradedUnits', !values.enableGradedUnits);
|
||||
setShowPopup(false);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const slice = createSlice({
|
||||
enableInContext: false,
|
||||
enableGradedUnits: false,
|
||||
unitLevelVisibility: false,
|
||||
postingRestrictions: null,
|
||||
},
|
||||
reducers: {
|
||||
loadApps: (state, { payload }) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user