feat: Add Publish confirmation modal [FC-0076] (#1677)
This commit is contained in:
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user