* View a unit page, which has its own URL * Components appear within a unit as full previews. Their top bar shows type icon and title on the left, and draft status (if any), tag count, overflow menu, and drag handle on the right. * Components have an overflow menu within a unit * Components can be selected within a unit * When components are selected, the standard component sidebar appears. The preview tab is hidden, since component previews are visible in the main content area. * Components within a unit full-page view have hover and selected states * Unit sidebar preview. * Frontend implementation Drag-n-drop components to reorder them in unit.
221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
import {
|
|
Button,
|
|
Tab,
|
|
Tabs,
|
|
Stack,
|
|
useToggle,
|
|
} from '@openedx/paragon';
|
|
import {
|
|
CheckBoxIcon,
|
|
CheckBoxOutlineBlank,
|
|
} from '@openedx/paragon/icons';
|
|
|
|
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
|
import { useLibraryContext } from '../common/context/LibraryContext';
|
|
import {
|
|
type ComponentInfoTab,
|
|
COMPONENT_INFO_TABS,
|
|
SidebarActions,
|
|
isComponentInfoTab,
|
|
useSidebarContext,
|
|
} from '../common/context/SidebarContext';
|
|
import ComponentMenu from '../components';
|
|
import { canEditComponent } from '../components/ComponentEditorModal';
|
|
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';
|
|
|
|
const AddComponentWidget = () => {
|
|
const intl = useIntl();
|
|
|
|
const { sidebarComponentInfo } = useSidebarContext();
|
|
|
|
const {
|
|
componentPickerMode,
|
|
onComponentSelected,
|
|
addComponentToSelectedComponents,
|
|
removeComponentFromSelectedComponents,
|
|
selectedComponents,
|
|
} = useComponentPickerContext();
|
|
|
|
const usageKey = sidebarComponentInfo?.id;
|
|
|
|
// istanbul ignore if: this should never happen
|
|
if (!usageKey) {
|
|
throw new Error('usageKey is required');
|
|
}
|
|
|
|
if (!componentPickerMode) {
|
|
return null;
|
|
}
|
|
|
|
if (componentPickerMode === 'single') {
|
|
return (
|
|
<Button
|
|
variant="outline-primary"
|
|
className="m-1 text-nowrap flex-grow-1"
|
|
onClick={() => {
|
|
onComponentSelected({ usageKey, blockType: getBlockType(usageKey) });
|
|
}}
|
|
>
|
|
{intl.formatMessage(messages.componentPickerSingleSelect)}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (componentPickerMode === 'multiple') {
|
|
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
|
|
const handleChange = () => {
|
|
const selectedComponent = {
|
|
usageKey,
|
|
blockType: getBlockType(usageKey),
|
|
};
|
|
if (!isChecked) {
|
|
addComponentToSelectedComponents(selectedComponent);
|
|
} else {
|
|
removeComponentFromSelectedComponents(selectedComponent);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Button
|
|
variant="outline-primary"
|
|
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
|
|
onClick={handleChange}
|
|
>
|
|
{intl.formatMessage(messages.componentPickerMultipleSelect)}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// istanbul ignore next: this should never happen
|
|
return null;
|
|
};
|
|
|
|
const ComponentInfo = () => {
|
|
const intl = useIntl();
|
|
|
|
const { readOnly, openComponentEditor } = useLibraryContext();
|
|
const {
|
|
sidebarTab,
|
|
setSidebarTab,
|
|
sidebarComponentInfo,
|
|
sidebarAction,
|
|
defaultTab,
|
|
hiddenTabs,
|
|
} = useSidebarContext();
|
|
const [
|
|
isPublishConfirmationOpen,
|
|
openPublishConfirmation,
|
|
closePublishConfirmation,
|
|
] = useToggle(false);
|
|
|
|
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
|
|
|
|
const tab: ComponentInfoTab = (
|
|
isComponentInfoTab(sidebarTab)
|
|
? sidebarTab
|
|
: defaultTab.component
|
|
);
|
|
|
|
useEffect(() => {
|
|
// Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo
|
|
if (jumpToCollections) {
|
|
setSidebarTab(COMPONENT_INFO_TABS.Manage);
|
|
}
|
|
}, [jumpToCollections, setSidebarTab]);
|
|
|
|
const usageKey = sidebarComponentInfo?.id;
|
|
// istanbul ignore if: this should never happen
|
|
if (!usageKey) {
|
|
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]);
|
|
|
|
// TODO: refactor sidebar Tabs to handle rendering and disabledTabs in one place.
|
|
const renderTab = React.useCallback((infoTab: ComponentInfoTab, component: React.ReactNode, title: string) => {
|
|
if (hiddenTabs.includes(infoTab)) {
|
|
// For some reason, returning anything other than empty list breaks the tab style
|
|
return [];
|
|
}
|
|
return (
|
|
<Tab eventKey={infoTab} title={title}>
|
|
{component}
|
|
</Tab>
|
|
);
|
|
}, [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={setSidebarTab}
|
|
>
|
|
{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}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ComponentInfo;
|