feat: Create collection Modal [FC-0062] (#1259)

* feat: Enable Collection button on Create Component in Library

* feat: CreateCollectionModal added

* test: For CreateCollectionModal

* refactor: Migrate FormikControl to TypeScript

* test: Add tests for EmptyStates
This commit is contained in:
Chris Chávez
2024-09-13 21:07:02 -05:00
committed by GitHub
parent fd48fef299
commit a37a1b1ef8
13 changed files with 486 additions and 50 deletions

View File

@@ -1,16 +1,25 @@
/* eslint-disable react/jsx-no-useless-fragment */
import React from 'react';
import { Form } from '@openedx/paragon';
import { getIn, useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import React from 'react';
import FormikErrorFeedback from './FormikErrorFeedback';
const FormikControl = ({
interface Props {
name: string;
label?: React.ReactElement;
help?: React.ReactElement;
className?: string;
controlClasses?: string;
value: string | number;
}
const FormikControl: React.FC<Props & React.ComponentProps<typeof Form.Control>> = ({
name,
label,
help,
className,
controlClasses,
// eslint-disable-next-line react/jsx-no-useless-fragment
label = <></>,
// eslint-disable-next-line react/jsx-no-useless-fragment
help = <></>,
className = '',
controlClasses = 'pb-2',
...params
}) => {
const {
@@ -39,23 +48,4 @@ const FormikControl = ({
);
};
FormikControl.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.element,
help: PropTypes.element,
className: PropTypes.string,
controlClasses: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
};
FormikControl.defaultProps = {
help: <></>,
label: <></>,
className: '',
controlClasses: 'pb-2',
};
export default FormikControl;

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useCallback } from 'react';
import { useParams } from 'react-router';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
@@ -15,18 +15,26 @@ type NoSearchResultsProps = {
};
export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
const { openAddContentSidebar } = useContext(LibraryContext);
const { openAddContentSidebar, openCreateCollectionModal } = useContext(LibraryContext);
const { libraryId } = useParams();
const { data: libraryData } = useContentLibrary(libraryId);
const canEditLibrary = libraryData?.canEditLibrary ?? false;
const handleOnClickButton = useCallback(() => {
if (searchType === 'collection') {
openCreateCollectionModal();
} else {
openAddContentSidebar();
}
}, [searchType]);
return (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
{searchType === 'collection'
? <FormattedMessage {...messages.noCollections} />
: <FormattedMessage {...messages.noComponents} />}
{canEditLibrary && (
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
<Button iconBefore={Add} onClick={handleOnClickButton}>
{searchType === 'collection'
? <FormattedMessage {...messages.addCollection} />
: <FormattedMessage {...messages.addComponent} />}

View File

@@ -13,6 +13,7 @@ import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './d
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { mockBroadcastChannel } from '../generic/data/api.mock';
import { LibraryLayout } from '.';
import { getLibraryCollectionsApiUrl } from './data/api';
mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
@@ -164,8 +165,23 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument();
// Open Create collection modal
const addCollectionButton = screen.getByRole('button', { name: /add collection/i });
fireEvent.click(addCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
// Click on Cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(collectionModalHeading).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
const addComponentButton = screen.getByRole('button', { name: /add component/i });
fireEvent.click(addComponentButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
});
it('shows the new content button', async () => {
@@ -535,6 +551,120 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});
it('should create a collection', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
title,
description,
});
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
// Click on Cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(collectionModalHeading).not.toBeInTheDocument();
// Open new collection modal again and create a collection
fireEvent.click(newCollectionButton);
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i });
fireEvent.change(nameField, { target: { value: title } });
fireEvent.change(descriptionField, { target: { value: description } });
fireEvent.click(createButton);
});
it('should show validations in create collection', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
title,
description,
});
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
fireEvent.focus(nameField);
fireEvent.blur(nameField);
// Click on create with an empty name
const createButton = screen.getByRole('button', { name: /create/i });
fireEvent.click(createButton);
expect(await screen.findByText(/collection name is required/i)).toBeInTheDocument();
});
it('should show error on create collection', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(500);
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();
// Create a normal collection
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i });
fireEvent.change(nameField, { target: { value: title } });
fireEvent.change(descriptionField, { target: { value: description } });
fireEvent.click(createButton);
});
it('shows both components and collections in recently modified section', async () => {
await renderLibraryPage();

View File

@@ -11,6 +11,7 @@ import { useQueryClient } from '@tanstack/react-query';
import EditorContainer from '../editors/EditorContainer';
import LibraryAuthoringPage from './LibraryAuthoringPage';
import { LibraryProvider } from './common/context';
import { CreateCollectionModal } from './create-collection';
import { invalidateComponentData } from './data/apiHooks';
const LibraryLayout = () => {
@@ -49,6 +50,7 @@ const LibraryLayout = () => {
element={<LibraryAuthoringPage />}
/>
</Routes>
<CreateCollectionModal />
</LibraryProvider>
);
};

View File

@@ -25,6 +25,39 @@ import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHook
import { getEditUrl } from '../components/utils';
import messages from './messages';
import { LibraryContext } from '../common/context';
type ContentType = {
name: string,
disabled: boolean,
icon: React.ComponentType,
blockType: string,
};
type AddContentButtonProps = {
contentType: ContentType,
onCreateContent: (blockType: string) => void,
};
const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => {
const {
name,
disabled,
icon,
blockType,
} = contentType;
return (
<Button
variant="outline-primary"
disabled={disabled}
className="m-2"
iconBefore={icon}
onClick={() => onCreateContent(blockType)}
>
{name}
</Button>
);
};
const AddContentContainer = () => {
const intl = useIntl();
@@ -35,7 +68,16 @@ const AddContentContainer = () => {
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock } = useCopyToClipboard(canEdit);
const {
openCreateCollectionModal,
} = React.useContext(LibraryContext);
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
disabled: false,
icon: BookOpen,
blockType: 'collection',
};
const contentTypes = [
{
name: intl.formatMessage(messages.textTypeButton),
@@ -98,6 +140,8 @@ const AddContentContainer = () => {
}).catch(() => {
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
});
} else if (blockType === 'collection') {
openCreateCollectionModal();
} else {
createBlockMutation.mutateAsync({
libraryId,
@@ -124,26 +168,14 @@ const AddContentContainer = () => {
return (
<Stack direction="vertical">
<Button
variant="outline-primary"
disabled
className="m-2 rounded-0"
iconBefore={BookOpen}
>
{intl.formatMessage(messages.collectionButton)}
</Button>
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
<hr className="w-100 bg-gray-500" />
{contentTypes.map((contentType) => (
<Button
<AddContentButton
key={`add-content-${contentType.blockType}`}
variant="outline-primary"
disabled={contentType.disabled}
className="m-2 rounded-0"
iconBefore={contentType.icon}
onClick={() => onCreateContent(contentType.blockType)}
>
{contentType.name}
</Button>
contentType={contentType}
onCreateContent={onCreateContent}
/>
))}
</Stack>
);

View File

@@ -1,3 +1,4 @@
import { useToggle } from '@openedx/paragon';
import React from 'react';
export enum SidebarBodyComponentId {
@@ -13,6 +14,9 @@ export interface LibraryContextData {
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
closeCreateCollectionModal: () => void;
}
export const LibraryContext = React.createContext({
@@ -21,6 +25,9 @@ export const LibraryContext = React.createContext({
openAddContentSidebar: () => {},
openInfoSidebar: () => {},
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
isCreateCollectionModalOpen: false,
openCreateCollectionModal: () => {},
closeCreateCollectionModal: () => {},
} as LibraryContextData);
/**
@@ -29,6 +36,7 @@ export const LibraryContext = React.createContext({
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const closeLibrarySidebar = React.useCallback(() => {
setSidebarBodyComponent(null);
@@ -57,6 +65,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
@@ -64,6 +75,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
]);
return (

View File

@@ -0,0 +1,125 @@
import React from 'react';
import {
ActionRow,
Button,
Form,
ModalDialog,
} from '@openedx/paragon';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { useCreateLibraryCollection } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
const CreateCollectionModal = () => {
const intl = useIntl();
const { libraryId } = useParams();
if (!libraryId) {
throw new Error('Rendered without libraryId URL parameter');
}
const create = useCreateLibraryCollection(libraryId!);
const {
isCreateCollectionModalOpen,
closeCreateCollectionModal,
} = React.useContext(LibraryContext);
const { showToast } = React.useContext(ToastContext);
const handleCreate = React.useCallback((values) => {
create.mutateAsync(values).then(() => {
closeCreateCollectionModal();
showToast(intl.formatMessage(messages.createCollectionSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.createCollectionError));
});
}, []);
return (
<ModalDialog
title={intl.formatMessage(messages.createCollectionModalTitle)}
isOpen={isCreateCollectionModalOpen}
onClose={closeCreateCollectionModal}
size="xl"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.createCollectionModalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<Formik
initialValues={{
title: '',
description: '',
}}
validationSchema={
Yup.object().shape({
title: Yup.string()
.required(intl.formatMessage(messages.createCollectionModalNameInvalid)),
description: Yup.string(),
})
}
onSubmit={handleCreate}
>
{(formikProps) => (
<>
<ModalDialog.Body className="mw-sm">
<Form onSubmit={formikProps.handleSubmit}>
<FormikControl
name="title"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createCollectionModalNameLabel)}
</Form.Label>
)}
value={formikProps.values.title}
placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)}
controlClasses="pb-2"
/>
<FormikControl
name="description"
as="textarea"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createCollectionModalDescriptionLabel)}
</Form.Label>
)}
value={formikProps.values.description}
placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)}
help={(
<Form.Text>
{intl.formatMessage(messages.createCollectionModalDescriptionDetails)}
</Form.Text>
)}
controlClasses="pb-2"
rows="5"
/>
</Form>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.createCollectionModalCancel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
onClick={formikProps.submitForm}
disabled={formikProps.isSubmitting || !formikProps.isValid || !formikProps.dirty}
>
{intl.formatMessage(messages.createCollectionModalCreate)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</>
)}
</Formik>
</ModalDialog>
);
};
export default CreateCollectionModal;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as CreateCollectionModal } from './CreateCollectionModal';

View File

@@ -0,0 +1,65 @@
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
import type { defineMessages as defineMessagesType } from 'react-intl';
// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = _defineMessages as typeof defineMessagesType;
const messages = defineMessages({
createCollectionModalTitle: {
id: 'course-authoring.library-authoring.modals.create-collection.title',
defaultMessage: 'New Collection',
description: 'Title of the Create Collection modal',
},
createCollectionModalCancel: {
id: 'course-authoring.library-authoring.modals.create-collection.cancel',
defaultMessage: 'Cancel',
description: 'Label of the Cancel button of the Create Collection modal',
},
createCollectionModalCreate: {
id: 'course-authoring.library-authoring.modals.create-collection.create',
defaultMessage: 'Create',
description: 'Label of the Create button of the Create Collection modal',
},
createCollectionModalNameLabel: {
id: 'course-authoring.library-authoring.modals.create-collection.form.name',
defaultMessage: 'Name your collection',
description: 'Label of the Name field of the Create Collection modal form',
},
createCollectionModalNamePlaceholder: {
id: 'course-authoring.library-authoring.modals.create-collection.form.name.placeholder',
defaultMessage: 'Give a descriptive title',
description: 'Placeholder of the Name field of the Create Collection modal form',
},
createCollectionModalNameInvalid: {
id: 'course-authoring.library-authoring.modals.create-collection.form.name.invalid',
defaultMessage: 'Collection name is required',
description: 'Message when the Name field of the Create Collection modal form is invalid',
},
createCollectionModalDescriptionLabel: {
id: 'course-authoring.library-authoring.modals.create-collection.form.description',
defaultMessage: 'Add a description (optional)',
description: 'Label of the Description field of the Create Collection modal form',
},
createCollectionModalDescriptionPlaceholder: {
id: 'course-authoring.library-authoring.modals.create-collection.form.description.placeholder',
defaultMessage: 'Add description',
description: 'Placeholder of the Description field of the Create Collection modal form',
},
createCollectionModalDescriptionDetails: {
id: 'course-authoring.library-authoring.modals.create-collection.form.description.details',
defaultMessage: 'Descriptions can help you and your team better organize and find what you are looking for',
description: 'Details of the Description field of the Create Collection modal form',
},
createCollectionSuccess: {
id: 'course-authoring.library-authoring.modals.create-collection.success',
defaultMessage: 'Collection created successfully',
description: 'Success message when creating a library collection',
},
createCollectionError: {
id: 'course-authoring.library-authoring.modals.create-collection.error',
defaultMessage: 'There is an error when creating the library collection',
description: 'Error message when creating a library collection',
},
});
export default messages;

View File

@@ -43,4 +43,19 @@ describe('library data API', () => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
});
it('should create collection', async () => {
const { axiosMock } = initializeMocks();
const libraryId = 'lib:org:1';
const url = api.getLibraryCollectionsApiUrl(libraryId);
axiosMock.onPost(url).reply(200);
await api.createCollection(libraryId, {
title: 'This is a test',
description: 'This is only a test',
});
expect(axiosMock.history.post[0].url).toEqual(url);
});
});

View File

@@ -32,6 +32,10 @@ export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/a
* Get the URL for the xblock OLX API
*/
export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`;
/**
* Get the URL for the Library Collections API.
*/
export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`;
export interface ContentLibrary {
id: string;
@@ -131,6 +135,11 @@ export interface UpdateXBlockFieldsRequest {
};
}
export interface CreateLibraryCollectionDataRequest {
title: string;
description: string | null;
}
/**
* Fetch the list of XBlock types that can be added to this library
*/
@@ -236,11 +245,21 @@ export async function getXBlockFields(usageKey: string): Promise<XBlockFields> {
/**
* Update xblock fields.
*/
export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlockFieldsRequest) {
export async function updateXBlockFields(usageKey: string, xblockData: UpdateXBlockFieldsRequest) {
const client = getAuthenticatedHttpClient();
await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);
}
/**
* Create a library collection
*/
export async function createCollection(libraryId: string, collectionData: CreateLibraryCollectionDataRequest) {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(getLibraryCollectionsApiUrl(libraryId), collectionData);
return camelCaseObject(data);
}
/**
* Fetch the OLX for the given XBlock.
*/

View File

@@ -5,8 +5,13 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl } from './api';
import { useCommitLibraryChanges, useCreateLibraryBlock, useRevertLibraryChanges } from './apiHooks';
import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl, getLibraryCollectionsApiUrl } from './api';
import {
useCommitLibraryChanges,
useCreateLibraryBlock,
useCreateLibraryCollection,
useRevertLibraryChanges,
} from './apiHooks';
let axiosMock;
@@ -70,4 +75,18 @@ describe('library api hooks', () => {
expect(axiosMock.history.delete[0].url).toEqual(url);
});
it('should create collection', async () => {
const libraryId = 'lib:org:1';
const url = getLibraryCollectionsApiUrl(libraryId);
axiosMock.onPost(url).reply(200);
const { result } = renderHook(() => useCreateLibraryCollection(libraryId), { wrapper });
await result.current.mutateAsync({
title: 'This is a test',
description: 'This is only a test',
});
expect(axiosMock.history.post[0].url).toEqual(url);
});
});

View File

@@ -23,7 +23,9 @@ import {
libraryPasteClipboard,
getXBlockFields,
updateXBlockFields,
createCollection,
getXBlockOLX,
type CreateLibraryCollectionDataRequest,
} from './api';
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -238,6 +240,19 @@ export const useUpdateXBlockFields = (usageKey: string) => {
});
};
/**
* Use this mutation to create a library collection
*/
export const useCreateLibraryCollection = (libraryId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateLibraryCollectionDataRequest) => createCollection(libraryId, data),
onSettled: () => {
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
},
});
};
/* istanbul ignore next */ // This is only used in developer builds, and the associated UI doesn't work in test or prod
export const useXBlockOLX = (usageKey: string) => (
useQuery({