feat: Library v2 components tab (#1109)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 <Icon src={icon} screenReaderText={type} />;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
69
src/generic/block-type-utils/constants.ts
Normal file
69
src/generic/block-type-utils/constants.ts
Normal file
@@ -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<string, React.ComponentType> = {
|
||||
video: VideoCameraIcon,
|
||||
other: BookOpenIcon,
|
||||
vertical: FormatListBulletedIcon,
|
||||
problem: EditIcon,
|
||||
lock: LockIcon,
|
||||
};
|
||||
|
||||
export const COMPONENT_TYPE_ICON_MAP: Record<string, React.ComponentType> = {
|
||||
[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<string, React.ComponentType> = {
|
||||
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',
|
||||
};
|
||||
47
src/generic/block-type-utils/index.scss
Normal file
47
src/generic/block-type-utils/index.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
15
src/generic/block-type-utils/index.tsx
Normal file
15
src/generic/block-type-utils/index.tsx
Normal file
@@ -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';
|
||||
}
|
||||
@@ -12,3 +12,4 @@
|
||||
@import "./modal-dropzone/ModalDropzone";
|
||||
@import "./configure-modal/ConfigureModal";
|
||||
@import "./drag-helper/SortableItem";
|
||||
@import "./block-type-utils";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -155,11 +155,12 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
const {
|
||||
getByRole, getByText, queryByText,
|
||||
getByRole, getByText, queryByText, findByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// 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('<LibraryAuthoringPage />', () => {
|
||||
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('<LibraryAuthoringPage />', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
<Route
|
||||
path={TabList.components}
|
||||
element={<LibraryComponents libraryId={libraryId} filter={{ searchKeywords }} />}
|
||||
element={<LibraryComponents libraryId={libraryId} filter={{ searchKeywords }} variant="full" />}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.collections}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import { useLibraryComponentCount } from './data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
type LibraryComponentsProps = {
|
||||
libraryId: string;
|
||||
filter: {
|
||||
searchKeywords: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LibraryComponents = ({ libraryId, filter: { searchKeywords } }: LibraryComponentsProps) => {
|
||||
const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords);
|
||||
|
||||
if (componentCount === 0) {
|
||||
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex my-6 justify-content-center">
|
||||
<FormattedMessage
|
||||
{...messages.componentsTempPlaceholder}
|
||||
values={{ componentCount }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryComponents;
|
||||
@@ -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 }) => (
|
||||
<Card>
|
||||
@@ -45,8 +45,8 @@ const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => {
|
||||
<Section title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}>
|
||||
<LibraryCollections />
|
||||
</Section>
|
||||
<Section title={intl.formatMessage(messages.componentsTitle, { componentCount })}>
|
||||
<LibraryComponents libraryId={libraryId} filter={filter} />
|
||||
<Section title={`Components (${componentCount})`}>
|
||||
<LibraryComponents libraryId={libraryId} filter={filter} variant="preview" />
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
2
src/library-authoring/__mocks__/index.js
Normal file
2
src/library-authoring/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as libraryComponentsMock } from './libraryComponentsMock';
|
||||
74
src/library-authoring/__mocks__/libraryComponentsMock.js
Normal file
74
src/library-authoring/__mocks__/libraryComponentsMock.js
Normal file
@@ -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',
|
||||
},
|
||||
];
|
||||
24
src/library-authoring/components/ComponentCard.scss
Normal file
24
src/library-authoring/components/ComponentCard.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/library-authoring/components/ComponentCard.tsx
Normal file
104
src/library-authoring/components/ComponentCard.tsx
Normal file
@@ -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 = () => (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
as={IconButton}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage
|
||||
{...messages.menuEdit}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage
|
||||
{...messages.menuCopyToClipboard}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage
|
||||
{...messages.menuAddToCollection}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Container className="library-component-card">
|
||||
<Card>
|
||||
<Card.Header
|
||||
className={`library-component-header ${getComponentStyleColor(blockType)}`}
|
||||
title={
|
||||
<Icon src={componentIcon} className="library-component-header-icon" />
|
||||
}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<ComponentCardMenu />
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Body>
|
||||
<Card.Section>
|
||||
<Stack direction="horizontal" className="d-flex justify-content-between">
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Icon src={componentIcon} size="sm" />
|
||||
<span className="small">{blockTypeDisplayName}</span>
|
||||
</Stack>
|
||||
<TagCount count={tagCount} />
|
||||
</Stack>
|
||||
<div className="text-truncate h3 mt-2">
|
||||
<Highlight text={displayName} />
|
||||
</div>
|
||||
<Highlight text={description} />
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentCard;
|
||||
165
src/library-authoring/components/LibraryComponents.test.tsx
Normal file
165
src/library-authoring/components/LibraryComponents.test.tsx
Normal file
@@ -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) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LibraryComponents libraryId="1" filter={{ searchKeywords: '' }} {...props} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<LibraryComponents />', () => {
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper variant="full" />);
|
||||
|
||||
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(<RootWrapper variant="preview" />);
|
||||
|
||||
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(<RootWrapper variant="full" />);
|
||||
|
||||
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(<RootWrapper variant="preview" />);
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800 });
|
||||
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
|
||||
|
||||
fireEvent.scroll(window, { target: { scrollY: 1000 } });
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
96
src/library-authoring/components/LibraryComponents.tsx
Normal file
96
src/library-authoring/components/LibraryComponents.tsx
Normal file
@@ -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 === '' ? <NoComponents /> : <NoSearchResults />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardGrid
|
||||
columnSizes={{
|
||||
sm: 12,
|
||||
md: 5,
|
||||
lg: 4,
|
||||
xl: 3,
|
||||
}}
|
||||
hasEqualColumnHeights
|
||||
>
|
||||
{ componentList.map((contentHit) => (
|
||||
<ComponentCard
|
||||
key={contentHit.id}
|
||||
contentHit={contentHit}
|
||||
blockTypeDisplayName={blockTypes[contentHit.blockType]?.displayName ?? ''}
|
||||
/>
|
||||
)) }
|
||||
</CardGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryComponents;
|
||||
2
src/library-authoring/components/index.ts
Normal file
2
src/library-authoring/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as LibraryComponents } from './LibraryComponents';
|
||||
25
src/library-authoring/components/messages.ts
Normal file
25
src/library-authoring/components/messages.ts
Normal file
@@ -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;
|
||||
@@ -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<LibraryBlockType[]> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
1
src/library-authoring/index.scss
Normal file
1
src/library-authoring/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "library-authoring/components/ComponentCard";
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, React.ComponentType> = {
|
||||
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
|
||||
|
||||
0
webpack.dev-tutor.config.js
Executable file
0
webpack.dev-tutor.config.js
Executable file
Reference in New Issue
Block a user