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.
This commit is contained in:
Chris Chávez
2025-09-05 12:12:40 -05:00
committed by GitHub
parent a7860b8392
commit 67fab054ab
34 changed files with 942 additions and 665 deletions

View File

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

View File

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

View File

@@ -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: () => <div>Mocked preview</div>,
@@ -78,26 +80,57 @@ describe('<ComponentInfo> 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(
<ComponentInfo />,
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(
<ComponentInfo />,
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(
<ComponentInfo />,
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('<ComponentInfo> 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('<ComponentInfo> 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('<ComponentInfo> 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('<ComponentInfo> 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('<ComponentInfo> 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.');

View File

@@ -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 (
<ComponentPublisher
handleClose={closePublisher}
componentId={componentId}
/>
);
}
return (
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(componentId) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<div className="flex-grow-1">
{!hasUnpublishedChanges ? (
<div className="m-1">
<PublishedChip />
</div>
) : (
<PublishDraftButton
onClick={openPublisher}
/>
)}
</div>
<div className="mt-2">
<ComponentMenu usageKey={componentId} />
</div>
</div>
);
};
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 (
<>
<Stack>
{!readOnly && (
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button
disabled={publishComponent.isLoading || !canPublish}
onClick={openPublishConfirmation}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</div>
)}
<AddComponentWidget />
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey={defaultTab.component}
activeKey={tab}
onSelect={handleTabChange}
>
{renderTab(COMPONENT_INFO_TABS.Preview, <ComponentPreview />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(COMPONENT_INFO_TABS.Manage, <ComponentManagement />, intl.formatMessage(messages.manageTabTitle))}
{renderTab(COMPONENT_INFO_TABS.Details, <ComponentDetails />, intl.formatMessage(messages.detailsTabTitle))}
</Tabs>
</Stack>
<PublishConfirmationModal
isOpen={isPublishConfirmationOpen}
onClose={closePublishConfirmation}
onConfirm={publish}
displayName={componentMetadata?.displayName || ''}
usageKey={usageKey}
showDownstreams={!!componentMetadata?.lastPublished}
/>
</>
<Stack>
{!readOnly && (
<ComponentActions
componentId={componentId}
hasUnpublishedChanges={hasUnpublishedChanges}
/>
)}
<AddComponentWidget />
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey={defaultTab.component}
activeKey={tab}
onSelect={handleTabChange}
>
{renderTab(COMPONENT_INFO_TABS.Preview, <ComponentPreview />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(COMPONENT_INFO_TABS.Manage, <ComponentManagement />, intl.formatMessage(messages.manageTabTitle))}
{renderTab(COMPONENT_INFO_TABS.Usage, <ComponentUsageTab />, intl.formatMessage(messages.usageTabTitle))}
{renderTab(COMPONENT_INFO_TABS.Details, <ComponentDetails />, intl.formatMessage(messages.detailsTabTitle))}
</Tabs>
</Stack>
);
};

View File

@@ -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 (
<ItemHierarchyPublisher
itemId={componentId}
handleClose={handleClose}
handlePublish={handlePublish}
/>
);
};

View File

@@ -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(<ComponentUsageTab />, {
path: `/library/${libraryId}/components/${usageKey}`,
params: { libraryId, selectedItemId: usageKey },
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId}>
<SidebarProvider
initialSidebarItemInfo={{
id: usageKey,
type: SidebarBodyItemId.ComponentInfo,
}}
>
{children}
</SidebarProvider>
</LibraryProvider>
),
});
describe('<ComponentUsageTab />', () => {
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();
});
});

View File

@@ -0,0 +1,12 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { ItemHierarchy } from '../hierarchy/ItemHierarchy';
export const ComponentUsageTab = () => (
<>
<h4>
<FormattedMessage {...messages.usageTabHierarchyHeading} />
</h4>
<ItemHierarchy />
</>
);

View File

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

View File

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

View File

@@ -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 (
<BaseModal
isOpen={isOpen}
close={onClose}
title={intl.formatMessage(
messages.publishConfirmationTitle,
{ displayName },
)}
confirmAction={(
<Button onClick={onConfirm}>
<FormattedMessage {...messages.publishConfirmationButton} />
</Button>
)}
>
<div>
<div className="pt-4">
{intl.formatMessage(messages.publishConfirmationBody)}
<div className="mt-2 p-2 border">
{displayName}
</div>
</div>
{showDownstreams && (
<div className="mt-4">
{hasDownstreamUsages ? (
<>
<FormattedMessage {...messages.publishConfimrationDownstreamsBody} />
<div className="mt-3 mb-3 border">
<ComponentUsage usageKey={usageKey} />
</div>
<Alert variant="warning">
<FormattedMessage {...messages.publishConfirmationDownstreamsAlert} />
</Alert>
</>
) : (
<FormattedMessage {...infoMessages.detailsTabUsageEmpty} />
)}
</div>
)}
</div>
</BaseModal>
);
};
export default PublishConfirmationModal;

