Display Container Publish status and confirm before publish (#2186)

Updates the Container sidebar to display:

* A confirmation step before publishing the container.
* Text + a full hierarchy to better demonstrate what will be published when the container is published.
This commit is contained in:
Jillian
2025-08-21 03:52:30 +09:30
committed by GitHub
parent 87af7e8973
commit 2f6e510b09
22 changed files with 1081 additions and 86 deletions

View File

@@ -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) => (
<Spinner
animation="border"
role="status"
@@ -19,14 +21,6 @@ export const LoadingSpinner = ({ size }) => (
/>
);
LoadingSpinner.defaultProps = {
size: undefined,
};
LoadingSpinner.propTypes = {
size: PropTypes.string,
};
const Loading = () => (
<div className="d-flex justify-content-center align-items-center flex-column vh-100">
<LoadingSpinner />

View File

@@ -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<string, React.ComponentType> = {
chapter: ViewCarousel,
problem: EditIcon,
lock: LockIcon,
multiple: Widgets,
};
export const COMPONENT_TYPE_ICON_MAP: Record<string, React.ComponentType> = {
@@ -65,6 +67,7 @@ export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
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,

View File

@@ -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',
}

View File

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

View File

@@ -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,
}) => (
<Stack>
<Container
className={classNames('hierarchy-row', { selected })}
>
<Stack
direction="horizontal"
gap={2}
>
<div className="icon">
<Icon
src={getItemIcon(containerType)}
screenReaderText={containerType}
title={containerType}
/>
</div>
<div className="text text-truncate">
{text}
</div>
{publishMessage && (
<Stack
direction="horizontal"
gap={2}
className="publish-status"
>
<Icon src={willPublish ? Check : Description} />
<FormattedMessage {...(willPublish ? messages.willPublishChipText : publishMessage)} />
</Stack>
)}
</Stack>
</Container>
{showArrow && (
<div
className={classNames('hierarchy-arrow', { selected })}
>
<Icon
src={ArrowDownward}
screenReaderText={' '}
/>
</div>
)}
</Stack>
);
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 <Loading />;
}
// 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 (
<Stack className="content-hierarchy">
{showSections && (
<ContainerHierarchyRow
containerType={ContainerType.Section}
text={intl.formatMessage(
messages.hierarchySections,
{
displayName: sections[0].displayName,
count: sections.length,
},
)}
showArrow={showSubsections}
selected={selectedSections}
willPublish={selectedSections}
publishMessage={publishMessage(sections)}
/>
)}
{showSubsections && (
<ContainerHierarchyRow
containerType={ContainerType.Subsection}
text={intl.formatMessage(
messages.hierarchySubsections,
{
displayName: subsections[0].displayName,
count: subsections.length,
},
)}
showArrow={showUnits}
selected={selectedSubsections}
willPublish={selectedSubsections || selectedSections}
publishMessage={publishMessage(subsections)}
/>
)}
{showUnits && (
<ContainerHierarchyRow
containerType={ContainerType.Unit}
text={intl.formatMessage(
messages.hierarchyUnits,
{
displayName: units[0].displayName,
count: units.length,
},
)}
showArrow={showComponents}
selected={selectedUnits}
willPublish={selectedUnits || selectedSubsections || selectedSections}
publishMessage={publishMessage(units)}
/>
)}
{showComponents && (
<ContainerHierarchyRow
containerType={ContainerType.Components}
text={intl.formatMessage(
messages.hierarchyComponents,
{
displayName: components[0].displayName,
count: components.length,
},
)}
showArrow={false}
selected={selectedComponents}
willPublish={selectedComponents || selectedUnits || selectedSubsections || selectedSections}
publishMessage={publishMessage(components)}
/>
)}
</Stack>
);
};
export default ContainerHierarchy;

View File

