diff --git a/src/index.jsx b/src/index.jsx
index ece4b5710..34f27f1b9 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -61,6 +61,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index 85fd31d92..b16082bc0 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -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(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();
const closeComponentEditor = useCallback(() => openComponentEditor(undefined), []);
+ const [selectedComponents, setSelectedComponents] = useState([]);
+
/** Helper function to consume addtional action once performed.
Required to redo the action.
*/
@@ -140,44 +193,97 @@ export const LibraryProvider = ({
});
}, []);
+ const addComponentToSelectedComponents = useCallback((
+ 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((
+ 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(() => ({
- 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(() => {
+ 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,
diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx
index 80e773c95..36832fc32 100644
--- a/src/library-authoring/component-info/ComponentInfo.tsx
+++ b/src/library-authoring/component-info/ComponentInfo.tsx
@@ -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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
+ // 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 = () => {
)}
- {componentPickerMode && (
-
- )}
+
', () => {
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();
+
+ 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();
+
+ 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([]));
+ });
});
diff --git a/src/library-authoring/component-picker/ComponentPicker.tsx b/src/library-authoring/component-picker/ComponentPicker.tsx
index 2502e1ac6..b40c2bd9a 100644
--- a/src/library-authoring/component-picker/ComponentPicker.tsx
+++ b/src/library-authoring/component-picker/ComponentPicker.tsx
@@ -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 = ({ returnToL
return ;
};
+/** 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 = ({
+ 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 (
{
-
+
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index f0da3a51a..c21f15467 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -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 }) => {
{collectionId && (
-
-
-
+
+
+
)}
@@ -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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
+ // 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 (
{
actions={(
{componentPickerMode ? (
-
+
) : (
)}
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index c8ee584a2..b591d956f 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -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',
},
});