diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index 9b6cee0993..792ab33ace 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -51,6 +51,7 @@ export const STRUCTURAL_TYPE_ICONS: Record = { vertical: UNIT_TYPE_ICONS_MAP.vertical, sequential: Folder, chapter: Folder, + collection: Folder, }; export const COMPONENT_TYPE_STYLE_COLOR_MAP = { diff --git a/src/hooks.js b/src/hooks.js deleted file mode 100644 index 73597e3ef6..0000000000 --- a/src/hooks.js +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect, useState } from 'react'; -import { history } from '@edx/frontend-platform'; - -export const useScrollToHashElement = ({ isLoading }) => { - const [elementWithHash, setElementWithHash] = useState(null); - - useEffect(() => { - const currentHash = window.location.hash.substring(1); - - if (currentHash) { - const element = document.getElementById(currentHash); - if (element) { - element.scrollIntoView(); - history.replace({ hash: '' }); - } - setElementWithHash(currentHash); - } - }, [isLoading]); - - return { elementWithHash }; -}; - -export const useEscapeClick = ({ onEscape, dependency }) => { - useEffect(() => { - const handleEscapeClick = (event) => { - if (event.key === 'Escape') { - onEscape(); - } - }; - - window.addEventListener('keydown', handleEscapeClick); - - return () => { - window.removeEventListener('keydown', handleEscapeClick); - }; - }, [dependency]); -}; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000000..87b94a4f4d --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { history } from '@edx/frontend-platform'; + +export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => { + const [elementWithHash, setElementWithHash] = useState(null); + + useEffect(() => { + const currentHash = window.location.hash.substring(1); + + if (currentHash) { + const element = document.getElementById(currentHash); + if (element) { + element.scrollIntoView(); + history.replace({ hash: '' }); + } + setElementWithHash(currentHash); + } + }, [isLoading]); + + return { elementWithHash }; +}; + +export const useEscapeClick = ({ onEscape, dependency }: { onEscape: () => void, dependency: any }) => { + useEffect(() => { + const handleEscapeClick = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onEscape(); + } + }; + + window.addEventListener('keydown', handleEscapeClick); + + return () => { + window.removeEventListener('keydown', handleEscapeClick); + }; + }, [dependency]); +}; + +/** + * Hook which loads next page of items on scroll + */ +export const useLoadOnScroll = ( + hasNextPage: boolean | undefined, + isFetchingNextPage: boolean, + fetchNextPage: () => void, + enabled: boolean, +) => { + useEffect(() => { + if (enabled) { + const onscroll = () => { + // Verify the position of the scroll to implement an infinite scroll. + // Used `loadLimit` to fetch next page before reach the end of the screen. + const loadLimit = 300; + const scrolledTo = window.scrollY + window.innerHeight; + const scrollDiff = document.body.scrollHeight - scrolledTo; + const isNearToBottom = scrollDiff <= loadLimit; + if (isNearToBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + window.addEventListener('scroll', onscroll); + return () => { + window.removeEventListener('scroll', onscroll); + }; + } + return () => { }; + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); +}; diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index e8e03cb4a6..7fa0d51900 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -10,7 +10,11 @@ import messages from './messages'; import { LibraryContext } from './common/context'; import { useContentLibrary } from './data/apiHooks'; -export const NoComponents = () => { +type NoSearchResultsProps = { + searchType?: 'collection' | 'component', +}; + +export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => { const { openAddContentSidebar } = useContext(LibraryContext); const { libraryId } = useParams(); const { data: libraryData } = useContentLibrary(libraryId); @@ -18,19 +22,25 @@ export const NoComponents = () => { return ( - + {searchType === 'collection' + ? + : } {canEditLibrary && ( )} ); }; -export const NoSearchResults = () => ( - - +export const NoSearchResults = ({ searchType = 'component' }: NoSearchResultsProps) => ( + + {searchType === 'collection' + ? + : } ); diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 6a5cac0912..e3c7c2d093 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -28,9 +28,12 @@ const returnEmptyResult = (_url, req) => { // We have to replace the query (search keywords) in the mock results with the actual query, // because otherwise we may have an inconsistent state that causes more queries and unexpected results. mockEmptyResult.results[0].query = query; + mockEmptyResult.results[2].query = query; // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockEmptyResult; }; @@ -48,10 +51,14 @@ const returnLowNumberResults = (_url, req) => { newMockResult.results[0].query = query; // Limit number of results to just 2 newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2); + newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2); newMockResult.results[0].estimatedTotalHits = 2; + newMockResult.results[2].estimatedTotalHits = 2; // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return newMockResult; }; @@ -129,7 +136,7 @@ describe('', () => { // "Recently Modified" header + sort shown expect(screen.getAllByText('Recently Modified').length).toEqual(2); - expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); expect(screen.getByText('Components (10)')).toBeInTheDocument(); expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument(); @@ -137,34 +144,38 @@ describe('', () => { fireEvent.click(screen.getByRole('tab', { name: 'Components' })); // "Recently Modified" default sort shown expect(screen.getAllByText('Recently Modified').length).toEqual(1); - expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); // Navigate to the collections tab fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); // "Recently Modified" default sort shown expect(screen.getAllByText('Recently Modified').length).toEqual(1); - expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument(); - expect(screen.getByText('Coming soon!')).toBeInTheDocument(); + expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument(); // Go back to Home tab // This step is necessary to avoid the url change leak to other tests fireEvent.click(screen.getByRole('tab', { name: 'Home' })); // "Recently Modified" header + sort shown expect(screen.getAllByText('Recently Modified').length).toEqual(2); - expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); expect(screen.getByText('Components (10)')).toBeInTheDocument(); }); - it('shows a library without components', async () => { + it('shows a library without components and collections', async () => { fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); await renderLibraryPage(); expect(await screen.findByText('Content library')).toBeInTheDocument(); expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); + expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument(); }); @@ -211,6 +222,14 @@ describe('', () => { // Navigate to the components tab fireEvent.click(screen.getByRole('tab', { name: 'Components' })); expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument(); + + // Navigate to the collections tab + fireEvent.click(screen.getByRole('tab', { name: 'Collections' })); + expect(screen.getByText('No matching collections found in this library.')).toBeInTheDocument(); + + // Go back to Home tab + // This step is necessary to avoid the url change leak to other tests + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); }); it('should open and close new content sidebar', async () => { @@ -282,20 +301,29 @@ describe('', () => { // "Recently Modified" header + sort shown await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); }); - expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); expect(screen.getByText('Components (10)')).toBeInTheDocument(); expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); - // There should only be one "View All" button, since the Components count + // There should be two "View All" button, since the Components and Collections count // are above the preview limit (4) - expect(screen.getByText('View All')).toBeInTheDocument(); + expect(screen.getAllByText('View All').length).toEqual(2); - // Clicking on "View All" button should navigate to the Components tab - fireEvent.click(screen.getByText('View All')); + // Clicking on first "View All" button should navigate to the Collections tab + fireEvent.click(screen.getAllByText('View All')[0]); // "Recently Modified" default sort shown expect(screen.getAllByText('Recently Modified').length).toEqual(1); - expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); + expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); + expect(screen.getByText('Collection 1')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'Home' })); + // Clicking on second "View All" button should navigate to the Components tab + fireEvent.click(screen.getAllByText('View All')[1]); + // "Recently Modified" default sort shown + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument(); expect(screen.queryByText('Components (10)')).not.toBeInTheDocument(); expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); @@ -304,7 +332,7 @@ describe('', () => { fireEvent.click(screen.getByRole('tab', { name: 'Home' })); // "Recently Modified" header + sort shown expect(screen.getAllByText('Recently Modified').length).toEqual(2); - expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Collections (6)')).toBeInTheDocument(); expect(screen.getByText('Components (10)')).toBeInTheDocument(); }); @@ -317,7 +345,7 @@ describe('', () => { // "Recently Modified" header + sort shown await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); }); - expect(screen.getByText('Collections (0)')).toBeInTheDocument(); + expect(screen.getByText('Collections (2)')).toBeInTheDocument(); expect(screen.getByText('Components (2)')).toBeInTheDocument(); expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument(); expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); @@ -405,8 +433,8 @@ describe('', () => { await renderLibraryPage(); // Click on the first component - waitFor(() => expect(screen.queryByText(displayName)).toBeInTheDocument()); - fireEvent.click(screen.getAllByText(displayName)[0]); + expect((await screen.findAllByText(displayName))[0]).toBeInTheDocument(); + fireEvent.click((await screen.findAllByText(displayName))[0]); const sidebar = screen.getByTestId('library-sidebar'); @@ -518,4 +546,20 @@ describe('', () => { expect(screen.getByText(/no matching components/i)).toBeInTheDocument(); }); + + it('shows both components and collections in recently modified section', async () => { + await renderLibraryPage(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + + // "Recently Modified" header + sort shown + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement; + expect(recentModifiedContainer).toBeTruthy(); + + const container = within(recentModifiedContainer!); + expect(container.queryAllByText('Text').length).toBeGreaterThan(0); + expect(container.queryAllByText('Collection').length).toBeGreaterThan(0); + }); }); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 7ec5e63a8a..c74943a755 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -206,7 +206,7 @@ const LibraryAuthoringPage = () => { /> } + element={} /> ( -
- -
-); +import { CardGrid } from '@openedx/paragon'; + +import { useLoadOnScroll } from '../hooks'; +import { useSearchContext } from '../search-manager'; +import { NoComponents, NoSearchResults } from './EmptyStates'; +import CollectionCard from './components/CollectionCard'; +import { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection'; + +type LibraryCollectionsProps = { + variant: 'full' | 'preview', +}; + +/** + * Library Collections to show collections grid + * + * Use style to: + * - 'full': Show all collections with Infinite scroll pagination. + * - 'preview': Show first 4 collections without pagination. + */ +const LibraryCollections = ({ variant }: LibraryCollectionsProps) => { + const { + collectionHits, + totalCollectionHits, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isFiltered, + } = useSearchContext(); + + const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits; + + useLoadOnScroll( + hasNextPage, + isFetchingNextPage, + fetchNextPage, + variant === 'full', + ); + + if (totalCollectionHits === 0) { + return isFiltered ? : ; + } + + return ( + + { collectionList.map((collectionHit) => ( + + )) } + + ); +}; export default LibraryCollections; diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx index 4500cf1a91..73d0e44ad5 100644 --- a/src/library-authoring/LibraryHome.tsx +++ b/src/library-authoring/LibraryHome.tsx @@ -20,13 +20,12 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) const intl = useIntl(); const { totalHits: componentCount, + totalCollectionHits: collectionCount, isFiltered, } = useSearchContext(); - const collectionCount = 0; - const renderEmptyState = () => { - if (componentCount === 0) { + if (componentCount === 0 && collectionCount === 0) { return isFiltered ? : ; } return null; @@ -42,9 +41,9 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) handleTabChange(tabList.collections)} > - + = ({ libraryId }) => { const intl = useIntl(); - const { totalHits: componentCount } = useSearchContext(); + const { + hits, + collectionHits, + totalHits, + totalCollectionHits, + } = useSearchContext(); + + const componentCount = totalHits + totalCollectionHits; + // Since we only display a fixed number of items in preview, + // only these number of items are use in sort step below + const componentList = hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT); + const collectionList = collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT); + // Sort them by `modified` field in reverse and display them + const recentItems = orderBy([ + ...componentList, + ...collectionList, + ], ['modified'], ['desc']).slice(0, LIBRARY_SECTION_PREVIEW_LIMIT); + + const { data: blockTypesData } = useLibraryBlockTypes(libraryId); + const blockTypes = useMemo(() => { + const result = {}; + if (blockTypesData) { + blockTypesData.forEach(blockType => { + result[blockType.blockType] = blockType; + }); + } + return result; + }, [blockTypesData]); return componentCount > 0 ? ( @@ -17,7 +48,30 @@ const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => { title={intl.formatMessage(messages.recentlyModifiedTitle)} contentCount={componentCount} > - + + {recentItems.map((contentHit) => ( + contentHit.type === 'collection' ? ( + + ) : ( + + ) + ))} + ) : null; diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index c800f368ad..f84d16c611 100644 --- a/src/library-authoring/__mocks__/library-search.json +++ b/src/library-authoring/__mocks__/library-search.json @@ -1,273 +1,485 @@ { - "comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts", - "note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.", - "results": [ + "comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts", + "note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.", + "results": [ + { + "indexUid": "studio_content", + "hits": [ { - "indexUid": "studio_content", - "hits": [ - { - "id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90", - "display_name": "Introduction to Testing", - "block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd", - "content": { - "html_content": "This is a text component which uses HTML." - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1721857069.042984, - "modified": 1725398676.078056, - "last_published": 1725035862.450613, - "usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd", - "block_type": "html", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2", - "display_name": "Second Text Component", - "block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480", - "content": { - "html_content": "Preview of the second text component here" - }, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1724879593.066427, - "modified": 1725034981.663482, - "last_published": 1725035862.450613, - "usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480", - "block_type": "html", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95", - "display_name": "Third Text component", - "block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e", - "content": { - "html_content": "This is a text component that I've edited within the library. " - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1721857034.455737, - "modified": 1722551300.377488, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e", - "block_type": "html", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2", - "display_name": "Text 4", - "block_id": "e59e8c73-4056-4894-bca4-062781fb3f68", - "content": { - "html_content": "" - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1720774228.49832, - "modified": 1720774228.49832, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68", - "block_type": "html", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115", - "display_name": "Blank Problem", - "block_id": "f16116c9-516e-4bb9-b99e-103599f62417", - "content": { - "problem_types": [], - "capa_content": " " - }, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1724725821.973896, - "modified": 1724725821.973896, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417", - "block_type": "problem", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtestproblem2ace6b9b-6620-413c-a66f-19c797527f34-3a7973b7", - "display_name": "Multiple Choice Problem", - "block_id": "2ace6b9b-6620-413c-a66f-19c797527f34", - "content": { - "problem_types": ["multiplechoiceresponse"], - "capa_content": "What is the gradient of an inverted hyperspace manifold?cos (x) ey ln(z) i + sin(x)ey ln(z)j + sin(x) ey(1/z)k " - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1720774232.76135, - "modified": 1720774232.76135, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:problem:2ace6b9b-6620-413c-a66f-19c797527f34", - "block_type": "problem", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtestproblem7d7e98ba-3ac9-4aa8-8946-159129b39a28-3a7973b7", - "display_name": "Single Choice Problem", - "block_id": "7d7e98ba-3ac9-4aa8-8946-159129b39a28", - "content": { - "problem_types": ["choiceresponse"], - "capa_content": "Blah blah?" - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1720774232.76135, - "modified": 1720774232.76135, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:problem:7d7e98ba-3ac9-4aa8-8946-159129b39a28", - "block_type": "problem", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtestproblem4e1a72f9-ac93-42aa-a61c-ab5f9698c398-3a7973b7", - "display_name": "Numerical Response Problem", - "block_id": "4e1a72f9-ac93-42aa-a61c-ab5f9698c398", - "content": { - "problem_types": ["numericalresponse"], - "capa_content": "What is 1 + 1?" - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1720774232.76135, - "modified": 1720774232.76135, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:problem:4e1a72f9-ac93-42aa-a61c-ab5f9698c398", - "block_type": "problem", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtestproblemad483625-ade2-4712-88d8-c9743abbd291-3a7973b7", - "display_name": "Option Response Problem", - "block_id": "ad483625-ade2-4712-88d8-c9743abbd291", - "content": { - "problem_types": ["optionresponse"], - "capa_content": "What is foobar?" - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1720774232.76135, - "modified": 1720774232.76135, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:problem:ad483625-ade2-4712-88d8-c9743abbd291", - "block_type": "problem", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - }, - { - "id": "lbaximtestproblemb4c859cb-de70-421a-917b-e6e01ce44bd8-3a7973b7", - "display_name": "String Response Problem", - "block_id": "b4c859cb-de70-421a-917b-e6e01ce44bd8", - "content": { - "problem_types": ["stringresponse"], - "capa_content": "What is your name?" - }, - "tags": {}, - "type": "library_block", - "breadcrumbs": [ - { - "display_name": "Test Library" - } - ], - "created": 1720774232.76135, - "modified": 1720774232.76135, - "last_published": 1724879092.002222, - "usage_key": "lb:Axim:TEST:problem:b4c859cb-de70-421a-917b-e6e01ce44bd8", - "block_type": "problem", - "context_key": "lib:Axim:TEST", - "org": "Axim", - "access_id": 15 - } + "id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90", + "display_name": "Introduction to Testing", + "block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd", + "content": { + "html_content": "This is a text component which uses HTML." + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1721857069.042984, + "modified": 1725878053.420395, + "last_published": 1725035862.450613, + "usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2", + "display_name": "Second Text Component", + "block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480", + "content": { + "html_content": "Preview of the second text component here" + }, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1724879593.066427, + "modified": 1725034981.663482, + "last_published": 1725035862.450613, + "usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95", + "display_name": "Third Text component", + "block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e", + "content": { + "html_content": "This is a text component that I've edited within the library. " + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1721857034.455737, + "modified": 1722551300.377488, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2", + "display_name": "Text 4", + "block_id": "e59e8c73-4056-4894-bca4-062781fb3f68", + "content": { + "html_content": "" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774228.49832, + "modified": 1720774228.49832, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115", + "display_name": "Blank Problem", + "block_id": "f16116c9-516e-4bb9-b99e-103599f62417", + "content": { + "problem_types": [], + "capa_content": " " + }, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1724725821.973896, + "modified": 1724725821.973896, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblem2ace6b9b-6620-413c-a66f-19c797527f34-3a7973b7", + "display_name": "Multiple Choice Problem", + "block_id": "2ace6b9b-6620-413c-a66f-19c797527f34", + "content": { + "problem_types": [ + "multiplechoiceresponse" + ], + "capa_content": "What is the gradient of an inverted hyperspace manifold?cos (x) ey ln(z) i + sin(x)ey ln(z)j + sin(x) ey(1/z)k " + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:2ace6b9b-6620-413c-a66f-19c797527f34", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblem7d7e98ba-3ac9-4aa8-8946-159129b39a28-3a7973b7", + "display_name": "Single Choice Problem", + "block_id": "7d7e98ba-3ac9-4aa8-8946-159129b39a28", + "content": { + "problem_types": [ + "choiceresponse" + ], + "capa_content": "Blah blah?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:7d7e98ba-3ac9-4aa8-8946-159129b39a28", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblem4e1a72f9-ac93-42aa-a61c-ab5f9698c398-3a7973b7", + "display_name": "Numerical Response Problem", + "block_id": "4e1a72f9-ac93-42aa-a61c-ab5f9698c398", + "content": { + "problem_types": [ + "numericalresponse" + ], + "capa_content": "What is 1 + 1?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:4e1a72f9-ac93-42aa-a61c-ab5f9698c398", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblemad483625-ade2-4712-88d8-c9743abbd291-3a7973b7", + "display_name": "Option Response Problem", + "block_id": "ad483625-ade2-4712-88d8-c9743abbd291", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "What is foobar?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:ad483625-ade2-4712-88d8-c9743abbd291", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + }, + { + "id": "lbaximtestproblemb4c859cb-de70-421a-917b-e6e01ce44bd8-3a7973b7", + "display_name": "String Response Problem", + "block_id": "b4c859cb-de70-421a-917b-e6e01ce44bd8", + "content": { + "problem_types": [ + "stringresponse" + ], + "capa_content": "What is your name?" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774232.76135, + "modified": 1720774232.76135, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:b4c859cb-de70-421a-917b-e6e01ce44bd8", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15 + } + ], + "query": "", + "processingTimeMs": 1, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 10 + }, + { + "indexUid": "studio_content", + "hits": [], + "query": "", + "processingTimeMs": 0, + "limit": 0, + "offset": 0, + "estimatedTotalHits": 10, + "facetDistribution": { + "block_type": { + "html": 4, + "problem": 6 + }, + "content.problem_types": { + "multiplechoiceresponse": 1, + "choiceresponse": 1, + "numericalresponse": 1, + "optionresponse": 1, + "stringresponse": 1 + } + }, + "facetStats": {} + }, + { + "indexUid": "studio", + "hits": [ + { + "display_name": "Collection 1", + "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.", + "id": 1, + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1725534795.628254, + "modified": 1725878053.420395, + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "_formatted": { + "display_name": "Collection 1", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…", + "id": "1", + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": "1725534795.628254", + "modified": "1725534795.628266", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": "16" + } + }, + { + "display_name": "Collection 2", + "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", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1725534795.619101, + "modified": 1725534795.619113, + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "_formatted": { + "display_name": "Collection 2", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…", + "id": "2", + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } ], - "query": "", - "processingTimeMs": 1, - "limit": 20, - "offset": 0, - "estimatedTotalHits": 10 + "created": "1725534795.619101", + "modified": "1725534795.619113", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": "16" + } }, { - "indexUid": "studio_content", - "hits": [], - "query": "", - "processingTimeMs": 0, - "limit": 0, - "offset": 0, - "estimatedTotalHits": 10, - "facetDistribution": { - "block_type": { - "html": 4, - "problem": 6 - }, - "content.problem_types": { - "multiplechoiceresponse": 1, - "choiceresponse": 1, - "numericalresponse": 1, - "optionresponse": 1, - "stringresponse": 1 - } - }, - "facetStats": {} + "display_name": "Collection 3", + "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", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1725534795.609781, + "modified": 1725534795.609794, + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "_formatted": { + "display_name": "Collection 3", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…", + "id": "3", + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": "1725534795.609781", + "modified": "1725534795.609794", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": "16" + } + }, + { + "display_name": "Collection 4", + "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", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1725534795.596287, + "modified": 1725534795.5963, + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "_formatted": { + "display_name": "Collection 4", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…", + "id": "4", + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": "1725534795.596287", + "modified": "1725534795.5963", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": "16" + } + }, + { + "display_name": "Collection 5", + "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", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1725534795.583068, + "modified": 1725534795.583082, + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "_formatted": { + "display_name": "Collection 5", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…", + "id": "5", + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": "1725534795.583068", + "modified": "1725534795.583082", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": "16" + } + }, + { + "display_name": "Collection 6", + "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", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1725534795.573794, + "modified": 1725534795.573808, + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "_formatted": { + "display_name": "Collection 6", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…", + "id": "6", + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": "1725534795.573794", + "modified": "1725534795.573808", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": "16" + } } - ] -} \ No newline at end of file + ], + "query": "learn", + "processingTimeMs": 1, + "limit": 6, + "offset": 0, + "estimatedTotalHits": 6 + } + ] +} diff --git a/src/library-authoring/components/BaseComponentCard.tsx b/src/library-authoring/components/BaseComponentCard.tsx new file mode 100644 index 0000000000..1cd77ee7fb --- /dev/null +++ b/src/library-authoring/components/BaseComponentCard.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { + Card, + Container, + Icon, + Stack, +} from '@openedx/paragon'; + +import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; +import TagCount from '../../generic/tag-count'; +import { ContentHitTags, Highlight } from '../../search-manager'; + +type BaseComponentCardProps = { + type: string, + displayName: string, + description: string, + tags: ContentHitTags, + actions: React.ReactNode, + blockTypeDisplayName: string, + openInfoSidebar: () => void +}; + +const BaseComponentCard = ({ + type, + displayName, + description, + tags, + actions, + blockTypeDisplayName, + openInfoSidebar, +} : BaseComponentCardProps) => { + const tagCount = useMemo(() => { + if (!tags) { + return 0; + } + return (tags.level0?.length || 0) + (tags.level1?.length || 0) + + (tags.level2?.length || 0) + (tags.level3?.length || 0); + }, [tags]); + + const componentIcon = getItemIcon(type); + + return ( + + { + if (['Enter', ' '].includes(e.key)) { + openInfoSidebar(); + } + }} + > + + } + actions={actions} + /> + + + + + + {blockTypeDisplayName} + + + +
+ +
+ +
+
+
+
+ ); +}; + +export default BaseComponentCard; diff --git a/src/library-authoring/components/CollectionCard.test.tsx b/src/library-authoring/components/CollectionCard.test.tsx new file mode 100644 index 0000000000..cc353312e6 --- /dev/null +++ b/src/library-authoring/components/CollectionCard.test.tsx @@ -0,0 +1,38 @@ +import { initializeMocks, render, screen } from '../../testUtils'; + +import { type CollectionHit } from '../../search-manager'; +import CollectionCard from './CollectionCard'; + +const CollectionHitSample: CollectionHit = { + id: '1', + type: 'collection', + contextKey: 'lb:org1:Demo_Course', + org: 'org1', + breadcrumbs: [{ displayName: 'Demo Lib' }], + displayName: 'Collection Display Name', + description: 'Collection description', + formatted: { + displayName: 'Collection Display Formated Name', + description: 'Collection description', + }, + created: 1722434322294, + modified: 1722434322294, + tags: {}, +}; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the card with title and description', () => { + render(); + + expect(screen.getByText('Collection Display Formated Name')).toBeInTheDocument(); + expect(screen.getByText('Collection description')).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx new file mode 100644 index 0000000000..477a264e4a --- /dev/null +++ b/src/library-authoring/components/CollectionCard.tsx @@ -0,0 +1,49 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Icon, + IconButton, +} from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; + +import { type CollectionHit } from '../../search-manager'; +import messages from './messages'; +import BaseComponentCard from './BaseComponentCard'; + +type CollectionCardProps = { + collectionHit: CollectionHit, +}; + +const CollectionCard = ({ collectionHit } : CollectionCardProps) => { + const intl = useIntl(); + + const { + type, + formatted, + tags, + } = collectionHit; + const { displayName = '', description = '' } = formatted; + + return ( + + + + )} + blockTypeDisplayName={intl.formatMessage(messages.collectionType)} + openInfoSidebar={() => {}} + /> + ); +}; + +export default CollectionCard; diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index f460bc3ba4..b2033ca373 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -1,24 +1,20 @@ -import React, { useContext, useMemo, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, - Card, - Container, Icon, IconButton, Dropdown, - Stack, } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; -import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; import { updateClipboard } from '../../generic/data/api'; -import TagCount from '../../generic/tag-count'; import { ToastContext } from '../../generic/toast-context'; -import { type ContentHit, Highlight } from '../../search-manager'; +import { type ContentHit } from '../../search-manager'; import { LibraryContext } from '../common/context'; import messages from './messages'; import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; +import BaseComponentCard from './BaseComponentCard'; type ComponentCardProps = { contentHit: ContentHit, @@ -77,55 +73,21 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps } = contentHit; const description = formatted?.content?.htmlContent ?? ''; const displayName = formatted?.displayName ?? ''; - const tagCount = useMemo(() => { - if (!tags) { - return 0; - } - return (tags.level0?.length || 0) + (tags.level1?.length || 0) - + (tags.level2?.length || 0) + (tags.level3?.length || 0); - }, [tags]); - - const componentIcon = getItemIcon(blockType); return ( - - openComponentInfoSidebar(usageKey)} - onKeyDown={(e: React.KeyboardEvent) => { - if (['Enter', ' '].includes(e.key)) { - openComponentInfoSidebar(usageKey); - } - }} - > - - } - actions={( - - - - )} - /> - - - - - - {blockTypeDisplayName} - - - -
- -
- -
-
-
-
+ + + + )} + blockTypeDisplayName={blockTypeDisplayName} + openInfoSidebar={() => openComponentInfoSidebar(usageKey)} + /> ); }; diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index 4065826428..4b6eb6c647 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { CardGrid } from '@openedx/paragon'; +import { useLoadOnScroll } from '../../hooks'; import { useSearchContext } from '../../search-manager'; import { NoComponents, NoSearchResults } from '../EmptyStates'; import { useLibraryBlockTypes } from '../data/apiHooks'; @@ -43,26 +44,12 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => { return result; }, [blockTypesData]); - useEffect(() => { - if (variant === 'full') { - const onscroll = () => { - // Verify the position of the scroll to implementa a infinite scroll. - // Used `loadLimit` to fetch next page before reach the end of the screen. - const loadLimit = 300; - const scrolledTo = window.scrollY + window.innerHeight; - const scrollDiff = document.body.scrollHeight - scrolledTo; - const isNearToBottom = scrollDiff <= loadLimit; - if (isNearToBottom && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - window.addEventListener('scroll', onscroll); - return () => { - window.removeEventListener('scroll', onscroll); - }; - } - return () => {}; - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + useLoadOnScroll( + hasNextPage, + isFetchingNextPage, + fetchNextPage, + variant === 'full', + ); if (componentCount === 0) { return isFiltered ? : ; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 50c085b16f..344a35b8aa 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -6,6 +6,16 @@ const messages = defineMessages({ defaultMessage: 'Component actions menu', description: 'Alt/title text for the component card menu button.', }, + collectionCardMenuAlt: { + id: 'course-authoring.library-authoring.collection.menu', + defaultMessage: 'Collection actions menu', + description: 'Alt/title text for the collection card menu button.', + }, + collectionType: { + id: 'course-authoring.library-authoring.collection.type', + defaultMessage: 'Collection', + description: 'Collection type text', + }, menuEdit: { id: 'course-authoring.library-authoring.component.menu.edit', defaultMessage: 'Edit', diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index 0f9bdfbd5b..d086d6b565 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -21,16 +21,31 @@ const messages = defineMessages({ defaultMessage: 'No matching components found in this library.', description: 'Message displayed when no search results are found', }, + noSearchResultsCollections: { + id: 'course-authoring.library-authoring.no-search-results-collections', + defaultMessage: 'No matching collections found in this library.', + description: 'Message displayed when no matching collections are found', + }, noComponents: { id: 'course-authoring.library-authoring.no-components', defaultMessage: 'You have not added any content to this library yet.', description: 'Message displayed when the library is empty', }, + noCollections: { + id: 'course-authoring.library-authoring.no-collections', + defaultMessage: 'You have not added any collection to this library yet.', + description: 'Message displayed when the library has no collections', + }, addComponent: { id: 'course-authoring.library-authoring.add-component', defaultMessage: 'Add component', description: 'Button text to add a new component', }, + addCollection: { + id: 'course-authoring.library-authoring.add-collection', + defaultMessage: 'Add collection', + description: 'Button text to add a new collection', + }, homeTab: { id: 'course-authoring.library-authoring.home-tab', defaultMessage: 'Home', diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index d980a851d5..d1e925a91a 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -8,7 +8,9 @@ import React from 'react'; import { useSearchParams } from 'react-router-dom'; import { MeiliSearch, type Filter } from 'meilisearch'; -import { ContentHit, SearchSortOption, forceArray } from './data/api'; +import { + CollectionHit, ContentHit, SearchSortOption, forceArray, +} from './data/api'; import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks'; export interface SearchContextData { @@ -39,6 +41,8 @@ export interface SearchContextData { fetchNextPage: () => void; closeSearchModal: () => void; hasError: boolean; + collectionHits: CollectionHit[]; + totalCollectionHits: number; } const SearchContext = React.createContext(undefined); diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index d220787929..8c4cf183d3 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -83,7 +83,7 @@ function formatTagsFilter(tagsFilter?: string[]): string[] { /** * The tags that are associated with a search result, at various levels of the tag hierarchy. */ -interface ContentHitTags { +export interface ContentHitTags { taxonomy?: string[]; level0?: string[]; level1?: string[]; @@ -95,42 +95,60 @@ interface ContentHitTags { * Information about a single XBlock returned in the search results * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py */ -export interface ContentHit { +interface BaseContentHit { id: string; - usageKey: string; - type: 'course_block' | 'library_block'; - blockId: string; + type: 'course_block' | 'library_block' | 'collection'; displayName: string; - /** The block_type part of the usage key. What type of XBlock this is. */ - blockType: string; /** The course or library ID */ contextKey: string; org: string; + breadcrumbs: Array<{ displayName: string }>; + tags: ContentHitTags; + /** Same fields with ... highlights */ + formatted: { displayName: string, content?: ContentDetails, description?: string }; + created: number; + modified: number; +} + +/** + * Information about a single XBlock returned in the search results + * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py + */ +export interface ContentHit extends BaseContentHit { + usageKey: string; + blockId: string; + /** The block_type part of the usage key. What type of XBlock this is. */ + blockType: string; /** * Breadcrumbs: * - First one is the name of the course/library itself. * - After that is the name and usage key of any parent Section/Subsection/Unit/etc. */ breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>]; - tags: ContentHitTags; content?: ContentDetails; - /** Same fields with ... highlights */ - formatted: { displayName: string, content?: ContentDetails }; - created: number; - modified: number; lastPublished: number | null; } +/** + * Information about a single collection returned in the search results + * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py + */ +export interface CollectionHit extends BaseContentHit { + description: string; + componentCount?: number; +} + /** * Convert search hits to camelCase * @param hit A search result directly from Meilisearch */ -function formatSearchHit(hit: Record): ContentHit { +function formatSearchHit(hit: Record): ContentHit | CollectionHit { // eslint-disable-next-line @typescript-eslint/naming-convention const { _formatted, ...newHit } = hit; newHit.formatted = { displayName: _formatted.display_name, content: _formatted.content ?? {}, + description: _formatted.description, }; return camelCaseObject(newHit); } @@ -165,6 +183,8 @@ export async function fetchSearchResults({ totalHits: number, blockTypes: Record, problemTypes: Record, + collectionHits: CollectionHit[], + totalCollectionHits: number, }> { const queries: MultiSearchQuery[] = []; @@ -185,6 +205,8 @@ export async function fetchSearchResults({ ...problemTypesFilterFormatted, ].flat()]; + const collectionsFilter = 'type = "collection"'; + // First query is always to get the hits, with all the filters applied. queries.push({ indexUid: indexName, @@ -192,6 +214,7 @@ export async function fetchSearchResults({ filter: [ // top-level entries in the array are AND conditions and must all match // Inner arrays are OR conditions, where only one needs to match. + `NOT ${collectionsFilter}`, // exclude collections ...typeFilters, ...extraFilterFormatted, ...tagsFilterFormatted, @@ -219,13 +242,37 @@ export async function fetchSearchResults({ limit: 0, // We don't need any "hits" for this - just the facetDistribution }); + // Third query is to get the hits for collections, with all the filters applied. + queries.push({ + indexUid: indexName, + q: searchKeywords, + filter: [ + // top-level entries in the array are AND conditions and must all match + // Inner arrays are OR conditions, where only one needs to match. + collectionsFilter, // include only collections + ...extraFilterFormatted, + // We exclude the block type filter as collections are only of 1 type i.e. collection. + ...tagsFilterFormatted, + ], + attributesToHighlight: ['display_name', 'description'], + highlightPreTag: HIGHLIGHT_PRE_TAG, + highlightPostTag: HIGHLIGHT_POST_TAG, + attributesToCrop: ['description'], + cropLength: 15, + sort, + offset, + limit, + }); + const { results } = await client.multiSearch(({ queries })); return { - hits: results[0].hits.map(formatSearchHit), + hits: results[0].hits.map(formatSearchHit) as ContentHit[], totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length, blockTypes: results[1].facetDistribution?.block_type ?? {}, problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {}, - nextOffset: results[0].hits.length === limit ? offset + limit : undefined, + nextOffset: results[0].hits.length === limit || results[2].hits.length === limit ? offset + limit : undefined, + collectionHits: results[2].hits.map(formatSearchHit) as CollectionHit[], + totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? results[2].hits.length, }; } diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index f5af9a159a..703251e6ee 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -103,8 +103,14 @@ export const useContentSearchResults = ({ [pages], ); + const collectionHits = React.useMemo( + () => pages?.reduce((allHits, page) => [...allHits, ...page.collectionHits], []) ?? [], + [pages], + ); + return { hits, + collectionHits, // The distribution of block type filter options blockTypes: pages?.[0]?.blockTypes ?? {}, problemTypes: pages?.[0]?.problemTypes ?? {}, @@ -119,6 +125,7 @@ export const useContentSearchResults = ({ hasNextPage: query.hasNextPage, // The last page has the most accurate count of total hits totalHits: pages?.[pages.length - 1]?.totalHits ?? 0, + totalCollectionHits: pages?.[pages.length - 1]?.totalCollectionHits ?? 0, }; }; diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index 267a333ecb..0f716a9c3e 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -8,4 +8,4 @@ export { default as SearchSortWidget } from './SearchSortWidget'; export { default as Stats } from './Stats'; export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api'; -export type { ContentHit } from './data/api'; +export type { CollectionHit, ContentHit, ContentHitTags } from './data/api'; diff --git a/src/search-modal/SearchUI.test.tsx b/src/search-modal/SearchUI.test.tsx index 1574b68917..acb3b5efe3 100644 --- a/src/search-modal/SearchUI.test.tsx +++ b/src/search-modal/SearchUI.test.tsx @@ -100,9 +100,12 @@ describe('', () => { // because otherwise Instantsearch will update the UI and change the query, // leading to unexpected results in the test cases. mockResult.results[0].query = query; + mockResult.results[2].query = query; // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockResult; }); fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult); @@ -174,8 +177,8 @@ describe('', () => { expect(fetchMock).toHaveLastFetched((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const requestedFilter = requestData?.queries[0].filter; - return requestedFilter?.[1] === 'type = "course_block"' - && requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"'; + return requestedFilter?.[2] === 'type = "course_block"' + && requestedFilter?.[3] === 'context_key = "course-v1:org+test+123"'; }); // Now we should see the results: expect(queryByText('Enter a keyword')).toBeNull(); @@ -398,8 +401,9 @@ describe('', () => { expect(fetchMock).toHaveLastFetched((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const requestedFilter = requestData?.queries[0].filter; - // the filter is: ['type = "course_block"', 'context_key = "course-v1:org+test+123"'] - return (requestedFilter?.length === 3); + // the filter is: + // ['NOT type == "collection"', '', 'type = "course_block"', 'context_key = "course-v1:org+test+123"'] + return (requestedFilter?.length === 4); }); // Now we should see the results: expect(getByText('6 results found')).toBeInTheDocument(); @@ -425,6 +429,7 @@ describe('', () => { const requestData = JSON.parse(req.body?.toString() ?? ''); const requestedFilter = requestData?.queries[0].filter; return JSON.stringify(requestedFilter) === JSON.stringify([ + 'NOT type = "collection"', [ 'block_type = problem', 'content.problem_types = choiceresponse', @@ -458,6 +463,7 @@ describe('', () => { const requestData = JSON.parse(req.body?.toString() ?? ''); const requestedFilter = requestData?.queries?.[0]?.filter; return JSON.stringify(requestedFilter) === JSON.stringify([ + 'NOT type = "collection"', [], 'type = "course_block"', 'context_key = "course-v1:org+test+123"', @@ -493,6 +499,7 @@ describe('', () => { const requestData = JSON.parse(req.body?.toString() ?? ''); const requestedFilter = requestData?.queries?.[0]?.filter; return JSON.stringify(requestedFilter) === JSON.stringify([ + 'NOT type = "collection"', [], 'type = "course_block"', 'context_key = "course-v1:org+test+123"', diff --git a/src/search-modal/__mocks__/empty-search-result.json b/src/search-modal/__mocks__/empty-search-result.json index a0ba5d6db9..52c41bb57a 100644 --- a/src/search-modal/__mocks__/empty-search-result.json +++ b/src/search-modal/__mocks__/empty-search-result.json @@ -22,6 +22,15 @@ "block_type": {} }, "facetStats": {} + }, + { + "indexUid": "studio", + "hits": [], + "query": "noresult", + "processingTimeMs": 0, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 0 } ] } diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json index 6800f83065..85308e6d30 100644 --- a/src/search-modal/__mocks__/search-result.json +++ b/src/search-modal/__mocks__/search-result.json @@ -365,6 +365,15 @@ } }, "facetStats": {} + }, + { + "indexUid": "studio", + "hits": [], + "query": "learn", + "processingTimeMs": 1, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 0 } ] }