feat: library component picker now supports multi-select (#1417)
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user