feat: Make selectable component cards & Component Info Sidebar [FC-0114] (#2880)

- Changes in the Unit sidebar context to enable selected components
- Implements the component info sidebar.
- Implements the container/component selection when opening the align sidebar
This commit is contained in:
Chris Chávez
2026-02-19 17:02:26 -05:00
committed by GitHub
parent 5ccf39d130
commit 7c1eb59f18
34 changed files with 516 additions and 179 deletions

View File

@@ -18,7 +18,7 @@ import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { RequestStatus, RequestStatusType } from './data/constants';
type ModalState = {
value: XBlock | UnitXBlock;
value?: XBlock | UnitXBlock;
subsectionId?: string;
sectionId?: string;
};

View File

@@ -403,10 +403,19 @@ mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mo
/**
* Mock for `getContentData()`
*/
export async function mockContentData(): Promise<any> {
return mockContentData.data;
export async function mockContentData(contentId: string): Promise<any> {
switch (contentId) {
case mockContentData.textXBlock:
return mockContentData.textXBlockData;
default:
return mockContentData.data;
}
}
mockContentData.data = {
displayName: 'Unit 1',
};
mockContentData.textXBlock = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
mockContentData.textXBlockData = {
displayName: 'Text XBlock 1',
};
mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData);

View File

@@ -48,6 +48,7 @@ interface CardHeaderProps {
onClickMoveDown: () => void;
onClickCopy?: () => void;
onClickCard?: (e: React.MouseEvent) => void;
onClickManageTags?: () => void;
titleComponent: ReactNode;
namePrefix: string;
proctoringExamConfigurationLink?: string,
@@ -86,6 +87,7 @@ const CardHeader = ({
onClickMoveDown,
onClickCopy,
onClickCard,
onClickManageTags,
titleComponent,
namePrefix,
actions,
@@ -113,6 +115,7 @@ const CardHeader = ({
if (showNewSidebar && showAlignSidebar) {
setCurrentPageKey('align');
onClickMenuButton();
onClickManageTags?.();
} else {
openLegacyTagsDrawer();
}

View File

@@ -9,12 +9,11 @@ import { useOutlineSidebarContext } from './OutlineSidebarContext';
export const OutlineAlignSidebar = () => {
const {
courseId,
currentSelection,
setCurrentSelection,
} = useCourseAuthoringContext();
const { selectedContainerState, clearSelection } = useOutlineSidebarContext();
const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId;
const sidebarContentId = selectedContainerState?.currentId || courseId;
const { data: contentData } = useContentData(sidebarContentId);

View File

@@ -36,6 +36,7 @@ interface OutlineSidebarContextData {
open: () => void;
toggle: () => void;
selectedContainerState?: SelectionState;
setSelectedContainerState: (selectedContainerState?: SelectionState) => void;
openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void;
clearSelection: () => void;
/** Stores last section that allows adding subsections inside it. */
@@ -188,6 +189,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
open,
toggle,
selectedContainerState,
setSelectedContainerState,
openContainerInfoSidebar,
clearSelection,
lastEditableSection,
@@ -205,6 +207,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
open,
toggle,
selectedContainerState,
setSelectedContainerState,
openContainerInfoSidebar,
clearSelection,
lastEditableSection,

View File

@@ -2,12 +2,19 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { SchoolOutline, Tag } from '@openedx/paragon/icons';
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils';
import { normalizeContainerType } from '@src/generic/key-utils';
import { SidebarContent, SidebarSection } from '@src/generic/sidebar';
import { useGetBlockTypes } from '@src/search-manager';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard';
import messages from '../messages';
interface Props {
@@ -16,17 +23,46 @@ interface Props {
export const InfoSection = ({ itemId }: Props) => {
const intl = useIntl();
const dispatch = useDispatch();
const queryClient = useQueryClient();
const { data: itemData } = useCourseItemData(itemId);
const { data: componentData } = useGetBlockTypes(
[`breadcrumbs.usage_key = "${itemId}"`],
);
const category = normalizeContainerType(itemData?.category || '');
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { courseId } = useCourseAuthoringContext();
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
/**
* Called after a library component sync operation completes (e.g. accepting or ignoring
* an upstream update). Refreshes all stale data that may have been affected:
* - Re-fetches the parent section's outline data so counts/status stay current.
* - Invalidates the library links query so the sync-status badges update.
* - Invalidates the full course outline query so the top-level view reflects the change.
*/
// istanbul ignore next
const handleOnPostChangeSync = useCallback(() => {
if (selectedContainerState?.sectionId) {
dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId]));
}
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.course(courseId),
});
}
}, [dispatch, selectedContainerState, queryClient, courseId]);
return (
<>
<LibraryReferenceCard itemId={itemId} />
<LibraryReferenceCard
itemId={itemId}
sectionId={selectedContainerState?.sectionId}
postChange={handleOnPostChangeSync}
goToParent={openContainerInfoSidebar}
/>
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages[`${category}ContentSummaryText`])}

View File

@@ -170,66 +170,6 @@ const messages = defineMessages({
defaultMessage: 'Settings',
description: 'Settings tab title in container sidebar',
},
libraryReferenceCardText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.text',
defaultMessage: 'Library Reference',
description: 'Library reference card text in sidebar',
},
hasTopParentText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text',
defaultMessage: '{name} was reused as part of a {parentType}.',
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block',
},
hasTopParentBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn',
defaultMessage: 'View {parentType}',
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block',
},
hasTopParentReadyToSyncText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text',
defaultMessage: '{name} was reused as part of a {parentType} which has updates available.',
description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block',
},
hasTopParentReadyToSyncBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn',
defaultMessage: 'Review Updates',
description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block',
},
hasTopParentBrokenLinkText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text',
defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.',
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.',
},
hasTopParentBrokenLinkBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn',
defaultMessage: 'Unlink {parentType}',
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.',
},
topParentBrokenLinkText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text',
defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.',
description: 'Text displayed in sidebar library reference card when a block has a broken link.',
},
topParentBrokenLinkBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn',
defaultMessage: 'Unlink from library',
description: 'Text displayed in sidebar library reference card button when a block has a broken link.',
},
topParentModifiedText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text',
defaultMessage: '{name} has been modified in this course.',
description: 'Text displayed in sidebar library reference card when it is modified in course.',
},
topParentReaadyToSyncText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text',
defaultMessage: '{name} has available updates',
description: 'Text displayed in sidebar library reference card when it is has updates available.',
},
topParentReaadyToSyncBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn',
defaultMessage: 'Review Updates',
description: 'Text displayed in sidebar library reference card button when it is has updates available.',
},
cannotAddAlertMsg: {
id: 'course-authoring.course-outline.sidebar.library.reference.add-sidebar.alert.text',
defaultMessage: '{name} is a library {category}. Content cannot be added to Library referenced {category}s.',

View File

@@ -321,6 +321,7 @@ describe('<SectionCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -346,6 +347,7 @@ describe('<SectionCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -369,5 +371,9 @@ describe('<SectionCard />', () => {
currentId: section.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: section.id,
sectionId: section.id,
});
});
});

