feat: library component picker now supports multi-select (#1417)

This commit is contained in:
Rômulo Penido
2024-10-22 20:41:49 -03:00
committed by GitHub
parent fe37d119f2
commit 11470f256d
8 changed files with 443 additions and 90 deletions

View File

@@ -61,6 +61,7 @@ const App = () => {
<Route path="/library/create" element={<CreateLibrary />} />
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
<Route path="/component-picker" element={<ComponentPicker />} />
<Route path="/component-picker/multiple" element={<ComponentPicker componentPickerMode="multiple" />} />
<Route path="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />

View File

@@ -9,6 +9,40 @@ import React, {
import type { ContentLibrary } from '../data/api';
import { useContentLibrary } from '../data/apiHooks';
interface SelectedComponent {
usageKey: string;
blockType: string;
}
export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void;
export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void;
type NoComponentPickerType = {
componentPickerMode?: undefined;
onComponentSelected?: never;
selectedComponents?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
};
type ComponentPickerSingleType = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
selectedComponents?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
};
type ComponentPickerMultipleType = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
selectedComponents: SelectedComponent[];
addComponentToSelectedComponents: ComponentSelectedEvent;
removeComponentFromSelectedComponents: ComponentSelectedEvent;
};
type ComponentPickerType = NoComponentPickerType | ComponentPickerSingleType | ComponentPickerMultipleType;
export enum SidebarBodyComponentId {
AddContent = 'add-content',
Info = 'info',
@@ -16,10 +50,6 @@ export enum SidebarBodyComponentId {
CollectionInfo = 'collection-info',
}
export enum SidebarAdditionalActions {
JumpToAddCollections = 'jump-to-add-collections',
}
export interface SidebarComponentInfo {
type: SidebarBodyComponentId;
id: string;
@@ -27,7 +57,11 @@ export interface SidebarComponentInfo {
additionalAction?: SidebarAdditionalActions;
}
export interface LibraryContextData {
export enum SidebarAdditionalActions {
JumpToAddCollections = 'jump-to-add-collections',
}
export type LibraryContextData = {
/** The ID of the current library */
libraryId: string;
libraryData?: ContentLibrary;
@@ -35,8 +69,6 @@ export interface LibraryContextData {
isLoadingLibraryData: boolean;
collectionId: string | undefined;
setCollectionId: (collectionId?: string) => void;
// Whether we're in "component picker" mode
componentPickerMode: boolean;
// Only show published components
showOnlyPublished: boolean;
// Sidebar stuff - only one sidebar is active at any given time:
@@ -61,7 +93,7 @@ export interface LibraryContextData {
openComponentEditor: (usageKey: string) => void;
closeComponentEditor: () => void;
resetSidebarAdditionalActions: () => void;
}
} & ComponentPickerType;
/**
* Library Context.
@@ -73,18 +105,35 @@ export interface LibraryContextData {
*/
const LibraryContext = React.createContext<LibraryContextData | undefined>(undefined);
interface LibraryProviderProps {
type NoComponentPickerProps = {
componentPickerMode?: undefined;
onComponentSelected?: never;
onChangeComponentSelection?: never;
};
export type ComponentPickerSingleProps = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
onChangeComponentSelection?: never;
};
export type ComponentPickerMultipleProps = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
onChangeComponentSelection?: ComponentSelectionChangedEvent;
};
type ComponentPickerProps = NoComponentPickerProps | ComponentPickerSingleProps | ComponentPickerMultipleProps;
type LibraryProviderProps = {
children?: React.ReactNode;
libraryId: string;
/** The initial collection ID to show */
collectionId?: string;
/** The component picker mode is a special mode where the user is selecting a component to add to a Unit (or another
* XBlock) */
componentPickerMode?: boolean;
showOnlyPublished?: boolean;
/** Only used for testing */
initialSidebarComponentInfo?: SidebarComponentInfo;
}
} & ComponentPickerProps;
/**
* React component to provide `LibraryContext`
@@ -93,7 +142,9 @@ export const LibraryProvider = ({
children,
libraryId,
collectionId: collectionIdProp,
componentPickerMode = false,
componentPickerMode,
onComponentSelected,
onChangeComponentSelection,
showOnlyPublished = false,
initialSidebarComponentInfo,
}: LibraryProviderProps) => {
@@ -106,6 +157,8 @@ export const LibraryProvider = ({
const [componentBeingEdited, openComponentEditor] = useState<string | undefined>();
const closeComponentEditor = useCallback(() => openComponentEditor(undefined), []);
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
/** Helper function to consume addtional action once performed.
Required to redo the action.
*/
@@ -140,44 +193,97 @@ export const LibraryProvider = ({
});
}, []);
const addComponentToSelectedComponents = useCallback<ComponentSelectedEvent>((
selectedComponent: SelectedComponent,
) => {
setSelectedComponents((prevSelectedComponents) => {
// istanbul ignore if: this should never happen
if (prevSelectedComponents.some((component) => component.usageKey === selectedComponent.usageKey)) {
return prevSelectedComponents;
}
const newSelectedComponents = [...prevSelectedComponents, selectedComponent];
onChangeComponentSelection?.(newSelectedComponents);
return newSelectedComponents;
});
}, []);
const removeComponentFromSelectedComponents = useCallback<ComponentSelectedEvent>((
selectedComponent: SelectedComponent,
) => {
setSelectedComponents((prevSelectedComponents) => {
// istanbul ignore if: this should never happen
if (!prevSelectedComponents.some((component) => component.usageKey === selectedComponent.usageKey)) {
return prevSelectedComponents;
}
const newSelectedComponents = prevSelectedComponents.filter(
(component) => component.usageKey !== selectedComponent.usageKey,
);
onChangeComponentSelection?.(newSelectedComponents);
return newSelectedComponents;
});
}, []);
const { data: libraryData, isLoading: isLoadingLibraryData } = useContentLibrary(libraryId);
const readOnly = componentPickerMode || !libraryData?.canEditLibrary;
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
const context = useMemo<LibraryContextData>(() => ({
libraryId,
libraryData,
collectionId,
setCollectionId,
readOnly,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
sidebarComponentInfo,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
resetSidebarAdditionalActions,
}), [
const context = useMemo<LibraryContextData>(() => {
const contextValue = {
libraryId,
libraryData,
collectionId,
setCollectionId,
readOnly,
isLoadingLibraryData,
showOnlyPublished,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
sidebarComponentInfo,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
resetSidebarAdditionalActions,
};
if (componentPickerMode === 'single') {
return {
...contextValue,
componentPickerMode,
onComponentSelected,
};
}
if (componentPickerMode === 'multiple') {
return {
...contextValue,
componentPickerMode,
selectedComponents,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
};
}
return contextValue;
}, [
libraryId,
collectionId,
setCollectionId,
libraryData,
readOnly,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
onChangeComponentSelection,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,

View File

@@ -6,6 +6,10 @@ import {
Tabs,
Stack,
} from '@openedx/paragon';
import {
CheckBoxIcon,
CheckBoxOutlineBlank,
} from '@openedx/paragon/icons';
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
import { ComponentMenu } from '../components';
@@ -18,6 +22,72 @@ import { getBlockType } from '../../generic/key-utils';
import { useLibraryBlockMetadata, usePublishComponent } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
const AddComponentWidget = () => {
const intl = useIntl();
const {
sidebarComponentInfo,
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
} = useLibraryContext();
const usageKey = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
if (!componentPickerMode) {
return null;
}
if (componentPickerMode === 'single') {
return (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
onClick={() => {
onComponentSelected({ usageKey, blockType: getBlockType(usageKey) });
}}
>
{intl.formatMessage(messages.componentPickerSingleSelect)}
</Button>
);
}
if (componentPickerMode === 'multiple') {
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
const handleChange = () => {
const selectedComponent = {
usageKey,
blockType: getBlockType(usageKey),
};
if (!isChecked) {
addComponentToSelectedComponents(selectedComponent);
} else {
removeComponentFromSelectedComponents(selectedComponent);
}
};
return (
<Button
variant="outline-primary"
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
onClick={handleChange}
>
{intl.formatMessage(messages.componentPickerMultipleSelect)}
</Button>
);
}
// istanbul ignore next: this should never happen
return null;
};
const ComponentInfo = () => {
const intl = useIntl();
@@ -25,7 +95,6 @@ const ComponentInfo = () => {
sidebarComponentInfo,
readOnly,
openComponentEditor,
componentPickerMode,
resetSidebarAdditionalActions,
} = useLibraryContext();
@@ -53,13 +122,6 @@ const ComponentInfo = () => {
const canEdit = canEditComponent(usageKey);
const handleAddComponentToCourse = () => {
window.parent.postMessage({
usageKey,
type: 'pickerComponentSelected',
category: getBlockType(usageKey),
}, '*');
};
const publishComponent = usePublishComponent(usageKey);
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
// Only can be published when the component has been modified after the last published date.
@@ -92,11 +154,7 @@ const ComponentInfo = () => {
<ComponentMenu usageKey={usageKey} />
</div>
)}
{componentPickerMode && (
<Button variant="outline-primary" className="m-1 text-nowrap flex-grow-1" onClick={handleAddComponentToCourse}>
{intl.formatMessage(messages.addComponentToCourse)}
</Button>
)}
<AddComponentWidget />
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"

View File

@@ -171,15 +171,15 @@ const messages = defineMessages({
defaultMessage: 'This component is not organized into any collection.',
description: 'Message to display in manage collections section when component is not part of any collection.',
},
addComponentToCourse: {
id: 'course-authoring.library-authoring.component.add-to-course',
defaultMessage: 'Add to Course',
componentPickerSingleSelect: {
id: 'course-authoring.library-authoring.component-picker.single-select',
defaultMessage: 'Add to Course', // TODO: Change this message to a generic one?
description: 'Button to add component to course',
},
addComponentToCourseError: {
id: 'course-authoring.library-authoring.component.add-to-course-error',
defaultMessage: 'Failed to add component to course',
description: 'Error message when adding component to course fails',
componentPickerMultipleSelect: {
id: 'course-authoring.library-authoring.component-picker.multiple-select',
defaultMessage: 'Select',
description: 'Button to select component',
},
publishSuccessMsg: {
id: 'course-authoring.component-authoring.component.publish.success',

View File

@@ -4,6 +4,7 @@ import {
fireEvent,
render,
screen,
waitFor,
within,
} from '../../testUtils';
import mockResult from '../__mocks__/library-search.json';
@@ -174,4 +175,86 @@ describe('<ComponentPicker />', () => {
await screen.findByText('Select which Library would you like to reference components from.');
});
it('should pick multiple components using the component card button', async () => {
const onChange = jest.fn();
render(<ComponentPicker componentPickerMode="multiple" onChangeComponentSelection={onChange} />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Select the first component
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
blockType: 'html',
},
]));
onChange.mockClear();
// Select another component (the second "Select" button is the same component as the first,
// but in the "Components" section instead of the "Recently Changed" section)
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[2]);
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
blockType: 'html',
},
{
blockType: 'html',
usageKey: 'lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480',
},
]));
onChange.mockClear();
// Deselect the first component
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
blockType: 'html',
usageKey: 'lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480',
},
]));
});
it('should pick multilpe components using the component sidebar', async () => {
const onChange = jest.fn();
render(<ComponentPicker componentPickerMode="multiple" onChangeComponentSelection={onChange} />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
// Wait for the content library to load
await screen.findByText(/Change Library/i);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// Click on the component card to open the sidebar
fireEvent.click(screen.queryAllByText('Introduction to Testing')[0]);
const sidebar = await screen.findByTestId('library-sidebar');
// Click the select component from the component sidebar
fireEvent.click(within(sidebar).getByRole('button', { name: 'Select' }));
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
blockType: 'html',
},
]));
onChange.mockClear();
// Click to deselect component from the component sidebar
fireEvent.click(within(sidebar).getByRole('button', { name: 'Select' }));
await waitFor(() => expect(onChange).toHaveBeenCalledWith([]));
});
});

