From 451ae3c8093f7ae281ae24be0482e3c3e0464767 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 30 Sep 2024 19:36:59 +0530 Subject: [PATCH 01/14] feat: add to collection in sidebar --- .../component-info/AddToCollectionsDrawer.tsx | 85 +++++++++++++++++++ .../component-info/ComponentManagement.tsx | 8 +- .../component-info/messages.ts | 15 ++++ src/search-manager/SearchSortWidget.tsx | 9 +- src/search-manager/data/api.ts | 4 + 5 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 src/library-authoring/component-info/AddToCollectionsDrawer.tsx diff --git a/src/library-authoring/component-info/AddToCollectionsDrawer.tsx b/src/library-authoring/component-info/AddToCollectionsDrawer.tsx new file mode 100644 index 0000000000..a217d676a2 --- /dev/null +++ b/src/library-authoring/component-info/AddToCollectionsDrawer.tsx @@ -0,0 +1,85 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, Scrollable, SelectableBox, Stack, useCheckboxSetValues } from '@openedx/paragon'; +import { Folder } from '@openedx/paragon/icons'; + +import { + SearchContextProvider, + SearchKeywordsField, + SearchSortWidget, + useSearchContext, +} from '../../search-manager'; +import { LibraryBlockMetadata } from "../data/api"; +import messages from './messages'; + +interface CollectionsDrawerProps { + componentMetadata: LibraryBlockMetadata; +} + +const CollectionsSelectableBox = () => { + const type = 'checkbox'; + const intl = useIntl(); + const { collectionHits, isFiltered } = useSearchContext(); + + const [selectedCollections, { add, remove, clear }] = useCheckboxSetValues([]); + const handleChange = (e) => { + e.target.checked ? add(e.target.value) : remove(e.target.value); + }; + + return ( + + + { collectionHits.map((contentHit) => ( + + + + {contentHit.displayName} + + + )) } + + + ); +} + +const AddToCollectionsDrawer = ({ componentMetadata }: CollectionsDrawerProps) => { + const intl = useIntl(); + const { displayName } = componentMetadata; + return ( + + + + + + + + + + + ) +} + +export default AddToCollectionsDrawer diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index 92adb33107..c3973cb5f5 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -2,17 +2,19 @@ import React from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Collapsible, Icon, Stack } from '@openedx/paragon'; -import { Tag } from '@openedx/paragon/icons'; +import { BookOpen, Tag } from '@openedx/paragon/icons'; import { useLibraryBlockMetadata } from '../data/apiHooks'; import StatusWidget from '../generic/status-widget'; import messages from './messages'; import { ContentTagsDrawer } from '../../content-tags-drawer'; import { useContentTaxonomyTagsData } from '../../content-tags-drawer/data/apiHooks'; +import AddToCollectionsDrawer from './AddToCollectionsDrawer'; interface ComponentManagementProps { usageKey: string; } + const ComponentManagement = ({ usageKey }: ComponentManagementProps) => { const intl = useIntl(); const { data: componentMetadata } = useLibraryBlockMetadata(usageKey); @@ -69,13 +71,13 @@ const ComponentManagement = ({ usageKey }: ComponentManagementProps) => { defaultOpen title={( - + {intl.formatMessage(messages.manageTabCollectionsTitle)} )} className="border-0" > - Collections placeholder + ); diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 8f52cdfefc..aff9b73591 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -121,6 +121,21 @@ const messages = defineMessages({ defaultMessage: 'Component Preview', description: 'Title for preview modal', }, + addToCollectionSubtitle: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.subtitle', + defaultMessage: 'Add {displayName} to Collection', + description: 'Subtitle for collection section in manage tab', + }, + addToCollectionSearchPlaceholder: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.search-placeholder', + defaultMessage: 'Search', + description: 'Placeholder text for collection search in manage tab', + }, + addToCollectionSelectionLabel: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.selection-aria-label', + defaultMessage: 'Collection selection', + description: 'Aria label text for collection selection box', + }, }); export default messages; diff --git a/src/search-manager/SearchSortWidget.tsx b/src/search-manager/SearchSortWidget.tsx index 01309845dd..6c9dca00af 100644 --- a/src/search-manager/SearchSortWidget.tsx +++ b/src/search-manager/SearchSortWidget.tsx @@ -6,8 +6,9 @@ import { Check, SwapVert } from '@openedx/paragon/icons'; import messages from './messages'; import { SearchSortOption } from './data/api'; import { useSearchContext } from './SearchManager'; +import classNames from 'classnames'; -export const SearchSortWidget: React.FC> = () => { +export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) => { const intl = useIntl(); const { searchSortOrder, @@ -82,11 +83,13 @@ export const SearchSortWidget: React.FC> = () => { title={intl.formatMessage(messages.searchSortWidgetAltTitle)} alt={intl.formatMessage(messages.searchSortWidgetAltTitle)} variant="outline-primary" - className="dropdown-toggle-menu-items d-flex" + className={classNames("dropdown-toggle-menu-items d-flex", { + "border-0": iconOnly + })} size="sm" > -
{toggleLabel}
+ { !iconOnly &&
{toggleLabel}
} {menuHeader} diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index fe73a7d3fc..4b46d3d91e 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -157,6 +157,7 @@ export function formatSearchHit(hit: Record): ContentHit | Collecti export interface OverrideQueries { components?: SearchParams, + blockTypes?: SearchParams, collections?: SearchParams, } @@ -168,6 +169,9 @@ function applyOverrideQueries( if (overrideQueries?.components) { newQueries[0] = { ...overrideQueries.components, indexUid: queries[0].indexUid }; } + if (overrideQueries?.blockTypes) { + newQueries[1] = { ...overrideQueries.blockTypes, indexUid: queries[1].indexUid }; + } if (overrideQueries?.collections) { newQueries[2] = { ...overrideQueries.collections, indexUid: queries[2].indexUid }; } From 2bb223b12da06fc2428a616e9dab3296138f5b6e Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 1 Oct 2024 18:42:03 +0530 Subject: [PATCH 02/14] refactor: set component data in sidebar Select related collections --- src/library-authoring/common/context.tsx | 20 +++++------ .../component-info/AddToCollectionsDrawer.tsx | 35 ++++++++++++++----- .../component-info/ComponentInfo.tsx | 8 +++-- .../component-info/ComponentManagement.tsx | 8 +++-- .../components/ComponentCard.tsx | 2 +- src/library-authoring/data/api.ts | 2 +- .../library-sidebar/LibrarySidebar.tsx | 6 ++-- src/search-manager/SearchManager.ts | 21 +++++++---- src/search-manager/data/api.ts | 1 + 9 files changed, 66 insertions(+), 37 deletions(-) diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 5c4b1938db..c078d4862c 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -1,6 +1,8 @@ import { useToggle } from '@openedx/paragon'; import React from 'react'; +import { ContentHit } from '../../search-manager'; + export enum SidebarBodyComponentId { AddContent = 'add-content', Info = 'info', @@ -16,9 +18,8 @@ export interface LibraryContextData { closeLibrarySidebar: () => void; openAddContentSidebar: () => void; openInfoSidebar: () => void; - openComponentInfoSidebar: (usageKey: string) => void; - currentComponentUsageKey?: string; - // "Create New Collection" modal + openComponentInfoSidebar: (componentHit: ContentHit) => void; + currentComponentData?: ContentHit; isCreateCollectionModalOpen: boolean; openCreateCollectionModal: () => void; closeCreateCollectionModal: () => void; @@ -47,21 +48,20 @@ const LibraryContext = React.createContext(undef */ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: string }) => { const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null); - const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState(); + const [currentComponentData, setCurrentComponentData] = React.useState(); const [currentCollectionId, setcurrentCollectionId] = React.useState(); const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false); const [componentBeingEdited, openComponentEditor] = React.useState(); const closeComponentEditor = React.useCallback(() => openComponentEditor(undefined), []); const resetSidebar = React.useCallback(() => { - setCurrentComponentUsageKey(undefined); + setCurrentComponentData(undefined); setcurrentCollectionId(undefined); setSidebarBodyComponent(null); }, []); const closeLibrarySidebar = React.useCallback(() => { resetSidebar(); - setCurrentComponentUsageKey(undefined); }, []); const openAddContentSidebar = React.useCallback(() => { resetSidebar(); @@ -72,9 +72,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: setSidebarBodyComponent(SidebarBodyComponentId.Info); }, []); const openComponentInfoSidebar = React.useCallback( - (usageKey: string) => { + (componentHit: ContentHit) => { resetSidebar(); - setCurrentComponentUsageKey(usageKey); + setCurrentComponentData(componentHit); setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo); }, [], @@ -92,7 +92,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: openAddContentSidebar, openInfoSidebar, openComponentInfoSidebar, - currentComponentUsageKey, + currentComponentData, isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, @@ -108,7 +108,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: openAddContentSidebar, openInfoSidebar, openComponentInfoSidebar, - currentComponentUsageKey, + currentComponentData, isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, diff --git a/src/library-authoring/component-info/AddToCollectionsDrawer.tsx b/src/library-authoring/component-info/AddToCollectionsDrawer.tsx index a217d676a2..958463e0c1 100644 --- a/src/library-authoring/component-info/AddToCollectionsDrawer.tsx +++ b/src/library-authoring/component-info/AddToCollectionsDrawer.tsx @@ -1,32 +1,46 @@ +import { useEffect } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Icon, Scrollable, SelectableBox, Stack, useCheckboxSetValues } from '@openedx/paragon'; import { Folder } from '@openedx/paragon/icons'; import { + ContentHit, SearchContextProvider, SearchKeywordsField, SearchSortWidget, useSearchContext, } from '../../search-manager'; -import { LibraryBlockMetadata } from "../data/api"; import messages from './messages'; interface CollectionsDrawerProps { - componentMetadata: LibraryBlockMetadata; + contentHit: ContentHit; } -const CollectionsSelectableBox = () => { +const CollectionsSelectableBox = ({ contentHit }: CollectionsDrawerProps) => { const type = 'checkbox'; const intl = useIntl(); - const { collectionHits, isFiltered } = useSearchContext(); + const { collectionHits } = useSearchContext(); + const [selectedCollections, { + add, + remove, + set, + clear, + }] = useCheckboxSetValues(contentHit.collections?.key || []); + + useEffect(() => { + set(contentHit.collections?.key || []); + + return () => { + clear(); + } + }, [contentHit]) - const [selectedCollections, { add, remove, clear }] = useCheckboxSetValues([]); const handleChange = (e) => { e.target.checked ? add(e.target.value) : remove(e.target.value); }; return ( - + { { ); } -const AddToCollectionsDrawer = ({ componentMetadata }: CollectionsDrawerProps) => { +const AddToCollectionsDrawer = ({ contentHit }: CollectionsDrawerProps) => { const intl = useIntl(); - const { displayName } = componentMetadata; + const { displayName } = contentHit; return ( - + {/* Set key to update selection when component usageKey changes */} + ) diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 19257b5de6..357e76f0ab 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -14,16 +14,18 @@ import messages from './messages'; import { canEditComponent } from '../components/ComponentEditorModal'; import { useLibraryContext } from '../common/context'; import { useContentLibrary } from '../data/apiHooks'; +import { ContentHit } from '../../search-manager'; interface ComponentInfoProps { - usageKey: string; + contentHit: ContentHit; } -const ComponentInfo = ({ usageKey }: ComponentInfoProps) => { +const ComponentInfo = ({ contentHit }: ComponentInfoProps) => { const intl = useIntl(); const { libraryId, openComponentEditor } = useLibraryContext(); const { data: libraryData } = useContentLibrary(libraryId); const canEdit = libraryData?.canEditLibrary && canEditComponent(usageKey); + const { usageKey } = contentHit; return ( @@ -49,7 +51,7 @@ const ComponentInfo = ({ usageKey }: ComponentInfoProps) => { - + diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index c3973cb5f5..735050db7f 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -10,13 +10,15 @@ import messages from './messages'; import { ContentTagsDrawer } from '../../content-tags-drawer'; import { useContentTaxonomyTagsData } from '../../content-tags-drawer/data/apiHooks'; import AddToCollectionsDrawer from './AddToCollectionsDrawer'; +import { ContentHit } from '../../search-manager'; interface ComponentManagementProps { - usageKey: string; + contentHit: ContentHit; } -const ComponentManagement = ({ usageKey }: ComponentManagementProps) => { +const ComponentManagement = ({ contentHit }: ComponentManagementProps) => { const intl = useIntl(); + const { usageKey } = contentHit; const { data: componentMetadata } = useLibraryBlockMetadata(usageKey); const { data: componentTags } = useContentTaxonomyTagsData(usageKey); @@ -77,7 +79,7 @@ const ComponentManagement = ({ usageKey }: ComponentManagementProps) => { )} className="border-0" > - + ); diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 095512d2d8..0cd0dcc45b 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -91,7 +91,7 @@ const ComponentCard = ({ contentHit } : ComponentCardProps) => { )} - openInfoSidebar={() => openComponentInfoSidebar(usageKey)} + openInfoSidebar={() => openComponentInfoSidebar(contentHit)} /> ); }; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 1c609722b9..145995b51e 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -54,7 +54,7 @@ export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseU */ export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`; /** - * Get the URL for the collection API. + * Get the URL for the collection components API. */ export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}components/`; /** diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 729484a071..6967a79152 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -33,7 +33,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { const { sidebarBodyComponent, closeLibrarySidebar, - currentComponentUsageKey, + currentComponentData, currentCollectionId, } = useLibraryContext(); @@ -41,7 +41,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { [SidebarBodyComponentId.AddContent]: , [SidebarBodyComponentId.Info]: , [SidebarBodyComponentId.ComponentInfo]: ( - currentComponentUsageKey && + currentComponentData && ), [SidebarBodyComponentId.CollectionInfo]: ( currentCollectionId && @@ -53,7 +53,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { [SidebarBodyComponentId.AddContent]: , [SidebarBodyComponentId.Info]: , [SidebarBodyComponentId.ComponentInfo]: ( - currentComponentUsageKey && + currentComponentData && ), [SidebarBodyComponentId.CollectionInfo]: ( currentCollectionId && diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index 1726e10d9f..818eb2df1e 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -93,7 +93,8 @@ export const SearchContextProvider: React.FC<{ children: React.ReactNode, closeSearchModal?: () => void, overrideQueries?: OverrideQueries, -}> = ({ overrideSearchSortOrder, overrideQueries, ...props }) => { + skipUrlUpdate?: boolean, +}> = ({ overrideSearchSortOrder, overrideQueries, skipUrlUpdate, ...props }) => { const [searchKeywords, setSearchKeywords] = React.useState(''); const [blockTypesFilter, setBlockTypesFilter] = React.useState([]); const [problemTypesFilter, setProblemTypesFilter] = React.useState([]); @@ -104,12 +105,18 @@ export const SearchContextProvider: React.FC<{ // E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA. // Default sort by Most Relevant if there's search keyword(s), else by Recently Modified. const defaultSearchSortOrder = searchKeywords ? SearchSortOption.RELEVANCE : SearchSortOption.RECENTLY_MODIFIED; - const [searchSortOrder, setSearchSortOrder] = useStateWithUrlSearchParam( - defaultSearchSortOrder, - 'sort', - (value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue), - (value: SearchSortOption) => value.toString(), - ); + var sortStateManager: [SearchSortOption, React.Dispatch>]; + if (skipUrlUpdate) { + sortStateManager = React.useState(defaultSearchSortOrder); + } else { + sortStateManager = useStateWithUrlSearchParam( + defaultSearchSortOrder, + 'sort', + (value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue), + (value: SearchSortOption) => value.toString(), + ); + } + const [searchSortOrder, setSearchSortOrder] = sortStateManager; // SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we // send it to useContentSearchResults as an empty array. const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 4b46d3d91e..1ec906f376 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -129,6 +129,7 @@ export interface ContentHit extends BaseContentHit { breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>]; content?: ContentDetails; lastPublished: number | null; + collections?: { displayName?: string[], key?: string[] }, } /** From f113e860c665f26cccaa21f7aa7cf2f5012d0bf4 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 7 Oct 2024 20:29:27 +0530 Subject: [PATCH 03/14] feat: manage collections --- .../component-info/AddToCollectionsDrawer.tsx | 102 -------- .../component-info/ComponentManagement.tsx | 4 +- .../component-info/ManageCollections.tsx | 217 ++++++++++++++++++ .../component-info/messages.ts | 42 +++- src/library-authoring/data/api.ts | 16 +- src/library-authoring/data/apiHooks.ts | 32 ++- src/search-manager/data/api.ts | 24 ++ src/search-manager/data/apiHooks.ts | 19 ++ src/search-manager/index.ts | 1 + 9 files changed, 344 insertions(+), 113 deletions(-) delete mode 100644 src/library-authoring/component-info/AddToCollectionsDrawer.tsx create mode 100644 src/library-authoring/component-info/ManageCollections.tsx diff --git a/src/library-authoring/component-info/AddToCollectionsDrawer.tsx b/src/library-authoring/component-info/AddToCollectionsDrawer.tsx deleted file mode 100644 index 958463e0c1..0000000000 --- a/src/library-authoring/component-info/AddToCollectionsDrawer.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect } from 'react'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Icon, Scrollable, SelectableBox, Stack, useCheckboxSetValues } from '@openedx/paragon'; -import { Folder } from '@openedx/paragon/icons'; - -import { - ContentHit, - SearchContextProvider, - SearchKeywordsField, - SearchSortWidget, - useSearchContext, -} from '../../search-manager'; -import messages from './messages'; - -interface CollectionsDrawerProps { - contentHit: ContentHit; -} - -const CollectionsSelectableBox = ({ contentHit }: CollectionsDrawerProps) => { - const type = 'checkbox'; - const intl = useIntl(); - const { collectionHits } = useSearchContext(); - const [selectedCollections, { - add, - remove, - set, - clear, - }] = useCheckboxSetValues(contentHit.collections?.key || []); - - useEffect(() => { - set(contentHit.collections?.key || []); - - return () => { - clear(); - } - }, [contentHit]) - - const handleChange = (e) => { - e.target.checked ? add(e.target.value) : remove(e.target.value); - }; - - return ( - - - { collectionHits.map((contentHit) => ( - - - - {contentHit.displayName} - - - )) } - - - ); -} - -const AddToCollectionsDrawer = ({ contentHit }: CollectionsDrawerProps) => { - const intl = useIntl(); - const { displayName } = contentHit; - return ( - - - - - - - - {/* Set key to update selection when component usageKey changes */} - - - - ) -} - -export default AddToCollectionsDrawer diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index 735050db7f..dcd835e7aa 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -9,7 +9,7 @@ import StatusWidget from '../generic/status-widget'; import messages from './messages'; import { ContentTagsDrawer } from '../../content-tags-drawer'; import { useContentTaxonomyTagsData } from '../../content-tags-drawer/data/apiHooks'; -import AddToCollectionsDrawer from './AddToCollectionsDrawer'; +import ManageCollections from './ManageCollections'; import { ContentHit } from '../../search-manager'; interface ComponentManagementProps { @@ -79,7 +79,7 @@ const ComponentManagement = ({ contentHit }: ComponentManagementProps) => { )} className="border-0" > - + ); diff --git a/src/library-authoring/component-info/ManageCollections.tsx b/src/library-authoring/component-info/ManageCollections.tsx new file mode 100644 index 0000000000..2acab918fc --- /dev/null +++ b/src/library-authoring/component-info/ManageCollections.tsx @@ -0,0 +1,217 @@ +import { useContext, useEffect, useState } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Icon, Scrollable, SelectableBox, Stack, useCheckboxSetValues } from '@openedx/paragon'; +import { Folder } from '@openedx/paragon/icons'; + +import { + ContentHit, + SearchContextProvider, + SearchKeywordsField, + SearchSortWidget, + useSearchContext, + useGetDocumentByBlockId, +} from '../../search-manager'; +import messages from './messages'; +import { useUpdateComponentCollections } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; +import Loading from '../../generic/Loading'; + +interface ManageCollectionsProps { + contentHit: ContentHit; +} + +interface CollectionsDrawerProps extends ManageCollectionsProps { + onClose: () => void; +} + +const CollectionsSelectableBox = ({ contentHit, onClose }: CollectionsDrawerProps) => { + const type = 'checkbox'; + const intl = useIntl(); + const { collectionHits } = useSearchContext(); + const { showToast } = useContext(ToastContext); + const [selectedCollections, { + add, + remove, + set, + clear, + }] = useCheckboxSetValues(contentHit.collections?.key || []); + + useEffect(() => { + set(contentHit.collections?.key || []); + + return () => { + clear(); + } + }, [contentHit]) + + const updateCollectionsMutation = useUpdateComponentCollections(contentHit.contextKey, contentHit.usageKey); + + const handleConfirmation = () => { + updateCollectionsMutation.mutateAsync(selectedCollections).then(() => { + showToast(intl.formatMessage(messages.manageCollectionsToComponentSuccess)); + onClose(); + }).catch(() => { + showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed)); + }); + } + + const handleChange = (e) => { + e.target.checked ? add(e.target.value) : remove(e.target.value); + }; + + return ( + + + + {collectionHits.map((contentHit) => ( + + + + {contentHit.displayName} + + + ))} + + + + + + + + ); +} + +const AddToCollectionsDrawer = ({ contentHit, onClose }: CollectionsDrawerProps) => { + const intl = useIntl(); + const { displayName } = contentHit; + + return ( + + + + + + + + {/* Set key to update selection when component usageKey changes */} + + + + ) +} + +const ComponentCollections = ({ collections, onManageClick }: { + collections?: string[]; + onManageClick: () => void; +}) => { + const intl = useIntl(); + + if (!collections) { + return ( + + + + + + + ); + } + + return ( + + {collections.map((collection) => ( + + + {collection} + + ))} + + + ); +} + +const ManageCollections = ({ contentHit }: ManageCollectionsProps) => { + const { data, isLoading } = useGetDocumentByBlockId( + contentHit.contextKey, + contentHit.blockId + ) as { data: ContentHit, isLoading: boolean}; + const [editing, setEditing] = useState(false); + + if (isLoading) { + return + } + + if (editing) { + return ( + setEditing(false)} + key={contentHit.usageKey} + /> + ); + } + return ( + setEditing(true)} + /> + ); +} + +export default ManageCollections; diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index aff9b73591..4e8c41d297 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -121,21 +121,51 @@ const messages = defineMessages({ defaultMessage: 'Component Preview', description: 'Title for preview modal', }, - addToCollectionSubtitle: { - id: 'course-authoring.library-authoring.component.manage-tab.collections.subtitle', - defaultMessage: 'Add {displayName} to Collection', - description: 'Subtitle for collection section in manage tab', + manageCollectionsText: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.text', + defaultMessage: 'Manage Collections', + description: 'Header and button text for collection section in manage tab', }, - addToCollectionSearchPlaceholder: { + manageCollectionsAddBtnText: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.btn-text', + defaultMessage: 'Add to Collection', + description: 'Button text for collection section in manage tab', + }, + manageCollectionsSearchPlaceholder: { id: 'course-authoring.library-authoring.component.manage-tab.collections.search-placeholder', defaultMessage: 'Search', description: 'Placeholder text for collection search in manage tab', }, - addToCollectionSelectionLabel: { + manageCollectionsSelectionLabel: { id: 'course-authoring.library-authoring.component.manage-tab.collections.selection-aria-label', defaultMessage: 'Collection selection', description: 'Aria label text for collection selection box', }, + manageCollectionsToComponentSuccess: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.add-success', + defaultMessage: 'Component collections updated', + description: 'Message to display on updating component collections', + }, + manageCollectionsToComponentFailed: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.add-failed', + defaultMessage: 'Failed to update Component collections', + description: 'Message to display on failure of updating component collections', + }, + manageCollectionsToComponentConfirmBtn: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.add-confirm-btn', + defaultMessage: 'Confirm', + description: 'Button text to confirm collections for a component', + }, + manageCollectionsToComponentCancelBtn: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.add-cancel-btn', + defaultMessage: 'Cancel', + description: 'Button text to cancel collections selection for a component', + }, + componentNotOrganizedIntoCollection: { + id: 'course-authoring.library-authoring.component.manage-tab.collections.no-collections', + defaultMessage: 'This component is not organized into any collection.', + description: 'Message to display in manage collections section when component is not part of any collection.', + }, }); export default messages; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 145995b51e..c0d4c42a50 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -18,6 +18,11 @@ export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl( */ export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`; +/** + * Get the URL for library block metadata. + */ +export const getLibraryBlockCollectionsUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}collections/`; + /** * Get the URL for content library list API. */ @@ -40,7 +45,7 @@ export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/a /** * Get the URL for the xblock OLX API */ -export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`; +export const getXBlockOLXApiUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}olx/`; /** * Get the URL for the xblock Assets List API */ @@ -377,3 +382,12 @@ export async function restoreCollection(libraryId: string, collectionId: string) const client = getAuthenticatedHttpClient(); await client.post(getLibraryCollectionRestoreApiUrl(libraryId, collectionId)); } + +/** + * Update component collections. + */ +export async function updateComponentCollections(usageKey: string, collectionKeys: string[]) { + await getAuthenticatedHttpClient().patch(getLibraryBlockCollectionsUrl(usageKey), { + collection_keys: collectionKeys, + }); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 42a1f53a34..f3517c77dc 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -34,6 +34,7 @@ import { restoreCollection, setXBlockOLX, getXBlockAssets, + updateComponentCollections, } from './api'; export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { @@ -331,9 +332,9 @@ export const useUpdateCollection = (libraryId: string, collectionId: string) => export const useUpdateCollectionComponents = (libraryId?: string, collectionId?: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (usage_keys: string[]) => { + mutationFn: async (usageKeys: string[]) => { if (libraryId !== undefined && collectionId !== undefined) { - return updateCollectionComponents(libraryId, collectionId, usage_keys); + return updateCollectionComponents(libraryId, collectionId, usageKeys); } return undefined; }, @@ -372,3 +373,30 @@ export const useRestoreCollection = (libraryId: string, collectionId: string) => }, }); }; + +/** + * Use this mutation to update collections related a component in a library + */ +export const useUpdateComponentCollections = (libraryId: string, usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (collectionKeys: string[]) => { + return updateComponentCollections(usageKey, collectionKeys); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onSettled: (_data, _error, _variables) => { + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + queryClient.invalidateQueries({ + predicate: (query) => { + const queryLibId = query.queryKey[5]; + if ( + (query.queryKey[0] !== 'content_search' && query.queryKey[1] !== 'get_by_block_id') + || typeof queryLibId !== 'string') { + return false; + } + return queryLibId === libraryId; + } + }); + }, + }); +}; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 1ec906f376..cf883d1e50 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -556,3 +556,27 @@ export async function fetchDocumentById({ client, indexName, id } : { const doc = await client.index(indexName).getDocument(id); return formatSearchHit(doc); } + +/** + * Fetch a content hit by block Id and library key i.e. context_key + */ +export const fetchContentByBlockId = async ( + client: MeiliSearch, + indexName: string, + libraryKey: string, + blockId: string, +): Promise => { + + const { results } = await client.multiSearch({ + queries: [{ + indexUid: indexName, + filter: [ + `context_key = "${libraryKey}"`, + `block_id = "${blockId}"`, + ], + limit: 1, + }], + }); + + return formatSearchHit(results[0].hits[0]); +}; diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index cd63bbb344..1463b9fd28 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -12,6 +12,7 @@ import { fetchDocumentById, fetchBlockTypes, OverrideQueries, + fetchContentByBlockId, } from './api'; /** @@ -283,3 +284,21 @@ export const useGetSingleDocument = ({ client, indexName, id }: { }, }) ); + +/* istanbul ignore next */ +export const useGetDocumentByBlockId = (libraryKey: string, blockId: string) => { + const { client, indexName } = useContentSearchConnection(); + return useQuery({ + enabled: client !== undefined && indexName !== undefined, + queryKey: [ + 'content_search', + 'get_by_block_id', + client?.config.apiKey, + client?.config.host, + indexName, + libraryKey, + blockId, + ], + queryFn: () => fetchContentByBlockId(client!, indexName!, libraryKey, blockId), + }); +}; diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index e2d4188be1..df2917aa61 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -9,5 +9,6 @@ export { default as SearchSortWidget } from './SearchSortWidget'; export { default as Stats } from './Stats'; export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api'; export { useGetBlockTypes } from './data/apiHooks'; +export { useGetDocumentByBlockId } from './data/apiHooks'; export type { CollectionHit, ContentHit, ContentHitTags } from './data/api'; From 8482e88128d7455ede6f6a0240b741b77a61d5e1 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 7 Oct 2024 20:46:41 +0530 Subject: [PATCH 04/14] fix: lint issues --- .../component-info/ManageCollections.tsx | 46 +++++++++---------- src/library-authoring/data/apiHooks.ts | 6 +-- src/search-manager/SearchManager.ts | 23 +++++----- src/search-manager/SearchSortWidget.tsx | 6 +-- src/search-manager/data/api.ts | 1 - 5 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/library-authoring/component-info/ManageCollections.tsx b/src/library-authoring/component-info/ManageCollections.tsx index 2acab918fc..ad04934f30 100644 --- a/src/library-authoring/component-info/ManageCollections.tsx +++ b/src/library-authoring/component-info/ManageCollections.tsx @@ -1,6 +1,8 @@ import { useContext, useEffect, useState } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Button, Icon, Scrollable, SelectableBox, Stack, useCheckboxSetValues } from '@openedx/paragon'; +import { + Button, Icon, Scrollable, SelectableBox, Stack, useCheckboxSetValues, +} from '@openedx/paragon'; import { Folder } from '@openedx/paragon/icons'; import { @@ -41,8 +43,8 @@ const CollectionsSelectableBox = ({ contentHit, onClose }: CollectionsDrawerProp return () => { clear(); - } - }, [contentHit]) + }; + }, [contentHit]); const updateCollectionsMutation = useUpdateComponentCollections(contentHit.contextKey, contentHit.usageKey); @@ -53,15 +55,13 @@ const CollectionsSelectableBox = ({ contentHit, onClose }: CollectionsDrawerProp }).catch(() => { showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed)); }); - } - - const handleChange = (e) => { - e.target.checked ? add(e.target.value) : remove(e.target.value); }; + const handleChange = (e) => (e.target.checked ? add(e.target.value) : remove(e.target.value)); + return ( - + - {collectionHits.map((contentHit) => ( + {collectionHits.map((collectionHit) => ( - {contentHit.displayName} + {collectionHit.displayName} ))} - + - + state={btnState} + labels={{ + default: intl.formatMessage(messages.manageCollectionsToComponentConfirmBtn), + }} + /> ); @@ -142,7 +148,7 @@ const ComponentCollections = ({ collections, onManageClick }: { }) => { const intl = useIntl(); - if (!collections) { + if (!collections?.length) { return ( diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 804b224053..b1f682e9cd 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -95,6 +95,7 @@ mockCreateLibraryBlock.newHtmlData = { created: '2024-07-22T21:37:49Z', modified: '2024-07-22T21:37:49Z', tagsCount: 0, + collections: [], } satisfies api.LibraryBlockMetadata; mockCreateLibraryBlock.newProblemData = { id: 'lb:Axim:TEST:problem:prob1', @@ -109,6 +110,7 @@ mockCreateLibraryBlock.newProblemData = { created: '2024-07-22T21:37:49Z', modified: '2024-07-22T21:37:49Z', tagsCount: 0, + collections: [], } satisfies api.LibraryBlockMetadata; mockCreateLibraryBlock.newVideoData = { id: 'lb:Axim:TEST:video:vid1', @@ -123,6 +125,7 @@ mockCreateLibraryBlock.newVideoData = { created: '2024-07-22T21:37:49Z', modified: '2024-07-22T21:37:49Z', tagsCount: 0, + collections: [], } satisfies api.LibraryBlockMetadata; /** Apply this mock. Returns a spy object that can tell you if it's been called. */ mockCreateLibraryBlock.applyMock = () => ( @@ -200,6 +203,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata); From 7e0380d89860332dd7fe6b3db1ad8f7167f695ee Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 10 Oct 2024 10:37:27 +0530 Subject: [PATCH 11/14] fix: lint issues --- .../component-info/ComponentManagement.test.tsx | 3 --- .../component-info/ManageCollections.test.tsx | 12 +++++------- .../component-info/ManageCollections.tsx | 4 ++-- .../components/ComponentCard.test.tsx | 1 + src/library-authoring/data/api.mocks.ts | 2 +- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx index 7920cfffca..3ce44cc341 100644 --- a/src/library-authoring/component-info/ComponentManagement.test.tsx +++ b/src/library-authoring/component-info/ComponentManagement.test.tsx @@ -32,12 +32,9 @@ const render = (ui: React.ReactElement) => baseRender(ui, { extraWrapper: ({ children }) => { children }, }); - - mockLibraryBlockMetadata.applyMock(); mockContentTaxonomyTagsData.applyMock(); - describe('', () => { beforeEach(() => { initializeMocks(); diff --git a/src/library-authoring/component-info/ManageCollections.test.tsx b/src/library-authoring/component-info/ManageCollections.test.tsx index 77e273f204..b6aa5af61c 100644 --- a/src/library-authoring/component-info/ManageCollections.test.tsx +++ b/src/library-authoring/component-info/ManageCollections.test.tsx @@ -1,5 +1,7 @@ import fetchMock from 'fetch-mock-jest'; +import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter/types'; import { initializeMocks, render as baseRender, @@ -10,16 +12,13 @@ import mockCollectionsResults from '../__mocks__/collection-search.json'; import { mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockLibraryBlockMetadata } from '../data/api.mocks'; import ManageCollections from './ManageCollections'; -import userEvent from '@testing-library/user-event'; import { LibraryProvider } from '../common/context'; -import MockAdapter from 'axios-mock-adapter/types'; import { getLibraryBlockCollectionsUrl } from '../data/api'; const render = (ui: React.ReactElement) => baseRender(ui, { extraWrapper: ({ children }) => { children }, }); - let axiosMock: MockAdapter; let mockShowToast; @@ -27,7 +26,6 @@ mockLibraryBlockMetadata.applyMock(); mockContentSearchConfig.applyMock(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; - describe('', () => { beforeEach(() => { const mocks = initializeMocks(); @@ -54,7 +52,7 @@ describe('', () => { axiosMock.onPatch(url).reply(200); render(); const manageBtn = await screen.findByRole('button', { name: 'Manage Collections' }); userEvent.click(manageBtn); @@ -68,7 +66,7 @@ describe('', () => { expect(axiosMock.history.patch.length).toEqual(1); expect(mockShowToast).toHaveBeenCalledWith('Component collections updated'); expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ - collection_keys: [ "my-first-collection", "my-second-collection" ], + collection_keys: ['my-first-collection', 'my-second-collection'], }); }); expect(screen.queryByRole('search')).not.toBeInTheDocument(); @@ -92,7 +90,7 @@ describe('', () => { await waitFor(() => { expect(axiosMock.history.patch.length).toEqual(1); expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ - collection_keys: [ "my-second-collection" ], + collection_keys: ['my-second-collection'], }); expect(mockShowToast).toHaveBeenCalledWith('Failed to update Component collections'); }); diff --git a/src/library-authoring/component-info/ManageCollections.tsx b/src/library-authoring/component-info/ManageCollections.tsx index 8b0f574cc8..d43e651111 100644 --- a/src/library-authoring/component-info/ManageCollections.tsx +++ b/src/library-authoring/component-info/ManageCollections.tsx @@ -1,4 +1,4 @@ -import { useContext, useMemo, useState, useEffect } from 'react'; +import { useContext, useState } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon, Scrollable, SelectableBox, Stack, StatefulButton, useCheckboxSetValues, @@ -49,7 +49,7 @@ const CollectionsSelectableBox = ({ usageKey, collections, onClose }: Collection }).catch(() => { showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed)); }).finally(() => { - setBtnState('default') + setBtnState('default'); onClose(); }); }; diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index b22c6b0283..f5a8266453 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -32,6 +32,7 @@ const contentHit: ContentHit = { created: 1722434322294, modified: 1722434322294, lastPublished: null, + collections: {}, }; const clipboardBroadcastChannelMock = { diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index b1f682e9cd..86c124afc6 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -264,7 +264,7 @@ mockLibraryBlockMetadata.dataWithCollections = { created: '2024-06-20T13:54:21Z', modified: '2024-06-21T13:54:21Z', tagsCount: 0, - collections: [{ title: 'My first collection', key: 'my-first-collection'}], + collections: [{ title: 'My first collection', key: 'my-first-collection' }], } satisfies api.LibraryBlockMetadata; /** Apply this mock. Returns a spy object that can tell you if it's been called. */ mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata); From 1c3a554a53426bdfb7bf1909d40c5629ba18ba77 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 10 Oct 2024 16:39:02 +0530 Subject: [PATCH 12/14] feat: remove from collection menu option --- .../add-content/AddContentContainer.tsx | 4 +-- .../components/ComponentCard.tsx | 26 +++++++++++++++++- src/library-authoring/components/messages.ts | 15 +++++++++++ src/library-authoring/data/api.ts | 13 +++++++-- src/library-authoring/data/apiHooks.test.tsx | 4 +-- src/library-authoring/data/apiHooks.ts | 27 ++++++++++++++++--- 6 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index d37fd75f05..7e93a40248 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -21,7 +21,7 @@ import { useParams } from 'react-router-dom'; import { ToastContext } from '../../generic/toast-context'; import { useCopyToClipboard } from '../../generic/clipboard'; import { getCanEdit } from '../../course-unit/data/selectors'; -import { useCreateLibraryBlock, useLibraryPasteClipboard, useUpdateCollectionComponents } from '../data/apiHooks'; +import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks'; import { useLibraryContext } from '../common/context'; import { canEditComponent } from '../components/ComponentEditorModal'; @@ -63,7 +63,7 @@ const AddContentContainer = () => { const intl = useIntl(); const { libraryId, collectionId } = useParams(); const createBlockMutation = useCreateLibraryBlock(); - const updateComponentsMutation = useUpdateCollectionComponents(libraryId, collectionId); + const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId); const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); const canEdit = useSelector(getCanEdit); diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 095512d2d8..25101d8287 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -16,6 +16,8 @@ import messages from './messages'; import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; import BaseComponentCard from './BaseComponentCard'; import { canEditComponent } from './ComponentEditorModal'; +import { useParams } from 'react-router'; +import { useRemoveComponentsFromCollection } from '../data/apiHooks'; type ComponentCardProps = { contentHit: ContentHit, @@ -23,10 +25,17 @@ type ComponentCardProps = { export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { const intl = useIntl(); - const { openComponentEditor } = useLibraryContext(); + const { + libraryId, + openComponentEditor, + closeLibrarySidebar, + currentComponentUsageKey, + } = useLibraryContext(); + const { collectionId } = useParams(); const canEdit = usageKey && canEditComponent(usageKey); const { showToast } = useContext(ToastContext); const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); + const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId); const updateClipboardClick = () => { updateClipboard(usageKey) .then((clipboardData) => { @@ -36,6 +45,18 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { .catch(() => showToast(intl.formatMessage(messages.copyToClipboardError))); }; + const removeFromCollection = () => { + removeComponentsMutation.mutateAsync([usageKey]).then(() => { + if (currentComponentUsageKey === usageKey) { + // Close sidebar if current component is open + closeLibrarySidebar(); + } + showToast(intl.formatMessage(messages.removeComponentSucess)); + }).catch(() => { + showToast(intl.formatMessage(messages.removeComponentFailure)); + }); + } + return ( e.stopPropagation()}> { + {collectionId && + + } diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 3bac3ad177..b230b5b21a 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -31,6 +31,21 @@ const messages = defineMessages({ defaultMessage: 'Add to collection', description: 'Menu item for add a component to collection.', }, + menuRemoveFromCollection: { + id: 'course-authoring.library-authoring.component.menu.remove', + defaultMessage: 'Remove from collection', + description: 'Menu item for remove a component from collection.', + }, + removeComponentSucess: { + id: 'course-authoring.library-authoring.component.remove-from-collection-success', + defaultMessage: 'Component successfully removed', + description: 'Message for successful removal of component from collection.', + }, + removeComponentFailure: { + id: 'course-authoring.library-authoring.component.remove-from-collection-failure', + defaultMessage: 'Failed to remove Component', + description: 'Message for failure of removal of component from collection.', + }, copyToClipboardSuccess: { id: 'course-authoring.library-authoring.component.copyToClipboardSuccess', defaultMessage: 'Component copied to clipboard', diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 01a7c328e8..8c0f007bee 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -365,14 +365,23 @@ export async function updateCollectionMetadata( } /** - * Update collection components. + * Add components to collection. */ -export async function updateCollectionComponents(libraryId: string, collectionId: string, usageKeys: string[]) { +export async function addComponentsToCollection(libraryId: string, collectionId: string, usageKeys: string[]) { await getAuthenticatedHttpClient().patch(getLibraryCollectionComponentApiUrl(libraryId, collectionId), { usage_keys: usageKeys, }); } +/** + * Update collection components. + */ +export async function removeComponentsFromCollection(libraryId: string, collectionId: string, usageKeys: string[]) { + await getAuthenticatedHttpClient().delete(getLibraryCollectionComponentApiUrl(libraryId, collectionId), { + data: { usage_keys: usageKeys }, + }); +} + /** * Soft-Delete collection. */ diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index c21f5f5a67..62fadf31ec 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -18,7 +18,7 @@ import { useCreateLibraryBlock, useCreateLibraryCollection, useRevertLibraryChanges, - useUpdateCollectionComponents, + useAddComponentsToCollection, useCollection, } from './apiHooks'; @@ -104,7 +104,7 @@ describe('library api hooks', () => { const collectionId = 'my-first-collection'; const url = getLibraryCollectionComponentApiUrl(libraryId, collectionId); axiosMock.onPatch(url).reply(200); - const { result } = renderHook(() => useUpdateCollectionComponents(libraryId, collectionId), { wrapper }); + const { result } = renderHook(() => useAddComponentsToCollection(libraryId, collectionId), { wrapper }); await result.current.mutateAsync(['some-usage-key']); expect(axiosMock.history.patch[0].url).toEqual(url); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 6c3cfd78ac..33cc648421 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -27,7 +27,7 @@ import { getXBlockOLX, updateCollectionMetadata, type UpdateCollectionComponentsRequest, - updateCollectionComponents, + addComponentsToCollection, type CreateLibraryCollectionDataRequest, getCollectionMetadata, deleteCollection, @@ -35,6 +35,7 @@ import { setXBlockOLX, getXBlockAssets, updateComponentCollections, + removeComponentsFromCollection, } from './api'; export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { @@ -329,12 +330,32 @@ export const useUpdateCollection = (libraryId: string, collectionId: string) => /** * Use this mutation to add components to a collection in a library */ -export const useUpdateCollectionComponents = (libraryId?: string, collectionId?: string) => { +export const useAddComponentsToCollection = (libraryId?: string, collectionId?: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (usageKeys: string[]) => { if (libraryId !== undefined && collectionId !== undefined) { - return updateCollectionComponents(libraryId, collectionId, usageKeys); + return addComponentsToCollection(libraryId, collectionId, usageKeys); + } + return undefined; + }, + onSettled: () => { + if (libraryId !== undefined && collectionId !== undefined) { + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + } + }, + }); +}; + +/** + * Use this mutation to remove components from a collection in a library + */ +export const useRemoveComponentsFromCollection = (libraryId?: string, collectionId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (usageKeys: string[]) => { + if (libraryId !== undefined && collectionId !== undefined) { + return removeComponentsFromCollection(libraryId, collectionId, usageKeys); } return undefined; }, From c8cc866d39248508791e0d86bf11e4a6b58a192c Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 10 Oct 2024 16:56:49 +0530 Subject: [PATCH 13/14] test: remove component from collection --- .../LibraryCollectionPage.test.tsx | 37 ++++++++++++++++++- .../components/ComponentCard.tsx | 10 +++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index b242601474..aa90836316 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -1,5 +1,7 @@ import fetchMock from 'fetch-mock-jest'; import { cloneDeep } from 'lodash'; +import MockAdapter from 'axios-mock-adapter/types'; + import { fireEvent, initializeMocks, @@ -17,6 +19,10 @@ import { import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock'; import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock'; import { LibraryLayout } from '..'; +import { getLibraryCollectionComponentApiUrl } from '../data/api'; + +let axiosMock: MockAdapter; +let mockShowToast; mockClipboardEmpty.applyMock(); mockGetCollectionMetadata.applyMock(); @@ -40,7 +46,9 @@ const { title } = mockGetCollectionMetadata.collectionData; describe('', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; fetchMock.mockReset(); // The Meilisearch client-side API uses fetch, not Axios. @@ -301,7 +309,6 @@ describe('', () => { expect(mockResult0.display_name).toStrictEqual(displayName); await renderLibraryCollectionPage(); - // Click on the first component. It should appear twice, in both "Recently Modified" and "Components" fireEvent.click((await screen.findAllByText(displayName))[0]); const sidebar = screen.getByTestId('library-sidebar'); @@ -324,4 +331,30 @@ describe('', () => { expect(screen.getByText(/no matching components/i)).toBeInTheDocument(); }); + + it('should remove component from collection and hides sidebar', async () => { + const url = getLibraryCollectionComponentApiUrl( + mockContentLibrary.libraryId, + mockCollection.collectionId, + ); + axiosMock.onDelete(url).reply(204); + const displayName = 'Introduction to Testing'; + await renderLibraryCollectionPage(); + + // open sidebar + fireEvent.click(await screen.findByText(displayName)); + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument()); + + const menuBtns = await screen.findAllByRole('button', { name: 'Component actions menu' }); + // open menu + fireEvent.click(menuBtns[0]); + + fireEvent.click(await screen.findByText('Remove from collection')); + await waitFor(() => { + expect(axiosMock.history.delete.length).toEqual(1); + expect(mockShowToast).toHaveBeenCalledWith('Component successfully removed'); + }); + // Should close sidebar as component was removed + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); }); diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 25101d8287..662344247a 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -8,6 +8,7 @@ import { } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; +import { useParams } from 'react-router'; import { updateClipboard } from '../../generic/data/api'; import { ToastContext } from '../../generic/toast-context'; import { type ContentHit } from '../../search-manager'; @@ -16,7 +17,6 @@ import messages from './messages'; import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; import BaseComponentCard from './BaseComponentCard'; import { canEditComponent } from './ComponentEditorModal'; -import { useParams } from 'react-router'; import { useRemoveComponentsFromCollection } from '../data/apiHooks'; type ComponentCardProps = { @@ -55,7 +55,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { }).catch(() => { showToast(intl.formatMessage(messages.removeComponentFailure)); }); - } + }; return ( e.stopPropagation()}> @@ -75,9 +75,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { - {collectionId && + {collectionId && ( + - } + + )} From 44c4c250ffdd6358b39cdcc0779f200b6bfeb8f5 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 11 Oct 2024 11:05:36 +0530 Subject: [PATCH 14/14] fix: comments and remove unnecessary args --- src/library-authoring/data/api.ts | 2 +- src/library-authoring/data/apiHooks.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 8c0f007bee..528ccfa450 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -374,7 +374,7 @@ export async function addComponentsToCollection(libraryId: string, collectionId: } /** - * Update collection components. + * Remove components from collection. */ export async function removeComponentsFromCollection(libraryId: string, collectionId: string, usageKeys: string[]) { await getAuthenticatedHttpClient().delete(getLibraryCollectionComponentApiUrl(libraryId, collectionId), { diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 33cc648421..f405497efb 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -402,8 +402,7 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin const queryClient = useQueryClient(); return useMutation({ mutationFn: async (collectionKeys: string[]) => updateComponentCollections(usageKey, collectionKeys), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onSettled: (_data, _error, _variables) => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); },