diff --git a/src/library-authoring/LibraryAuthoringPage.scss b/src/library-authoring/LibraryAuthoringPage.scss
index 48c524ee7..5a4470008 100644
--- a/src/library-authoring/LibraryAuthoringPage.scss
+++ b/src/library-authoring/LibraryAuthoringPage.scss
@@ -13,7 +13,7 @@
.library-authoring-sidebar {
z-index: 1000; // same as header
- flex: 455px 0 0;
+ flex: 500px 0 0;
position: sticky;
top: 0;
right: 0;
diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx
index 85db6f28e..fea6d9c35 100644
--- a/src/library-authoring/common/context/SidebarContext.tsx
+++ b/src/library-authoring/common/context/SidebarContext.tsx
@@ -32,6 +32,7 @@ export const isCollectionInfoTab = (tab: string): tab is CollectionInfoTab => (
export const COMPONENT_INFO_TABS = {
Preview: 'preview',
Manage: 'manage',
+ Usage: 'usage',
Details: 'details',
} as const;
export type ComponentInfoTab = typeof COMPONENT_INFO_TABS[keyof typeof COMPONENT_INFO_TABS];
diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx
index e4a741823..695c59764 100644
--- a/src/library-authoring/component-info/ComponentInfo.test.tsx
+++ b/src/library-authoring/component-info/ComponentInfo.test.tsx
@@ -10,6 +10,7 @@ import {
mockContentLibrary,
mockLibraryBlockMetadata,
mockGetEntityLinks,
+ mockGetComponentHierarchy,
} from '../data/api.mocks';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext';
@@ -21,6 +22,7 @@ mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockGetEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock();
+mockGetComponentHierarchy.applyMock();
jest.mock('./ComponentPreview', () => ({
__esModule: true, // Required when mocking 'default' export
default: () =>
Mocked preview
,
@@ -78,26 +80,57 @@ describe(' Sidebar', () => {
await waitFor(() => expect(editButton).not.toBeDisabled());
});
- it('should show a disabled "Publish" button when the component is already published', async () => {
+ it('should show a "Published" chip when the component is already published', async () => {
initializeMocks();
render(
,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishDisabled),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/ });
- expect(publishButton).toBeDisabled();
+ expect(await screen.findByText(/Published/)).toBeInTheDocument();
});
- it('should show a working "Publish" button when the component is not published', async () => {
+ it('should show a working "Publish Changes" button when the component is not published', async () => {
initializeMocks();
render(
,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/ });
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/ });
await waitFor(() => expect(publishButton).not.toBeDisabled());
});
+ it('should show the confirmation box', async () => {
+ initializeMocks();
+ render(
+ ,
+ withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished),
+ );
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/ });
+ const editButton = screen.getByRole('button', { name: /edit component/i });
+ const menuButton = screen.getByRole('button', { name: /component actions menu/i });
+ expect(publishButton).toBeInTheDocument();
+ expect(editButton).toBeInTheDocument();
+ expect(menuButton).toBeInTheDocument();
+ publishButton.click();
+
+ // Should show hirearchy info
+ expect(await screen.findByText(/Confirm Publish/i)).toBeInTheDocument();
+
+ // Should hide all action buttons
+ expect(publishButton).not.toBeInTheDocument();
+ expect(editButton).not.toBeInTheDocument();
+ expect(menuButton).not.toBeInTheDocument();
+
+ // Click on Cancel
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ cancelButton.click();
+
+ // Should show all action buttons
+ expect(publishButton).not.toBeInTheDocument();
+ expect(editButton).not.toBeInTheDocument();
+ expect(menuButton).not.toBeInTheDocument();
+ });
+
it('should show publish confirmation on first publish', async () => {
initializeMocks();
render(
@@ -105,13 +138,13 @@ describe(' Sidebar', () => {
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/i });
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/i });
publishButton.click();
- expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument();
- expect(screen.getByText(mockLibraryBlockMetadata.dataNeverPublished.displayName)).toBeInTheDocument();
- expect(screen.queryByText(/This content is currently being used in:/i)).not.toBeInTheDocument();
- expect(screen.queryByText(/This component can be synced in courses after publish./i)).not.toBeInTheDocument();
+ // Should show the confirmation box
+ expect(await screen.findByText(/Confirm Publish/i)).toBeInTheDocument();
+ const secondPublishButton = await screen.getByRole('button', { name: /publish/i });
+ secondPublishButton.click();
});
it('should show publish confirmation on already published', async () => {
@@ -121,14 +154,14 @@ describe(' Sidebar', () => {
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChanges),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/i });
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/i });
await waitFor(() => expect(publishButton).not.toBeDisabled());
publishButton.click();
- expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument();
- expect(screen.getByText(mockLibraryBlockMetadata.dataPublishedWithChanges.displayName)).toBeInTheDocument();
- expect(screen.getByText(/This content is currently being used in:/i)).toBeInTheDocument();
- expect(screen.getByText(/This component can be synced in courses after publish./i)).toBeInTheDocument();
+ // Should show the confirmation box
+ expect(await screen.findByText(/Confirm Publish/i)).toBeInTheDocument();
+ const secondPublishButton = await screen.getByRole('button', { name: /publish/i });
+ secondPublishButton.click();
});
it('should show publish confirmation on already published empty downstreams', async () => {
@@ -138,14 +171,14 @@ describe(' Sidebar', () => {
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/i });
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/i });
await waitFor(() => expect(publishButton).not.toBeDisabled());
publishButton.click();
- expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument();
- expect(screen.getByText(mockLibraryBlockMetadata.dataPublishedWithChanges.displayName)).toBeInTheDocument();
- expect(screen.getAllByText(/This component is not used in any course./i).length).toBe(2);
- expect(screen.queryByText(/This component can be synced in courses after publish./i)).not.toBeInTheDocument();
+ // Should show the confirmation box
+ expect(await screen.findByText(/Confirm Publish/i)).toBeInTheDocument();
+ const secondPublishButton = await screen.getByRole('button', { name: /publish/i });
+ secondPublishButton.click();
});
it('should show toast message when the component is published successfully', async () => {
@@ -157,15 +190,13 @@ describe(' Sidebar', () => {
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/i });
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/i });
publishButton.click();
- // Should show publish confirmation modal
- expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument();
-
- // Click on confirm
- const confirmButton = await screen.findByRole('button', { name: /Publish/i });
- confirmButton.click();
+ // Should show the confirmation box
+ expect(await screen.findByText(/Confirm Publish/i)).toBeInTheDocument();
+ const secondPublishButton = await screen.getByRole('button', { name: /publish/i });
+ secondPublishButton.click();
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Component published successfully.');
@@ -181,15 +212,13 @@ describe(' Sidebar', () => {
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished),
);
- const publishButton = await screen.findByRole('button', { name: /Publish component/i });
+ const publishButton = await screen.findByRole('button', { name: /Publish Changes/i });
publishButton.click();
- // Should show publish confirmation modal
- expect(await screen.findByText(/Publish all unpublished changes for this component?/i)).toBeInTheDocument();
-
- // Click on confirm
- const confirmButton = await screen.findByRole('button', { name: /Publish/i });
- confirmButton.click();
+ // Should show the confirmation box
+ expect(await screen.findByText(/Confirm Publish/i)).toBeInTheDocument();
+ const secondPublishButton = await screen.getByRole('button', { name: /publish/i });
+ secondPublishButton.click();
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('There was an error publishing the component.');
diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx
index b76361d31..967a4050a 100644
--- a/src/library-authoring/component-info/ComponentInfo.tsx
+++ b/src/library-authoring/component-info/ComponentInfo.tsx
@@ -11,6 +11,7 @@ import {
CheckBoxIcon,
CheckBoxOutlineBlank,
} from '@openedx/paragon/icons';
+import { getBlockType } from '@src/generic/key-utils';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
@@ -26,10 +27,10 @@ import ComponentDetails from './ComponentDetails';
import ComponentManagement from './ComponentManagement';
import ComponentPreview from './ComponentPreview';
import messages from './messages';
-import { getBlockType } from '../../generic/key-utils';
-import { useLibraryBlockMetadata, usePublishComponent } from '../data/apiHooks';
-import { ToastContext } from '../../generic/toast-context';
-import PublishConfirmationModal from '../components/PublishConfirmationModal';
+import { useLibraryBlockMetadata } from '../data/apiHooks';
+import { ComponentUsageTab } from './ComponentUsageTab';
+import { PublishDraftButton, PublishedChip } from '../generic/publish-status-buttons';
+import { ComponentPublisher } from './ComponentPublisher';
const AddComponentWidget = () => {
const intl = useIntl();
@@ -98,10 +99,58 @@ const AddComponentWidget = () => {
return null;
};
+const ComponentActions = ({
+ componentId,
+ hasUnpublishedChanges,
+}: {
+ componentId: string,
+ hasUnpublishedChanges: boolean,
+}) => {
+ const intl = useIntl();
+ const { openComponentEditor } = useLibraryContext();
+ const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
+ const canEdit = canEditComponent(componentId);
+
+ if (isPublisherOpen) {
+ return (
+
+ );
+ }
+
+ return (
+
+
openComponentEditor(componentId) } : { disabled: true })}
+ variant="outline-primary"
+ className="m-1 text-nowrap flex-grow-1"
+ >
+ {intl.formatMessage(messages.editComponentButtonTitle)}
+
+
+ {!hasUnpublishedChanges ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+};
+
const ComponentInfo = () => {
const intl = useIntl();
+ const { readOnly } = useLibraryContext();
- const { readOnly, openComponentEditor } = useLibraryContext();
const {
sidebarTab,
setSidebarTab,
@@ -110,11 +159,6 @@ const ComponentInfo = () => {
hiddenTabs,
resetSidebarAction,
} = useSidebarContext();
- const [
- isPublishConfirmationOpen,
- openPublishConfirmation,
- closePublishConfirmation,
- ] = useToggle(false);
const tab: ComponentInfoTab = (
isComponentInfoTab(sidebarTab)
@@ -127,29 +171,14 @@ const ComponentInfo = () => {
setSidebarTab(newTab);
};
- const usageKey = sidebarItemInfo?.id;
+ const componentId = sidebarItemInfo?.id;
// istanbul ignore if: this should never happen
- if (!usageKey) {
+ if (!componentId) {
throw new Error('usageKey is required');
}
- const canEdit = canEditComponent(usageKey);
-
- const publishComponent = usePublishComponent(usageKey);
- const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
- // Only can be published when the component has been modified after the last published date.
- const canPublish = (new Date(componentMetadata?.modified ?? 0)) > (new Date(componentMetadata?.lastPublished ?? 0));
- const { showToast } = React.useContext(ToastContext);
-
- const publish = React.useCallback(() => {
- closePublishConfirmation();
- publishComponent.mutateAsync()
- .then(() => {
- showToast(intl.formatMessage(messages.publishSuccessMsg));
- }).catch(() => {
- showToast(intl.formatMessage(messages.publishErrorMsg));
- });
- }, [publishComponent, showToast, intl]);
+ const { data: componentMetadata } = useLibraryBlockMetadata(componentId);
+ const hasUnpublishedChanges = componentMetadata?.hasUnpublishedChanges || false;
// TODO: refactor sidebar Tabs to handle rendering and disabledTabs in one place.
const renderTab = React.useCallback((infoTab: ComponentInfoTab, component: React.ReactNode, title: string) => {
@@ -165,50 +194,27 @@ const ComponentInfo = () => {
}, [hiddenTabs, defaultTab.component]);
return (
- <>
-
- {!readOnly && (
-
- openComponentEditor(usageKey) } : { disabled: true })}
- variant="outline-primary"
- className="m-1 text-nowrap flex-grow-1"
- >
- {intl.formatMessage(messages.editComponentButtonTitle)}
-
-
- {intl.formatMessage(messages.publishComponentButtonTitle)}
-
-
-
- )}
-
-
- {renderTab(COMPONENT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))}
- {renderTab(COMPONENT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))}
- {renderTab(COMPONENT_INFO_TABS.Details, , intl.formatMessage(messages.detailsTabTitle))}
-
-
-
- >
+
+ {!readOnly && (
+
+ )}
+
+
+ {renderTab(COMPONENT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))}
+ {renderTab(COMPONENT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))}
+ {renderTab(COMPONENT_INFO_TABS.Usage, , intl.formatMessage(messages.usageTabTitle))}
+ {renderTab(COMPONENT_INFO_TABS.Details, , intl.formatMessage(messages.detailsTabTitle))}
+
+
);
};
diff --git a/src/library-authoring/component-info/ComponentPublisher.tsx b/src/library-authoring/component-info/ComponentPublisher.tsx
new file mode 100644
index 000000000..d42a81245
--- /dev/null
+++ b/src/library-authoring/component-info/ComponentPublisher.tsx
@@ -0,0 +1,46 @@
+import { useCallback, useContext } from 'react';
+
+import { ToastContext } from '@src/generic/toast-context';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+import { usePublishComponent } from '../data/apiHooks';
+import { ItemHierarchyPublisher } from '../hierarchy/ItemHierarchyPublisher';
+
+type ComponentPublisherProps = {
+ componentId: string;
+ handleClose: () => void;
+};
+
+/**
+ * ComponentPublisher handles the publishing flow for a given component.
+ *
+ * @param componentId - The unique identifier of the component.
+ * @param handleClose - Function to handle close the publisher.
+ */
+export const ComponentPublisher = ({
+ componentId,
+ handleClose,
+}: ComponentPublisherProps) => {
+ const intl = useIntl();
+ const publishComponent = usePublishComponent(componentId);
+ const { showToast } = useContext(ToastContext);
+
+ const handlePublish = useCallback(async () => {
+ try {
+ await publishComponent.mutateAsync();
+ showToast(intl.formatMessage(messages.publishSuccessMsg));
+ } catch (error) {
+ showToast(intl.formatMessage(messages.publishErrorMsg));
+ }
+ handleClose();
+ }, [publishComponent, showToast, intl]);
+
+ return (
+
+ );
+};
diff --git a/src/library-authoring/component-info/ComponentUsageTab.test.tsx b/src/library-authoring/component-info/ComponentUsageTab.test.tsx
new file mode 100644
index 000000000..c6de97fc4
--- /dev/null
+++ b/src/library-authoring/component-info/ComponentUsageTab.test.tsx
@@ -0,0 +1,57 @@
+import {
+ initializeMocks,
+ render as baseRender,
+ screen,
+} from '@src/testUtils';
+import { mockContentSearchConfig } from '@src/search-manager/data/api.mock';
+
+import { mockContentLibrary, mockGetComponentHierarchy, mockLibraryBlockMetadata } from '../data/api.mocks';
+import { ComponentUsageTab } from './ComponentUsageTab';
+import { LibraryProvider } from '../common/context/LibraryContext';
+import { SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext';
+
+mockContentSearchConfig.applyMock();
+mockContentLibrary.applyMock();
+mockLibraryBlockMetadata.applyMock();
+mockGetComponentHierarchy.applyMock();
+
+const {
+ libraryId,
+} = mockContentLibrary;
+
+const render = (usageKey: string) => baseRender( , {
+ path: `/library/${libraryId}/components/${usageKey}`,
+ params: { libraryId, selectedItemId: usageKey },
+ extraWrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+});
+
+describe(' ', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ it('should render the component usage loading', async () => {
+ render(mockLibraryBlockMetadata.usageKeyThatNeverLoads);
+ expect(await screen.findByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('should render the component usage', async () => {
+ render(mockLibraryBlockMetadata.usageKeyPublished);
+
+ expect(await screen.findByText('text block 0')).toBeInTheDocument();
+ expect(await screen.getByText('4 Units')).toBeInTheDocument();
+ expect(await screen.getByText('3 Subsections')).toBeInTheDocument();
+ expect(await screen.getByText('2 Sections')).toBeInTheDocument();
+ });
+});
diff --git a/src/library-authoring/component-info/ComponentUsageTab.tsx b/src/library-authoring/component-info/ComponentUsageTab.tsx
new file mode 100644
index 000000000..daa4a6fbb
--- /dev/null
+++ b/src/library-authoring/component-info/ComponentUsageTab.tsx
@@ -0,0 +1,12 @@
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import { ItemHierarchy } from '../hierarchy/ItemHierarchy';
+
+export const ComponentUsageTab = () => (
+ <>
+
+
+
+
+ >
+);
diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts
index 6273e2693..bbbe3524b 100644
--- a/src/library-authoring/component-info/messages.ts
+++ b/src/library-authoring/component-info/messages.ts
@@ -76,11 +76,6 @@ const messages = defineMessages({
defaultMessage: 'Edit component',
description: 'Title for edit component button',
},
- publishComponentButtonTitle: {
- id: 'course-authoring.library-authoring.component.publish.title',
- defaultMessage: 'Publish component',
- description: 'Title for publish component button',
- },
previewTabTitle: {
id: 'course-authoring.library-authoring.component.preview-tab.title',
defaultMessage: 'Preview',
@@ -91,6 +86,11 @@ const messages = defineMessages({
defaultMessage: 'Manage',
description: 'Title for manage tab',
},
+ usageTabTitle: {
+ id: 'course-authoring.library-authoring.component.usage-tab.title',
+ defaultMessage: 'Usage',
+ description: 'Title for manage tab',
+ },
manageTabTagsTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.tags-title',
defaultMessage: 'Tags ({count})',
@@ -151,6 +151,11 @@ const messages = defineMessages({
defaultMessage: 'There was an error publishing the component.',
description: 'Message when there is an error when publishing the component.',
},
+ usageTabHierarchyHeading: {
+ id: 'course-authoring.library-authoring.component-sidebar.usage-tab.hierarchy-heading',
+ defaultMessage: 'Content Hierarchy',
+ description: 'Heading for usage tab hierarchy section',
+ },
});
export default messages;
diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx
index 6b9b3e4a0..eeaa5ba22 100644
--- a/src/library-authoring/components/ComponentMenu.tsx
+++ b/src/library-authoring/components/ComponentMenu.tsx
@@ -121,6 +121,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
as={IconButton}
src={MoreVert}
iconAs={Icon}
+ size="sm"
variant="primary"
alt={intl.formatMessage(messages.componentCardMenuAlt)}
data-testid="component-card-menu-toggle"
diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx
deleted file mode 100644
index f698b976b..000000000
--- a/src/library-authoring/components/PublishConfirmationModal.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
-import { Alert, Button } from '@openedx/paragon';
-
-import BaseModal from '@src/editors/sharedComponents/BaseModal';
-import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
-
-import messages from './messages';
-import infoMessages from '../component-info/messages';
-import { ComponentUsage } from '../component-info/ComponentUsage';
-
-interface PublishConfirmationModalProps {
- isOpen: boolean,
- onClose: () => void,
- onConfirm: () => void,
- displayName: string,
- usageKey: string,
- showDownstreams: boolean,
-}
-
-const PublishConfirmationModal = ({
- isOpen,
- onClose,
- onConfirm,
- usageKey,
- displayName,
- showDownstreams,
-}: PublishConfirmationModalProps) => {
- const intl = useIntl();
-
- const {
- data: dataDownstreamLinks,
- isLoading: isLoadingDownstreamLinks,
- } = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' });
-
- const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0;
-
- return (
-
-
-
- )}
- >
-
-
- {intl.formatMessage(messages.publishConfirmationBody)}
-
- {displayName}
-
-
- {showDownstreams && (
-
- {hasDownstreamUsages ? (
- <>
-
-
-
-
-
-
-
- >
- ) : (
-
- )}
-
- )}
-
-
- );
-};
-
-export default PublishConfirmationModal;
diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx
index 29156197e..23c3d063c 100644
--- a/src/library-authoring/containers/ContainerInfo.tsx
+++ b/src/library-authoring/containers/ContainerInfo.tsx
@@ -30,7 +30,8 @@ import { LibraryContainerChildren } from '../section-subsections/LibraryContaine
import messages from './messages';
import { useContainer } from '../data/apiHooks';
import ContainerDeleter from './ContainerDeleter';
-import ContainerPublishStatus from './ContainerPublishStatus';
+import { ContainerPublisher } from './ContainerPublisher';
+import { PublishDraftButton, PublishedChip } from '../generic/publish-status-buttons';
type ContainerPreviewProps = {
containerId: string,
@@ -76,11 +77,70 @@ const ContainerPreview = ({ containerId } : ContainerPreviewProps) => {
return ;
};
-const ContainerInfo = () => {
+const ContainerActions = ({
+ containerId,
+ containerType,
+ hasUnpublishedChanges,
+}: {
+ containerId: string,
+ containerType: string,
+ hasUnpublishedChanges: boolean,
+}) => {
const intl = useIntl();
-
const { libraryId } = useLibraryContext();
const { componentPickerMode } = useComponentPickerContext();
+ const { insideUnit, insideSubsection, insideSection } = useLibraryRoutes();
+ const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
+
+ const showOpenButton = !componentPickerMode && !(
+ insideUnit || insideSubsection || insideSection
+ );
+
+ if (isPublisherOpen) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {showOpenButton && (
+
+ {intl.formatMessage(messages.openButton)}
+
+ )}
+ {!componentPickerMode && (
+ !hasUnpublishedChanges ? (
+
+ ) : (
+
+ )
+ )}
+ {showOpenButton && (
+
+
+
+ )}
+
+ );
+};
+
+const ContainerInfo = () => {
+ const intl = useIntl();
const {
defaultTab,
hiddenTabs,
@@ -89,8 +149,6 @@ const ContainerInfo = () => {
sidebarItemInfo,
resetSidebarAction,
} = useSidebarContext();
- const { insideUnit, insideSubsection, insideSection } = useLibraryRoutes();
-
const containerId = sidebarItemInfo?.id;
const containerType = containerId ? getBlockType(containerId) : undefined;
const { data: container } = useContainer(containerId);
@@ -100,10 +158,6 @@ const ContainerInfo = () => {
sidebarTab && isContainerInfoTab(sidebarTab)
) ? sidebarTab : defaultContainerTab;
- const showOpenButton = !componentPickerMode && !(
- insideUnit || insideSubsection || insideSection
- );
-
/* istanbul ignore next */
const handleTabChange = (newTab: ContainerInfoTab) => {
resetSidebarAction();
@@ -128,26 +182,11 @@ const ContainerInfo = () => {
return (
-
- {showOpenButton && (
-
- {intl.formatMessage(messages.openButton)}
-
- )}
- {!showOpenButton && !componentPickerMode && (
-
- )}
- {showOpenButton && (
-
- )}
-
+
void;
- containerId: string;
-};
-
-const ContainerPublisher = ({
- close,
- containerId,
-}: ContainerPublisherProps) => {
- const intl = useIntl();
- const containerType = getBlockType(containerId);
- const publishContainer = usePublishContainer(containerId);
-
- const {
- data: hierarchy,
- isLoading,
- isError,
- } = useContainerHierarchy(containerId);
-
- const { showToast } = useContext(ToastContext);
-
- const handlePublish = useCallback(async () => {
- try {
- await publishContainer.mutateAsync();
- showToast(intl.formatMessage(messages.publishContainerSuccess));
- } catch (error) {
- showToast(intl.formatMessage(messages.publishContainerFailed));
- }
- close();
- }, [publishContainer, showToast]);
-
- if (isLoading) {
- return ;
- }
-
- // istanbul ignore if: this should never happen
- if (isError) {
- return null;
- }
-
- const highlight = (...chunks: ReactNode[]) => {chunks} ;
- const childWarningMessage = () => {
- let childCount: number;
- let childMessage: MessageDescriptor;
- let noChildMessage: MessageDescriptor;
-
- switch (containerType) {
- case ContainerType.Section:
- childCount = hierarchy.subsections.length;
- childMessage = messages.publishSectionWithChildrenWarning;
- noChildMessage = messages.publishSectionWarning;
- break;
- case ContainerType.Subsection:
- childCount = hierarchy.units.length;
- childMessage = messages.publishSubsectionWithChildrenWarning;
- noChildMessage = messages.publishSubsectionWarning;
- break;
- default: // ContainerType.Unit
- childCount = hierarchy.components.length;
- childMessage = messages.publishUnitWithChildrenWarning;
- noChildMessage = messages.publishUnitWarning;
- }
- return intl.formatMessage(
- childCount ? childMessage : noChildMessage,
- {
- childCount,
- highlight,
- },
- );
- };
-
- const parentWarningMessage = () => {
- let parentCount: number;
- let parentMessage: MessageDescriptor;
-
- switch (containerType) {
- case ContainerType.Subsection:
- parentMessage = messages.publishSubsectionWithParentWarning;
- parentCount = hierarchy.sections.length;
- break;
- case ContainerType.Unit:
- parentMessage = messages.publishUnitWithParentWarning;
- parentCount = hierarchy.subsections.length;
- break;
- default: // ContainerType.Section has no parents
- return undefined;
- }
- return intl.formatMessage(parentMessage, { parentCount, highlight });
- };
-
- return (
-
- {intl.formatMessage(messages.publishContainerConfirmHeading)}
- {childWarningMessage()} {parentWarningMessage()}
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- close();
- }}
- >
- {intl.formatMessage(messages.publishContainerCancel)}
-
- {
- e.preventDefault();
- e.stopPropagation();
- await handlePublish();
- }}
- variant="primary rounded-0"
- label={intl.formatMessage(messages.publishContainerConfirm)}
- />
-
-
- );
-};
-
-type ContainerPublishStatusProps = {
- containerId: string;
-};
-
-const ContainerPublishStatus = ({
- containerId,
-}: ContainerPublishStatusProps) => {
- const intl = useIntl();
- const { readOnly } = useLibraryContext();
- const [isConfirmingPublish, confirmPublish, cancelPublish] = useToggle(false);
- const {
- data: container,
- isLoading,
- isError,
- } = useContainer(containerId);
-
- if (isLoading) {
- return ;
- }
-
- // istanbul ignore if: this should never happen
- if (isError) {
- return null;
- }
-
- if (!container.hasUnpublishedChanges) {
- return (
-
- {intl.formatMessage(messages.publishedChipText)}
-
- );
- }
-
- return (
- (isConfirmingPublish
- ? (
-
- ) : (
-
-
- ({intl.formatMessage(messages.draftChipText)})
-
- ),
- }}
- />
-
- )
- )
- );
-};
-
-export default ContainerPublishStatus;
diff --git a/src/library-authoring/containers/ContainerPublisher.tsx b/src/library-authoring/containers/ContainerPublisher.tsx
new file mode 100644
index 000000000..626009207
--- /dev/null
+++ b/src/library-authoring/containers/ContainerPublisher.tsx
@@ -0,0 +1,46 @@
+import { useCallback, useContext } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { ToastContext } from '@src/generic/toast-context';
+
+import { usePublishContainer } from '../data/apiHooks';
+import messages from './messages';
+import { ItemHierarchyPublisher } from '../hierarchy/ItemHierarchyPublisher';
+
+type ContainerPublisherProps = {
+ containerId: string;
+ handleClose: () => void;
+};
+
+/**
+ * ContainerPublisher handles the publishing flow for a given container.
+ *
+ * @param containerId - The unique identifier of the container.
+ * @param handleClose - Function to handle close the publisher.
+ */
+export const ContainerPublisher = ({
+ containerId,
+ handleClose,
+}: ContainerPublisherProps) => {
+ const intl = useIntl();
+ const publishContainer = usePublishContainer(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));
+ }
+ handleClose();
+ }, [publishContainer, showToast]);
+
+ return (
+
+ );
+};
diff --git a/src/library-authoring/containers/ContainerUsage.tsx b/src/library-authoring/containers/ContainerUsage.tsx
index afd942636..fc363cd2c 100644
--- a/src/library-authoring/containers/ContainerUsage.tsx
+++ b/src/library-authoring/containers/ContainerUsage.tsx
@@ -1,6 +1,6 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
-import ContainerHierarchy from './ContainerHierarchy';
+import { ItemHierarchy } from '../hierarchy/ItemHierarchy';
const ContainerUsage = () => {
const intl = useIntl();
@@ -8,7 +8,7 @@ const ContainerUsage = () => {
return (
<>
{intl.formatMessage(messages.usageTabHierarchyHeading)}
-
+
>
);
};
diff --git a/src/library-authoring/containers/index.scss b/src/library-authoring/containers/index.scss
index 84d58bae9..84de0f08f 100644
--- a/src/library-authoring/containers/index.scss
+++ b/src/library-authoring/containers/index.scss
@@ -1,3 +1 @@
@import "./ContainerCard.scss";
-@import "./ContainerPublishStatus.scss";
-@import "./ContainerHierarchy.scss";
diff --git a/src/library-authoring/containers/index.tsx b/src/library-authoring/containers/index.tsx
index 8aa5fe764..69a1596f5 100644
--- a/src/library-authoring/containers/index.tsx
+++ b/src/library-authoring/containers/index.tsx
@@ -3,4 +3,3 @@ export { default as ContainerInfoHeader } from './ContainerInfoHeader';
export { ContainerEditableTitle } from './ContainerEditableTitle';
export { HeaderActions } from './HeaderActions';
export { FooterActions } from './FooterActions';
-export { default as ContainerHierarchy } from './ContainerHierarchy';
diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts
index 6c2bf4519..e6e5f9987 100644
--- a/src/library-authoring/containers/messages.ts
+++ b/src/library-authoring/containers/messages.ts
@@ -1,21 +1,6 @@
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',
@@ -41,11 +26,6 @@ const messages = defineMessages({
defaultMessage: 'Collections ({count})',
description: 'Title for collections section in manage tab',
},
- 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',
@@ -56,118 +36,6 @@ const messages = defineMessages({
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 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',
- defaultMessage: 'All changes published',
- description: 'Popup text after publishing a unit/subsection/section',
- },
- publishContainerFailed: {
- id: 'course-authoring.library-authoring.container-sidebar.publish-failure',
- defaultMessage: 'Failed to publish changes',
- description: 'Popup text seen if publishing a unit/subsection/section fails',
- },
- publishSectionWarning: {
- id: 'course-authoring.library-authoring.section-sidebar.publish-empty-warning',
- defaultMessage: 'This section will be published .',
- description: 'Content details shown before publishing an empty section',
- },
- publishSectionWithChildrenWarning: {
- id: 'course-authoring.library-authoring.section-sidebar.publish-warning',
- defaultMessage: (
- 'This section and the {childCount, plural, one {subsection} other {subsections}}'
- + ' it contains will all be published .'
- ),
- description: 'Content details shown before publishing a section that contains subsections',
- },
- publishSubsectionWarning: {
- id: 'course-authoring.library-authoring.subsection-sidebar.publish-empty-warning',
- defaultMessage: 'This subsection will be published .',
- description: 'Content details shown before publishing an empty subsection',
- },
- publishSubsectionWithChildrenWarning: {
- id: 'course-authoring.library-authoring.subsection-sidebar.publish-warning',
- defaultMessage: (
- 'This subsection and the {childCount, plural, one {unit} other {units}}'
- + ' it contains will all be published .'
- ),
- description: 'Content details shown before publishing a subsection that contains units',
- },
- publishSubsectionWithParentWarning: {
- id: 'course-authoring.library-authoring.subsection-sidebar.publish-parent-warning',
- defaultMessage: (
- 'Its {parentCount, plural, one {parent section} other {parent sections}}'
- + ' will be draft .'
- ),
- description: 'Parent details shown before publishing a unit that has one or more parent subsections',
- },
- publishUnitWarning: {
- id: 'course-authoring.library-authoring.unit-sidebar.publish-empty-warning',
- defaultMessage: 'This unit will be published .',
- description: 'Content details shown before publishing an empty unit',
- },
- publishUnitWithChildrenWarning: {
- id: 'course-authoring.library-authoring.unit-sidebar.publish-warning',
- defaultMessage: (
- 'This unit and the {childCount, plural, one {component} other {components}}'
- + ' it contains will all be published .'
- ),
- description: 'Content details shown before publishing a unit that contains components',
- },
- publishUnitWithParentWarning: {
- id: 'course-authoring.library-authoring.unit-sidebar.publish-parent-warning',
- defaultMessage: (
- 'Its {parentCount, plural, one {parent subsection} other {parent subsections}}'
- + ' will be draft .'
- ),
- description: 'Parent details shown before publishing a unit that has one or more parent subsections',
- },
settingsTabTitle: {
id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title',
defaultMessage: 'Settings',
@@ -338,6 +206,16 @@ const messages = defineMessages({
defaultMessage: 'Undo',
description: 'Toast message to undo deletion of container',
},
+ publishContainerSuccess: {
+ id: 'course-authoring.library-authoring.container-sidebar.publisher.publish-success',
+ defaultMessage: 'All changes published',
+ description: 'Popup text after publishing a container',
+ },
+ publishContainerFailed: {
+ id: 'course-authoring.library-authoring.container-sidebar.publisher.publish-failure',
+ defaultMessage: 'Failed to publish changes',
+ description: 'Popup text seen if publishing a container fails',
+ },
});
export default messages;
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index cb7c12b7b..c5d3a796b 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -380,7 +380,7 @@ mockLibraryBlockMetadata.dataNeverPublished = {
publishedBy: null,
lastDraftCreated: null,
lastDraftCreatedBy: null,
- hasUnpublishedChanges: false,
+ hasUnpublishedChanges: true,
created: '2024-06-20T13:54:21Z',
modified: '2024-06-21T13:54:21Z',
tagsCount: 0,
@@ -543,10 +543,10 @@ 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.subsectionId = 'lct:org1:Demo_course:subsection:subsection-0';
+mockGetContainerMetadata.subsectionIdPublished = 'lct: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.subsectionIdEmpty = 'lct:org1:Demo_course:subsection:subsection-empty';
mockGetContainerMetadata.unitIdError = 'lct:org:lib:unit:container_error';
mockGetContainerMetadata.sectionIdError = 'lct:org:lib:section:section_error';
mockGetContainerMetadata.subsectionIdError = 'lct:org:lib:section:section_error';
@@ -664,12 +664,64 @@ mockGetContainerChildren.applyMock = () => {
jest.spyOn(api, 'getLibraryContainerChildren').mockImplementation(mockGetContainerChildren);
};
+/**
+ * Mock for `getBlockHierarchy()`
+ *
+ * This mock returns a fixed response for the given component ID.
+ */
+export async function mockGetComponentHierarchy(componentId: string): Promise {
+ const getChildren = (childId: string, childCount: number) => {
+ let blockType = 'html';
+ let name = 'text';
+ let typeNamespace = 'lb';
+ if (childId.includes('unit')) {
+ blockType = 'unit';
+ name = blockType;
+ typeNamespace = 'lct';
+ } else if (childId.includes('subsection')) {
+ blockType = 'subsection';
+ name = blockType;
+ typeNamespace = 'lct';
+ } else if (childId.includes('section')) {
+ blockType = 'section';
+ name = blockType;
+ typeNamespace = 'lct';
+ }
+
+ return Array(childCount).fill(mockGetContainerChildren.childTemplate).map(
+ (child, idx) => (
+ {
+ ...child,
+ id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
+ displayName: `${name} block ${idx}`,
+ publishedDisplayName: `${name} block published ${idx}`,
+ hasUnpublishedChanges: true,
+ }
+ ),
+ );
+ };
+
+ return Promise.resolve(
+ {
+ objectKey: componentId,
+ sections: getChildren(mockGetContainerMetadata.sectionId, 2),
+ subsections: getChildren(mockGetContainerMetadata.subsectionId, 3),
+ units: getChildren(mockGetContainerMetadata.unitId, 4),
+ components: getChildren(componentId, 1),
+ },
+ );
+}
+/** Apply this mock. Returns a spy object that can tell you if it's been called. */
+mockGetComponentHierarchy.applyMock = () => {
+ jest.spyOn(api, 'getBlockHierarchy').mockImplementation(mockGetComponentHierarchy);
+};
+
/**
* Mock for `getLibraryContainerHierarchy()`
*
* This mock returns a fixed response for the given container ID.
*/
-export async function mockGetContainerHierarchy(containerId: string): Promise {
+export async function mockGetContainerHierarchy(containerId: string): Promise {
const getChildren = (childId: string, childCount: number) => {
let blockType = 'html';
let name = 'text';
@@ -737,7 +789,7 @@ export async function mockGetContainerHierarchy(containerId: string): Promise {
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index a1cf9feb4..f5a3aa8d2 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -45,6 +45,11 @@ export const getLibraryBlockRestoreUrl = (usageKey: string) => `${getLibraryBloc
*/
export const getLibraryBlockCollectionsUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}collections/`;
+/**
+ * Get the URL for a single component hierarchy api.
+ */
+export const getLibraryBlockHierarchyUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}hierarchy/`;
+
/**
* Get the URL for content library list API.
*/
@@ -120,13 +125,13 @@ export const getLibraryContainerRestoreApiUrl = (containerId: string) => `${getL
* Get the URL for a single container children api.
*/
export const getLibraryContainerChildrenApiUrl = (containerId: string, published: boolean = false) => `${getLibraryContainerApiUrl(containerId)}children/?published=${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/`;
+/**
+ * Get the URL for library container collections.
+ */
export const getLibraryContainerCollectionsUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}collections/`;
/**
* Get the URL for the API endpoint to publish a single container (+ children).
@@ -583,6 +588,23 @@ export async function updateComponentCollections(usageKey: string, collectionKey
});
}
+export interface ItemHierarchyData {
+ objectKey: string;
+ sections: Container[];
+ subsections: Container[];
+ units: Container[];
+ components: LibraryBlockMetadata[];
+}
+export type ItemHierarchyMember = Container | LibraryBlockMetadata;
+
+/**
+ * Get the full hierarchy of a component
+ */
+export async function getBlockHierarchy(usageKey: string): Promise {
+ const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockHierarchyUrl(usageKey));
+ return camelCaseObject(data);
+}
+
export interface CreateLibraryContainerDataRequest {
title: string;
containerType: ContainerType;
@@ -721,21 +743,12 @@ 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 {
+): Promise {
const { data } = await getAuthenticatedHttpClient().get(
getLibraryContainerHierarchyApiUrl(containerId),
);
diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx
index 57b32b435..df5d5166a 100644
--- a/src/library-authoring/data/apiHooks.test.tsx
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -331,8 +331,8 @@ describe('library api hooks', () => {
// 2. containerChildren
// 3. containerHierarchy
// 4 & 5. subsections
- // 6 & 7. subsections hierarchy
- expect(spy).toHaveBeenCalledTimes(7);
+ // 6 all hierarchies
+ expect(spy).toHaveBeenCalledTimes(6);
});
describe('publishContainer', () => {
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 09c3a0dfe..9dd891e13 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -79,10 +79,15 @@ export const libraryAuthoringQueryKeys = {
...libraryAuthoringQueryKeys.container(containerId),
'children',
],
- containerHierarchy: (containerId: string) => [
- ...libraryAuthoringQueryKeys.container(containerId),
- 'hierarchy',
- ],
+ containerHierarchy: (containerId?: string) => {
+ if (containerId) {
+ return [
+ 'hierarchy',
+ ...libraryAuthoringQueryKeys.container(containerId),
+ ];
+ }
+ return ['hierarchy'];
+ },
};
export const xblockQueryKeys = {
@@ -106,6 +111,15 @@ export const xblockQueryKeys = {
* introspecting the usage keys.
*/
allComponentMetadata: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'componentMetadata',
+ componentHierarchy: (usageKey?: string) => {
+ if (usageKey) {
+ return [
+ 'hierarchy',
+ ...xblockQueryKeys.xblock(usageKey),
+ ];
+ }
+ return ['hierarchy'];
+ },
};
/**
@@ -435,6 +449,24 @@ export const usePublishComponent = (usageKey: string) => {
});
};
+/** Get the full hierarchy of the given library item (component/container) */
+export const useLibraryItemHierarchy = (key: string) => {
+ let queryKey: (string | undefined)[];
+ let queryFn: () => Promise;
+ if (key.startsWith('lb:')) {
+ queryKey = xblockQueryKeys.componentHierarchy(key);
+ queryFn = () => api.getBlockHierarchy(key);
+ } else {
+ queryKey = libraryAuthoringQueryKeys.containerHierarchy(key!);
+ queryFn = () => api.getLibraryContainerHierarchy(key!);
+ }
+ return useQuery({
+ queryKey,
+ queryFn,
+ enabled: !!key,
+ });
+};
+
/** Get the list of assets (static files) attached to a library component */
export const useXBlockAssets = (usageKey: string) => (
useQuery({
@@ -730,17 +762,6 @@ 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.
@@ -786,18 +807,14 @@ 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) });
- });
- }
+ // Invalidate all hierarchies to update grandparents and grandchildren
+ // It would be complex to bring the entire hierarchy and only update the items within that hierarchy.
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(undefined) });
+ queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentHierarchy(undefined) });
+ const containerType = getBlockType(containerId);
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.
@@ -863,7 +880,7 @@ export const useRemoveContainerChildren = (containerId?: string) => {
}
return api.removeLibraryContainerChildren(containerId, itemIds);
},
- onSettled: (_data, _error, variables) => {
+ onSettled: () => {
if (!containerId) {
return;
}
@@ -873,14 +890,10 @@ export const useRemoveContainerChildren = (containerId?: string) => {
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) });
- });
- }
+ // Invalidate all hierarchies to update grandparents and grandchildren
+ // It would be complex to bring the entire hierarchy and only update the items within that hierarchy.
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(undefined) });
+ queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentHierarchy(undefined) });
},
});
};
diff --git a/src/library-authoring/generic/index.scss b/src/library-authoring/generic/index.scss
index 210a91092..58fee02e5 100644
--- a/src/library-authoring/generic/index.scss
+++ b/src/library-authoring/generic/index.scss
@@ -1,3 +1,4 @@
@import "./history-widget/HistoryWidget";
@import "./status-widget/StatusWidget";
@import "./parent-breadcrumbs";
+@import "./publish-status-buttons";
diff --git a/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx b/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx
new file mode 100644
index 000000000..ed2d50b6d
--- /dev/null
+++ b/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx
@@ -0,0 +1,46 @@
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+import { Button } from '@openedx/paragon';
+
+import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
+
+import messages from './messages';
+
+type PublishDraftButtonProps = {
+ onClick: () => void;
+};
+
+/**
+ * PublishDraftButton renders a button that triggers the publishing action
+ * for a draft component/container.
+ *
+ * The button is disabled when the library is in read-only mode.
+ * It displays a localized label along with the draft status.
+ *
+ * @param onClick - Callback invoked when the button is clicked.
+ */
+export const PublishDraftButton = ({
+ onClick,
+}: PublishDraftButtonProps) => {
+ const intl = useIntl();
+ const { readOnly } = useLibraryContext();
+
+ return (
+
+
+ ({intl.formatMessage(messages.draftChipText)})
+
+ ),
+ }}
+ />
+
+ );
+};
diff --git a/src/library-authoring/generic/publish-status-buttons/PublishedChip.tsx b/src/library-authoring/generic/publish-status-buttons/PublishedChip.tsx
new file mode 100644
index 000000000..e0ff404e0
--- /dev/null
+++ b/src/library-authoring/generic/publish-status-buttons/PublishedChip.tsx
@@ -0,0 +1,18 @@
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Container } from '@openedx/paragon';
+
+import messages from './messages';
+
+/**
+ * PublishedChip displays a styled label indicating that a component
+ * or container is already published.
+ *
+ * It renders a localized message with consistent styling for status chips.
+ */
+export const PublishedChip = () => (
+
+
+
+);
diff --git a/src/library-authoring/containers/ContainerPublishStatus.scss b/src/library-authoring/generic/publish-status-buttons/index.scss
similarity index 51%
rename from src/library-authoring/containers/ContainerPublishStatus.scss
rename to src/library-authoring/generic/publish-status-buttons/index.scss
index ae88273f6..ddeddbe72 100644
--- a/src/library-authoring/containers/ContainerPublishStatus.scss
+++ b/src/library-authoring/generic/publish-status-buttons/index.scss
@@ -1,20 +1,3 @@
-.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;
diff --git a/src/library-authoring/generic/publish-status-buttons/index.tsx b/src/library-authoring/generic/publish-status-buttons/index.tsx
new file mode 100644
index 000000000..e93257c5f
--- /dev/null
+++ b/src/library-authoring/generic/publish-status-buttons/index.tsx
@@ -0,0 +1,2 @@
+export { PublishedChip } from './PublishedChip';
+export { PublishDraftButton } from './PublishDraftButton';
diff --git a/src/library-authoring/generic/publish-status-buttons/messages.ts b/src/library-authoring/generic/publish-status-buttons/messages.ts
new file mode 100644
index 000000000..396dea48e
--- /dev/null
+++ b/src/library-authoring/generic/publish-status-buttons/messages.ts
@@ -0,0 +1,21 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ publishedChipText: {
+ id: 'course-authoring.library-authoring.publish-status-buttons.publish-chip.text',
+ defaultMessage: 'Published',
+ description: 'Text shown when a unit/section/subsection/component is published.',
+ },
+ publishContainerButton: {
+ id: 'course-authoring.library-authoring.publish-status-buttons.publish-draft-button.text',
+ defaultMessage: 'Publish Changes {publishStatus}',
+ description: 'Button text to initiate publish the unit/subsection/section/component, showing current publish status',
+ },
+ draftChipText: {
+ id: 'course-authoring.library-authoring.publish-status.publish-draft-button.draft-chip.text',
+ defaultMessage: 'Draft',
+ description: 'Chip in publish draft button',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/containers/ContainerHierarchy.tsx b/src/library-authoring/hierarchy/ItemHierarchy.tsx
similarity index 88%
rename from src/library-authoring/containers/ContainerHierarchy.tsx
rename to src/library-authoring/hierarchy/ItemHierarchy.tsx
index 937585a1e..58f7599a7 100644
--- a/src/library-authoring/containers/ContainerHierarchy.tsx
+++ b/src/library-authoring/hierarchy/ItemHierarchy.tsx
@@ -1,17 +1,19 @@
import type { MessageDescriptor } from 'react-intl';
+import classNames from 'classnames';
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 = ({
+import { getItemIcon } from '@src/generic/block-type-utils';
+import { ContainerType } from '@src/generic/key-utils';
+import Loading from '@src/generic/Loading';
+
+import type { ItemHierarchyMember } from '../data/api';
+import messages from './messages';
+import { useSidebarContext } from '../common/context/SidebarContext';
+import { useLibraryItemHierarchy } from '../data/apiHooks';
+
+const HierarchyRow = ({
containerType,
text,
selected,
@@ -69,25 +71,25 @@ const ContainerHierarchyRow = ({
);
-const ContainerHierarchy = ({
+export const ItemHierarchy = ({
showPublishStatus = false,
}: {
showPublishStatus?: boolean,
}) => {
const intl = useIntl();
const { sidebarItemInfo } = useSidebarContext();
- const containerId = sidebarItemInfo?.id;
+ const itemId = sidebarItemInfo?.id;
// istanbul ignore if: this should never happen
- if (!containerId) {
- throw new Error('containerId is required');
+ if (!itemId) {
+ throw new Error('itemId is required');
}
const {
data,
isLoading,
isError,
- } = useContainerHierarchy(containerId);
+ } = useLibraryItemHierarchy(itemId);
if (isLoading) {
return ;
@@ -106,7 +108,7 @@ const ContainerHierarchy = ({
} = data;
// Returns a message describing the publish status of the given hierarchy row.
- const publishMessage = (contents: ContainerHierarchyMember[]) => {
+ const publishMessage = (contents: ItemHierarchyMember[]) => {
// If we're not showing publish status, then we don't need a publish message
if (!showPublishStatus) {
return undefined;
@@ -121,9 +123,9 @@ const ContainerHierarchy = ({
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)
+ // Returns True if any of the items in the list match the currently selected item.
+ const selected = (contents: ItemHierarchyMember[]): boolean => (
+ contents.some((item) => item.id === itemId)
);
// Use the "selected" status to determine the selected row.
@@ -141,7 +143,7 @@ const ContainerHierarchy = ({
return (
{showSections && (
-
)}
{showSubsections && (
-
)}
{showUnits && (
-
)}
{showComponents && (
-
);
};
-
-export default ContainerHierarchy;
diff --git a/src/library-authoring/hierarchy/ItemHierarchyPublisher.tsx b/src/library-authoring/hierarchy/ItemHierarchyPublisher.tsx
new file mode 100644
index 000000000..b86079578
--- /dev/null
+++ b/src/library-authoring/hierarchy/ItemHierarchyPublisher.tsx
@@ -0,0 +1,137 @@
+import { type ReactNode } from 'react';
+import type { MessageDescriptor } from 'react-intl';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Button,
+ Container,
+} from '@openedx/paragon';
+
+import LoadingButton from '@src/generic/loading-button';
+import { ContainerType, getBlockType } from '@src/generic/key-utils';
+import Loading from '@src/generic/Loading';
+
+import messages from './messages';
+import { ItemHierarchy } from './ItemHierarchy';
+import { useLibraryItemHierarchy } from '../data/apiHooks';
+
+type ItemHierarchyPublisherProps = {
+ itemId: string;
+ handleClose: () => void;
+ handlePublish: () => void;
+};
+
+export const ItemHierarchyPublisher = ({
+ itemId,
+ handleClose,
+ handlePublish,
+}: ItemHierarchyPublisherProps) => {
+ const intl = useIntl();
+ const itemType = getBlockType(itemId);
+
+ const {
+ data: hierarchy,
+ isLoading,
+ isError,
+ } = useLibraryItemHierarchy(itemId);
+
+ if (isLoading) {
+ return ;
+ }
+
+ // istanbul ignore if: this should never happen
+ if (isError) {
+ return null;
+ }
+
+ const highlight = (...chunks: ReactNode[]) => {chunks} ;
+ const childWarningMessage = () => {
+ let childCount: number;
+ let childMessage: MessageDescriptor;
+ let noChildMessage: MessageDescriptor;
+
+ switch (itemType) {
+ 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;
+ case ContainerType.Unit:
+ childCount = hierarchy.components.length;
+ childMessage = messages.publishUnitWithChildrenWarning;
+ noChildMessage = messages.publishUnitWarning;
+ break;
+ default: // The item is a component
+ childCount = 0;
+ childMessage = messages.empty; // Never used
+ noChildMessage = messages.publishComponentWarning;
+ break;
+ }
+ return intl.formatMessage(
+ childCount ? childMessage : noChildMessage,
+ {
+ childCount,
+ highlight,
+ },
+ );
+ };
+
+ const parentWarningMessage = () => {
+ let parentCount: number;
+ let parentMessage: MessageDescriptor;
+
+ switch (itemType) {
+ case ContainerType.Section:
+ // Section has no parents
+ return undefined;
+ case ContainerType.Subsection:
+ parentMessage = messages.publishSubsectionWithParentWarning;
+ parentCount = hierarchy.sections.length;
+ break;
+ case ContainerType.Unit:
+ parentMessage = messages.publishUnitWithParentWarning;
+ parentCount = hierarchy.subsections.length;
+ break;
+ default: // The item is a component
+ parentMessage = messages.publishComponentsWithParentWarning;
+ parentCount = hierarchy.units.length;
+ }
+ return intl.formatMessage(parentMessage, { parentCount, highlight });
+ };
+
+ return (
+
+ {intl.formatMessage(messages.publishConfirmHeading)}
+ {childWarningMessage()} {parentWarningMessage()}
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handleClose();
+ }}
+ >
+ {intl.formatMessage(messages.publishCancel)}
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ await handlePublish();
+ }}
+ variant="primary rounded-0"
+ label={intl.formatMessage(messages.publishConfirm)}
+ />
+
+
+ );
+};
diff --git a/src/library-authoring/containers/ContainerHierarchy.scss b/src/library-authoring/hierarchy/index.scss
similarity index 85%
rename from src/library-authoring/containers/ContainerHierarchy.scss
rename to src/library-authoring/hierarchy/index.scss
index fed123b32..a9bd04d06 100644
--- a/src/library-authoring/containers/ContainerHierarchy.scss
+++ b/src/library-authoring/hierarchy/index.scss
@@ -52,3 +52,20 @@
}
}
}
+
+.status-box {
+ border: 2px solid;
+ border-radius: 4px;
+
+ &.draft-status {
+ @extend %draft-status;
+ }
+
+ &.published-status {
+ @extend %published-status;
+ }
+
+ .container-name {
+ width: 200px;
+ }
+}
diff --git a/src/library-authoring/hierarchy/messages.ts b/src/library-authoring/hierarchy/messages.ts
new file mode 100644
index 000000000..61e32597d
--- /dev/null
+++ b/src/library-authoring/hierarchy/messages.ts
@@ -0,0 +1,141 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ willPublishChipText: {
+ id: 'course-authoring.library-authoring.container-component.hierarchy.will-publish-chip.text',
+ defaultMessage: 'Will Publish',
+ description: 'Text shown when a component/unit/section/subsection will be published when confirmed.',
+ },
+ draftChipText: {
+ id: 'course-authoring.library-authoring.container-component.hierarchy.draft-chip.text',
+ defaultMessage: 'Draft',
+ description: 'Chip in component/container that is shown when has unpublished changes',
+ },
+ publishedChipText: {
+ id: 'course-authoring.library-authoring.container-component.hierarchy.published-chip.text',
+ defaultMessage: 'Published',
+ description: 'Text shown when a unit/section/subsection/component is published.',
+ },
+ 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.'
+ ),
+ },
+ publishSectionWithChildrenWarning: {
+ id: 'course-authoring.library-authoring.section-sidebar.hierarchy-publisher.publish-warning',
+ defaultMessage: (
+ 'This section and the {childCount, plural, one {subsection} other {subsections}}'
+ + ' it contains will all be published .'
+ ),
+ description: 'Content details shown before publishing a section that contains subsections',
+ },
+ publishSectionWarning: {
+ id: 'course-authoring.library-authoring.section-sidebar.hierarchy-publisher.publish-empty-warning',
+ defaultMessage: 'This section will be published .',
+ description: 'Content details shown before publishing an empty section',
+ },
+ publishSubsectionWithChildrenWarning: {
+ id: 'course-authoring.library-authoring.subsection-sidebar.hierarchy-publisher.publish-warning',
+ defaultMessage: (
+ 'This subsection and the {childCount, plural, one {unit} other {units}}'
+ + ' it contains will all be published .'
+ ),
+ description: 'Content details shown before publishing a subsection that contains units',
+ },
+ publishSubsectionWarning: {
+ id: 'course-authoring.library-authoring.subsection-sidebar.hierarchy-publisher.publish-empty-warning',
+ defaultMessage: 'This subsection will be published .',
+ description: 'Content details shown before publishing an empty subsection',
+ },
+ publishUnitWithChildrenWarning: {
+ id: 'course-authoring.library-authoring.unit-sidebar.hierarchy-publisher.publish-warning',
+ defaultMessage: (
+ 'This unit and the {childCount, plural, one {component} other {components}}'
+ + ' it contains will all be published .'
+ ),
+ description: 'Content details shown before publishing a unit that contains components',
+ },
+ publishUnitWarning: {
+ id: 'course-authoring.library-authoring.unit-sidebar.hierarchy-publisher.publish-empty-warning',
+ defaultMessage: 'This unit will be published .',
+ description: 'Content details shown before publishing an empty unit',
+ },
+ publishSubsectionWithParentWarning: {
+ id: 'course-authoring.library-authoring.subsection-sidebar.hierarchy-publisher.publish-parent-warning',
+ defaultMessage: (
+ 'Its {parentCount, plural, one {parent section} other {parent sections}}'
+ + ' will be draft .'
+ ),
+ description: 'Parent details shown before publishing a unit that has one or more parent subsections',
+ },
+ publishUnitWithParentWarning: {
+ id: 'course-authoring.library-authoring.unit-sidebar.hierarchy-publisher.publish-parent-warning',
+ defaultMessage: (
+ 'Its {parentCount, plural, one {parent subsection} other {parent subsections}}'
+ + ' will be draft .'
+ ),
+ description: 'Parent details shown before publishing a unit that has one or more parent subsections',
+ },
+ publishConfirmHeading: {
+ id: 'course-authoring.library-authoring.item-sidebar.hierarchy-publisher.publish-confirm-heading',
+ defaultMessage: 'Confirm Publish',
+ description: 'Header text shown while confirming publish of a unit/subsection/section',
+ },
+ publishCancel: {
+ id: 'course-authoring.library-authoring.item-sidebar.hierarchy-publisher.publish-cancel',
+ defaultMessage: 'Cancel',
+ description: 'Button text shown to cancel publish of a unit/subsection/section',
+ },
+ publishConfirm: {
+ id: 'course-authoring.library-authoring.item-sidebar.hierarchy-publisher.publish-confirm-button',
+ defaultMessage: 'Publish',
+ description: 'Button text shown to confirm publish of a unit/subsection/section',
+ },
+ empty: {
+ id: 'course-authoring.library-authoring.item-sidebar.hierarchy-publisher.empty',
+ defaultMessage: 'No child',
+ description: 'Message when the item has no children',
+ },
+ publishComponentWarning: {
+ id: 'course-authoring.library-authoring.component-sidebar.hierarchy-publisher.publish-empty-warning',
+ defaultMessage: 'This component will be published .',
+ description: 'Content details shown before publishing an empty unit',
+ },
+ publishComponentsWithParentWarning: {
+ id: 'course-authoring.library-authoring.component-sidebar.hierarchy-publisher.publish-parent-warning',
+ defaultMessage: (
+ 'Its {parentCount, plural, one {parent unit} other {parent units}}'
+ + ' will be draft .'
+ ),
+ description: 'Parent details shown before publishing a component that has one or more parent units',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss
index a93dd477b..1404cb68f 100644
--- a/src/library-authoring/index.scss
+++ b/src/library-authoring/index.scss
@@ -5,6 +5,7 @@
@import "./units";
@import "./section-subsections";
@import "./containers";
+@import "./hierarchy";
.library-cards-grid {
display: grid;
diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx
index da81dd264..0ddda577c 100644
--- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx
+++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx
@@ -100,7 +100,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
>
-
+
)}
diff --git a/src/library-authoring/section-subsections/messages.ts b/src/library-authoring/section-subsections/messages.ts
index 5b038dbc6..896b4cbf6 100644
--- a/src/library-authoring/section-subsections/messages.ts
+++ b/src/library-authoring/section-subsections/messages.ts
@@ -16,6 +16,11 @@ 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({