diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index ffbbdbc290..7cbbd5ed7b 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -316,12 +316,11 @@ const LibraryAuthoringPage = ({ const activeTypeFilters = { components: 'type = "library_block"', - collections: 'type = "collection"', units: 'block_type = "unit"', subsections: 'block_type = "subsection"', sections: 'block_type = "section"', }; - 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 416d1a57b3..e55014a8ae 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -45,6 +45,17 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) const { libraryId, openCreateCollectionModal, collectionId } = useOptionalLibraryContext(); const { openAddContentSidebar, openComponentInfoSidebar } = useSidebarContext(); const { insideCollection } = useLibraryRoutes(); + /** + * 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; + /** * Placeholder blocks represent fake blocks for failed imports from other sources, such as courses. * They should only be displayed when viewing all components in the home tab of the library and the @@ -103,7 +114,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 37ef2e72c5..b5b632f875 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/common/context/ComponentPickerContext.tsx b/src/library-authoring/common/context/ComponentPickerContext.tsx index a5f46112f2..e6d6a4053c 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; @@ -36,6 +48,7 @@ type ComponentPickerSingleType = BasePickerType & { componentPickerMode: 'single'; onComponentSelected: ComponentSelectedEvent; selectedComponents?: never; + selectedCollections?: never; addComponentToSelectedComponents?: never; removeComponentFromSelectedComponents?: never; }; @@ -44,6 +57,7 @@ type ComponentPickerMultipleType = BasePickerType & { componentPickerMode: 'multiple'; onComponentSelected?: never; selectedComponents: SelectedComponent[]; + selectedCollections: SelectedCollection[]; addComponentToSelectedComponents: ComponentSelectedEvent; removeComponentFromSelectedComponents: ComponentSelectedEvent; }; @@ -94,16 +108,105 @@ export const ComponentPickerProvider = ({ extraFilter, }: 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; }); @@ -111,15 +214,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; }); @@ -138,6 +267,7 @@ export const ComponentPickerProvider = ({ return { componentPickerMode, restrictToLibrary, + selectedCollections, selectedComponents, addComponentToSelectedComponents, removeComponentFromSelectedComponents, @@ -156,6 +286,7 @@ export const ComponentPickerProvider = ({ selectedComponents, onChangeComponentSelection, extraFilter, + selectedCollections, ]); return ( diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx index 0cd84192fe..d80cb7beeb 100644 --- a/src/library-authoring/component-picker/ComponentPicker.test.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx @@ -312,7 +312,7 @@ describe('', () => { onChange.mockClear(); // Select another component - fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[1]); + 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..2d29598725 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 '@src/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 (