Skip to content
Open
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
3 changes: 1 addition & 2 deletions src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}

Expand Down
13 changes: 12 additions & 1 deletion src/library-authoring/LibraryContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -76,7 +87,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)

return (
<div className="library-cards-grid">
{hits.map((contentHit) => {
{filteredHits.map((contentHit) => {
const CardComponent = LibraryItemCard[contentHit.type] || ComponentCard;

return <CardComponent key={contentHit.id} hit={contentHit} />;
Expand Down
5 changes: 5 additions & 0 deletions src/library-authoring/__mocks__/library-search.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -25,7 +24,7 @@ const LibraryCollectionComponents = () => {
return (
<Stack direction="vertical" gap={3}>
<h3 className="text-gray">Content ({componentCount})</h3>
<LibraryContent contentType={ContentType.collections} />
<LibraryContent />
</Stack>
);
};
Expand Down
150 changes: 140 additions & 10 deletions src/library-authoring/common/context/ComponentPickerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -21,6 +32,7 @@ type NoComponentPickerType = {
*/
onComponentSelected?: never;
selectedComponents?: never;
selectedCollections?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
restrictToLibrary?: never;
Expand All @@ -30,6 +42,7 @@ type ComponentPickerSingleType = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
selectedComponents?: never;
selectedCollections?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
restrictToLibrary: boolean;
Expand All @@ -39,6 +52,7 @@ type ComponentPickerMultipleType = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
selectedComponents: SelectedComponent[];
selectedCollections: SelectedCollection[];
addComponentToSelectedComponents: ComponentSelectedEvent;
removeComponentFromSelectedComponents: ComponentSelectedEvent;
restrictToLibrary: boolean;
Expand Down Expand Up @@ -85,32 +99,147 @@ export const ComponentPickerProvider = ({
onChangeComponentSelection,
}: ComponentPickerProviderProps) => {
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
const [selectedCollections, setSelectedCollections] = useState<SelectedCollection[]>([]);

/**
* 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<ComponentSelectedEvent>((
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;
});
}, []);

const removeComponentFromSelectedComponents = useCallback<ComponentSelectedEvent>((
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;
});
Expand All @@ -128,6 +257,7 @@ export const ComponentPickerProvider = ({
return {
componentPickerMode,
restrictToLibrary,
selectedCollections,
selectedComponents,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
Expand All @@ -143,7 +273,7 @@ export const ComponentPickerProvider = ({
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
onChangeComponentSelection,
selectedCollections,
]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ describe('<ComponentPicker />', () => {
onChange.mockClear();

// Select another component
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[1]);
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[7]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add more information on why we need this change? Something simple.
I won't argue about the test change, but it could be valuable to track the why.

await waitFor(() => expect(onChange).toHaveBeenCalledWith([
{
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
Expand Down
Loading
Loading