diff --git a/src/generic/Loading.jsx b/src/generic/Loading.tsx similarity index 72% rename from src/generic/Loading.jsx rename to src/generic/Loading.tsx index 8262e89b33..19e2d83580 100644 --- a/src/generic/Loading.jsx +++ b/src/generic/Loading.tsx @@ -1,9 +1,11 @@ -import React from 'react'; -import PropTypes from 'prop-types'; import { Spinner } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -export const LoadingSpinner = ({ size }) => ( +interface LoadingSpinnerProps { + size?: string; +} + +export const LoadingSpinner = ({ size }: LoadingSpinnerProps) => ( ( /> ); -LoadingSpinner.defaultProps = { - size: undefined, -}; - -LoadingSpinner.propTypes = { - size: PropTypes.string, -}; - const Loading = () => (
diff --git a/src/library-authoring/containers/ContainerInfo.test.tsx b/src/library-authoring/containers/ContainerInfo.test.tsx index 75c5cc91b3..048cd962a3 100644 --- a/src/library-authoring/containers/ContainerInfo.test.tsx +++ b/src/library-authoring/containers/ContainerInfo.test.tsx @@ -4,7 +4,9 @@ import type MockAdapter from 'axios-mock-adapter'; import { initializeMocks, render as baseRender, screen, waitFor, fireEvent, -} from '../../testUtils'; +} from '@src/testUtils'; +import { PublishStatus } from '@src/search-manager'; +import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock'; import { mockContentLibrary, mockGetContainerChildren, mockGetContainerMetadata } from '../data/api.mocks'; import { LibraryProvider } from '../common/context/LibraryContext'; import ContainerInfo from './ContainerInfo'; @@ -15,6 +17,7 @@ mockGetContainerMetadata.applyMock(); mockContentLibrary.applyMock(); mockGetContainerMetadata.applyMock(); mockGetContainerChildren.applyMock(); +mockContentSearchConfig.applyMock(); // TODO Remove this to un-skip section/subsection tests, when implemented const testIf = (condition) => (condition ? it : it.skip); @@ -22,10 +25,18 @@ const testIf = (condition) => (condition ? it : it.skip); const { libraryId } = mockContentLibrary; const { unitId, subsectionId, sectionId } = mockGetContainerMetadata; -const render = (containerId, showOnlyPublished: boolean = false) => { +const render = ( + containerId, + containerType: string = '', // renders container page + showOnlyPublished: boolean = false, +) => { const params: { libraryId: string, selectedItemId?: string } = { libraryId, selectedItemId: containerId }; + const path = containerType + ? `/library/:libraryId/${containerType.toLowerCase()}/:selectedItemId?` + : '/library/:libraryId/:selectedItemId?'; + return baseRender(, { - path: '/library/:libraryId/:selectedItemId?', + path, params, extraWrapper: ({ children }) => ( ', () => { beforeEach(() => { ({ axiosMock, mockShowToast } = initializeMocks()); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [], + }, + ], + }); }); [ { containerType: 'Unit', containerId: unitId, + childType: 'component', }, { containerType: 'Subsection', containerId: subsectionId, + childType: 'unit', }, { containerType: 'Section', containerId: sectionId, + childType: 'subsection', }, - ].forEach(({ containerId, containerType }) => { + ].forEach(({ containerId, containerType, childType }) => { testIf(containerType === 'Unit')(`should delete the ${containerType} using the menu`, async () => { axiosMock.onDelete(getLibraryContainerApiUrl(containerId)).reply(200); render(containerId); @@ -90,14 +111,74 @@ describe('', () => { expect(mockShowToast).toHaveBeenCalled(); }); - it('can publish the container', async () => { + it(`shows Published if the ${containerType} has no draft changes`, async () => { axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200); - render(containerId); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [ + { + type: 'library_container', + usageKey: containerId, + blockType: containerType.toLowerCase(), + publishStatus: PublishStatus.Published, + }, + ], + }, + ], + }); + render(containerId, containerType); + + // "Published" status should be displayed + const publishedStatus = await screen.findByText('Published'); + expect(publishedStatus).toBeInTheDocument(); + }); + + it(`can publish the ${containerType} from the container page`, async () => { + axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [ + { + type: 'library_container', + usageKey: containerId, + blockType: containerType.toLowerCase(), + publishStatus: PublishStatus.Modified, + }, + ], + }, + ], + }); + render(containerId, containerType); // Click on Publish button - const publishButton = await screen.findByRole('button', { name: 'Publish' }); + let publishButton = await screen.findByRole('button', { name: /publish changes/i }); expect(publishButton).toBeInTheDocument(); userEvent.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Reveals the confirmation box with warning text + expect(await screen.findByText( + `Are you sure you want to publish this ${containerType.toLowerCase()}?`, + )).toBeInTheDocument(); + + // Click on the confirm Cancel button + const publishCancel = await screen.findByRole('button', { name: 'Cancel' }); + expect(publishCancel).toBeInTheDocument(); + userEvent.click(publishCancel); + expect(axiosMock.history.post.length).toBe(0); + + // Click on Publish button again + publishButton = await screen.findByRole('button', { name: /publish changes/i }); + expect(publishButton).toBeInTheDocument(); + userEvent.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Click on the confirm Publish button + const publishConfirm = await screen.findByRole('button', { name: 'Publish' }); + expect(publishConfirm).toBeInTheDocument(); + userEvent.click(publishConfirm); await waitFor(() => { expect(axiosMock.history.post.length).toBe(1); @@ -107,12 +188,32 @@ describe('', () => { it(`shows an error if publishing the ${containerType} fails`, async () => { axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(500); - render(containerId); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [ + { + type: 'library_container', + usageKey: containerId, + blockType: containerType.toLowerCase(), + publishStatus: PublishStatus.Modified, + }, + ], + }, + ], + }); + render(containerId, containerType); - // Click on Publish button - const publishButton = await screen.findByRole('button', { name: 'Publish' }); + // Click on Publish button to reveal the confirmation box + const publishButton = await screen.findByRole('button', { name: /publish changes/i }); expect(publishButton).toBeInTheDocument(); userEvent.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Click on the confirm Publish button + const publishConfirm = await screen.findByRole('button', { name: 'Publish' }); + expect(publishConfirm).toBeInTheDocument(); + userEvent.click(publishConfirm); await waitFor(() => { expect(axiosMock.history.post.length).toBe(1); @@ -120,8 +221,78 @@ describe('', () => { expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes'); }); + it(`shows single child before publishing the ${containerType}`, async () => { + axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [ + { + type: 'library_container', + usageKey: containerId, + blockType: containerType.toLowerCase(), + publishStatus: PublishStatus.Modified, + content: { + childDisplayNames: [ + 'one', + ], + }, + }, + ], + }, + ], + }); + render(containerId, containerType); + + // Click on Publish button + const publishButton = await screen.findByRole('button', { name: /publish changes/i }); + expect(publishButton).toBeInTheDocument(); + userEvent.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Check warning text in the confirmation box + expect(await screen.findByText( + `This ${containerType.toLowerCase()} and its 1 ${childType} will all be published.`, + )).toBeInTheDocument(); + }); + + it(`shows child count before publishing the ${containerType}`, async () => { + axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [ + { + type: 'library_container', + usageKey: containerId, + blockType: containerType.toLowerCase(), + publishStatus: PublishStatus.Modified, + content: { + childDisplayNames: [ + 'one', 'two', + ], + }, + }, + ], + }, + ], + }); + render(containerId, containerType); + + // Click on Publish button + const publishButton = await screen.findByRole('button', { name: /publish changes/i }); + expect(publishButton).toBeInTheDocument(); + userEvent.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Check warning text in the confirmation box + expect(await screen.findByText( + `This ${containerType.toLowerCase()} and its 2 ${childType}s will all be published.`, + )).toBeInTheDocument(); + }); + testIf(containerType === 'Unit')(`show only published ${containerType} content`, async () => { - render(containerId, true); + render(containerId, '', true); expect(await screen.findByTestId('container-info-menu-toggle')).toBeInTheDocument(); expect(screen.getByText(/text block published 1/i)).toBeInTheDocument(); }); diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index 8be15f53dd..08a954def2 100644 --- a/src/library-authoring/containers/ContainerInfo.tsx +++ b/src/library-authoring/containers/ContainerInfo.tsx @@ -13,6 +13,7 @@ import React, { useCallback } from 'react'; import { Link } from 'react-router-dom'; import { MoreVert } from '@openedx/paragon/icons'; +import { ContainerType, getBlockType } from '@src/generic/key-utils'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; import { @@ -28,9 +29,8 @@ import { LibraryContainerChildren } from '../section-subsections/LibraryContaine import messages from './messages'; import componentMessages from '../components/messages'; import ContainerDeleter from '../components/ContainerDeleter'; -import { useContainer, usePublishContainer } from '../data/apiHooks'; -import { ContainerType, getBlockType } from '../../generic/key-utils'; -import { ToastContext } from '../../generic/toast-context'; +import ContainerPublishStatus from './ContainerPublishStatus'; +import { useContainer } from '../data/apiHooks'; type ContainerMenuProps = { containerId: string, @@ -85,9 +85,8 @@ const ContainerPreview = ({ containerId } : ContainerPreviewProps) => { const ContainerInfo = () => { const intl = useIntl(); - const { libraryId, readOnly } = useLibraryContext(); + const { libraryId } = useLibraryContext(); const { componentPickerMode } = useComponentPickerContext(); - const { showToast } = React.useContext(ToastContext); const { defaultTab, hiddenTabs, @@ -101,7 +100,6 @@ const ContainerInfo = () => { const containerId = sidebarItemInfo?.id; const containerType = containerId ? getBlockType(containerId) : undefined; const { data: container } = useContainer(containerId); - const publishContainer = usePublishContainer(containerId!); const defaultContainerTab = defaultTab.container; const tab: ContainerInfoTab = ( @@ -130,15 +128,6 @@ const ContainerInfo = () => { ); }, [hiddenTabs, defaultContainerTab, containerId]); - const handlePublish = useCallback(async () => { - try { - await publishContainer.mutateAsync(); - showToast(intl.formatMessage(messages.publishContainerSuccess)); - } catch (error) { - showToast(intl.formatMessage(messages.publishContainerFailed)); - } - }, [publishContainer]); - if (!container || !containerId || !containerType) { return null; } @@ -156,15 +145,10 @@ const ContainerInfo = () => { {intl.formatMessage(messages.openButton)} )} - {!componentPickerMode && !readOnly && ( - + {!showOpenButton && !componentPickerMode && ( + )} {showOpenButton && ( void; + container: ContainerHit; +}; + +const ContainerPublisher = ({ + close, + container, +}: ContainerPublisherProps) => { + const intl = useIntl(); + const publishContainer = usePublishContainer(container.usageKey); + const { showToast } = useContext(ToastContext); + + const handlePublish = useCallback(async () => { + try { + await publishContainer.mutateAsync(); + showToast(intl.formatMessage(messages.publishContainerSuccess)); + } catch (error) { + showToast(intl.formatMessage(messages.publishContainerFailed)); + } + close(); + }, [publishContainer, showToast]); + + const warningMessage = useMemo(() => { + const childCount = container.content?.childDisplayNames?.length || 0; + let childMessage: MessageDescriptor; + let noChildMessage: MessageDescriptor; + + switch (container.blockType) { + case ContainerType.Section: + childMessage = messages.publishSectionWithChildrenWarning; + noChildMessage = messages.publishSectionWarning; + break; + case ContainerType.Subsection: + childMessage = messages.publishSubsectionWithChildrenWarning; + noChildMessage = messages.publishSubsectionWarning; + break; + default: // ContainerType.Unit + childMessage = messages.publishUnitWithChildrenWarning; + noChildMessage = messages.publishUnitWarning; + } + return intl.formatMessage( + childCount ? childMessage : noChildMessage, + { childCount }, + ); + }, [container]); + + return ( + +

{intl.formatMessage(messages.publishContainerConfirmHeading)}

+

{warningMessage}

+ + + { + e.preventDefault(); + e.stopPropagation(); + await handlePublish(); + }} + variant="primary rounded-0" + label={intl.formatMessage(messages.publishContainerConfirm)} + /> + +
+ ); +}; + +type ContainerPublishStatusProps = { + containerId: string; +}; + +const ContainerPublishStatus = ({ + containerId, +}: ContainerPublishStatusProps) => { + const intl = useIntl(); + const { readOnly } = useLibraryContext(); + const [isConfirmingPublish, confirmPublish, cancelPublish] = useToggle(false); + const { hits, isLoading, isError } = useContentFromSearchIndex([containerId]); + + if (isLoading) { + return ; + } + + // istanbul ignore if: this should never happen + if (isError || !hits) { + return null; + } + + // TODO -- why isn't this auto-updating when the container gets modified or published? + const container = (hits as ContainerHit[])[0]; + if (container.publishStatus === PublishStatus.Published) { + return ( + + {intl.formatMessage(messages.publishedChipText)} + + ); + } + + return ( + (isConfirmingPublish + ? ( + + ) : ( + + ) + ) + ); +}; + +export default ContainerPublishStatus; diff --git a/src/library-authoring/containers/index.scss b/src/library-authoring/containers/index.scss new file mode 100644 index 0000000000..46bba7d588 --- /dev/null +++ b/src/library-authoring/containers/index.scss @@ -0,0 +1 @@ +@import "./ContainerPublishStatus.scss"; diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 1dec3cafc1..5a97e0c8a9 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -1,6 +1,16 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + draftChipText: { + id: 'course-authoring.library-authoring.container-component.draft-chip.text', + defaultMessage: 'Draft', + description: 'Chip in children in section and subsection page that is shown when children has unpublished changes', + }, + publishedChipText: { + id: 'course-authoring.library-authoring.container-component.published-chip.text', + defaultMessage: 'Published', + description: 'Text shown when a unit/section/subsection is published.', + }, openButton: { id: 'course-authoring.library-authoring.container-sidebar.open-button', defaultMessage: 'Open', @@ -28,8 +38,23 @@ const messages = defineMessages({ }, publishContainerButton: { id: 'course-authoring.library-authoring.container-sidebar.publish-button', + defaultMessage: 'Publish Changes {publishStatus}', + description: 'Button text to initiate publish the unit/subsection/section, showing current publish status', + }, + publishContainerConfirmHeading: { + id: 'course-authoring.library-authoring.container-sidebar.publish-confirm-heading', + defaultMessage: 'Confirm Publish', + description: 'Header text shown while confirming publish of a unit/subsection/section', + }, + publishContainerConfirm: { + id: 'course-authoring.library-authoring.container-sidebar.publish-confirm-button', defaultMessage: 'Publish', - description: 'Button text to publish the unit/subsection/section', + description: 'Button text shown to confirm publish of a unit/subsection/section', + }, + publishContainerCancel: { + id: 'course-authoring.library-authoring.container-sidebar.publish-cancel', + defaultMessage: 'Cancel', + description: 'Button text shown to cancel publish of a unit/subsection/section', }, publishContainerSuccess: { id: 'course-authoring.library-authoring.container-sidebar.publish-success', @@ -41,6 +66,36 @@ const messages = defineMessages({ defaultMessage: 'Failed to publish changes', description: 'Popup text seen if publishing a unit/subsection/section fails', }, + publishSectionWarning: { + id: 'course-authoring.library-authoring.section-sidebar.publish-empty-warning', + defaultMessage: 'Are you sure you want to publish this section?', + description: 'Content details shown before publishing a empty section', + }, + publishSectionWithChildrenWarning: { + id: 'course-authoring.library-authoring.section-sidebar.publish-warning', + defaultMessage: 'This section and its {childCount, plural, one {{childCount} subsection} other {{childCount} subsections}} will all be published.', + description: 'Content details shown before publishing a section', + }, + publishSubsectionWarning: { + id: 'course-authoring.library-authoring.subsection-sidebar.publish-empty-warning', + defaultMessage: 'Are you sure you want to publish this subsection?', + description: 'Content details shown before publishing an empty subsection', + }, + publishSubsectionWithChildrenWarning: { + id: 'course-authoring.library-authoring.subsection-sidebar.publish-warning', + defaultMessage: 'This subsection and its {childCount, plural, one {{childCount} unit} other {{childCount} units}} will all be published.', + description: 'Content details shown before publishing a subsection', + }, + publishUnitWarning: { + id: 'course-authoring.library-authoring.unit-sidebar.publish-empty-warning', + defaultMessage: 'Are you sure you want to publish this unit?', + description: 'Content details shown before publishing an empty unit', + }, + publishUnitWithChildrenWarning: { + id: 'course-authoring.library-authoring.unit-sidebar.publish-warning', + defaultMessage: 'This unit and its {childCount, plural, one {{childCount} component} other {{childCount} components}} will all be published.', + description: 'Content details shown before publishing a unit', + }, settingsTabTitle: { id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title', defaultMessage: 'Settings', diff --git a/src/library-authoring/generic/status-widget/StatusWidget.scss b/src/library-authoring/generic/status-widget/StatusWidget.scss index 7253940ed1..fcbe24c527 100644 --- a/src/library-authoring/generic/status-widget/StatusWidget.scss +++ b/src/library-authoring/generic/status-widget/StatusWidget.scss @@ -1,12 +1,26 @@ +%draft-status { + background-color: #FDF3E9; + border-color: #F4B57B !important; + color: #00262B; +} + +%published-status { + background-color: var(--pgn-color-info-100); + border-color: var(--pgn-color-info-400) !important; + color: var(--pgn-color-primary-500); +} + .status-widget { + border-top: 4px solid; + border-left: none; + border-right: none; + border-bottom: none; + &.draft-status { - background-color: #FDF3E9; - border-top: 4px solid #F4B57B; + @extend %draft-status; } &.published-status { - background-color: var(--pgn-color-info-100); - border-top: 4px solid var(--pgn-color-info-400); + @extend %published-status; } } - diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 1de9533738..a93dd477bd 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -4,6 +4,7 @@ @import "./LibraryAuthoringPage"; @import "./units"; @import "./section-subsections"; +@import "./containers"; .library-cards-grid { display: grid; diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx index cd29078c1c..257cf24f66 100644 --- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -80,7 +80,7 @@ const ContainerRow = ({ container, readOnly }: ContainerRowProps) => { > - + )} diff --git a/src/library-authoring/section-subsections/messages.ts b/src/library-authoring/section-subsections/messages.ts index 896b4cbf6a..5b038dbc66 100644 --- a/src/library-authoring/section-subsections/messages.ts +++ b/src/library-authoring/section-subsections/messages.ts @@ -16,11 +16,6 @@ export const messages = defineMessages({ defaultMessage: 'Failed to update children order', description: 'Toast message displayed when reordering of children items in container fails', }, - draftChipText: { - id: 'course-authoring.library-authoring.container-component.draft-chip.text', - defaultMessage: 'Draft', - description: 'Chip in children in section and subsection page that is shown when children has unpublished changes', - }, }); export const sectionMessages = defineMessages({