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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import React, {
|
||||
useContext, useEffect, useState, useRef,
|
||||
} from 'react';
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ jest.mock('@tanstack/react-query', () => ({
|
||||
},
|
||||
isSuccess: true,
|
||||
};
|
||||
} if (queryKey[0] === 'contentTaxonomyTagsCount') {
|
||||
} if (queryKey[0] === 'contentTagsCount') {
|
||||
return {
|
||||
data: 17,
|
||||
isSuccess: true,
|
||||
|
||||
@@ -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,
|
||||
2
src/generic/__mocks__/index.js
Normal file
2
src/generic/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as contentTagsCountMock } from './contentTagsCount';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user