From 67fab054ab4a79edb991d0f65791832aa8409180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Fri, 5 Sep 2025 12:12:40 -0500 Subject: [PATCH] feat: Secondary publish workflow for components [FC-0097] (#2399) - Adds the new publish button and the new confirm publish box for components. - Deletes the old confirm publish modal for components - Adds the publish button next to the open button for containers - Update changes to grand-parent and grand-child items. --- .../LibraryAuthoringPage.scss | 2 +- .../common/context/SidebarContext.tsx | 1 + .../component-info/ComponentInfo.test.tsx | 97 +++++--- .../component-info/ComponentInfo.tsx | 152 +++++++------ .../component-info/ComponentPublisher.tsx | 46 ++++ .../component-info/ComponentUsageTab.test.tsx | 57 +++++ .../component-info/ComponentUsageTab.tsx | 12 + .../component-info/messages.ts | 15 +- .../components/ComponentMenu.tsx | 1 + .../components/PublishConfirmationModal.tsx | 80 ------- .../containers/ContainerInfo.tsx | 97 +++++--- .../containers/ContainerPublishStatus.tsx | 210 ------------------ .../containers/ContainerPublisher.tsx | 46 ++++ .../containers/ContainerUsage.tsx | 4 +- src/library-authoring/containers/index.scss | 2 - src/library-authoring/containers/index.tsx | 1 - src/library-authoring/containers/messages.ts | 142 +----------- src/library-authoring/data/api.mocks.ts | 64 +++++- src/library-authoring/data/api.ts | 39 ++-- src/library-authoring/data/apiHooks.test.tsx | 4 +- src/library-authoring/data/apiHooks.ts | 79 ++++--- src/library-authoring/generic/index.scss | 1 + .../PublishDraftButton.tsx | 46 ++++ .../publish-status-buttons/PublishedChip.tsx | 18 ++ .../publish-status-buttons/index.scss} | 17 -- .../generic/publish-status-buttons/index.tsx | 2 + .../publish-status-buttons/messages.ts | 21 ++ .../ItemHierarchy.tsx} | 48 ++-- .../hierarchy/ItemHierarchyPublisher.tsx | 137 ++++++++++++ .../index.scss} | 17 ++ src/library-authoring/hierarchy/messages.ts | 141 ++++++++++++ src/library-authoring/index.scss | 1 + .../LibraryContainerChildren.tsx | 2 +- .../section-subsections/messages.ts | 5 + 34 files changed, 942 insertions(+), 665 deletions(-) create mode 100644 src/library-authoring/component-info/ComponentPublisher.tsx create mode 100644 src/library-authoring/component-info/ComponentUsageTab.test.tsx create mode 100644 src/library-authoring/component-info/ComponentUsageTab.tsx delete mode 100644 src/library-authoring/components/PublishConfirmationModal.tsx delete mode 100644 src/library-authoring/containers/ContainerPublishStatus.tsx create mode 100644 src/library-authoring/containers/ContainerPublisher.tsx create mode 100644 src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx create mode 100644 src/library-authoring/generic/publish-status-buttons/PublishedChip.tsx rename src/library-authoring/{containers/ContainerPublishStatus.scss => generic/publish-status-buttons/index.scss} (51%) create mode 100644 src/library-authoring/generic/publish-status-buttons/index.tsx create mode 100644 src/library-authoring/generic/publish-status-buttons/messages.ts rename src/library-authoring/{containers/ContainerHierarchy.tsx => hierarchy/ItemHierarchy.tsx} (88%) create mode 100644 src/library-authoring/hierarchy/ItemHierarchyPublisher.tsx rename src/library-authoring/{containers/ContainerHierarchy.scss => hierarchy/index.scss} (85%) create mode 100644 src/library-authoring/hierarchy/messages.ts 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 ( +
+ +
+ {!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 && ( -
- - - -
- )} - - - {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 && ( + + )} + {!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 && ( - - )} - {!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(); - await handlePublish(); - }} - variant="primary rounded-0" - label={intl.formatMessage(messages.publishContainerConfirm)} - /> - -
- ); -}; - -type ContainerPublishStatusProps = { - containerId: string; -}; - -const ContainerPublishStatus = ({ - containerId, -}: ContainerPublishStatusProps) => { - const intl = useIntl(); - const { readOnly } = useLibraryContext(); - const [isConfirmingPublish, confirmPublish, cancelPublish] = useToggle(false); - const { - data: container, - isLoading, - isError, - } = useContainer(containerId); - - if (isLoading) { - return ; - } - - // istanbul ignore if: this should never happen - if (isError) { - return null; - } - - if (!container.hasUnpublishedChanges) { - return ( - - {intl.formatMessage(messages.publishedChipText)} - - ); - } - - return ( - (isConfirmingPublish - ? ( - - ) : ( - - ) - ) - ); -}; - -export default ContainerPublishStatus; diff --git a/src/library-authoring/containers/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 ( + + ); +}; 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(); + 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({