View File

@@ -62,7 +62,7 @@ const SectionCard = ({
const currentRef = useRef(null);
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const {
@@ -199,6 +199,13 @@ const SectionCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: section.id,
sectionId: section.id,
});
};
const handleOpenHighlightsModal = () => {
onOpenHighlightsModal(section);
};
@@ -284,6 +291,7 @@ const SectionCard = ({
onClickSync={openSyncModal}
onClickCard={(e) => onClickCard(e, true)}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -416,6 +416,7 @@ describe('<SubsectionCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -441,6 +442,7 @@ describe('<SubsectionCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -465,5 +467,10 @@ describe('<SubsectionCard />', () => {
subsectionId: subsection.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: subsection.id,
subsectionId: subsection.id,
sectionId: section.id,
});
});
});

View File

@@ -67,7 +67,7 @@ const SubsectionCard = ({
const intl = useIntl();
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
@@ -162,6 +162,14 @@ const SubsectionCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: subsection.id,
subsectionId: subsection.id,
sectionId: section.id,
});
};
const handleOnPostChangeSync = useCallback(() => {
dispatch(fetchCourseSectionQuery([section.id]));
if (courseId) {
@@ -290,6 +298,7 @@ const SubsectionCard = ({
onClickSync={openSyncModal}
onClickCard={(e) => onClickCard(e, true)}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -307,6 +307,7 @@ describe('<UnitCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -332,6 +333,7 @@ describe('<UnitCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -356,5 +358,10 @@ describe('<UnitCard />', () => {
subsectionId: subsection.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: unit.id,
subsectionId: subsection.id,
sectionId: section.id,
});
});
});

View File

@@ -63,7 +63,7 @@ const UnitCard = ({
const currentRef = useRef(null);
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const locatorId = searchParams.get('show');
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
const namePrefix = 'unit';
@@ -140,6 +140,14 @@ const UnitCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: unit.id,
subsectionId: subsection.id,
sectionId: section.id,
});
};
const handleUnitMoveUp = () => {
onOrderChange(section, moveUpDetails);
};
@@ -269,6 +277,7 @@ const UnitCard = ({
onClickSync={openSyncModal}
onClickCard={onClickCard}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -25,6 +25,7 @@ import { getClipboardUrl } from '@src/generic/data/api';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { mockContentData } from '@src/content-tags-drawer/data/api.mocks';
import {
mockContentLibrary,
mockGetContentLibraryV2List,
@@ -89,6 +90,7 @@ mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetContentLibraryV2List.applyMock();
mockLibraryBlockMetadata.applyMock();
mockContentData.applyMock();
const {
block_id: id,
@@ -2939,9 +2941,10 @@ describe('<CourseUnit />', () => {
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId });
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: mockContentData.textXBlock });
await screen.findByText('Align');
await screen.findByText(mockContentData.textXBlockData.displayName);
});
describe('Add sidebar', () => {
@@ -3244,4 +3247,21 @@ describe('<CourseUnit />', () => {
expect(sidebarToggle).toBeInTheDocument();
expect(within(sidebarToggle).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
});
it('opens the component info sidebar on postMessage event', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
render(<RootWrapper />);
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
simulatePostMessageEvent(messageTypes.xblockSelected, {
contentId: mockContentData.textXBlock,
});
await screen.findByText(mockContentData.textXBlockData.displayName);
});
});

View File

@@ -79,4 +79,7 @@ export const messageTypes = {
copyXBlockLegacy: 'copyXBlockLegacy',
hideProcessingNotification: 'hideProcessingNotification',
handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage',
xblockSelected: 'xblockSelected',
clearSelection: 'clearSelection',
selectXblock: 'selectXBlock',
};

View File

@@ -91,7 +91,7 @@ describe('<HeaderNavigations />', () => {
expect(infoButton).toBeInTheDocument();
await user.click(infoButton);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info');
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info', null);
});
it('click Add button should open add sidebar', async () => {
@@ -107,6 +107,6 @@ describe('<HeaderNavigations />', () => {
expect(addButton).toBeInTheDocument();
await user.click(addButton);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add');
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add', null);
});
});

View File

@@ -52,7 +52,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat
<Button
variant="outline-primary"
iconBefore={InfoOutline}
onClick={() => setCurrentPageKey('info')}
onClick={() => setCurrentPageKey('info', null)}
>
{intl.formatMessage(messages.infoButton)}
</Button>
@@ -60,7 +60,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat
<Button
variant="outline-primary"
iconBefore={Add}
onClick={() => setCurrentPageKey('add')}
onClick={() => setCurrentPageKey('add', null)}
>
{intl.formatMessage(messages.addButton)}
</Button>

View File

@@ -139,8 +139,9 @@ export const useCourseUnit = ({
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
const unitXBlockActions = {
handleDelete: (XBlockId) => {
dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
handleDelete: async (XBlockId) => {
// oxlint-disable-next-line typescript-eslint(await-thenable)
await dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
},
handleDuplicate: (XBlockId) => {
dispatch(duplicateUnitItemQuery(

View File

@@ -1,4 +1,5 @@
import { render, screen, initializeMocks } from '@src/testUtils';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { UnitSidebarProvider } from './UnitSidebarContext';
@@ -16,9 +17,11 @@ jest.mock('react-router-dom', () => ({
}));
const renderComponent = () => render(
<UnitSidebarProvider readOnly={false}>
<UnitAlignSidebar />
</UnitSidebarProvider>,
<IframeProvider>
<UnitSidebarProvider readOnly={false}>
<UnitAlignSidebar />
</UnitSidebarProvider>
</IframeProvider>,
);
describe('OutlineAlignSidebar', () => {

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
import { AlignSidebar } from '@src/generic/sidebar/AlignSidebar';
import { useCallback } from 'react';
import { useUnitSidebarContext } from './UnitSidebarContext';
/**
@@ -9,18 +9,19 @@ import { useUnitSidebarContext } from './UnitSidebarContext';
*/
export const UnitAlignSidebar = () => {
const { blockId } = useParams();
const { currentComponentId, setCurrentPageKey } = useUnitSidebarContext();
const { selectedComponentId, setCurrentPageKey } = useUnitSidebarContext();
const sidebarContentId = currentComponentId || blockId;
const sidebarContentId = selectedComponentId || blockId;
const {
data: contentData,
} = useContentData(sidebarContentId);
// istanbul ignore next
const handleBack = useCallback(() => {
// Set the align sidebar without current component to back
// to unit align sidebar.
setCurrentPageKey('align');
setCurrentPageKey('align', null);
}, [setCurrentPageKey]);
return (
@@ -30,7 +31,7 @@ export const UnitAlignSidebar = () => {
? contentData.displayName : ''
}
contentId={sidebarContentId || ''}
onBackBtnClick={currentComponentId ? handleBack : undefined}
onBackBtnClick={selectedComponentId ? handleBack : undefined}
/>
);
};

View File

@@ -4,19 +4,19 @@ import {
import { SidebarPage } from '@src/generic/sidebar';
import { useToggle } from '@openedx/paragon';
import { useStateWithUrlSearchParam } from '@src/hooks';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { messageTypes } from '../constants';
export type UnitSidebarPageKeys = 'info' | 'add' | 'align';
export type UnitSidebarPages = Record<UnitSidebarPageKeys, SidebarPage>;
interface UnitSidebarContextData {
currentPageKey: UnitSidebarPageKeys;
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string) => void;
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string | null) => void;
currentTabKey?: string;
setCurrentTabKey: (tabKey: string | undefined) => void;
// The Id of the component used in the current sidebar page
// The component is not necessarily selected to open a selected sidebar.
// Example: Align sidebar
currentComponentId?: string;
selectedComponentId?: string;
setSelectedComponentId: (componentId?: string) => void;
isOpen: boolean;
open: () => void;
toggle: () => void;
@@ -32,6 +32,7 @@ export const UnitSidebarProvider = ({
children?: React.ReactNode,
readOnly: boolean,
}) => {
const { sendMessageToIframe } = useIframe();
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<UnitSidebarPageKeys>(
'info',
'sidebar',
@@ -39,16 +40,23 @@ export const UnitSidebarProvider = ({
(value: UnitSidebarPageKeys) => value,
);
const [currentTabKey, setCurrentTabKey] = useState<string>();
const [currentComponentId, setCurrentComponentId] = useState<string>();
const [selectedComponentId, setSelectedComponentId] = useState<string>();
const [isOpen, open,, toggle] = useToggle(true);
const setCurrentPageKey = useCallback(/* istanbul ignore next */ (
pageKey: UnitSidebarPageKeys,
componentId?: string,
componentId?: string | null,
) => {
// Reset tab
setCurrentTabKey(undefined);
setCurrentPageKeyState(pageKey);
setCurrentComponentId(componentId);
if (componentId !== undefined) {
setSelectedComponentId(componentId === null ? undefined : componentId);
}
if (componentId === null) {
// Deselect the component
sendMessageToIframe(messageTypes.clearSelection, null);
}
open();
}, [open]);
@@ -58,7 +66,8 @@ export const UnitSidebarProvider = ({
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
currentComponentId,
selectedComponentId,
setSelectedComponentId,
isOpen,
open,
toggle,
@@ -69,7 +78,8 @@ export const UnitSidebarProvider = ({
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
currentComponentId,
selectedComponentId,
setSelectedComponentId,
isOpen,
open,
toggle,

View File

@@ -71,6 +71,11 @@ const messages = defineMessages({
defaultMessage: 'Advanced Blocks',
description: 'Title for the add advanced blocks page in the unit sidebar',
},
sidebarDisabledAddTooltip: {
id: 'course-authoring.course-unit.sidebar.add.disabled.tooltip',
defaultMessage: 'Cannot add content to components',
description: 'Tooltip for the Add sidebar when is disabled.',
},
});
export default messages;

View File

@@ -2,10 +2,10 @@ import { getConfig } from '@edx/frontend-platform';
import { Info, Tag, Plus } from '@openedx/paragon/icons';
import { SidebarPage } from '@src/generic/sidebar';
import messages from './messages';
import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { AddSidebar } from './AddSidebar';
import { useUnitSidebarContext } from './UnitSidebarContext';
import { InfoSidebar } from './unit-info/InfoSidebar';
export type UnitSidebarPages = {
info: SidebarPage;
@@ -21,10 +21,11 @@ export type UnitSidebarPages = {
*/
export const useUnitSidebarPages = (): UnitSidebarPages => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
const { readOnly } = useUnitSidebarContext();
const { readOnly, selectedComponentId } = useUnitSidebarContext();
const hasComponentSelected = selectedComponentId !== undefined;
return {
info: {
component: UnitInfoSidebar,
component: InfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
@@ -33,6 +34,8 @@ export const useUnitSidebarPages = (): UnitSidebarPages => {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
disabled: hasComponentSelected,
tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined,
},
}),
...(showAlignSidebar && {

View File

@@ -0,0 +1,83 @@
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate } from 'react-router-dom';
import { Tag } from '@openedx/paragon/icons';
import { useQueryClient } from '@tanstack/react-query';
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
import { ContentTagsSnippet } from '@src/content-tags-drawer';
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
import type { XBlockData } from '@src/content-tags-drawer/data/types';
import { getItemIcon } from '@src/generic/block-type-utils';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { messageTypes } from '@src/course-unit/constants';
import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard';
import { getCourseUnitData } from '@src/course-unit/data/selectors';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks';
import { useUnitSidebarContext } from '../UnitSidebarContext';
import messages from './messages';
/**
* Sidebar info for components
*/
export const ComponentInfoSidebar = () => {
const intl = useIntl();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { sendMessageToIframe } = useIframe();
const unitData = useSelector(getCourseUnitData);
const { courseId } = useCourseAuthoringContext();
const sectionId = unitData?.ancestorInfo?.ancestors?.find(
(ancestor) => ancestor.category === 'chapter',
)?.id;
const {
selectedComponentId,
setCurrentPageKey,
} = useUnitSidebarContext();
const { data: contentData } = useContentData(selectedComponentId) as { data: XBlockData | undefined };
// istanbul ignore next
const handleBack = () => {
setCurrentPageKey('info', null);
};
const handleGoToParent = (containerId: string) => {
navigate(`/course/${courseId}?show=${encodeURIComponent(containerId)}`);
};
// istanbul ignore next
const handlePostChange = () => {
sendMessageToIframe(messageTypes.refreshXBlock, null);
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(selectedComponentId),
});
};
return (
<>
<SidebarTitle
title={contentData?.displayName || ''}
icon={getItemIcon(contentData?.category || '')}
onBackBtnClick={handleBack}
/>
<LibraryReferenceCard
itemId={selectedComponentId}
sectionId={sectionId}
goToParent={handleGoToParent}
postChange={handlePostChange}
/>
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages.sidebarSectionTaxonomies)}
icon={Tag}
>
<ContentTagsSnippet contentId={selectedComponentId || ''} />
</SidebarSection>
</SidebarContent>
</>
);
};

View File

@@ -0,0 +1,19 @@
import { useUnitSidebarContext } from '../UnitSidebarContext';
import { ComponentInfoSidebar } from './ComponentInfoSidebar';
import { UnitInfoSidebar } from './UnitInfoSidebar';
/**
* Main component to render the Info Sidebar in the unit page
*
* Depending of the selected component, this can render
* the unit infor sidebar or the component info sidebar
*/
export const InfoSidebar = () => {
const { selectedComponentId } = useUnitSidebarContext();
if (selectedComponentId) {
return <ComponentInfoSidebar />;
}
return <UnitInfoSidebar />;
};

View File

@@ -189,7 +189,7 @@ const UnitInfoSettings = () => {
};
/**
* Main component that renders the tabs of the info sidebar.
* Component that renders the tabs of the info sidebar for units.
*/
export const UnitInfoSidebar = () => {
const intl = useIntl();

View File

@@ -16,6 +16,7 @@ export type UseMessageHandlersTypes = {
handleShowProcessingNotification: (variant: string) => void;
handleHideProcessingNotification: () => void;
handleRefreshIframe: () => void;
handleXBlockSelected: (id: string) => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { debounce } from 'lodash';
import { useClipboard } from '../../../generic/clipboard';
import { handleResponseErrors } from '../../../generic/saving-error-alert';
import { NOTIFICATION_MESSAGES } from '../../../constants';
import { updateSavingStatus } from '../../data/slice';
import { messageTypes } from '../../constants';
import { useClipboard } from '@src/generic/clipboard';
import { messageTypes } from '@src/course-unit/constants';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import { updateSavingStatus } from '@src/course-unit/data/slice';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
/**
@@ -32,6 +33,7 @@ export const useMessageHandlers = ({
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
handleXBlockSelected,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -45,7 +47,7 @@ export const useMessageHandlers = ({
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
[messageTypes.toggleCourseXBlockDropdown]: ({
courseXBlockDropdownHeight,
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
}) => setIframeOffset(courseXBlockDropdownHeight),
[messageTypes.editXBlock]: ({ id }) => handleShowLegacyEditXBlockModal(id),
[messageTypes.closeXBlockEditorModal]: handleCloseLegacyEditorXBlockModal,
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
@@ -63,6 +65,7 @@ export const useMessageHandlers = ({
payload.type,
payload.locator,
),
[messageTypes.xblockSelected]: ({ contentId }) => handleXBlockSelected(contentId),
}), [
courseId,
handleDeleteXBlock,
@@ -71,5 +74,6 @@ export const useMessageHandlers = ({
handleManageXBlockAccess,
handleScrollToXBlock,
copyToClipboard,
handleXBlockSelected,
]);
};

View File

@@ -24,6 +24,7 @@ import { UnlinkModal } from '@src/generic/unlink-modal';
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
import EditorPage from '@src/editors/EditorPage';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { messageTypes } from '../constants';
import {
fetchCourseSectionVerticalData,
@@ -53,12 +54,15 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const { setCurrentPageKey } = useUnitSidebarContext();
const {
setCurrentPageKey,
setSelectedComponentId,
} = useUnitSidebarContext();
// Useful to reload iframe
const [iframeKey, setIframeKey] = useState(0);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
const { isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal } = useCourseAuthoringContext();
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
@@ -115,7 +119,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const handleUnlinkXBlock = (usageId: string) => {
setUnlinkXBlockId(usageId);
openUnlinkModal();
openUnlinkModal({});
};
const handleManageXBlockAccess = (usageId: string) => {
@@ -127,9 +131,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
}
};
const onDeleteSubmit = () => {
const onDeleteSubmit = async () => {
if (deleteXBlockId) {
unitXBlockActions.handleDelete(deleteXBlockId);
await unitXBlockActions.handleDelete(deleteXBlockId);
setSelectedComponentId(undefined);
closeDeleteModal();
}
};
@@ -180,6 +185,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const handleOpenManageTagsModal = (id: string) => {
if (isUnitPageNewDesignEnabled()) {
setCurrentPageKey('align', id);
sendMessageToIframe(messageTypes.selectXblock, { locator: id });
} else {
// Legacy manage tags modal
setConfigureXBlockId(id);
@@ -204,6 +210,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
setIframeKey((prev) => prev + 1);
};
const handleXBlockSelected = (id) => {
setCurrentPageKey('info', id);
};
const messageHandlers = useMessageHandlers({
courseId,
dispatch,
@@ -222,6 +232,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
handleXBlockSelected,
});
useIframeMessages(readonly ? {} : messageHandlers);

View File

@@ -31,7 +31,7 @@ export interface XBlockContainerIframeProps {
blockId: string;
isUnitVerticalType: boolean,
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDelete: (XBlockId: string | null) => Promise<void> | void;
handleDuplicate: (XBlockId: string | null) => void;
handleUnlink: (XBlockId: string | null) => void;
};

View File

@@ -37,21 +37,16 @@ const itemData = {
},
};
const mockUseOutlineSidebarContext = jest.fn().mockReturnValue({
selectedContainerState: { currentId: itemData.id, sectionId: sectionData.id },
openContainerInfoSidebar: jest.fn(),
});
const mockUseCourseAuthoringContext = jest.fn().mockReturnValue({
openUnlinkModal: jest.fn(),
courseId: 'course1',
});
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
useOutlineSidebarContext: () => mockUseOutlineSidebarContext(),
}));
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => mockUseCourseAuthoringContext(),
}));
const mockPostChange = jest.fn();
const mockOpenContainerInfoSidebar = jest.fn();
const mockOpenSyncModal = jest.fn();
jest.mock('@src/hooks', () => ({
useToggleWithValue: () => [false, {}, mockOpenSyncModal, jest.fn()],
@@ -70,7 +65,14 @@ describe('LibraryReferenceCard', () => {
});
it('renders the LibraryReferenceCard normally', async () => {
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(/Library Reference/)).toBeInTheDocument();
});
@@ -86,7 +88,14 @@ describe('LibraryReferenceCard', () => {
axiosMock
.onGet(getXBlockApiUrl(itemData.id))
.reply(200, data);
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(
`The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.`,
)).toBeInTheDocument();
@@ -109,7 +118,14 @@ describe('LibraryReferenceCard', () => {
readyToSync: true,
},
});
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(
`${itemData.displayName} has available updates`,
)).toBeInTheDocument();
@@ -128,7 +144,14 @@ describe('LibraryReferenceCard', () => {
downstreamCustomized: ['displayName'],
},
});
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(
`${itemData.displayName} has been modified in this course.`,
)).toBeInTheDocument();
@@ -146,7 +169,14 @@ describe('LibraryReferenceCard', () => {
errorMessage: 'some error',
},
});
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(
`${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.`,
)).toBeInTheDocument();
@@ -180,7 +210,14 @@ describe('LibraryReferenceCard', () => {
axiosMock
.onGet(getXBlockApiUrl(sectionData.id))
.reply(200, parentData);
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(
`${itemData.displayName} was reused as part of a section which has updates available.`,
)).toBeInTheDocument();
@@ -200,13 +237,20 @@ describe('LibraryReferenceCard', () => {
topLevelParentKey: sectionData.upstreamInfo.downstreamKey,
},
});
render(<LibraryReferenceCard itemId={itemData.id} />);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
expect(await screen.findByText(
`${itemData.displayName} was reused as part of a section.`,
)).toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'View section' }));
expect(mockUseOutlineSidebarContext().openContainerInfoSidebar).toHaveBeenCalledWith(
expect(mockOpenContainerInfoSidebar).toHaveBeenCalledWith(
sectionData.id,
undefined,
sectionData.id,

View File

@@ -3,38 +3,43 @@ import {
Button, Card, Icon, Stack,
} from '@openedx/paragon';
import { Cached, LinkOff, Newsstand } from '@openedx/paragon/icons';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { XBlock } from '@src/data/types';
import { ContainerType, getBlockType, normalizeContainerType } from '@src/generic/key-utils';
import { useToggleWithValue } from '@src/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';
import messages from './messages';
interface SubProps {
blockData: XBlock;
displayName: string;
openSyncModal: (val: XBlock) => void;
sectionId?: string;
}
const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => {
interface HasTopParentSubProps extends SubProps {
goToParent: (containerId: string, subsectionId?: string, sectionId?: string) => void;
}
const HasTopParentTextAndButton = ({
blockData,
displayName,
openSyncModal,
goToParent,
sectionId,
}: HasTopParentSubProps) => {
const { upstreamInfo } = blockData;
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { openUnlinkModal } = useCourseAuthoringContext();
const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey);
const handleUnlinkClick = () => {
// istanbul ignore if
if (!selectedContainerState?.sectionId || !parentData) {
if (!sectionId || !parentData) {
return;
}
openUnlinkModal({ value: parentData, sectionId: selectedContainerState.sectionId });
openUnlinkModal({ value: parentData, sectionId });
};
const handleSyncClick = () => {
@@ -52,17 +57,17 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su
}
const category = getBlockType(upstreamInfo.topLevelParentKey) as ContainerType;
if ([ContainerType.Chapter, ContainerType.Section].includes(category)) {
return openContainerInfoSidebar(
return goToParent(
upstreamInfo.topLevelParentKey,
undefined,
upstreamInfo.topLevelParentKey,
);
}
// Only possible option is sequential or subsection
return openContainerInfoSidebar(
return goToParent(
upstreamInfo.topLevelParentKey,
upstreamInfo.topLevelParentKey,
selectedContainerState?.sectionId,
sectionId,
);
};
@@ -119,9 +124,13 @@ const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: Su
);
};
const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => {
const TopLevelTextAndButton = ({
blockData,
displayName,
openSyncModal,
sectionId,
}: SubProps) => {
const { upstreamInfo } = blockData;
const { selectedContainerState } = useOutlineSidebarContext();
const { openUnlinkModal } = useCourseAuthoringContext();
const messageValues = {
name: displayName,
@@ -129,10 +138,10 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro
const handleUnlinkClick = () => {
// istanbul ignore if
if (!selectedContainerState?.sectionId) {
if (!sectionId) {
return;
}
openUnlinkModal({ value: blockData, sectionId: selectedContainerState.sectionId });
openUnlinkModal({ value: blockData, sectionId });
};
const handleSyncClick = () => {
@@ -180,15 +189,23 @@ const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubPro
interface Props {
itemId?: string;
sectionId?: string;
postChange: (accept: boolean) => void,
goToParent: (containerId: string, subsectionId?: string, sectionId?: string) => void;
}
export const LibraryReferenceCard = ({ itemId }: Props) => {
/**
* Libray reference card to show info and actions about
* upstream link of an item.
*/
export const LibraryReferenceCard = ({
itemId,
sectionId,
postChange,
goToParent,
}: Props) => {
const { data: itemData, isPending } = useCourseItemData(itemId);
const { selectedContainerState } = useOutlineSidebarContext();
const { courseId } = useCourseAuthoringContext();
const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue<XBlock>();
const dispatch = useDispatch();
const queryClient = useQueryClient();
const blockSyncData = useMemo(() => {
if (!syncModalData?.upstreamInfo?.readyToSync) {
@@ -205,19 +222,6 @@ export const LibraryReferenceCard = ({ itemId }: Props) => {
};
}, [syncModalData]);
// istanbul ignore next
const handleOnPostChangeSync = useCallback(() => {
if (selectedContainerState?.sectionId) {
dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId]));
}
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.course(courseId),
});
}
}, [dispatch, selectedContainerState, queryClient, courseId]);
if (!itemData?.upstreamInfo?.upstreamRef) {
return null;
}
@@ -235,11 +239,14 @@ export const LibraryReferenceCard = ({ itemId }: Props) => {
blockData={itemData}
displayName={itemData.displayName}
openSyncModal={openSyncModal}
sectionId={sectionId}
/>
<HasTopParentTextAndButton
blockData={itemData}
displayName={itemData.displayName}
openSyncModal={openSyncModal}
sectionId={sectionId}
goToParent={goToParent}
/>
</Stack>
</Card.Section>
@@ -249,7 +256,7 @@ export const LibraryReferenceCard = ({ itemId }: Props) => {
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
postChange={postChange}
/>
)}
</div>

View File

@@ -0,0 +1,66 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
libraryReferenceCardText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.text',
defaultMessage: 'Library Reference',
description: 'Library reference card text in sidebar',
},
hasTopParentText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text',
defaultMessage: '{name} was reused as part of a {parentType}.',
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block',
},
hasTopParentBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn',
defaultMessage: 'View {parentType}',
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block',
},
hasTopParentReadyToSyncText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text',
defaultMessage: '{name} was reused as part of a {parentType} which has updates available.',
description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block',
},
hasTopParentReadyToSyncBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn',
defaultMessage: 'Review Updates',
description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block',
},
hasTopParentBrokenLinkText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text',
defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.',
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.',
},
hasTopParentBrokenLinkBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn',
defaultMessage: 'Unlink {parentType}',
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.',
},
topParentBrokenLinkText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text',
defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.',
description: 'Text displayed in sidebar library reference card when a block has a broken link.',
},
topParentBrokenLinkBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn',
defaultMessage: 'Unlink from library',
description: 'Text displayed in sidebar library reference card button when a block has a broken link.',
},
topParentModifiedText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text',
defaultMessage: '{name} has been modified in this course.',
description: 'Text displayed in sidebar library reference card when it is modified in course.',
},
topParentReaadyToSyncText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text',
defaultMessage: '{name} has available updates',
description: 'Text displayed in sidebar library reference card when it is has updates available.',
},
topParentReaadyToSyncBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn',
defaultMessage: 'Review Updates',
description: 'Text displayed in sidebar library reference card button when it is has updates available.',
},
});
export default messages;

