feat: Add component to Unit [FC-0083] (#1784)

Creation workflow in unit page.
This commit is contained in:
Chris Chávez
2025-04-14 17:36:46 -05:00
committed by GitHub
parent f46e4ce4e8
commit a522c48045
19 changed files with 288 additions and 41 deletions

View File

@@ -18,7 +18,7 @@ import { useEditorContext } from '../../EditorContext';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContentContainer';
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContent';
import libraryMessages from '../../../library-authoring/add-content/messages';
import './index.scss';

View File

@@ -4,6 +4,8 @@ import {
getLibraryId,
isLibraryKey,
isLibraryV1Key,
getContainerTypeFromId,
ContainerType,
} from './key-utils';
describe('component utils', () => {
@@ -97,4 +99,16 @@ describe('component utils', () => {
});
}
});
describe('getContainerTypeFromId', () => {
for (const [input, expected] of [
['lct:org:lib:unit:my-unit-9284e2', ContainerType.Unit],
['lct:OpenCraftX:ALPHA:my-unit-a3223f', undefined],
['', undefined],
]) {
it(`returns '${expected}' for container key '${input}'`, () => {
expect(getContainerTypeFromId(input!)).toStrictEqual(expected);
});
}
});
});

View File

@@ -49,3 +49,26 @@ export const buildCollectionUsageKey = (learningContextKey: string, collectionId
const orgLib = learningContextKey.replace('lib:', '');
return `lib-collection:${orgLib}:${collectionId}`;
};
export enum ContainerType {
Unit = 'unit',
}
/**
* Given a container key like `ltc:org:lib:unit:id`
* get the container type
*/
export function getContainerTypeFromId(containerId: string): ContainerType | undefined {
const parts = containerId.split(':');
if (parts.length < 2) {
return undefined;
}
const maybeType = parts[parts.length - 2];
if (Object.values(ContainerType).includes(maybeType as ContainerType)) {
return maybeType as ContainerType;
}
return undefined;
}

View File

@@ -43,7 +43,7 @@ const LibraryLayout = () => {
/** The component picker modal to use. We need to pass it as a reference instead of
* directly importing it to avoid the import cycle:
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
* Sidebar > AddContentContainer > ComponentPicker */
* Sidebar > AddContent > ComponentPicker */
componentPicker={ComponentPicker}
>
<SidebarProvider>

View File

@@ -13,11 +13,11 @@ import {
} from '../data/api.mocks';
import {
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
getXBlockFieldsApiUrl,
getXBlockFieldsApiUrl, getLibraryContainerChildrenApiUrl,
} from '../data/api';
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import AddContentContainer from './AddContentContainer';
import AddContent from './AddContent';
import { ComponentEditorModal } from '../components/ComponentEditorModal';
import editorCmsApi from '../../editors/data/services/cms/api';
import { ToastActionData } from '../../generic/toast-context';
@@ -32,7 +32,7 @@ jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCs
const { libraryId } = mockContentLibrary;
const render = (collectionId?: string) => {
const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId };
return baseRender(<AddContentContainer />, {
return baseRender(<AddContent />, {
path: '/library/:libraryId/:collectionId?',
params,
extraWrapper: ({ children }) => (
@@ -45,10 +45,25 @@ const render = (collectionId?: string) => {
),
});
};
const renderWithUnit = (unitId: string) => {
const params: { libraryId: string, unitId?: string } = { libraryId, unitId };
return baseRender(<AddContent />, {
path: '/library/:libraryId/:unitId?',
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
>
{ children }
<ComponentEditorModal />
</LibraryProvider>
),
});
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<AddContentContainer />', () => {
describe('<AddContent />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
@@ -290,4 +305,71 @@ describe('<AddContentContainer />', () => {
expect(mockShowToast).toHaveBeenCalledWith(expectedError);
});
});
it('should not show collection/unit buttons when create component in container', async () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
renderWithUnit(unitId);
expect(await screen.findByRole('button', { name: 'Text' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
});
it('should create a component in unit', async () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
const usageKey = mockXBlockFields.usageKeyNewHtml;
const createUrl = getCreateLibraryBlockUrl(libraryId);
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
axiosMock.onPost(createUrl).reply(200, {
id: usageKey,
});
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
axiosMock.onPost(linkUrl).reply(200);
renderWithUnit(unitId);
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
// Component should be linked to Unit on saving the changes in the editor.
const saveButton = screen.getByLabelText('Save changes and return to learning context');
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
expect(axiosMock.history.post[0].url).toEqual(createUrl);
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
});
it('should show error on create a component in unit', async () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
const usageKey = mockXBlockFields.usageKeyNewHtml;
const createUrl = getCreateLibraryBlockUrl(libraryId);
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
axiosMock.onPost(createUrl).reply(200, {
id: usageKey,
});
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
axiosMock.onPost(linkUrl).reply(400);
renderWithUnit(unitId);
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
const saveButton = screen.getByLabelText('Save changes and return to learning context');
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
expect(axiosMock.history.post[0].url).toEqual(createUrl);
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.');
});
});

