Compare commits

...

6 Commits

Author SHA1 Message Date
Muhammad Abdullah Waheed
beb5f51e47 refactor: updated data dog config 2024-04-04 12:32:41 +05:00
Muhammad Abdullah Waheed
6a115797e6 refactor: testing datadog logging 2024-03-29 22:15:10 +05:00
Rômulo Penido
f57d40ea34 feat: tag sections, subsections, and the whole course (FC-0053) (#879)
* feat: tag sections, subsections, and the whole course
* docs: add comments to useContentTagsCount
2024-03-28 17:44:29 +05:30
Kristin Aoki
80bf86992d fix: transcript and thumbnail uploads (#914)
* fix: transcript and thumbnail uploads

* chore: add missing tests
2024-03-25 10:02:09 -04:00
Yusuf Musleh
1dde30a0a2 [FC-0036] feat: Make tags widget keyboard accessible (#900)
Adds the ability to navigate the new "Add Tags" widget using the keyboard, making it fully accessible through the keyboard.
2024-03-21 17:26:22 +05:30
Braden MacDonald
9a6e12bd3b Clean up Taxonomy API files/hooks/queries [FC-0036] (#850)
* chore: rename apiHooks.jsx to apihooks.js

* refactor: consolidate taxonomy API code

* fix: was not invalidating tags after import

* fix: UI was freezing while computing plan for large import files
2024-03-20 09:31:10 +05:30
72 changed files with 1922 additions and 1553 deletions

31
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "AGPL-3.0",
"dependencies": {
"@datadog/browser-rum": "^5.13.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -2323,6 +2324,36 @@
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@datadog/browser-core": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/@datadog/browser-core/-/browser-core-5.13.0.tgz",
"integrity": "sha512-5WciDj4IqpfaFZViJNXxovmDQiwoPZ/UWq4WMW7YafG22XNjrc6XbL5PWuAaG6fqcYFW0peE8g56ji5O78vMSA=="
},
"node_modules/@datadog/browser-rum": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/@datadog/browser-rum/-/browser-rum-5.13.0.tgz",
"integrity": "sha512-3xRyKp4rnMWMNhoy/pd8uDpQsNO3dh3nivNk5MzaxKo+mLsJUGFtj9nIq/jN38jl1tOEFbFMcgvCo15bkdHeQw==",
"dependencies": {
"@datadog/browser-core": "5.13.0",
"@datadog/browser-rum-core": "5.13.0"
},
"peerDependencies": {
"@datadog/browser-logs": "5.13.0"
},
"peerDependenciesMeta": {
"@datadog/browser-logs": {
"optional": true
}
}
},
"node_modules/@datadog/browser-rum-core": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/@datadog/browser-rum-core/-/browser-rum-core-5.13.0.tgz",
"integrity": "sha512-MbDbg+ciQlYd2c0gE21Jw1P4R/liLIzPQW96iJGpUBG67ScfYjKyNS0GnKtLwak9rcQJInv3FODhgr2DUfm88w==",
"dependencies": {
"@datadog/browser-core": "5.13.0"
}
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",

View File

@@ -36,6 +36,7 @@
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
},
"dependencies": {
"@datadog/browser-rum": "^5.13.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",

View File

@@ -1,3 +1,4 @@
import { Ref } from 'react';
import type {} from 'react-select/base';
// This import is necessary for module augmentation.
// It allows us to extend the 'Props' interface in the 'react-select/base' module
@@ -16,6 +17,9 @@ export interface TaxonomySelectProps {
appliedContentTagsTree: Record<string, TagTreeEntry>;
stagedContentTagsTree: Record<string, TagTreeEntry>;
checkedTags: string[];
selectCancelRef: Ref,
selectAddRef: Ref,
selectInlineAddRef: Ref,
handleCommitStagedTags: () => void;
handleCancelStagedTags: () => void;
handleSelectableBoxChange: React.ChangeEventHandler;

View File

@@ -41,6 +41,8 @@ const CustomMenu = (props) => {
handleCommitStagedTags,
handleCancelStagedTags,
searchTerm,
selectCancelRef,
selectAddRef,
value,
} = props.selectProps;
const intl = useIntl();
@@ -56,6 +58,7 @@ const CustomMenu = (props) => {
className="taxonomy-tags-selectable-box-set"
onChange={handleSelectableBoxChange}
value={checkedTags}
tabIndex="-1"
>
<ContentTagsDropDownSelector
key={`selector-${taxonomyId}`}
@@ -70,6 +73,8 @@ const CustomMenu = (props) => {
<div className="d-flex flex-row justify-content-end">
<div className="d-inline">
<Button
tabIndex="0"
ref={selectCancelRef}
variant="tertiary"
className="cancel-add-tags-button"
onClick={handleCancelStagedTags}
@@ -77,6 +82,8 @@ const CustomMenu = (props) => {
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
</Button>
<Button
tabIndex="0"
ref={selectAddRef}
variant="tertiary"
className="text-info-500 add-tags-button"
disabled={!(value && value.length)}
@@ -91,6 +98,13 @@ const CustomMenu = (props) => {
);
};
const disableActionKeys = (e) => {
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'Backspace'];
if (arrowKeys.includes(e.code)) {
e.preventDefault();
}
};
const CustomLoadingIndicator = () => {
const intl = useIntl();
return (
@@ -110,6 +124,7 @@ const CustomIndicatorsContainer = (props) => {
const {
value,
handleCommitStagedTags,
selectInlineAddRef,
} = props.selectProps;
const intl = useIntl();
return (
@@ -119,9 +134,12 @@ const CustomIndicatorsContainer = (props) => {
<Button
variant="dark"
size="sm"
className="mt-2 mb-2 rounded-0"
className="mt-2 mb-2 rounded-0 inline-add-button"
onClick={handleCommitStagedTags}
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
ref={selectInlineAddRef}
tabIndex="0"
onKeyDown={disableActionKeys} // To prevent navigating staged tags when button focused
>
{ intl.formatMessage(messages.collapsibleInlineAddStagedTagsButtonText) }
</Button>
@@ -219,8 +237,13 @@ const ContentTagsCollapsible = ({
}) => {
const intl = useIntl();
const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData;
const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false);
const {
tagChangeHandler,
removeAppliedTagHandler,
@@ -250,9 +273,11 @@ const ContentTagsCollapsible = ({
const handleSearchChange = React.useCallback((value, { action }) => {
if (action === 'input-blur') {
// Cancel/clear search if focused away from select input
handleSearch.cancel();
setSearchTerm('');
if (!selectMenuIsOpen) {
// Cancel/clear search if focused away from select input and menu closed
handleSearch.cancel();
setSearchTerm('');
}
} else if (action === 'input-change') {
if (value === '') {
// No need to debounce when search term cleared. Clear debounce function
@@ -262,7 +287,7 @@ const ContentTagsCollapsible = ({
handleSearch(value);
}
}
}, []);
}, [selectMenuIsOpen, setSearchTerm, handleSearch]);
// onChange handler for react-select component, currently only called when
// staged tags in the react-select input are removed or fully cleared.
@@ -287,14 +312,55 @@ const ContentTagsCollapsible = ({
handleStagedTagsMenuChange([]);
selectRef.current?.blur();
setSearchTerm('');
setSelectMenuIsOpen(false);
}, [commitStagedTags, handleStagedTagsMenuChange, selectRef, setSearchTerm]);
const handleCancelStagedTags = React.useCallback(() => {
handleStagedTagsMenuChange([]);
selectRef.current?.blur();
setSearchTerm('');
setSelectMenuIsOpen(false);
}, [handleStagedTagsMenuChange, selectRef, setSearchTerm]);
const handleSelectOnKeyDown = (event) => {
const focusedElement = event.target;
if (event.key === 'Escape') {
setSelectMenuIsOpen(false);
} else if (event.key === 'Tab') {
// Keep the menu open when navigating inside the select menu
setSelectMenuIsOpen(true);
// Determine when to close the menu when navigating with keyboard
if (!event.shiftKey) { // Navigating forwards
if (focusedElement === selectAddRef.current) {
setSelectMenuIsOpen(false);
} else if (focusedElement === selectCancelRef.current && selectAddRef.current?.disabled) {
setSelectMenuIsOpen(false);
}
// Navigating backwards
// @ts-ignore inputRef actually exists under the current selectRef
} else if (event.shiftKey && focusedElement === selectRef.current?.inputRef) {
setSelectMenuIsOpen(false);
}
}
};
// Open the select menu and make sure the search term is cleared when focused
const onSelectMenuFocus = React.useCallback(() => {
setSelectMenuIsOpen(true);
setSearchTerm('');
}, [setSelectMenuIsOpen, setSearchTerm]);
// Handles logic to close the select menu when clicking outside
const handleOnBlur = React.useCallback((event) => {
// Check if a target we are focusing to is an element in our select menu, if not close it
const menuClasses = ['dropdown-selector', 'inline-add-button', 'cancel-add-tags-button'];
if (!event.relatedTarget || !menuClasses.some(cls => event.relatedTarget.className?.includes(cls))) {
setSelectMenuIsOpen(false);
}
}, [setSelectMenuIsOpen]);
return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
@@ -306,6 +372,18 @@ const ContentTagsCollapsible = ({
{canTagObject && (
<Select
onBlur={handleOnBlur}
styles={{
// Overriding 'x' button styles for staged tags when navigating by keyboard
multiValueRemove: (base, state) => ({
...base,
background: state.isFocused ? 'black' : base.background,
color: state.isFocused ? 'white' : base.color,
}),
}}
menuIsOpen={selectMenuIsOpen}
onFocus={onSelectMenuFocus}
onKeyDown={handleSelectOnKeyDown}
ref={/** @type {React.RefObject} */(selectRef)}
isMulti
isLoading={updateTags.isLoading}
@@ -332,6 +410,9 @@ const ContentTagsCollapsible = ({
handleCommitStagedTags={handleCommitStagedTags}
handleCancelStagedTags={handleCancelStagedTags}
searchTerm={searchTerm}
selectCancelRef={selectCancelRef}
selectAddRef={selectAddRef}
selectInlineAddRef={selectInlineAddRef}
value={stagedContentTags}
/>
)}

View File

@@ -9,22 +9,89 @@ import userEvent from '@testing-library/user-event';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
const taxonomyMockData = {
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 2,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 2',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12346,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12347,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
};
const nestedTaxonomyMockData = {
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1.1',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 1',
id: 12354,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 1.2',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 1',
id: 12355,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
};
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
mutate: jest.fn(),
})),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
useTaxonomyTagsData: jest.fn((_, parentTagValue) => {
// To mock nested call of useTaxonomyData in subtags dropdown
if (parentTagValue === 'Tag 1') {
return nestedTaxonomyMockData;
}
return taxonomyMockData;
}),
}));
const data = {
@@ -107,48 +174,6 @@ describe('<ContentTagsCollapsible />', () => {
);
}
function setupTaxonomyMock() {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 2',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12346,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12347,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
});
}
it('should render taxonomy tags data along content tags number badge', async () => {
const { container, getByText } = await getComponent();
expect(getByText('Taxonomy 1')).toBeInTheDocument();
@@ -157,7 +182,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `addStagedContentTag` when tag checked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
@@ -189,7 +213,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `removeStagedContentTag` when tag staged tag unchecked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
@@ -220,7 +243,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `setStagedTags` to clear staged tags when clicking inline "Add" button', async () => {
setupTaxonomyMock();
// Setup component to have staged tags
const { container, getByText } = await getComponent({
...data,
@@ -246,7 +268,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should call `setStagedTags` to clear staged tags when clicking "Add tags" button in dropdown', async () => {
setupTaxonomyMock();
// Setup component to have staged tags
const { container, getByText } = await getComponent({
...data,
@@ -348,7 +369,6 @@ describe('<ContentTagsCollapsible />', () => {
});
it('should close dropdown selector when clicking away', async () => {
setupTaxonomyMock();
const { container, getByText, queryByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
@@ -376,8 +396,140 @@ describe('<ContentTagsCollapsible />', () => {
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should test keyboard navigation of add tags widget', async () => {
const {
container,
getByText,
queryByText,
queryAllByText,
} = await getComponent();
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add a tag" button to open dropdown
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
fireEvent.mouseDown(addTagsButton);
// Wait for the dropdown selector for tags to open, Tag 3 should appear
// since it is not applied
expect(queryByText('Tag 3')).toBeInTheDocument();
/*
The dropdown data looks like the following:
│Tag 1
│ │
│ ├─ Tag 1.1
│ │
│ │
│ └─ Tag 1.2
│Tag 2
│Tag 3
*/
// Press tab to focus on first element in dropdown, Tag 1 should be focused
userEvent.tab();
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1Div).toHaveFocus();
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
userEvent.keyboard('{arrowright}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
userEvent.keyboard('{arrowleft}');
expect(queryAllByText('Tag 1.1').length).toBe(1);
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
userEvent.keyboard('{enter}');
expect(queryAllByText('Tag 1.1').length).toBe(2);
expect(queryByText('Tag 1.2')).toBeInTheDocument();
// Press down arrow to navigate to Tag 1.1, it should be focused
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press down arrow again to navigate to Tag 1.2, it should be fouced
userEvent.keyboard('{arrowdown}');
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press down arrow again to navigate to Tag 2, it should be fouced
userEvent.keyboard('{arrowdown}');
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
expect(dropdownTag2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.2, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press up arrow to navigate back to Tag 1.1, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1pt1Div).toHaveFocus();
// Press up arrow again to navigate to Tag 1, it should be focused
userEvent.keyboard('{arrowup}');
expect(dropdownTag1Div).toHaveFocus();
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
userEvent.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
expect(dropdownTag1pt2Div).toHaveFocus();
// Press space key to check Tag 1.2, it should be staged
userEvent.keyboard('{space}');
const taxonomyId = 123;
const addedStagedTag = {
value: 'Tag%201,Tag%201.2',
label: 'Tag 1.2',
};
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
userEvent.keyboard('{enter}');
const tagValue = 'Tag%201,Tag%201.2';
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
// Press left arrow to navigate back to Tag 1, it should be focused
userEvent.keyboard('{arrowleft}');
expect(dropdownTag1Div).toHaveFocus();
// Press tab key it should jump to cancel button, it should be focused
userEvent.tab();
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
expect(dropdownCancel).toHaveFocus();
// Press tab again, it should exit and close the select menu, since there are not staged tags
userEvent.tab();
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press shift tab, focus back on select menu input, it should open the menu
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).toBeInTheDocument();
// Press shift tab again, it should focus out and close the select menu
userEvent.tab({ shift: true });
expect(queryByText('Tag 3')).not.toBeInTheDocument();
// Press tab again, the select menu should open, then press escape, it should close
userEvent.tab();
expect(queryByText('Tag 3')).toBeInTheDocument();
userEvent.keyboard('{escape}');
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should remove applied tags when clicking on `x` of tag bubble', async () => {
setupTaxonomyMock();
const { container, getByText } = await getComponent();
// Expand the Taxonomy to view applied tags

View File

@@ -20,7 +20,7 @@ import {
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import { useTaxonomyList } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
@@ -37,14 +37,9 @@ import Loading from '../generic/Loading';
*/
const ContentTagsDrawer = ({ id, onClose }) => {
const intl = useIntl();
// TODO: We can delete this when the iframe is no longer used on edx-platform
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
const params = useParams();
let contentId = id;
if (contentId === undefined) {
// TODO: We can delete this when the iframe is no longer used on edx-platform
contentId = params.contentId;
}
const contentId = id ?? params.contentId;
const org = extractOrgFromContentId(contentId);
@@ -74,18 +69,21 @@ const ContentTagsDrawer = ({ id, onClose }) => {
setStagedContentTags(prevStagedContentTags => ({ ...prevStagedContentTags, [taxonomyId]: tagsList }));
}, [setStagedContentTags]);
const useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse(org);
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
return { taxonomyListData, isTaxonomyListLoaded };
};
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId);
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
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) {
@@ -140,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

@@ -1,7 +1,12 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act, render, fireEvent, screen,
act,
fireEvent,
render,
waitFor,
screen,
} from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
@@ -10,7 +15,7 @@ import {
useContentData,
useTaxonomyTagsData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import { getTaxonomyListData } from '../taxonomy/data/api';
import messages from './messages';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
@@ -23,6 +28,7 @@ jest.mock('react-router-dom', () => ({
}),
}));
// FIXME: replace these mocks with API mocks
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
@@ -46,20 +52,30 @@ jest.mock('./data/apiHooks', () => ({
})),
}));
jest.mock('../taxonomy/data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
jest.mock('../taxonomy/data/api', () => ({
// By default, the mock taxonomy list will never load (promise never resolves):
getTaxonomyListData: jest.fn(),
}));
const queryClient = new QueryClient();
const RootWrapper = (params) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDrawer {...params} />
<QueryClientProvider client={queryClient}>
<ContentTagsDrawer {...params} />
</QueryClientProvider>
</IntlProvider>
);
describe('<ContentTagsDrawer />', () => {
beforeEach(async () => {
await queryClient.resetQueries();
// By default, we mock the API call with a promise that never resolves.
// You can override this in specific test.
getTaxonomyListData.mockReturnValue(new Promise(() => {}));
});
const setupMockDataForStagedTagsTesting = () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
@@ -84,7 +100,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
getTaxonomyListData.mockResolvedValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -148,7 +164,6 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the taxonomy tags query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
@@ -181,7 +196,6 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
@@ -218,7 +232,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
getTaxonomyListData.mockResolvedValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -233,6 +247,7 @@ describe('<ContentTagsDrawer />', () => {
});
await act(async () => {
const { container, getByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('badge');
@@ -241,10 +256,11 @@ describe('<ContentTagsDrawer />', () => {
});
});
it('should test adding a content tag to the staged tags for a taxonomy', () => {
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const { container, getByText, getAllByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
@@ -267,10 +283,11 @@ describe('<ContentTagsDrawer />', () => {
expect(getAllByText('Tag 3').length).toBe(2);
});
it('should test removing a staged content from a taxonomy', () => {
it('should test removing a staged content from a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const { container, getByText, getAllByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
@@ -297,7 +314,7 @@ describe('<ContentTagsDrawer />', () => {
expect(getAllByText('Tag 3').length).toBe(1);
});
it('should test clearing staged tags for a taxonomy', () => {
it('should test clearing staged tags for a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const {
@@ -306,6 +323,7 @@ describe('<ContentTagsDrawer />', () => {
getAllByText,
queryByText,
} = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];

View File

@@ -114,10 +114,120 @@ const ContentTagsDropDownSelector = ({
return !!appliedTraversal[tag.value];
};
const isStagedExplicit = (tag) => {
// Traverse the staged tags tree using the lineage
let stagedTraversal = stagedContentTagsTree;
lineage.forEach(t => {
stagedTraversal = stagedTraversal[t]?.children || {};
});
return stagedTraversal[tag.value] && stagedTraversal[tag.value].explicit;
};
// Returns the state of the tag as a string: [Unchecked/Implicit/Checked]
const getTagState = (tag) => {
if (isApplied(tag) || isStagedExplicit(tag)) {
return intl.formatMessage(messages.taxonomyTagChecked);
}
if (isImplicit(tag)) {
return intl.formatMessage(messages.taxonomyTagImplicit);
}
return intl.formatMessage(messages.taxonomyTagUnchecked);
};
const isTopOfTagTreeDropdown = (index) => index === 0 && level === 0;
const loadMoreTags = useCallback(() => {
setNumPages((x) => x + 1);
}, []);
const handleKeyBoardNav = (e, hasChildren) => {
const keyPressed = e.code;
const currentElement = e.target;
const encapsulator = currentElement.closest('.dropdown-selector-tag-encapsulator');
// Get tag value with full lineage, this is URI encoded
const tagValueWithLineage = currentElement.querySelector('.pgn__form-checkbox-input')?.value;
// Extract and decode the actual tag value
let tagValue = tagValueWithLineage.split(',').slice(-1)[0];
tagValue = tagValue ? decodeURIComponent(tagValue) : tagValue;
if (keyPressed === 'ArrowRight') {
e.preventDefault();
if (tagValue && !isOpen(tagValue)) {
clickAndEnterHandler(tagValue);
}
} else if (keyPressed === 'ArrowLeft') {
e.preventDefault();
if (tagValue && isOpen(tagValue)) {
clickAndEnterHandler(tagValue);
} else {
// Handles case of jumping out of subtags to previous parent tag
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
prevParentTag?.focus();
}
} else if (keyPressed === 'ArrowUp') {
const prevSubTags = encapsulator?.previousElementSibling?.querySelectorAll('.dropdown-selector-tag-actions');
const prevSubTag = prevSubTags && prevSubTags[prevSubTags.length - 1];
const prevTag = encapsulator?.previousElementSibling?.querySelector('.dropdown-selector-tag-actions');
if (prevSubTag) {
// Handles case of jumping in to subtags
prevSubTag.focus();
} else if (prevTag) {
// Handles case of navigating to previous tag on same level
prevTag.focus();
} else {
// Handles case of jumping out of subtags to previous parent tag
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
prevParentTag?.focus();
}
} else if (keyPressed === 'ArrowDown') {
const subTagEncapsulator = encapsulator?.querySelector('.dropdown-selector-tag-encapsulator');
const nextSubTag = subTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
const nextTag = encapsulator?.nextElementSibling?.querySelector('.dropdown-selector-tag-actions');
if (nextSubTag) {
// Handles case of jumping into subtags
nextSubTag.focus();
} else if (nextTag) {
// Handles case of navigating to next tag on same level
nextTag?.focus();
} else {
// Handles case of jumping out of subtags to next focusable parent tag
let nextParentTagEncapsulator = encapsulator?.parentNode?.closest('.dropdown-selector-tag-encapsulator');
while (nextParentTagEncapsulator) {
const nextParentTag = nextParentTagEncapsulator.nextElementSibling?.querySelector(
'.dropdown-selector-tag-actions',
);
if (nextParentTag) {
nextParentTag.focus();
break;
}
nextParentTagEncapsulator = nextParentTagEncapsulator.parentNode.closest(
'.dropdown-selector-tag-encapsulator',
);
}
}
} else if (keyPressed === 'Enter') {
e.preventDefault();
if (hasChildren && tagValue) {
clickAndEnterHandler(tagValue);
} else {
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
checkbox.click();
}
} else if (keyPressed === 'Space') {
e.preventDefault();
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
checkbox.click();
}
};
return (
<div style={{ marginLeft: `${level * 1 }rem` }}>
{tagPages.isLoading ? (
@@ -131,24 +241,39 @@ const ContentTagsDropDownSelector = ({
) : null }
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
{tagPages.data?.map((tagData) => (
<React.Fragment key={tagData.value}>
{tagPages.data?.map((tagData, i) => (
<div key={tagData.value} className="mt-1 ml-1 dropdown-selector-tag-encapsulator">
<div
className="d-flex flex-row"
style={{
minHeight: '44px',
}}
>
<div className="d-flex">
{/* The tabIndex and onKeyDown are needed to implement custom keyboard navigation */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="d-flex dropdown-selector-tag-actions"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={isTopOfTagTreeDropdown(i) ? 0 : -1} // Only enable tab into top of dropdown tree to set focus
onKeyDown={(e) => handleKeyBoardNav(e, tagData.childCount > 0)}
aria-label={
intl.formatMessage(
(isTopOfTagTreeDropdown(i)
? messages.taxonomyTagActionInstructionsAriaLabel
: messages.taxonomyTagActionsAriaLabel),
{ tag: tagData.value, tagState: getTagState(tagData) },
)
}
>
<SelectableBox
inputHidden={false}
type="checkbox"
className="d-flex align-items-center taxonomy-tags-selectable-box"
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
data-selectable-box="taxonomy-tags"
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
isIndeterminate={isApplied(tagData) || isImplicit(tagData)}
disabled={isApplied(tagData) || isImplicit(tagData)}
tabIndex="-1"
>
<HighlightedText text={tagData.value} highlight={searchTerm} />
</SelectableBox>
@@ -158,8 +283,7 @@ const ContentTagsDropDownSelector = ({
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="0"
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
tabIndex="-1"
/>
</div>
)}
@@ -178,17 +302,18 @@ const ContentTagsDropDownSelector = ({
/>
)}
</React.Fragment>
</div>
))}
{ hasMorePages
? (
<div>
<Button
tabIndex="0"
variant="tertiary"
iconBefore={Add}
onClick={loadMoreTags}
className="mb-2 taxonomy-tags-load-more-button px-0 text-info-500"
className="mb-2 ml-1 taxonomy-tags-load-more-button px-0 text-info-500"
>
<FormattedMessage {...messages.loadMoreTagsButtonText} />
</Button>

View File

@@ -32,3 +32,8 @@
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
outline: none !important;
}
.dropdown-selector-tag-actions:focus-visible {
outline: solid 2px $info-900;
border-radius: 4px;
}

View File

@@ -199,68 +199,6 @@ describe('<ContentTagsDropDownSelector />', () => {
});
});
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
},
});
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
},
};
const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
});
it('should render taxonomy tags drop down selector and change search term', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,

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

@@ -25,9 +25,25 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
defaultMessage: 'No tags found with the search term "{searchTerm}"',
},
taxonomyTagsCheckboxAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
defaultMessage: '{tag} checkbox',
taxonomyTagChecked: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
defaultMessage: 'Checked',
},
taxonomyTagUnchecked: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-unchecked',
defaultMessage: 'Unchecked',
},
taxonomyTagImplicit: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-implicit',
defaultMessage: 'Implicit',
},
taxonomyTagActionInstructionsAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-action-instructions.aria.label',
defaultMessage: '{tagState} Tag: {tag}. Use the arrow keys to move among the tags in this taxonomy. Press space to select a tag.',
},
taxonomyTagActionsAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-actions.aria.label',
defaultMessage: '{tagState} Tag: {tag}',
},
taxonomyTagsAriaLabel: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',

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

@@ -26,7 +26,10 @@ const VideoThumbnail = ({
intl,
}) => {
const fileInputControl = useFileInput({
onAddFile: (file) => handleAddThumbnail(file, id),
onAddFile: (files) => {
const [file] = files;
handleAddThumbnail(file, id);
},
setSelectedRows: () => {},
setAddOpen: () => false,
});
@@ -46,18 +49,30 @@ const VideoThumbnail = ({
const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded;
return (
<div data-testid={`video-thumbnail-${id}`} className="video-thumbnail row justify-content-center align-itmes-center">
{allowThumbnailUpload && <div className="thumbnail-overlay" />}
<div className="video-thumbnail row justify-content-center align-itmes-center">
{allowThumbnailUpload && showThumbnail && <div className="thumbnail-overlay" />}
{showThumbnail && !thumbnailError && pageLoadStatus === RequestStatus.SUCCESSFUL ? (
<div className="border rounded">
<Image
style={imageSize}
className="m-1 bg-light-300"
src={thumbnail}
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
onError={() => setThumbnailError(true)}
/>
</div>
<>
<div className="border rounded">
<Image
style={imageSize}
className="m-1 bg-light-300"
src={thumbnail}
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
onError={() => setThumbnailError(true)}
/>
</div>
<div className="add-thumbnail" data-testid={`video-thumbnail-${id}`}>
<Button
variant="primary"
size="sm"
onClick={fileInputControl.click}
tabIndex="0"
>
{addThumbnailMessage}
</Button>
</div>
</>
) : (
<>
<div
@@ -76,24 +91,12 @@ const VideoThumbnail = ({
</>
)}
{allowThumbnailUpload && (
<>
<div className="add-thumbnail">
<Button
variant="primary"
size="sm"
onClick={fileInputControl.click}
tabIndex="0"
>
{addThumbnailMessage}
</Button>
</div>
<FileInput
key="video-thumbnail-upload"
fileInput={fileInputControl}
supportedFileFormats={supportedFiles}
allowMultiple={false}
/>
</>
<FileInput
key="video-thumbnail-upload"
fileInput={fileInputControl}
supportedFileFormats={supportedFiles}
allowMultiple={false}
/>
)}
</div>
);

View File

@@ -218,6 +218,12 @@ describe('Videos page', () => {
const updateStatus = store.getState().videos.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('should no render thumbnail upload button', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
const addThumbnailButton = screen.queryByTestId('video-thumbnail-mOckID5');
expect(addThumbnailButton).toBeNull();
});
});
describe('table actions', () => {

View File

@@ -32,7 +32,8 @@ const Transcript = ({
}, [transcript]);
const input = useFileInput({
onAddFile: (file) => {
onAddFile: (files) => {
const [file] = files;
handleTranscript({
file,
language,

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

View File

@@ -14,6 +14,7 @@ import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
import { datadogRum } from '@datadog/browser-rum';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { logError } from '@edx/frontend-platform/logging';
@@ -37,6 +38,21 @@ const App = () => {
useEffect(() => {
if (process.env.HOTJAR_APP_ID) {
try {
datadogRum.init({
applicationId: 'a3f99dcb-4955-4baa-8341-39a88603ab08',
clientToken: 'pubf2e79d946cec4c4413965620ba0e0b72',
site: 'datadoghq.com',
service: 'edx-frontend-sandbox',
env: 'staging',
// Specify a version number to identify the deployed version of your application in Datadog
version: '1.0.0',
sessionSampleRate: 100,
sessionReplaySampleRate: 20,
trackUserInteractions: true,
trackResources: true,
trackLongTasks: true,
defaultPrivacyLevel: 'mask-user-input',
});
initializeHotjar({
hotjarId: process.env.HOTJAR_APP_ID,
hotjarVersion: process.env.HOTJAR_VERSION,
@@ -101,6 +117,8 @@ subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({
handlers: {
config: () => {

View File

@@ -24,17 +24,15 @@ import { Helmet } from 'react-helmet';
import { useOrganizationListData } from '../generic/data/apiHooks';
import SubHeader from '../generic/sub-header/SubHeader';
import getPageHeadTitle from '../generic/utils';
import { getTaxonomyTemplateApiUrl } from './data/api';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { ALL_TAXONOMIES, apiUrls, UNASSIGNED } from './data/api';
import { useImportNewTaxonomy, useTaxonomyList } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
const ALL_TAXONOMIES = 'All taxonomies';
const UNASSIGNED = 'Unassigned';
const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
const intl = useIntl();
const importMutation = useImportNewTaxonomy();
return (
<>
<OverlayTrigger
@@ -55,13 +53,13 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
href={getTaxonomyTemplateApiUrl('csv')}
href={apiUrls.taxonomyTemplate('csv')}
data-testid="taxonomy-download-template-csv"
>
{intl.formatMessage(messages.downloadTemplateButtonCSVLabel)}
</Dropdown.Item>
<Dropdown.Item
href={getTaxonomyTemplateApiUrl('json')}
href={apiUrls.taxonomyTemplate('json')}
data-testid="taxonomy-download-template-json"
>
{intl.formatMessage(messages.downloadTemplateButtonJSONLabel)}
@@ -71,7 +69,7 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
</OverlayTrigger>
<Button
iconBefore={Add}
onClick={() => importTaxonomy(intl)}
onClick={() => importTaxonomy(intl, importMutation)}
data-testid="taxonomy-import-button"
disabled={!canAddTaxonomy}
>
@@ -154,8 +152,10 @@ const TaxonomyListPage = () => {
isSuccess: isOrganizationListLoaded,
} = useOrganizationListData();
const taxonomyListData = useTaxonomyListDataResponse(selectedOrgFilter);
const isLoaded = useIsTaxonomyListDataLoaded(selectedOrgFilter);
const {
data: taxonomyListData,
isSuccess: isLoaded,
} = useTaxonomyList(selectedOrgFilter);
const canAddTaxonomy = taxonomyListData?.canAddTaxonomy ?? false;
const getOrgSelect = () => (

View File

@@ -4,13 +4,17 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, fireEvent, render } from '@testing-library/react';
import {
act,
fireEvent,
render,
waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { getTaxonomyTemplateApiUrl } from './data/api';
import { apiUrls } from './data/api';
import TaxonomyListPage from './TaxonomyListPage';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import { TaxonomyContext } from './common/context';
@@ -27,14 +31,12 @@ const taxonomies = [{
tagsCount: 0,
}];
const organizationsListUrl = 'http://localhost:18010/organizations';
const listTaxonomiesUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/?enabled=true';
const listTaxonomiesUnassignedUrl = `${listTaxonomiesUrl}&unassigned=true`;
const listTaxonomiesOrg1Url = `${listTaxonomiesUrl}&org=Org+1`;
const listTaxonomiesOrg2Url = `${listTaxonomiesUrl}&org=Org+2`;
const organizations = ['Org 1', 'Org 2'];
jest.mock('./data/apiHooks', () => ({
...jest.requireActual('./data/apiHooks'),
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
}));
jest.mock('./import-tags', () => ({
importTaxonomy: jest.fn(),
}));
@@ -82,7 +84,8 @@ describe('<TaxonomyListPage />', () => {
});
it('shows the spinner before the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
// Simulate an API request that times out:
axiosMock.onGet(listTaxonomiesUrl).reply(new Promise(() => {}));
await act(async () => {
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
@@ -91,61 +94,50 @@ describe('<TaxonomyListPage />', () => {
});
it('shows the data table after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
canAddTaxonomy: false,
});
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
await act(async () => {
const { getByTestId } = render(<RootWrapper />);
const { getByTestId, queryByText } = render(<RootWrapper />);
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
});
});
it.each(['CSV', 'JSON'])('downloads the taxonomy template %s', async (fileFormat) => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
canAddTaxonomy: false,
});
const { findByRole } = render(<RootWrapper />);
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
const { findByRole, queryByText } = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
const templateMenu = await findByRole('button', { name: 'Download template' });
fireEvent.click(templateMenu);
const templateButton = await findByRole('link', { name: `${fileFormat} template` });
fireEvent.click(templateButton);
expect(templateButton.href).toBe(getTaxonomyTemplateApiUrl(fileFormat.toLowerCase()));
expect(templateButton.href).toBe(apiUrls.taxonomyTemplate(fileFormat.toLowerCase()));
});
it('disables the import taxonomy button if not permitted', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [],
canAddTaxonomy: false,
});
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: false });
const { getByRole } = render(<RootWrapper />);
const { queryByText, getByRole } = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).toBeDisabled();
});
it('calls the import taxonomy action when the import button is clicked', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [],
canAddTaxonomy: true,
});
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: true });
const { getByRole } = render(<RootWrapper />);
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).not.toBeDisabled();
// Once the API response is received and rendered, the Import button should be enabled:
await waitFor(() => { expect(importButton).not.toBeDisabled(); });
fireEvent.click(importButton);
expect(importTaxonomy).toHaveBeenCalled();
});
it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
axiosMock.onGet(listTaxonomiesUrl).reply(200, {
results: [{
id: 1,
name: 'Taxonomy',
@@ -163,7 +155,10 @@ describe('<TaxonomyListPage />', () => {
getByText,
getByRole,
getAllByText,
queryByText,
} = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
expect(getByTestId('taxonomy-orgs-filter-selector')).toBeInTheDocument();
// Check that the default filter is set to 'All taxonomies' when page is loaded
@@ -184,13 +179,29 @@ describe('<TaxonomyListPage />', () => {
});
it('should fetch taxonomies with correct params for org filters', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
const defaults = {
id: 1,
showSystemBadge: false,
canChangeTaxonomy: true,
canDeleteTaxonomy: true,
tagsCount: 0,
description: 'Taxonomy description here',
};
axiosMock.onGet(listTaxonomiesUnassignedUrl).reply(200, {
canAddTaxonomy: false,
results: [{ name: 'Unassigned Taxonomy A', ...defaults }],
});
axiosMock.onGet(listTaxonomiesOrg1Url).reply(200, {
canAddTaxonomy: false,
results: [{ name: 'Org1 Taxonomy B', ...defaults }],
});
axiosMock.onGet(listTaxonomiesOrg2Url).reply(200, {
canAddTaxonomy: false,
results: [{ name: 'Org2 Taxonomy C', ...defaults }],
});
const { getByRole } = render(<RootWrapper />);
const { getByRole, getByText, queryByText } = render(<RootWrapper />);
// Open the taxonomies org filter select menu
const taxonomiesFilterSelectMenu = await getByRole('button', { name: 'All taxonomies' });
@@ -198,22 +209,28 @@ describe('<TaxonomyListPage />', () => {
// Check that the 'Unassigned' option is correctly called
fireEvent.click(getByRole('link', { name: 'Unassigned' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('Unassigned');
await waitFor(() => {
expect(getByText('Unassigned Taxonomy A')).toBeInTheDocument();
});
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
// Check that the 'Org 1' option is correctly called
fireEvent.click(getByRole('link', { name: 'Org 1' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 1');
await waitFor(() => {
expect(getByText('Org1 Taxonomy B')).toBeInTheDocument();
});
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
// Check that the 'Org 2' option is correctly called
fireEvent.click(getByRole('link', { name: 'Org 2' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 2');
await waitFor(() => {
expect(queryByText('Org1 Taxonomy B')).not.toBeInTheDocument();
expect(queryByText('Org2 Taxonomy C')).toBeInTheDocument();
});
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
@@ -221,6 +238,8 @@ describe('<TaxonomyListPage />', () => {
// Check that the 'All' option is correctly called, it should show as
// 'All' rather than 'All taxonomies' in the select menu since its not selected
fireEvent.click(getByRole('link', { name: 'All' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('All taxonomies');
await waitFor(() => {
expect(getByText(taxonomies[0].description)).toBeInTheDocument();
});
});
});

View File

@@ -3,71 +3,123 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyListApiUrl = (org) => {
const url = new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl());
url.searchParams.append('enabled', 'true');
if (org !== undefined) {
if (org === 'Unassigned') {
url.searchParams.append('unassigned', 'true');
} else if (org !== 'All taxonomies') {
url.searchParams.append('org', org);
}
const getTaxonomiesV1Endpoint = () => new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl()).href;
/**
* Helper method for creating URLs for the tagging/taxonomy API. Used only in this file.
* @param {string} path The subpath within the taxonomies "v1" REST API namespace
* @param {Record<string, string | number>} [searchParams] Query parameters to include
*/
const makeUrl = (path, searchParams) => {
const url = new URL(path, getTaxonomiesV1Endpoint());
if (searchParams) {
Object.entries(searchParams).forEach(([k, v]) => url.searchParams.append(k, String(v)));
}
return url.href;
};
export const getExportTaxonomyApiUrl = (pk, format) => new URL(
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
getApiBaseUrl(),
).href;
export const ALL_TAXONOMIES = '__all';
export const UNASSIGNED = '__unassigned';
export const getTaxonomyTemplateApiUrl = (format) => new URL(
`api/content_tagging/v1/taxonomies/import/template.${format}`,
getApiBaseUrl(),
).href;
/**
* Get the URL for a Taxonomy
* @param {number} pk
* @returns {string}
*/
export const getTaxonomyApiUrl = (pk) => new URL(`api/content_tagging/v1/taxonomies/${pk}/`, getApiBaseUrl()).href;
/** @satisfies {Record<string, (...args: any[]) => string>} */
export const apiUrls = {
/**
* Get the URL of the "list all taxonomies" endpoint
* @param {string} [org] Optionally, Filter the list to only show taxonomies assigned to this org
*/
taxonomyList(org) {
const params = {};
if (org !== undefined) {
if (org === UNASSIGNED) {
params.unassigned = 'true';
} else if (org !== ALL_TAXONOMIES) {
params.org = org;
}
}
return makeUrl('.', { enabled: 'true', ...params });
},
/**
* Get the URL of the API endpoint to download a taxonomy as a CSV/JSON file.
* @param {number} taxonomyId The ID of the taxonomy
* @param {'json'|'csv'} format Which format to use for the export
*/
exportTaxonomy: (taxonomyId, format) => makeUrl(`${taxonomyId}/export/`, { output_format: format, download: 1 }),
/**
* The the URL of the downloadable template file that shows how to format a
* taxonomy file.
* @param {'json'|'csv'} format The format requested
*/
taxonomyTemplate: (format) => makeUrl(`import/template.${format}`),
/**
* Get the URL for a Taxonomy
* @param {number} taxonomyId The ID of the taxonomy
*/
taxonomy: (taxonomyId) => makeUrl(`${taxonomyId}/`),
/**
* Get the URL for listing the tags of a taxonomy
* @param {number} taxonomyId
* @param {number} pageIndex Zero-indexed page number
* @param {*} pageSize How many tags per page to load
*/
tagList: (taxonomyId, pageIndex, pageSize) => makeUrl(`${taxonomyId}/tags/`, {
page: (pageIndex + 1), page_size: pageSize,
}),
/**
* Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future.
* @param {number} taxonomyId
* @param {string} parentTagValue
*/
allSubtagsOf: (taxonomyId, parentTagValue) => makeUrl(`${taxonomyId}/tags/`, {
// Load as deeply as we can
full_depth_threshold: 10000,
parent_tag: parentTagValue,
}),
/** URL to create a new taxonomy from an import file. */
createTaxonomyFromImport: () => makeUrl('import/'),
/**
* @param {number} taxonomyId
*/
tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`),
/**
* @param {number} taxonomyId
*/
tagsPlanImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/plan/`),
};
/**
* Get list of taxonomies.
* @param {string} org Optioanl organization query param
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
* @returns {Promise<import("./types.mjs").TaxonomyListData>}
*/
export async function getTaxonomyListData(org) {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl(org));
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomyList(org));
return camelCaseObject(data);
}
/**
* Delete a Taxonomy
* @param {number} pk
* @returns {Promise<Object>}
* @param {number} taxonomyId
* @returns {Promise<void>}
*/
export async function deleteTaxonomy(pk) {
await getAuthenticatedHttpClient().delete(getTaxonomyApiUrl(pk));
export async function deleteTaxonomy(taxonomyId) {
await getAuthenticatedHttpClient().delete(apiUrls.taxonomy(taxonomyId));
}
/** Get a Taxonomy
* @param {number} pk
* @returns {Promise<import("./types.mjs").TaxonomyData>}
*/
export async function getTaxonomy(pk) {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyApiUrl(pk));
/**
* Get metadata about a Taxonomy
* @param {number} taxonomyId The ID of the taxonomy to get
* @returns {Promise<import("./types.mjs").TaxonomyData>}
*/
export async function getTaxonomy(taxonomyId) {
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomy(taxonomyId));
return camelCaseObject(data);
}
/**
* Downloads the file of the exported taxonomy
* @param {number} pk
* @param {string} format
* @param {number} taxonomyId The ID of the taxonomy
* @param {'json'|'csv'} format Which format to use for the export file.
* @returns {void}
*/
export function getTaxonomyExportFile(pk, format) {
window.location.href = getExportTaxonomyApiUrl(pk, format);
export function getTaxonomyExportFile(taxonomyId, format) {
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
}

View File

@@ -1,3 +1,4 @@
// @ts-check
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -5,11 +6,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { taxonomyListMock } from '../__mocks__';
import {
getExportTaxonomyApiUrl,
apiUrls,
getTaxonomyExportFile,
getTaxonomyListApiUrl,
getTaxonomyListData,
getTaxonomyApiUrl,
getTaxonomy,
deleteTaxonomy,
} from './api';
@@ -17,7 +16,6 @@ import {
let axiosMock;
describe('taxonomy api calls', () => {
const { location } = window;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -35,50 +33,47 @@ describe('taxonomy api calls', () => {
jest.clearAllMocks();
});
beforeAll(() => {
delete window.location;
window.location = {
href: '',
};
});
afterAll(() => {
window.location = location;
});
it.each([
undefined,
'All taxonomies',
'Unassigned',
'testOrg',
])('should get taxonomy list data for \'%s\' org filter', async (org) => {
axiosMock.onGet(getTaxonomyListApiUrl(org)).reply(200, taxonomyListMock);
axiosMock.onGet(apiUrls.taxonomyList(org)).reply(200, taxonomyListMock);
const result = await getTaxonomyListData(org);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl(org));
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomyList(org));
expect(result).toEqual(taxonomyListMock);
});
it('should delete a taxonomy', async () => {
axiosMock.onDelete(getTaxonomyApiUrl()).reply(200);
await deleteTaxonomy();
const taxonomyId = 123;
axiosMock.onDelete(apiUrls.taxonomy(taxonomyId)).reply(200);
await deleteTaxonomy(taxonomyId);
expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl());
expect(axiosMock.history.delete[0].url).toEqual(apiUrls.taxonomy(taxonomyId));
});
it('should call get taxonomy', async () => {
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
await getTaxonomy(1);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyApiUrl(1));
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomy(1));
});
it('Export should set window.location.href correctly', () => {
const origLocation = window.location;
// @ts-ignore
delete window.location;
// @ts-ignore
window.location = { href: '' };
const pk = 1;
const format = 'json';
getTaxonomyExportFile(pk, format);
expect(window.location.href).toEqual(apiUrls.exportTaxonomy(pk, format));
expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format));
// Restore the location object of window:
window.location = origLocation;
});
});

View File

@@ -0,0 +1,206 @@
// @ts-check
/**
* This is a file used especially in this `taxonomy` module.
*
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
* This approach accelerates the development.
*
* In this file you will find two types of hooks:
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
* Ex. useTaxonomyListData.
* - Hooks that calls the query hook, prepare and return the data.
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { apiUrls, ALL_TAXONOMIES } from './api';
import * as api from './api';
// Query key patterns. Allows an easy way to clear all data related to a given taxonomy.
// https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
export const taxonomyQueryKeys = {
all: ['taxonomies'],
/**
* Key for the list of taxonomies, optionally filtered by org.
* @param {string} [org] Which org we fetched the taxonomy list for (optional)
*/
taxonomyList: (org) => [
...taxonomyQueryKeys.all, 'taxonomyList', ...(org && org !== ALL_TAXONOMIES ? [org] : []),
],
/**
* Base key for data specific to a single taxonomy. No data is stored directly in this key.
* @param {number} taxonomyId ID of the taxonomy
*/
taxonomy: (taxonomyId) => [...taxonomyQueryKeys.all, 'taxonomy', taxonomyId],
/**
* @param {number} taxonomyId ID of the taxonomy
*/
taxonomyMetadata: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'metadata'],
/**
* @param {number} taxonomyId ID of the taxonomy
*/
taxonomyTagList: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'tags'],
/**
* @param {number} taxonomyId ID of the taxonomy
* @param {number} pageIndex Which page of tags to load (zero-based)
* @param {number} pageSize
*/
taxonomyTagListPage: (taxonomyId, pageIndex, pageSize) => [
...taxonomyQueryKeys.taxonomyTagList(taxonomyId), 'page', pageIndex, pageSize,
],
/**
* Query for loading _all_ the subtags of a particular parent tag
* @param {number} taxonomyId ID of the taxonomy
* @param {string} parentTagValue
*/
taxonomyTagSubtagsList: (taxonomyId, parentTagValue) => [
...taxonomyQueryKeys.taxonomyTagList(taxonomyId), 'subtags', parentTagValue,
],
/**
* @param {number} taxonomyId ID of the taxonomy
* @param {string} fileId Some string to uniquely identify the file we want to upload
*/
importPlan: (taxonomyId, fileId) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId],
};
/**
* Builds the query to get the taxonomy list
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
*/
export const useTaxonomyList = (org) => (
useQuery({
queryKey: taxonomyQueryKeys.taxonomyList(org),
queryFn: () => api.getTaxonomyListData(org),
})
);
/**
* Builds the mutation to delete a taxonomy.
* @returns A function that can be used to delete the taxonomy.
*/
export const useDeleteTaxonomy = () => {
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
mutationFn: async ({ pk }) => api.deleteTaxonomy(pk),
onSettled: (_d, _e, args) => {
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyList() });
queryClient.removeQueries({ queryKey: taxonomyQueryKeys.taxonomy(args.pk) });
},
});
return mutateAsync;
};
/** Builds the query to get the taxonomy detail
* @param {number} taxonomyId
*/
export const useTaxonomyDetails = (taxonomyId) => useQuery({
queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId),
queryFn: () => api.getTaxonomy(taxonomyId),
});
/**
* Use this mutation to import a new taxonomy.
*/
export const useImportNewTaxonomy = () => {
const queryClient = useQueryClient();
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* import("./types.mjs").TaxonomyData,
* any,
* {
* name: string,
* exportId: string,
* description: string,
* file: File,
* }
* >}
*/
mutationFn: async ({
name, exportId, description, file,
}) => {
const formData = new FormData();
formData.append('taxonomy_name', name);
formData.append('taxonomy_export_id', exportId);
formData.append('taxonomy_description', description);
formData.append('file', file);
const { data } = await getAuthenticatedHttpClient().post(apiUrls.createTaxonomyFromImport(), formData);
return camelCaseObject(data);
},
onSuccess: (data) => {
// There's a new taxonomy, so the list of taxonomies needs to be refreshed:
queryClient.invalidateQueries({
queryKey: taxonomyQueryKeys.taxonomyList(),
});
queryClient.setQueryData(taxonomyQueryKeys.taxonomyMetadata(data.id), data);
},
});
};
/**
* Build the mutation to import tags to an existing taxonomy
*/
export const useImportTags = () => {
const queryClient = useQueryClient();
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* import("./types.mjs").TaxonomyData,
* any,
* {
* taxonomyId: number,
* file: File,
* }
* >}
*/
mutationFn: async ({ taxonomyId, file }) => {
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsImport(taxonomyId), formData);
return camelCaseObject(data);
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data?.error || err.message);
}
},
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: taxonomyQueryKeys.taxonomyTagList(data.id),
});
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
queryClient.setQueryData(taxonomyQueryKeys.taxonomyMetadata(data.id), data);
},
});
};
/**
* Preview the results of importing the given file into an existing taxonomy.
* @param {number} taxonomyId The ID of the taxonomy whose tags we're updating.
* @param {File|null} file The file that we want to import
*/
export const useImportPlan = (taxonomyId, file) => useQuery({
queryKey: taxonomyQueryKeys.importPlan(taxonomyId, file ? `${file.name}${file.lastModified}${file.size}` : ''),
/**
* @type {import("@tanstack/react-query").QueryFunction<string|null>}
*/
queryFn: async () => {
if (file === null) {
return null;
}
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsPlanImport(taxonomyId), formData);
return /** @type {string} */(data.plan);
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data?.error || err.message);
}
},
retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times!
});

View File

@@ -1,106 +0,0 @@
// @ts-check
/**
* This is a file used especially in this `taxonomy` module.
*
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
* This approach accelerates the development.
*
* In this file you will find two types of hooks:
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
* Ex. useTaxonomyListData.
* - Hooks that calls the query hook, prepare and return the data.
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTaxonomyListData, deleteTaxonomy, getTaxonomy } from './api';
/**
* Builds the query to get the taxonomy list
* @param {string} org Optional organization query param
*/
const useTaxonomyListData = (org) => (
useQuery({
queryKey: ['taxonomyList', org],
queryFn: () => getTaxonomyListData(org),
})
);
/**
* Builds the mutation to delete a taxonomy.
* @returns An object with the mutation configuration.
*/
export const useDeleteTaxonomy = () => {
const queryClient = useQueryClient();
const { mutate } = useMutation({
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
mutationFn: async ({ pk }) => deleteTaxonomy(pk),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['taxonomyList'] });
},
});
return mutate;
};
/** Builds the query to get the taxonomy detail
* @param {number} taxonomyId
*/
const useTaxonomyDetailData = (taxonomyId) => (
useQuery({
queryKey: ['taxonomyDetail', taxonomyId],
queryFn: async () => getTaxonomy(taxonomyId),
})
);
/**
* Gets the taxonomy list data
* @param {string} org Optional organization query param
* @returns {import("./types.mjs").TaxonomyListData | undefined}
*/
export const useTaxonomyListDataResponse = (org) => {
const response = useTaxonomyListData(org);
if (response.status === 'success') {
return { ...response.data, refetch: response.refetch };
}
return undefined;
};
/**
* Returns the status of the taxonomy list query
* @param {string} org Optional organization param
* @returns {boolean}
*/
export const useIsTaxonomyListDataLoaded = (org) => (
useTaxonomyListData(org).status === 'success'
);
/**
* @param {number} taxonomyId
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isSuccess">}
*/
export const useTaxonomyDetailDataStatus = (taxonomyId) => {
const {
isError,
error,
isFetched,
isSuccess,
} = useTaxonomyDetailData(taxonomyId);
return {
isError,
error,
isFetched,
isSuccess,
};
};
/**
* @param {number} taxonomyId
* @returns {import("./types.mjs").TaxonomyData | undefined}
*/
export const useTaxonomyDetailDataResponse = (taxonomyId) => {
const { isSuccess, data } = useTaxonomyDetailData(taxonomyId);
if (isSuccess) {
return data;
}
return undefined;
};

View File

@@ -1,78 +1,110 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { act } from '@testing-library/react';
// @ts-check
import React from 'react'; // Required to use JSX syntax without type errors
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { apiUrls } from './api';
import {
useTaxonomyListDataResponse,
useIsTaxonomyListDataLoaded,
useDeleteTaxonomy,
useImportPlan,
useImportTags,
useImportNewTaxonomy,
} from './apiHooks';
import { deleteTaxonomy } from './api';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
let axiosMock;
jest.mock('./api', () => ({
deleteTaxonomy: jest.fn(),
}));
/*
* TODO: We can refactor this test: Mock the API response using axiosMock.
* Ref: https://github.com/openedx/frontend-app-course-authoring/pull/684#issuecomment-1847694090
*/
describe('useTaxonomyListDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
const result = useTaxonomyListDataResponse();
expect(result).toEqual({ data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const result = useTaxonomyListDataResponse();
expect(result).toBeUndefined();
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
describe('useIsTaxonomyListDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const result = useIsTaxonomyListDataLoaded();
const emptyFile = new File([], 'empty.csv');
expect(result).toBe(true);
});
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const result = useIsTaxonomyListDataLoaded();
expect(result).toBe(false);
});
});
describe('useDeleteTaxonomy', () => {
it('should call the delete function', async () => {
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
const mutation = useDeleteTaxonomy();
mutation();
expect(useMutation).toBeCalled();
const [config] = useMutation.mock.calls[0];
const { mutationFn } = config;
await act(async () => {
await mutationFn({ pk: 1 });
expect(deleteTaxonomy).toBeCalledWith(1);
describe('import taxonomy api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call import new taxonomy', async () => {
const mockResult = {
id: 8,
name: 'Taxonomy name',
exportId: 'taxonomy_export_id',
description: 'Taxonomy description',
};
axiosMock.onPost(apiUrls.createTaxonomyFromImport()).reply(201, mockResult);
const { result } = renderHook(() => useImportNewTaxonomy(), { wrapper });
const mutateResult = await result.current.mutateAsync({
name: 'Taxonomy name',
description: 'Taxonomy description',
exportId: 'taxonomy_export_id',
file: emptyFile,
});
expect(axiosMock.history.post[0].url).toEqual(apiUrls.createTaxonomyFromImport());
expect(mutateResult).toEqual(mockResult);
});
it('should call import tags', async () => {
const taxonomy = { id: 1, name: 'taxonomy name' };
axiosMock.onPut(apiUrls.tagsImport(1)).reply(200, taxonomy);
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData');
const { result } = renderHook(() => useImportTags(), { wrapper });
await result.current.mutateAsync({ taxonomyId: 1, file: emptyFile });
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsImport(1));
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['taxonomies', 'taxonomy', 1, 'tags'] });
expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomies', 'taxonomy', 1, 'metadata'], taxonomy);
});
it('should call plan import tags', async () => {
axiosMock.onPut(apiUrls.tagsPlanImport(1)).reply(200, { plan: 'some plan' });
const { result } = renderHook(() => useImportPlan(1, emptyFile), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
expect(result.current.data).toEqual('some plan');
});
it('should handle errors in plan import tags', async () => {
axiosMock.onPut(apiUrls.tagsPlanImport(1)).reply(400, { error: 'test error' });
const { result } = renderHook(() => useImportPlan(1, emptyFile), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBeTruthy();
});
expect(result.current.error).toEqual(Error('test error'));
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
});
});

View File

@@ -1,7 +1,7 @@
// @ts-check
/**
* @typedef {Object} TaxonomyData
* @typedef {Object} TaxonomyData Metadata about a taxonomy
* @property {number} id
* @property {string} name
* @property {string} description
@@ -20,14 +20,13 @@
*/
/**
* @typedef {Object} TaxonomyListData
* @typedef {Object} TaxonomyListData The list of taxonomies
* @property {string} next
* @property {string} previous
* @property {number} count
* @property {number} numPages
* @property {number} currentPage
* @property {number} start
* @property {function} refetch
* @property {boolean} canAddTaxonomy
* @property {TaxonomyData[]} results
*/

View File

@@ -18,7 +18,7 @@ const ExportModal = ({
onClose,
}) => {
const intl = useIntl();
const [outputFormat, setOutputFormat] = useState('csv');
const [outputFormat, setOutputFormat] = useState(/** @type {'csv'|'json'} */('csv'));
const onClickExport = React.useCallback(() => {
onClose();

View File

@@ -1,5 +1,5 @@
// @ts-check
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
useToggle,
@@ -22,10 +22,11 @@ import {
import PropTypes from 'prop-types';
import LoadingButton from '../../generic/loading-button';
import { LoadingSpinner } from '../../generic/Loading';
import { getFileSizeToClosestByte } from '../../utils';
import { TaxonomyContext } from '../common/context';
import { getTaxonomyExportFile } from '../data/api';
import { planImportTags, useImportTags } from './data/api';
import { useImportTags, useImportPlan } from '../data/apiHooks';
import messages from './messages';
const linebreak = <> <br /> <br /> </>;
@@ -73,20 +74,17 @@ const UploadStep = ({
file,
setFile,
importPlanError,
setImportPlanError,
}) => {
const intl = useIntl();
/** @type {(args: {fileData: FormData}) => void} */
const handleFileLoad = ({ fileData }) => {
setFile(fileData.get('file'));
setImportPlanError(null);
};
const clearFile = (e) => {
e.stopPropagation();
setFile(null);
setImportPlanError(null);
};
return (
@@ -147,7 +145,6 @@ UploadStep.propTypes = {
}),
setFile: PropTypes.func.isRequired,
importPlanError: PropTypes.string,
setImportPlanError: PropTypes.func.isRequired,
};
UploadStep.defaultProps = {
@@ -228,35 +225,28 @@ const ImportTagsWizard = ({
const [file, setFile] = useState(/** @type {null|File} */ (null));
const [importPlan, setImportPlan] = useState(/** @type {null|string[]} */ (null));
const [importPlanError, setImportPlanError] = useState(null);
const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false);
const importPlanResult = useImportPlan(taxonomy.id, file);
const importPlan = useMemo(() => {
if (!importPlanResult.data) {
return null;
}
let planArrayTemp = importPlanResult.data.split('\n');
planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines
planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line
const planArray = planArrayTemp
.filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines
.map((line) => line.split(':')[1].trim()); // Get only the action message
return /** @type {string[]} */(planArray);
}, [importPlanResult.data]);
const importTagsMutation = useImportTags();
const generatePlan = async () => {
disableDialog();
try {
if (file) {
const plan = await planImportTags(taxonomy.id, file);
let planArrayTemp = plan.split('\n');
planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines
planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line
const planArray = planArrayTemp
.filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines
.map((line) => line.split(':')[1].trim()); // Get only the action message
setImportPlan(planArray);
setImportPlanError(null);
setCurrentStep('plan');
}
} catch (/** @type {any} */ error) {
setImportPlan(null);
setImportPlanError(error.message);
} finally {
enableDialog();
}
};
const generatePlan = React.useCallback(() => {
setCurrentStep('plan');
}, []);
const confirmImportTags = async () => {
disableDialog();
@@ -326,8 +316,8 @@ const ImportTagsWizard = ({
onClose={onClose}
size="lg"
>
{isDialogDisabled && (
// This div is used to prevent the user from interacting with the dialog while it is disabled
{(isDialogDisabled) && (
// This div is used to prevent the user from interacting with the dialog while the import is happening
<div className="position-absolute w-100 h-100 d-block zindex-9" />
)}
@@ -341,8 +331,7 @@ const ImportTagsWizard = ({
<UploadStep
file={file}
setFile={setFile}
importPlanError={importPlanError}
setImportPlanError={setImportPlanError}
importPlanError={/** @type {Error|undefined} */(importPlanResult.error)?.message}
/>
<PlanStep importPlan={importPlan} />
<ConfirmStep importPlan={importPlan} />
@@ -369,11 +358,16 @@ const ImportTagsWizard = ({
<Button variant="tertiary" onClick={onClose}>
{intl.formatMessage(messages.importWizardButtonCancel)}
</Button>
<LoadingButton
label={intl.formatMessage(messages.importWizardButtonImport)}
disabled={!file || !!importPlanError}
onClick={generatePlan}
/>
{
importPlanResult.isLoading ? <LoadingSpinner />
: (
<LoadingButton
label={intl.formatMessage(messages.importWizardButtonImport)}
disabled={!file || importPlanResult.isLoading || !!importPlanResult.error}
onClick={generatePlan}
/>
)
}
</Stepper.ActionRow>
<Stepper.ActionRow eventKey="plan">

View File

@@ -1,9 +1,12 @@
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
fireEvent,
render,
waitFor,
@@ -13,29 +16,18 @@ import PropTypes from 'prop-types';
import initializeStore from '../../store';
import { getTaxonomyExportFile } from '../data/api';
import { TaxonomyContext } from '../common/context';
import { planImportTags } from './data/api';
import ImportTagsWizard from './ImportTagsWizard';
let store;
const queryClient = new QueryClient();
let axiosMock;
jest.mock('../data/api', () => ({
...jest.requireActual('../data/api'),
getTaxonomyExportFile: jest.fn(),
}));
const mockUseImportTagsMutate = jest.fn();
jest.mock('./data/api', () => ({
...jest.requireActual('./data/api'),
planImportTags: jest.fn(),
useImportTags: jest.fn(() => ({
...jest.requireActual('./data/api').useImportTags(),
mutateAsync: mockUseImportTagsMutate,
})),
}));
const mockSetToastMessage = jest.fn();
const mockSetAlertProps = jest.fn();
const context = {
@@ -45,6 +37,9 @@ const context = {
setAlertProps: mockSetAlertProps,
};
const planImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/plan/';
const doImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/';
const taxonomy = {
id: 1,
name: 'Test Taxonomy',
@@ -77,6 +72,7 @@ describe('<ImportTagsWizard />', () => {
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
@@ -129,7 +125,7 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('upload-step')).toBeInTheDocument();
// Continue flow
const importButton = getByRole('button', { name: 'Import' });
let importButton = getByRole('button', { name: 'Import' });
expect(importButton).toHaveAttribute('aria-disabled', 'true');
// Invalid file type
@@ -138,48 +134,56 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('dropzone')).toBeInTheDocument();
expect(importButton).toHaveAttribute('aria-disabled', 'true');
const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' });
// Correct file type
const fileJson = new File(['file contents'], 'example.json', { type: 'application/gzip' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: 'Import plan' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
expect(getByText('example.json')).toBeInTheDocument();
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
expect(getByText('example1.json')).toBeInTheDocument();
// Clear file
fireEvent.click(getByTestId('clear-file-button'));
expect(await findByTestId('dropzone')).toBeInTheDocument();
// Reselect file
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
// Simulate error (note: React-Query may start to retrieve the import plan as soon as the file is selected)
axiosMock.onPut(planImportUrl).replyOnce(400, { error: 'Test error - details here' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example2.json')], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
// Simulate error
planImportTags.mockRejectedValueOnce(new Error('Test error'));
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
fireEvent.click(importButton);
// Check error message
expect(planImportTags).toHaveBeenCalledWith(taxonomy.id, fileJson);
expect(await findByText('Test error')).toBeInTheDocument();
const errorAlert = getByText('Test error');
await waitFor(async () => {
// Note: import button gets re-created after showing a spinner while the import plan is loaded.
importButton = getByRole('button', { name: 'Import' });
expect(await findByText('Test error - details here')).toBeInTheDocument();
// Because of the import error, we cannot proceed to the next step
expect(importButton).toHaveAttribute('aria-disabled', 'true');
});
const errorAlert = getByText('Test error - details here');
// Reselect file to clear the error
fireEvent.click(getByTestId('clear-file-button'));
expect(errorAlert).not.toBeInTheDocument();
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
// Now simulate uploading a correct file.
const expectedPlan = 'Import plan for Test import taxonomy\n'
+ '--------------------------------\n'
+ '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n'
+ '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n'
+ '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n'
+ '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n'
+ '#5: Delete tag (external_id=old_tag_1)\n'
+ '#6: Delete tag (external_id=old_tag_2)\n';
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: expectedPlan });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example3.json')], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
const expectedPlan = 'Import plan for Test import taxonomy\n'
+ '--------------------------------\n'
+ '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n'
+ '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n'
+ '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n'
+ '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n'
+ '#5: Delete tag (external_id=old_tag_1)\n'
+ '#6: Delete tag (external_id=old_tag_2)\n';
planImportTags.mockResolvedValueOnce(expectedPlan);
await waitFor(() => {
// Note: import button gets re-created after showing a spinner while the import plan is loaded.
importButton = getByRole('button', { name: 'Import' });
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
});
fireEvent.click(importButton);
@@ -188,7 +192,6 @@ describe('<ImportTagsWizard />', () => {
// Test back button
fireEvent.click(getByTestId('back-button'));
expect(getByTestId('upload-step')).toBeInTheDocument();
planImportTags.mockResolvedValueOnce(expectedPlan);
fireEvent.click(getByRole('button', { name: 'Import' }));
expect(await findByTestId('plan-step')).toBeInTheDocument();
@@ -205,9 +208,9 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('confirm-step')).toBeInTheDocument();
if (expectedResult === 'success') {
mockUseImportTagsMutate.mockResolvedValueOnce({});
axiosMock.onPut(doImportUrl).replyOnce(200, {});
} else {
mockUseImportTagsMutate.mockRejectedValueOnce(new Error('Test error'));
axiosMock.onPut(doImportUrl).replyOnce(400, { error: 'Test error' });
}
const confirmButton = getByRole('button', { name: 'Yes, import file' });
@@ -215,24 +218,24 @@ describe('<ImportTagsWizard />', () => {
expect(confirmButton).not.toHaveAttribute('aria-disabled', 'true');
});
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockUseImportTagsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, file: fileJson });
});
act(() => { fireEvent.click(confirmButton); });
if (expectedResult === 'success') {
// Toast message shown
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
await waitFor(() => {
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
});
} else {
// Alert message shown
expect(mockSetAlertProps).toBeCalledWith(
expect.objectContaining({
variant: 'danger',
title: 'Import error',
description: 'Test error',
}),
);
await waitFor(() => {
expect(mockSetAlertProps).toBeCalledWith(
expect.objectContaining({
variant: 'danger',
title: 'Import error',
description: 'Test error',
}),
);
});
}
});
});

View File

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

View File

@@ -1,5 +0,0 @@
export default {
name: 'Taxonomy name',
exportId: 'taxonomy_export_id',
description: 'Taxonomy description',
};

View File

@@ -1,114 +0,0 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useQueryClient, useMutation } from '@tanstack/react-query';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyImportNewApiUrl = () => new URL(
'api/content_tagging/v1/taxonomies/import/',
getApiBaseUrl(),
).href;
/**
* @param {number} taxonomyId
* @returns {string}
*/
export const getTagsImportApiUrl = (taxonomyId) => new URL(
`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/`,
getApiBaseUrl(),
).href;
/**
* @param {number} taxonomyId
* @returns {string}
*/
export const getTagsPlanImportApiUrl = (taxonomyId) => new URL(
`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/plan/`,
getApiBaseUrl(),
).href;
/**
* Import a new taxonomy
* @param {string} taxonomyName
* @param {string} taxonomyDescription
* @param {File} file
* @returns {Promise<import('../../data/types.mjs').TaxonomyData>}
*/
export async function importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file) {
// ToDo: transform this to use react-query like useImportTags
const formData = new FormData();
formData.append('taxonomy_name', taxonomyName);
formData.append('taxonomy_export_id', taxonomyExportId);
formData.append('taxonomy_description', taxonomyDescription);
formData.append('file', file);
const { data } = await getAuthenticatedHttpClient().post(
getTaxonomyImportNewApiUrl(),
formData,
);
return camelCaseObject(data);
}
/**
* Build the mutation to import tags to an existing taxonomy
*/
export const useImportTags = () => {
const queryClient = useQueryClient();
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* any,
* any,
* {
* taxonomyId: number
* file: File
* }
* >}
*/
mutationFn: async ({ taxonomyId, file }) => {
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(
getTagsImportApiUrl(taxonomyId),
formData,
);
return camelCaseObject(data);
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data || err.message);
}
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: ['tagList', variables.taxonomyId],
});
queryClient.setQueryData(['taxonomyDetail', variables.taxonomyId], data);
},
});
};
/**
* Plan import tags to an existing taxonomy, overwriting existing tags
* @param {number} taxonomyId
* @param {File} file
* @returns {Promise<string>}
*/
export async function planImportTags(taxonomyId, file) {
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(
getTagsPlanImportApiUrl(taxonomyId),
formData,
);
return data.plan;
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data?.error || err.message);
}
}

View File

@@ -1,88 +0,0 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { taxonomyImportMock } from '../__mocks__';
import {
getTaxonomyImportNewApiUrl,
getTagsImportApiUrl,
getTagsPlanImportApiUrl,
importNewTaxonomy,
planImportTags,
useImportTags,
} from './api';
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('import taxonomy api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call import new taxonomy', async () => {
axiosMock.onPost(getTaxonomyImportNewApiUrl()).reply(201, taxonomyImportMock);
const result = await importNewTaxonomy('Taxonomy name', 'taxonomy_export_id', 'Taxonomy description');
expect(axiosMock.history.post[0].url).toEqual(getTaxonomyImportNewApiUrl());
expect(result).toEqual(taxonomyImportMock);
});
it('should call import tags', async () => {
const taxonomy = { id: 1, name: 'taxonomy name' };
axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, taxonomy);
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData');
const { result } = renderHook(() => useImportTags(), { wrapper });
await result.current.mutateAsync({ taxonomyId: 1 });
expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1));
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['tagList', 1],
});
expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomyDetail', 1], taxonomy);
});
it('should call plan import tags', async () => {
axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(200, { plan: 'plan' });
await planImportTags(1);
expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1));
});
it('should handle errors in plan import tags', async () => {
axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(400, { error: 'test error' });
expect(planImportTags(1)).rejects.toEqual(Error('test error'));
expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1));
});
});

View File

@@ -1,3 +1,3 @@
// @ts-check
export { importTaxonomy } from './data/utils';
export { importTaxonomy } from './utils';
export { default as ImportTagsWizard } from './ImportTagsWizard';

View File

@@ -1,6 +1,5 @@
// @ts-check
import messages from '../messages';
import { importNewTaxonomy } from './api';
import messages from './messages';
/*
* This function get a file from the user. It does this by creating a
@@ -38,7 +37,12 @@ const selectFile = async () => new Promise((resolve) => {
});
/* istanbul ignore next */
export const importTaxonomy = async (intl) => { // eslint-disable-line import/prefer-default-export
/**
* @param {*} intl The react-intl object returned by the useIntl() hook
* @param {ReturnType<typeof import('../data/apiHooks').useImportNewTaxonomy>} importMutation The import mutation
* returned by the useImportNewTaxonomy() hook.
*/
export const importTaxonomy = async (intl, importMutation) => { // eslint-disable-line import/prefer-default-export
/*
* This function is a temporary "Barebones" implementation of the import
* functionality with `prompt` and `alert`. It is intended to be replaced
@@ -92,27 +96,30 @@ export const importTaxonomy = async (intl) => { // eslint-disable-line import/pr
return;
}
const taxonomyName = getTaxonomyName();
if (taxonomyName == null) {
const name = getTaxonomyName();
if (name == null) {
return;
}
const taxonomyExportId = getTaxonomyExportId();
if (taxonomyExportId == null) {
const exportId = getTaxonomyExportId();
if (exportId == null) {
return;
}
const taxonomyDescription = getTaxonomyDescription();
if (taxonomyDescription == null) {
const description = getTaxonomyDescription();
if (description == null) {
return;
}
importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file)
.then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
})
.catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
importMutation.mutateAsync({
name,
exportId,
description,
file,
}).then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
}).catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
};

View File

@@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import { useOrganizationListData } from '../../generic/data/apiHooks';
import { TaxonomyContext } from '../common/context';
import { useTaxonomyDetailDataResponse } from '../data/apiHooks';
import { useTaxonomyDetails } from '../data/apiHooks';
import { useManageOrgs } from './data/api';
import messages from './messages';
import './ManageOrgsModal.scss';
@@ -83,7 +83,7 @@ const ManageOrgsModal = ({
data: organizationListData,
} = useOrganizationListData();
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const { data: taxonomy } = useTaxonomyDetails(taxonomyId);
const manageOrgMutation = useManageOrgs();

View File

@@ -7,8 +7,7 @@ import Proptypes from 'prop-types';
import { LoadingSpinner } from '../../generic/Loading';
import messages from './messages';
import { useTagListDataResponse, useTagListDataStatus } from './data/apiHooks';
import { useSubTags } from './data/api';
import { useTagListData, useSubTags } from './data/apiHooks';
const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => {
const subTagsData = useSubTags(taxonomyId, parentTagValue);
@@ -69,8 +68,7 @@ const TagListTable = ({ taxonomyId }) => {
pageIndex: 0,
pageSize: 100,
});
const { isLoading } = useTagListDataStatus(taxonomyId, options);
const tagList = useTagListDataResponse(taxonomyId, options);
const { isLoading, data: tagList } = useTagListData(taxonomyId, options);
const fetchData = (args) => {
if (!isEqual(args, options)) {

View File

@@ -1,52 +0,0 @@
// @ts-check
// TODO: this file needs to be merged into src/taxonomy/data/api.js
// We are creating a mess with so many different /data/[api|types].js files in subfolders.
// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file.
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getTagListApiUrl = (taxonomyId, page, pageSize) => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
url.searchParams.append('page', page + 1);
url.searchParams.append('page_size', pageSize);
return url.href;
};
/**
* @param {number} taxonomyId
* @param {import('./types.mjs').QueryOptions} options
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useTagListData = (taxonomyId, options) => {
const { pageIndex, pageSize } = options;
return useQuery({
queryKey: ['tagList', taxonomyId, pageIndex],
queryFn: async () => {
const { data } = await getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId, pageIndex, pageSize));
return camelCaseObject(data);
},
});
};
/**
* Temporary hook to load *all* the subtags of a given tag in a taxonomy.
* Doesn't handle pagination or anything. This is meant to be replaced by
* something more sophisticated later, as we improve the "taxonomy details" page.
* @param {number} taxonomyId
* @param {string} parentTagValue
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
queryKey: ['subtagsList', taxonomyId, parentTagValue],
queryFn: async () => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
url.searchParams.set('full_depth_threshold', '10000'); // Load as deeply as we can
url.searchParams.set('parent_tag', parentTagValue);
const response = await getAuthenticatedHttpClient().get(url.href);
return camelCaseObject(response.data);
},
});

View File

@@ -1,27 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import {
useTagListData,
} from './api';
const mockHttpClient = {
get: jest.fn(),
};
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(() => mockHttpClient),
}));
describe('useTagListData', () => {
it('should call useQuery with the correct parameters', () => {
useTagListData('1', { pageIndex: 3 });
expect(useQuery).toHaveBeenCalledWith({
queryKey: ['tagList', '1', 3],
queryFn: expect.any(Function),
});
});
});

View File

@@ -0,0 +1,41 @@
// @ts-check
// TODO: this file needs to be merged into src/taxonomy/data/apiHooks.js
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { apiUrls } from '../../data/api';
import { taxonomyQueryKeys } from '../../data/apiHooks';
/**
* @param {number} taxonomyId
* @param {import('./types.mjs').QueryOptions} options
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useTagListData = (taxonomyId, options) => {
const { pageIndex, pageSize } = options;
return useQuery({
queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
queryFn: async () => {
const { data } = await getAuthenticatedHttpClient().get(apiUrls.tagList(taxonomyId, pageIndex, pageSize));
return camelCaseObject(data);
},
});
};
/**
* Temporary hook to load *all* the subtags of a given tag in a taxonomy.
* Doesn't handle pagination or anything. This is meant to be replaced by
* something more sophisticated later, as we improve the "taxonomy details" page.
* @param {number} taxonomyId
* @param {string} parentTagValue
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
queryKey: taxonomyQueryKeys.taxonomyTagSubtagsList(taxonomyId, parentTagValue),
queryFn: async () => {
const response = await getAuthenticatedHttpClient().get(apiUrls.allSubtagsOf(taxonomyId, parentTagValue));
return camelCaseObject(response.data);
},
});

View File

@@ -1,41 +0,0 @@
// @ts-check
import {
useTagListData,
} from './api';
/* eslint-disable max-len */
/**
* @param {number} taxonomyId
* @param {import("./types.mjs").QueryOptions} options
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isLoading" | "isSuccess" >}
*/ /* eslint-enable max-len */
export const useTagListDataStatus = (taxonomyId, options) => {
const {
error,
isError,
isFetched,
isLoading,
isSuccess,
} = useTagListData(taxonomyId, options);
return {
error,
isError,
isFetched,
isLoading,
isSuccess,
};
};
/**
* @param {number} taxonomyId
* @param {import("./types.mjs").QueryOptions} options
* @returns {import("./types.mjs").TagListData | undefined}
*/
export const useTagListDataResponse = (taxonomyId, options) => {
const { isSuccess, data } = useTagListData(taxonomyId, options);
if (isSuccess) {
return data;
}
return undefined;
};

View File

@@ -1,45 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import {
useTagListDataStatus,
useTagListDataResponse,
} from './apiHooks';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
describe('useTagListDataStatus', () => {
it('should return status values', () => {
const status = {
error: undefined,
isError: false,
isFetched: true,
isLoading: true,
isSuccess: true,
};
useQuery.mockReturnValueOnce(status);
const result = useTagListDataStatus(0, {});
expect(result).toEqual(status);
});
});
describe('useTagListDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const result = useTagListDataResponse(0, {});
expect(result).toEqual('data');
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const result = useTagListDataResponse(0, {});
expect(result).toBeUndefined();
});
});

View File

@@ -17,7 +17,7 @@ import taxonomyMessages from '../messages';
import { TagListTable } from '../tag-list';
import { TaxonomyMenu } from '../taxonomy-menu';
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks';
import { useTaxonomyDetails } from '../data/apiHooks';
import SystemDefinedBadge from '../system-defined-badge';
const TaxonomyDetailPage = () => {
@@ -25,8 +25,11 @@ const TaxonomyDetailPage = () => {
const { taxonomyId: taxonomyIdString } = useParams();
const taxonomyId = Number(taxonomyIdString);
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
const {
data: taxonomy,
isError,
isFetched,
} = useTaxonomyDetails(taxonomyId);
if (!isFetched) {
return (

View File

@@ -6,7 +6,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render } from '@testing-library/react';
import { getTaxonomyApiUrl } from '../data/api';
import { apiUrls } from '../data/api';
import initializeStore from '../../store';
import TaxonomyDetailPage from './TaxonomyDetailPage';
@@ -64,7 +64,7 @@ describe('<TaxonomyDetailPage />', () => {
it('shows the spinner before the query is complete', () => {
// Use unresolved promise to keep the Loading visible
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(() => new Promise());
axiosMock.onGet(apiUrls.taxonomy(1)).reply(() => new Promise());
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
@@ -73,7 +73,7 @@ describe('<TaxonomyDetailPage />', () => {
it('shows the connector error component if not taxonomy returned', async () => {
// Use empty response to trigger the error. Returning an error do not
// work because the query will retry.
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
const { findByTestId } = render(<RootWrapper />);
@@ -81,7 +81,7 @@ describe('<TaxonomyDetailPage />', () => {
});
it('should render page and page title correctly', async () => {
await axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
await axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
@@ -109,7 +109,7 @@ describe('<TaxonomyDetailPage />', () => {
});
it('should show system defined badge', async () => {
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
@@ -125,7 +125,7 @@ describe('<TaxonomyDetailPage />', () => {
});
it('should not show system defined badge', async () => {
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',