@@ -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(<ContainerInfo />, {
path: '/library/:libraryId/:selectedItemId?',
path,
params,
extraWrapper: ({ children }) => (
<LibraryProvider
@@ -58,16 +130,35 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
{
containerType: ContainerType.Unit,
containerId: unitId,
childType: 'component',
willPublishCount: 2,
parentType: 'subsection',
parentCount: 3,
},
{
containerType: ContainerType.Subsection,
containerId: subsectionId,
childType: 'unit',
willPublishCount: 3,
parentType: 'section',
parentCount: 2,
},
{
containerType: ContainerType.Section,
containerId: sectionId,
childType: 'subsection',
willPublishCount: 4,
parentType: '',
parentCount: 0,
},
].forEach(({ containerId, containerType }) => {
].forEach(({
containerId,
containerType,
childType,
willPublishCount,
parentType,
parentCount,
}) => {
describe(`<ContainerInfo /> 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`, // <strong>published</strong>
'i',
))).toBeInTheDocument();
if (parentCount > 0) {
expect(screen.getByText(new RegExp(
`Its parent ${parentType}s will be`, // <srong>draft</strong>
'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`, // <strong>published</strong>
'i',
))).toBeInTheDocument();
if (parentCount) {
expect(screen.getByText(new RegExp(
`Its parent ${parentType} will be`, // <strong>draft</strong>
'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`, // <strong>published</strong>
'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();
});
});
});

View File

@@ -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)}
</Button>
)}
{!componentPickerMode && !readOnly && (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
disabled={!container.hasUnpublishedChanges || publishContainer.isLoading}
onClick={handlePublish}
>
{intl.formatMessage(messages.publishContainerButton)}
</Button>
{!showOpenButton && !componentPickerMode && (
<ContainerPublishStatus
containerId={containerId}
/>
)}
{showOpenButton && (
<ContainerMenu containerId={containerId} />
@@ -180,6 +165,11 @@ const ContainerInfo = () => {
intl.formatMessage(messages.manageTabTitle),
<ContainerOrganize />,
)}
{renderTab(
CONTAINER_INFO_TABS.Usage,
intl.formatMessage(messages.usageTabTitle),
<ContainerUsage />,
)}
{renderTab(
CONTAINER_INFO_TABS.Settings,
intl.formatMessage(messages.settingsTabTitle),

View File

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

View File

@@ -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 <Loading />;
}
// istanbul ignore if: this should never happen
if (isError) {
return null;
}
const highlight = (...chunks: ReactNode[]) => <strong>{chunks}</strong>;
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 (
<Container
className="p-3 status-box draft-status"
>
<h4>{intl.formatMessage(messages.publishContainerConfirmHeading)}</h4>
<p>{childWarningMessage()} {parentWarningMessage()}</p>
<ContainerHierarchy showPublishStatus />
<ActionRow>
<Button
variant="outline-primary rounded-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
close();
}}
>
{intl.formatMessage(messages.publishContainerCancel)}
</Button>
<LoadingButton
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
await handlePublish();
}}
variant="primary rounded-0"
label={intl.formatMessage(messages.publishContainerConfirm)}
/>
</ActionRow>
</Container>
);
};
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 <Loading />;
}
// istanbul ignore if: this should never happen
if (isError) {
return null;
}
if (!container.hasUnpublishedChanges) {
return (
<Container
className="p-2 text-nowrap flex-grow-1 status-button published-status font-weight-bold"
>
{intl.formatMessage(messages.publishedChipText)}
</Container>
);
}
return (
(isConfirmingPublish
? (
<ContainerPublisher
close={cancelPublish}
containerId={containerId}
/>
) : (
<Button
variant="outline-primary rounded-0 status-button draft-status font-weight-bold"
className="m-1 flex-grow-1"
disabled={readOnly}
onClick={confirmPublish}
>
<FormattedMessage
{...messages.publishContainerButton}
values={{
publishStatus: (
<span className="font-weight-500">
({intl.formatMessage(messages.draftChipText)})
</span>
),
}}
/>
</Button>
)
)
);
};
export default ContainerPublishStatus;

View File

@@ -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 (
<>
<h4>{intl.formatMessage(messages.usageTabHierarchyHeading)}</h4>
<ContainerHierarchy />
</>
);
};
export default ContainerUsage;

View File

@@ -1 +1,3 @@
@import "./ContainerCard.scss";
@import "./ContainerPublishStatus.scss";
@import "./ContainerHierarchy.scss";

View File

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

View File

@@ -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 <highlight>published</highlight>.',
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 <highlight>published</highlight>.'
),
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 <highlight>published</highlight>.',
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 <highlight>published</highlight>.'
),
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 <highlight>draft</highlight>.'
),
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 <highlight>published</highlight>.',
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 <highlight>published</highlight>.'
),
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 <highlight>draft</highlight>.'
),
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',

View File

@@ -516,6 +516,13 @@ export async function mockGetContainerMetadata(containerId: string): Promise<api
case mockGetContainerMetadata.subsectionId:
case mockGetContainerMetadata.subsectionIdEmpty:
return Promise.resolve(mockGetContainerMetadata.subsectionData);
case mockGetContainerMetadata.unitIdPublished:
case mockGetContainerMetadata.sectionIdPublished:
case mockGetContainerMetadata.subsectionIdPublished:
return Promise.resolve({
...mockGetContainerMetadata.containerData,
hasUnpublishedChanges: false,
});
default:
if (containerId.startsWith('lct:org1:Demo_course_generated:')) {
const lastPart = containerId.split(':').pop();
@@ -533,8 +540,11 @@ export async function mockGetContainerMetadata(containerId: string): Promise<api
}
mockGetContainerMetadata.unitId = 'lct:org:lib:unit:test-unit-9a207';
mockGetContainerMetadata.unitIdEmpty = 'lct:org:lib:unit:test-unit-empty';
mockGetContainerMetadata.unitIdPublished = 'lct:org:lib:unit:test-unit-published';
mockGetContainerMetadata.sectionId = 'lct:org:lib:section:test-section-1';
mockGetContainerMetadata.sectionIdPublished = 'lct:org:lib:section:test-section-published';
mockGetContainerMetadata.subsectionId = 'lb:org1:Demo_course:subsection:subsection-0';
mockGetContainerMetadata.subsectionIdPublished = 'lb:org1:Demo_course:subsection:subsection-published';
mockGetContainerMetadata.sectionIdEmpty = 'lct:org:lib:section:test-section-empty';
mockGetContainerMetadata.subsectionIdEmpty = 'lb:org1:Demo_course:subsection:subsection-empty';
mockGetContainerMetadata.unitIdError = 'lct:org:lib:unit:container_error';
@@ -654,6 +664,86 @@ mockGetContainerChildren.applyMock = () => {
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<api.ContainerHierarchyData> {
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()`
*

View File

@@ -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<ContainerHierarchyData> {
const { data } = await getAuthenticatedHttpClient().get(
getLibraryContainerHierarchyApiUrl(containerId),
);
return camelCaseObject(data);
}
/**
* Publish a container, and any unpublished children within it.
*

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
>
<Stack direction="horizontal" gap={1}>
<Icon size="xs" src={Description} />
<FormattedMessage {...messages.draftChipText} />
<FormattedMessage {...containerMessages.draftChipText} />
</Stack>
</Badge>
)}

View File

@@ -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({

View File