From 7e79056b6d0b224d31ebfff895c31a2c0977db53 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 9 Jan 2026 12:35:06 -0500 Subject: [PATCH 01/14] feat: enable to select collections as components in the problems picker modal --- .../LibraryAuthoringPage.tsx | 3 +- src/library-authoring/LibraryContent.tsx | 13 +- .../common/context/ComponentPickerContext.tsx | 150 ++++++++++++++++-- .../components/AddComponentWidget.tsx | 77 ++++++++- .../components/CollectionCard.tsx | 12 +- .../components/ComponentCard.tsx | 7 +- 6 files changed, 239 insertions(+), 23 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index b49ae07e76..77ff30cc16 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -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]); } diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx index 53fdb23ecd..98d959fce1 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -43,6 +43,17 @@ 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 while collection in Collections tab. + * Note: LibraryAuthoringPage.tsx has been modified to skip backend filtering for this purpose. + */ + const filteredHits = contentType === ContentType.collections + ? hits.filter((hit) => hit.type === 'collection') + : hits; + useEffect(() => { if (usageKey) { openComponentInfoSidebar(usageKey); @@ -76,7 +87,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/common/context/ComponentPickerContext.tsx b/src/library-authoring/common/context/ComponentPickerContext.tsx index 83c258ec1d..514df45027 100644 --- a/src/library-authoring/common/context/ComponentPickerContext.tsx +++ b/src/library-authoring/common/context/ComponentPickerContext.tsx @@ -9,9 +9,20 @@ import { 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 type ComponentSelectedEvent = ( + selectedComponent: SelectedComponent, + collectionComponents?: SelectedComponent[] | number +) => void; export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void; type NoComponentPickerType = { @@ -21,6 +32,7 @@ type NoComponentPickerType = { */ onComponentSelected?: never; selectedComponents?: never; + selectedCollections?: never; addComponentToSelectedComponents?: never; removeComponentFromSelectedComponents?: never; restrictToLibrary?: never; @@ -30,6 +42,7 @@ type ComponentPickerSingleType = { componentPickerMode: 'single'; onComponentSelected: ComponentSelectedEvent; selectedComponents?: never; + selectedCollections?: never; addComponentToSelectedComponents?: never; removeComponentFromSelectedComponents?: never; restrictToLibrary: boolean; @@ -39,6 +52,7 @@ type ComponentPickerMultipleType = { componentPickerMode: 'multiple'; onComponentSelected?: never; selectedComponents: SelectedComponent[]; + selectedCollections: SelectedCollection[]; addComponentToSelectedComponents: ComponentSelectedEvent; removeComponentFromSelectedComponents: ComponentSelectedEvent; restrictToLibrary: boolean; @@ -85,16 +99,105 @@ export const ComponentPickerProvider = ({ onChangeComponentSelection, }: ComponentPickerProviderProps) => { const [selectedComponents, setSelectedComponents] = useState([]); + const [selectedCollections, setSelectedCollections] = useState([]); + + /** + * Updates the selectedCollections state based on how many components are selected. + * @param collectionKey - The key of the collection to update + * @param selectedCount - Number of components currently selected in the collection + * @param totalCount - Total number of components in the collection + */ + const updateCollectionStatus = useCallback(( + collectionKey: string, + selectedCount: number, + totalCount: number, + ) => { + setSelectedCollections((prevSelectedCollections) => { + const filteredCollections = prevSelectedCollections.filter( + (collection) => collection.key !== collectionKey, + ); + + if (selectedCount === 0) { + return filteredCollections; + } + if (selectedCount >= totalCount) { + return [...filteredCollections, { key: collectionKey, status: 'selected' as CollectionStatus }]; + } + return [...filteredCollections, { key: collectionKey, status: 'indeterminate' as CollectionStatus }]; + }); + }, []); + + /** + * Finds the common collection key between a component and selected components. + */ + const findCommonCollectionKey = useCallback(( + componentKeys: string[] | undefined, + components: SelectedComponent[], + ): string | undefined => { + if (!componentKeys?.length || !components.length) { + return undefined; + } + + for (const component of components) { + const commonKey = component.collectionKeys?.find((key) => componentKeys.includes(key)); + if (commonKey) { + return commonKey; + } + } + + return undefined; + }, []); const addComponentToSelectedComponents = useCallback(( selectedComponent: SelectedComponent, + collectionComponents?: SelectedComponent[] | number, ) => { + const componentsToAdd = Array.isArray(collectionComponents) && collectionComponents.length + ? collectionComponents + : [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]; + + // Handle collection selection (when selecting entire collection) + if (Array.isArray(collectionComponents) && collectionComponents.length) { + updateCollectionStatus( + selectedComponent.usageKey, + collectionComponents.length, + collectionComponents.length, + ); + } + + // Handle individual component selection (with total count) + if (typeof collectionComponents === 'number') { + const componentCollectionKeys = selectedComponent.collectionKeys; + const selectedCollectionComponents = newSelectedComponents.filter( + (component) => component.collectionKeys?.some( + (key) => componentCollectionKeys?.includes(key), + ), + ); + + const collectionKey = findCommonCollectionKey( + componentCollectionKeys, + selectedCollectionComponents, + ); + + if (collectionKey) { + updateCollectionStatus( + collectionKey, + selectedCollectionComponents.length, + collectionComponents, + ); + } + } + onChangeComponentSelection?.(newSelectedComponents); return newSelectedComponents; }); @@ -102,15 +205,41 @@ export const ComponentPickerProvider = ({ const removeComponentFromSelectedComponents = useCallback(( selectedComponent: SelectedComponent, + collectionComponents?: SelectedComponent[] | number, ) => { + const componentsToRemove = Array.isArray(collectionComponents) && collectionComponents.length + ? collectionComponents + : [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), ); + + if (typeof collectionComponents === 'number') { + const componentCollectionKeys = selectedComponent.collectionKeys; + const collectionKey = findCommonCollectionKey(componentCollectionKeys, componentsToRemove); + + if (collectionKey) { + const remainingCollectionComponents = newSelectedComponents.filter( + (component) => component.collectionKeys?.includes(collectionKey), + ); + updateCollectionStatus( + collectionKey, + remainingCollectionComponents.length, + collectionComponents, + ); + } + } else { + // Fallback: remove collections that have no remaining components + setSelectedCollections((prevSelectedCollections) => prevSelectedCollections.filter( + (collection) => newSelectedComponents.some( + (component) => component.collectionKeys?.includes(collection.key), + ), + )); + } + onChangeComponentSelection?.(newSelectedComponents); return newSelectedComponents; }); @@ -128,6 +257,7 @@ export const ComponentPickerProvider = ({ return { componentPickerMode, restrictToLibrary, + selectedCollections, selectedComponents, addComponentToSelectedComponents, removeComponentFromSelectedComponents, @@ -143,7 +273,7 @@ export const ComponentPickerProvider = ({ addComponentToSelectedComponents, removeComponentFromSelectedComponents, selectedComponents, - onChangeComponentSelection, + selectedCollections, ]); return ( diff --git a/src/library-authoring/components/AddComponentWidget.tsx b/src/library-authoring/components/AddComponentWidget.tsx index a6e1c0f9ba..197d0c1a02 100644 --- a/src/library-authoring/components/AddComponentWidget.tsx +++ b/src/library-authoring/components/AddComponentWidget.tsx @@ -1,20 +1,56 @@ +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 { ContentHit, useSearchContext } from '../../search-manager'; +import { SelectedComponent, useComponentPickerContext } from '../common/context/ComponentPickerContext'; import messages from './messages'; interface AddComponentWidgetProps { usageKey: string; blockType: string; + collectionKeys?: string[]; + isCollection?: boolean; } -const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => { +/** + * Builds an array of SelectedComponent from collection hits. + */ +const buildCollectionComponents = ( + hits: ReturnType['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['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) => { const intl = useIntl(); const { @@ -23,8 +59,22 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => addComponentToSelectedComponents, removeComponentFromSelectedComponents, selectedComponents, + selectedCollections, } = useComponentPickerContext(); + const { hits } = useSearchContext(); + + const collectionData = useMemo(() => { + // When selecting a collection: retrieve all its components to enable bulk selection + if (isCollection) { + return buildCollectionComponents(hits, 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]); + // istanbul ignore if: this should never happen if (!usageKey) { throw new Error('usageKey is required'); @@ -50,24 +100,37 @@ 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, }; if (!isChecked) { - addComponentToSelectedComponents(selectedComponent); + addComponentToSelectedComponents(selectedComponent, collectionData); } else { - removeComponentFromSelectedComponents(selectedComponent); + removeComponentFromSelectedComponents(selectedComponent, collectionData); } }; return (