feat: Library v2 components tab (#1109)

This commit is contained in:
Chris Chávez
2024-07-22 20:04:57 -05:00
committed by GitHub
parent 3a14141a4e
commit 77135cde1d
30 changed files with 721 additions and 117 deletions

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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),

View File

@@ -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} />;
};

View File

@@ -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';

View File

@@ -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';

View 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',
};

View 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;
}
}

View 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';
}

View File

@@ -12,3 +12,4 @@
@import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/SortableItem";
@import "./block-type-utils";

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as libraryComponentsMock } from './libraryComponentsMock';

View 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',
},
];

View 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;
}
}
}

View 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;

View 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();
});
});

View 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;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as LibraryComponents } from './LibraryComponents';

View 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;

View File

@@ -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,

View File

@@ -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
*/

View File

@@ -0,0 +1 @@
@import "library-authoring/components/ComponentCard";

View File

@@ -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',

View File

@@ -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
View File