diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index b49ae07e76..4b5b2074b2 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -38,7 +38,7 @@ import {
} from '../search-manager';
import LibraryContent from './LibraryContent';
import { LibrarySidebar } from './library-sidebar';
-import { useComponentPickerContext } from './common/context/ComponentPickerContext';
+import { useComponentPickerContext, CollectionIndexProvider } from './common/context/ComponentPickerContext';
import { useLibraryContext } from './common/context/LibraryContext';
import { SidebarBodyComponentId, useSidebarContext } from './common/context/SidebarContext';
import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes';
@@ -233,10 +233,9 @@ const LibraryAuthoringPage = ({
const activeTypeFilters = {
components: 'type = "library_block"',
- collections: 'type = "collection"',
units: 'block_type = "unit"',
};
- if (activeKey !== ContentType.home) {
+ if (activeKey !== ContentType.home && activeKey !== ContentType.collections) {
extraFilter.push(activeTypeFilters[activeKey]);
}
@@ -285,38 +284,40 @@ const LibraryAuthoringPage = ({
extraFilter={extraFilter}
overrideTypesFilter={overrideTypesFilter}
>
- }
- subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined}
- breadcrumbs={breadcumbs}
- headerActions={ }
- hideBorder
- />
-
- {visibleTabsToRender}
-
-
-
-
- {!(insideCollections || insideUnits) && }
-
+ }
+ subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined}
+ breadcrumbs={breadcumbs}
+ headerActions={ }
+ hideBorder
/>
-
-
-
-
-
+
+ {visibleTabsToRender}
+
+
+
+
+ {!(insideCollections || insideUnits) && }
+
+
+
+
+
+
+
{!componentPickerMode && }
diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx
index 53fdb23ecd..882c182615 100644
--- a/src/library-authoring/LibraryContent.tsx
+++ b/src/library-authoring/LibraryContent.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
import { LoadingSpinner } from '../generic/Loading';
import { useSearchContext } from '../search-manager';
import { NoComponents, NoSearchResults } from './EmptyStates';
@@ -43,6 +43,20 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
const { openCreateCollectionModal } = useLibraryContext();
const { openAddContentSidebar, openComponentInfoSidebar } = useSidebarContext();
+ /**
+ * Filter collections on the frontend to display only collection cards in the Collections tab.
+ * This approach is used instead of backend filtering to ensure that all components (including those
+ * within collections) remain available in the 'hits' array. This is necessary for the component
+ * selection workflow when adding components to xblocks by choosing the whole collection in Collections tab.
+ * Note: LibraryAuthoringPage.tsx has been modified to skip backend filtering for this purpose.
+ */
+ const filteredHits = useMemo(
+ () => (contentType === ContentType.collections
+ ? hits.filter((hit) => hit.type === 'collection')
+ : hits),
+ [hits, contentType],
+ );
+
useEffect(() => {
if (usageKey) {
openComponentInfoSidebar(usageKey);
@@ -76,7 +90,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
return (
- {hits.map((contentHit) => {
+ {filteredHits.map((contentHit) => {
const CardComponent = LibraryItemCard[contentHit.type] || ComponentCard;
return
;
diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json
index ba27af2185..8299bb5f21 100644
--- a/src/library-authoring/__mocks__/library-search.json
+++ b/src/library-authoring/__mocks__/library-search.json
@@ -67,6 +67,7 @@
},
{
"display_name": "Collection 2",
+ "block_id": "col2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
"id": 2,
"type": "collection",
@@ -99,6 +100,7 @@
},
{
"display_name": "Collection 3",
+ "block_id": "col3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
"id": 3,
"type": "collection",
@@ -131,6 +133,7 @@
},
{
"display_name": "Collection 4",
+ "block_id": "col4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
"id": 4,
"type": "collection",
@@ -163,6 +166,7 @@
},
{
"display_name": "Collection 5",
+ "block_id": "col5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
"id": 5,
"type": "collection",
@@ -195,6 +199,7 @@
},
{
"display_name": "Collection 6",
+ "block_id": "col6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
"id": 6,
"type": "collection",
diff --git a/src/library-authoring/collections/LibraryCollectionComponents.tsx b/src/library-authoring/collections/LibraryCollectionComponents.tsx
index 6fcd79aa5c..b0d071071d 100644
--- a/src/library-authoring/collections/LibraryCollectionComponents.tsx
+++ b/src/library-authoring/collections/LibraryCollectionComponents.tsx
@@ -4,7 +4,6 @@ import { useSearchContext } from '../../search-manager';
import messages from './messages';
import { useSidebarContext } from '../common/context/SidebarContext';
import LibraryContent from '../LibraryContent';
-import { ContentType } from '../routes';
const LibraryCollectionComponents = () => {
const { totalHits: componentCount, isFiltered } = useSearchContext();
@@ -25,7 +24,7 @@ const LibraryCollectionComponents = () => {
return (
Content ({componentCount})
-
+
);
};
diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx
index 943664788d..314ed76cbd 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.tsx
@@ -29,7 +29,7 @@ import {
} from '../../search-manager';
import { SubHeaderTitle } from '../LibraryAuthoringPage';
import { useCollection, useContentLibrary } from '../data/apiHooks';
-import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
+import { useComponentPickerContext, CollectionIndexProvider } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext';
import messages from './messages';
@@ -208,22 +208,24 @@ const LibraryCollectionPage = () => {
- }
- breadcrumbs={breadcrumbs}
- headerActions={ }
- hideBorder
- />
-
-
-
-
-
-
-
-
-
-
+
+ }
+ breadcrumbs={breadcrumbs}
+ headerActions={ }
+ hideBorder
+ />
+
+
+
+
+
+
+
+
+
+
+
{!componentPickerMode &&
}
diff --git a/src/library-authoring/common/context/ComponentPickerContext.tsx b/src/library-authoring/common/context/ComponentPickerContext.tsx
index 83c258ec1d..2b4f95ce37 100644
--- a/src/library-authoring/common/context/ComponentPickerContext.tsx
+++ b/src/library-authoring/common/context/ComponentPickerContext.tsx
@@ -5,13 +5,35 @@ import {
useMemo,
useState,
} from 'react';
+import { ContentHit, useSearchContext } from '../../../search-manager';
+
+/**
+ * Provides pre-computed collection indexing data to avoid repeated computation.
+ */
+const CollectionIndexContext = createContext
(undefined);
export interface SelectedComponent {
usageKey: string;
blockType: string;
+ collectionKeys?: string[];
}
-export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void;
+export type CollectionStatus = 'selected' | 'indeterminate';
+
+export interface SelectedCollection {
+ key: string;
+ status: CollectionStatus;
+}
+
+export interface CollectionData {
+ components: SelectedComponent[];
+ affectedCollectionSizes: Map;
+}
+
+export type ComponentSelectedEvent = (
+ selectedComponent: SelectedComponent,
+ collectionComponents?: CollectionData | Map
+) => void;
export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void;
type NoComponentPickerType = {
@@ -21,6 +43,7 @@ type NoComponentPickerType = {
*/
onComponentSelected?: never;
selectedComponents?: never;
+ selectedCollections?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
restrictToLibrary?: never;
@@ -30,6 +53,7 @@ type ComponentPickerSingleType = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
selectedComponents?: never;
+ selectedCollections?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
restrictToLibrary: boolean;
@@ -39,6 +63,7 @@ type ComponentPickerMultipleType = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
selectedComponents: SelectedComponent[];
+ selectedCollections: SelectedCollection[];
addComponentToSelectedComponents: ComponentSelectedEvent;
removeComponentFromSelectedComponents: ComponentSelectedEvent;
restrictToLibrary: boolean;
@@ -74,6 +99,121 @@ type ComponentPickerProviderProps = {
children?: React.ReactNode;
} & ComponentPickerProps;
+/**
+ * Pre-computed collection indexing data for O(1) lookups
+ */
+export interface CollectionIndexData {
+ /** Map: collectionKey → components in that collection */
+ collectionToComponents: Map;
+ /** Map: componentUsageKey → collection keys it belongs to */
+ componentToCollections: Map;
+ /** Map: collectionKey → Map of all affected collections with their sizes */
+ collectionToAffectedSizes: Map>;
+ /** Map: collectionKey → total component count (for quick size lookup) */
+ collectionSizes: Map;
+}
+
+/**
+ * Hook to build indexing maps for collections and components.
+ * Pre-computes all relationships for O(1) lookups during selection operations.
+ * @param hits - Search hits from which to build the indexes
+ * @returns Pre-computed collection index data
+ */
+export const useCollectionIndexing = (
+ hits: ReturnType['hits'],
+): CollectionIndexData => useMemo(() => {
+ const collectionToComponents = new Map();
+ const componentToCollections = new Map();
+ const collectionSizes = new Map();
+
+ // First pass: build basic indexes
+ hits.forEach((hit) => {
+ if (hit.type === 'library_block') {
+ const collectionKeys = (hit as ContentHit).collections?.key ?? [];
+
+ // Index component → collections mapping
+ if (hit.usageKey) {
+ componentToCollections.set(hit.usageKey, collectionKeys);
+ }
+
+ // Index collection → components mapping
+ collectionKeys.forEach((collectionKey: string) => {
+ if (!collectionToComponents.has(collectionKey)) {
+ collectionToComponents.set(collectionKey, []);
+ }
+ collectionToComponents.get(collectionKey)!.push({
+ usageKey: hit.usageKey,
+ blockType: hit.blockType,
+ collectionKeys,
+ });
+ });
+ }
+ });
+
+ // Second pass: compute collection sizes
+ collectionToComponents.forEach((components, collectionKey) => {
+ collectionSizes.set(collectionKey, components.length);
+ });
+
+ // Third pass: pre-compute affected collections for each collection
+ const collectionToAffectedSizes = new Map>();
+ collectionToComponents.forEach((components, collectionKey) => {
+ const affectedSizes = new Map();
+
+ // For each component in this collection, find all collections it belongs to
+ components.forEach((component) => {
+ component.collectionKeys?.forEach((affectedKey) => {
+ if (!affectedSizes.has(affectedKey)) {
+ affectedSizes.set(affectedKey, collectionSizes.get(affectedKey) ?? 0);
+ }
+ });
+ });
+
+ collectionToAffectedSizes.set(collectionKey, affectedSizes);
+ });
+
+ return {
+ collectionToComponents,
+ componentToCollections,
+ collectionToAffectedSizes,
+ collectionSizes,
+ };
+}, [hits]);
+
+type CollectionIndexProviderProps = {
+ children: React.ReactNode;
+};
+
+/**
+ * React component to provide pre-computed collection indexing data.
+ * Must be rendered inside a SearchContextProvider.
+ * This provider ensures collection indexing is computed once and shared
+ * with all AddComponentWidget instances, avoiding O(n*m) complexity.
+ */
+export const CollectionIndexProvider = ({ children }: CollectionIndexProviderProps) => {
+ const { hits } = useSearchContext();
+ const collectionIndexData = useCollectionIndexing(hits);
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Hook to access pre-computed collection indexing data.
+ * Must be used within a CollectionIndexProvider.
+ * @returns CollectionIndexData with pre-computed maps
+ */
+export const useCollectionIndexContext = (): CollectionIndexData => {
+ const ctx = useContext(CollectionIndexContext);
+ if (ctx === undefined) {
+ throw new Error('useCollectionIndexContext must be used within a CollectionIndexProvider');
+ }
+ return ctx;
+};
+
/**
* React component to provide `ComponentPickerContext`
*/
@@ -85,36 +225,126 @@ export const ComponentPickerProvider = ({
onChangeComponentSelection,
}: ComponentPickerProviderProps) => {
const [selectedComponents, setSelectedComponents] = useState([]);
+ const [selectedCollections, setSelectedCollections] = useState([]);
const addComponentToSelectedComponents = useCallback((
selectedComponent: SelectedComponent,
+ collectionComponents?: CollectionData | Map,
) => {
+ const isCollectionSelection = collectionComponents && 'components' in collectionComponents;
+ const componentsToAdd = isCollectionSelection
+ ? collectionComponents.components
+ : [selectedComponent];
+
setSelectedComponents((prevSelectedComponents) => {
- // istanbul ignore if: this should never happen
- if (prevSelectedComponents.some((component) => component.usageKey === selectedComponent.usageKey)) {
+ const existingKeys = new Set(prevSelectedComponents.map((c) => c.usageKey));
+ const newComponents = componentsToAdd.filter((c) => !existingKeys.has(c.usageKey));
+
+ if (newComponents.length === 0) {
return prevSelectedComponents;
}
- const newSelectedComponents = [...prevSelectedComponents, selectedComponent];
+
+ const newSelectedComponents = [...prevSelectedComponents, ...newComponents];
+
+ const collectionSizes = isCollectionSelection
+ ? collectionComponents.affectedCollectionSizes
+ : collectionComponents;
+
+ if (collectionSizes instanceof Map && collectionSizes.size > 0) {
+ const selectedByCollection = new Map();
+
+ newSelectedComponents.forEach((component) => {
+ component.collectionKeys?.forEach((key) => {
+ if (collectionSizes.has(key)) {
+ selectedByCollection.set(key, (selectedByCollection.get(key) ?? 0) + 1);
+ }
+ });
+ });
+
+ // Batch update all collection statuses
+ setSelectedCollections((prevSelectedCollections) => {
+ const collectionMap = new Map(
+ prevSelectedCollections.map((c) => [c.key, c]),
+ );
+
+ collectionSizes.forEach((totalCount, collectionKey) => {
+ const selectedCount = selectedByCollection.get(collectionKey) ?? 0;
+
+ if (selectedCount === 0) {
+ collectionMap.delete(collectionKey);
+ } else if (selectedCount >= totalCount) {
+ collectionMap.set(collectionKey, { key: collectionKey, status: 'selected' });
+ } else {
+ collectionMap.set(collectionKey, { key: collectionKey, status: 'indeterminate' });
+ }
+ });
+
+ return Array.from(collectionMap.values());
+ });
+ }
+
onChangeComponentSelection?.(newSelectedComponents);
return newSelectedComponents;
});
- }, []);
+ }, [onChangeComponentSelection]);
const removeComponentFromSelectedComponents = useCallback((
selectedComponent: SelectedComponent,
+ collectionComponents?: CollectionData | Map,
) => {
+ const isCollectionSelection = collectionComponents && 'components' in collectionComponents;
+ const componentsToRemove = isCollectionSelection
+ ? collectionComponents.components
+ : [selectedComponent];
+ const usageKeysToRemove = new Set(componentsToRemove.map((c) => c.usageKey));
+
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,
+ (component) => !usageKeysToRemove.has(component.usageKey),
);
+
+ const collectionSizes = isCollectionSelection
+ ? collectionComponents.affectedCollectionSizes
+ : collectionComponents;
+
+ if (collectionSizes instanceof Map && collectionSizes.size > 0) {
+ const selectedByCollection = new Map();
+
+ // Only count components for collections we care about
+ newSelectedComponents.forEach((component) => {
+ component.collectionKeys?.forEach((key) => {
+ if (collectionSizes.has(key)) {
+ selectedByCollection.set(key, (selectedByCollection.get(key) ?? 0) + 1);
+ }
+ });
+ });
+
+ // Batch update all collection statuses
+ setSelectedCollections((prevSelectedCollections) => {
+ const collectionMap = new Map(
+ prevSelectedCollections.map((c) => [c.key, c]),
+ );
+
+ collectionSizes.forEach((totalCount, collectionKey) => {
+ const selectedCount = selectedByCollection.get(collectionKey) ?? 0;
+
+ if (selectedCount === 0) {
+ collectionMap.delete(collectionKey);
+ } else if (selectedCount >= totalCount) {
+ collectionMap.set(collectionKey, { key: collectionKey, status: 'selected' });
+ } else {
+ collectionMap.set(collectionKey, { key: collectionKey, status: 'indeterminate' });
+ }
+ });
+
+ return Array.from(collectionMap.values());
+ });
+ }
+
onChangeComponentSelection?.(newSelectedComponents);
return newSelectedComponents;
});
- }, []);
+ }, [onChangeComponentSelection]);
const context = useMemo(() => {
switch (componentPickerMode) {
@@ -128,6 +358,7 @@ export const ComponentPickerProvider = ({
return {
componentPickerMode,
restrictToLibrary,
+ selectedCollections,
selectedComponents,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
@@ -143,7 +374,7 @@ export const ComponentPickerProvider = ({
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
- onChangeComponentSelection,
+ selectedCollections,
]);
return (
diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx
index cd7f497a37..6394eee202 100644
--- a/src/library-authoring/component-picker/ComponentPicker.test.tsx
+++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx
@@ -230,7 +230,11 @@ describe(' ', () => {
onChange.mockClear();
// Select another component
- fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[1]);
+ /**
+ * Due to collections have "Select" buttons as well, we need to target the 8th button in the list
+ * to select the second component in the search results, taking into account the `library-search.json` mock data.
+ */
+ fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[7]);
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
diff --git a/src/library-authoring/components/AddComponentWidget.tsx b/src/library-authoring/components/AddComponentWidget.tsx
index a6e1c0f9ba..b23dea7973 100644
--- a/src/library-authoring/components/AddComponentWidget.tsx
+++ b/src/library-authoring/components/AddComponentWidget.tsx
@@ -1,20 +1,31 @@
+import { useMemo } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import {
AddCircleOutline,
CheckBoxIcon,
+ IndeterminateCheckBox,
CheckBoxOutlineBlank,
} from '@openedx/paragon/icons';
-import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
+import {
+ SelectedComponent,
+ useComponentPickerContext,
+ useCollectionIndexContext,
+} from '../common/context/ComponentPickerContext';
+
import messages from './messages';
interface AddComponentWidgetProps {
usageKey: string;
blockType: string;
+ collectionKeys?: string[];
+ isCollection?: boolean;
}
-const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => {
+const AddComponentWidget = ({
+ usageKey, blockType, collectionKeys, isCollection,
+}: AddComponentWidgetProps) => {
const intl = useIntl();
const {
@@ -23,8 +34,47 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) =>
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
+ selectedCollections,
} = useComponentPickerContext();
+ const {
+ collectionToComponents,
+ componentToCollections,
+ collectionToAffectedSizes,
+ collectionSizes,
+ } = useCollectionIndexContext();
+
+ const collectionData = useMemo(() => {
+ if (isCollection) {
+ return {
+ components: collectionToComponents.get(usageKey) ?? [],
+ affectedCollectionSizes: collectionToAffectedSizes.get(usageKey) ?? new Map(),
+ };
+ }
+
+ const componentCollectionKeys = componentToCollections.get(usageKey);
+ if (!componentCollectionKeys?.length) {
+ return new Map();
+ }
+
+ const sizes = new Map();
+ componentCollectionKeys.forEach((collectionKey) => {
+ const size = collectionSizes.get(collectionKey);
+ if (size !== undefined) {
+ sizes.set(collectionKey, size);
+ }
+ });
+
+ return sizes;
+ }, [
+ collectionToComponents,
+ componentToCollections,
+ collectionToAffectedSizes,
+ collectionSizes,
+ usageKey,
+ isCollection,
+ ]);
+
// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
@@ -50,24 +100,46 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) =>
}
if (componentPickerMode === 'multiple') {
- const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
+ const collectionStatus = selectedCollections.find((c) => c.key === usageKey)?.status;
+
+ const isChecked = isCollection
+ ? collectionStatus === 'selected'
+ : selectedComponents.some((component) => component.usageKey === usageKey);
+
+ const isIndeterminate = isCollection && collectionStatus === 'indeterminate';
+
+ const getIcon = () => {
+ if (isChecked) { return CheckBoxIcon; }
+ if (isIndeterminate) { return IndeterminateCheckBox; }
+ return CheckBoxOutlineBlank;
+ };
const handleChange = () => {
- const selectedComponent = {
+ const selectedComponent: SelectedComponent = {
usageKey,
blockType,
+ collectionKeys,
};
+
+ const isCollectionData = isCollection && 'components' in collectionData;
+
if (!isChecked) {
- addComponentToSelectedComponents(selectedComponent);
+ addComponentToSelectedComponents(
+ selectedComponent,
+ isCollectionData ? collectionData : collectionData as Map,
+ );
} else {
- removeComponentFromSelectedComponents(selectedComponent);
+ removeComponentFromSelectedComponents(
+ selectedComponent,
+ isCollectionData ? collectionData : collectionData as Map,
+ );
}
};
return (
{intl.formatMessage(messages.componentPickerMultipleSelectTitle)}
diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx
index 21d5ef5d4f..ee978d7935 100644
--- a/src/library-authoring/components/CollectionCard.tsx
+++ b/src/library-authoring/components/CollectionCard.tsx
@@ -20,6 +20,7 @@ import { ToastContext } from '../../generic/toast-context';
import { useDeleteCollection, useRestoreCollection } from '../data/apiHooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages';
+import AddComponentWidget from './AddComponentWidget';
type CollectionMenuProps = {
hit: CollectionHit,
@@ -155,9 +156,16 @@ const CollectionCard = ({ hit } : CollectionCardProps) => {
description={description}
tags={tags}
numChildren={numChildrenCount}
- actions={!componentPickerMode && (
+ actions={(
-
+ {componentPickerMode ? (
+
+ ) : (
+ )}
)}
onSelect={openCollection}
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index 4b58147fbf..cc3aa3fd3f 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -26,6 +26,7 @@ const ComponentCard = ({ hit }: ComponentCardProps) => {
formatted,
tags,
usageKey,
+ collections,
publishStatus,
} = hit;
const componentDescription: string = (
@@ -56,7 +57,11 @@ const ComponentCard = ({ hit }: ComponentCardProps) => {
actions={(
{componentPickerMode ? (
-
+
) : (
)}
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts
index d829b8a527..afde2c68cb 100644
--- a/src/search-manager/data/api.ts
+++ b/src/search-manager/data/api.ts
@@ -310,6 +310,69 @@ export async function fetchSearchResults({
};
}
+export async function fetchAllSearchResults(
+ params: FetchSearchParams,
+): Promise<{
+ hits: HitType[];
+ totalHits: number;
+ blockTypes: Record;
+ problemTypes: Record;
+ publishStatus: Record;
+ }> {
+ const allHits: HitType[] = [];
+ const limit = params.limit ?? 100;
+
+ let offset = 0;
+ let nextOffset: number | undefined;
+
+ let totalHits = 0;
+ let blockTypes: Record = {};
+ let problemTypes: Record = {};
+ let publishStatus: Record = {};
+
+ let hasNextPage = true;
+
+ while (hasNextPage) {
+ // eslint-disable-next-line no-await-in-loop
+ const page = await fetchSearchResults({
+ ...params,
+ offset,
+ limit,
+ });
+
+ allHits.push(...page.hits);
+
+ if (offset === 0) {
+ totalHits = page.totalHits;
+ blockTypes = page.blockTypes;
+ problemTypes = page.problemTypes;
+ publishStatus = page.publishStatus;
+ }
+
+ nextOffset = page.nextOffset;
+
+ // Stop conditions
+ if (
+ nextOffset === undefined
+ || nextOffset <= offset
+ || allHits.length >= totalHits
+ ) {
+ hasNextPage = false;
+ break;
+ }
+
+ offset = nextOffset;
+ }
+
+ return {
+ hits: allHits,
+ totalHits,
+ blockTypes,
+ problemTypes,
+ publishStatus,
+ };
+}
+
/**
* Fetch the block types facet distribution for the search results.
*/
diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts
index a5ead78dcf..77507dc90f 100644
--- a/src/search-manager/data/apiHooks.ts
+++ b/src/search-manager/data/apiHooks.ts
@@ -6,7 +6,7 @@ import {
SearchSortOption,
TAG_SEP,
fetchAvailableTagOptions,
- fetchSearchResults,
+ fetchAllSearchResults,
fetchTagsThatMatchKeyword,
getContentSearchConfig,
fetchBlockTypes,
@@ -106,7 +106,7 @@ export const useContentSearchResults = ({
if (client === undefined || indexName === undefined) {
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
}
- return fetchSearchResults({
+ return fetchAllSearchResults({
client,
extraFilter,
indexName,
@@ -123,7 +123,6 @@ export const useContentSearchResults = ({
limit,
});
},
- getNextPageParam: (lastPage) => lastPage.nextOffset,
// Avoid flickering results when user is typing... keep old results until new is available.
keepPreviousData: true,
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.