View File

@@ -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 <LibraryContainerChildren containerKey={containerId} readOnly />;
};
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 (
<ContainerPublisher
handleClose={closePublisher}
containerId={containerId}
/>
);
}
return (
<div className="d-flex flex-wrap">
{showOpenButton && (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
as={Link}
to={`/library/${libraryId}/${containerType}/${containerId}`}
>
{intl.formatMessage(messages.openButton)}
</Button>
)}
{!componentPickerMode && (
!hasUnpublishedChanges ? (
<div className="m-1 flex-grow-1">
<PublishedChip />
</div>
) : (
<div className="flex-grow-1">
<PublishDraftButton
onClick={openPublisher}
/>
</div>
)
)}
{showOpenButton && (
<div className="mt-1">
<ContainerMenu containerId={containerId} />
</div>
)}
</div>
);
};
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 (
<Stack>
<div className="d-flex flex-wrap">
{showOpenButton && (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
as={Link}
to={`/library/${libraryId}/${containerType}/${containerId}`}
>
{intl.formatMessage(messages.openButton)}
</Button>
)}
{!showOpenButton && !componentPickerMode && (
<ContainerPublishStatus
containerId={containerId}
/>
)}
{showOpenButton && (
<ContainerMenu containerId={containerId} />
)}
</div>
<ContainerActions
containerId={containerId}
containerType={containerType}
hasUnpublishedChanges={container.hasUnpublishedChanges}
/>
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"

View File

@@ -1,210 +0,0 @@
/**
* Shows the LibraryContainer's publish status,
* and enables publishing any unpublished changes.
*/
import { type ReactNode, useContext, useCallback } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import type { MessageDescriptor } from 'react-intl';
import {
ActionRow,
Button,
Container,
useToggle,
} from '@openedx/paragon';
import Loading from '@src/generic/Loading';
import LoadingButton from '@src/generic/loading-button';
import { ToastContext } from '@src/generic/toast-context';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useContainer, useContainerHierarchy, usePublishContainer } from '../data/apiHooks';
import ContainerHierarchy from './ContainerHierarchy';
import messages from './messages';
type ContainerPublisherProps = {
close: () => void;
containerId: string;
};
const ContainerPublisher = ({
close,
containerId,
}: ContainerPublisherProps) => {
const intl = useIntl();
const containerType = getBlockType(containerId);
const publishContainer = usePublishContainer(containerId);
const {
data: hierarchy,
isLoading,
isError,
} = useContainerHierarchy(containerId);
const { showToast } = useContext(ToastContext);
const handlePublish = useCallback(async () => {
try {
await publishContainer.mutateAsync();
showToast(intl.formatMessage(messages.publishContainerSuccess));
} catch (error) {
showToast(intl.formatMessage(messages.publishContainerFailed));
}
close();
}, [publishContainer, showToast]);
if (isLoading) {
return <Loading />;
}
// istanbul ignore if: this should never happen
if (isError) {
return null;
}
const highlight = (...chunks: ReactNode[]) => <strong>{chunks}</strong>;
const childWarningMessage = () => {
let childCount: number;
let childMessage: MessageDescriptor;
let noChildMessage: MessageDescriptor;
switch (containerType) {
case ContainerType.Section:
childCount = hierarchy.subsections.length;
childMessage = messages.publishSectionWithChildrenWarning;
noChildMessage = messages.publishSectionWarning;
break;
case ContainerType.Subsection:
childCount = hierarchy.units.length;
childMessage = messages.publishSubsectionWithChildrenWarning;
noChildMessage = messages.publishSubsectionWarning;
break;
default: // ContainerType.Unit
childCount = hierarchy.components.length;
childMessage = messages.publishUnitWithChildrenWarning;
noChildMessage = messages.publishUnitWarning;
}
return intl.formatMessage(
childCount ? childMessage : noChildMessage,
{
childCount,
highlight,
},
);
};
const parentWarningMessage = () => {
let parentCount: number;
let parentMessage: MessageDescriptor;
switch (containerType) {
case ContainerType.Subsection:
parentMessage = messages.publishSubsectionWithParentWarning;
parentCount = hierarchy.sections.length;
break;
case ContainerType.Unit:
parentMessage = messages.publishUnitWithParentWarning;
parentCount = hierarchy.subsections.length;
break;
default: // ContainerType.Section has no parents
return undefined;
}
return intl.formatMessage(parentMessage, { parentCount, highlight });
};
return (
<Container
className="p-3 status-box draft-status"
>
<h4>{intl.formatMessage(messages.publishContainerConfirmHeading)}</h4>
<p>{childWarningMessage()} {parentWarningMessage()}</p>
<ContainerHierarchy showPublishStatus />
<ActionRow>
<Button
variant="outline-primary rounded-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
close();
}}
>
{intl.formatMessage(messages.publishContainerCancel)}
</Button>
<LoadingButton
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
await handlePublish();
}}
variant="primary rounded-0"
label={intl.formatMessage(messages.publishContainerConfirm)}
/>
</ActionRow>
</Container>
);
};
type ContainerPublishStatusProps = {
containerId: string;
};
const ContainerPublishStatus = ({
containerId,
}: ContainerPublishStatusProps) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const [isConfirmingPublish, confirmPublish, cancelPublish] = useToggle(false);
const {
data: container,
isLoading,
isError,
} = useContainer(containerId);
if (isLoading) {
return <Loading />;
}
// istanbul ignore if: this should never happen
if (isError) {
return null;
}
if (!container.hasUnpublishedChanges) {
return (
<Container
className="p-2 text-nowrap flex-grow-1 status-button published-status font-weight-bold"
>
{intl.formatMessage(messages.publishedChipText)}
</Container>
);
}
return (
(isConfirmingPublish
? (
<ContainerPublisher
close={cancelPublish}
containerId={containerId}
/>
) : (
<Button
variant="outline-primary rounded-0 status-button draft-status font-weight-bold"
className="m-1 flex-grow-1"
disabled={readOnly}
onClick={confirmPublish}
>
<FormattedMessage
{...messages.publishContainerButton}
values={{
publishStatus: (
<span className="font-weight-500">
({intl.formatMessage(messages.draftChipText)})
</span>
),
}}
/>
</Button>
)
)
);
};
export default ContainerPublishStatus;

