feat: Create advanced blocks in libraries [FC-0076] (#1653)

List view to show and create the advanced blocks
This commit is contained in:
Chris Chávez
2025-03-05 12:46:17 -05:00
committed by GitHub
parent 26c919a070
commit 0eda5aec23
16 changed files with 322 additions and 103 deletions

4
.env
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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');
},
},

View File

@@ -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.';

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>;
};
/**

View File

@@ -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/ });

View File

@@ -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>> = () => {

View File

@@ -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> {

View File

@@ -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';

View File

@@ -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.
*/

View File

@@ -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);
});
});

View File

@@ -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
*/

View File

@@ -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 {