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:
@@ -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 />
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
54
src/library-authoring/containers/ContainerHierarchy.scss
Normal file
54
src/library-authoring/containers/ContainerHierarchy.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src/library-authoring/containers/ContainerHierarchy.tsx
Normal file
211
src/library-authoring/containers/ContainerHierarchy.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
31
src/library-authoring/containers/ContainerPublishStatus.scss
Normal file
31
src/library-authoring/containers/ContainerPublishStatus.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/library-authoring/containers/ContainerPublishStatus.tsx
Normal file
210
src/library-authoring/containers/ContainerPublishStatus.tsx
Normal 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;
|
||||
16
src/library-authoring/containers/ContainerUsage.tsx
Normal file
16
src/library-authoring/containers/ContainerUsage.tsx
Normal 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;
|
||||
@@ -1 +1,3 @@
|
||||
@import "./ContainerCard.scss";
|
||||
@import "./ContainerPublishStatus.scss";
|
||||
@import "./ContainerHierarchy.scss";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()`
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
0
webpack.dev-tutor.config.js
Normal file
0
webpack.dev-tutor.config.js
Normal file
Reference in New Issue
Block a user