View File

@@ -0,0 +1,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 (
<ItemHierarchyPublisher
itemId={containerId}
handleClose={handleClose}
handlePublish={handlePublish}
/>
);
};

View File

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

View File

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

View File

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

View File

@@ -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 <highlight>published</highlight>.',
description: 'Content details shown before publishing an empty section',
},
publishSectionWithChildrenWarning: {
id: 'course-authoring.library-authoring.section-sidebar.publish-warning',
defaultMessage: (
'This section and the {childCount, plural, one {subsection} other {subsections}}'
+ ' it contains will all be <highlight>published</highlight>.'
),
description: 'Content details shown before publishing a section that contains subsections',
},
publishSubsectionWarning: {
id: 'course-authoring.library-authoring.subsection-sidebar.publish-empty-warning',
defaultMessage: 'This subsection will be <highlight>published</highlight>.',
description: 'Content details shown before publishing an empty subsection',
},
publishSubsectionWithChildrenWarning: {
id: 'course-authoring.library-authoring.subsection-sidebar.publish-warning',
defaultMessage: (
'This subsection and the {childCount, plural, one {unit} other {units}}'
+ ' it contains will all be <highlight>published</highlight>.'
),
description: 'Content details shown before publishing a subsection that contains units',
},
publishSubsectionWithParentWarning: {
id: 'course-authoring.library-authoring.subsection-sidebar.publish-parent-warning',
defaultMessage: (
'Its {parentCount, plural, one {parent section} other {parent sections}}'
+ ' will be <highlight>draft</highlight>.'
),
description: 'Parent details shown before publishing a unit that has one or more parent subsections',
},
publishUnitWarning: {
id: 'course-authoring.library-authoring.unit-sidebar.publish-empty-warning',
defaultMessage: 'This unit will be <highlight>published</highlight>.',
description: 'Content details shown before publishing an empty unit',
},
publishUnitWithChildrenWarning: {
id: 'course-authoring.library-authoring.unit-sidebar.publish-warning',
defaultMessage: (
'This unit and the {childCount, plural, one {component} other {components}}'
+ ' it contains will all be <highlight>published</highlight>.'
),
description: 'Content details shown before publishing a unit that contains components',
},
publishUnitWithParentWarning: {
id: 'course-authoring.library-authoring.unit-sidebar.publish-parent-warning',
defaultMessage: (
'Its {parentCount, plural, one {parent subsection} other {parent subsections}}'
+ ' will be <highlight>draft</highlight>.'
),
description: 'Parent details shown before publishing a unit that has one or more parent subsections',
},
settingsTabTitle: {
id: 'course-authoring.library-authoring.container-sidebar.settings-tab.title',
defaultMessage: 'Settings',
@@ -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;

View File

@@ -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<api.ItemHierarchyData> {
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<api.ContainerHierarchyData> {
export async function mockGetContainerHierarchy(containerId: string): Promise<api.ItemHierarchyData> {
const getChildren = (childId: string, childCount: number) => {
let blockType = 'html';
let name = 'text';
@@ -737,7 +789,7 @@ export async function mockGetContainerHierarchy(containerId: string): Promise<ap
mockGetContainerHierarchy.unitIdOneChild = 'lct:org:lib:unit:test-unit-one';
mockGetContainerHierarchy.sectionIdOneChild = 'lct:org:lib:section:test-section-one';
mockGetContainerHierarchy.subsectionIdOneChild = 'lb:org1:Demo_course:subsection:subsection-one';
mockGetContainerHierarchy.subsectionIdOneChild = 'lct:org1:Demo_course:subsection:subsection-one';
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetContainerHierarchy.applyMock = () => {

View File

@@ -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<ItemHierarchyData> {
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<ContainerHierarchyData> {
): Promise<ItemHierarchyData> {
const { data } = await getAuthenticatedHttpClient().get(
getLibraryContainerHierarchyApiUrl(containerId),
);

View File

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

View File

@@ -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<api.ItemHierarchyData>;
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) });
},
});
};

View File

@@ -1,3 +1,4 @@
@import "./history-widget/HistoryWidget";
@import "./status-widget/StatusWidget";
@import "./parent-breadcrumbs";
@import "./publish-status-buttons";

View File

@@ -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 (
<Button
variant="outline-primary w-100 rounded-0 status-button draft-status font-weight-bold"
className="m-1"
disabled={readOnly}
onClick={onClick}
>
<FormattedMessage
{...messages.publishContainerButton}
values={{
publishStatus: (
<span className="font-weight-500">
({intl.formatMessage(messages.draftChipText)})
</span>
),
}}
/>
</Button>
);
};

