Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion src/library-authoring/common/context/ComponentPickerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,46 @@ type ComponentPickerProviderProps = {
children?: React.ReactNode;
} & ComponentPickerProps;

/**
* Builds indexed maps for efficient collection lookups.
* This creates two maps:
* 1. collectionToComponents: Maps collection keys to their component arrays
* 2. componentToCollections: Maps component usage keys to their collection keys
*
* Complexity: O(n) once per hits update instead of O(n × m) per widget
*/
export const useCollectionIndexing = (
hits: any[], // Type should match ReturnType<typeof useSearchContext>['hits']
) => useMemo(() => {
const collectionToComponents = new Map<string, SelectedComponent[]>();
const componentToCollections = new Map<string, string[]>();

hits.forEach((hit) => {
if (hit.type === 'library_block') {
const collectionKeys = (hit as any).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,
});
});
}
});

return { collectionToComponents, componentToCollections };
}, [hits]);

/**
* React component to provide `ComponentPickerContext`
*/
Expand Down Expand Up @@ -129,6 +169,7 @@ export const ComponentPickerProvider = ({

/**
* Finds the common collection key between a component and selected components.
* Optimized with Set for O(n) instead of O(n*m) complexity.
*/
const findCommonCollectionKey = useCallback((
componentKeys: string[] | undefined,
Expand All @@ -138,8 +179,11 @@ export const ComponentPickerProvider = ({
return undefined;
}

// Convert to Set for O(1) lookups instead of O(m) for each includes()
const componentKeySet = new Set(componentKeys);

for (const component of components) {
const commonKey = component.collectionKeys?.find((key) => componentKeys.includes(key));
const commonKey = component.collectionKeys?.find((key) => componentKeySet.has(key));
if (commonKey) {
return commonKey;
}
Expand Down
65 changes: 27 additions & 38 deletions src/library-authoring/components/AddComponentWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import {
CheckBoxOutlineBlank,
} from '@openedx/paragon/icons';

import { ContentHit, useSearchContext } from '../../search-manager';
import { SelectedComponent, useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useSearchContext } from '../../search-manager';
import {
SelectedComponent,
useComponentPickerContext,
useCollectionIndexing,
} from '../common/context/ComponentPickerContext';
import messages from './messages';

interface AddComponentWidgetProps {
Expand All @@ -19,35 +23,6 @@ interface AddComponentWidgetProps {
isCollection?: boolean;
}

/**
* Builds an array of SelectedComponent from collection hits.
*/
const buildCollectionComponents = (
hits: ReturnType<typeof useSearchContext>['hits'],
collectionUsageKey: string,
): SelectedComponent[] => hits
.filter((hit) => hit.type === 'library_block' && hit.collections?.key?.includes(collectionUsageKey))
.map((hit: ContentHit) => ({
usageKey: hit.usageKey,
blockType: hit.blockType,
collectionKeys: (hit as ContentHit).collections?.key,
}));

/**
* Counts the number of hits that share a collection key with the given component.
*/
const countCollectionHits = (
hits: ReturnType<typeof useSearchContext>['hits'],
componentCollectionKey: string[] | undefined,
): number => {
if (!componentCollectionKey?.length) {
return 0;
}
return hits.filter(
(hit) => (hit as ContentHit).collections?.key?.some((key) => componentCollectionKey.includes(key)),
).length;
};

const AddComponentWidget = ({
usageKey, blockType, collectionKeys, isCollection,
}: AddComponentWidgetProps) => {
Expand All @@ -64,16 +39,30 @@ const AddComponentWidget = ({

const { hits } = useSearchContext();

// Use indexed lookup for O(1) performance instead of O(n) filtering
const { collectionToComponents, componentToCollections } = useCollectionIndexing(hits);

const collectionData = useMemo(() => {
// When selecting a collection: retrieve all its components to enable bulk selection
// When selecting a collection: O(1) lookup instead of O(n) filter
if (isCollection) {
return buildCollectionComponents(hits, usageKey);
return collectionToComponents.get(usageKey) ?? [];
}
// When selecting an individual component: get the total count of components in its collection
// This count is used to determine if the entire collection should be marked as selected
const componentCollectionKey = (hits.find((hit) => hit.usageKey === usageKey) as ContentHit)?.collections?.key;
return countCollectionHits(hits, componentCollectionKey);
}, [hits, usageKey, isCollection]);

// When selecting an individual component: O(1) lookup + O(m) count
const componentCollectionKeys = componentToCollections.get(usageKey);
if (!componentCollectionKeys?.length) {
return 0;
}

// Count total components across all collections this component belongs to
const componentSet = new Set<string>();
componentCollectionKeys.forEach((collectionKey) => {
const components = collectionToComponents.get(collectionKey) ?? [];
components.forEach((comp) => componentSet.add(comp.usageKey));
});

return componentSet.size;
}, [collectionToComponents, componentToCollections, usageKey, isCollection]);

// istanbul ignore if: this should never happen
if (!usageKey) {
Expand Down