Skip to content

Commit

Permalink
feat: library component picker now supports multi-select (#1417)
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido authored Oct 22, 2024
1 parent fe37d11 commit 11470f2
Show file tree
Hide file tree
Showing 8 changed files with 443 additions and 90 deletions.
1 change: 1 addition & 0 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 />} />
Expand Down
190 changes: 148 additions & 42 deletions src/library-authoring/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,66 @@ 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',
ComponentInfo = 'component-info',
CollectionInfo = 'collection-info',
}

export enum SidebarAdditionalActions {
JumpToAddCollections = 'jump-to-add-collections',
}

export interface SidebarComponentInfo {
type: SidebarBodyComponentId;
id: string;
/** Additional action on Sidebar display */
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;
readOnly: boolean;
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:
Expand All @@ -61,7 +93,7 @@ export interface LibraryContextData {
openComponentEditor: (usageKey: string) => void;
closeComponentEditor: () => void;
resetSidebarAdditionalActions: () => void;
}
} & ComponentPickerType;

/**
* Library Context.
Expand All @@ -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`
Expand All @@ -93,7 +142,9 @@ export const LibraryProvider = ({
children,
libraryId,
collectionId: collectionIdProp,
componentPickerMode = false,
componentPickerMode,
onComponentSelected,
onChangeComponentSelection,
showOnlyPublished = false,
initialSidebarComponentInfo,
}: LibraryProviderProps) => {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 71 additions & 13 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,14 +22,79 @@ 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();

const {
sidebarComponentInfo,
readOnly,
openComponentEditor,
componentPickerMode,
resetSidebarAdditionalActions,
} = useLibraryContext();

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
Loading

0 comments on commit 11470f2

Please sign in to comment.