feat: [FC-0044] Unit page - display component support label (#913)

* feat: [FC-0044] Unit page - display component support label

* fix: add message description for tooltip of module support
This commit is contained in:
Ihor Romaniuk
2024-04-04 15:28:39 +02:00
committed by GitHub
parent 806591f1cc
commit ffa0f14693
8 changed files with 273 additions and 55 deletions

View File

@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { getCourseSectionVertical } from '../data/selectors';
import { COMPONENT_ICON_TYPES } from '../constants';
import { COMPONENT_TYPES } from '../constants';
import ComponentModalView from './add-component-modals/ComponentModalView';
import AddComponentButton from './add-component-btn';
import messages from './messages';
@@ -20,32 +20,32 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
const handleCreateNewXBlock = (type, moduleName) => {
switch (type) {
case COMPONENT_ICON_TYPES.discussion:
case COMPONENT_ICON_TYPES.dragAndDrop:
case COMPONENT_TYPES.discussion:
case COMPONENT_TYPES.dragAndDrop:
handleCreateNewCourseXBlock({ type, parentLocator: blockId });
break;
case COMPONENT_ICON_TYPES.problem:
case COMPONENT_ICON_TYPES.video:
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
});
break;
// TODO: The library functional will be a bit different of current legacy (CMS)
// behaviour and this ticket is on hold (blocked by other development team).
case COMPONENT_ICON_TYPES.library:
case COMPONENT_TYPES.library:
handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId });
break;
case COMPONENT_ICON_TYPES.advanced:
case COMPONENT_TYPES.advanced:
handleCreateNewCourseXBlock({
type: moduleName, category: moduleName, parentLocator: blockId,
});
break;
case COMPONENT_ICON_TYPES.openassessment:
case COMPONENT_TYPES.openassessment:
handleCreateNewCourseXBlock({
boilerplate: moduleName, category: type, parentLocator: blockId,
});
break;
case COMPONENT_ICON_TYPES.html:
case COMPONENT_TYPES.html:
handleCreateNewCourseXBlock({
type,
boilerplate: moduleName,
@@ -70,22 +70,26 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
const { type, displayName } = component;
let modalParams;
if (!component.templates.length) {
return null;
}
switch (type) {
case COMPONENT_ICON_TYPES.advanced:
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_ICON_TYPES.html:
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_ICON_TYPES.openassessment:
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,

View File

@@ -14,7 +14,7 @@ import { executeThunk } from '../../utils';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { courseSectionVerticalMock } from '../__mocks__';
import { COMPONENT_ICON_TYPES } from '../constants';
import { COMPONENT_TYPES } from '../constants';
import AddComponent from './AddComponent';
import messages from './messages';
@@ -66,7 +66,7 @@ describe('<AddComponent />', () => {
));
});
it('doesn\'t render AddComponent component when there aren\'t componentTemplates', async () => {
it('AddComponent component doesn\'t render when there aren\'t componentTemplates', async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -80,7 +80,43 @@ describe('<AddComponent />', () => {
expect(queryByRole('heading', { name: messages.title.defaultMessage })).not.toBeInTheDocument();
});
it('does\'t call handleCreateNewCourseXblock with custom component create button is clicked', async () => {
it('AddComponent component item doesn\'t render when there aren\'t templates', async () => {
const componentTemplates = courseSectionVerticalMock.component_templates;
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
component_templates: [
...courseSectionVerticalMock.component_templates.map((component) => {
if (component.type === COMPONENT_TYPES.discussion) {
return {
...component,
templates: [],
};
}
return component;
}),
],
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { queryByRole, getByRole } = renderComponent();
Object.keys(componentTemplates).map((component) => {
if (componentTemplates[component].type === COMPONENT_TYPES.discussion) {
return expect(queryByRole('button', {
name: new RegExp(`${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, 'i'),
})).not.toBeInTheDocument();
}
return expect(getByRole('button', {
name: new RegExp(`${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, 'i'),
})).toBeInTheDocument();
});
});
it('handleCreateNewCourseXblock does\'t call with custom component create button is clicked', async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -88,7 +124,7 @@ describe('<AddComponent />', () => {
component_templates: [
{
type: 'custom',
templates: [],
templates: [{ display_name: 'Custom' }],
display_name: 'Custom',
support_legend: {},
},
@@ -117,7 +153,7 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: 'discussion',
type: COMPONENT_TYPES.discussion,
});
});
@@ -132,7 +168,7 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: 'drag-and-drop-v2',
type: COMPONENT_TYPES.dragAndDrop,
});
});
@@ -147,7 +183,7 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: 'problem',
type: COMPONENT_TYPES.problem,
}, expect.any(Function));
});
@@ -162,7 +198,7 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: 'video',
type: COMPONENT_TYPES.video,
}, expect.any(Function));
});
@@ -178,7 +214,7 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
category: 'library_content',
type: 'library',
type: COMPONENT_TYPES.library,
});
});
@@ -213,7 +249,7 @@ describe('<AddComponent />', () => {
await waitFor(() => {
expect(getByText(/Add advanced component/i)).toBeInTheDocument();
componentTemplates.forEach((componentTemplate) => {
if (componentTemplate.type === COMPONENT_ICON_TYPES.advanced) {
if (componentTemplate.type === COMPONENT_TYPES.advanced) {
componentTemplate.templates.forEach((template) => {
expect(within(modalContainer).getByRole('radio', { name: template.display_name })).toBeInTheDocument();
});
@@ -234,7 +270,7 @@ describe('<AddComponent />', () => {
await waitFor(() => {
expect(getByText(/Add text component/i)).toBeInTheDocument();
componentTemplates.forEach((componentTemplate) => {
if (componentTemplate.type === COMPONENT_ICON_TYPES.html) {
if (componentTemplate.type === COMPONENT_TYPES.html) {
componentTemplate.templates.forEach((template) => {
expect(within(modalContainer).getByRole('radio', { name: template.display_name })).toBeInTheDocument();
});
@@ -256,7 +292,7 @@ describe('<AddComponent />', () => {
await waitFor(() => {
expect(getByText(/Add open response component/i)).toBeInTheDocument();
componentTemplates.forEach((componentTemplate) => {
if (componentTemplate.type === COMPONENT_ICON_TYPES.openassessment) {
if (componentTemplate.type === COMPONENT_TYPES.openassessment) {
componentTemplate.templates.forEach((template) => {
expect(within(modalContainer).getByRole('radio', { name: template.display_name })).toBeInTheDocument();
});
@@ -312,8 +348,8 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: 'html',
boilerplate: 'html',
type: COMPONENT_TYPES.html,
boilerplate: COMPONENT_TYPES.html,
}, expect.any(Function));
});
@@ -338,8 +374,102 @@ describe('<AddComponent />', () => {
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
category: 'openassessment',
category: COMPONENT_TYPES.openassessment,
boilerplate: 'peer-assessment',
});
});
describe('component support label', () => {
it('component support label is hidden if component support legend is disabled', async () => {
const supportLevels = ['fs', 'ps'];
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
component_templates: [
...courseSectionVerticalMock.component_templates.map((component) => {
if (component.type === COMPONENT_TYPES.advanced) {
return {
...component,
support_legend: { show_legend: false },
templates: [
...component.templates.map((template, i) => ({
...template,
support_level: supportLevels[i] || true,
})),
],
};
}
return component;
}),
],
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByRole } = renderComponent();
const advancedButton = getByRole('button', {
name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'),
});
userEvent.click(advancedButton);
const modalContainer = getByRole('dialog');
const fullySupportLabel = within(modalContainer)
.queryByText(messages.modalComponentSupportLabelFullySupported.defaultMessage);
const provisionallySupportLabel = within(modalContainer)
.queryByText(messages.modalComponentSupportLabelProvisionallySupported.defaultMessage);
expect(fullySupportLabel).not.toBeInTheDocument();
expect(provisionallySupportLabel).not.toBeInTheDocument();
});
it('displays component support label and tooltip in component modal', async () => {
const supportLevels = ['fs', 'ps'];
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
component_templates: [
...courseSectionVerticalMock.component_templates.map((component) => {
if (component.type === COMPONENT_TYPES.advanced) {
return {
...component,
support_legend: { show_legend: true },
templates: [
...component.templates.map((template, i) => ({
...template,
support_level: supportLevels[i] || true,
})),
],
};
}
return component;
}),
],
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByRole, getByText } = renderComponent();
const advancedButton = getByRole('button', {
name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'),
});
userEvent.click(advancedButton);
const modalContainer = getByRole('dialog');
const fullySupportLabel = within(modalContainer)
.getByText(messages.modalComponentSupportLabelFullySupported.defaultMessage);
const provisionallySupportLabel = within(modalContainer)
.getByText(messages.modalComponentSupportLabelProvisionallySupported.defaultMessage);
expect(fullySupportLabel).toBeInTheDocument();
expect(provisionallySupportLabel).toBeInTheDocument();
userEvent.hover(fullySupportLabel);
expect(getByText(messages.modalComponentSupportTooltipFullySupported.defaultMessage)).toBeInTheDocument();
userEvent.hover(provisionallySupportLabel);
expect(getByText(messages.modalComponentSupportTooltipProvisionallySupported.defaultMessage)).toBeInTheDocument();
});
});
});

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import { Icon } from '@openedx/paragon';
import { EditNote as EditNoteIcon } from '@openedx/paragon/icons';
import { COMPONENT_ICON_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../constants';
import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../constants';
const AddComponentIcon = ({ type }) => {
const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon;
@@ -11,7 +11,7 @@ const AddComponentIcon = ({ type }) => {
};
AddComponentIcon.propTypes = {
type: PropTypes.oneOf(Object.values(COMPONENT_ICON_TYPES)).isRequired,
type: PropTypes.oneOf(Object.values(COMPONENT_TYPES)).isRequired,
};
export default AddComponentIcon;

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { Form } from '@openedx/paragon';
import { Form, OverlayTrigger, Tooltip } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { updateQueryPendingStatus } from '../../data/slice';
import { getXBlockSupportMessages } from '../../constants';
import AddComponentButton from '../add-component-btn';
import messages from '../messages';
import ModalContainer from './ModalContainer';
@@ -18,7 +19,10 @@ const ComponentModalView = ({
const dispatch = useDispatch();
const [moduleTitle, setModuleTitle] = useState('');
const { open, close, isOpen } = modalParams;
const { type, displayName, templates } = component;
const {
type, displayName, templates, supportLegend,
} = component;
const supportLabels = getXBlockSupportMessages(intl);
const handleSubmit = () => {
handleCreateNewXBlock(type, moduleTitle);
@@ -51,15 +55,32 @@ const ComponentModalView = ({
>
{templates.map((componentTemplate) => {
const value = componentTemplate.boilerplateName || componentTemplate.category;
const isDisplaySupportLabel = supportLegend.showLegend && supportLabels[componentTemplate.supportLevel];
return (
<Form.Radio
key={componentTemplate.displayName}
className="add-component-modal-radio mb-2.5"
value={value}
>
{componentTemplate.displayName}
</Form.Radio>
<div className="d-flex justify-content-between w-100 mb-2.5 align-items-end">
<Form.Radio
key={componentTemplate.displayName}
className="add-component-modal-radio"
value={value}
>
{componentTemplate.displayName}
</Form.Radio>
{isDisplaySupportLabel && (
<OverlayTrigger
placement="right"
overlay={(
<Tooltip>
{supportLabels[componentTemplate.supportLevel].tooltip}
</Tooltip>
)}
>
<span className="x-small text-gray-500 flex-shrink-0 ml-2">
{supportLabels[componentTemplate.supportLevel].label}
</span>
</OverlayTrigger>
)}
</div>
);
})}
</Form.RadioSet>
@@ -85,8 +106,14 @@ ComponentModalView.propTypes = {
boilerplateName: PropTypes.string,
category: PropTypes.string,
displayName: PropTypes.string.isRequired,
supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
}),
),
supportLegend: PropTypes.shape({
allowUnsupportedXblocks: PropTypes.bool,
documentationLabel: PropTypes.string,
showLegend: PropTypes.bool,
}),
}).isRequired,
};

View File

@@ -21,6 +21,46 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.modal.container.cancel.button.text',
defaultMessage: 'Cancel',
},
modalComponentSupportLabelFullySupported: {
id: 'course-authoring.course-unit.modal.component.support.label.fully-supported',
defaultMessage: 'Fully supported',
description: 'Label for advance problem type\'s support status with full platform support',
},
modalComponentSupportLabelProvisionallySupported: {
id: 'course-authoring.course-unit.modal.component.support.label.provisionally-support',
defaultMessage: 'Provisionally supported',
description: 'Label for advance problem type\'s support status with provisional platform support',
},
modalComponentSupportLabelNotSupported: {
id: 'course-authoring.course-unit.modal.component.support.label.not-supported',
defaultMessage: 'Not supported',
description: 'Label for advance problem type\'s support status with no platform support',
},
modalComponentSupportTooltipFullySupported: {
id: 'course-authoring.course-unit.modal.component.support.tooltip.fully-supported',
defaultMessage: 'Fully supported tools and features are available on edX, are '
+ 'fully tested, have user interfaces where applicable, and are documented in the '
+ 'official edX guides that are available on docs.edx.org.',
description: 'Message for support status tooltip for modules with full platform support',
},
modalComponentSupportTooltipNotSupported: {
id: 'course-authoring.course-unit.modal.component.support.tooltip.not-supported',
defaultMessage: 'Tools with no support are not maintained by edX, and might be '
+ 'deprecated in the future. They are not recommended for use in courses due to '
+ 'non-compliance with one or more of the base requirements, such as testing, '
+ 'accessibility, internationalization, and documentation.',
description: 'Message for support status tooltip for modules which is not supported',
},
modalComponentSupportTooltipProvisionallySupported: {
id: 'course-authoring.course-unit.modal.component.support.tooltip.provisionally-support',
defaultMessage: 'Provisionally supported tools might lack the robustness of functionality '
+ 'that your courses require. edX does not have control over the quality of the software, '
+ 'or of the content that can be provided using these tools. Test these tools thoroughly '
+ 'before using them in your course, especially in graded sections. Complete documentation '
+ 'might not be available for provisionally supported tools, or documentation might be '
+ 'available from sources other than edX.',
description: 'Message for support status tooltip for modules with provisional platform support',
},
});
export default messages;

View File

@@ -12,11 +12,13 @@ import {
TextFields as TextFieldsIcon,
VideoCamera as VideoCameraIcon,
} from '@openedx/paragon/icons';
import messages from './sidebar/messages';
import addComponentMessages from './add-component/messages';
export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock'];
export const COMPONENT_ICON_TYPES = {
export const COMPONENT_TYPES = {
advanced: 'advanced',
discussion: 'discussion',
library: 'library',
@@ -36,14 +38,14 @@ export const TYPE_ICONS_MAP = {
};
export const COMPONENT_TYPE_ICON_MAP = {
[COMPONENT_ICON_TYPES.advanced]: ScienceIcon,
[COMPONENT_ICON_TYPES.discussion]: QuestionAnswerOutlineIcon,
[COMPONENT_ICON_TYPES.library]: LibraryIcon,
[COMPONENT_ICON_TYPES.html]: TextFieldsIcon,
[COMPONENT_ICON_TYPES.openassessment]: EditNoteIcon,
[COMPONENT_ICON_TYPES.problem]: HelpOutlineIcon,
[COMPONENT_ICON_TYPES.video]: VideoCameraIcon,
[COMPONENT_ICON_TYPES.dragAndDrop]: BackHandIcon,
[COMPONENT_TYPES.advanced]: ScienceIcon,
[COMPONENT_TYPES.discussion]: QuestionAnswerOutlineIcon,
[COMPONENT_TYPES.library]: LibraryIcon,
[COMPONENT_TYPES.html]: TextFieldsIcon,
[COMPONENT_TYPES.openassessment]: EditNoteIcon,
[COMPONENT_TYPES.problem]: HelpOutlineIcon,
[COMPONENT_TYPES.video]: VideoCameraIcon,
[COMPONENT_TYPES.dragAndDrop]: BackHandIcon,
};
export const getUnitReleaseStatus = (intl) => ({
@@ -68,3 +70,18 @@ export const PUBLISH_TYPES = {
discardChanges: 'discard_changes',
makePublic: 'make_public',
};
export const getXBlockSupportMessages = (intl) => ({
fs: { // Fully supported
label: intl.formatMessage(addComponentMessages.modalComponentSupportLabelFullySupported),
tooltip: intl.formatMessage(addComponentMessages.modalComponentSupportTooltipFullySupported),
},
ps: { // Provisionally supported
label: intl.formatMessage(addComponentMessages.modalComponentSupportLabelProvisionallySupported),
tooltip: intl.formatMessage(addComponentMessages.modalComponentSupportTooltipProvisionallySupported),
},
us: { // Not supported
label: intl.formatMessage(addComponentMessages.modalComponentSupportLabelNotSupported),
tooltip: intl.formatMessage(addComponentMessages.modalComponentSupportTooltipNotSupported),
},
});

View File

@@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import { scrollToElement } from '../../course-outline/utils';
import { getCourseId } from '../data/selectors';
import { COMPONENT_ICON_TYPES } from '../constants';
import { COMPONENT_TYPES } from '../constants';
import messages from './messages';
const CourseXBlock = ({
@@ -30,9 +30,9 @@ const CourseXBlock = ({
const handleEdit = () => {
switch (type) {
case COMPONENT_ICON_TYPES.html:
case COMPONENT_ICON_TYPES.problem:
case COMPONENT_ICON_TYPES.video:
case COMPONENT_TYPES.html:
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
navigate(`/course/${courseId}/editor/${type}/${id}`);
break;
default:

View File

@@ -6,7 +6,7 @@ import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { getCourseId } from '../data/selectors';
import { COMPONENT_ICON_TYPES } from '../constants';
import { COMPONENT_TYPES } from '../constants';
import { courseVerticalChildrenMock } from '../__mocks__';
import CourseXBlock from './CourseXBlock';
@@ -143,7 +143,7 @@ describe('<CourseXBlock />', () => {
describe('edit', () => {
it('navigates to editor page on edit HTML xblock', () => {
const { getByText, getByRole } = renderComponent({
type: COMPONENT_ICON_TYPES.html,
type: COMPONENT_TYPES.html,
});
const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage });
@@ -157,7 +157,7 @@ describe('<CourseXBlock />', () => {
it('navigates to editor page on edit Video xblock', () => {
const { getByText, getByRole } = renderComponent({
type: COMPONENT_ICON_TYPES.video,
type: COMPONENT_TYPES.video,
});
const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage });
@@ -171,7 +171,7 @@ describe('<CourseXBlock />', () => {
it('navigates to editor page on edit Problem xblock', () => {
const { getByText, getByRole } = renderComponent({
type: COMPONENT_ICON_TYPES.problem,
type: COMPONENT_TYPES.problem,
});
const editButton = getByRole('button', { name: messages.blockAltButtonEdit.defaultMessage });