View File

@@ -2,7 +2,12 @@ import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Stepper } from '@openedx/paragon';
import { LibraryProvider, useLibraryContext } from '../common/context';
import {
type ComponentSelectedEvent,
type ComponentSelectionChangedEvent,
LibraryProvider,
useLibraryContext,
} from '../common/context';
import LibraryAuthoringPage from '../LibraryAuthoringPage';
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
import SelectLibrary from './SelectLibrary';
@@ -20,8 +25,35 @@ const InnerComponentPicker: React.FC<LibraryComponentPickerProps> = ({ returnToL
return <LibraryAuthoringPage returnToLibrarySelection={returnToLibrarySelection} />;
};
/** Default handler in single-select mode. Used by the legacy UI for adding a single selected component to a course. */
const defaultComponentSelectedCallback: ComponentSelectedEvent = ({ usageKey, blockType }) => {
window.parent.postMessage({ usageKey, type: 'pickerComponentSelected', category: blockType }, '*');
};
/** Default handler in multi-select mode. Used by the legacy UI for adding components to a problem bank. */
const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selections) => {
window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*');
};
type ComponentPickerProps = {
componentPickerMode?: 'single',
onComponentSelected?: ComponentSelectedEvent,
onChangeComponentSelection?: never,
} | {
componentPickerMode: 'multiple'
onComponentSelected?: never,
onChangeComponentSelection?: ComponentSelectionChangedEvent,
};
// eslint-disable-next-line import/prefer-default-export
export const ComponentPicker = () => {
export const ComponentPicker: React.FC<ComponentPickerProps> = ({
componentPickerMode = 'single',
/** This default callback is used to send the selected component back to the parent window,
* when the component picker is used in an iframe.
*/
onComponentSelected = defaultComponentSelectedCallback,
onChangeComponentSelection = defaultSelectionChangedCallback,
}) => {
const [currentStep, setCurrentStep] = useState('select-library');
const [selectedLibrary, setSelectedLibrary] = useState('');
@@ -40,6 +72,14 @@ export const ComponentPicker = () => {
setSelectedLibrary('');
};
const libraryProviderProps = componentPickerMode === 'single' ? {
componentPickerMode,
onComponentSelected,
} : {
componentPickerMode,
onChangeComponentSelection,
};
return (
<Stepper
activeKey={currentStep}
@@ -49,7 +89,11 @@ export const ComponentPicker = () => {
</Stepper.Step>
<Stepper.Step eventKey="pick-components" title="Pick some components">
<LibraryProvider libraryId={selectedLibrary} componentPickerMode showOnlyPublished={variant === 'published'}>
<LibraryProvider
libraryId={selectedLibrary}
showOnlyPublished={variant === 'published'}
{...libraryProviderProps}
>
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
</LibraryProvider>
</Stepper.Step>

View File

@@ -8,7 +8,12 @@ import {
IconButton,
useToggle,
} from '@openedx/paragon';
import { AddCircleOutline, MoreVert } from '@openedx/paragon/icons';
import {
AddCircleOutline,
CheckBoxIcon,
CheckBoxOutlineBlank,
MoreVert,
} from '@openedx/paragon/icons';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { updateClipboard } from '../../generic/data/api';
@@ -89,9 +94,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
<FormattedMessage {...messages.menuDelete} />
</Dropdown.Item>
{collectionId && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
)}
<Dropdown.Item onClick={showManageCollections}>
<FormattedMessage {...messages.menuAddToCollection} />
@@ -102,6 +107,76 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
);
};
interface AddComponentWidgetProps {
usageKey: string;
blockType: string;
}
const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => {
const intl = useIntl();
const {
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
} = useLibraryContext();
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}
// istanbul ignore if: this should never happen
if (!componentPickerMode) {
return null;
}
if (componentPickerMode === 'single') {
return (
<Button
variant="outline-primary"
iconBefore={AddCircleOutline}
onClick={() => {
onComponentSelected({ usageKey, blockType });
}}
>
<FormattedMessage {...messages.componentPickerSingleSelectTitle} />
</Button>
);
}
if (componentPickerMode === 'multiple') {
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
const handleChange = () => {
const selectedComponent = {
usageKey,
blockType,
};
if (!isChecked) {
addComponentToSelectedComponents(selectedComponent);
} else {
removeComponentFromSelectedComponents(selectedComponent);
}
};
return (
<Button
variant="outline-primary"
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
onClick={handleChange}
>
{intl.formatMessage(messages.componentPickerMultipleSelectTitle)}
</Button>
);
}
// istanbul ignore next: this should never happen
return null;
};
const ComponentCard = ({ contentHit }: ComponentCardProps) => {
const {
openComponentInfoSidebar,
@@ -122,14 +197,6 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
showOnlyPublished ? formatted.published?.displayName : formatted.displayName
) ?? '';
const handleAddComponentToCourse = () => {
window.parent.postMessage({
usageKey,
type: 'pickerComponentSelected',
category: blockType,
}, '*');
};
return (
<BaseComponentCard
componentType={blockType}
@@ -139,13 +206,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
actions={(
<ActionRow>
{componentPickerMode ? (
<Button
variant="outline-primary"
iconBefore={AddCircleOutline}
onClick={handleAddComponentToCourse}
>
<FormattedMessage {...messages.addComponentToCourseButtonTitle} />
</Button>
<AddComponentWidget usageKey={usageKey} blockType={blockType} />
) : (
<ComponentMenu usageKey={usageKey} />
)}

View File

@@ -121,15 +121,15 @@ const messages = defineMessages({
defaultMessage: 'Failed to undo delete collection operation',
description: 'Message to display on failure to undo delete collection',
},
addComponentToCourseButtonTitle: {
id: 'course-authoring.library-authoring.component-picker.button.title',
componentPickerSingleSelectTitle: {
id: 'course-authoring.library-authoring.component-picker.single..title',
defaultMessage: 'Add',
description: 'Button title for picking a component',
},
addComponentToCourseError: {
id: 'course-authoring.library-authoring.component-picker.error',
defaultMessage: 'Failed to add component to course',
description: 'Error message for failed to add component to course',
componentPickerMultipleSelectTitle: {
id: 'course-authoring.library-authoring.component-picker.multiple.title',
defaultMessage: 'Select',
description: 'Button title for selecting multiple components',
},
});