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/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index 34a2346c88..aedb48c14e 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -16,6 +16,7 @@ import { Folder, ViewCarousel, ViewDay, + Widgets, WidthWide, } from '@openedx/paragon/icons'; import NewsstandIcon from '../NewsstandIcon'; @@ -43,6 +44,7 @@ export const UNIT_TYPE_ICONS_MAP: Record = { chapter: ViewCarousel, problem: EditIcon, lock: LockIcon, + multiple: Widgets, }; export const COMPONENT_TYPE_ICON_MAP: Record = { @@ -65,6 +67,7 @@ export const STRUCTURAL_TYPE_ICONS: Record = { subsection: UNIT_TYPE_ICONS_MAP.sequential, chapter: UNIT_TYPE_ICONS_MAP.chapter, section: UNIT_TYPE_ICONS_MAP.chapter, + components: UNIT_TYPE_ICONS_MAP.multiple, collection: Folder, libraryContent: Folder, paste: ContentPasteIcon, diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 3e219292c7..6fc9b6ec7b 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -70,4 +70,9 @@ export enum ContainerType { Chapter = 'chapter', Sequential = 'sequential', Vertical = 'vertical', + /** + * Components are not strictly a container type, but we add this here for simplicity when rendering the container + * hierarchy. + */ + Components = 'components', } diff --git a/src/library-authoring/containers/ContainerHierarchy.scss b/src/library-authoring/containers/ContainerHierarchy.scss new file mode 100644 index 0000000000..fed123b323 --- /dev/null +++ b/src/library-authoring/containers/ContainerHierarchy.scss @@ -0,0 +1,54 @@ +.content-hierarchy { + margin-bottom: var(--pgn-spacing-paragraph-margin-bottom); + + .hierarchy-row { + border: 1px solid var(--pgn-color-light-500); + border-radius: 4px; + background-color: var(--pgn-color-white); + padding: 0; + margin: 0; + + &.selected { + border: 3px solid var(--pgn-color-primary-500); + border-radius: 4px; + } + + .icon { + background-color: var(--pgn-color-light-300); + border-top: 2px solid var(--pgn-color-light-300); + border-bottom: 2px solid var(--pgn-color-light-300); + border-right: 1px solid var(--pgn-color-light-500); + border-radius: 1px 0 0 1px; + padding: 8px 12px; + } + + &.selected .icon { + background-color: var(--pgn-color-primary-500); + border-color: var(--pgn-color-primary-500); + color: var(--pgn-color-white); + } + + .text { + padding: 8px 12px; + flex-grow: 2; + } + + .publish-status { + background-color: var(--pgn-color-info-200); + white-space: nowrap; + padding: 8px 12px; + } + } + + .hierarchy-arrow { + color: var(--pgn-color-light-500); + padding: 0 0 0 14px; + position: relative; + top: -4px; + height: 20px; + + &.selected { + color: var(--pgn-color-primary-500); + } + } +} diff --git a/src/library-authoring/containers/ContainerHierarchy.tsx b/src/library-authoring/containers/ContainerHierarchy.tsx new file mode 100644 index 0000000000..937585a1e3 --- /dev/null +++ b/src/library-authoring/containers/ContainerHierarchy.tsx @@ -0,0 +1,211 @@ +import type { MessageDescriptor } from 'react-intl'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Container, Icon, Stack } from '@openedx/paragon'; +import { ArrowDownward, Check, Description } from '@openedx/paragon/icons'; +import classNames from 'classnames'; +import { getItemIcon } from '@src/generic/block-type-utils'; +import Loading from '@src/generic/Loading'; +import { ContainerType } from '@src/generic/key-utils'; +import type { ContainerHierarchyMember } from '../data/api'; +import { useContainerHierarchy } from '../data/apiHooks'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import messages from './messages'; + +const ContainerHierarchyRow = ({ + containerType, + text, + selected, + showArrow, + willPublish = false, + publishMessage = undefined, +}: { + containerType: ContainerType, + text: string, + selected: boolean, + showArrow: boolean, + willPublish?: boolean, + publishMessage?: MessageDescriptor, +}) => ( + + + +
+ +
+
+ {text} +
+ {publishMessage && ( + + + + + )} +
+
+ {showArrow && ( +
+ +
+ )} +
+); + +const ContainerHierarchy = ({ + showPublishStatus = false, +}: { + showPublishStatus?: boolean, +}) => { + const intl = useIntl(); + const { sidebarItemInfo } = useSidebarContext(); + const containerId = sidebarItemInfo?.id; + + // istanbul ignore if: this should never happen + if (!containerId) { + throw new Error('containerId is required'); + } + + const { + data, + isLoading, + isError, + } = useContainerHierarchy(containerId); + + if (isLoading) { + return ; + } + + // istanbul ignore if: this should never happen + if (isError) { + return null; + } + + const { + sections, + subsections, + units, + components, + } = data; + + // Returns a message describing the publish status of the given hierarchy row. + const publishMessage = (contents: ContainerHierarchyMember[]) => { + // If we're not showing publish status, then we don't need a publish message + if (!showPublishStatus) { + return undefined; + } + + // If any item has unpublished changes, mark this row as Draft. + if (contents.some((item) => item.hasUnpublishedChanges)) { + return messages.draftChipText; + } + + // Otherwise, it's Published + return messages.publishedChipText; + }; + + // Returns True if any of the items in the list match the currently selected container. + const selected = (contents: ContainerHierarchyMember[]): boolean => ( + contents.some((item) => item.id === containerId) + ); + + // Use the "selected" status to determine the selected row. + // If showPublishStatus, that row and its children will be marked "willPublish". + const selectedSections = selected(sections); + const selectedSubsections = selected(subsections); + const selectedUnits = selected(units); + const selectedComponents = selected(components); + + const showSections = sections && sections.length > 0; + const showSubsections = subsections && subsections.length > 0; + const showUnits = units && units.length > 0; + const showComponents = components && components.length > 0; + + return ( + + {showSections && ( + + )} + {showSubsections && ( + + )} + {showUnits && ( + + )} + {showComponents && ( + + )} + + ); +}; + +export default ContainerHierarchy; diff --git a/src/library-authoring/containers/ContainerInfo.test.tsx b/src/library-authoring/containers/ContainerInfo.test.tsx index 554d028e0b..db4d9d4c93 100644 --- a/src/library-authoring/containers/ContainerInfo.test.tsx +++ b/src/library-authoring/containers/ContainerInfo.test.tsx @@ -7,9 +7,14 @@ import { within, } from '@src/testUtils'; import { ContainerType } from '@src/generic/key-utils'; -import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock'; import type { ToastActionData } from '@src/generic/toast-context'; -import { mockContentLibrary, mockGetContainerChildren, mockGetContainerMetadata } from '../data/api.mocks'; +import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock'; +import { + mockContentLibrary, + mockGetContainerChildren, + mockGetContainerMetadata, + mockGetContainerHierarchy, +} from '../data/api.mocks'; import { LibraryProvider } from '../common/context/LibraryContext'; import ContainerInfo from './ContainerInfo'; import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api'; @@ -19,9 +24,68 @@ mockContentLibrary.applyMock(); mockContentSearchConfig.applyMock(); mockGetContainerMetadata.applyMock(); mockGetContainerChildren.applyMock(); +mockGetContainerHierarchy.applyMock(); const { libraryId } = mockContentLibrary; -const { unitId, subsectionId, sectionId } = mockGetContainerMetadata; +const { + unitId, + subsectionId, + sectionId, + unitIdEmpty, + subsectionIdEmpty, + sectionIdEmpty, + unitIdPublished, + subsectionIdPublished, + sectionIdPublished, +} = mockGetContainerMetadata; + +const { + unitIdOneChild, + subsectionIdOneChild, + sectionIdOneChild, +} = mockGetContainerHierarchy; + +// Convert a given containerId to its "empty" equivalent +const emptyId = (id: string) => { + switch (id) { + case unitId: + return unitIdEmpty; + case subsectionId: + return subsectionIdEmpty; + case sectionId: + return sectionIdEmpty; + default: + return undefined; + } +}; + +// Convert a given containerId to its "published" equivalent +const publishedId = (id: string) => { + switch (id) { + case unitId: + return unitIdPublished; + case subsectionId: + return subsectionIdPublished; + case sectionId: + return sectionIdPublished; + default: + return undefined; + } +}; + +// Convert a given containerId to its "one child" equivalent +const singleChild = (id: string) => { + switch (id) { + case unitId: + return unitIdOneChild; + case subsectionId: + return subsectionIdOneChild; + case sectionId: + return sectionIdOneChild; + default: + return undefined; + } +}; const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -29,10 +93,18 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); -const render = (containerId: string, 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}/:selectedItemId?` + : '/library/:libraryId/:selectedItemId?'; + return baseRender(, { - path: '/library/:libraryId/:selectedItemId?', + path, params, extraWrapper: ({ children }) => ( { +].forEach(({ + containerId, + containerType, + childType, + willPublishCount, + parentType, + parentCount, +}) => { describe(` with containerType: ${containerType}`, () => { beforeEach(() => { ({ axiosMock, mockShowToast } = initializeMocks()); @@ -108,15 +199,55 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo expect(mockShowToast).toHaveBeenCalled(); }); - it('can publish the container', async () => { + it(`shows Published if the ${containerType} has no draft changes`, async () => { + render(publishedId(containerId), containerType); + + // "Published" status should be displayed + expect(await screen.findByText('Published')).toBeInTheDocument(); + }); + + it(`can publish the ${containerType} from the container page`, async () => { const user = userEvent.setup(); axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200); - render(containerId); + 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(); + await user.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Reveals the confirmation box with warning text and publish hierarchy + expect(await screen.findByText('Confirm Publish')).toBeInTheDocument(); + expect(screen.getByText(new RegExp( + `This ${containerType} and the ${childType}s it contains will all be`, // published + 'i', + ))).toBeInTheDocument(); + if (parentCount > 0) { + expect(screen.getByText(new RegExp( + `Its parent ${parentType}s will be`, // draft + 'i', + ))).toBeInTheDocument(); + } + expect(await screen.queryAllByText('Will Publish').length).toBe(willPublishCount); + expect(await screen.queryAllByText('Draft').length).toBe(4 - willPublishCount); + + // Click on the confirm Cancel button + const publishCancel = await screen.findByRole('button', { name: 'Cancel' }); + expect(publishCancel).toBeInTheDocument(); + await user.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(); await user.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Click on the confirm Publish button + const publishConfirm = await screen.findByRole('button', { name: 'Publish' }); + expect(publishConfirm).toBeInTheDocument(); + await user.click(publishConfirm); await waitFor(() => { expect(axiosMock.history.post.length).toBe(1); @@ -127,12 +258,18 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo it(`shows an error if publishing the ${containerType} fails`, async () => { const user = userEvent.setup(); axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(500); - render(containerId); + 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(); await user.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Click on the confirm Publish button + const publishConfirm = await screen.findByRole('button', { name: 'Publish' }); + expect(publishConfirm).toBeInTheDocument(); + await user.click(publishConfirm); await waitFor(() => { expect(axiosMock.history.post.length).toBe(1); @@ -140,10 +277,49 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes'); }); + it(`shows single child / parent message before publishing the ${containerType}`, async () => { + const user = userEvent.setup(); + render(singleChild(containerId), containerType); + + // Click on Publish button + const publishButton = await screen.findByRole('button', { name: /publish changes/i }); + expect(publishButton).toBeInTheDocument(); + await user.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Check warning text in the confirmation box + expect(screen.getByText(new RegExp( + `This ${containerType} and the ${childType} it contains will all be`, // published + 'i', + ))).toBeInTheDocument(); + if (parentCount) { + expect(screen.getByText(new RegExp( + `Its parent ${parentType} will be`, // draft + 'i', + ))).toBeInTheDocument(); + } + }); + + it(`omits child count before publishing an empty ${containerType}`, async () => { + const user = userEvent.setup(); + render(emptyId(containerId), containerType); + + // Click on Publish button + const publishButton = await screen.findByRole('button', { name: /publish changes/i }); + expect(publishButton).toBeInTheDocument(); + await user.click(publishButton); + expect(publishButton).not.toBeInTheDocument(); + + // Check warning text in the confirmation box + expect(await screen.findByText(new RegExp( + `This ${containerType} will be`, // published + 'i', + ))).toBeInTheDocument(); + }); + it(`show only published ${containerType} content`, async () => { - render(containerId, true); - expect(await screen.findByTestId('container-info-menu-toggle')).toBeInTheDocument(); - expect(screen.getByText(/block published 1/i)).toBeInTheDocument(); + render(containerId, containerType, true); + expect(await screen.findByText(/block published 1/i)).toBeInTheDocument(); }); it(`shows the ${containerType} Preview tab by default and the children are readonly`, async () => { @@ -162,22 +338,10 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo // Check that there are no menu buttons for components expect(screen.queryAllByRole('button', { name: /component actions menu/i }).length).toBe(0); - let childType: string; - switch (containerType) { - case ContainerType.Section: - childType = ContainerType.Subsection; - break; - case ContainerType.Subsection: - childType = ContainerType.Unit; - break; - case ContainerType.Unit: - childType = 'text'; - break; - default: - break; - } - const child = await screen.findByText(`${childType!} block 0`); - screen.debug(child.parentElement!.parentElement!.parentElement!); + // If the childType is a component, it should be displayed as a text block + const childTypeDisplayName = childType === 'component' ? 'text' : childType; + const child = await screen.findByText(`${childTypeDisplayName} block 0`); + // Check that there are no menu buttons for containers expect(within( child.parentElement!.parentElement!.parentElement!, @@ -187,5 +351,30 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo // Click should not do anything in preview expect(mockNavigate).not.toHaveBeenCalled(); }); + + it(`shows the ${containerType} hierarchy in the Usage tab`, async () => { + const user = userEvent.setup(); + render(containerId, containerType); + const usageTab = await screen.findByText('Usage'); + await user.click(usageTab); + expect(usageTab).toHaveAttribute('aria-selected', 'true'); + + // Content hierarchy selects the current containerType and shows its display name + expect(await screen.findByText('Content Hierarchy')).toBeInTheDocument(); + const container = await screen.findByText(`${containerType} block 0`); + expect(container.parentElement!.parentElement).toHaveClass('selected'); + + // Other container types should show counts + if (containerType !== 'section') { + expect(await screen.findByText('2 Sections')).toBeInTheDocument(); + } + if (containerType !== 'subsection') { + expect(await screen.findByText('3 Subsections')).toBeInTheDocument(); + } + if (containerType !== 'unit') { + expect(await screen.findByText('4 Units')).toBeInTheDocument(); + } + expect(await screen.findByText('5 Components')).toBeInTheDocument(); + }); }); }); diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index 76b13b4b91..29156197ed 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 { @@ -22,14 +23,14 @@ import { useSidebarContext, } from '../common/context/SidebarContext'; import ContainerOrganize from './ContainerOrganize'; +import ContainerUsage from './ContainerUsage'; import { useLibraryRoutes } from '../routes'; import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks'; import { LibraryContainerChildren } from '../section-subsections/LibraryContainerChildren'; import messages from './messages'; -import { useContainer, usePublishContainer } from '../data/apiHooks'; -import { ContainerType, getBlockType } from '../../generic/key-utils'; -import { ToastContext } from '../../generic/toast-context'; +import { useContainer } from '../data/apiHooks'; import ContainerDeleter from './ContainerDeleter'; +import ContainerPublishStatus from './ContainerPublishStatus'; type ContainerPreviewProps = { containerId: string, @@ -78,9 +79,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, @@ -94,7 +94,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 = ( @@ -123,15 +122,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; } @@ -149,15 +139,10 @@ const ContainerInfo = () => { {intl.formatMessage(messages.openButton)} )} - {!componentPickerMode && !readOnly && ( - + {!showOpenButton && !componentPickerMode && ( + )} {showOpenButton && ( @@ -180,6 +165,11 @@ const ContainerInfo = () => { intl.formatMessage(messages.manageTabTitle), , )} + {renderTab( + CONTAINER_INFO_TABS.Usage, + intl.formatMessage(messages.usageTabTitle), + , + )} {renderTab( CONTAINER_INFO_TABS.Settings, intl.formatMessage(messages.settingsTabTitle), diff --git a/src/library-authoring/containers/ContainerPublishStatus.scss b/src/library-authoring/containers/ContainerPublishStatus.scss new file mode 100644 index 0000000000..ae88273f67 --- /dev/null +++ b/src/library-authoring/containers/ContainerPublishStatus.scss @@ -0,0 +1,31 @@ +.status-box { + border: 2px solid; + border-radius: 4px; + + &.draft-status { + @extend %draft-status; + } + + &.published-status { + @extend %published-status; + } + + .container-name { + width: 200px; + } +} + +.status-button { + border: 1px solid; + border-left: 4px solid; + text-align: center; + white-space: pre-wrap; + + &.draft-status { + @extend %draft-status; + } + + &.published-status { + @extend %published-status; + } +} diff --git a/src/library-authoring/containers/ContainerPublishStatus.tsx b/src/library-authoring/containers/ContainerPublishStatus.tsx new file mode 100644 index 0000000000..f054c13261 --- /dev/null +++ b/src/library-authoring/containers/ContainerPublishStatus.tsx @@ -0,0 +1,210 @@ +/** + * Shows the LibraryContainer's publish status, + * and enables publishing any unpublished changes. + */ +import { type ReactNode, useContext, useCallback } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import type { MessageDescriptor } from 'react-intl'; +import { + ActionRow, + Button, + Container, + useToggle, +} from '@openedx/paragon'; +import Loading from '@src/generic/Loading'; +import LoadingButton from '@src/generic/loading-button'; +import { ToastContext } from '@src/generic/toast-context'; +import { ContainerType, getBlockType } from '@src/generic/key-utils'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useContainer, useContainerHierarchy, usePublishContainer } from '../data/apiHooks'; +import ContainerHierarchy from './ContainerHierarchy'; +import messages from './messages'; + +type ContainerPublisherProps = { + close: () => void; + containerId: string; +}; + +const ContainerPublisher = ({ + close, + containerId, +}: ContainerPublisherProps) => { + const intl = useIntl(); + const containerType = getBlockType(containerId); + const publishContainer = usePublishContainer(containerId); + + const { + data: hierarchy, + isLoading, + isError, + } = useContainerHierarchy(containerId); + + 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]); + + if (isLoading) { + return ; + } + + // istanbul ignore if: this should never happen + if (isError) { + return null; + } + + const highlight = (...chunks: ReactNode[]) => {chunks}; + const childWarningMessage = () => { + let childCount: number; + let childMessage: MessageDescriptor; + let noChildMessage: MessageDescriptor; + + switch (containerType) { + case ContainerType.Section: + childCount = hierarchy.subsections.length; + childMessage = messages.publishSectionWithChildrenWarning; + noChildMessage = messages.publishSectionWarning; + break; + case ContainerType.Subsection: + childCount = hierarchy.units.length; + childMessage = messages.publishSubsectionWithChildrenWarning; + noChildMessage = messages.publishSubsectionWarning; + break; + default: // ContainerType.Unit + childCount = hierarchy.components.length; + childMessage = messages.publishUnitWithChildrenWarning; + noChildMessage = messages.publishUnitWarning; + } + return intl.formatMessage( + childCount ? childMessage : noChildMessage, + { + childCount, + highlight, + }, + ); + }; + + const parentWarningMessage = () => { + let parentCount: number; + let parentMessage: MessageDescriptor; + + switch (containerType) { + case ContainerType.Subsection: + parentMessage = messages.publishSubsectionWithParentWarning; + parentCount = hierarchy.sections.length; + break; + case ContainerType.Unit: + parentMessage = messages.publishUnitWithParentWarning; + parentCount = hierarchy.subsections.length; + break; + default: // ContainerType.Section has no parents + return undefined; + } + return intl.formatMessage(parentMessage, { parentCount, highlight }); + }; + + return ( + +

{intl.formatMessage(messages.publishContainerConfirmHeading)}

+

{childWarningMessage()} {parentWarningMessage()}

+ + + + { + 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 { + data: container, + isLoading, + isError, + } = useContainer(containerId); + + if (isLoading) { + return ; + } + + // istanbul ignore if: this should never happen + if (isError) { + return null; + } + + if (!container.hasUnpublishedChanges) { + return ( + + {intl.formatMessage(messages.publishedChipText)} + + ); + } + + return ( + (isConfirmingPublish + ? ( + + ) : ( + + ) + ) + ); +}; + +export default ContainerPublishStatus; diff --git a/src/library-authoring/containers/ContainerUsage.tsx b/src/library-authoring/containers/ContainerUsage.tsx new file mode 100644 index 0000000000..afd942636e --- /dev/null +++ b/src/library-authoring/containers/ContainerUsage.tsx @@ -0,0 +1,16 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import ContainerHierarchy from './ContainerHierarchy'; + +const ContainerUsage = () => { + const intl = useIntl(); + + return ( + <> +

{intl.formatMessage(messages.usageTabHierarchyHeading)}

+ + + ); +}; + +export default ContainerUsage; diff --git a/src/library-authoring/containers/index.scss b/src/library-authoring/containers/index.scss index 84de0f08fc..84d58bae92 100644 --- a/src/library-authoring/containers/index.scss +++ b/src/library-authoring/containers/index.scss @@ -1 +1,3 @@ @import "./ContainerCard.scss"; +@import "./ContainerPublishStatus.scss"; +@import "./ContainerHierarchy.scss"; diff --git a/src/library-authoring/containers/index.tsx b/src/library-authoring/containers/index.tsx index 69a1596f56..8aa5fe7644 100644 --- a/src/library-authoring/containers/index.tsx +++ b/src/library-authoring/containers/index.tsx @@ -3,3 +3,4 @@ export { default as ContainerInfoHeader } from './ContainerInfoHeader'; export { ContainerEditableTitle } from './ContainerEditableTitle'; export { HeaderActions } from './HeaderActions'; export { FooterActions } from './FooterActions'; +export { default as ContainerHierarchy } from './ContainerHierarchy'; diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 95489bfac5..6c2bf45197 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -1,6 +1,21 @@ 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.', + }, + willPublishChipText: { + id: 'course-authoring.library-authoring.container-component.will-publish-chip.text', + defaultMessage: 'Will Publish', + description: 'Text shown when a component/unit/section/subsection will be published when confirmed.', + }, openButton: { id: 'course-authoring.library-authoring.container-sidebar.open-button', defaultMessage: 'Open', @@ -28,8 +43,65 @@ 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', + }, + usageTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.usage-tab.title', + defaultMessage: 'Usage', + description: 'Title for usage tab', + }, + usageTabHierarchyHeading: { + id: 'course-authoring.library-authoring.container-sidebar.usage-tab.hierarchy-heading', + defaultMessage: 'Content Hierarchy', + description: 'Heading for usage tab hierarchy section', + }, + hierarchySections: { + id: 'course-authoring.library-authoring.container-sidebar.hierarchy-sections', + defaultMessage: '{count, plural, one {{displayName}} other {{count} Sections}}', + description: ( + 'Text used for the section part of the hierarchy: show the displayName when there is one, or ' + + 'the count when there is more than one.' + ), + }, + hierarchySubsections: { + id: 'course-authoring.library-authoring.container-sidebar.hierarchy-subsections', + defaultMessage: '{count, plural, one {{displayName}} other {{count} Subsections}}', + description: ( + 'Text used for the subsection part of the hierarchy: show the displayName when there is one, or ' + + 'the count when there is more than one.' + ), + }, + hierarchyUnits: { + id: 'course-authoring.library-authoring.container-sidebar.hierarchy-units', + defaultMessage: '{count, plural, one {{displayName}} other {{count} Units}}', + description: ( + 'Text used for the unit part of the hierarchy: show the displayName when there is one, or ' + + 'the count when there is more than one.' + ), + }, + hierarchyComponents: { + id: 'course-authoring.library-authoring.container-sidebar.hierarchy-components', + defaultMessage: '{count, plural, one {{displayName}} other {{count} Components}}', + description: ( + 'Text used for the components part of the hierarchy: show the displayName when there is one, or ' + + 'the count when there is more than one.' + ), + }, + 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 +113,61 @@ 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: 'This section will be published.', + description: 'Content details shown before publishing an empty section', + }, + publishSectionWithChildrenWarning: { + id: 'course-authoring.library-authoring.section-sidebar.publish-warning', + defaultMessage: ( + 'This section and the {childCount, plural, one {subsection} other {subsections}}' + + ' it contains will all be published.' + ), + description: 'Content details shown before publishing a section that contains subsections', + }, + publishSubsectionWarning: { + id: 'course-authoring.library-authoring.subsection-sidebar.publish-empty-warning', + defaultMessage: 'This subsection will be published.', + description: 'Content details shown before publishing an empty subsection', + }, + publishSubsectionWithChildrenWarning: { + id: 'course-authoring.library-authoring.subsection-sidebar.publish-warning', + defaultMessage: ( + 'This subsection and the {childCount, plural, one {unit} other {units}}' + + ' it contains will all be published.' + ), + description: 'Content details shown before publishing a subsection that contains units', + }, + publishSubsectionWithParentWarning: { + id: 'course-authoring.library-authoring.subsection-sidebar.publish-parent-warning', + defaultMessage: ( + 'Its {parentCount, plural, one {parent section} other {parent sections}}' + + ' will be draft.' + ), + description: 'Parent details shown before publishing a unit that has one or more parent subsections', + }, + publishUnitWarning: { + id: 'course-authoring.library-authoring.unit-sidebar.publish-empty-warning', + defaultMessage: 'This unit will be published.', + description: 'Content details shown before publishing an empty unit', + }, + publishUnitWithChildrenWarning: { + id: 'course-authoring.library-authoring.unit-sidebar.publish-warning', + defaultMessage: ( + 'This unit and the {childCount, plural, one {component} other {components}}' + + ' it contains will all be published.' + ), + description: 'Content details shown before publishing a unit that contains components', + }, + publishUnitWithParentWarning: { + id: 'course-authoring.library-authoring.unit-sidebar.publish-parent-warning', + defaultMessage: ( + 'Its {parentCount, plural, one {parent subsection} other {parent subsections}}' + + ' will be draft.' + ), + description: 'Parent details shown before publishing a unit that has one or more parent subsections', + }, settingsTabTitle: { id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title', defaultMessage: 'Settings', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index dd13fb11a4..cb7c12b7b5 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -516,6 +516,13 @@ export async function mockGetContainerMetadata(containerId: string): Promise { jest.spyOn(api, 'getLibraryContainerChildren').mockImplementation(mockGetContainerChildren); }; +/** + * Mock for `getLibraryContainerHierarchy()` + * + * This mock returns a fixed response for the given container ID. + */ +export async function mockGetContainerHierarchy(containerId: string): Promise { + const getChildren = (childId: string, childCount: number) => { + let blockType = 'html'; + let name = 'text'; + let typeNamespace = 'lb'; + if (childId.includes('unit')) { + blockType = 'unit'; + name = blockType; + typeNamespace = 'lct'; + } else if (childId.includes('subsection')) { + blockType = 'subsection'; + name = blockType; + typeNamespace = 'lct'; + } else if (childId.includes('section')) { + blockType = 'section'; + name = blockType; + typeNamespace = 'lct'; + } + + let numChildren = childCount; + if ( + // The selected container only shows itself, no other items. + childId === containerId + || [ + mockGetContainerHierarchy.unitIdOneChild, + mockGetContainerHierarchy.subsectionIdOneChild, + mockGetContainerHierarchy.sectionIdOneChild, + ].includes(containerId) + ) { + numChildren = 1; + } else if ([ + mockGetContainerMetadata.unitIdEmpty, + mockGetContainerMetadata.sectionIdEmpty, + mockGetContainerMetadata.subsectionIdEmpty, + ].includes(containerId)) { + numChildren = 0; + } + return Array(numChildren).fill(mockGetContainerChildren.childTemplate).map( + (child, idx) => ( + { + ...child, + id: ( + childId === containerId + ? childId + // Generate a unique ID when multiple child blocks + : `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}` + ), + displayName: `${name} block ${idx}`, + publishedDisplayName: `${name} block published ${idx}`, + hasUnpublishedChanges: true, + } + ), + ); + }; + + return Promise.resolve( + { + objectKey: containerId, + sections: getChildren(mockGetContainerMetadata.sectionId, 2), + subsections: getChildren(mockGetContainerMetadata.subsectionId, 3), + units: getChildren(mockGetContainerMetadata.unitId, 4), + components: getChildren('lb:org1:Demo_course_generated:text:text-0', 5), + }, + ); +} + +mockGetContainerHierarchy.unitIdOneChild = 'lct:org:lib:unit:test-unit-one'; +mockGetContainerHierarchy.sectionIdOneChild = 'lct:org:lib:section:test-section-one'; +mockGetContainerHierarchy.subsectionIdOneChild = 'lb:org1:Demo_course:subsection:subsection-one'; + +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockGetContainerHierarchy.applyMock = () => { + jest.spyOn(api, 'getLibraryContainerHierarchy').mockImplementation(mockGetContainerHierarchy); +}; + /** * Mock for `getXBlockOLX()` * diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 2f0f3b2a39..a1cf9feb46 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -123,6 +123,10 @@ export const getLibraryContainerChildrenApiUrl = (containerId: string, published /** * Get the URL for library container collections. */ +/** + * Get the URL for a single container hierarchy api. + */ +export const getLibraryContainerHierarchyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}hierarchy/`; export const getLibraryContainerCollectionsUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}collections/`; /** * Get the URL for the API endpoint to publish a single container (+ children). @@ -717,6 +721,27 @@ export async function removeLibraryContainerChildren( return camelCaseObject(data); } +export interface ContainerHierarchyData { + objectKey: string; + sections: Container[]; + subsections: Container[]; + units: Container[]; + components: LibraryBlockMetadata[]; +} +export type ContainerHierarchyMember = Container | LibraryBlockMetadata; + +/** + * Fetch a library container's hierarchy metadata. + */ +export async function getLibraryContainerHierarchy( + containerId: string, +): Promise { + const { data } = await getAuthenticatedHttpClient().get( + getLibraryContainerHierarchyApiUrl(containerId), + ); + return camelCaseObject(data); +} + /** * Publish a container, and any unpublished children within it. * diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index e0e19cd013..57b32b4353 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -292,7 +292,7 @@ describe('library api hooks', () => { }); it('should remove container children', async () => { - const containerId = 'lct:org:lib1'; + const containerId = 'lct:org:lib:unit:unit-1'; const url = getLibraryContainerChildrenApiUrl(containerId); axiosMock.onDelete(url).reply(200); @@ -326,9 +326,13 @@ describe('library api hooks', () => { expect(axiosMock.history.post[0].url).toEqual(url); - // Two call for `containerChildren` and library predicate - // and two more calls to invalidate the subsections. - expect(spy).toHaveBeenCalledTimes(4); + // Keys should be invalidated: + // 1. library + // 2. containerChildren + // 3. containerHierarchy + // 4 & 5. subsections + // 6 & 7. subsections hierarchy + expect(spy).toHaveBeenCalledTimes(7); }); describe('publishContainer', () => { diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index f6d3fd69f1..09c3a0dfe8 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -79,6 +79,10 @@ export const libraryAuthoringQueryKeys = { ...libraryAuthoringQueryKeys.container(containerId), 'children', ], + containerHierarchy: (containerId: string) => [ + ...libraryAuthoringQueryKeys.container(containerId), + 'hierarchy', + ], }; export const xblockQueryKeys = { @@ -726,6 +730,17 @@ export const useContainerChildren = (containerId?: string, published: boolean = }) ); +/** + * Get the metadata and hierarchy for a container in a library + */ +export const useContainerHierarchy = (containerId?: string) => ( + useQuery({ + enabled: !!containerId, + queryKey: libraryAuthoringQueryKeys.containerHierarchy(containerId!), + queryFn: () => api.getLibraryContainerHierarchy(containerId!), + }) +); + /** * If you work with `useContentFromSearchIndex`, you can use this * function to get the query key, usually to invalidate the query. @@ -771,9 +786,18 @@ export const useAddItemsToContainer = (containerId?: string) => { // container list. const libraryId = getLibraryId(containerId); queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) }); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(containerId) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); const containerType = getBlockType(containerId); + if (['subsection', 'section'].includes(containerType)) { + // If the container is a subsection or section, we invalidate the + // children query to update the hierarchy. + variables.forEach((itemId) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(itemId) }); + }); + } + if (containerType === 'section') { // We invalidate the search query of the each itemId if the container is a section. // This because the subsection page calls this query individually. @@ -833,13 +857,13 @@ export const useUpdateContainerChildren = (containerId?: string) => { export const useRemoveContainerChildren = (containerId?: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (usageKeys: string[]) => { + mutationFn: async (itemIds: string[]) => { if (!containerId) { return undefined; } - return api.removeLibraryContainerChildren(containerId, usageKeys); + return api.removeLibraryContainerChildren(containerId, itemIds); }, - onSettled: () => { + onSettled: (_data, _error, variables) => { if (!containerId) { return; } @@ -848,6 +872,15 @@ export const useRemoveContainerChildren = (containerId?: string) => { const libraryId = getLibraryId(containerId); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) }); + + const containerType = getBlockType(containerId); + if (['subsection', 'section'].includes(containerType)) { + // If the container is a subsection or section, we invalidate the + // children query to update the hierarchy. + variables.forEach((itemId) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(itemId) }); + }); + } }, }); }; @@ -865,6 +898,7 @@ export const usePublishContainer = (containerId: string) => { // The child components/xblocks could and even the container itself could appear in many different collections // or other containers, so it's best to just invalidate everything. queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryContent(libraryId) }); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(containerId) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); // For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes" queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata }); 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 766d5bd391..a93dd477bd 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1,10 +1,10 @@ @import "./component-info/ComponentPreview"; @import "./components"; -@import "./containers"; @import "./generic"; @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 0ddda577cf..da81dd264c 100644 --- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -100,7 +100,7 @@ const ContainerRow = ({ containerKey, 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({ diff --git a/webpack.dev-tutor.config.js b/webpack.dev-tutor.config.js new file mode 100644 index 0000000000..e69de29bb2