feat: add an allowlist of for supported blocks in library [FC-0062] (#1378)

* feat: show error msg from server on paste

* feat: add an allowlist of for supported blocks in library

Libraries v2 currently don't support editing blocks other than problem,
text and videos. This commit adds a configuration variable called
`LIBRARY_SUPPORTED_BLOCKS` to setup allowed list of block types users
can paste into libraries. By default it is set to support
'problem,text,video,html`.

* feat: enable add button for blocks based on setting


---------

Co-authored-by: Rômulo Penido <romulo@opencraft.com>
This commit is contained in:
Navin Karkera
2024-10-15 20:22:35 +05:30
committed by GitHub
parent 66b14a5b16
commit 7fb460019e
9 changed files with 130 additions and 49 deletions

1
.env
View File

@@ -45,3 +45,4 @@ ENABLE_HOME_PAGE_COURSE_API_V2=false
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_MODE="v1 only"
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"

View File

@@ -48,3 +48,4 @@ ENABLE_HOME_PAGE_COURSE_API_V2=false
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_MODE="mixed"
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"

View File

@@ -40,3 +40,4 @@ ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_MODE="mixed"
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"

View File

@@ -18,7 +18,7 @@ mockClipboardEmpty.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImp
/**
* Mock for `getClipboard()` that simulates a copied HTML component
*/
export async function mockClipboardHtml(): Promise<api.ClipboardStatus> {
export async function mockClipboardHtml(blockType?: string): Promise<api.ClipboardStatus> {
return {
content: {
id: 69,
@@ -26,7 +26,7 @@ export async function mockClipboardHtml(): Promise<api.ClipboardStatus> {
created: '2024-01-16T13:33:21.314439Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'html',
blockType: blockType || 'html',
blockTypeDisplay: 'Text',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
displayName: 'Blank HTML Page',
@@ -36,7 +36,7 @@ export async function mockClipboardHtml(): Promise<api.ClipboardStatus> {
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
};
}
mockClipboardHtml.applyMock = () => jest.spyOn(api, 'getClipboard').mockImplementation(mockClipboardHtml);
mockClipboardHtml.applyMock = (blockType?: string) => jest.spyOn(api, 'getClipboard').mockImplementation(() => mockClipboardHtml(blockType));
mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml);
/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */

View File

@@ -133,6 +133,7 @@ initialize({
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true',
LIBRARY_MODE: process.env.LIBRARY_MODE || 'v1 only',
LIBRARY_SUPPORTED_BLOCKS: (process.env.LIBRARY_SUPPORTED_BLOCKS || 'problem,video,html').split(','),
}, 'CourseAuthoringConfig');
},
},

View File

@@ -77,7 +77,7 @@ describe('<AddContentContainer />', () => {
});
it('should handle failure to paste content', async () => {
const { axiosMock } = initializeMocks();
const { axiosMock, mockShowToast } = initializeMocks();
// Simulate having an HTML block in the clipboard:
mockClipboardHtml.applyMock();
@@ -89,8 +89,54 @@ describe('<AddContentContainer />', () => {
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
await waitFor(() => {
expect(axiosMock.history.post[0].url).toEqual(pasteUrl);
expect(mockShowToast).toHaveBeenCalledWith('There was an error pasting the content.');
});
});
// TODO: check that an actual error message is shown?!
it('should handle failure to paste content and show server error if available', async () => {
const { axiosMock, mockShowToast } = initializeMocks();
// Simulate having an HTML block in the clipboard:
mockClipboardHtml.applyMock();
const errMsg = 'Libraries do not support this type of content yet.';
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
// eslint-disable-next-line prefer-promise-reject-errors
axiosMock.onPost(pasteUrl).reply(() => Promise.reject({
customAttributes: {
httpErrorStatus: 400,
httpErrorResponseData: JSON.stringify({ block_type: errMsg }),
},
}));
render();
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => {
expect(axiosMock.history.post[0].url).toEqual(pasteUrl);
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
});
});
it('should stop user from pasting unsupported blocks and show toast', async () => {
const { axiosMock, mockShowToast } = initializeMocks();
// Simulate having an HTML block in the clipboard:
mockClipboardHtml.applyMock('openassessment');
const errMsg = 'Libraries do not support this type of content yet.';
render();
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(0);
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
});
});
});

View File

