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 && (
-
- openComponentEditor(usageKey) } : { disabled: true })}
- variant="outline-primary"
- className="m-1 text-nowrap flex-grow-1"
- >
- {intl.formatMessage(messages.editComponentButtonTitle)}
-
-
- {intl.formatMessage(messages.publishComponentButtonTitle)}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
+ <>
+
+ {!readOnly && (
+
+ openComponentEditor(usageKey) } : { disabled: true })}
+ variant="outline-primary"
+ className="m-1 text-nowrap flex-grow-1"
+ >
+ {intl.formatMessage(messages.editComponentButtonTitle)}
+
+
+ {intl.formatMessage(messages.publishComponentButtonTitle)}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
);
};
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 [];
}
}