feat: Create advanced blocks in libraries [FC-0076] (#1653)
List view to show and create the advanced blocks
This commit is contained in:
4
.env
4
.env
@@ -44,4 +44,6 @@ INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2"
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
# TODO: Missing support for ORA2
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||
|
||||
@@ -47,4 +47,5 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2"
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
|
||||
@@ -39,5 +39,6 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "other" is used to test the workflow for creating blocks that aren't supported by the built-in editors
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html,drag-and-drop-v2,other"
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
# TODO: Missing support for ORA2
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||
|
||||
@@ -146,7 +146,7 @@ initialize({
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 === 'true',
|
||||
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true',
|
||||
LIBRARY_SUPPORTED_BLOCKS: (process.env.LIBRARY_SUPPORTED_BLOCKS || 'problem,video,html').split(','),
|
||||
LIBRARY_UNSUPPORTED_BLOCKS: (process.env.LIBRARY_UNSUPPORTED_BLOCKS || 'conditional,step-builder,problem-builder').split(','),
|
||||
}, 'CourseAuthoringConfig');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
waitFor,
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
import { mockContentLibrary, mockXBlockFields } from '../data/api.mocks';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockBlockTypesMetadata,
|
||||
mockXBlockFields,
|
||||
} from '../data/api.mocks';
|
||||
import {
|
||||
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
|
||||
getXBlockFieldsApiUrl,
|
||||
@@ -55,7 +59,8 @@ describe('<AddContentContainer />', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should render content buttons', () => {
|
||||
it('should render content buttons', async () => {
|
||||
mockBlockTypesMetadata.applyMock();
|
||||
mockClipboardEmpty.applyMock();
|
||||
render();
|
||||
expect(screen.queryByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||
@@ -64,8 +69,54 @@ describe('<AddContentContainer />', () => {
|
||||
expect(screen.queryByRole('button', { name: /open reponse/i })).not.toBeInTheDocument(); // Excluded from MVP
|
||||
expect(screen.queryByRole('button', { name: /drag drop/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /video/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument(); // To test not editor supported blocks
|
||||
expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render advanced content buttons', async () => {
|
||||
mockBlockTypesMetadata.applyMock();
|
||||
mockClipboardEmpty.applyMock();
|
||||
render();
|
||||
|
||||
const advancedButton = await screen.findByRole('button', { name: /advanced \/ other/i });
|
||||
fireEvent.click(advancedButton);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /poll/i })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /survey/i })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /google document/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return to content view fron advanced block creation view', async () => {
|
||||
mockBlockTypesMetadata.applyMock();
|
||||
mockClipboardEmpty.applyMock();
|
||||
render();
|
||||
|
||||
const advancedButton = await screen.findByRole('button', { name: /advanced \/ other/i });
|
||||
fireEvent.click(advancedButton);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /poll/i })).toBeInTheDocument();
|
||||
|
||||
const returnButton = screen.getByRole('button', { name: /back to list/i });
|
||||
fireEvent.click(returnButton);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /text/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create an advanced content', async () => {
|
||||
mockBlockTypesMetadata.applyMock();
|
||||
mockClipboardEmpty.applyMock();
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
render();
|
||||
|
||||
const advancedButton = await screen.findByRole('button', { name: /advanced \/ other/i });
|
||||
fireEvent.click(advancedButton);
|
||||
|
||||
const surveyButton = await screen.findByRole('button', { name: /survey/i });
|
||||
fireEvent.click(surveyButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
|
||||
});
|
||||
|
||||
it('should open the editor modal to create a content when the block is supported', async () => {
|
||||
@@ -80,40 +131,6 @@ describe('<AddContentContainer />', () => {
|
||||
expect(await screen.findByRole('heading', { name: /Text/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create a component when the block is not supported by the editor', async () => {
|
||||
mockClipboardEmpty.applyMock();
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
render();
|
||||
const textButton = screen.getByRole('button', { name: /other/i });
|
||||
fireEvent.click(textButton);
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
|
||||
});
|
||||
|
||||
it('should create a content in a collection for non-editable blocks', async () => {
|
||||
mockClipboardEmpty.applyMock();
|
||||
const collectionId = 'some-collection-id';
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
|
||||
axiosMock.onPost(url).reply(200, { id: 'some-component-id' });
|
||||
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||
|
||||
render(collectionId);
|
||||
|
||||
// Select a block that is not supported by the editor should create the component
|
||||
const textButton = screen.getByRole('button', { name: /other/i });
|
||||
fireEvent.click(textButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||
});
|
||||
|
||||
it('should create a content in a collection for editable blocks', async () => {
|
||||
mockClipboardEmpty.applyMock();
|
||||
const collectionId = 'some-collection-id';
|
||||
@@ -225,7 +242,7 @@ describe('<AddContentContainer />', () => {
|
||||
|
||||
it('should stop user from pasting unsupported blocks and show toast', async () => {
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock('openassessment');
|
||||
mockClipboardHtml.applyMock('conditional');
|
||||
|
||||
const errMsg = 'Libraries do not support this type of content yet.';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
@@ -18,18 +18,25 @@ import {
|
||||
Question,
|
||||
VideoCamera,
|
||||
ContentPaste,
|
||||
KeyboardBackspace,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useCopyToClipboard } from '../../generic/clipboard';
|
||||
import { getCanEdit } from '../../course-unit/data/selectors';
|
||||
import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
|
||||
import {
|
||||
useCreateLibraryBlock,
|
||||
useLibraryPasteClipboard,
|
||||
useAddComponentsToCollection,
|
||||
useBlockTypesMetadata,
|
||||
} from '../data/apiHooks';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { PickLibraryContentModal } from './PickLibraryContentModal';
|
||||
import { blockTypes } from '../../editors/data/constants/app';
|
||||
|
||||
import messages from './messages';
|
||||
import type { BlockTypeMetadata } from '../data/api';
|
||||
|
||||
type ContentType = {
|
||||
name: string,
|
||||
@@ -43,6 +50,20 @@ type AddContentButtonProps = {
|
||||
onCreateContent: (blockType: string) => void,
|
||||
};
|
||||
|
||||
type AddContentViewProps = {
|
||||
contentTypes: ContentType[],
|
||||
onCreateContent: (blockType: string) => void,
|
||||
isAddLibraryContentModalOpen: boolean,
|
||||
closeAddLibraryContentModal: () => void,
|
||||
};
|
||||
|
||||
type AddAdvancedContentViewProps = {
|
||||
closeAdvancedList: () => void,
|
||||
onCreateContent: (blockType: string) => void,
|
||||
advancedBlocks: Record<string, BlockTypeMetadata>,
|
||||
isBlockTypeEnabled: (blockType) => boolean,
|
||||
};
|
||||
|
||||
const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => {
|
||||
const {
|
||||
name,
|
||||
@@ -62,6 +83,93 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const AddContentView = ({
|
||||
contentTypes,
|
||||
onCreateContent,
|
||||
isAddLibraryContentModalOpen,
|
||||
closeAddLibraryContentModal,
|
||||
}: AddContentViewProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
collectionId,
|
||||
componentPicker,
|
||||
} = useLibraryContext();
|
||||
|
||||
const collectionButtonData = {
|
||||
name: intl.formatMessage(messages.collectionButton),
|
||||
disabled: false,
|
||||
icon: BookOpen,
|
||||
blockType: 'collection',
|
||||
};
|
||||
|
||||
const libraryContentButtonData = {
|
||||
name: intl.formatMessage(messages.libraryContentButton),
|
||||
disabled: false,
|
||||
icon: Folder,
|
||||
blockType: 'libraryContent',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{collectionId ? (
|
||||
componentPicker && (
|
||||
<>
|
||||
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
|
||||
<PickLibraryContentModal
|
||||
isOpen={isAddLibraryContentModalOpen}
|
||||
onClose={closeAddLibraryContentModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
|
||||
)}
|
||||
<hr className="w-100 bg-gray-500" />
|
||||
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
|
||||
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
|
||||
<AddContentButton
|
||||
key={`add-content-${contentType.blockType}`}
|
||||
contentType={contentType}
|
||||
onCreateContent={onCreateContent}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddAdvancedContentView = ({
|
||||
closeAdvancedList,
|
||||
onCreateContent,
|
||||
advancedBlocks,
|
||||
isBlockTypeEnabled,
|
||||
}: AddAdvancedContentViewProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex">
|
||||
<Button variant="tertiary" iconBefore={KeyboardBackspace} onClick={closeAdvancedList}>
|
||||
{intl.formatMessage(messages.backToAddContentListButton)}
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(advancedBlocks).map((blockType) => (
|
||||
isBlockTypeEnabled(blockType) ? (
|
||||
<AddContentButton
|
||||
key={`add-content-${blockType}`}
|
||||
contentType={{
|
||||
name: advancedBlocks[blockType].displayName,
|
||||
blockType,
|
||||
icon: AutoAwesome,
|
||||
disabled: false,
|
||||
}}
|
||||
onCreateContent={onCreateContent}
|
||||
/>
|
||||
) : null
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const parseErrorMsg = (
|
||||
intl,
|
||||
error: any,
|
||||
@@ -79,6 +187,7 @@ export const parseErrorMsg = (
|
||||
}
|
||||
return intl.formatMessage(defaultMessage);
|
||||
};
|
||||
|
||||
const AddContentContainer = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
@@ -86,32 +195,27 @@ const AddContentContainer = () => {
|
||||
collectionId,
|
||||
openCreateCollectionModal,
|
||||
openComponentEditor,
|
||||
componentPicker,
|
||||
} = useLibraryContext();
|
||||
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
|
||||
const createBlockMutation = useCreateLibraryBlock();
|
||||
const pasteClipboardMutation = useLibraryPasteClipboard();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);
|
||||
const { sharedClipboardData } = useCopyToClipboard(canEdit);
|
||||
const { showPasteXBlock } = useCopyToClipboard(canEdit);
|
||||
|
||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||
const [isAdvancedListOpen, showAdvancedList, closeAdvancedList] = useToggle();
|
||||
|
||||
const isBlockTypeEnabled = (blockType: string) => getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
|
||||
// We use block types data from backend to verify the enabled basic and advanced blocks.
|
||||
// Also, we use that data to get the translated display name of the block.
|
||||
const { data: blockTypesDataList } = useBlockTypesMetadata(libraryId);
|
||||
const blockTypesData = useMemo(() => blockTypesDataList?.reduce((acc, block) => {
|
||||
acc[block.blockType] = block;
|
||||
return acc;
|
||||
}, {}), [blockTypesDataList]);
|
||||
|
||||
const collectionButtonData = {
|
||||
name: intl.formatMessage(messages.collectionButton),
|
||||
disabled: false,
|
||||
icon: BookOpen,
|
||||
blockType: 'collection',
|
||||
};
|
||||
|
||||
const libraryContentButtonData = {
|
||||
name: intl.formatMessage(messages.libraryContentButton),
|
||||
disabled: false,
|
||||
icon: Folder,
|
||||
blockType: 'libraryContent',
|
||||
};
|
||||
const isBlockTypeEnabled = (blockType: string) => !getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType);
|
||||
|
||||
const contentTypes = [
|
||||
{
|
||||
@@ -144,14 +248,27 @@ const AddContentContainer = () => {
|
||||
icon: VideoCamera,
|
||||
blockType: 'video',
|
||||
},
|
||||
{
|
||||
name: intl.formatMessage(messages.otherTypeButton),
|
||||
disabled: !isBlockTypeEnabled('other'),
|
||||
icon: AutoAwesome,
|
||||
blockType: 'other', // This block doesn't exist yet.
|
||||
},
|
||||
];
|
||||
|
||||
const isBasicBlock = (blockType: string) => contentTypes.some(
|
||||
content => content.blockType === blockType,
|
||||
);
|
||||
|
||||
const advancedBlocks = useMemo(() => (blockTypesData ? Object.fromEntries(
|
||||
Object.entries(blockTypesData).filter(([key]) => !isBasicBlock(key)),
|
||||
) : {}), [blockTypesData]) as Record<string, BlockTypeMetadata>;
|
||||
|
||||
// Include the 'Advanced / Other' button if there are enabled advanced Xblocks
|
||||
if (Object.keys(advancedBlocks).length > 0) {
|
||||
const pasteButton = {
|
||||
name: intl.formatMessage(messages.otherTypeButton),
|
||||
disabled: false,
|
||||
icon: AutoAwesome,
|
||||
blockType: 'advancedXBlock',
|
||||
};
|
||||
contentTypes.push(pasteButton);
|
||||
}
|
||||
|
||||
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
|
||||
// that can be pasted
|
||||
if (showPasteXBlock) {
|
||||
@@ -222,6 +339,8 @@ const AddContentContainer = () => {
|
||||
openCreateCollectionModal();
|
||||
} else if (blockType === 'libraryContent') {
|
||||
showAddLibraryContentModal();
|
||||
} else if (blockType === 'advancedXBlock') {
|
||||
showAdvancedList();
|
||||
} else {
|
||||
onCreateBlock(blockType);
|
||||
}
|
||||
@@ -234,36 +353,21 @@ const AddContentContainer = () => {
|
||||
|
||||
return (
|
||||
<Stack direction="vertical">
|
||||
{collectionId ? (
|
||||
componentPicker && (
|
||||
<>
|
||||
<AddContentButton
|
||||
contentType={libraryContentButtonData}
|
||||
onCreateContent={onCreateContent}
|
||||
/>
|
||||
<PickLibraryContentModal
|
||||
isOpen={isAddLibraryContentModalOpen}
|
||||
onClose={closeAddLibraryContentModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<AddContentButton
|
||||
contentType={collectionButtonData}
|
||||
{isAdvancedListOpen ? (
|
||||
<AddAdvancedContentView
|
||||
closeAdvancedList={closeAdvancedList}
|
||||
onCreateContent={onCreateContent}
|
||||
advancedBlocks={advancedBlocks}
|
||||
isBlockTypeEnabled={isBlockTypeEnabled}
|
||||
/>
|
||||
) : (
|
||||
<AddContentView
|
||||
contentTypes={contentTypes}
|
||||
onCreateContent={onCreateContent}
|
||||
isAddLibraryContentModalOpen={isAddLibraryContentModalOpen}
|
||||
closeAddLibraryContentModal={closeAddLibraryContentModal}
|
||||
/>
|
||||
)}
|
||||
<hr className="w-100 bg-gray-500" />
|
||||
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
|
||||
{contentTypes
|
||||
.filter((ct) => !ct.disabled)
|
||||
.map((contentType) => (
|
||||
<AddContentButton
|
||||
key={`add-content-${contentType.blockType}`}
|
||||
contentType={contentType}
|
||||
onCreateContent={onCreateContent}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -117,6 +117,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Libraries do not support this type of content yet.',
|
||||
description: 'Message when unsupported block is pasted in library',
|
||||
},
|
||||
backToAddContentListButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.back',
|
||||
defaultMessage: 'Back to List',
|
||||
description: 'Messag of button in advanced creation view to return to the main creation view.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import type { ComponentPicker } from '../../component-picker';
|
||||
import type { ContentLibrary } from '../../data/api';
|
||||
import type { ContentLibrary, BlockTypeMetadata } from '../../data/api';
|
||||
import { useContentLibrary } from '../../data/apiHooks';
|
||||
import { useComponentPickerContext } from './ComponentPickerContext';
|
||||
|
||||
@@ -42,6 +42,7 @@ export type LibraryContextData = {
|
||||
openComponentEditor: (usageKey: string, onClose?: (data?:any) => void, blockType?:string) => void;
|
||||
closeComponentEditor: (data?:any) => void;
|
||||
componentPicker?: typeof ComponentPicker;
|
||||
blockTypesData?: Record<string, BlockTypeMetadata>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('<ComponentInfo> Sidebar', () => {
|
||||
initializeMocks();
|
||||
render(
|
||||
<ComponentInfo />,
|
||||
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyThirdPartyXBlock),
|
||||
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyUnsupportedXBlock),
|
||||
);
|
||||
|
||||
const editButton = await screen.findByRole('button', { name: /Edit component/ });
|
||||
|
||||
@@ -15,7 +15,7 @@ export function canEditComponent(usageKey: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
|
||||
return !getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType);
|
||||
}
|
||||
|
||||
export const ComponentEditorModal: React.FC<Record<never, never>> = () => {
|
||||
|
||||
@@ -327,7 +327,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.Li
|
||||
case thisMock.usageKeyPublished: return thisMock.dataPublished;
|
||||
case thisMock.usageKeyWithCollections: return thisMock.dataWithCollections;
|
||||
case thisMock.usageKeyPublishDisabled: return thisMock.dataPublishDisabled;
|
||||
case thisMock.usageKeyThirdPartyXBlock: return thisMock.dataThirdPartyXBlock;
|
||||
case thisMock.usageKeyUnsupportedXBlock: return thisMock.dataUnsupportedXBlock;
|
||||
case thisMock.usageKeyForTags: return thisMock.dataPublished;
|
||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||
}
|
||||
@@ -372,11 +372,11 @@ mockLibraryBlockMetadata.dataPublishDisabled = {
|
||||
id: mockLibraryBlockMetadata.usageKeyPublishDisabled,
|
||||
modified: '2024-06-11T13:54:21Z',
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyThirdPartyXBlock = mockXBlockFields.usageKeyThirdParty;
|
||||
mockLibraryBlockMetadata.dataThirdPartyXBlock = {
|
||||
mockLibraryBlockMetadata.usageKeyUnsupportedXBlock = 'lb:Axim:TEST:conditional:12345';
|
||||
mockLibraryBlockMetadata.dataUnsupportedXBlock = {
|
||||
...mockLibraryBlockMetadata.dataPublished,
|
||||
id: mockLibraryBlockMetadata.usageKeyThirdPartyXBlock,
|
||||
blockType: 'third_party',
|
||||
id: mockLibraryBlockMetadata.usageKeyUnsupportedXBlock,
|
||||
blockType: 'conditional',
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyForTags = mockContentTaxonomyTagsData.largeTagsId;
|
||||
mockLibraryBlockMetadata.usageKeyWithCollections = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
@@ -527,6 +527,29 @@ mockGetLibraryTeam.notMember = {
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockGetLibraryTeam.applyMock = () => jest.spyOn(api, 'getLibraryTeam').mockImplementation(mockGetLibraryTeam);
|
||||
|
||||
/**
|
||||
* Mock for `getBlockTypes()`
|
||||
*
|
||||
* Use `mockBlockTypesMetadata.applyMock()` to apply it to the whole test suite.
|
||||
*/
|
||||
export async function mockBlockTypesMetadata(libraryId: string): Promise<api.BlockTypeMetadata[]> {
|
||||
const thisMock = mockBlockTypesMetadata;
|
||||
switch (libraryId) {
|
||||
case mockContentLibrary.libraryId: return thisMock.blockTypesMetadata;
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockBlockTypesMetadata.blockTypesMetadata = [
|
||||
{ blockType: 'poll', displayName: 'Poll' },
|
||||
{ blockType: 'survey', displayName: 'Survey' },
|
||||
{ blockType: 'google-document', displayName: 'Google Document' },
|
||||
];
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockBlockTypesMetadata.applyMock = () => jest.spyOn(api, 'getBlockTypes').mockImplementation(mockBlockTypesMetadata);
|
||||
|
||||
export async function mockComponentDownstreamLinks(
|
||||
usageKey: string,
|
||||
): ReturnType<typeof api.getComponentDownstreamLinks> {
|
||||
|
||||
@@ -66,6 +66,19 @@ describe('library data API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlockTypes', () => {
|
||||
it('should get block types metadata', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const libraryId = 'lib:org:1';
|
||||
const url = api.getBlockTypesMetaDataUrl(libraryId);
|
||||
axiosMock.onGet(url).reply(200);
|
||||
|
||||
await api.getBlockTypes(libraryId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create collection', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const libraryId = 'lib:org:1';
|
||||
|
||||
@@ -24,6 +24,11 @@ export const getLibraryTeamApiUrl = (libraryId: string) => `${getApiBaseUrl()}/a
|
||||
*/
|
||||
export const getLibraryTeamMemberApiUrl = (libraryId: string, username: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/team/user/${username}/`;
|
||||
|
||||
/**
|
||||
* Get the URL for block types metadata.
|
||||
*/
|
||||
export const getBlockTypesMetaDataUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`;
|
||||
|
||||
/**
|
||||
* Get the URL for library block metadata.
|
||||
*/
|
||||
@@ -263,6 +268,11 @@ export interface CreateLibraryCollectionDataRequest {
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface BlockTypeMetadata {
|
||||
blockType: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export type UpdateCollectionComponentsRequest = Partial<CreateLibraryCollectionDataRequest>;
|
||||
|
||||
/**
|
||||
@@ -384,6 +394,16 @@ export async function updateLibraryTeamMember(memberData: UpdateLibraryTeamMembe
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of XBlock types that can be added to this library
|
||||
*/
|
||||
export async function getBlockTypes(libraryId: string): Promise<BlockTypeMetadata[]> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const url = getBlockTypesMetaDataUrl(libraryId);
|
||||
const { data } = await client.get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste clipboard content into library.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getLibraryCollectionComponentApiUrl,
|
||||
getLibraryCollectionsApiUrl,
|
||||
getLibraryCollectionApiUrl,
|
||||
getBlockTypesMetaDataUrl,
|
||||
} from './api';
|
||||
import {
|
||||
useCommitLibraryChanges,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
useRevertLibraryChanges,
|
||||
useAddComponentsToCollection,
|
||||
useCollection,
|
||||
useBlockTypesMetadata,
|
||||
} from './apiHooks';
|
||||
|
||||
let axiosMock;
|
||||
@@ -123,4 +125,17 @@ describe('library api hooks', () => {
|
||||
expect(result.current.data).toEqual({ testData: 'test-value' });
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
});
|
||||
|
||||
it('should get block types metadata', async () => {
|
||||
const libraryId = 'lib:org:1';
|
||||
const url = getBlockTypesMetaDataUrl(libraryId);
|
||||
|
||||
axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' });
|
||||
const { result } = renderHook(() => useBlockTypesMetadata(libraryId), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBeFalsy();
|
||||
});
|
||||
expect(result.current.data).toEqual({ testData: 'test-value' });
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
publishXBlock,
|
||||
deleteXBlockAsset,
|
||||
restoreLibraryBlock,
|
||||
getBlockTypes,
|
||||
getComponentDownstreamLinks,
|
||||
} from './api';
|
||||
import { VersionSpec } from '../LibraryBlock';
|
||||
@@ -85,6 +86,11 @@ export const libraryAuthoringQueryKeys = {
|
||||
libraryId,
|
||||
collectionId,
|
||||
],
|
||||
blockTypes: (libraryId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
'blockTypes',
|
||||
libraryId,
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
@@ -248,6 +254,17 @@ export const useLibraryTeam = (libraryId: string | undefined) => (
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to fetch the list of XBlock types that can be added to this library.
|
||||
*/
|
||||
export const useBlockTypesMetadata = (libraryId: string | undefined) => (
|
||||
useQuery({
|
||||
queryKey: libraryAuthoringQueryKeys.blockTypes(libraryId),
|
||||
queryFn: () => getBlockTypes(libraryId!),
|
||||
enabled: libraryId !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to add a new member to a content library's team
|
||||
*/
|
||||
|
||||
@@ -48,7 +48,7 @@ mergeConfig({
|
||||
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
|
||||
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
|
||||
LMS_BASE_URL: process.env.LMS_BASE_URL || null,
|
||||
LIBRARY_SUPPORTED_BLOCKS: (process.env.LIBRARY_SUPPORTED_BLOCKS || 'problem,video,html').split(','),
|
||||
LIBRARY_UNSUPPORTED_BLOCKS: (process.env.LIBRARY_UNSUPPORTED_BLOCKS || 'conditional,step-builder,problem-builder').split(','),
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
class ResizeObserver {
|
||||
|
||||
Reference in New Issue
Block a user