feat: Add Publish confirmation modal [FC-0076] (#1677)

This commit is contained in:
Chris Chávez
2025-03-11 17:37:40 -05:00
committed by GitHub
parent 732fd28eb9
commit 3aa409d065
5 changed files with 252 additions and 36 deletions

View File

@@ -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: () => <div>Mocked preview</div>,
@@ -91,6 +98,53 @@ describe('<ComponentInfo> Sidebar', () => {
await waitFor(() => expect(publishButton).not.toBeDisabled());
});
it('should show publish confirmation on first publish', async () => {
render(
<ComponentInfo />,
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(
<ComponentInfo />,
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(
<ComponentInfo />,
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('<ComponentInfo> 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('<ComponentInfo> 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.');
});

View File

@@ -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 (
<Stack>
{!readOnly && (
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button disabled={publishComponent.isLoading || !canPublish} onClick={publish} variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</div>
)}
<AddComponentWidget />
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey={COMPONENT_INFO_TABS.Preview}
activeKey={tab}
onSelect={setSidebarTab}
>
<Tab eventKey={COMPONENT_INFO_TABS.Preview} title={intl.formatMessage(messages.previewTabTitle)}>
<ComponentPreview />
</Tab>
<Tab eventKey={COMPONENT_INFO_TABS.Manage} title={intl.formatMessage(messages.manageTabTitle)}>
<ComponentManagement />
</Tab>
<Tab eventKey={COMPONENT_INFO_TABS.Details} title={intl.formatMessage(messages.detailsTabTitle)}>
<ComponentDetails />
</Tab>
</Tabs>
</Stack>
<>
<Stack>
{!readOnly && (
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button
disabled={publishComponent.isLoading || !canPublish}
onClick={openPublishConfirmation}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</div>
)}
<AddComponentWidget />
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey={COMPONENT_INFO_TABS.Preview}
activeKey={tab}
onSelect={setSidebarTab}
>
<Tab eventKey={COMPONENT_INFO_TABS.Preview} title={intl.formatMessage(messages.previewTabTitle)}>
<ComponentPreview />
</Tab>
<Tab eventKey={COMPONENT_INFO_TABS.Manage} title={intl.formatMessage(messages.manageTabTitle)}>
<ComponentManagement />
</Tab>
<Tab eventKey={COMPONENT_INFO_TABS.Details} title={intl.formatMessage(messages.detailsTabTitle)}>
<ComponentDetails />
</Tab>
</Tabs>
</Stack>
<PublishConfirmationModal
isOpen={isPublishConfirmationOpen}
onClose={closePublishConfirmation}
onConfirm={publish}
displayName={componentMetadata?.displayName || ''}
usageKey={usageKey}
showDownstreams={!!componentMetadata?.lastPublished}
/>
</>
);
};

View File

@@ -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 (
<BaseModal
isOpen={isOpen}
close={onClose}
title={intl.formatMessage(
messages.publishConfirmationTitle,
{ displayName },
)}
confirmAction={(
<Button onClick={onConfirm}>
<FormattedMessage {...messages.publishConfirmationButton} />
</Button>
)}
>
<div>
<div className="pt-4">
{intl.formatMessage(messages.publishConfirmationBody)}
<div className="mt-2 p-2 border">
{displayName}
</div>
</div>
{showDownstreams && (
<div className="mt-4">
{hasDownstreamUsages ? (
<>
<FormattedMessage {...messages.publishConfimrationDownstreamsBody} />
<div className="mt-3 mb-3 border">
<ComponentUsage usageKey={usageKey} />
</div>
<Alert variant="warning">
<FormattedMessage {...messages.publishConfirmationDownstreamsAlert} />
</Alert>
</>
) : (
<FormattedMessage {...infoMessages.detailsTabUsageEmpty} />
)}
</div>
)}
</div>
</BaseModal>
);
};
export default PublishConfirmationModal;

View File

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

View File

@@ -329,6 +329,8 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.Li
case thisMock.usageKeyPublishDisabled: return thisMock.dataPublishDisabled;
case thisMock.usageKeyUnsupportedXBlock: return thisMock.dataUnsupportedXBlock;
case thisMock.usageKeyForTags: return thisMock.dataPublished;
case thisMock.usageKeyPublishedWithChanges: return thisMock.dataPublishedWithChanges;
case thisMock.usageKeyPublishedWithChangesV2: return thisMock.dataPublishedWithChanges;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
}
@@ -395,6 +397,23 @@ mockLibraryBlockMetadata.dataWithCollections = {
tagsCount: 0,
collections: [{ title: 'My first collection', key: 'my-first-collection' }],
} satisfies api.LibraryBlockMetadata;
mockLibraryBlockMetadata.usageKeyPublishedWithChanges = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fvv';
mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2 = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fv2';
mockLibraryBlockMetadata.dataPublishedWithChanges = {
id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fvv',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 2',
lastPublished: '2024-06-22T00:00:00',
publishedBy: 'Luke',
lastDraftCreated: null,
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
hasUnpublishedChanges: true,
created: '2024-06-20T13:54:21Z',
modified: '2024-06-23T13:54:21Z',
tagsCount: 0,
collections: [],
} satisfies api.LibraryBlockMetadata;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockLibraryBlockMetadata.applyMock = () => 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 [];
}
}