From 11470f256d9ce330576c0771b5cbc0933dc3b51e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 22 Oct 2024 20:41:49 -0300 Subject: [PATCH] feat: library component picker now supports multi-select (#1417) --- src/index.jsx | 1 + src/library-authoring/common/context.tsx | 190 ++++++++++++++---- .../component-info/ComponentInfo.tsx | 84 ++++++-- .../component-info/messages.ts | 14 +- .../component-picker/ComponentPicker.test.tsx | 83 ++++++++ .../component-picker/ComponentPicker.tsx | 50 ++++- .../components/ComponentCard.tsx | 99 +++++++-- src/library-authoring/components/messages.ts | 12 +- 8 files changed, 443 insertions(+), 90 deletions(-) diff --git a/src/index.jsx b/src/index.jsx index ece4b57105..34f27f1b9a 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 85fd31d923..b16082bc0f 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 80e773c952..36832fc329 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 2502e1ac6b..b40c2bd9af 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 f0da3a51a0..c21f15467e 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 c8ee584a2c..b591d956f5 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', }, });