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:
Braden MacDonald
2025-04-18 07:34:46 -07:00
committed by GitHub
parent ea8a8e5285
commit ea0a031d7b
7 changed files with 160 additions and 49 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,7 +129,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
};
const onTagSidebarClose = () => {
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId!));
closeManageTagsDrawer();
};