View File

@@ -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 = () => (
<Container
className="p-2 text-nowrap flex-grow-1 status-button published-status font-weight-bold"
>
<FormattedMessage {...messages.publishedChipText} />
</Container>
);

View File

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

View File

@@ -0,0 +1,2 @@
export { PublishedChip } from './PublishedChip';
export { PublishDraftButton } from './PublishDraftButton';

View File

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

View File

@@ -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 = ({
</Stack>
);
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 <Loading />;
@@ -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 (
<Stack className="content-hierarchy">
{showSections && (
<ContainerHierarchyRow
<HierarchyRow
containerType={ContainerType.Section}
text={intl.formatMessage(
messages.hierarchySections,
@@ -157,7 +159,7 @@ const ContainerHierarchy = ({
/>
)}
{showSubsections && (
<ContainerHierarchyRow
<HierarchyRow
containerType={ContainerType.Subsection}
text={intl.formatMessage(
messages.hierarchySubsections,
@@ -173,7 +175,7 @@ const ContainerHierarchy = ({
/>
)}
{showUnits && (
<ContainerHierarchyRow
<HierarchyRow
containerType={ContainerType.Unit}
text={intl.formatMessage(
messages.hierarchyUnits,
@@ -189,7 +191,7 @@ const ContainerHierarchy = ({
/>
)}
{showComponents && (
<ContainerHierarchyRow
<HierarchyRow
containerType={ContainerType.Components}
text={intl.formatMessage(
messages.hierarchyComponents,
@@ -207,5 +209,3 @@ const ContainerHierarchy = ({
</Stack>
);
};
export default ContainerHierarchy;

View File

@@ -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 <Loading />;
}
// istanbul ignore if: this should never happen
if (isError) {
return null;
}
const highlight = (...chunks: ReactNode[]) => <strong>{chunks}</strong>;
const childWarningMessage = () => {
let childCount: number;
let childMessage: MessageDescriptor;
let noChildMessage: MessageDescriptor;
switch (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 (
<Container
className="p-3 status-box draft-status"
>
<h4>{intl.formatMessage(messages.publishConfirmHeading)}</h4>
<p>{childWarningMessage()} {parentWarningMessage()}</p>
<ItemHierarchy showPublishStatus />
<ActionRow>
<Button
variant="outline-primary rounded-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClose();
}}
>
{intl.formatMessage(messages.publishCancel)}
</Button>
<LoadingButton
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
await handlePublish();
}}
variant="primary rounded-0"
label={intl.formatMessage(messages.publishConfirm)}
/>
</ActionRow>
</Container>
);
};

View File

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

View File

@@ -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 <highlight>published</highlight>.'
),
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 <highlight>published</highlight>.',
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 <highlight>published</highlight>.'
),
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 <highlight>published</highlight>.',
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 <highlight>published</highlight>.'
),
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 <highlight>published</highlight>.',
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 <highlight>draft</highlight>.'
),
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 <highlight>draft</highlight>.'
),
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 <highlight>published</highlight>.',
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 <highlight>draft</highlight>.'
),
description: 'Parent details shown before publishing a component that has one or more parent units',
},
});
export default messages;

View File

@@ -5,6 +5,7 @@
@import "./units";
@import "./section-subsections";
@import "./containers";
@import "./hierarchy";
.library-cards-grid {
display: grid;

View File

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

View File

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