feat: button to publish a container [FC-0083] (#1827)
- Publish button with functionality of publish units and components inside the unit
This commit is contained in:
@@ -8,12 +8,16 @@ import {
|
||||
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import UnitInfo from './UnitInfo';
|
||||
import { getLibraryContainerApiUrl } from '../data/api';
|
||||
import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api';
|
||||
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
|
||||
|
||||
mockGetContainerMetadata.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockGetContainerMetadata.applyMock();
|
||||
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const { containerId } = mockGetContainerMetadata;
|
||||
|
||||
const render = () => baseRender(<UnitInfo />, {
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider
|
||||
@@ -38,7 +42,7 @@ describe('<UnitInfo />', () => {
|
||||
({ axiosMock, mockShowToast } = initializeMocks());
|
||||
});
|
||||
|
||||
it('should detele the unit using the menu', async () => {
|
||||
it('should delete the unit using the menu', async () => {
|
||||
axiosMock.onDelete(getLibraryContainerApiUrl(containerId)).reply(200);
|
||||
render();
|
||||
|
||||
@@ -61,4 +65,34 @@ describe('<UnitInfo />', () => {
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can publish the container', async () => {
|
||||
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200);
|
||||
render();
|
||||
|
||||
// Click on Publish button
|
||||
const publishButton = await screen.findByRole('button', { name: 'Publish' });
|
||||
expect(publishButton).toBeInTheDocument();
|
||||
userEvent.click(publishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('All changes published');
|
||||
});
|
||||
|
||||
it('shows an error if publishing the container fails', async () => {
|
||||
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(500);
|
||||
render();
|
||||
|
||||
// Click on Publish button
|
||||
const publishButton = await screen.findByRole('button', { name: 'Publish' });
|
||||
expect(publishButton).toBeInTheDocument();
|
||||
userEvent.click(publishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
@@ -28,7 +28,8 @@ import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks';
|
||||
import messages from './messages';
|
||||
import componentMessages from '../components/messages';
|
||||
import ContainerDeleter from '../components/ContainerDeleter';
|
||||
import { useContainer } from '../data/apiHooks';
|
||||
import { useContainer, usePublishContainer } from '../data/apiHooks';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
containerId: string,
|
||||
@@ -71,8 +72,9 @@ const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => {
|
||||
const UnitInfo = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { libraryId } = useLibraryContext();
|
||||
const { libraryId, readOnly } = useLibraryContext();
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
const { showToast } = React.useContext(ToastContext);
|
||||
const {
|
||||
defaultTab,
|
||||
hiddenTabs,
|
||||
@@ -90,6 +92,7 @@ const UnitInfo = () => {
|
||||
|
||||
const unitId = sidebarComponentInfo?.id;
|
||||
const { data: container } = useContainer(unitId);
|
||||
const publishContainer = usePublishContainer(unitId!);
|
||||
|
||||
const showOpenUnitButton = !insideUnit && !componentPickerMode;
|
||||
|
||||
@@ -105,6 +108,15 @@ const UnitInfo = () => {
|
||||
);
|
||||
}, [hiddenTabs, defaultTab.unit, unitId]);
|
||||
|
||||
const handlePublish = React.useCallback(async () => {
|
||||
try {
|
||||
await publishContainer.mutateAsync();
|
||||
showToast(intl.formatMessage(messages.publishContainerSuccess));
|
||||
} catch (error) {
|
||||
showToast(intl.formatMessage(messages.publishContainerFailed));
|
||||
}
|
||||
}, [publishContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
// Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo
|
||||
if (jumpToCollections) {
|
||||
@@ -118,8 +130,8 @@ const UnitInfo = () => {
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{showOpenUnitButton && (
|
||||
<div className="d-flex flex-wrap">
|
||||
<div className="d-flex flex-wrap">
|
||||
{showOpenUnitButton && (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="m-1 text-nowrap flex-grow-1"
|
||||
@@ -128,12 +140,24 @@ const UnitInfo = () => {
|
||||
>
|
||||
{intl.formatMessage(messages.openUnitButton)}
|
||||
</Button>
|
||||
)}
|
||||
{!componentPickerMode && !readOnly && (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="m-1 text-nowrap flex-grow-1"
|
||||
disabled={!container.hasUnpublishedChanges || publishContainer.isLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{intl.formatMessage(messages.publishContainerButton)}
|
||||
</Button>
|
||||
)}
|
||||
{showOpenUnitButton && ( // Check: should we still show this on the unit page?
|
||||
<UnitMenu
|
||||
containerId={unitId}
|
||||
displayName={container.displayName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-3 d-flex justify-content-around"
|
||||
|
||||
@@ -26,6 +26,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Collections ({count})',
|
||||
description: 'Title for collections section in organize tab',
|
||||
},
|
||||
publishContainerButton: {
|
||||
id: 'course-authoring.library-authoring.container-sidebar.publish-button',
|
||||
defaultMessage: 'Publish',
|
||||
description: 'Button text to publish the unit/subsection/section',
|
||||
},
|
||||
publishContainerSuccess: {
|
||||
id: 'course-authoring.library-authoring.container-sidebar.publish-success',
|
||||
defaultMessage: 'All changes published',
|
||||
description: 'Popup text after publishing a unit/subsection/section',
|
||||
},
|
||||
publishContainerFailed: {
|
||||
id: 'course-authoring.library-authoring.container-sidebar.publish-failure',
|
||||
defaultMessage: 'Failed to publish changes',
|
||||
description: 'Popup text seen if publishing a unit/subsection/section fails',
|
||||
},
|
||||
settingsTabTitle: {
|
||||
id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title',
|
||||
defaultMessage: 'Settings',
|
||||
|
||||
@@ -124,6 +124,10 @@ export const getLibraryContainerChildrenApiUrl = (containerId: string) => `${get
|
||||
* Get the URL for library container collections.
|
||||
*/
|
||||
export const getLibraryContainerCollectionsUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}collections/`;
|
||||
/**
|
||||
* Get the URL for the API endpoint to publish a single container (+ children).
|
||||
*/
|
||||
export const getLibraryContainerPublishApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}publish/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -700,3 +704,13 @@ export async function removeLibraryContainerChildren(
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a container, and any unpublished children within it.
|
||||
*
|
||||
* This doesn't return any data at the moment, but we could have it return a
|
||||
* list of the auto-published children in the future, if that would be helpful.
|
||||
*/
|
||||
export async function publishContainer(containerId: string) {
|
||||
await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getLibraryContainerApiUrl,
|
||||
getLibraryContainerRestoreApiUrl,
|
||||
getLibraryContainerChildrenApiUrl,
|
||||
getLibraryContainerPublishApiUrl,
|
||||
} from './api';
|
||||
import {
|
||||
useCommitLibraryChanges,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
useAddComponentsToContainer,
|
||||
useUpdateContainerChildren,
|
||||
useRemoveContainerChildren,
|
||||
usePublishContainer,
|
||||
} from './apiHooks';
|
||||
|
||||
let axiosMock;
|
||||
@@ -308,4 +310,16 @@ describe('library api hooks', () => {
|
||||
expect(axiosMock.history.patch.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishContainer', () => {
|
||||
it('should publish a container', async () => {
|
||||
const containerId = 'lct:org:lib:unit:1';
|
||||
const url = getLibraryContainerPublishApiUrl(containerId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
const { result } = renderHook(() => usePublishContainer(containerId), { wrapper });
|
||||
await result.current.mutateAsync();
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,25 +40,13 @@ import {
|
||||
restoreCollection,
|
||||
setXBlockOLX,
|
||||
getXBlockAssets,
|
||||
updateComponentCollections,
|
||||
removeItemsFromCollection,
|
||||
publishXBlock,
|
||||
deleteXBlockAsset,
|
||||
restoreLibraryBlock,
|
||||
getBlockTypes,
|
||||
createLibraryContainer,
|
||||
addComponentsToContainer,
|
||||
type CreateLibraryContainerDataRequest,
|
||||
getContainerMetadata,
|
||||
updateContainerMetadata,
|
||||
deleteContainer,
|
||||
type UpdateContainerDataRequest,
|
||||
restoreContainer,
|
||||
getLibraryContainerChildren,
|
||||
updateContainerCollections,
|
||||
updateLibraryContainerChildren,
|
||||
removeLibraryContainerChildren,
|
||||
} from './api';
|
||||
import * as api from './api';
|
||||
import { VersionSpec } from '../LibraryBlock';
|
||||
|
||||
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
||||
@@ -82,6 +70,12 @@ export const libraryAuthoringQueryKeys = {
|
||||
* Base key for data specific to a contentLibrary
|
||||
*/
|
||||
contentLibrary: (contentLibraryId?: string) => [...libraryAuthoringQueryKeys.all, contentLibraryId],
|
||||
/** All keys for content within the library should be below this key */
|
||||
contentLibraryContent: (contentLibraryId?: string) => [
|
||||
...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
|
||||
'content',
|
||||
],
|
||||
/** Keys for the list of all libraries */
|
||||
contentLibraryList: (customParams?: GetLibrariesV2CustomParams) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
'list',
|
||||
@@ -93,8 +87,8 @@ export const libraryAuthoringQueryKeys = {
|
||||
libraryId,
|
||||
],
|
||||
collection: (libraryId?: string, collectionId?: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
libraryId,
|
||||
...libraryAuthoringQueryKeys.contentLibraryContent(libraryId),
|
||||
'collection',
|
||||
collectionId,
|
||||
],
|
||||
blockTypes: (libraryId?: string) => [
|
||||
@@ -104,7 +98,7 @@ export const libraryAuthoringQueryKeys = {
|
||||
],
|
||||
container: (containerId?: string) => {
|
||||
const baseKey = containerId
|
||||
? libraryAuthoringQueryKeys.contentLibrary(getLibraryId(containerId))
|
||||
? libraryAuthoringQueryKeys.contentLibraryContent(getLibraryId(containerId))
|
||||
: libraryAuthoringQueryKeys.all;
|
||||
return [
|
||||
...baseKey,
|
||||
@@ -112,17 +106,10 @@ export const libraryAuthoringQueryKeys = {
|
||||
containerId,
|
||||
];
|
||||
},
|
||||
containerChildren: (containerId?: string) => {
|
||||
const baseKey = containerId
|
||||
? libraryAuthoringQueryKeys.contentLibrary(getLibraryId(containerId))
|
||||
: libraryAuthoringQueryKeys.all;
|
||||
return [
|
||||
...baseKey,
|
||||
'container',
|
||||
containerId,
|
||||
'children',
|
||||
];
|
||||
},
|
||||
containerChildren: (containerId: string) => [
|
||||
...libraryAuthoringQueryKeys.container(containerId),
|
||||
'children',
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
@@ -139,6 +126,9 @@ export const xblockQueryKeys = {
|
||||
xblockAssets: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'assets'],
|
||||
componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'],
|
||||
componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'],
|
||||
|
||||
/** Predicate used to invalidate all metadata only */
|
||||
allComponentMetadata: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'componentMetadata',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -587,7 +577,7 @@ export const useUpdateComponentCollections = (usageKey: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const libraryId = getLibraryId(usageKey);
|
||||
return useMutation({
|
||||
mutationFn: async (collectionKeys: string[]) => updateComponentCollections(usageKey, collectionKeys),
|
||||
mutationFn: async (collectionKeys: string[]) => api.updateComponentCollections(usageKey, collectionKeys),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
@@ -601,7 +591,7 @@ export const useUpdateComponentCollections = (usageKey: string) => {
|
||||
export const useCreateLibraryContainer = (libraryId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateLibraryContainerDataRequest) => createLibraryContainer(libraryId, data),
|
||||
mutationFn: (data: api.CreateLibraryContainerDataRequest) => api.createLibraryContainer(libraryId, data),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
},
|
||||
@@ -615,7 +605,7 @@ export const useContainer = (containerId?: string) => (
|
||||
useQuery({
|
||||
enabled: !!containerId,
|
||||
queryKey: libraryAuthoringQueryKeys.container(containerId!),
|
||||
queryFn: () => getContainerMetadata(containerId!),
|
||||
queryFn: () => api.getContainerMetadata(containerId!),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -626,7 +616,7 @@ export const useUpdateContainer = (containerId: string) => {
|
||||
const libraryId = getLibraryId(containerId);
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateContainerDataRequest) => updateContainerMetadata(containerId, data),
|
||||
mutationFn: (data: api.UpdateContainerDataRequest) => api.updateContainerMetadata(containerId, data),
|
||||
onSettled: () => {
|
||||
// NOTE: We invalidate the library query here because we need to update the library's
|
||||
// container list.
|
||||
@@ -643,7 +633,7 @@ export const useDeleteContainer = (containerId: string) => {
|
||||
const libraryId = getLibraryId(containerId);
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => deleteContainer(containerId),
|
||||
mutationFn: async () => api.deleteContainer(containerId),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
@@ -658,7 +648,7 @@ export const useRestoreContainer = (containerId: string) => {
|
||||
const libraryId = getLibraryId(containerId);
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => restoreContainer(containerId),
|
||||
mutationFn: async () => api.restoreContainer(containerId),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
},
|
||||
@@ -671,8 +661,8 @@ export const useRestoreContainer = (containerId: string) => {
|
||||
export const useContainerChildren = (containerId?: string) => (
|
||||
useQuery({
|
||||
enabled: !!containerId,
|
||||
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId),
|
||||
queryFn: () => getLibraryContainerChildren(containerId!),
|
||||
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
|
||||
queryFn: () => api.getLibraryContainerChildren(containerId!),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -684,7 +674,7 @@ export const useAddComponentsToContainer = (containerId?: string) => {
|
||||
return useMutation({
|
||||
mutationFn: async (componentIds: string[]) => {
|
||||
if (containerId !== undefined) {
|
||||
return addComponentsToContainer(containerId, componentIds);
|
||||
return api.addComponentsToContainer(containerId, componentIds);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
@@ -701,7 +691,7 @@ export const useUpdateContainerCollections = (containerId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const libraryId = getLibraryId(containerId);
|
||||
return useMutation({
|
||||
mutationFn: async (collectionKeys: string[]) => updateContainerCollections(containerId, collectionKeys),
|
||||
mutationFn: async (collectionKeys: string[]) => api.updateContainerCollections(containerId, collectionKeys),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
@@ -719,7 +709,7 @@ export const useUpdateContainerChildren = (containerId?: string) => {
|
||||
if (!containerId) {
|
||||
return undefined;
|
||||
}
|
||||
return updateLibraryContainerChildren(containerId, usageKeys);
|
||||
return api.updateLibraryContainerChildren(containerId, usageKeys);
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!containerId) {
|
||||
@@ -744,7 +734,7 @@ export const useRemoveContainerChildren = (containerId?: string) => {
|
||||
if (!containerId) {
|
||||
return undefined;
|
||||
}
|
||||
return removeLibraryContainerChildren(containerId, usageKeys);
|
||||
return api.removeLibraryContainerChildren(containerId, usageKeys);
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!containerId) {
|
||||
@@ -758,3 +748,23 @@ export const useRemoveContainerChildren = (containerId?: string) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this mutation to publish changes to a container and any children within it
|
||||
*/
|
||||
export const usePublishContainer = (containerId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const libraryId = getLibraryId(containerId);
|
||||
return useMutation({
|
||||
mutationFn: () => api.publishContainer(containerId),
|
||||
onSettled: () => {
|
||||
// Invalidate all content-related metadata and search results for the whole library.
|
||||
// The child components/xblocks could and even the container itself could appear in many different collections
|
||||
// or other containers, so it's best to just invalidate everything.
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryContent(libraryId) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
// For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes"
|
||||
queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
|
||||
};
|
||||
|
||||
const onTagSidebarClose = () => {
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId!));
|
||||
closeManageTagsDrawer();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user