fix: UX issues in unit page (#1913)
Fixes the following issues: * Selection behavior * Component selection is by header click only * Newly created blocks within a unit should be selected on creation/save, appear selected, and have their sidebar open * Some long text components seem to display at the default height rather than a longer height * Within the full-page unit view, the "add to collection" overflow menu item on components does not seem to work/only opens the sidebar. * Draft status indicator text is not vertically centered with icon * When reordering, dragging a short component past a long component often causes a strange stutter effect. * When dragging to reorder a component, moving quickly or scrolling often causes the drag handle to be lost / causes the block to jump somewhere else * Reordering may not consistently support a keyboard-accessible option to change order, like in course authoring * Tag button on component header opens the old tag side pane
This commit is contained in:
@@ -10,4 +10,5 @@ coverage:
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
||||
- "src/index.js"
|
||||
|
||||
@@ -21,6 +21,7 @@ const path = '/content/:contentId?/*';
|
||||
const mockOnClose = jest.fn();
|
||||
const mockSetBlockingSheet = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
const mockSidebarAction = jest.fn();
|
||||
mockContentTaxonomyTagsData.applyMock();
|
||||
mockTaxonomyListData.applyMock();
|
||||
mockTaxonomyTagsData.applyMock();
|
||||
@@ -40,6 +41,11 @@ jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('../library-authoring/common/context/SidebarContext', () => ({
|
||||
...jest.requireActual('../library-authoring/common/context/SidebarContext'),
|
||||
useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }),
|
||||
}));
|
||||
|
||||
const renderDrawer = (contentId, drawerParams = {}) => (
|
||||
render(
|
||||
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
|
||||
@@ -184,6 +190,26 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change to edit mode sidebar action is set to JumpToManageTags', async () => {
|
||||
mockSidebarAction.mockReturnValueOnce('jump-to-manage-tags');
|
||||
renderDrawer(stagedTagsId, { variant: 'component' });
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// Show delete tag buttons
|
||||
expect(screen.getAllByRole('button', {
|
||||
name: /delete/i,
|
||||
}).length).toBe(2);
|
||||
|
||||
// Show add a tag select
|
||||
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
|
||||
|
||||
// Show cancel button
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
|
||||
// Show save button
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change to read mode when click on `Cancel` on drawer variant', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
@@ -14,6 +14,7 @@ import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import Loading from '../generic/Loading';
|
||||
import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper';
|
||||
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
|
||||
import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext';
|
||||
|
||||
interface TaxonomyListProps {
|
||||
contentId: string;
|
||||
@@ -244,6 +245,7 @@ const ContentTagsDrawer = ({
|
||||
if (contentId === undefined) {
|
||||
throw new Error('Error: contentId cannot be null.');
|
||||
}
|
||||
const { sidebarAction } = useSidebarContext();
|
||||
|
||||
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
|
||||
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
|
||||
@@ -260,6 +262,7 @@ const ContentTagsDrawer = ({
|
||||
closeToast,
|
||||
setCollapsibleToInitalState,
|
||||
otherTaxonomies,
|
||||
toEditMode,
|
||||
} = context;
|
||||
|
||||
let onCloseDrawer: () => void;
|
||||
@@ -302,8 +305,13 @@ const ContentTagsDrawer = ({
|
||||
|
||||
// First call of the initial collapsible states
|
||||
React.useEffect(() => {
|
||||
setCollapsibleToInitalState();
|
||||
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
|
||||
// Open tag edit mode when sidebarAction is JumpToManageTags
|
||||
if (sidebarAction === SidebarActions.JumpToManageTags) {
|
||||
toEditMode();
|
||||
} else {
|
||||
setCollapsibleToInitalState();
|
||||
}
|
||||
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded, sidebarAction, toEditMode]);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useParams } from 'react-router';
|
||||
import {
|
||||
getTaxonomyTagsData,
|
||||
getContentTaxonomyTagsData,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
updateContentTaxonomyTags,
|
||||
getContentTaxonomyTagsCount,
|
||||
} from './api';
|
||||
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
|
||||
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
|
||||
import { getLibraryId } from '../../generic/key-utils';
|
||||
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
|
||||
@@ -129,6 +130,7 @@ export const useContentData = (contentId, enabled) => (
|
||||
export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
const queryClient = useQueryClient();
|
||||
const unitIframe = window.frames['xblock-iframe'];
|
||||
const { unitId } = useParams();
|
||||
|
||||
return useMutation({
|
||||
/**
|
||||
@@ -158,6 +160,10 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
|
||||
// Invalidate content search to update tags count
|
||||
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
// If the tags for a compoent were edited from Unit page, invalidate children query to fetch count again.
|
||||
if (unitId) {
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: /* istanbul ignore next */ () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('useContentTaxonomyTagsUpdater', () => {
|
||||
|
||||
const contentId = 'testerContent';
|
||||
const taxonomyId = 123;
|
||||
const mutation = useContentTaxonomyTagsUpdater(contentId);
|
||||
const mutation = renderHook(() => useContentTaxonomyTagsUpdater(contentId)).result.current;
|
||||
const tagsData = [{
|
||||
taxonomy: taxonomyId,
|
||||
tags: ['tag1', 'tag2'],
|
||||
|
||||
@@ -2230,7 +2230,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, courseSectionMock);
|
||||
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await userEvent.click(expandBtn);
|
||||
userEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import { verticalSortableListCollisionDetection } from './verticalSortableList';
|
||||
|
||||
const DraggableList = ({
|
||||
itemList,
|
||||
@@ -56,13 +56,20 @@ const DraggableList = ({
|
||||
setActiveId?.(event.active.id);
|
||||
}, [setActiveId]);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId?.(null);
|
||||
}, [setActiveId]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
collisionDetection={closestCenter}
|
||||
collisionDetection={verticalSortableListCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
// autoScroll does not play well with verticalSortableListCollisionDetection strategy
|
||||
autoScroll={false}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext
|
||||
items={itemList}
|
||||
|
||||
@@ -45,14 +45,15 @@ const SortableItem = ({
|
||||
};
|
||||
|
||||
return (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card
|
||||
style={style}
|
||||
className="mx-0"
|
||||
isClickable={isClickable}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ActionRow style={actionStyle}>
|
||||
{actions}
|
||||
|
||||
80
src/generic/DraggableList/verticalSortableList.ts
Normal file
80
src/generic/DraggableList/verticalSortableList.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/* istanbul ignore file */
|
||||
/**
|
||||
This sorting strategy was copied over from https://github.com/clauderic/dnd-kit/pull/805
|
||||
to resolve issues with variable sized draggables.
|
||||
*/
|
||||
import { CollisionDetection, DroppableContainer } from '@dnd-kit/core';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
const collision = (dropppableContainer?: DroppableContainer) => ({
|
||||
id: dropppableContainer?.id ?? '',
|
||||
value: dropppableContainer,
|
||||
});
|
||||
|
||||
// Look for the first (/ furthest up / highest) droppable container that is at least
|
||||
// 50% covered by the top edge of the dragging container.
|
||||
const highestDroppableContainerMajorityCovered: CollisionDetection = ({
|
||||
droppableContainers,
|
||||
collisionRect,
|
||||
}) => {
|
||||
const ascendingDroppabaleContainers = sortBy(
|
||||
droppableContainers,
|
||||
(c) => c?.rect.current?.top,
|
||||
);
|
||||
|
||||
for (const droppableContainer of ascendingDroppabaleContainers) {
|
||||
const {
|
||||
rect: { current: droppableRect },
|
||||
} = droppableContainer;
|
||||
|
||||
if (droppableRect) {
|
||||
const coveredPercentage = (droppableRect.top + droppableRect.height - collisionRect.top)
|
||||
/ droppableRect.height;
|
||||
|
||||
if (coveredPercentage > 0.5) {
|
||||
return [collision(droppableContainer)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we haven't found anything then we are off the top, so return the first item
|
||||
return [collision(ascendingDroppabaleContainers[0])];
|
||||
};
|
||||
|
||||
// Look for the last (/ furthest down / lowest) droppable container that is at least
|
||||
// 50% covered by the bottom edge of the dragging container.
|
||||
const lowestDroppableContainerMajorityCovered: CollisionDetection = ({
|
||||
droppableContainers,
|
||||
collisionRect,
|
||||
}) => {
|
||||
const descendingDroppabaleContainers = sortBy(
|
||||
droppableContainers,
|
||||
(c) => c?.rect.current?.top,
|
||||
).reverse();
|
||||
|
||||
for (const droppableContainer of descendingDroppabaleContainers) {
|
||||
const {
|
||||
rect: { current: droppableRect },
|
||||
} = droppableContainer;
|
||||
|
||||
if (droppableRect) {
|
||||
const coveredPercentage = (collisionRect.bottom - droppableRect.top) / droppableRect.height;
|
||||
|
||||
if (coveredPercentage > 0.5) {
|
||||
return [collision(droppableContainer)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we haven't found anything then we are off the bottom, so return the last item
|
||||
return [collision(descendingDroppabaleContainers[0])];
|
||||
};
|
||||
|
||||
export const verticalSortableListCollisionDetection: CollisionDetection = (
|
||||
args,
|
||||
) => {
|
||||
if (args.collisionRect.top < (args.active.rect.current?.initial?.top ?? 0)) {
|
||||
return highestDroppableContainerMajorityCovered(args);
|
||||
}
|
||||
return lowestDroppableContainerMajorityCovered(args);
|
||||
};
|
||||
@@ -96,7 +96,8 @@ describe('useIframeBehavior', () => {
|
||||
window.dispatchEvent(new MessageEvent('message', message));
|
||||
});
|
||||
|
||||
expect(setIframeHeight).toHaveBeenCalledWith(500);
|
||||
// +10 padding
|
||||
expect(setIframeHeight).toHaveBeenCalledWith(510);
|
||||
expect(setHasLoaded).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ export const useIframeBehavior = ({
|
||||
|
||||
switch (type) {
|
||||
case iframeMessageTypes.resize:
|
||||
setIframeHeight(payload.height);
|
||||
// Adding 10px as padding
|
||||
setIframeHeight(payload.height + 10);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ const path = '/library/:libraryId/*';
|
||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||
|
||||
describe('<LibraryAuthoringPage />', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
@@ -78,6 +82,10 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const renderLibraryPage = async () => {
|
||||
render(<LibraryLayout />, { path, params: { libraryId: mockContentLibrary.libraryId } });
|
||||
|
||||
@@ -392,7 +400,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => {
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item - component', async () => {
|
||||
const mockResult0 = { ...mockResult }.results[0].hits[0];
|
||||
const displayName = 'Introduction to Testing';
|
||||
expect(mockResult0.display_name).toStrictEqual(displayName);
|
||||
@@ -407,9 +415,10 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
|
||||
const { getByRole, queryByText } = within(sidebar);
|
||||
const { getByRole, findByText } = within(sidebar);
|
||||
|
||||
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument());
|
||||
expect(await findByText(displayName)).toBeInTheDocument();
|
||||
jest.advanceTimersByTime(300);
|
||||
expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage');
|
||||
const closeButton = getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
@@ -417,7 +426,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => {
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item - unit', async () => {
|
||||
const displayName = 'Test Unit';
|
||||
await renderLibraryPage();
|
||||
|
||||
@@ -430,9 +439,10 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
|
||||
const { getByRole, queryByText } = within(sidebar);
|
||||
const { getByRole, findByText } = within(sidebar);
|
||||
|
||||
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument());
|
||||
expect(await findByText(displayName)).toBeInTheDocument();
|
||||
jest.advanceTimersByTime(300);
|
||||
expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage');
|
||||
const closeButton = getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
@@ -16,6 +17,7 @@ interface LibraryBlockProps {
|
||||
view?: string;
|
||||
scrolling?: string;
|
||||
minHeight?: string;
|
||||
scrollIntoView?: boolean;
|
||||
}
|
||||
/**
|
||||
* React component that displays an XBlock in a sandboxed IFrame.
|
||||
@@ -33,6 +35,7 @@ export const LibraryBlock = ({
|
||||
view,
|
||||
minHeight,
|
||||
scrolling = 'no',
|
||||
scrollIntoView = false,
|
||||
}: LibraryBlockProps) => {
|
||||
const { iframeRef, setIframeRef } = useIframe();
|
||||
const xblockView = view ?? 'student_view';
|
||||
@@ -49,6 +52,13 @@ export const LibraryBlock = ({
|
||||
onBlockNotification,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
/* istanbul ignore next */
|
||||
if (scrollIntoView) {
|
||||
iframeRef?.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [scrollIntoView]);
|
||||
|
||||
useIframeContent(iframeRef, setIframeRef);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,7 +63,8 @@ export interface SidebarComponentInfo {
|
||||
}
|
||||
|
||||
export enum SidebarActions {
|
||||
JumpToAddCollections = 'jump-to-add-collections',
|
||||
JumpToManageCollections = 'jump-to-manage-collections',
|
||||
JumpToManageTags = 'jump-to-manage-tags',
|
||||
ManageTeam = 'manage-team',
|
||||
None = '',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
@@ -17,7 +17,6 @@ import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import {
|
||||
type ComponentInfoTab,
|
||||
COMPONENT_INFO_TABS,
|
||||
SidebarActions,
|
||||
isComponentInfoTab,
|
||||
useSidebarContext,
|
||||
} from '../common/context/SidebarContext';
|
||||
@@ -107,9 +106,9 @@ const ComponentInfo = () => {
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
sidebarComponentInfo,
|
||||
sidebarAction,
|
||||
defaultTab,
|
||||
hiddenTabs,
|
||||
resetSidebarAction,
|
||||
} = useSidebarContext();
|
||||
const [
|
||||
isPublishConfirmationOpen,
|
||||
@@ -117,20 +116,16 @@ const ComponentInfo = () => {
|
||||
closePublishConfirmation,
|
||||
] = useToggle(false);
|
||||
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
|
||||
|
||||
const tab: ComponentInfoTab = (
|
||||
isComponentInfoTab(sidebarTab)
|
||||
? sidebarTab
|
||||
: defaultTab.component
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo
|
||||
if (jumpToCollections) {
|
||||
setSidebarTab(COMPONENT_INFO_TABS.Manage);
|
||||
}
|
||||
}, [jumpToCollections, setSidebarTab]);
|
||||
const handleTabChange = (newTab: ComponentInfoTab) => {
|
||||
resetSidebarAction();
|
||||
setSidebarTab(newTab);
|
||||
};
|
||||
|
||||
const usageKey = sidebarComponentInfo?.id;
|
||||
// istanbul ignore if: this should never happen
|
||||
@@ -198,7 +193,7 @@ const ComponentInfo = () => {
|
||||
className="my-3 d-flex justify-content-around"
|
||||
defaultActiveKey={defaultTab.component}
|
||||
activeKey={tab}
|
||||
onSelect={setSidebarTab}
|
||||
onSelect={handleTabChange}
|
||||
>
|
||||
{renderTab(COMPONENT_INFO_TABS.Preview, <ComponentPreview />, intl.formatMessage(messages.previewTabTitle))}
|
||||
{renderTab(COMPONENT_INFO_TABS.Manage, <ComponentManagement />, intl.formatMessage(messages.manageTabTitle))}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
|
||||
import { SidebarActions, SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
|
||||
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
|
||||
import ComponentManagement from './ComponentManagement';
|
||||
|
||||
@@ -19,6 +19,16 @@ jest.mock('../../content-tags-drawer', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSearchParam = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
|
||||
useSearchParams: () => [
|
||||
{ getAll: (paramName: string) => mockSearchParam(paramName) },
|
||||
() => {},
|
||||
],
|
||||
}));
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockContentTaxonomyTagsData.applyMock();
|
||||
@@ -55,6 +65,11 @@ const render = (usageKey: string, libraryId?: string) => baseRender(<ComponentMa
|
||||
describe('<ComponentManagement />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
mockSearchParam.mockResolvedValue([undefined, () => {}]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render draft status', async () => {
|
||||
@@ -119,4 +134,34 @@ describe('<ComponentManagement />', () => {
|
||||
render(mockLibraryBlockMetadata.usageKeyWithCollections);
|
||||
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open collection section when sidebarAction = JumpToManageCollections', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
mockSearchParam.mockReturnValue([SidebarActions.JumpToManageCollections]);
|
||||
render(mockLibraryBlockMetadata.usageKeyWithCollections);
|
||||
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument();
|
||||
const tagsSection = await screen.findByRole('button', { name: 'Tags (0)' });
|
||||
expect(tagsSection).toHaveAttribute('aria-expanded', 'false');
|
||||
const collectionsSection = await screen.findByRole('button', { name: 'Collections (1)' });
|
||||
expect(collectionsSection).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should open tags section when sidebarAction = JumpToManageTags', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
mockSearchParam.mockReturnValue([SidebarActions.JumpToManageTags]);
|
||||
render(mockLibraryBlockMetadata.usageKeyForTags);
|
||||
expect(await screen.findByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument();
|
||||
const tagsSection = await screen.findByRole('button', { name: 'Tags (6)' });
|
||||
expect(tagsSection).toHaveAttribute('aria-expanded', 'true');
|
||||
const collectionsSection = await screen.findByRole('button', { name: 'Collections (0)' });
|
||||
expect(collectionsSection).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ const ComponentManagement = () => {
|
||||
const intl = useIntl();
|
||||
const { readOnly, isLoadingLibraryData } = useLibraryContext();
|
||||
const { sidebarComponentInfo, sidebarAction, resetSidebarAction } = useSidebarContext();
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections;
|
||||
const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags;
|
||||
const [tagsCollapseIsOpen, setTagsCollapseOpen] = React.useState(!jumpToCollections);
|
||||
const [collectionsCollapseIsOpen, setCollectionsCollapseOpen] = React.useState(true);
|
||||
|
||||
@@ -26,8 +27,11 @@ const ComponentManagement = () => {
|
||||
if (jumpToCollections) {
|
||||
setTagsCollapseOpen(false);
|
||||
setCollectionsCollapseOpen(true);
|
||||
} else if (jumpToTags) {
|
||||
setTagsCollapseOpen(true);
|
||||
setCollectionsCollapseOpen(false);
|
||||
}
|
||||
}, [jumpToCollections, tagsCollapseIsOpen, collectionsCollapseIsOpen]);
|
||||
}, [jumpToCollections, jumpToTags]);
|
||||
|
||||
useEffect(() => {
|
||||
// This is required to redo actions.
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import messages from './messages';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
@@ -36,6 +38,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
closeLibrarySidebar,
|
||||
setSidebarAction,
|
||||
} = useSidebarContext();
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
|
||||
const canEdit = usageKey && canEditComponent(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
@@ -87,10 +90,22 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleJumpToCollection = useRunOnNextRender(() => {
|
||||
// TODO: Ugly hack to make sure sidebar shows add to collection section
|
||||
// This needs to run after all changes to url takes place to avoid conflicts.
|
||||
setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections), 250);
|
||||
});
|
||||
|
||||
const showManageCollections = useCallback(() => {
|
||||
setSidebarAction(SidebarActions.JumpToAddCollections);
|
||||
navigateTo({ componentId: usageKey });
|
||||
openComponentInfoSidebar(usageKey);
|
||||
}, [setSidebarAction, openComponentInfoSidebar, usageKey]);
|
||||
scheduleJumpToCollection();
|
||||
}, [
|
||||
scheduleJumpToCollection,
|
||||
openComponentInfoSidebar,
|
||||
usageKey,
|
||||
navigateTo,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dropdown id="component-card-dropdown">
|
||||
@@ -123,11 +138,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{!unitId && (
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
|
||||
</Dropdown>
|
||||
|
||||
@@ -24,6 +24,7 @@ import AddComponentWidget from './AddComponentWidget';
|
||||
import BaseCard from './BaseCard';
|
||||
import messages from './messages';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
hit: ContainerHit,
|
||||
@@ -45,6 +46,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
|
||||
} = useSidebarContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
|
||||
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
|
||||
|
||||
@@ -60,10 +62,17 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleJumpToCollection = useRunOnNextRender(() => {
|
||||
// TODO: Ugly hack to make sure sidebar shows add to collection section
|
||||
// This needs to run after all changes to url takes place to avoid conflicts.
|
||||
setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections));
|
||||
});
|
||||
|
||||
const showManageCollections = useCallback(() => {
|
||||
setSidebarAction(SidebarActions.JumpToAddCollections);
|
||||
navigateTo({ unitId: containerId });
|
||||
openUnitInfoSidebar(containerId);
|
||||
}, [setSidebarAction, openUnitInfoSidebar, containerId]);
|
||||
scheduleJumpToCollection();
|
||||
}, [scheduleJumpToCollection, navigateTo, openUnitInfoSidebar, containerId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -28,7 +28,7 @@ const ContainerOrganize = () => {
|
||||
|
||||
const { readOnly } = useLibraryContext();
|
||||
const { sidebarComponentInfo, sidebarAction } = useSidebarContext();
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections;
|
||||
|
||||
const containerId = sidebarComponentInfo?.id;
|
||||
// istanbul ignore if: this should never happen
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import {
|
||||
type UnitInfoTab,
|
||||
SidebarActions,
|
||||
UNIT_INFO_TABS,
|
||||
isUnitInfoTab,
|
||||
useSidebarContext,
|
||||
@@ -81,9 +80,8 @@ const UnitInfo = () => {
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
sidebarComponentInfo,
|
||||
sidebarAction,
|
||||
resetSidebarAction,
|
||||
} = useSidebarContext();
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
|
||||
const { insideUnit } = useLibraryRoutes();
|
||||
|
||||
const tab: UnitInfoTab = (
|
||||
@@ -96,6 +94,12 @@ const UnitInfo = () => {
|
||||
|
||||
const showOpenUnitButton = !insideUnit && !componentPickerMode;
|
||||
|
||||
/* istanbul ignore next */
|
||||
const handleTabChange = (newTab: UnitInfoTab) => {
|
||||
resetSidebarAction();
|
||||
setSidebarTab(newTab);
|
||||
};
|
||||
|
||||
const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => {
|
||||
if (hiddenTabs.includes(infoTab)) {
|
||||
// For some reason, returning anything other than empty list breaks the tab style
|
||||
@@ -117,13 +121,6 @@ const UnitInfo = () => {
|
||||
}
|
||||
}, [publishContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
// Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo
|
||||
if (jumpToCollections) {
|
||||
setSidebarTab(UNIT_INFO_TABS.Manage);
|
||||
}
|
||||
}, [jumpToCollections, setSidebarTab]);
|
||||
|
||||
if (!container || !unitId) {
|
||||
return null;
|
||||
}
|
||||
@@ -163,7 +160,7 @@ const UnitInfo = () => {
|
||||
className="my-3 d-flex justify-content-around"
|
||||
defaultActiveKey={defaultTab.unit}
|
||||
activeKey={tab}
|
||||
onSelect={setSidebarTab}
|
||||
onSelect={handleTabChange}
|
||||
>
|
||||
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks preview />, intl.formatMessage(messages.previewTabTitle))}
|
||||
{renderTab(UNIT_INFO_TABS.Manage, <ContainerOrganize />, intl.formatMessage(messages.manageTabTitle))}
|
||||
|
||||
@@ -259,6 +259,9 @@ export interface LibraryBlockMetadata {
|
||||
modified: string | null;
|
||||
tagsCount: number;
|
||||
collections: CollectionMetadata[];
|
||||
// Local only variable set to true when a new block is added
|
||||
// NOTE: Currently only updated when a new component is added inside a unit
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateLibraryDataRequest {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useQueryClient,
|
||||
type Query,
|
||||
type QueryClient,
|
||||
replaceEqualDeep,
|
||||
} from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
@@ -632,6 +633,22 @@ export const useContainerChildren = (containerId?: string) => (
|
||||
enabled: !!containerId,
|
||||
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
|
||||
queryFn: () => api.getLibraryContainerChildren(containerId!),
|
||||
structuralSharing: (oldData: api.LibraryBlockMetadata[], newData: api.LibraryBlockMetadata[]) => {
|
||||
// This just sets `isNew` flag to new children components
|
||||
if (oldData) {
|
||||
const oldDataIds = oldData.map((obj) => obj.id);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
newData = newData.map((newObj) => {
|
||||
if (!oldDataIds.includes(newObj.id)) {
|
||||
// Set isNew = true if we have new child on refetch
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
newObj.isNew = true;
|
||||
}
|
||||
return newObj;
|
||||
});
|
||||
}
|
||||
return replaceEqualDeep(oldData, newData);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }:
|
||||
const collectionNames = collections.map((collection) => collection.title);
|
||||
|
||||
return (
|
||||
sidebarAction === SidebarActions.JumpToAddCollections
|
||||
sidebarAction === SidebarActions.JumpToManageCollections
|
||||
? (
|
||||
<AddToCollectionsDrawer
|
||||
opaqueKey={opaqueKey}
|
||||
@@ -217,7 +217,7 @@ const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }:
|
||||
) : (
|
||||
<EntityCollections
|
||||
collections={collectionNames}
|
||||
onManageClick={() => setSidebarAction(SidebarActions.JumpToAddCollections)}
|
||||
onManageClick={() => setSidebarAction(SidebarActions.JumpToManageCollections)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -10,11 +10,17 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AddContent, AddContentHeader } from '../add-content';
|
||||
import { CollectionInfo, CollectionInfoHeader } from '../collections';
|
||||
import { ContainerInfoHeader, UnitInfo } from '../containers';
|
||||
import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import {
|
||||
COMPONENT_INFO_TABS, SidebarActions, SidebarBodyComponentId, useSidebarContext,
|
||||
} from '../common/context/SidebarContext';
|
||||
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
|
||||
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
|
||||
import messages from '../messages';
|
||||
|
||||
interface LibrarySidebarProps {
|
||||
onSidebarClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar container for library pages.
|
||||
*
|
||||
@@ -24,9 +30,25 @@ import messages from '../messages';
|
||||
* You can add more components in `bodyComponentMap`.
|
||||
* Use the returned actions to open and close this sidebar.
|
||||
*/
|
||||
const LibrarySidebar = () => {
|
||||
const LibrarySidebar = ({ onSidebarClose }: LibrarySidebarProps) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarComponentInfo, closeLibrarySidebar } = useSidebarContext();
|
||||
const {
|
||||
sidebarAction,
|
||||
setSidebarTab,
|
||||
sidebarComponentInfo,
|
||||
closeLibrarySidebar,
|
||||
} = useSidebarContext();
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections;
|
||||
const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags;
|
||||
|
||||
React.useEffect(() => {
|
||||
// Show Manage tab if JumpToManageCollections or JumpToManageTags action is set
|
||||
if (jumpToCollections || jumpToTags) {
|
||||
// COMPONENT_INFO_TABS.Manage works for containers as well as its value
|
||||
// is same as UNIT_INFO_TABS.Manage.
|
||||
setSidebarTab(COMPONENT_INFO_TABS.Manage);
|
||||
}
|
||||
}, [jumpToCollections, setSidebarTab, jumpToTags]);
|
||||
|
||||
const bodyComponentMap = {
|
||||
[SidebarBodyComponentId.AddContent]: <AddContent />,
|
||||
@@ -49,6 +71,11 @@ const LibrarySidebar = () => {
|
||||
const buildBody = () : React.ReactNode => bodyComponentMap[sidebarComponentInfo?.type || 'unknown'];
|
||||
const buildHeader = (): React.ReactNode => headerComponentMap[sidebarComponentInfo?.type || 'unknown'];
|
||||
|
||||
const handleSidebarClose = () => {
|
||||
closeLibrarySidebar();
|
||||
onSidebarClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4} className="p-3 text-primary-700">
|
||||
<Stack direction="horizontal" className="d-flex justify-content-between">
|
||||
@@ -58,7 +85,7 @@ const LibrarySidebar = () => {
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.closeButtonAlt)}
|
||||
onClick={closeLibrarySidebar}
|
||||
onClick={handleSidebarClose}
|
||||
size="inline"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -15,6 +15,14 @@ jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
jest.mock('./common/context/LibraryContext', () => ({
|
||||
...jest.requireActual('./common/context/LibraryContext'),
|
||||
useLibraryContext: () => ({
|
||||
setComponentId: jest.fn(),
|
||||
setUnitId: jest.fn(),
|
||||
setCollectionId: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useSearchParams,
|
||||
type PathMatch,
|
||||
} from 'react-router-dom';
|
||||
import { useLibraryContext } from './common/context/LibraryContext';
|
||||
|
||||
export const BASE_ROUTE = '/library/:libraryId';
|
||||
|
||||
@@ -66,6 +67,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { setComponentId, setUnitId, setCollectionId } = useLibraryContext();
|
||||
|
||||
const insideCollection = matchPath(BASE_ROUTE + ROUTES.COLLECTION, pathname);
|
||||
const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname);
|
||||
@@ -99,6 +101,18 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
||||
};
|
||||
let route: string;
|
||||
|
||||
// Update componentId, unitId, collectionId in library context if is not undefined.
|
||||
// Ids can be cleared from route by passing in empty string so we need to set it.
|
||||
if (componentId !== undefined) {
|
||||
setComponentId(componentId);
|
||||
}
|
||||
if (unitId !== undefined) {
|
||||
setUnitId(unitId);
|
||||
}
|
||||
if (collectionId !== undefined) {
|
||||
setCollectionId(collectionId);
|
||||
}
|
||||
|
||||
// Providing contentType overrides the current route so we can change tabs.
|
||||
if (contentType === ContentType.components) {
|
||||
route = ROUTES.COMPONENTS;
|
||||
@@ -158,7 +172,15 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
||||
pathname: newPath,
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
}, [navigate, params, searchParams, pathname]);
|
||||
}, [
|
||||
navigate,
|
||||
params,
|
||||
searchParams,
|
||||
pathname,
|
||||
setComponentId,
|
||||
setUnitId,
|
||||
setCollectionId,
|
||||
]);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Badge, Button, Icon, IconButton, Stack, useToggle,
|
||||
ActionRow, Badge, Button, Icon, Stack, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { Add, Description, DragIndicator } from '@openedx/paragon/icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Add, Description } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { ContentTagsDrawerSheet } from '../../content-tags-drawer';
|
||||
import {
|
||||
useCallback, useContext, useEffect, useState,
|
||||
} from 'react';
|
||||
import { blockTypes } from '../../editors/data/constants/app';
|
||||
import DraggableList, { SortableItem } from '../../generic/DraggableList';
|
||||
|
||||
@@ -22,7 +22,6 @@ import { PickLibraryContentModal } from '../add-content';
|
||||
import ComponentMenu from '../components';
|
||||
import { LibraryBlockMetadata } from '../data/api';
|
||||
import {
|
||||
libraryAuthoringQueryKeys,
|
||||
useContainerChildren,
|
||||
useUpdateContainerChildren,
|
||||
useUpdateXBlockFields,
|
||||
@@ -30,9 +29,10 @@ import {
|
||||
import { LibraryBlock } from '../LibraryBlock';
|
||||
import { useLibraryRoutes, ContentType } from '../routes';
|
||||
import messages from './messages';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { canEditComponent } from '../components/ComponentEditorModal';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
/** Components that need large min height in preview */
|
||||
const LARGE_COMPONENTS = [
|
||||
@@ -43,17 +43,24 @@ const LARGE_COMPONENTS = [
|
||||
'lti_consumer',
|
||||
];
|
||||
|
||||
interface BlockHeaderProps {
|
||||
block: LibraryBlockMetadata;
|
||||
onTagClick: () => void;
|
||||
interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata {
|
||||
originalId: string;
|
||||
}
|
||||
|
||||
/** Component header, split out to reuse in drag overlay */
|
||||
const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => {
|
||||
interface ComponentBlockProps {
|
||||
block: LibraryBlockMetadataWithUniqueId;
|
||||
preview?: boolean;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
/** Component header */
|
||||
const BlockHeader = ({ block }: ComponentBlockProps) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
const { openComponentInfoSidebar, setSidebarAction } = useSidebarContext();
|
||||
|
||||
const updateMutation = useUpdateXBlockFields(block.id);
|
||||
const updateMutation = useUpdateXBlockFields(block.originalId);
|
||||
|
||||
const handleSaveDisplayName = (newDisplayName: string) => {
|
||||
updateMutation.mutateAsync({
|
||||
@@ -67,9 +74,30 @@ const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const scheduleJumpToTags = useRunOnNextRender(() => {
|
||||
// TODO: Ugly hack to make sure sidebar shows manage tags section
|
||||
// This needs to run after all changes to url takes place to avoid conflicts.
|
||||
setTimeout(() => setSidebarAction(SidebarActions.JumpToManageTags), 250);
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
const jumpToManageTags = () => {
|
||||
navigateTo({ componentId: block.originalId });
|
||||
openComponentInfoSidebar(block.originalId);
|
||||
scheduleJumpToTags();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="horizontal" gap={2} className="font-weight-bold">
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
gap={2}
|
||||
className="font-weight-bold"
|
||||
// Prevent parent card from being clicked.
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon src={getItemIcon(block.blockType)} />
|
||||
<InplaceTextEditor
|
||||
onSave={handleSaveDisplayName}
|
||||
@@ -77,25 +105,119 @@ const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => {
|
||||
/>
|
||||
</Stack>
|
||||
<ActionRow.Spacer />
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
gap={3}
|
||||
// Prevent parent card from being clicked.
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{block.hasUnpublishedChanges && (
|
||||
<Badge
|
||||
className="px-2 pt-1"
|
||||
className="px-2 py-1"
|
||||
variant="warning"
|
||||
>
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon className="mb-1" size="xs" src={Description} />
|
||||
<Icon size="xs" src={Description} />
|
||||
<FormattedMessage {...messages.draftChipText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
)}
|
||||
<TagCount size="sm" count={block.tagsCount} onClick={onTagClick} />
|
||||
<ComponentMenu usageKey={block.id} />
|
||||
<TagCount size="sm" count={block.tagsCount} onClick={jumpToManageTags} />
|
||||
<ComponentMenu usageKey={block.originalId} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** ComponentBlock to render preview of given component under Unit */
|
||||
const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => {
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
|
||||
const {
|
||||
unitId, collectionId, componentId, openComponentEditor,
|
||||
} = useLibraryContext();
|
||||
|
||||
const { openInfoSidebar } = useSidebarContext();
|
||||
|
||||
const handleComponentSelection = useCallback((numberOfClicks: number) => {
|
||||
navigateTo({ componentId: block.originalId });
|
||||
const canEdit = canEditComponent(block.originalId);
|
||||
if (numberOfClicks > 1 && canEdit) {
|
||||
// Open editor on double click.
|
||||
openComponentEditor(block.originalId);
|
||||
} else {
|
||||
// open current component sidebar
|
||||
openInfoSidebar(block.originalId, collectionId, unitId);
|
||||
}
|
||||
}, [block, collectionId, unitId, navigateTo, canEditComponent, openComponentEditor, openInfoSidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (block.isNew) {
|
||||
handleComponentSelection(1);
|
||||
}
|
||||
}, [block]);
|
||||
|
||||
/* istanbul ignore next */
|
||||
const calculateMinHeight = () => {
|
||||
if (LARGE_COMPONENTS.includes(block.blockType)) {
|
||||
return '700px';
|
||||
}
|
||||
return '200px';
|
||||
};
|
||||
|
||||
const getComponentStyle = useCallback(() => {
|
||||
if (isDragging) {
|
||||
return {
|
||||
outline: '2px dashed gray',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'hidden',
|
||||
};
|
||||
} if (componentId === block.originalId) {
|
||||
return {
|
||||
outline: '2px solid black',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}, [isDragging, componentId, block]);
|
||||
|
||||
return (
|
||||
<IframeProvider>
|
||||
<SortableItem
|
||||
id={block.id}
|
||||
componentStyle={getComponentStyle()}
|
||||
actions={<BlockHeader block={block} />}
|
||||
actionStyle={{
|
||||
borderRadius: '8px 8px 0px 0px',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#FBFAF9',
|
||||
borderBottom: 'solid 1px #E1DDDB',
|
||||
}}
|
||||
isClickable
|
||||
onClick={(e: { detail: number; }) => handleComponentSelection(e.detail)}
|
||||
disabled={preview}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={classNames('p-3', {
|
||||
'container-mw-md': block.blockType === blockTypes.video,
|
||||
})}
|
||||
// Prevent parent card from being clicked.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<LibraryBlock
|
||||
usageKey={block.originalId}
|
||||
version={showOnlyPublished ? 'published' : undefined}
|
||||
minHeight={calculateMinHeight()}
|
||||
scrollIntoView={block.isNew}
|
||||
/>
|
||||
</div>
|
||||
</SortableItem>
|
||||
</IframeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface LibraryUnitBlocksProps {
|
||||
/** set to true if it is rendered as preview
|
||||
* This disables drag and drop
|
||||
@@ -105,28 +227,16 @@ interface LibraryUnitBlocksProps {
|
||||
|
||||
export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
|
||||
const intl = useIntl();
|
||||
const [orderedBlocks, setOrderedBlocks] = useState<LibraryBlockMetadata[]>([]);
|
||||
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
|
||||
const [orderedBlocks, setOrderedBlocks] = useState<LibraryBlockMetadataWithUniqueId[]>([]);
|
||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||
|
||||
const [hidePreviewFor, setHidePreviewFor] = useState<string | null>(null);
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const {
|
||||
unitId,
|
||||
showOnlyPublished,
|
||||
componentId,
|
||||
readOnly,
|
||||
setComponentId,
|
||||
openComponentEditor,
|
||||
} = useLibraryContext();
|
||||
const { unitId, readOnly } = useLibraryContext();
|
||||
|
||||
const {
|
||||
openAddContentSidebar,
|
||||
} = useSidebarContext();
|
||||
const { openAddContentSidebar } = useSidebarContext();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const orderMutator = useUpdateContainerChildren(unitId);
|
||||
const {
|
||||
data: blocks,
|
||||
@@ -135,7 +245,32 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
|
||||
error,
|
||||
} = useContainerChildren(unitId);
|
||||
|
||||
useEffect(() => setOrderedBlocks(blocks || []), [blocks]);
|
||||
const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => {
|
||||
if (!newOrder) {
|
||||
return;
|
||||
}
|
||||
const usageKeys = newOrder.map((o) => o.originalId);
|
||||
try {
|
||||
await orderMutator.mutateAsync(usageKeys);
|
||||
showToast(intl.formatMessage(messages.orderUpdatedMsg));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(messages.failedOrderUpdatedMsg));
|
||||
}
|
||||
}, [orderMutator]);
|
||||
|
||||
useEffect(() => {
|
||||
// Create new ids which are unique using index.
|
||||
// This is required to support multiple components with same id under a unit.
|
||||
const newBlocks = blocks?.map((block, idx) => {
|
||||
const newBlock: LibraryBlockMetadataWithUniqueId = {
|
||||
...block,
|
||||
id: `${block.id}----${idx}`,
|
||||
originalId: block.id,
|
||||
};
|
||||
return newBlock;
|
||||
});
|
||||
return setOrderedBlocks(newBlocks || []);
|
||||
}, [blocks, setOrderedBlocks]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
@@ -146,106 +281,25 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
const handleReorder = () => async (newOrder: LibraryBlockMetadata[]) => {
|
||||
const usageKeys = newOrder.map((o) => o.id);
|
||||
try {
|
||||
await orderMutator.mutateAsync(usageKeys);
|
||||
showToast(intl.formatMessage(messages.orderUpdatedMsg));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(messages.failedOrderUpdatedMsg));
|
||||
}
|
||||
};
|
||||
|
||||
const onTagSidebarClose = () => {
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId!));
|
||||
closeManageTagsDrawer();
|
||||
};
|
||||
|
||||
const handleComponentSelection = (block: LibraryBlockMetadata, numberOfClicks: number) => {
|
||||
setComponentId(block.id);
|
||||
navigateTo({ componentId: block.id });
|
||||
const canEdit = canEditComponent(block.id);
|
||||
if (numberOfClicks > 1 && canEdit) {
|
||||
// Open editor on double click.
|
||||
openComponentEditor(block.id);
|
||||
}
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const calculateMinHeight = (block: LibraryBlockMetadata) => {
|
||||
if (LARGE_COMPONENTS.includes(block.blockType)) {
|
||||
return '700px';
|
||||
}
|
||||
return '200px';
|
||||
};
|
||||
|
||||
const renderOverlay = (activeId: string | null) => {
|
||||
if (!activeId) {
|
||||
return null;
|
||||
}
|
||||
const block = orderedBlocks?.find((val) => val.id === activeId);
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ActionRow className="bg-light-200 border border-light-500 p-2 rounded">
|
||||
<BlockHeader block={block} onTagClick={openManageTagsDrawer} />
|
||||
<IconButton
|
||||
src={DragIndicator}
|
||||
variant="light"
|
||||
iconAs={Icon}
|
||||
alt=""
|
||||
/>
|
||||
</ActionRow>
|
||||
);
|
||||
};
|
||||
|
||||
const renderedBlocks = orderedBlocks?.map((block, idx) => (
|
||||
// A container can have multiple instances of the same block
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<IframeProvider key={`${block.id}-${idx}-${block.modified}`}>
|
||||
<SortableItem
|
||||
id={block.id}
|
||||
componentStyle={null}
|
||||
actions={<BlockHeader block={block} onTagClick={openManageTagsDrawer} />}
|
||||
actionStyle={{
|
||||
borderRadius: '8px 8px 0px 0px',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#FBFAF9',
|
||||
borderBottom: 'solid 1px #E1DDDB',
|
||||
outline: hidePreviewFor === block.id && '2px dashed gray',
|
||||
}}
|
||||
isClickable
|
||||
onClick={(e: { detail: number; }) => handleComponentSelection(block, e.detail)}
|
||||
disabled={preview}
|
||||
>
|
||||
{hidePreviewFor !== block.id && (
|
||||
<div className={classNames('p-3', {
|
||||
'container-mw-md': block.blockType === blockTypes.video,
|
||||
})}
|
||||
>
|
||||
<LibraryBlock
|
||||
usageKey={block.id}
|
||||
version={showOnlyPublished ? 'published' : undefined}
|
||||
minHeight={calculateMinHeight(block)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SortableItem>
|
||||
</IframeProvider>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="library-unit-page">
|
||||
<DraggableList
|
||||
itemList={orderedBlocks}
|
||||
setState={setOrderedBlocks}
|
||||
updateOrder={handleReorder}
|
||||
renderOverlay={renderOverlay}
|
||||
activeId={hidePreviewFor}
|
||||
setActiveId={setHidePreviewFor}
|
||||
>
|
||||
{renderedBlocks}
|
||||
{orderedBlocks?.map((block, idx) => (
|
||||
// A container can have multiple instances of the same block
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<ComponentBlock
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${block.originalId}-${idx}-${block.modified}`}
|
||||
block={block}
|
||||
isDragging={hidePreviewFor === block.id}
|
||||
/>
|
||||
))}
|
||||
</DraggableList>
|
||||
{!preview && (
|
||||
<div className="d-flex">
|
||||
@@ -281,11 +335,6 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ContentTagsDrawerSheet
|
||||
id={componentId}
|
||||
onClose={onTagSidebarClose}
|
||||
showSheet={isManageTagsDrawerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,14 +42,14 @@ mockContentLibrary.applyMock();
|
||||
mockXBlockFields.applyMock();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
|
||||
const closestCenter = jest.fn();
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
const verticalSortableListCollisionDetection = jest.fn();
|
||||
jest.mock('../../generic/DraggableList/verticalSortableList', () => ({
|
||||
...jest.requireActual('../../generic/DraggableList/verticalSortableList'),
|
||||
// Since jsdom (used by jest) does not support getBoundingClientRect function
|
||||
// which is required for drag-n-drop calculations, we mock closestCorners fn
|
||||
// from dnd-kit to return collided elements as per the test. This allows us to
|
||||
// test all drag-n-drop handlers.
|
||||
closestCenter: () => closestCenter(),
|
||||
verticalSortableListCollisionDetection: () => verticalSortableListCollisionDetection(),
|
||||
}));
|
||||
|
||||
describe('<LibraryUnitPage />', () => {
|
||||
@@ -187,9 +187,9 @@ describe('<LibraryUnitPage />', () => {
|
||||
|
||||
it('should open and close component sidebar on component selection', async () => {
|
||||
renderLibraryUnitPage();
|
||||
|
||||
const component = await screen.findByText('text block 0');
|
||||
userEvent.click(component);
|
||||
// Card is 3 levels up the component name div
|
||||
userEvent.click(component.parentElement!.parentElement!.parentElement!);
|
||||
const sidebar = await screen.findByTestId('library-sidebar');
|
||||
|
||||
const { findByRole, findByText } = within(sidebar);
|
||||
@@ -276,29 +276,39 @@ describe('<LibraryUnitPage />', () => {
|
||||
axiosMock
|
||||
.onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId))
|
||||
.reply(200);
|
||||
closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
|
||||
});
|
||||
verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
|
||||
});
|
||||
setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' }));
|
||||
await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated'));
|
||||
});
|
||||
|
||||
it('should cancel update order api on cancelling dragging component', async () => {
|
||||
renderLibraryUnitPage();
|
||||
const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0];
|
||||
axiosMock
|
||||
.onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId))
|
||||
.reply(200);
|
||||
verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
|
||||
});
|
||||
setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Escape' }));
|
||||
await waitFor(() => expect(mockShowToast).not.toHaveBeenLastCalledWith('Order updated'));
|
||||
});
|
||||
|
||||
it('should show toast error message on update order failure', async () => {
|
||||
renderLibraryUnitPage();
|
||||
const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0];
|
||||
axiosMock
|
||||
.onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId))
|
||||
.reply(500);
|
||||
closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
|
||||
});
|
||||
verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(firstDragHandle, { code: 'Space' });
|
||||
});
|
||||
setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' }));
|
||||
await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update components order'));
|
||||
});
|
||||
|
||||
@@ -311,7 +321,7 @@ describe('<LibraryUnitPage />', () => {
|
||||
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
|
||||
fireEvent.click(menu);
|
||||
|
||||
const removeButton = await screen.getByText('Remove from unit');
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -342,7 +352,7 @@ describe('<LibraryUnitPage />', () => {
|
||||
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
|
||||
fireEvent.click(menu);
|
||||
|
||||
const removeButton = await screen.getByText('Remove from unit');
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -360,7 +370,7 @@ describe('<LibraryUnitPage />', () => {
|
||||
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
|
||||
fireEvent.click(menu);
|
||||
|
||||
const removeButton = await screen.getByText('Remove from unit');
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -388,7 +398,7 @@ describe('<LibraryUnitPage />', () => {
|
||||
renderLibraryUnitPage();
|
||||
|
||||
const component = await screen.findByText('text block 0');
|
||||
userEvent.click(component);
|
||||
userEvent.click(component.parentElement!.parentElement!.parentElement!);
|
||||
const sidebar = await screen.findByTestId('library-sidebar');
|
||||
|
||||
const { findByRole, findByText } = within(sidebar);
|
||||
@@ -409,7 +419,7 @@ describe('<LibraryUnitPage />', () => {
|
||||
renderLibraryUnitPage();
|
||||
const component = await screen.findByText('text block 0');
|
||||
// trigger double click
|
||||
userEvent.click(component, undefined, { clickCount: 2 });
|
||||
userEvent.click(component.parentElement!.parentElement!.parentElement!, undefined, { clickCount: 2 });
|
||||
expect(await screen.findByRole('dialog', { name: 'Editor Dialog' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ const HeaderActions = () => {
|
||||
} else {
|
||||
openUnitInfoSidebar(unitId);
|
||||
}
|
||||
navigateTo({ unitId });
|
||||
navigateTo({ unitId, componentId: '' });
|
||||
}, [unitId, infoSidebarIsOpen]);
|
||||
|
||||
return (
|
||||
@@ -123,15 +123,24 @@ export const LibraryUnitPage = () => {
|
||||
const {
|
||||
libraryId,
|
||||
unitId,
|
||||
collectionId,
|
||||
componentId,
|
||||
collectionId,
|
||||
} = useLibraryContext();
|
||||
const {
|
||||
sidebarComponentInfo,
|
||||
openInfoSidebar,
|
||||
sidebarComponentInfo,
|
||||
setDefaultTab,
|
||||
setHiddenTabs,
|
||||
} = useSidebarContext();
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
|
||||
// Open unit or component sidebar on mount
|
||||
useEffect(() => {
|
||||
// includes componentId to open correct sidebar on page mount from url
|
||||
openInfoSidebar(componentId, collectionId, unitId);
|
||||
// avoid including componentId in dependencies to prevent flicker on closing sidebar.
|
||||
// See below useEffect that clears componentId on closing sidebar.
|
||||
}, [unitId, collectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultTab({
|
||||
@@ -150,10 +159,6 @@ export const LibraryUnitPage = () => {
|
||||
};
|
||||
}, [setDefaultTab, setHiddenTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
openInfoSidebar(componentId, collectionId, unitId);
|
||||
}, [componentId, unitId, collectionId]);
|
||||
|
||||
if (!unitId || !libraryId) {
|
||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||
throw new Error('Rendered without unitId or libraryId URL parameter');
|
||||
@@ -232,7 +237,7 @@ export const LibraryUnitPage = () => {
|
||||
className="library-authoring-sidebar box-shadow-left-1 bg-white"
|
||||
data-testid="library-sidebar"
|
||||
>
|
||||
<LibrarySidebar />
|
||||
<LibrarySidebar onSidebarClose={() => navigateTo({ componentId: '' })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
&:focus {
|
||||
// this is required for clicks to be passed to underlying iframe component
|
||||
pointer-events: none;
|
||||
outline: solid 1px $dark-500;
|
||||
}
|
||||
|
||||
&::before {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/utils.js
21
src/utils.js
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useState, useContext, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import * as Yup from 'yup';
|
||||
@@ -301,3 +301,22 @@ export const getFileSizeToClosestByte = (fileSize) => {
|
||||
const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2);
|
||||
return `${fileSizeFixedDecimal} ${units[divides]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A generic hook to run callback on next render cycle.
|
||||
* @param {} callback - Callback function that needs to be run later
|
||||
*/
|
||||
export const useRunOnNextRender = (callback) => {
|
||||
const [scheduled, setScheduled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScheduled(false);
|
||||
callback();
|
||||
}, [scheduled]);
|
||||
|
||||
return () => setScheduled(true);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user