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 { Spinner } from '@openedx/paragon';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
export const LoadingSpinner = ({ size }) => (
|
interface LoadingSpinnerProps {
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingSpinner = ({ size }: LoadingSpinnerProps) => (
|
||||||
<Spinner
|
<Spinner
|
||||||
animation="border"
|
animation="border"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -19,14 +21,6 @@ export const LoadingSpinner = ({ size }) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
LoadingSpinner.defaultProps = {
|
|
||||||
size: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
LoadingSpinner.propTypes = {
|
|
||||||
size: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Loading = () => (
|
const Loading = () => (
|
||||||
<div className="d-flex justify-content-center align-items-center flex-column vh-100">
|
<div className="d-flex justify-content-center align-items-center flex-column vh-100">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
ViewCarousel,
|
ViewCarousel,
|
||||||
ViewDay,
|
ViewDay,
|
||||||
|
Widgets,
|
||||||
WidthWide,
|
WidthWide,
|
||||||
} from '@openedx/paragon/icons';
|
} from '@openedx/paragon/icons';
|
||||||
import NewsstandIcon from '../NewsstandIcon';
|
import NewsstandIcon from '../NewsstandIcon';
|
||||||
@@ -43,6 +44,7 @@ export const UNIT_TYPE_ICONS_MAP: Record<string, React.ComponentType> = {
|
|||||||
chapter: ViewCarousel,
|
chapter: ViewCarousel,
|
||||||
problem: EditIcon,
|
problem: EditIcon,
|
||||||
lock: LockIcon,
|
lock: LockIcon,
|
||||||
|
multiple: Widgets,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COMPONENT_TYPE_ICON_MAP: Record<string, React.ComponentType> = {
|
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,
|
subsection: UNIT_TYPE_ICONS_MAP.sequential,
|
||||||
chapter: UNIT_TYPE_ICONS_MAP.chapter,
|
chapter: UNIT_TYPE_ICONS_MAP.chapter,
|
||||||
section: UNIT_TYPE_ICONS_MAP.chapter,
|
section: UNIT_TYPE_ICONS_MAP.chapter,
|
||||||
|
components: UNIT_TYPE_ICONS_MAP.multiple,
|
||||||
collection: Folder,
|
collection: Folder,
|
||||||
libraryContent: Folder,
|
libraryContent: Folder,
|
||||||
paste: ContentPasteIcon,
|
paste: ContentPasteIcon,
|
||||||
|
|||||||
@@ -70,4 +70,9 @@ export enum ContainerType {
|
|||||||
Chapter = 'chapter',
|
Chapter = 'chapter',
|
||||||
Sequential = 'sequential',
|
Sequential = 'sequential',
|
||||||
Vertical = 'vertical',
|
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,
|
within,
|
||||||
} from '@src/testUtils';
|
} from '@src/testUtils';
|
||||||
import { ContainerType } from '@src/generic/key-utils';
|
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 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 { LibraryProvider } from '../common/context/LibraryContext';
|
||||||
import ContainerInfo from './ContainerInfo';
|
import ContainerInfo from './ContainerInfo';
|
||||||
import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api';
|
import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api';
|
||||||
@@ -19,9 +24,68 @@ mockContentLibrary.applyMock();
|
|||||||
mockContentSearchConfig.applyMock();
|
mockContentSearchConfig.applyMock();
|
||||||
mockGetContainerMetadata.applyMock();
|
mockGetContainerMetadata.applyMock();
|
||||||
mockGetContainerChildren.applyMock();
|
mockGetContainerChildren.applyMock();
|
||||||
|
mockGetContainerHierarchy.applyMock();
|
||||||
|
|
||||||
const { libraryId } = mockContentLibrary;
|
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();
|
const mockNavigate = jest.fn();
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
@@ -29,10 +93,18 @@ jest.mock('react-router-dom', () => ({
|
|||||||
useNavigate: () => mockNavigate,
|
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 params: { libraryId: string, selectedItemId?: string } = { libraryId, selectedItemId: containerId };
|
||||||
|
const path = containerType
|
||||||
|
? `/library/:libraryId/${containerType}/:selectedItemId?`
|
||||||
|
: '/library/:libraryId/:selectedItemId?';
|
||||||
|
|
||||||
return baseRender(<ContainerInfo />, {
|
return baseRender(<ContainerInfo />, {
|
||||||
path: '/library/:libraryId/:selectedItemId?',
|
path,
|
||||||
params,
|
params,
|
||||||
extraWrapper: ({ children }) => (
|
extraWrapper: ({ children }) => (
|
||||||
<LibraryProvider
|
<LibraryProvider
|
||||||
@@ -58,16 +130,35 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
|||||||
{
|
{
|
||||||
containerType: ContainerType.Unit,
|
containerType: ContainerType.Unit,
|
||||||
containerId: unitId,
|
containerId: unitId,
|
||||||
|
childType: 'component',
|
||||||
|
willPublishCount: 2,
|
||||||
|
parentType: 'subsection',
|
||||||
|
parentCount: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
containerType: ContainerType.Subsection,
|
containerType: ContainerType.Subsection,
|
||||||
containerId: subsectionId,
|
containerId: subsectionId,
|
||||||
|
childType: 'unit',
|
||||||
|
willPublishCount: 3,
|
||||||
|
parentType: 'section',
|
||||||
|
parentCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
containerType: ContainerType.Section,
|
containerType: ContainerType.Section,
|
||||||
containerId: sectionId,
|
containerId: sectionId,
|
||||||
|
childType: 'subsection',
|
||||||
|
willPublishCount: 4,
|
||||||
|
parentType: '',
|
||||||
|
parentCount: 0,
|
||||||
},
|
},
|
||||||
].forEach(({ containerId, containerType }) => {
|
].forEach(({
|
||||||
|
containerId,
|
||||||
|
containerType,
|
||||||
|
childType,
|
||||||
|
willPublishCount,
|
||||||
|
parentType,
|
||||||
|
parentCount,
|
||||||
|
}) => {
|
||||||
describe(`<ContainerInfo /> with containerType: ${containerType}`, () => {
|
describe(`<ContainerInfo /> with containerType: ${containerType}`, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
({ axiosMock, mockShowToast } = initializeMocks());
|
({ axiosMock, mockShowToast } = initializeMocks());
|
||||||
@@ -108,15 +199,55 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
|||||||
expect(mockShowToast).toHaveBeenCalled();
|
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();
|
const user = userEvent.setup();
|
||||||
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200);
|
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200);
|
||||||
render(containerId);
|
render(containerId, containerType);
|
||||||
|
|
||||||
// Click on Publish button
|
// 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();
|
expect(publishButton).toBeInTheDocument();
|
||||||
await user.click(publishButton);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
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 () => {
|
it(`shows an error if publishing the ${containerType} fails`, async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(500);
|
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(500);
|
||||||
render(containerId);
|
render(containerId, containerType);
|
||||||
|
|
||||||
// Click on Publish button
|
// Click on Publish button to reveal the confirmation box
|
||||||
const publishButton = await screen.findByRole('button', { name: 'Publish' });
|
const publishButton = await screen.findByRole('button', { name: /publish changes/i });
|
||||||
expect(publishButton).toBeInTheDocument();
|
expect(publishButton).toBeInTheDocument();
|
||||||
await user.click(publishButton);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
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');
|
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 () => {
|
it(`show only published ${containerType} content`, async () => {
|
||||||
render(containerId, true);
|
render(containerId, containerType, true);
|
||||||
expect(await screen.findByTestId('container-info-menu-toggle')).toBeInTheDocument();
|
expect(await screen.findByText(/block published 1/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/block published 1/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`shows the ${containerType} Preview tab by default and the children are readonly`, async () => {
|
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
|
// Check that there are no menu buttons for components
|
||||||
expect(screen.queryAllByRole('button', { name: /component actions menu/i }).length).toBe(0);
|
expect(screen.queryAllByRole('button', { name: /component actions menu/i }).length).toBe(0);
|
||||||
|
|
||||||
let childType: string;
|
// If the childType is a component, it should be displayed as a text block
|
||||||
switch (containerType) {
|
const childTypeDisplayName = childType === 'component' ? 'text' : childType;
|
||||||
case ContainerType.Section:
|
const child = await screen.findByText(`${childTypeDisplayName} block 0`);
|
||||||
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!);
|
|
||||||
// Check that there are no menu buttons for containers
|
// Check that there are no menu buttons for containers
|
||||||
expect(within(
|
expect(within(
|
||||||
child.parentElement!.parentElement!.parentElement!,
|
child.parentElement!.parentElement!.parentElement!,
|
||||||
@@ -187,5 +351,30 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
|||||||
// Click should not do anything in preview
|
// Click should not do anything in preview
|
||||||
expect(mockNavigate).not.toHaveBeenCalled();
|
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 { Link } from 'react-router-dom';
|
||||||
import { MoreVert } from '@openedx/paragon/icons';
|
import { MoreVert } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
|
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||||
import {
|
import {
|
||||||
@@ -22,14 +23,14 @@ import {
|
|||||||
useSidebarContext,
|
useSidebarContext,
|
||||||
} from '../common/context/SidebarContext';
|
} from '../common/context/SidebarContext';
|
||||||
import ContainerOrganize from './ContainerOrganize';
|
import ContainerOrganize from './ContainerOrganize';
|
||||||
|
import ContainerUsage from './ContainerUsage';
|
||||||
import { useLibraryRoutes } from '../routes';
|
import { useLibraryRoutes } from '../routes';
|
||||||
import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks';
|
import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks';
|
||||||
import { LibraryContainerChildren } from '../section-subsections/LibraryContainerChildren';
|
import { LibraryContainerChildren } from '../section-subsections/LibraryContainerChildren';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { useContainer, usePublishContainer } from '../data/apiHooks';
|
import { useContainer } from '../data/apiHooks';
|
||||||
import { ContainerType, getBlockType } from '../../generic/key-utils';
|
|
||||||
import { ToastContext } from '../../generic/toast-context';
|
|
||||||
import ContainerDeleter from './ContainerDeleter';
|
import ContainerDeleter from './ContainerDeleter';
|
||||||
|
import ContainerPublishStatus from './ContainerPublishStatus';
|
||||||
|
|
||||||
type ContainerPreviewProps = {
|
type ContainerPreviewProps = {
|
||||||
containerId: string,
|
containerId: string,
|
||||||
@@ -78,9 +79,8 @@ const ContainerPreview = ({ containerId } : ContainerPreviewProps) => {
|
|||||||
const ContainerInfo = () => {
|
const ContainerInfo = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { libraryId, readOnly } = useLibraryContext();
|
const { libraryId } = useLibraryContext();
|
||||||
const { componentPickerMode } = useComponentPickerContext();
|
const { componentPickerMode } = useComponentPickerContext();
|
||||||
const { showToast } = React.useContext(ToastContext);
|
|
||||||
const {
|
const {
|
||||||
defaultTab,
|
defaultTab,
|
||||||
hiddenTabs,
|
hiddenTabs,
|
||||||
@@ -94,7 +94,6 @@ const ContainerInfo = () => {
|
|||||||
const containerId = sidebarItemInfo?.id;
|
const containerId = sidebarItemInfo?.id;
|
||||||
const containerType = containerId ? getBlockType(containerId) : undefined;
|
const containerType = containerId ? getBlockType(containerId) : undefined;
|
||||||
const { data: container } = useContainer(containerId);
|
const { data: container } = useContainer(containerId);
|
||||||
const publishContainer = usePublishContainer(containerId!);
|
|
||||||
|
|
||||||
const defaultContainerTab = defaultTab.container;
|
const defaultContainerTab = defaultTab.container;
|
||||||
const tab: ContainerInfoTab = (
|
const tab: ContainerInfoTab = (
|
||||||
@@ -123,15 +122,6 @@ const ContainerInfo = () => {
|
|||||||
);
|
);
|
||||||
}, [hiddenTabs, defaultContainerTab, containerId]);
|
}, [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) {
|
if (!container || !containerId || !containerType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -149,15 +139,10 @@ const ContainerInfo = () => {
|
|||||||
{intl.formatMessage(messages.openButton)}
|
{intl.formatMessage(messages.openButton)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!componentPickerMode && !readOnly && (
|
{!showOpenButton && !componentPickerMode && (
|
||||||
<Button
|
<ContainerPublishStatus
|
||||||
variant="outline-primary"
|
containerId={containerId}
|
||||||
className="m-1 text-nowrap flex-grow-1"
|
/>
|
||||||
disabled={!container.hasUnpublishedChanges || publishContainer.isLoading}
|
|
||||||
onClick={handlePublish}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.publishContainerButton)}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{showOpenButton && (
|
{showOpenButton && (
|
||||||
<ContainerMenu containerId={containerId} />
|
<ContainerMenu containerId={containerId} />
|
||||||
@@ -180,6 +165,11 @@ const ContainerInfo = () => {
|
|||||||
intl.formatMessage(messages.manageTabTitle),
|
intl.formatMessage(messages.manageTabTitle),
|
||||||
<ContainerOrganize />,
|
<ContainerOrganize />,
|
||||||
)}
|
)}
|
||||||
|
{renderTab(
|
||||||
|
CONTAINER_INFO_TABS.Usage,
|
||||||
|
intl.formatMessage(messages.usageTabTitle),
|
||||||
|
<ContainerUsage />,
|
||||||
|
)}
|
||||||
{renderTab(
|
{renderTab(
|
||||||
CONTAINER_INFO_TABS.Settings,
|
CONTAINER_INFO_TABS.Settings,
|
||||||
intl.formatMessage(messages.settingsTabTitle),
|
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 "./ContainerCard.scss";
|
||||||
|
@import "./ContainerPublishStatus.scss";
|
||||||
|
@import "./ContainerHierarchy.scss";
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { default as ContainerInfoHeader } from './ContainerInfoHeader';
|
|||||||
export { ContainerEditableTitle } from './ContainerEditableTitle';
|
export { ContainerEditableTitle } from './ContainerEditableTitle';
|
||||||
export { HeaderActions } from './HeaderActions';
|
export { HeaderActions } from './HeaderActions';
|
||||||
export { FooterActions } from './FooterActions';
|
export { FooterActions } from './FooterActions';
|
||||||
|
export { default as ContainerHierarchy } from './ContainerHierarchy';
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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: {
|
openButton: {
|
||||||
id: 'course-authoring.library-authoring.container-sidebar.open-button',
|
id: 'course-authoring.library-authoring.container-sidebar.open-button',
|
||||||
defaultMessage: 'Open',
|
defaultMessage: 'Open',
|
||||||
@@ -28,8 +43,65 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
publishContainerButton: {
|
publishContainerButton: {
|
||||||
id: 'course-authoring.library-authoring.container-sidebar.publish-button',
|
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',
|
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: {
|
publishContainerSuccess: {
|
||||||
id: 'course-authoring.library-authoring.container-sidebar.publish-success',
|
id: 'course-authoring.library-authoring.container-sidebar.publish-success',
|
||||||
@@ -41,6 +113,61 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Failed to publish changes',
|
defaultMessage: 'Failed to publish changes',
|
||||||
description: 'Popup text seen if publishing a unit/subsection/section fails',
|
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: {
|
settingsTabTitle: {
|
||||||
id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title',
|
id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title',
|
||||||
defaultMessage: 'Settings',
|
defaultMessage: 'Settings',
|
||||||
|
|||||||
@@ -516,6 +516,13 @@ export async function mockGetContainerMetadata(containerId: string): Promise<api
|
|||||||
case mockGetContainerMetadata.subsectionId:
|
case mockGetContainerMetadata.subsectionId:
|
||||||
case mockGetContainerMetadata.subsectionIdEmpty:
|
case mockGetContainerMetadata.subsectionIdEmpty:
|
||||||
return Promise.resolve(mockGetContainerMetadata.subsectionData);
|
return Promise.resolve(mockGetContainerMetadata.subsectionData);
|
||||||
|
case mockGetContainerMetadata.unitIdPublished:
|
||||||
|
case mockGetContainerMetadata.sectionIdPublished:
|
||||||
|
case mockGetContainerMetadata.subsectionIdPublished:
|
||||||
|
return Promise.resolve({
|
||||||
|
...mockGetContainerMetadata.containerData,
|
||||||
|
hasUnpublishedChanges: false,
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
if (containerId.startsWith('lct:org1:Demo_course_generated:')) {
|
if (containerId.startsWith('lct:org1:Demo_course_generated:')) {
|
||||||
const lastPart = containerId.split(':').pop();
|
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.unitId = 'lct:org:lib:unit:test-unit-9a207';
|
||||||
mockGetContainerMetadata.unitIdEmpty = 'lct:org:lib:unit:test-unit-empty';
|
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.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.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.sectionIdEmpty = 'lct:org:lib:section:test-section-empty';
|
||||||
mockGetContainerMetadata.subsectionIdEmpty = 'lb:org1:Demo_course:subsection:subsection-empty';
|
mockGetContainerMetadata.subsectionIdEmpty = 'lb:org1:Demo_course:subsection:subsection-empty';
|
||||||
mockGetContainerMetadata.unitIdError = 'lct:org:lib:unit:container_error';
|
mockGetContainerMetadata.unitIdError = 'lct:org:lib:unit:container_error';
|
||||||
@@ -654,6 +664,86 @@ mockGetContainerChildren.applyMock = () => {
|
|||||||
jest.spyOn(api, 'getLibraryContainerChildren').mockImplementation(mockGetContainerChildren);
|
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()`
|
* Mock for `getXBlockOLX()`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export const getLibraryContainerChildrenApiUrl = (containerId: string, published
|
|||||||
/**
|
/**
|
||||||
* Get the URL for library container collections.
|
* 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/`;
|
export const getLibraryContainerCollectionsUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}collections/`;
|
||||||
/**
|
/**
|
||||||
* Get the URL for the API endpoint to publish a single container (+ children).
|
* Get the URL for the API endpoint to publish a single container (+ children).
|
||||||
@@ -717,6 +721,27 @@ export async function removeLibraryContainerChildren(
|
|||||||
return camelCaseObject(data);
|
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.
|
* Publish a container, and any unpublished children within it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ describe('library api hooks', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should remove container children', async () => {
|
it('should remove container children', async () => {
|
||||||
const containerId = 'lct:org:lib1';
|
const containerId = 'lct:org:lib:unit:unit-1';
|
||||||
const url = getLibraryContainerChildrenApiUrl(containerId);
|
const url = getLibraryContainerChildrenApiUrl(containerId);
|
||||||
|
|
||||||
axiosMock.onDelete(url).reply(200);
|
axiosMock.onDelete(url).reply(200);
|
||||||
@@ -326,9 +326,13 @@ describe('library api hooks', () => {
|
|||||||
|
|
||||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||||
|
|
||||||
// Two call for `containerChildren` and library predicate
|
// Keys should be invalidated:
|
||||||
// and two more calls to invalidate the subsections.
|
// 1. library
|
||||||
expect(spy).toHaveBeenCalledTimes(4);
|
// 2. containerChildren
|
||||||
|
// 3. containerHierarchy
|
||||||
|
// 4 & 5. subsections
|
||||||
|
// 6 & 7. subsections hierarchy
|
||||||
|
expect(spy).toHaveBeenCalledTimes(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('publishContainer', () => {
|
describe('publishContainer', () => {
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export const libraryAuthoringQueryKeys = {
|
|||||||
...libraryAuthoringQueryKeys.container(containerId),
|
...libraryAuthoringQueryKeys.container(containerId),
|
||||||
'children',
|
'children',
|
||||||
],
|
],
|
||||||
|
containerHierarchy: (containerId: string) => [
|
||||||
|
...libraryAuthoringQueryKeys.container(containerId),
|
||||||
|
'hierarchy',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const xblockQueryKeys = {
|
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
|
* If you work with `useContentFromSearchIndex`, you can use this
|
||||||
* function to get the query key, usually to invalidate the query.
|
* function to get the query key, usually to invalidate the query.
|
||||||
@@ -771,9 +786,18 @@ export const useAddItemsToContainer = (containerId?: string) => {
|
|||||||
// container list.
|
// container list.
|
||||||
const libraryId = getLibraryId(containerId);
|
const libraryId = getLibraryId(containerId);
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) });
|
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(containerId) });
|
||||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||||
|
|
||||||
const containerType = getBlockType(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) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (containerType === 'section') {
|
if (containerType === 'section') {
|
||||||
// We invalidate the search query of the each itemId if the container is a 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.
|
// This because the subsection page calls this query individually.
|
||||||
@@ -833,13 +857,13 @@ export const useUpdateContainerChildren = (containerId?: string) => {
|
|||||||
export const useRemoveContainerChildren = (containerId?: string) => {
|
export const useRemoveContainerChildren = (containerId?: string) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (usageKeys: string[]) => {
|
mutationFn: async (itemIds: string[]) => {
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return api.removeLibraryContainerChildren(containerId, usageKeys);
|
return api.removeLibraryContainerChildren(containerId, itemIds);
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: (_data, _error, variables) => {
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -848,6 +872,15 @@ export const useRemoveContainerChildren = (containerId?: string) => {
|
|||||||
const libraryId = getLibraryId(containerId);
|
const libraryId = getLibraryId(containerId);
|
||||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
|
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
|
// 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.
|
// or other containers, so it's best to just invalidate everything.
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryContent(libraryId) });
|
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryContent(libraryId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(containerId) });
|
||||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||||
// For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes"
|
// For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes"
|
||||||
queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata });
|
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 {
|
.status-widget {
|
||||||
|
border-top: 4px solid;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
&.draft-status {
|
&.draft-status {
|
||||||
background-color: #FDF3E9;
|
@extend %draft-status;
|
||||||
border-top: 4px solid #F4B57B;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.published-status {
|
&.published-status {
|
||||||
background-color: var(--pgn-color-info-100);
|
@extend %published-status;
|
||||||
border-top: 4px solid var(--pgn-color-info-400);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@import "./component-info/ComponentPreview";
|
@import "./component-info/ComponentPreview";
|
||||||
@import "./components";
|
@import "./components";
|
||||||
@import "./containers";
|
|
||||||
@import "./generic";
|
@import "./generic";
|
||||||
@import "./LibraryAuthoringPage";
|
@import "./LibraryAuthoringPage";
|
||||||
@import "./units";
|
@import "./units";
|
||||||
@import "./section-subsections";
|
@import "./section-subsections";
|
||||||
|
@import "./containers";
|
||||||
|
|
||||||
.library-cards-grid {
|
.library-cards-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
|
|||||||
>
|
>
|
||||||
<Stack direction="horizontal" gap={1}>
|
<Stack direction="horizontal" gap={1}>
|
||||||
<Icon size="xs" src={Description} />
|
<Icon size="xs" src={Description} />
|
||||||
<FormattedMessage {...messages.draftChipText} />
|
<FormattedMessage {...containerMessages.draftChipText} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ export const messages = defineMessages({
|
|||||||
defaultMessage: 'Failed to update children order',
|
defaultMessage: 'Failed to update children order',
|
||||||
description: 'Toast message displayed when reordering of children items in container fails',
|
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({
|
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