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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`])}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,4 +79,7 @@ export const messageTypes = {
|
||||
copyXBlockLegacy: 'copyXBlockLegacy',
|
||||
hideProcessingNotification: 'hideProcessingNotification',
|
||||
handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage',
|
||||
xblockSelected: 'xblockSelected',
|
||||
clearSelection: 'clearSelection',
|
||||
selectXblock: 'selectXBlock',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
19
src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx
Normal file
19
src/course-unit/unit-sidebar/unit-info/InfoSidebar.tsx
Normal 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 />;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
66
src/generic/library-reference-card/messages.ts
Normal file
66
src/generic/library-reference-card/messages.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user