From 3aa409d065f3cef062929a889d98867ca4ba297a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Tue, 11 Mar 2025 17:37:40 -0500 Subject: [PATCH] feat: Add Publish confirmation modal [FC-0076] (#1677) --- .../component-info/ComponentInfo.test.tsx | 70 +++++++++++++- .../component-info/ComponentInfo.tsx | 93 ++++++++++++------- .../components/PublishConfirmationModal.tsx | 79 ++++++++++++++++ src/library-authoring/components/messages.ts | 25 +++++ src/library-authoring/data/api.mocks.ts | 21 +++++ 5 files changed, 252 insertions(+), 36 deletions(-) create mode 100644 src/library-authoring/components/PublishConfirmationModal.tsx diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index f60d30398..8dfb02327 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -4,7 +4,12 @@ import { screen, waitFor, } from '../../testUtils'; -import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks'; +import { + mockContentLibrary, + mockLibraryBlockMetadata, + mockComponentDownstreamLinks, +} from '../data/api.mocks'; +import { mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; import { mockBroadcastChannel } from '../../generic/data/api.mock'; import { LibraryProvider } from '../common/context/LibraryContext'; import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; @@ -14,6 +19,8 @@ import { getXBlockPublishApiUrl } from '../data/api'; mockBroadcastChannel(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockComponentDownstreamLinks.applyMock(); +mockFetchIndexDocuments.applyMock(); jest.mock('./ComponentPreview', () => ({ __esModule: true, // Required when mocking 'default' export default: () =>
Mocked preview
, @@ -91,6 +98,53 @@ describe(' Sidebar', () => { await waitFor(() => expect(publishButton).not.toBeDisabled()); }); + it('should show publish confirmation on first publish', async () => { + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished), + ); + + const publishButton = await screen.findByRole('button', { name: /Publish component/i }); + publishButton.click(); + + expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument(); + expect(screen.getByText(mockLibraryBlockMetadata.dataNeverPublished.displayName)).toBeInTheDocument(); + expect(screen.queryByText(/This content is currently being used in:/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/This component can be synced in courses after publish./i)).not.toBeInTheDocument(); + }); + + it('should show publish confirmation on already published', async () => { + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChanges), + ); + + const publishButton = await screen.findByRole('button', { name: /Publish component/i }); + await waitFor(() => expect(publishButton).not.toBeDisabled()); + publishButton.click(); + + expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument(); + expect(screen.getByText(mockLibraryBlockMetadata.dataPublishedWithChanges.displayName)).toBeInTheDocument(); + expect(screen.getByText(/This content is currently being used in:/i)).toBeInTheDocument(); + expect(screen.getByText(/This component can be synced in courses after publish./i)).toBeInTheDocument(); + }); + + it('should show publish confirmation on already published empty downstreams', async () => { + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2), + ); + + const publishButton = await screen.findByRole('button', { name: /Publish component/i }); + await waitFor(() => expect(publishButton).not.toBeDisabled()); + publishButton.click(); + + expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument(); + expect(screen.getByText(mockLibraryBlockMetadata.dataPublishedWithChanges.displayName)).toBeInTheDocument(); + expect(screen.getAllByText(/This component is not used in any course./i).length).toBe(2); + expect(screen.queryByText(/This component can be synced in courses after publish./i)).not.toBeInTheDocument(); + }); + it('should show toast message when the component is published successfully', async () => { const { axiosMock, mockShowToast } = initializeMocks(); const url = getXBlockPublishApiUrl(mockLibraryBlockMetadata.usageKeyNeverPublished); @@ -103,6 +157,13 @@ describe(' Sidebar', () => { const publishButton = await screen.findByRole('button', { name: /Publish component/i }); publishButton.click(); + // Should show publish confirmation modal + expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument(); + + // Click on confirm + const confirmButton = await screen.findByRole('button', { name: /Publish/i }); + confirmButton.click(); + await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith('Component published successfully.'); }); @@ -120,6 +181,13 @@ describe(' Sidebar', () => { const publishButton = await screen.findByRole('button', { name: /Publish component/i }); publishButton.click(); + // Should show publish confirmation modal + expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument(); + + // Click on confirm + const confirmButton = await screen.findByRole('button', { name: /Publish/i }); + confirmButton.click(); + await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith('There was an error publishing the component.'); }); diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 085e6d120..686ca14e5 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -5,6 +5,7 @@ import { Tab, Tabs, Stack, + useToggle, } from '@openedx/paragon'; import { CheckBoxIcon, @@ -29,6 +30,7 @@ import messages from './messages'; import { getBlockType } from '../../generic/key-utils'; import { useLibraryBlockMetadata, usePublishComponent } from '../data/apiHooks'; import { ToastContext } from '../../generic/toast-context'; +import PublishConfirmationModal from '../components/PublishConfirmationModal'; const AddComponentWidget = () => { const intl = useIntl(); @@ -107,6 +109,11 @@ const ComponentInfo = () => { sidebarComponentInfo, sidebarAction, } = useSidebarContext(); + const [ + isPublishConfirmationOpen, + openPublishConfirmation, + closePublishConfirmation, + ] = useToggle(false); const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; @@ -138,6 +145,7 @@ const ComponentInfo = () => { const { showToast } = React.useContext(ToastContext); const publish = React.useCallback(() => { + closePublishConfirmation(); publishComponent.mutateAsync() .then(() => { showToast(intl.formatMessage(messages.publishSuccessMsg)); @@ -147,41 +155,56 @@ const ComponentInfo = () => { }, [publishComponent, showToast, intl]); return ( - - {!readOnly && ( -
- - - -
- )} - - - - - - - - - - - - -
+ <> + + {!readOnly && ( +
+ + + +
+ )} + + + + + + + + + + + + +
+ + ); }; diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx new file mode 100644 index 000000000..bb9e40f5b --- /dev/null +++ b/src/library-authoring/components/PublishConfirmationModal.tsx @@ -0,0 +1,79 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Alert, Button } from '@openedx/paragon'; + +import BaseModal from '../../editors/sharedComponents/BaseModal'; +import messages from './messages'; +import infoMessages from '../component-info/messages'; +import { ComponentUsage } from '../component-info/ComponentUsage'; +import { useComponentDownstreamLinks } from '../data/apiHooks'; + +interface PublishConfirmationModalProps { + isOpen: boolean, + onClose: () => void, + onConfirm: () => void, + displayName: string, + usageKey: string, + showDownstreams: boolean, +} + +const PublishConfirmationModal = ({ + isOpen, + onClose, + onConfirm, + usageKey, + displayName, + showDownstreams, +}: PublishConfirmationModalProps) => { + const intl = useIntl(); + + const { + data: dataDownstreamLinks, + isLoading: isLoadingDownstreamLinks, + } = useComponentDownstreamLinks(usageKey); + + const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0; + + return ( + + + + )} + > +
+
+ {intl.formatMessage(messages.publishConfirmationBody)} +
+ {displayName} +
+
+ {showDownstreams && ( +
+ {hasDownstreamUsages ? ( + <> + +
+ +
+ + + + + ) : ( + + )} +
+ )} +
+
+ ); +}; + +export default PublishConfirmationModal; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index f1a8a7701..0cb32d195 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -146,5 +146,30 @@ const messages = defineMessages({ defaultMessage: 'Unpublished changes', description: 'Badge text shown when a component has unpublished changes', }, + publishConfirmationTitle: { + id: 'course-authoring.library-authoring.component.publish-confirmation.title', + defaultMessage: 'Publish {displayName}', + description: 'Title of the modal to confirm publish a component in a library', + }, + publishConfirmationButton: { + id: 'course-authoring.library-authoring.component.publish-confirmation.confirm', + defaultMessage: 'Publish', + description: 'Text in confirmation button of the modal to confirm publish a component in a library', + }, + publishConfirmationBody: { + id: 'course-authoring.library-authoring.component.publish-confirmation.body', + defaultMessage: 'Publish all unpublished changes for this component?', + description: 'Body text of the modal to confirm publish a component in a library', + }, + publishConfimrationDownstreamsBody: { + id: 'course-authoring.library-authoring.component.publish-confirmation.downsteams-body', + defaultMessage: 'This content is currently being used in:', + description: 'Body text to show downstreams of the modal to confirm publish a component in a library', + }, + publishConfirmationDownstreamsAlert: { + id: 'course-authoring.library-authoring.component.publish-confirmation.downsteams-alert', + defaultMessage: 'This component can be synced in courses after publish.', + description: 'Alert text of the modal to confirm publish a component in a library.', + }, }); export default messages; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 0eda4a83f..d93f25b5b 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -329,6 +329,8 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata); @@ -556,6 +575,8 @@ export async function mockComponentDownstreamLinks( const thisMock = mockComponentDownstreamLinks; switch (usageKey) { case thisMock.usageKey: return thisMock.componentUsage; + case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.componentUsage; + case mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2: return thisMock.emptyComponentUsage; default: return []; } }