View File

@@ -23,6 +23,7 @@ import {
useLibraryPasteClipboard,
useAddComponentsToCollection,
useBlockTypesMetadata,
useAddComponentsToContainer,
} from '../data/apiHooks';
import { useLibraryContext } from '../common/context/LibraryContext';
import { PickLibraryContentModal } from './PickLibraryContentModal';
@@ -30,6 +31,7 @@ import { blockTypes } from '../../editors/data/constants/app';
import messages from './messages';
import type { BlockTypeMetadata } from '../data/api';
import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils';
type ContentType = {
name: string,
@@ -87,7 +89,12 @@ const AddContentView = ({
const {
collectionId,
componentPicker,
unitId,
} = useLibraryContext();
let upstreamContainerType: ContainerType | undefined;
if (unitId) {
upstreamContainerType = getContainerTypeFromId(unitId);
}
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
@@ -109,21 +116,25 @@ const AddContentView = ({
return (
<>
{collectionId ? (
componentPicker && (
<>
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</>
)
) : (
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
{upstreamContainerType !== ContainerType.Unit && (
<>
{collectionId ? (
componentPicker && (
<>
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</>
)
) : (
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
)}
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
<hr className="w-100 bg-gray-500" />
</>
)}
<AddContentButton contentType={unitButtonData} 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
@@ -186,7 +197,7 @@ export const parseErrorMsg = (
return intl.formatMessage(defaultMessage);
};
const AddContentContainer = () => {
const AddContent = () => {
const intl = useIntl();
const {
libraryId,
@@ -194,8 +205,10 @@ const AddContentContainer = () => {
openCreateCollectionModal,
openCreateUnitModal,
openComponentEditor,
unitId,
} = useLibraryContext();
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
const addComponentsToCollectionMutation = useAddComponentsToCollection(libraryId, collectionId);
const addComponentsToContainerMutation = useAddComponentsToContainer(libraryId, unitId);
const createBlockMutation = useCreateLibraryBlock();
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
@@ -274,9 +287,16 @@ const AddContentContainer = () => {
}
const linkComponent = (usageKey: string) => {
updateComponentsMutation.mutateAsync([usageKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
if (collectionId) {
addComponentsToCollectionMutation.mutateAsync([usageKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
});
}
if (unitId) {
addComponentsToContainerMutation.mutateAsync([usageKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
});
}
};
const onPaste = () => {
@@ -374,4 +394,4 @@ const AddContentContainer = () => {
);
};
export default AddContentContainer;
export default AddContent;

View File

@@ -42,7 +42,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
collectionId,
/** We need to get it as a reference instead of directly importing it to avoid the import cycle:
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
* Sidebar > AddContentContainer > ComponentPicker */
* Sidebar > AddContent > ComponentPicker */
componentPicker: ComponentPicker,
} = useLibraryContext();
@@ -65,7 +65,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
})
.catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
});
}, [selectedComponents]);

View File

@@ -1,2 +1,2 @@
export { default as AddContentContainer } from './AddContentContainer';
export { default as AddContent } from './AddContent';
export { default as AddContentHeader } from './AddContentHeader';

View File

@@ -84,11 +84,16 @@ const messages = defineMessages({
defaultMessage: 'Content linked successfully.',
description: 'Message when linking of content to a collection in library is success',
},
errorAssociateComponentMessage: {
errorAssociateComponentToCollectionMessage: {
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
defaultMessage: 'There was an error linking the content to this collection.',
description: 'Message when linking of content to a collection in library fails',
},
errorAssociateComponentToContainerMessage: {
id: 'course-authoring.library-authoring.associate-container-content.error.text',
defaultMessage: 'There was an error linking the content to this container.',
description: 'Message when linking of content to a container in library fails',
},
addContentTitle: {
id: 'course-authoring.library-authoring.sidebar.title.add-content',
defaultMessage: 'Add Content',

View File

@@ -72,7 +72,7 @@ type LibraryProviderProps = {
/** The component picker modal to use. We need to pass it as a reference instead of
* directly importing it to avoid the import cycle:
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
* Sidebar > AddContentContainer > ComponentPicker */
* Sidebar > AddContent > ComponentPicker */
componentPicker?: typeof ComponentPicker;
};

View File

@@ -13,6 +13,7 @@ import messages from './messages';
import { useCreateLibraryContainer } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import { ContainerType } from '../../generic/key-utils';
const CreateUnitModal = () => {
const intl = useIntl();
@@ -27,7 +28,7 @@ const CreateUnitModal = () => {
const handleCreate = React.useCallback(async (values) => {
try {
await create.mutateAsync({
containerType: 'unit',
containerType: ContainerType.Unit,
...values,
});
// TODO: Navigate to the new unit

View File

@@ -113,6 +113,17 @@ describe('library data API', () => {
axiosMock.onPost(url).reply(200);
await api.restoreContainer(containerId);
});
it('should add components to unit', async () => {
const { axiosMock } = initializeMocks();
const componentId = 'lb:org:lib:html:1';
const containerId = 'ltc:org:lib:unit:1';
const url = api.getLibraryContainerChildrenApiUrl(containerId);
axiosMock.onPost(url).reply(200);
await api.addComponentsToContainer(containerId, [componentId]);
expect(axiosMock.history.post[0].url).toEqual(url);
});
});

View File

@@ -1,6 +1,7 @@
import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { VersionSpec } from '../LibraryBlock';
import { type ContainerType } from '../../generic/key-utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -576,7 +577,7 @@ export async function updateComponentCollections(usageKey: string, collectionKey
export interface CreateLibraryContainerDataRequest {
title: string;
containerType: string;
containerType: ContainerType;
}
/**
@@ -648,3 +649,15 @@ export async function getLibraryContainerChildren(containerId: string): Promise<
const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerChildrenApiUrl(containerId));
return camelCaseObject(data);
}
/**
* Add components to library container
*/
export async function addComponentsToContainer(containerId: string, componentIds: string[]) {
const client = getAuthenticatedHttpClient();
// POSTing to this URL will append children; PATCHing to it will replace the children.
await client.post(
getLibraryContainerChildrenApiUrl(containerId),
snakeCaseObject({ usageKeys: componentIds }),
);
}

View File

@@ -28,6 +28,7 @@ import {
useDeleteContainer,
useRestoreContainer,
useContainerChildren,
useAddComponentsToContainer,
} from './apiHooks';
let axiosMock;
@@ -258,4 +259,18 @@ describe('library api hooks', () => {
]);
expect(axiosMock.history.get[0].url).toEqual(url);
});
it('should add components to container', async () => {
const libraryId = 'lib:org:1';
const componentId = 'lb:org:lib:html:1';
const containerId = 'ltc:org:lib:unit:1';
const url = getLibraryContainerChildrenApiUrl(containerId);
axiosMock.onPost(url).reply(200);
const { result } = renderHook(() => useAddComponentsToContainer(libraryId, containerId), { wrapper });
await result.current.mutateAsync([componentId]);
expect(axiosMock.history.post[0].url).toEqual(url);
});
});

View File

@@ -47,6 +47,7 @@ import {
restoreLibraryBlock,
getBlockTypes,
createLibraryContainer,
addComponentsToContainer,
type CreateLibraryContainerDataRequest,
getContainerMetadata,
updateContainerMetadata,
@@ -669,3 +670,21 @@ export const useContainerChildren = (libraryId?: string, containerId?: string) =
queryFn: () => getLibraryContainerChildren(containerId!),
})
);
/**
* Use this mutation to add components to a container
*/
export const useAddComponentsToContainer = (libraryId?: string, containerId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (componentIds: string[]) => {
if (containerId !== undefined) {
return addComponentsToContainer(containerId, componentIds);
}
return undefined;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(libraryId, containerId) });
},
});
};

View File

@@ -7,7 +7,7 @@ import {
import { Close } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AddContentContainer, AddContentHeader } from '../add-content';
import { AddContent, AddContentHeader } from '../add-content';
import { CollectionInfo, CollectionInfoHeader } from '../collections';
import { ContainerInfoHeader, UnitInfo } from '../containers';
import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext';
@@ -29,7 +29,7 @@ const LibrarySidebar = () => {
const { sidebarComponentInfo, closeLibrarySidebar } = useSidebarContext();
const bodyComponentMap = {
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
[SidebarBodyComponentId.AddContent]: <AddContent />,
[SidebarBodyComponentId.Info]: <LibraryInfo />,
[SidebarBodyComponentId.ComponentInfo]: <ComponentInfo />,
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfo />,

View File

@@ -1,8 +1,8 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Badge, Icon, Stack, useToggle,
ActionRow, Badge, Button, Icon, Stack, useToggle,
} from '@openedx/paragon';
import { Description } from '@openedx/paragon/icons';
import { Add, Description } from '@openedx/paragon/icons';
import { useQueryClient } from '@tanstack/react-query';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
@@ -22,8 +22,10 @@ import { libraryAuthoringQueryKeys, useContainerChildren } from '../data/apiHook
import { LibraryBlock } from '../LibraryBlock';
import { useLibraryRoutes } from '../routes';
import messages from './messages';
import { useSidebarContext } from '../common/context/SidebarContext';
export const LibraryUnitBlocks = () => {
const intl = useIntl();
const [orderedBlocks, setOrderedBlocks] = useState<LibraryBlockMetadata[]>([]);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
const { navigateTo } = useLibraryRoutes();
@@ -33,9 +35,14 @@ export const LibraryUnitBlocks = () => {
unitId,
showOnlyPublished,
componentId,
readOnly,
setComponentId,
} = useLibraryContext();
const {
openAddContentSidebar,
} = useSidebarContext();
const queryClient = useQueryClient();
const {
data: blocks,
@@ -128,6 +135,31 @@ export const LibraryUnitBlocks = () => {
<DraggableList itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder}>
{renderedBlocks}
</DraggableList>
<div className="d-flex">
<div className="w-100 mr-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
block
>
{intl.formatMessage(messages.newContentButton)}
</Button>
</div>
<div className="w-100 ml-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled
block
>
{intl.formatMessage(messages.addExistingContentButton)}
</Button>
</div>
</div>
<ContentTagsDrawerSheet
id={componentId}
onClose={onTagSidebarClose}

View File

@@ -26,6 +26,7 @@ const HeaderActions = () => {
const { unitId, readOnly } = useLibraryContext();
const {
openAddContentSidebar,
closeLibrarySidebar,
openUnitInfoSidebar,
sidebarComponentInfo,
@@ -64,8 +65,9 @@ const HeaderActions = () => {
iconBefore={Add}
variant="primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
>
{intl.formatMessage(messages.newContentButton)}
{intl.formatMessage(messages.addContentButton)}
</Button>
</div>
);

View File

@@ -6,9 +6,19 @@ const messages = defineMessages({
defaultMessage: 'Unit Info',
description: 'Button text to unit sidebar from unit page',
},
newContentButton: {
id: 'course-authoring.library-authoring.unit-header.buttons.new-content',
addContentButton: {
id: 'course-authoring.library-authoring.unit-header.buttons.add-content',
defaultMessage: 'Add Content',
description: 'Text of button to add content to unit',
},
addExistingContentButton: {
id: 'course-authoring.library-authoring.unit-header.buttons.add-existing-content',
defaultMessage: 'Add Existing Content',
description: 'Text of button to add existing content to unit',
},
newContentButton: {
id: 'course-authoring.library-authoring.unit-header.buttons.add-new-content',
defaultMessage: 'Add New Content',
description: 'Text of button to add new content to unit',
},
breadcrumbsAriaLabel: {