feat: tag sections, subsections, and the whole course (FC-0053) (#879)

* feat: tag sections, subsections, and the whole course
* docs: add comments to useContentTagsCount
This commit is contained in:
Rômulo Penido
2024-03-28 09:14:29 -03:00
committed by GitHub
parent 80bf86992d
commit f57d40ea34
32 changed files with 671 additions and 560 deletions

View File

@@ -76,6 +76,15 @@ const ContentTagsDrawer = ({ id, onClose }) => {
} = useContentTaxonomyTagsData(contentId);
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
let contentName = '';
if (isContentDataLoaded) {
if ('displayName' in contentData) {
contentName = contentData.displayName;
} else {
contentName = contentData.courseDisplayNameWithDefault;
}
}
let onCloseDrawer = onClose;
if (onCloseDrawer === undefined) {
onCloseDrawer = () => {
@@ -129,7 +138,7 @@ const ContentTagsDrawer = ({ id, onClose }) => {
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentData.displayName }</h3>
? <h3>{ contentName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner

View File

@@ -30,6 +30,7 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
};
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getCourseContentDataApiURL = (contentId) => new URL(`/api/contentstore/v1/course_settings/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
@@ -74,9 +75,14 @@ export async function getContentTaxonomyTagsCount(contentId) {
* @returns {Promise<import("./types.mjs").ContentData>}
*/
export async function getContentData(contentId) {
const url = contentId.startsWith('lb:')
? getLibraryContentDataApiUrl(contentId)
: getXBlockContentDataApiURL(contentId);
let url;
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
} else if (contentId.startsWith('course-v1:')) {
url = getCourseContentDataApiURL(contentId);
} else {
url = getXBlockContentDataApiURL(contentId);
}
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

View File

@@ -11,7 +11,6 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
@@ -106,17 +105,6 @@ export const useContentTaxonomyTagsData = (contentId) => (
})
);
/**
* Build the query to get the count og taxonomy tags applied to the content object
* @param {string} contentId The ID of the content object to fetch the count of the applied tags for
*/
export const useContentTaxonomyTagsCount = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTagsCount', contentId],
queryFn: () => getContentTaxonomyTagsCount(contentId),
})
);
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
@@ -150,8 +138,13 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
onSettled: /* istanbul ignore next */ () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] });
let contentPattern;
if (contentId.includes('course-v1')) {
contentPattern = contentId;
} else {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
},
});
};

View File

@@ -6,7 +6,6 @@ import {
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
useContentTaxonomyTagsCount,
} from './apiHooks';
import { updateContentTaxonomyTags } from './api';
@@ -135,24 +134,6 @@ describe('useContentTaxonomyTagsData', () => {
});
});
describe('useContentTaxonomyTagsCount', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentTaxonomyTagsCount(contentId);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentTaxonomyTagsCount(contentId);
expect(result).toEqual({ isSuccess: false });
});
});
describe('useContentData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });

View File

@@ -30,7 +30,7 @@
*/
/**
* @typedef {Object} ContentData
* @typedef {Object} XBlockData
* @property {string} id
* @property {string} displayName
* @property {string} category
@@ -58,3 +58,12 @@
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
*/
/**
* @typedef {Object} CourseData
* @property {string} courseDisplayNameWithDefault
*/
/**
* @typedef {XBlockData | CourseData} ContentData
*/

View File

@@ -4,8 +4,8 @@ import { Stack } from '@openedx/paragon';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useContentTagsCount } from '../../generic/data/apiHooks';
import messages from '../messages';
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
import TagCount from '../../generic/tag-count';
const TagsSidebarHeader = () => {
@@ -13,9 +13,9 @@ const TagsSidebarHeader = () => {
const contentId = useParams().blockId;
const {
data: contentTaxonomyTagsCount,
isSuccess: isContentTaxonomyTagsCountLoaded,
} = useContentTaxonomyTagsCount(contentId || '');
data: contentTagsCount,
isSuccess: isContentTagsCountLoaded,
} = useContentTagsCount(contentId || '');
return (
<Stack
@@ -25,8 +25,8 @@ const TagsSidebarHeader = () => {
<h3 className="course-unit-sidebar-header-title m-0">
{intl.formatMessage(messages.tagsSidebarTitle)}
</h3>
{ isContentTaxonomyTagsCountLoaded
&& <TagCount count={contentTaxonomyTagsCount} />}
{ isContentTagsCountLoaded
&& <TagCount count={contentTagsCount} />}
</Stack>
);
};

View File

@@ -1,36 +1,39 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import TagsSidebarHeader from './TagsSidebarHeader';
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
jest.mock('../data/apiHooks', () => ({
useContentTaxonomyTagsCount: jest.fn(() => ({
isSuccess: false,
data: 17,
})),
const mockGetTagsCount = jest.fn();
jest.mock('../../generic/data/api', () => ({
...jest.requireActual('../../generic/data/api'),
getTagsCount: () => mockGetTagsCount(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ blockId: '123' }),
}));
const queryClient = new QueryClient();
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<TagsSidebarHeader />
<QueryClientProvider client={queryClient}>
<TagsSidebarHeader />
</QueryClientProvider>
</IntlProvider>
);
describe('<TagsSidebarHeader>', () => {
it('should not render count on loading', () => {
it('should render count only after query is complete', async () => {
let resolvePromise;
mockGetTagsCount.mockReturnValueOnce(new Promise((resolve) => { resolvePromise = resolve; }));
render(<RootWrapper />);
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
expect(screen.queryByText('17')).not.toBeInTheDocument();
});
it('should render count after query is complete', () => {
useContentTaxonomyTagsCount.mockReturnValue({
isSuccess: true,
data: 17,
});
render(<RootWrapper />);
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
expect(screen.getByText('17')).toBeInTheDocument();
resolvePromise({ 123: 17 });
expect(await screen.findByText('17')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
// @ts-check
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -52,7 +53,6 @@ import {
} from './drag-helper/utils';
import { useCourseOutline } from './hooks';
import messages from './messages';
import useUnitTagsCount from './data/apiHooks';
const CourseOutline = ({ courseId }) => {
const intl = useIntl();
@@ -113,7 +113,6 @@ const CourseOutline = ({ courseId }) => {
mfeProctoredExamSettingsUrl,
handleDismissNotification,
advanceSettingsUrl,
prevContainerInfo,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
@@ -133,27 +132,6 @@ const CourseOutline = ({ courseId }) => {
const { category } = useSelector(getCurrentItem);
const deleteCategory = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
const unitsIdPattern = useMemo(() => {
let pattern = '';
sections.forEach((section) => {
section.childInfo.children.forEach((subsection) => {
subsection.childInfo.children.forEach((unit) => {
if (pattern !== '') {
pattern += `,${unit.id}`;
} else {
pattern += unit.id;
}
});
});
});
return pattern;
}, [sections]);
const {
data: unitsTagCounts,
isSuccess: isUnitsTagCountsLoaded,
} = useUnitTagsCount(unitsIdPattern);
/**
* Move section to new index
* @param {any} currentIndex
@@ -268,7 +246,6 @@ const CourseOutline = ({ courseId }) => {
) : null}
</TransitionReplace>
<SubHeader
className="mt-5"
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={(
@@ -307,7 +284,6 @@ const CourseOutline = ({ courseId }) => {
items={sections}
setSections={setSections}
restoreSectionList={restoreSectionList}
prevContainerInfo={prevContainerInfo}
handleSectionDragAndDrop={handleSectionDragAndDrop}
handleSubsectionDragAndDrop={handleSubsectionDragAndDrop}
handleUnitDragAndDrop={handleUnitDragAndDrop}
@@ -319,7 +295,6 @@ const CourseOutline = ({ courseId }) => {
>
{sections.map((section, sectionIndex) => (
<SectionCard
id={section.id}
key={section.id}
section={section}
index={sectionIndex}
@@ -398,7 +373,6 @@ const CourseOutline = ({ courseId }) => {
onOrderChange={updateUnitOrderByIndex}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
/>
))}
</SortableContext>
@@ -482,6 +456,7 @@ const CourseOutline = ({ courseId }) => {
variant="danger"
icon={WarningIcon}
title={intl.formatMessage(messages.alertErrorTitle)}
description=""
aria-hidden="true"
/>
)}

View File

@@ -6,6 +6,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cloneDeep } from 'lodash';
import { closestCorners } from '@dnd-kit/core';
@@ -85,11 +86,13 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
jest.mock('./data/apiHooks', () => () => ({
data: {},
isSuccess: true,
jest.mock('./data/api', () => ({
...jest.requireActual('./data/api'),
getTagsCount: () => jest.fn().mockResolvedValue({}),
}));
const queryClient = new QueryClient();
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
// Since jsdom (used by jest) does not support getBoundingClientRect function
@@ -104,9 +107,11 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseOutline courseId={courseId} />
</IntlProvider>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<CourseOutline courseId={courseId} />
</IntlProvider>
</QueryClientProvider>
</AppProvider>
);

View File

@@ -4,4 +4,3 @@ export { default as courseBestPracticesMock } from './courseBestPractices';
export { default as courseLaunchMock } from './courseLaunch';
export { default as courseSectionMock } from './courseSection';
export { default as courseSubsectionMock } from './courseSubsection';
export { default as contentTagsCountMock } from './contentTagsCount';

View File

@@ -1,5 +1,7 @@
// @ts-check
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchParams } from 'react-router-dom';
import {
@@ -8,18 +10,22 @@ import {
Hyperlink,
Icon,
IconButton,
Sheet,
useToggle,
} from '@openedx/paragon';
import {
MoreVert as MoveVertIcon,
EditOutline as EditIcon,
} from '@openedx/paragon/icons';
import { useContentTagsCount } from '../../generic/data/apiHooks';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import TagCount from '../../generic/tag-count';
import { useEscapeClick } from '../../hooks';
import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils';
import CardStatus from './CardStatus';
import messages from './messages';
import TagCount from '../../generic/tag-count';
const CardHeader = ({
title,
@@ -28,7 +34,6 @@ const CardHeader = ({
hasChanges,
onClickPublish,
onClickConfigure,
onClickManageTags,
onClickMenuButton,
onClickEdit,
isFormOpen,
@@ -50,16 +55,18 @@ const CardHeader = ({
discussionEnabled,
discussionsSettings,
parentInfo,
tagsCount,
}) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
const [titleValue, setTitleValue] = useState(title);
const cardHeaderRef = useRef(null);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
const { data: contentTagCount } = useContentTagsCount(cardId);
useEffect(() => {
const locatorId = searchParams.get('show');
if (!locatorId) {
@@ -91,134 +98,148 @@ const CardHeader = ({
});
return (
<div
className="item-card-header"
data-testid={`${namePrefix}-card-header`}
ref={cardHeaderRef}
>
{isFormOpen ? (
<Form.Group className="m-0 w-75">
<Form.Control
data-testid={`${namePrefix}-edit-field`}
ref={(e) => e && e.focus()}
value={titleValue}
name="displayName"
onChange={(e) => setTitleValue(e.target.value)}
aria-label="edit field"
onBlur={() => onEditSubmit(titleValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onEditSubmit(titleValue);
}
}}
disabled={isDisabledEditField}
/>
</Form.Group>
) : (
<>
{titleComponent}
<IconButton
className="item-card-edit-icon"
data-testid={`${namePrefix}-edit-button`}
alt={intl.formatMessage(messages.altButtonEdit)}
iconAs={EditIcon}
onClick={onClickEdit}
/>
</>
)}
<div className="ml-auto d-flex">
{(isVertical || isSequential) && (
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
<>
<div
className="item-card-header"
data-testid={`${namePrefix}-card-header`}
ref={cardHeaderRef}
>
{isFormOpen ? (
<Form.Group className="m-0 w-75">
<Form.Control
data-testid={`${namePrefix}-edit-field`}
ref={(e) => e && e.focus()}
value={titleValue}
name="displayName"
onChange={(e) => setTitleValue(e.target.value)}
aria-label="edit field"
onBlur={() => onEditSubmit(titleValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onEditSubmit(titleValue);
}
}}
disabled={isDisabledEditField}
/>
</Form.Group>
) : (
<>
{titleComponent}
<IconButton
className="item-card-edit-icon"
data-testid={`${namePrefix}-edit-button`}
alt={intl.formatMessage(messages.altButtonEdit)}
iconAs={EditIcon}
onClick={onClickEdit}
/>
</>
)}
{ tagsCount > 0 && <TagCount count={tagsCount} onClick={onClickManageTags} /> }
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
<Dropdown.Toggle
className="item-card-header__menu"
id={`${namePrefix}-card-header__menu`}
data-testid={`${namePrefix}-card-header__menu-button`}
as={IconButton}
src={MoveVertIcon}
alt={`${namePrefix}-card-header__menu`}
iconAs={Icon}
/>
<Dropdown.Menu>
{isSequential && proctoringExamConfigurationLink && (
<div className="ml-auto d-flex">
{(isVertical || isSequential) && (
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
)}
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && contentTagCount > 0 && (
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
)}
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
<Dropdown.Toggle
className="item-card-header__menu"
id={`${namePrefix}-card-header__menu`}
data-testid={`${namePrefix}-card-header__menu-button`}
as={IconButton}
src={MoveVertIcon}
alt={`${namePrefix}-card-header__menu`}
iconAs={Icon}
/>
<Dropdown.Menu>
{isSequential && proctoringExamConfigurationLink && (
<Dropdown.Item
as={Hyperlink}
target="_blank"
destination={proctoringExamConfigurationLink}
href={proctoringExamConfigurationLink}
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
>
{intl.formatMessage(messages.menuProctoringLinkText)}
</Dropdown.Item>
)}
<Dropdown.Item
as={Hyperlink}
target="_blank"
destination={proctoringExamConfigurationLink}
href={proctoringExamConfigurationLink}
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
data-testid={`${namePrefix}-card-header__menu-publish-button`}
disabled={isDisabledPublish}
onClick={onClickPublish}
>
{intl.formatMessage(messages.menuProctoringLinkText)}
{intl.formatMessage(messages.menuPublish)}
</Dropdown.Item>
)}
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-publish-button`}
disabled={isDisabledPublish}
onClick={onClickPublish}
>
{intl.formatMessage(messages.menuPublish)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
</Dropdown.Item>
{onClickManageTags && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
onClick={onClickManageTags}
data-testid={`${namePrefix}-card-header__menu-configure-button`}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuManageTags)}
{intl.formatMessage(messages.menuConfigure)}
</Dropdown.Item>
)}
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.menuManageTags)}
</Dropdown.Item>
)}
{isVertical && enableCopyPasteUnits && (
<Dropdown.Item onClick={onClickCopy}>
{intl.formatMessage(messages.menuCopy)}
</Dropdown.Item>
)}
{actions.duplicable && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
onClick={onClickDuplicate}
>
{intl.formatMessage(messages.menuDuplicate)}
</Dropdown.Item>
)}
{actions.draggable && (
<>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
onClick={onClickMoveUp}
disabled={!actions.allowMoveUp}
>
{intl.formatMessage(messages.menuMoveUp)}
{isVertical && enableCopyPasteUnits && (
<Dropdown.Item onClick={onClickCopy}>
{intl.formatMessage(messages.menuCopy)}
</Dropdown.Item>
)}
{actions.duplicable && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
onClick={onClickMoveDown}
disabled={!actions.allowMoveDown}
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
onClick={onClickDuplicate}
>
{intl.formatMessage(messages.menuMoveDown)}
{intl.formatMessage(messages.menuDuplicate)}
</Dropdown.Item>
</>
)}
{actions.deletable && (
<Dropdown.Item
className="border-top border-light"
data-testid={`${namePrefix}-card-header__menu-delete-button`}
onClick={onClickDelete}
>
{intl.formatMessage(messages.menuDelete)}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
)}
{actions.draggable && (
<>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
onClick={onClickMoveUp}
disabled={!actions.allowMoveUp}
>
{intl.formatMessage(messages.menuMoveUp)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
onClick={onClickMoveDown}
disabled={!actions.allowMoveDown}
>
{intl.formatMessage(messages.menuMoveDown)}
</Dropdown.Item>
</>
)}
{actions.deletable && (
<Dropdown.Item
className="border-top border-light"
data-testid={`${namePrefix}-card-header__menu-delete-button`}
onClick={onClickDelete}
>
{intl.formatMessage(messages.menuDelete)}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</div>
</div>
</div>
<Sheet
position="right"
show={isManageTagsDrawerOpen}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
>
<ContentTagsDrawer
id={cardId}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
/>
</Sheet>
</>
);
};
@@ -231,8 +252,6 @@ CardHeader.defaultProps = {
discussionEnabled: false,
discussionsSettings: {},
parentInfo: {},
onClickManageTags: null,
tagsCount: undefined,
cardId: '',
};
@@ -243,7 +262,6 @@ CardHeader.propTypes = {
hasChanges: PropTypes.bool.isRequired,
onClickPublish: PropTypes.func.isRequired,
onClickConfigure: PropTypes.func.isRequired,
onClickManageTags: PropTypes.func,
onClickMenuButton: PropTypes.func.isRequired,
onClickEdit: PropTypes.func.isRequired,
isFormOpen: PropTypes.bool.isRequired,
@@ -278,7 +296,6 @@ CardHeader.propTypes = {
isTimeLimited: PropTypes.bool,
graded: PropTypes.bool,
}),
tagsCount: PropTypes.number,
};
export default CardHeader;

View File

@@ -2,7 +2,9 @@ import { MemoryRouter } from 'react-router-dom';
import {
act, render, fireEvent, waitFor, screen,
} from '@testing-library/react';
import { setConfig, getConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ITEM_BADGE_STATUS } from '../constants';
import CardHeader from './CardHeader';
@@ -18,9 +20,15 @@ const onClickDuplicateMock = jest.fn();
const onClickConfigureMock = jest.fn();
const onClickMoveUpMock = jest.fn();
const onClickMoveDownMock = jest.fn();
const onClickManageTagsMock = jest.fn();
const closeFormMock = jest.fn();
const mockGetTagsCount = jest.fn();
jest.mock('../../generic/data/api', () => ({
...jest.requireActual('../../generic/data/api'),
getTagsCount: () => mockGetTagsCount(),
}));
const cardHeaderProps = {
title: 'Some title',
status: ITEM_BADGE_STATUS.live,
@@ -29,7 +37,6 @@ const cardHeaderProps = {
onClickMenuButton: onClickMenuButtonMock,
onClickPublish: onClickPublishMock,
onClickEdit: onClickEditMock,
onClickManageTags: onClickManageTagsMock,
isFormOpen: false,
onEditSubmit: jest.fn(),
closeForm: closeFormMock,
@@ -49,6 +56,8 @@ const cardHeaderProps = {
},
};
const queryClient = new QueryClient();
const renderComponent = (props, entry = '/') => {
const titleComponent = (
<TitleButton
@@ -62,13 +71,15 @@ const renderComponent = (props, entry = '/') => {
return render(
<IntlProvider locale="en">
<MemoryRouter initialEntries={[entry]}>
<CardHeader
{...cardHeaderProps}
titleComponent={titleComponent}
{...props}
/>
</MemoryRouter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<CardHeader
{...cardHeaderProps}
titleComponent={titleComponent}
{...props}
/>
</MemoryRouter>
</QueryClientProvider>
</IntlProvider>,
);
};
@@ -170,14 +181,32 @@ describe('<CardHeader />', () => {
expect(onClickPublishMock).toHaveBeenCalled();
});
it('calls onClickManageTags when the menu is clicked', async () => {
it('only shows Manage tags menu if the waffle flag is enabled', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
renderComponent();
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
expect(screen.queryByText(messages.menuManageTags.defaultMessage)).not.toBeInTheDocument();
});
it('shows ContentTagsDrawer when the menu is clicked', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
renderComponent();
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
const manageTagsMenuItem = await screen.findByText(messages.menuManageTags.defaultMessage);
await act(async () => fireEvent.click(manageTagsMenuItem));
expect(onClickManageTagsMock).toHaveBeenCalled();
fireEvent.click(manageTagsMenuItem);
// Check if the drawer is open
expect(screen.getByTestId('drawer-close-button')).toBeInTheDocument();
});
it('calls onClickEdit when the button is clicked', async () => {
@@ -264,19 +293,33 @@ describe('<CardHeader />', () => {
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
});
it('should render tag count if is not zero', () => {
renderComponent({
...cardHeaderProps,
tagsCount: 17,
it('should render tag count if is not zero and the waffle flag is enabled', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
expect(screen.getByText('17')).toBeInTheDocument();
mockGetTagsCount.mockResolvedValue({ 12345: 17 });
renderComponent();
expect(await screen.findByText('17')).toBeInTheDocument();
});
it('shouldn render tag count if the waffle flag is disabled', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
mockGetTagsCount.mockResolvedValue({ 12345: 17 });
renderComponent();
expect(screen.queryByText('17')).not.toBeInTheDocument();
});
it('should not render tag count if is zero', () => {
renderComponent({
...cardHeaderProps,
tagsCount: 0,
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
mockGetTagsCount.mockResolvedValue({ 12345: 0 });
renderComponent();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
});

View File

@@ -29,7 +29,6 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
/**
* @typedef {Object} courseOutline
@@ -473,18 +472,3 @@ export async function dismissNotification(url) {
await getAuthenticatedHttpClient()
.delete(url);
}
/**
* Gets the tags count of multiple content by id separated by commas.
* @param {string} contentPattern
* @returns {Promise<Object>}
*/
export async function getTagsCount(contentPattern) {
if (contentPattern) {
const { data } = await getAuthenticatedHttpClient()
.get(getTagsCountApiUrl(contentPattern));
return data;
}
return null;
}

View File

@@ -1,40 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { contentTagsCountMock } from '../__mocks__';
import { getTagsCountApiUrl, getTagsCount } from './api';
let axiosMock;
describe('course outline api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should get tags count', async () => {
const pattern = 'this,is,a,pattern';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb06';
axiosMock.onGet().reply(200, contentTagsCountMock);
const result = await getTagsCount(pattern);
expect(axiosMock.history.get[0].url).toEqual(getTagsCountApiUrl(pattern));
expect(result).toEqual(contentTagsCountMock);
expect(contentTagsCountMock[contentId]).toEqual(15);
});
it('should get null on empty pattenr', async () => {
const result = await getTagsCount('');
expect(result).toEqual(null);
});
});

View File

@@ -1,16 +0,0 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { getTagsCount } from './api';
/**
* Builds the query to get tags count of a group of units.
* @param {string} contentPattern The IDs of units separated by commas.
*/
const useUnitTagsCount = (contentPattern) => (
useQuery({
queryKey: ['unitTagsCount', contentPattern],
queryFn: /* istanbul ignore next */ () => getTagsCount(contentPattern),
})
);
export default useUnitTagsCount;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
export const DragContext = React.createContext({});
export const DragContext = React.createContext({ activeId: '', overId: '', children: undefined });
const DragContextProvider = ({ activeId, overId, children }) => {
const contextValue = React.useMemo(() => ({

View File

@@ -1,3 +1,4 @@
// @ts-check
import React, {
useContext, useEffect, useState, useRef,
} from 'react';

View File

@@ -7,6 +7,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import SectionCard from './SectionCard';
@@ -34,30 +35,34 @@ const section = {
const onEditSectionSubmit = jest.fn();
const queryClient = new QueryClient();
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<SectionCard
section={section}
index={1}
canMoveItem={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
onNewSubsectionSubmit={jest.fn()}
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
>
<span>children</span>
</SectionCard>
</IntlProvider>,
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<SectionCard
section={section}
index={1}
canMoveItem={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
onNewSubsectionSubmit={jest.fn()}
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
>
<span>children</span>
</SectionCard>
</IntlProvider>
</QueryClientProvider>
</AppProvider>,
);

View File

@@ -2,16 +2,38 @@ import React, { useContext } from 'react';
import moment from 'moment/moment';
import PropTypes from 'prop-types';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform/config';
import {
Button, Hyperlink, Form, Stack,
Button, Hyperlink, Form, Sheet, Stack, useToggle,
} from '@openedx/paragon';
import { AppContext } from '@edx/frontend-platform/react';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import TagCount from '../../generic/tag-count';
import { useHelpUrls } from '../../help-urls/hooks';
import { VIDEO_SHARING_OPTIONS } from '../constants';
import { useContentTagsCount } from '../../generic/data/apiHooks';
import messages from './messages';
import { getVideoSharingOptionText } from '../utils';
const StatusBarItem = ({ title, children }) => (
<div className="d-flex flex-column justify-content-between">
<h5>{title}</h5>
<div className="d-flex align-items-center">
{children}
</div>
</div>
);
StatusBarItem.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node,
};
StatusBarItem.defaultProps = {
children: null,
};
const StatusBar = ({
statusBarData,
isLoading,
@@ -48,109 +70,135 @@ const StatusBar = ({
socialSharing: socialSharingUrl,
} = useHelpUrls(['contentHighlights', 'socialSharing']);
const { data: courseTagCount } = useContentTagsCount(courseId);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
return null;
}
return (
<Stack direction="horizontal" gap={3.5} className="d-flex align-items-stretch outline-status-bar" data-testid="outline-status-bar">
<div className="d-flex flex-column justify-content-between">
<h5>{intl.formatMessage(messages.startDateTitle)}</h5>
<Hyperlink
className="small"
destination={scheduleDestination()}
showLaunchIcon={false}
>
{courseReleaseDateObj.isValid() ? (
<FormattedDate
value={courseReleaseDateObj}
year="numeric"
month="short"
day="2-digit"
hour="numeric"
minute="numeric"
/>
) : courseReleaseDate}
</Hyperlink>
</div>
<div className="d-flex flex-column justify-content-between">
<h5>{intl.formatMessage(messages.pacingTypeTitle)}</h5>
<span className="small">
{isSelfPaced
? intl.formatMessage(messages.pacingTypeSelfPaced)
: intl.formatMessage(messages.pacingTypeInstructorPaced)}
</span>
</div>
<div className="d-flex flex-column justify-content-between">
<h5>{intl.formatMessage(messages.checklistTitle)}</h5>
<Hyperlink
className="small"
destination={checklistDestination()}
showLaunchIcon={false}
>
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
</Hyperlink>
</div>
<div className="d-flex flex-column justify-content-between">
<h5>{intl.formatMessage(messages.highlightEmailsTitle)}</h5>
<div className="d-flex align-items-center">
{highlightsEnabledForMessaging ? (
<span data-testid="highlights-enabled-span" className="small">
{intl.formatMessage(messages.highlightEmailsEnabled)}
</span>
) : (
<Button data-testid="highlights-enable-button" size="sm" onClick={openEnableHighlightsModal}>
{intl.formatMessage(messages.highlightEmailsButton)}
</Button>
)}
<>
<Stack direction="horizontal" gap={3.5} className="d-flex align-items-stretch outline-status-bar" data-testid="outline-status-bar">
<StatusBarItem title={intl.formatMessage(messages.startDateTitle)}>
<Hyperlink
className="small ml-2"
destination={contentHighlightsUrl}
target="_blank"
className="small"
destination={scheduleDestination()}
showLaunchIcon={false}
>
{intl.formatMessage(messages.highlightEmailsLink)}
{courseReleaseDateObj.isValid() ? (
<FormattedDate
value={courseReleaseDateObj}
year="numeric"
month="short"
day="2-digit"
hour="numeric"
minute="numeric"
/>
) : courseReleaseDate}
</Hyperlink>
</div>
</div>
{videoSharingEnabled && (
<Form.Group
size="sm"
className="d-flex flex-column justify-content-between m-0"
>
<Form.Label
className="h5"
>{intl.formatMessage(messages.videoSharingTitle)}
</Form.Label>
</StatusBarItem>
<StatusBarItem title={intl.formatMessage(messages.pacingTypeTitle)}>
<span className="small">
{isSelfPaced
? intl.formatMessage(messages.pacingTypeSelfPaced)
: intl.formatMessage(messages.pacingTypeInstructorPaced)}
</span>
</StatusBarItem>
<StatusBarItem title={intl.formatMessage(messages.checklistTitle)}>
<Hyperlink
className="small"
destination={checklistDestination()}
showLaunchIcon={false}
>
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
</Hyperlink>
</StatusBarItem>
<StatusBarItem title={intl.formatMessage(messages.highlightEmailsTitle)}>
<div className="d-flex align-items-center">
<Form.Control
as="select"
defaultValue={videoSharingOptions}
onChange={(e) => handleVideoSharingOptionChange(e.target.value)}
>
{Object.values(VIDEO_SHARING_OPTIONS).map((option) => (
<option
key={option}
value={option}
>
{getVideoSharingOptionText(option, messages, intl)}
</option>
))}
</Form.Control>
{highlightsEnabledForMessaging ? (
<span data-testid="highlights-enabled-span" className="small">
{intl.formatMessage(messages.highlightEmailsEnabled)}
</span>
) : (
<Button data-testid="highlights-enable-button" size="sm" onClick={openEnableHighlightsModal}>
{intl.formatMessage(messages.highlightEmailsButton)}
</Button>
)}
<Hyperlink
className="small"
destination={socialSharingUrl}
className="small ml-2"
destination={contentHighlightsUrl}
target="_blank"
showLaunchIcon={false}
>
{intl.formatMessage(messages.videoSharingLink)}
{intl.formatMessage(messages.highlightEmailsLink)}
</Hyperlink>
</div>
</Form.Group>
</StatusBarItem>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<StatusBarItem title={intl.formatMessage(messages.courseTagsTitle)}>
<div className="d-flex align-items-center">
<TagCount count={courseTagCount} />
{ /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ }
<a
className="small ml-2"
href="#"
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.courseManageTagsLink)}
</a>
</div>
</StatusBarItem>
)}
{videoSharingEnabled && (
<Form.Group
size="sm"
className="d-flex flex-column justify-content-between m-0"
>
<Form.Label
className="h5"
>{intl.formatMessage(messages.videoSharingTitle)}
</Form.Label>
<div className="d-flex align-items-center">
<Form.Control
as="select"
defaultValue={videoSharingOptions}
onChange={(e) => handleVideoSharingOptionChange(e.target.value)}
>
{Object.values(VIDEO_SHARING_OPTIONS).map((option) => (
<option
key={option}
value={option}
>
{getVideoSharingOptionText(option, messages, intl)}
</option>
))}
</Form.Control>
<Hyperlink
className="small"
destination={socialSharingUrl}
target="_blank"
showLaunchIcon={false}
>
{intl.formatMessage(messages.videoSharingLink)}
</Hyperlink>
</div>
</Form.Group>
)}
</Stack>
)}
</Stack>
<Sheet
position="right"
show={isManageTagsDrawerOpen}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
>
<ContentTagsDrawer
id={courseId}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
/>
</Sheet>
</>
);
};

View File

@@ -3,6 +3,8 @@ import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getConfig, setConfig } from '@edx/frontend-platform/config';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import StatusBar from './StatusBar';
import messages from './messages';
@@ -11,7 +13,7 @@ import { VIDEO_SHARING_OPTIONS } from '../constants';
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const courseId = 'course-v1:123';
const isLoading = false;
const openEnableHighlightsModalMock = jest.fn();
const handleVideoSharingOptionChange = jest.fn();
@@ -23,6 +25,11 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('../../generic/data/api', () => ({
...jest.requireActual('../../generic/data/api'),
getTagsCount: jest.fn().mockResolvedValue({ 'course-v1:123': 17 }),
}));
jest.mock('../../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'content-highlights-link',
@@ -45,18 +52,22 @@ const statusBarData = {
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
};
const queryClient = new QueryClient();
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<StatusBar
courseId={courseId}
isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
statusBarData={statusBarData}
{...props}
/>
</IntlProvider>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<StatusBar
courseId={courseId}
isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
statusBarData={statusBarData}
{...props}
/>
</IntlProvider>
</QueryClientProvider>
</AppProvider>,
);
@@ -133,4 +144,23 @@ describe('<StatusBar />', () => {
expect(queryByTestId('video-sharing-wrapper')).not.toBeInTheDocument();
});
it('renders the tag count if the waffle flag is enabled', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
const { findByText } = renderComponent();
expect(await findByText('17')).toBeInTheDocument();
});
it('doesnt renders the tag count if the waffle flag is disabled', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
const { queryByText } = renderComponent();
expect(queryByText('17')).not.toBeInTheDocument();
});
});

View File

@@ -41,6 +41,16 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.status-bar.highlight-emails.link',
defaultMessage: 'Learn more',
},
courseTagsTitle: {
id: 'course-authoring.course-outline.status-bar.course-tags',
defaultMessage: 'Course tags',
description: 'Course tags header in course outline',
},
courseManageTagsLink: {
id: 'course-authoring.course-outline.status-bar.course-manage-tags-link',
defaultMessage: 'Manage tags',
description: 'Opens the drawer to edit content tags',
},
videoSharingTitle: {
id: 'course-authoring.course-outline.status-bar.video-sharing.title',
defaultMessage: 'Video Sharing',

View File

@@ -1,4 +1,5 @@
import {
// @ts-check
import React, {
useContext, useEffect, useState, useRef,
} from 'react';
import PropTypes from 'prop-types';
@@ -165,6 +166,7 @@ const SubsectionCard = ({
<CardHeader
title={displayName}
status={subsectionStatus}
cardId={id}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}

View File

@@ -8,6 +8,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import SubsectionCard from './SubsectionCard';
@@ -52,36 +53,39 @@ const subsection = {
};
const onEditSubectionSubmit = jest.fn();
const queryClient = new QueryClient();
const renderComponent = (props, entry = '/') => render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[entry]}>
<IntlProvider locale="en">
<SubsectionCard
section={section}
subsection={subsection}
index={1}
isSelfPaced={false}
getPossibleMoves={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onNewUnitSubmit={jest.fn()}
isCustomRelativeDatesActive={false}
onEditClick={jest.fn()}
savingStatus=""
onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()}
namePrefix="subsection"
onOpenConfigureModal={jest.fn()}
onPasteClick={jest.fn()}
{...props}
>
<span>children</span>
</SubsectionCard>
</IntlProvider>,
</MemoryRouter>,
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<IntlProvider locale="en">
<SubsectionCard
section={section}
subsection={subsection}
index={1}
isSelfPaced={false}
getPossibleMoves={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onNewUnitSubmit={jest.fn()}
isCustomRelativeDatesActive={false}
onEditClick={jest.fn()}
savingStatus=""
onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()}
namePrefix="subsection"
onOpenConfigureModal={jest.fn()}
onPasteClick={jest.fn()}
{...props}
>
<span>children</span>
</SubsectionCard>
</IntlProvider>
</MemoryRouter>
</QueryClientProvider>
</AppProvider>,
);

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react';
// @ts-check
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useToggle, Sheet } from '@openedx/paragon';
import { useToggle } from '@openedx/paragon';
import { isEmpty } from 'lodash';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
@@ -11,7 +12,6 @@ import SortableItem from '../drag-helper/SortableItem';
import TitleLink from '../card-header/TitleLink';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import { ContentTagsDrawer } from '../../content-tags-drawer';
const UnitCard = ({
unit,
@@ -31,13 +31,11 @@ const UnitCard = ({
onOrderChange,
onCopyToClipboardClick,
discussionsSettings,
tagsCount,
}) => {
const currentRef = useRef(null);
const dispatch = useDispatch();
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'unit';
const [showManageTags, setShowManageTags] = useState(false);
const {
id,
@@ -129,77 +127,63 @@ const UnitCard = ({
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
return (
<>
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#fdfdfd',
...borderStyle,
}}
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#fdfdfd',
...borderStyle,
}}
>
<div
className="unit-card"
data-testid="unit-card"
ref={currentRef}
>
<div
className="unit-card"
data-testid="unit-card"
ref={currentRef}
>
<CardHeader
title={displayName}
status={unitStatus}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickConfigure={onOpenConfigureModal}
onClickManageTags={/* istanbul ignore next */ () => setShowManageTags(true)}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleUnitMoveUp}
onClickMoveDown={handleUnitMoveDown}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
isVertical
enableCopyPasteUnits={enableCopyPasteUnits}
onClickCopy={handleCopyClick}
discussionEnabled={discussionEnabled}
discussionsSettings={discussionsSettings}
parentInfo={parentInfo}
tagsCount={tagsCount}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={unit}
/>
</div>
</div>
</SortableItem>
<Sheet
position="right"
show={showManageTags}
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
>
<ContentTagsDrawer
id={id}
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
<CardHeader
title={displayName}
status={unitStatus}
hasChanges={hasChanges}
cardId={id}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickConfigure={onOpenConfigureModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleUnitMoveUp}
onClickMoveDown={handleUnitMoveDown}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
isVertical
enableCopyPasteUnits={enableCopyPasteUnits}
onClickCopy={handleCopyClick}
discussionEnabled={discussionEnabled}
discussionsSettings={discussionsSettings}
parentInfo={parentInfo}
/>
</Sheet>
</>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={unit}
/>
</div>
</div>
</SortableItem>
);
};
UnitCard.defaultProps = {
discussionsSettings: {},
tagsCount: undefined,
};
UnitCard.propTypes = {
@@ -256,7 +240,6 @@ UnitCard.propTypes = {
providerType: PropTypes.string,
enableGradedUnits: PropTypes.bool,
}),
tagsCount: PropTypes.number,
};
export default UnitCard;

View File

@@ -7,6 +7,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import UnitCard from './UnitCard';
@@ -49,29 +50,33 @@ const unit = {
isHeaderVisible: true,
};
const queryClient = new QueryClient();
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<UnitCard
section={section}
subsection={subsection}
unit={unit}
index={1}
getPossibleMoves={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
onCopyToClipboardClick={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}
getTitleLink={(id) => `/some/${id}`}
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
/>
</IntlProvider>,
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<UnitCard
section={section}
subsection={subsection}
unit={unit}
index={1}
getPossibleMoves={jest.fn()}
onOrderChange={jest.fn()}
onOpenPublishModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
onCopyToClipboardClick={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}
getTitleLink={(id) => `/some/${id}`}
isSelfPaced={false}
isCustomRelativeDatesActive={false}
{...props}
/>
</IntlProvider>
</QueryClientProvider>
</AppProvider>,
);

View File

@@ -69,7 +69,7 @@ jest.mock('@tanstack/react-query', () => ({
},
isSuccess: true,
};
} if (queryKey[0] === 'contentTaxonomyTagsCount') {
} if (queryKey[0] === 'contentTagsCount') {
return {
data: 17,
isSuccess: true,

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb01': 10,
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb02': 11,
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb03': 12,

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as contentTagsCountMock } from './contentTagsCount';

View File

@@ -8,6 +8,7 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
/**
* Get's organizations data. Returns list of organization names.
@@ -43,3 +44,18 @@ export async function createOrRerunCourse(courseData) {
);
return camelCaseObject(data);
}
/**
* Gets the tags count of multiple content by id separated by commas or a pattern using a '*' wildcard.
* @param {string} contentPattern
* @returns {Promise<Object>}
*/
export async function getTagsCount(contentPattern) {
if (contentPattern) {
const { data } = await getAuthenticatedHttpClient()
.get(getTagsCountApiUrl(contentPattern));
return data;
}
return null;
}

View File

@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { contentTagsCountMock } from '../__mocks__';
import {
createOrRerunCourse,
getApiBaseUrl,
@@ -9,6 +10,8 @@ import {
getCreateOrRerunCourseUrl,
getCourseRerunUrl,
getCourseRerun,
getTagsCount,
getTagsCountApiUrl,
} from './api';
let axiosMock;
@@ -72,4 +75,19 @@ describe('generic api calls', () => {
expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl());
expect(result).toEqual(courseRerunData);
});
it('should get tags count', async () => {
const pattern = 'this,is,a,pattern';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb06';
axiosMock.onGet().reply(200, contentTagsCountMock);
const result = await getTagsCount(pattern);
expect(axiosMock.history.get[0].url).toEqual(getTagsCountApiUrl(pattern));
expect(result).toEqual(contentTagsCountMock);
expect(contentTagsCountMock[contentId]).toEqual(15);
});
it('should get null on empty pattern', async () => {
const result = await getTagsCount('');
expect(result).toEqual(null);
});
});

View File

@@ -1,6 +1,6 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { getOrganizations } from './api';
import { getOrganizations, getTagsCount } from './api';
/**
* Builds the query to get a list of available organizations
@@ -12,4 +12,23 @@ export const useOrganizationListData = () => (
})
);
export default useOrganizationListData;
/**
* Builds the query to get tags count of the whole contentId course and
* returns the tags count of the specific contentId.
* @param {string} contentId
*/
export const useContentTagsCount = (contentId) => {
let contentPattern;
if (contentId.includes('course-v1')) {
// If the contentId is a course, we want to get the tags count only for the course
contentPattern = contentId;
} else {
// If the contentId is not a course, we want to get the tags count for all the content of the course
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
return useQuery({
queryKey: ['contentTagsCount', contentPattern],
queryFn: /* istanbul ignore next */ () => getTagsCount(contentPattern),
select: (data) => data[contentId] || 0, // Return the tags count of the specific contentId
});
};

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import useUnitTagsCount from './apiHooks';
import { useContentTagsCount } from './apiHooks';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
@@ -9,11 +9,11 @@ jest.mock('./api', () => ({
getTagsCount: jest.fn(),
}));
describe('useUnitTagsCount', () => {
describe('useContentTagsCount', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const pattern = '123';
const result = useUnitTagsCount(pattern);
const result = useContentTagsCount(pattern);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
@@ -21,7 +21,7 @@ describe('useUnitTagsCount', () => {
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const pattern = '123';
const result = useUnitTagsCount(pattern);
const result = useContentTagsCount(pattern);
expect(result).toEqual({ isSuccess: false });
});