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