Skip to content

Commit 75ae9d5

Browse files
authored
feat: handle duplicate children in container pages [FC-0112] (#2584)
If we have duplicate container or component in parent page in library, clicking on one of them selects both and removing any one from the parent blocks removes all instances. This PR handles duplicates by including index/order_number of each child component in the url and using it to exclude a single instance and update parent structure.
1 parent cec074e commit 75ae9d5

File tree

15 files changed

+341
-120
lines changed

15 files changed

+341
-120
lines changed

src/container-comparison/CompareContainersWidget.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { LoadingSpinner } from '@src/generic/Loading';
1313
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
1414
import { BoldText } from '@src/utils';
1515

16+
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
1617
import ChildrenPreview from './ChildrenPreview';
1718
import ContainerRow from './ContainerRow';
1819
import { useCourseContainerChildren } from './data/apiHooks';
@@ -60,7 +61,7 @@ const CompareContainersWidgetInner = ({
6061
data: libData,
6162
isError: isLibError,
6263
error: libError,
63-
} = useContainerChildren(state === 'removed' ? undefined : upstreamBlockId, true);
64+
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
6465
const {
6566
data: containerData,
6667
isError: isContainerTitleError,

src/library-authoring/common/context/SidebarContext.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface DefaultTabs {
7272
export interface SidebarItemInfo {
7373
type: SidebarBodyItemId;
7474
id: string;
75+
index?: number;
7576
}
7677

7778
export enum SidebarActions {
@@ -88,7 +89,7 @@ export type SidebarContextData = {
8889
openCollectionInfoSidebar: (collectionId: string) => void;
8990
openComponentInfoSidebar: (usageKey: string) => void;
9091
openContainerInfoSidebar: (usageKey: string) => void;
91-
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId) => void;
92+
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId, index?: number) => void;
9293
sidebarItemInfo?: SidebarItemInfo;
9394
sidebarAction: SidebarActions;
9495
setSidebarAction: (action: SidebarActions) => void;
@@ -154,35 +155,38 @@ export const SidebarProvider = ({
154155
setSidebarItemInfo({ id: '', type: SidebarBodyItemId.Info });
155156
}, []);
156157

157-
const openComponentInfoSidebar = useCallback((usageKey: string) => {
158+
const openComponentInfoSidebar = useCallback((usageKey: string, index?: number) => {
158159
setSidebarItemInfo({
159160
id: usageKey,
160161
type: SidebarBodyItemId.ComponentInfo,
162+
index,
161163
});
162164
}, []);
163165

164-
const openCollectionInfoSidebar = useCallback((newCollectionId: string) => {
166+
const openCollectionInfoSidebar = useCallback((newCollectionId: string, index?: number) => {
165167
setSidebarItemInfo({
166168
id: newCollectionId,
167169
type: SidebarBodyItemId.CollectionInfo,
170+
index,
168171
});
169172
}, []);
170173

171-
const openContainerInfoSidebar = useCallback((usageKey: string) => {
174+
const openContainerInfoSidebar = useCallback((usageKey: string, index?: number) => {
172175
setSidebarItemInfo({
173176
id: usageKey,
174177
type: SidebarBodyItemId.ContainerInfo,
178+
index,
175179
});
176180
}, []);
177181

178182
const { navigateTo } = useLibraryRoutes();
179-
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId) => {
180-
navigateTo({ selectedItemId });
181-
setSidebarItemInfo({ id: selectedItemId, type });
183+
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId, index?: number) => {
184+
navigateTo({ selectedItemId, index });
185+
setSidebarItemInfo({ id: selectedItemId, type, index });
182186
}, [navigateTo, setSidebarItemInfo]);
183187

184188
// Set the initial sidebar state based on the URL parameters and context.
185-
const { selectedItemId } = useParams();
189+
const { selectedItemId, index: indexParam } = useParams();
186190
const { collectionId, containerId } = useLibraryContext();
187191
const { componentPickerMode } = useComponentPickerContext();
188192

@@ -198,12 +202,15 @@ export const SidebarProvider = ({
198202

199203
// Handle selected item id changes
200204
if (selectedItemId) {
205+
// if a item is selected that means we have list of items displayed
206+
// which means we can get the index from url and set it.
207+
const indexNumber = indexParam ? Number(indexParam) : undefined;
201208
if (selectedItemId.startsWith('lct:')) {
202-
openContainerInfoSidebar(selectedItemId);
209+
openContainerInfoSidebar(selectedItemId, indexNumber);
203210
} else if (selectedItemId.startsWith('lb:')) {
204-
openComponentInfoSidebar(selectedItemId);
211+
openComponentInfoSidebar(selectedItemId, indexNumber);
205212
} else {
206-
openCollectionInfoSidebar(selectedItemId);
213+
openCollectionInfoSidebar(selectedItemId, indexNumber);
207214
}
208215
} else if (collectionId) {
209216
openCollectionInfoSidebar(collectionId);

src/library-authoring/component-info/ComponentInfo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ const ComponentActions = ({
111111
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
112112
const canEdit = canEditComponent(componentId);
113113

114+
const { sidebarItemInfo } = useSidebarContext();
115+
114116
if (isPublisherOpen) {
115117
return (
116118
<ComponentPublisher
@@ -141,7 +143,7 @@ const ComponentActions = ({
141143
)}
142144
</div>
143145
<div className="mt-2">
144-
<ComponentMenu usageKey={componentId} />
146+
<ComponentMenu usageKey={componentId} index={sidebarItemInfo?.index} />
145147
</div>
146148
</div>
147149
);

src/library-authoring/components/ComponentMenu.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,23 @@ import { useClipboard } from '@src/generic/clipboard';
1212
import { getBlockType } from '@src/generic/key-utils';
1313
import { ToastContext } from '@src/generic/toast-context';
1414

15-
import { useLibraryContext } from '../common/context/LibraryContext';
16-
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
17-
import { useRemoveItemsFromCollection } from '../data/apiHooks';
15+
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
16+
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
17+
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
18+
import containerMessages from '@src/library-authoring/containers/messages';
19+
import { useLibraryRoutes } from '@src/library-authoring/routes';
20+
import { useRunOnNextRender } from '@src/utils';
1821
import { canEditComponent } from './ComponentEditorModal';
1922
import ComponentDeleter from './ComponentDeleter';
2023
import ComponentRemover from './ComponentRemover';
2124
import messages from './messages';
22-
import containerMessages from '../containers/messages';
23-
import { useLibraryRoutes } from '../routes';
24-
import { useRunOnNextRender } from '../../utils';
2525

26-
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
26+
interface Props {
27+
usageKey: string;
28+
index?: number;
29+
}
30+
31+
export const ComponentMenu = ({ usageKey, index }: Props) => {
2732
const intl = useIntl();
2833
const {
2934
libraryId,
@@ -135,6 +140,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
135140
{isRemoveModalOpen && (
136141
<ComponentRemover
137142
usageKey={usageKey}
143+
index={index}
138144
close={closeRemoveModal}
139145
/>
140146
)}

src/library-authoring/components/ComponentRemover.tsx

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,60 +4,101 @@ import { Warning } from '@openedx/paragon/icons';
44

55
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
66
import { ToastContext } from '@src/generic/toast-context';
7-
import { useLibraryContext } from '../common/context/LibraryContext';
8-
import { useSidebarContext } from '../common/context/SidebarContext';
7+
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
8+
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
99
import {
1010
useContainer,
1111
useRemoveContainerChildren,
12-
useAddItemsToContainer,
1312
useLibraryBlockMetadata,
14-
} from '../data/apiHooks';
13+
useContainerChildren,
14+
useUpdateContainerChildren,
15+
} from '@src/library-authoring/data/apiHooks';
16+
import { LibraryBlockMetadata } from '@src/library-authoring/data/api';
1517
import messages from './messages';
1618

1719
interface Props {
1820
usageKey: string;
21+
index?: number;
1922
close: () => void;
2023
}
2124

22-
const ComponentRemover = ({ usageKey, close }: Props) => {
25+
const ComponentRemover = ({ usageKey, index, close }: Props) => {
2326
const intl = useIntl();
2427
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
25-
const { containerId } = useLibraryContext();
28+
const { containerId, showOnlyPublished } = useLibraryContext();
2629
const { showToast } = useContext(ToastContext);
2730

2831
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
29-
const addItemToContainerMutation = useAddItemsToContainer(containerId);
32+
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
3033
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
3134
const { data: component, isPending } = useLibraryBlockMetadata(usageKey);
35+
// Use update api for children if duplicates are present to avoid removing all instances of the child
36+
const { data: children } = useContainerChildren<LibraryBlockMetadata>(containerId, showOnlyPublished);
37+
const childrenUsageIds = children?.map((child) => child.id);
38+
const hasDuplicates = (childrenUsageIds?.filter((child) => child === usageKey).length || 0) > 1;
3239

3340
// istanbul ignore if: loading state
3441
if (isPending || isPendingParentContainer) {
3542
// Only show the modal when all data is ready
3643
return null;
3744
}
3845

46+
const restoreComponent = () => {
47+
// istanbul ignore if: this should never happen
48+
if (!childrenUsageIds) {
49+
return;
50+
}
51+
updateContainerChildrenMutation.mutateAsync(childrenUsageIds).then(() => {
52+
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
53+
}).catch(() => {
54+
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
55+
});
56+
};
57+
58+
const showSuccessToast = () => {
59+
showToast(
60+
intl.formatMessage(messages.removeComponentFromContainerSuccess),
61+
{
62+
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
63+
onClick: restoreComponent,
64+
},
65+
);
66+
};
67+
68+
const showFailureToast = () => showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
69+
3970
const removeFromContainer = () => {
40-
const restoreComponent = () => {
41-
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
42-
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
43-
}).catch(() => {
44-
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
45-
});
46-
};
4771
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
4872
if (sidebarItemInfo?.id === usageKey) {
4973
// Close sidebar if current component is open
5074
closeLibrarySidebar();
5175
}
52-
showToast(
53-
intl.formatMessage(messages.removeComponentFromContainerSuccess),
54-
{
55-
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
56-
onClick: restoreComponent,
57-
},
58-
);
76+
showSuccessToast();
77+
}).catch(() => {
78+
showFailureToast();
79+
});
80+
81+
close();
82+
};
83+
84+
const excludeOneInstance = () => {
85+
if (!childrenUsageIds || typeof index === 'undefined') {
86+
return;
87+
}
88+
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== usageKey || idx !== index);
89+
updateContainerChildrenMutation.mutateAsync(updatedKeys).then(() => {
90+
// istanbul ignore if
91+
if (sidebarItemInfo?.id === usageKey && sidebarItemInfo?.index === index) {
92+
// Close sidebar if current component is open
93+
closeLibrarySidebar();
94+
}
95+
// Already tested as part of removeFromContainer
96+
// istanbul ignore next
97+
showSuccessToast();
5998
}).catch(() => {
60-
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
99+
// Already tested as part of removeFromContainer
100+
// istanbul ignore next
101+
showFailureToast();
61102
});
62103

63104
close();
@@ -76,7 +117,7 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
76117
title={intl.formatMessage(messages.removeComponentWarningTitle)}
77118
icon={Warning}
78119
description={removeText}
79-
onDeleteSubmit={removeFromContainer}
120+
onDeleteSubmit={hasDuplicates ? excludeOneInstance : removeFromContainer}
80121
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
81122
buttonVariant="primary"
82123
/>

src/library-authoring/containers/ContainerCard.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,24 @@ import { type ContainerHit, Highlight, PublishStatus } from '@src/search-manager
1717
import { ToastContext } from '@src/generic/toast-context';
1818
import { useRunOnNextRender } from '@src/utils';
1919

20-
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
21-
import { useLibraryContext } from '../common/context/LibraryContext';
22-
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
23-
import { useRemoveItemsFromCollection } from '../data/apiHooks';
24-
import { useLibraryRoutes } from '../routes';
20+
import { useComponentPickerContext } from '@src/library-authoring/common/context/ComponentPickerContext';
21+
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
22+
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
23+
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
24+
import { useLibraryRoutes } from '@src/library-authoring/routes';
25+
import BaseCard from '@src/library-authoring/components/BaseCard';
26+
import AddComponentWidget from '@src/library-authoring/components/AddComponentWidget';
2527
import messages from './messages';
2628
import ContainerDeleter from './ContainerDeleter';
2729
import ContainerRemover from './ContainerRemover';
28-
import BaseCard from '../components/BaseCard';
29-
import AddComponentWidget from '../components/AddComponentWidget';
3030

3131
type ContainerMenuProps = {
3232
containerKey: string;
3333
displayName: string;
34+
index?: number;
3435
};
3536

36-
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
37+
export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMenuProps) => {
3738
const intl = useIntl();
3839
const { libraryId, collectionId, containerId } = useLibraryContext();
3940
const {
@@ -144,6 +145,7 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
144145
close={cancelRemove}
145146
containerKey={containerKey}
146147
displayName={displayName}
148+
index={index}
147149
/>
148150
)}
149151
</>

0 commit comments

Comments
 (0)