diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index b4c151859d..a2c80f8b74 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle } from '@openedx/paragon'; import { getCourseSectionVertical } from '../data/selectors'; -import { COMPONENT_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import ComponentModalView from './add-component-modals/ComponentModalView'; import AddComponentButton from './add-component-btn'; import messages from './messages'; diff --git a/src/course-unit/add-component/AddComponent.test.jsx b/src/course-unit/add-component/AddComponent.test.jsx index 9bd5a5de04..f09378bf09 100644 --- a/src/course-unit/add-component/AddComponent.test.jsx +++ b/src/course-unit/add-component/AddComponent.test.jsx @@ -14,7 +14,7 @@ import { executeThunk } from '../../utils'; import { fetchCourseSectionVerticalData } from '../data/thunk'; import { getCourseSectionVerticalApiUrl } from '../data/api'; import { courseSectionVerticalMock } from '../__mocks__'; -import { COMPONENT_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import AddComponent from './AddComponent'; import messages from './messages'; diff --git a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx index 4ace3ea015..91cc5b09b1 100644 --- a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx +++ b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { EditNote as EditNoteIcon } from '@openedx/paragon/icons'; -import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../constants'; +import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants'; const AddComponentIcon = ({ type }) => { const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index b7e7bf5c6b..9ff040d63c 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -1,53 +1,6 @@ -import { - BackHand as BackHandIcon, - BookOpen as BookOpenIcon, - Edit as EditIcon, - EditNote as EditNoteIcon, - FormatListBulleted as FormatListBulletedIcon, - HelpOutline as HelpOutlineIcon, - LibraryAdd as LibraryIcon, - Lock as LockIcon, - QuestionAnswerOutline as QuestionAnswerOutlineIcon, - Science as ScienceIcon, - TextFields as TextFieldsIcon, - VideoCamera as VideoCameraIcon, -} from '@openedx/paragon/icons'; - import messages from './sidebar/messages'; import addComponentMessages from './add-component/messages'; -export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; - -export const COMPONENT_TYPES = { - advanced: 'advanced', - discussion: 'discussion', - library: 'library', - html: 'html', - openassessment: 'openassessment', - problem: 'problem', - video: 'video', - dragAndDrop: 'drag-and-drop-v2', -}; - -export const TYPE_ICONS_MAP = { - video: VideoCameraIcon, - other: BookOpenIcon, - vertical: FormatListBulletedIcon, - problem: EditIcon, - lock: LockIcon, -}; - -export const COMPONENT_TYPE_ICON_MAP = { - [COMPONENT_TYPES.advanced]: ScienceIcon, - [COMPONENT_TYPES.discussion]: QuestionAnswerOutlineIcon, - [COMPONENT_TYPES.library]: LibraryIcon, - [COMPONENT_TYPES.html]: TextFieldsIcon, - [COMPONENT_TYPES.openassessment]: EditNoteIcon, - [COMPONENT_TYPES.problem]: HelpOutlineIcon, - [COMPONENT_TYPES.video]: VideoCameraIcon, - [COMPONENT_TYPES.dragAndDrop]: BackHandIcon, -}; - export const getUnitReleaseStatus = (intl) => ({ release: intl.formatMessage(messages.releaseStatusTitle), released: intl.formatMessage(messages.releasedStatusTitle), diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx index 69830e4bde..9294d419f1 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx @@ -2,10 +2,10 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { BookOpen as BookOpenIcon } from '@openedx/paragon/icons'; -import { TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../constants'; +import { UNIT_TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../../generic/block-type-utils/constants'; const UnitIcon = ({ type }) => { - const icon = TYPE_ICONS_MAP[type] || BookOpenIcon; + const icon = UNIT_TYPE_ICONS_MAP[type] || BookOpenIcon; return ; }; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 394fd22e87..2d8f6221e8 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -16,7 +16,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem'; import { scrollToElement } from '../../course-outline/utils'; import { COURSE_BLOCK_NAMES } from '../../constants'; import { copyToClipboard } from '../../generic/data/thunks'; -import { COMPONENT_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import XBlockMessages from './xblock-messages/XBlockMessages'; import messages from './messages'; diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index ad8e09184b..0cdf05d4f6 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -16,7 +16,8 @@ import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api import { fetchCourseSectionVerticalData } from '../data/thunk'; import { executeThunk } from '../../utils'; import { getCourseId } from '../data/selectors'; -import { PUBLISH_TYPES, COMPONENT_TYPES } from '../constants'; +import { PUBLISH_TYPES } from '../constants'; +import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; import CourseXBlock from './CourseXBlock'; import messages from './messages'; diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts new file mode 100644 index 0000000000..9b6cee0993 --- /dev/null +++ b/src/generic/block-type-utils/constants.ts @@ -0,0 +1,69 @@ +import React from 'react'; +import { + BackHand as BackHandIcon, + BookOpen as BookOpenIcon, + Edit as EditIcon, + EditNote as EditNoteIcon, + FormatListBulleted as FormatListBulletedIcon, + HelpOutline as HelpOutlineIcon, + LibraryAdd as LibraryIcon, + Lock as LockIcon, + QuestionAnswerOutline as QuestionAnswerOutlineIcon, + Science as ScienceIcon, + TextFields as TextFieldsIcon, + VideoCamera as VideoCameraIcon, + Folder, +} from '@openedx/paragon/icons'; + +export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; + +export const COMPONENT_TYPES = { + advanced: 'advanced', + discussion: 'discussion', + library: 'library', + html: 'html', + openassessment: 'openassessment', + problem: 'problem', + video: 'video', + dragAndDrop: 'drag-and-drop-v2', +}; + +export const UNIT_TYPE_ICONS_MAP: Record = { + video: VideoCameraIcon, + other: BookOpenIcon, + vertical: FormatListBulletedIcon, + problem: EditIcon, + lock: LockIcon, +}; + +export const COMPONENT_TYPE_ICON_MAP: Record = { + [COMPONENT_TYPES.advanced]: ScienceIcon, + [COMPONENT_TYPES.discussion]: QuestionAnswerOutlineIcon, + [COMPONENT_TYPES.library]: LibraryIcon, + [COMPONENT_TYPES.html]: TextFieldsIcon, + [COMPONENT_TYPES.openassessment]: EditNoteIcon, + [COMPONENT_TYPES.problem]: HelpOutlineIcon, + [COMPONENT_TYPES.video]: VideoCameraIcon, + [COMPONENT_TYPES.dragAndDrop]: BackHandIcon, +}; + +export const STRUCTURAL_TYPE_ICONS: Record = { + vertical: UNIT_TYPE_ICONS_MAP.vertical, + sequential: Folder, + chapter: Folder, +}; + +export const COMPONENT_TYPE_STYLE_COLOR_MAP = { + [COMPONENT_TYPES.advanced]: 'component-style-other', + [COMPONENT_TYPES.discussion]: 'component-style-default', + [COMPONENT_TYPES.library]: 'component-style-default', + [COMPONENT_TYPES.html]: 'component-style-html', + [COMPONENT_TYPES.openassessment]: 'component-style-default', + [COMPONENT_TYPES.problem]: 'component-style-default', + [COMPONENT_TYPES.video]: 'component-style-video', + [COMPONENT_TYPES.dragAndDrop]: 'component-style-default', + vertical: 'component-style-vertical', + sequential: 'component-style-default', + chapter: 'component-style-default', + collection: 'component-style-collection', +}; diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss new file mode 100644 index 0000000000..a546d8ca6b --- /dev/null +++ b/src/generic/block-type-utils/index.scss @@ -0,0 +1,47 @@ +.component-style-default { + background-color: #005C9E; + + .pgn__icon { + color: white; + } +} + +.component-style-html { + background-color: #9747FF; + + .pgn__icon { + color: white; + } +} + +.component-style-collection { + background-color: #FFCD29; + + .pgn__icon { + color: black; + } +} + +.component-style-video { + background-color: #358F0A; + + .pgn__icon { + color: white; + } +} + +.component-style-vertical { + background-color: #0B8E77; + + .pgn__icon { + color: white; + } +} + +.component-style-other { + background-color: #646464; + + .pgn__icon { + color: white; + } +} diff --git a/src/generic/block-type-utils/index.tsx b/src/generic/block-type-utils/index.tsx new file mode 100644 index 0000000000..0204b8e016 --- /dev/null +++ b/src/generic/block-type-utils/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Article } from '@openedx/paragon/icons'; +import { + COMPONENT_TYPE_ICON_MAP, + STRUCTURAL_TYPE_ICONS, + COMPONENT_TYPE_STYLE_COLOR_MAP, +} from './constants'; + +export function getItemIcon(blockType: string): React.ComponentType { + return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; +} + +export function getComponentStyleColor(blockType: string): string { + return COMPONENT_TYPE_STYLE_COLOR_MAP[blockType] ?? 'bg-component'; +} diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 43a9973a41..be2da4fc84 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -12,3 +12,4 @@ @import "./modal-dropzone/ModalDropzone"; @import "./configure-modal/ConfigureModal"; @import "./drag-helper/SortableItem"; +@import "./block-type-utils"; diff --git a/src/index.scss b/src/index.scss index 912b40933f..381ca17082 100644 --- a/src/index.scss +++ b/src/index.scss @@ -29,6 +29,7 @@ @import "search-modal/SearchModal"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; +@import "library-authoring"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index fcd8c60b97..0e90e222f6 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -155,11 +155,12 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); const { - getByRole, getByText, queryByText, + getByRole, getByText, queryByText, findByText, } = render(); // Ensure the search endpoint is called - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + // One called for LibraryComponents and another called for components count + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('Content library')).toBeInTheDocument(); expect(getByText(libraryData.title)).toBeInTheDocument(); @@ -169,14 +170,13 @@ describe('', () => { expect(getByText('Recently Modified')).toBeInTheDocument(); expect(getByText('Collections (0)')).toBeInTheDocument(); expect(getByText('Components (6)')).toBeInTheDocument(); - expect(getByText('There are 6 components in this library')).toBeInTheDocument(); + expect(await findByText('Test HTML Block')).toBeInTheDocument(); // Navigate to the components tab fireEvent.click(getByRole('tab', { name: 'Components' })); expect(queryByText('Recently Modified')).not.toBeInTheDocument(); expect(queryByText('Collections (0)')).not.toBeInTheDocument(); expect(queryByText('Components (6)')).not.toBeInTheDocument(); - expect(getByText('There are 6 components in this library')).toBeInTheDocument(); // Navigate to the collections tab fireEvent.click(getByRole('tab', { name: 'Collections' })); @@ -192,7 +192,6 @@ describe('', () => { expect(getByText('Recently Modified')).toBeInTheDocument(); expect(getByText('Collections (0)')).toBeInTheDocument(); expect(getByText('Components (6)')).toBeInTheDocument(); - expect(getByText('There are 6 components in this library')).toBeInTheDocument(); }); it('show library without components', async () => { diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5247372756..8d5e2f7313 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -21,7 +21,7 @@ import Loading from '../generic/Loading'; import SubHeader from '../generic/sub-header/SubHeader'; import Header from '../header'; import NotFoundAlert from '../generic/NotFoundAlert'; -import LibraryComponents from './LibraryComponents'; +import LibraryComponents from './components/LibraryComponents'; import LibraryCollections from './LibraryCollections'; import LibraryHome from './LibraryHome'; import { useContentLibrary } from './data/apiHooks'; @@ -126,7 +126,7 @@ const LibraryAuthoringPage = () => { /> } + element={} /> { - const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords); - - if (componentCount === 0) { - return searchKeywords === '' ? : ; - } - - return ( -
- -
- ); -}; - -export default LibraryComponents; diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx index 1a79c05cf0..0c202b2cdc 100644 --- a/src/library-authoring/LibraryHome.tsx +++ b/src/library-authoring/LibraryHome.tsx @@ -6,9 +6,9 @@ import { import { NoComponents, NoSearchResults } from './EmptyStates'; import LibraryCollections from './LibraryCollections'; -import LibraryComponents from './LibraryComponents'; import { useLibraryComponentCount } from './data/apiHooks'; import messages from './messages'; +import { LibraryComponents } from './components'; const Section = ({ title, children } : { title: string, children: React.ReactNode }) => ( @@ -45,8 +45,8 @@ const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
-
- +
+
); diff --git a/src/library-authoring/__mocks__/index.js b/src/library-authoring/__mocks__/index.js new file mode 100644 index 0000000000..6d72558350 --- /dev/null +++ b/src/library-authoring/__mocks__/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as libraryComponentsMock } from './libraryComponentsMock'; diff --git a/src/library-authoring/__mocks__/libraryComponentsMock.js b/src/library-authoring/__mocks__/libraryComponentsMock.js new file mode 100644 index 0000000000..8f3dfa2a7f --- /dev/null +++ b/src/library-authoring/__mocks__/libraryComponentsMock.js @@ -0,0 +1,74 @@ +module.exports = [ + { + id: '1', + displayName: 'Text', + formatted: { + content: { + htmlContent: 'This is a text: ID=1', + }, + }, + tags: { + level0: ['1', '2', '3'], + }, + blockType: 'text', + }, + { + id: '2', + displayName: 'Text', + formatted: { + content: { + htmlContent: 'This is a text: ID=2', + }, + }, + tags: { + level0: ['1', '2', '3'], + }, + blockType: 'text', + }, + { + id: '3', + displayName: 'Video', + formatted: { + content: { + htmlContent: 'This is a video: ID=3', + }, + }, + tags: { + level0: ['1', '2'], + }, + blockType: 'video', + }, + { + id: '4', + displayName: 'Video', + formatted: { + content: { + htmlContent: 'This is a video: ID=4', + }, + }, + tags: { + level0: ['1', '2'], + }, + blockType: 'text', + }, + { + id: '5', + displayName: 'Problem', + formatted: { + content: { + htmlContent: 'This is a problem: ID=5', + }, + }, + blockType: 'problem', + }, + { + id: '6', + displayName: 'Problem', + formatted: { + content: { + htmlContent: 'This is a problem: ID=6', + }, + }, + blockType: 'problem', + }, +]; diff --git a/src/library-authoring/components/ComponentCard.scss b/src/library-authoring/components/ComponentCard.scss new file mode 100644 index 0000000000..cd39a690e5 --- /dev/null +++ b/src/library-authoring/components/ComponentCard.scss @@ -0,0 +1,24 @@ +.library-component-card { + .pgn__card { + height: 100%; + } + + .library-component-header { + border-top-left-radius: .375rem; + border-top-right-radius: .375rem; + padding: 0 .5rem 0 1.25rem; + + .library-component-header-icon { + width: 2.3rem; + height: 2.3rem; + } + + .pgn__card-header-content { + margin-top: .55rem; + } + + .pgn__card-header-actions { + margin: .25rem 0 .25rem 1rem; + } + } +} diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx new file mode 100644 index 0000000000..0789354491 --- /dev/null +++ b/src/library-authoring/components/ComponentCard.tsx @@ -0,0 +1,104 @@ +import React, { useMemo } from 'react'; +import { + ActionRow, + Card, + Container, + Icon, + IconButton, + Dropdown, + Stack, +} from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import TagCount from '../../generic/tag-count'; +import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; +import { ContentHit } from '../../search-modal/data/api'; +import Highlight from '../../search-modal/Highlight'; + +type ComponentCardProps = { + contentHit: ContentHit, + blockTypeDisplayName: string, +}; + +const ComponentCardMenu = () => ( + + + + + + + + + + + + + + +); + +const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => { + const { + blockType, + formatted, + tags, + } = 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 ( + + + + } + actions={( + + + + )} + /> + + + + + + {blockTypeDisplayName} + + + +
+ +
+ +
+
+
+
+ ); +}; + +export default ComponentCard; diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx new file mode 100644 index 0000000000..13687a2c09 --- /dev/null +++ b/src/library-authoring/components/LibraryComponents.test.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, fireEvent } from '@testing-library/react'; +import LibraryComponents from './LibraryComponents'; + +import initializeStore from '../../store'; +import { libraryComponentsMock } from '../__mocks__'; + +const mockUseLibraryComponents = jest.fn(); +const mockUseLibraryComponentCount = jest.fn(); +const mockUseLibraryBlockTypes = jest.fn(); +const mockFetchNextPage = jest.fn(); +let store; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const data = { + hits: [], + isFetching: true, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, +}; +const countData = { + componentCount: 1, + collectionCount: 0, +}; +const blockTypeData = { + data: [ + { + blockType: 'html', + displayName: 'Text', + }, + { + blockType: 'video', + displayName: 'Video', + }, + { + blockType: 'problem', + displayName: 'Problem', + }, + ], +}; + +jest.mock('../data/apiHooks', () => ({ + useLibraryComponents: () => mockUseLibraryComponents(), + useLibraryComponentCount: () => mockUseLibraryComponentCount(), + useLibraryBlockTypes: () => mockUseLibraryBlockTypes(), +})); + +const RootWrapper = (props) => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + mockUseLibraryComponents.mockReturnValue(data); + mockUseLibraryComponentCount.mockReturnValue(countData); + mockUseLibraryBlockTypes.mockReturnValue(blockTypeData); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render empty state', async () => { + mockUseLibraryComponentCount.mockReturnValueOnce({ + ...countData, + componentCount: 0, + }); + render(); + expect(await screen.findByText(/you have not added any content to this library yet\./i)); + }); + + it('should render components in full variant', async () => { + mockUseLibraryComponents.mockReturnValue({ + ...data, + hits: libraryComponentsMock, + isFetching: false, + }); + render(); + + expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument(); + expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument(); + expect(screen.getByText('This is a video: ID=3')).toBeInTheDocument(); + expect(screen.getByText('This is a video: ID=4')).toBeInTheDocument(); + expect(screen.getByText('This is a problem: ID=5')).toBeInTheDocument(); + expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument(); + }); + + it('should render components in preview variant', async () => { + mockUseLibraryComponents.mockReturnValue({ + ...data, + hits: libraryComponentsMock, + isFetching: false, + }); + render(); + + expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument(); + expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument(); + expect(screen.getByText('This is a video: ID=3')).toBeInTheDocument(); + expect(screen.getByText('This is a video: ID=4')).toBeInTheDocument(); + expect(screen.queryByText('This is a problem: ID=5')).not.toBeInTheDocument(); + expect(screen.queryByText('This is a problem: ID=6')).not.toBeInTheDocument(); + }); + + it('should call `fetchNextPage` on scroll to bottom in full variant', async () => { + mockUseLibraryComponents.mockReturnValue({ + ...data, + hits: libraryComponentsMock, + isFetching: false, + hasNextPage: true, + }); + + render(); + + Object.defineProperty(window, 'innerHeight', { value: 800 }); + Object.defineProperty(document.body, 'scrollHeight', { value: 1600 }); + + fireEvent.scroll(window, { target: { scrollY: 1000 } }); + + expect(mockFetchNextPage).toHaveBeenCalled(); + }); + + it('should not call `fetchNextPage` on croll to bottom in preview variant', async () => { + mockUseLibraryComponents.mockReturnValue({ + ...data, + hits: libraryComponentsMock, + isFetching: false, + hasNextPage: true, + }); + + render(); + + Object.defineProperty(window, 'innerHeight', { value: 800 }); + Object.defineProperty(document.body, 'scrollHeight', { value: 1600 }); + + fireEvent.scroll(window, { target: { scrollY: 1000 } }); + + expect(mockFetchNextPage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx new file mode 100644 index 0000000000..b2e7ed68b1 --- /dev/null +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useMemo } from 'react'; + +import { CardGrid } from '@openedx/paragon'; +import { NoComponents, NoSearchResults } from '../EmptyStates'; +import { useLibraryBlockTypes, useLibraryComponentCount, useLibraryComponents } from '../data/apiHooks'; +import ComponentCard from './ComponentCard'; + +type LibraryComponentsProps = { + libraryId: string, + filter: { + searchKeywords: string, + }, + variant: string, +}; + +/** + * Library Components to show components grid + * + * Use style to: + * - 'full': Show all components with Infinite scroll pagination. + * - 'preview': Show first 4 components without pagination. + */ +const LibraryComponents = ({ + libraryId, + filter: { searchKeywords }, + variant, +}: LibraryComponentsProps) => { + const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords); + const { + hits, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useLibraryComponents(libraryId, searchKeywords); + + const componentList = variant === 'preview' ? hits.slice(0, 4) : hits; + + // TODO add this to LibraryContext + const { data: blockTypesData } = useLibraryBlockTypes(libraryId); + const blockTypes = useMemo(() => { + const result = {}; + if (blockTypesData) { + blockTypesData.forEach(blockType => { + result[blockType.blockType] = blockType; + }); + } + 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]); + + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + + return ( + + { componentList.map((contentHit) => ( + + )) } + + ); +}; + +export default LibraryComponents; diff --git a/src/library-authoring/components/index.ts b/src/library-authoring/components/index.ts new file mode 100644 index 0000000000..63c42720e0 --- /dev/null +++ b/src/library-authoring/components/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as LibraryComponents } from './LibraryComponents'; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts new file mode 100644 index 0000000000..1e80f26c73 --- /dev/null +++ b/src/library-authoring/components/messages.ts @@ -0,0 +1,25 @@ +import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n'; +import type { defineMessages as defineMessagesType } from 'react-intl'; + +// frontend-platform currently doesn't provide types... do it ourselves. +const defineMessages = _defineMessages as typeof defineMessagesType; + +const messages = defineMessages({ + menuEdit: { + id: 'course-authoring.library-authoring.component.menu.edit', + defaultMessage: 'Edit', + description: 'Menu item for edit a component.', + }, + menuCopyToClipboard: { + id: 'course-authoring.library-authoring.component.menu.copy', + defaultMessage: 'Copy to Clipboard', + description: 'Menu item for copy a component.', + }, + menuAddToCollection: { + id: 'course-authoring.library-authoring.component.menu.add', + defaultMessage: 'Add to Collection', + description: 'Menu item for add a component to collection.', + }, +}); + +export default messages; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 37eb4eb3de..a0129b5c16 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -7,6 +7,10 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; * Get the URL for the content library API. */ export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`; +/** + * Get the URL for get block types of library. + */ +export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`; /** * Get the URL for create content in library. */ @@ -32,6 +36,22 @@ export interface ContentLibrary { license: string; } +export interface LibraryBlockType { + blockType: string; + displayName: string; +} + +/** + * Fetch block types of a library + */ +export async function getLibraryBlockTypes(libraryId?: string): Promise { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); + return camelCaseObject(data); +} export interface LibrariesV2Response { next: string | null, previous: string | null, diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index d2b4cbd802..fe87357efa 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -5,8 +5,9 @@ import { MeiliSearch } from 'meilisearch'; import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; import { type GetLibrariesV2CustomParams, - createLibraryBlock, getContentLibrary, + getLibraryBlockTypes, + createLibraryBlock, getContentLibraryV2List, } from './api'; @@ -21,6 +22,12 @@ export const libraryAuthoringQueryKeys = { 'list', ...(customParams ? [customParams] : []), ], + contentLibraryBlockTypes: (contentLibraryId?: string) => [ + ...libraryAuthoringQueryKeys.all, + ...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId), + 'content', + 'libraryBlockTypes', + ], }; /** @@ -33,6 +40,40 @@ export const useContentLibrary = (libraryId?: string) => ( }) ); +/** + * Hook to fetch block types of a library. + */ +export const useLibraryBlockTypes = (libraryId) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.contentLibraryBlockTypes(libraryId), + queryFn: () => getLibraryBlockTypes(libraryId), + }) +); + +/** + * Hook to fetch components in a library. + */ +export const useLibraryComponents = (libraryId: string, searchKeywords: string) => { + const { data: connectionDetails } = useContentSearchConnection(); + + const indexName = connectionDetails?.indexName; + const client = React.useMemo(() => { + if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { + return undefined; + } + return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); + }, [connectionDetails?.apiKey, connectionDetails?.url]); + + const libFilter = `context_key = "${libraryId}"`; + + return useContentSearchResults({ + client, + indexName, + searchKeywords, + extraFilter: [libFilter], + }); +}; + /** * Use this mutation to create a block in a library */ diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss new file mode 100644 index 0000000000..87c22f838e --- /dev/null +++ b/src/library-authoring/index.scss @@ -0,0 +1 @@ +@import "library-authoring/components/ComponentCard"; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index d127be9ed4..88116c620b 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -60,10 +60,15 @@ const messages = defineMessages({ defaultMessage: 'Coming soon!', description: 'Temp placeholder for the collections container. This will be replaced with the actual collection list.', }, - recentComponentsTempPlaceholder: { - id: 'course-authoring.library-authoring.recent-components-temp-placeholder', - defaultMessage: 'Recently modified components and collections will be displayed here.', - description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.', + createLibrary: { + id: 'course-authoring.library-authoring.create-library', + defaultMessage: 'Create library', + description: 'Header for the create library form', + }, + createLibraryTempPlaceholder: { + id: 'course-authoring.library-authoring.create-library-temp-placeholder', + defaultMessage: 'This is a placeholder for the create library form. This will be replaced with the actual form.', + description: 'Temp placeholder for the create library container. This will be replaced with the new library form.', }, recentlyModifiedTitle: { id: 'course-authoring.library-authoring.recently-modified-title', @@ -80,6 +85,11 @@ const messages = defineMessages({ defaultMessage: 'Components ({componentCount})', description: 'Title for the components container', }, + recentComponentsTempPlaceholder: { + id: 'course-authoring.library-authoring.recent-components-temp-placeholder', + defaultMessage: 'Recently modified components and collections will be displayed here.', + description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.', + }, addContentTitle: { id: 'course-authoring.library-authoring.drawer.title.add-content', defaultMessage: 'Add Content', diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx index 1fe86751fe..9075fbc389 100644 --- a/src/search-modal/SearchResult.tsx +++ b/src/search-modal/SearchResult.tsx @@ -6,31 +6,17 @@ import { IconButton, Stack, } from '@openedx/paragon'; -import { - Article, - Folder, - OpenInNew, -} from '@openedx/paragon/icons'; +import { OpenInNew } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { constructLibraryAuthoringURL } from '../utils'; -import { COMPONENT_TYPE_ICON_MAP, TYPE_ICONS_MAP } from '../course-unit/constants'; import { getStudioHomeData } from '../studio-home/data/selectors'; import { useSearchContext } from './manager/SearchManager'; import type { ContentHit } from './data/api'; import Highlight from './Highlight'; import messages from './messages'; - -const STRUCTURAL_TYPE_ICONS: Record = { - vertical: TYPE_ICONS_MAP.vertical, - sequential: Folder, - chapter: Folder, -}; - -function getItemIcon(blockType: string): React.ComponentType { - return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; -} +import { getItemIcon } from '../generic/block-type-utils'; /** * Returns the URL Suffix for library/library component hit diff --git a/webpack.dev-tutor.config.js b/webpack.dev-tutor.config.js new file mode 100755 index 0000000000..e69de29bb2