@@ -5,6 +5,7 @@ import {
Button,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import {
Article,
AutoAwesome,
@@ -61,17 +62,31 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro
const AddContentContainer = () => {
const intl = useIntl();
const { libraryId, collectionId } = useParams();
const { collectionId } = useParams();
const {
libraryId,
openCreateCollectionModal,
openComponentEditor,
} = useLibraryContext();
const createBlockMutation = useCreateLibraryBlock();
const updateComponentsMutation = useUpdateCollectionComponents(libraryId, collectionId);
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock } = useCopyToClipboard(canEdit);
const {
openCreateCollectionModal,
openComponentEditor,
} = useLibraryContext();
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);
const parsePasteErrorMsg = (error: any) => {
let errMsg: string;
try {
const { customAttributes: { httpErrorResponseData } } = error;
errMsg = JSON.parse(httpErrorResponseData).block_type;
} catch (_err) {
errMsg = intl.formatMessage(messages.errorPasteClipboardMessage);
}
return errMsg;
};
const isBlockTypeEnabled = (blockType: string) => getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
@@ -82,37 +97,37 @@ const AddContentContainer = () => {
const contentTypes = [
{
name: intl.formatMessage(messages.textTypeButton),
disabled: false,
disabled: !isBlockTypeEnabled('html'),
icon: Article,
blockType: 'html',
},
{
name: intl.formatMessage(messages.problemTypeButton),
disabled: false,
disabled: !isBlockTypeEnabled('problem'),
icon: Question,
blockType: 'problem',
},
{
name: intl.formatMessage(messages.openResponseTypeButton),
disabled: false,
disabled: !isBlockTypeEnabled('openassessment'),
icon: Create,
blockType: 'openassessment',
},
{
name: intl.formatMessage(messages.dragDropTypeButton),
disabled: false,
disabled: !isBlockTypeEnabled('drag-and-drop-v2'),
icon: ThumbUpOutline,
blockType: 'drag-and-drop-v2',
},
{
name: intl.formatMessage(messages.videoTypeButton),
disabled: false,
disabled: !isBlockTypeEnabled('video'),
icon: VideoCamera,
blockType: 'video',
},
{
name: intl.formatMessage(messages.otherTypeButton),
disabled: true,
disabled: !isBlockTypeEnabled('other'),
icon: AutoAwesome,
blockType: 'other', // This block doesn't exist yet.
},
@@ -130,39 +145,49 @@ const AddContentContainer = () => {
contentTypes.push(pasteButton);
}
const onCreateContent = (blockType: string) => {
if (libraryId) {
if (blockType === 'paste') {
pasteClipboardMutation.mutateAsync({
libraryId,
blockId: `${uuid4()}`,
}).then(() => {
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
}).catch(() => {
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
});
} else if (blockType === 'collection') {
openCreateCollectionModal();
const onPaste = () => {
if (!isBlockTypeEnabled(sharedClipboardData.content?.blockType)) {
showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage));
return;
}
pasteClipboardMutation.mutateAsync({
libraryId,
blockId: `${uuid4()}`,
}).then(() => {
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
}).catch((error) => {
showToast(parsePasteErrorMsg(error));
});
};
const onCreateBlock = (blockType: string) => {
createBlockMutation.mutateAsync({
libraryId,
blockType,
definitionId: `${uuid4()}`,
}).then((data) => {
const hasEditor = canEditComponent(data.id);
updateComponentsMutation.mutateAsync([data.id]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
if (hasEditor) {
openComponentEditor(data.id);
} else {
createBlockMutation.mutateAsync({
libraryId,
blockType,
definitionId: `${uuid4()}`,
}).then((data) => {
const hasEditor = canEditComponent(data.id);
updateComponentsMutation.mutateAsync([data.id]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
if (hasEditor) {
openComponentEditor(data.id);
} else {
// We can't start editing this right away so just show a toast message:
showToast(intl.formatMessage(messages.successCreateMessage));
}
}).catch(() => {
showToast(intl.formatMessage(messages.errorCreateMessage));
});
// We can't start editing this right away so just show a toast message:
showToast(intl.formatMessage(messages.successCreateMessage));
}
}).catch(() => {
showToast(intl.formatMessage(messages.errorCreateMessage));
});
};
const onCreateContent = (blockType: string) => {
if (blockType === 'paste') {
onPaste();
} else if (blockType === 'collection') {
openCreateCollectionModal();
} else {
onCreateBlock(blockType);
}
};

View File

@@ -76,6 +76,11 @@ const messages = defineMessages({
defaultMessage: 'Pasting content from clipboard...',
description: 'Message when in process of pasting content in library',
},
unsupportedBlockPasteClipboardMessage: {
id: 'course-authoring.library-authoring.paste-clipboard.unsupportedblock-error.text',
defaultMessage: 'Libraries do not support this type of content yet.',
description: 'Message when unsupported block is pasted in library',
},
});
export default messages;

View File

@@ -45,6 +45,7 @@ mergeConfig({
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
LMS_BASE_URL: process.env.LMS_BASE_URL || null,
LIBRARY_MODE: process.env.LIBRARY_MODE || 'v1 only',
LIBRARY_SUPPORTED_BLOCKS: (process.env.LIBRARY_SUPPORTED_BLOCKS || 'problem,video,html').split(','),
}, 'CourseAuthoringConfig');
class ResizeObserver {