View File

@@ -5,6 +5,7 @@ import {
Icon,
IconButton,
IconButtonToggle,
IconButtonWithTooltip,
Stack,
} from '@openedx/paragon';
import { ResizableBox } from '@src/generic/resizable/Resizable';
@@ -18,6 +19,8 @@ export interface SidebarPage {
component: React.ComponentType;
icon: React.ComponentType;
title: MessageDescriptor;
disabled?: boolean;
tooltip?: MessageDescriptor;
}
type SidebarPages = Record<string, SidebarPage>;
@@ -85,7 +88,11 @@ export function Sidebar<T extends SidebarPages>({
}: SidebarProps<T>) {
const intl = useIntl();
const SidebarComponent = pages[currentPageKey].component;
const {
component: SidebarComponent,
icon: SidebarIcon,
title,
} = pages[currentPageKey];
const activeKey = isOpen ? currentPageKey : undefined;
return (
@@ -100,14 +107,15 @@ export function Sidebar<T extends SidebarPages>({
variant="tertiary"
className="x-small text-primary font-weight-bold pl-0"
>
{intl.formatMessage(pages[currentPageKey].title)}
<Icon src={pages[currentPageKey].icon} size="xs" className="ml-2" />
{intl.formatMessage(title)}
<Icon src={SidebarIcon} size="xs" className="ml-2" />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
{Object.entries(pages).map(([key, page]) => (
<Dropdown.Item
key={key}
onClick={() => setCurrentPageKey(key)}
disabled={page.disabled}
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
@@ -134,18 +142,30 @@ export function Sidebar<T extends SidebarPages>({
activeValue={activeKey}
onChange={setCurrentPageKey}
>
{Object.entries(pages).map(([key, page]) => (
<IconButton
key={key}
// FIXME: The following ts-ignore can be removed when the type fix is released in paragon
// https://github.com/openedx/paragon/pull/4031
// @ts-ignore
value={key}
src={page.icon}
alt={intl.formatMessage(page.title)}
className="rounded-iconbutton my-2"
/>
))}
{Object.entries(pages).map(([key, page]) => {
const buttonData = {
key,
value: key,
src: page.icon,
alt: intl.formatMessage(page.title),
className: 'rounded-iconbutton my-2',
disabled: page.disabled,
};
if (page.tooltip) {
return (
<IconButtonWithTooltip
{...buttonData}
style={{ pointerEvents: 'all' }}
tooltipContent={<div>{intl.formatMessage(page.tooltip)}</div>}
/>
);
}
return (
<IconButton {...buttonData} />
);
})}
</IconButtonToggle>
</div>
</Stack>