From ea0a031d7b6f206a00c5acdb12ea7f9c7dc2dc7a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 18 Apr 2025 07:34:46 -0700 Subject: [PATCH] feat: button to publish a container [FC-0083] (#1827) - Publish button with functionality of publish units and components inside the unit --- .../containers/UnitInfo.test.tsx | 38 +++++++- src/library-authoring/containers/UnitInfo.tsx | 38 ++++++-- src/library-authoring/containers/messages.ts | 15 ++++ src/library-authoring/data/api.ts | 14 +++ src/library-authoring/data/apiHooks.test.tsx | 14 +++ src/library-authoring/data/apiHooks.ts | 88 +++++++++++-------- .../units/LibraryUnitBlocks.tsx | 2 +- 7 files changed, 160 insertions(+), 49 deletions(-) diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx index b93437664..677063f85 100644 --- a/src/library-authoring/containers/UnitInfo.test.tsx +++ b/src/library-authoring/containers/UnitInfo.test.tsx @@ -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(, { extraWrapper: ({ children }) => ( ', () => { ({ 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('', () => { }); 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'); + }); }); diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index a991f3d16..164962fcc 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -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 ( - {showOpenUnitButton && ( -
+
+ {showOpenUnitButton && ( + )} + {!componentPickerMode && !readOnly && ( + + )} + {showOpenUnitButton && ( // Check: should we still show this on the unit page? -
- )} + )} +
`${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)); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 3f2a1b5d1..83153de5a 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -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); + }); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 317bf43a8..3a6effe4e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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 }); + }, + }); +}; diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 39ed5fddb..87aacfa04 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -129,7 +129,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { }; const onTagSidebarClose = () => { - queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId)); + queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId!)); closeManageTagsDrawer(); };