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:
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
46
src/library-authoring/component-info/ComponentPublisher.tsx
Normal file
46
src/library-authoring/component-info/ComponentPublisher.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
12
src/library-authoring/component-info/ComponentUsageTab.tsx
Normal file
12
src/library-authoring/component-info/ComponentUsageTab.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
46
src/library-authoring/containers/ContainerPublisher.tsx
Normal file
46
src/library-authoring/containers/ContainerPublisher.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
@import "./ContainerCard.scss";
|
||||
@import "./ContainerPublishStatus.scss";
|
||||
@import "./ContainerHierarchy.scss";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "./history-widget/HistoryWidget";
|
||||
@import "./status-widget/StatusWidget";
|
||||
@import "./parent-breadcrumbs";
|
||||
@import "./publish-status-buttons";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PublishedChip } from './PublishedChip';
|
||||
export { PublishDraftButton } from './PublishDraftButton';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
137
src/library-authoring/hierarchy/ItemHierarchyPublisher.tsx
Normal file
137
src/library-authoring/hierarchy/ItemHierarchyPublisher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
141
src/library-authoring/hierarchy/messages.ts
Normal file
141
src/library-authoring/hierarchy/messages.ts
Normal 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;
|
||||
@@ -5,6 +5,7 @@
|
||||
@import "./units";
|
||||
@import "./section-subsections";
|
||||
@import "./containers";
|
||||
@import "./hierarchy";
|
||||
|
||||
.library-cards-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -100,7 +100,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
|
||||
>
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon size="xs" src={Description} />
|
||||
<FormattedMessage {...containerMessages.draftChipText} />
|
||||
<FormattedMessage {...messages.draftChipText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user