Compare commits

..

16 Commits

Author SHA1 Message Date
Jesper Hodge
af494d5e13 feat: add basic pagination 2024-04-03 11:18:53 -04:00
johnvente
b58a673bba chore: removing cms link in course card items 2024-03-22 12:46:20 -05:00
johnvente
371876b4db fix: pagination enabled request and test for tab section added again 2024-03-22 12:34:24 -05:00
johnvente
17fe28a813 chore: update feature name for pagination 2024-03-20 12:44:18 -05:00
johnvente
2b74321125 fix: solve conflicts 2024-03-19 18:47:09 -05:00
johnvente
497b861d82 fix: course home number of 0 courses 2024-03-19 11:39:23 -05:00
johnvente
4701c721b8 fix: linter problems 2024-03-19 11:06:27 -05:00
johnvente
c973c2a9f9 fix: solve conflicts 2024-03-19 10:34:36 -05:00
Jhon Vente
8d3dc9a6a9 refactor: change customParams to requestParams 2024-02-23 09:08:38 -05:00
Jhon Vente
f31cae24ea feat: adding feature for pagination 2024-02-23 07:26:27 -05:00
Jhon Vente
6d6bd12e68 chore: deleting unnecessary blank line 2024-02-20 09:55:17 -05:00
Jhon Vente
f5624c9981 fix: solve conflicts 2024-02-20 09:50:47 -05:00
Jhon Vente
1f6d30ff23 test: adding test for studio home slice 2024-02-08 14:43:55 -05:00
Jhon Vente
d6e2e91b9a refactor: addressing pr comments 2024-02-08 13:15:00 -05:00
Jhon Vente
458e2191cf chore: addressing some comments 2024-02-08 10:30:45 -05:00
Jhon Vente
94be906dda feat: pagination studio home for courses 2024-02-06 20:12:13 -05:00
91 changed files with 1930 additions and 1969 deletions

1
.env
View File

@@ -41,4 +41,5 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
AI_TRANSLATIONS_BASE_URL=''
ENABLE_HOME_PAGE_COURSE_API_V2='true'
ENABLE_CHECKLIST_QUALITY=''

View File

@@ -43,4 +43,6 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
ENABLE_HOME_PAGE_COURSE_API_V2='true'
ENABLE_HOME_PAGE_LIBRARY_API_V2=true
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -35,4 +35,5 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2='true'
ENABLE_CHECKLIST_QUALITY=true

31
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"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",
@@ -2324,36 +2323,6 @@
"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,7 +36,6 @@
"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,4 +1,3 @@
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
@@ -17,9 +16,6 @@ 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,8 +41,6 @@ const CustomMenu = (props) => {
handleCommitStagedTags,
handleCancelStagedTags,
searchTerm,
selectCancelRef,
selectAddRef,
value,
} = props.selectProps;
const intl = useIntl();
@@ -58,7 +56,6 @@ const CustomMenu = (props) => {
className="taxonomy-tags-selectable-box-set"
onChange={handleSelectableBoxChange}
value={checkedTags}
tabIndex="-1"
>
<ContentTagsDropDownSelector
key={`selector-${taxonomyId}`}
@@ -73,8 +70,6 @@ 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}
@@ -82,8 +77,6 @@ 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)}
@@ -98,13 +91,6 @@ 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 (
@@ -124,7 +110,6 @@ const CustomIndicatorsContainer = (props) => {
const {
value,
handleCommitStagedTags,
selectInlineAddRef,
} = props.selectProps;
const intl = useIntl();
return (
@@ -134,12 +119,9 @@ const CustomIndicatorsContainer = (props) => {
<Button
variant="dark"
size="sm"
className="mt-2 mb-2 rounded-0 inline-add-button"
className="mt-2 mb-2 rounded-0"
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>
@@ -237,13 +219,8 @@ 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,
@@ -273,11 +250,9 @@ const ContentTagsCollapsible = ({
const handleSearchChange = React.useCallback((value, { action }) => {
if (action === 'input-blur') {
if (!selectMenuIsOpen) {
// Cancel/clear search if focused away from select input and menu closed
handleSearch.cancel();
setSearchTerm('');
}
// Cancel/clear search if focused away from select input
handleSearch.cancel();
setSearchTerm('');
} else if (action === 'input-change') {
if (value === '') {
// No need to debounce when search term cleared. Clear debounce function
@@ -287,7 +262,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.
@@ -312,55 +287,14 @@ 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">
@@ -372,18 +306,6 @@ 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}
@@ -410,9 +332,6 @@ const ContentTagsCollapsible = ({
handleCommitStagedTags={handleCommitStagedTags}
handleCancelStagedTags={handleCancelStagedTags}
searchTerm={searchTerm}
selectCancelRef={selectCancelRef}
selectAddRef={selectAddRef}
selectInlineAddRef={selectInlineAddRef}
value={stagedContentTags}
/>
)}

View File

@@ -9,89 +9,22 @@ import userEvent from '@testing-library/user-event';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import messages from './messages';
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,
}],
},
};
import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
mutate: jest.fn(),
})),
useTaxonomyTagsData: jest.fn((_, parentTagValue) => {
// To mock nested call of useTaxonomyData in subtags dropdown
if (parentTagValue === 'Tag 1') {
return nestedTaxonomyMockData;
}
return taxonomyMockData;
}),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
}));
const data = {
@@ -174,6 +107,48 @@ 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();
@@ -182,6 +157,7 @@ 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
@@ -213,6 +189,7 @@ 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
@@ -243,6 +220,7 @@ 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,
@@ -268,6 +246,7 @@ 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,
@@ -369,6 +348,7 @@ 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
@@ -396,140 +376,8 @@ 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 { useTaxonomyList } from '../taxonomy/data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
@@ -37,9 +37,14 @@ import Loading from '../generic/Loading';
*/
const ContentTagsDrawer = ({ id, onClose }) => {
const intl = useIntl();
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
// TODO: We can delete this when the iframe is no longer used on edx-platform
const params = useParams();
const contentId = id ?? params.contentId;
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 org = extractOrgFromContentId(contentId);
@@ -69,21 +74,18 @@ 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 { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
let contentName = '';
if (isContentDataLoaded) {
if ('displayName' in contentData) {
contentName = contentData.displayName;
} else {
contentName = contentData.courseDisplayNameWithDefault;
}
}
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
let onCloseDrawer = onClose;
if (onCloseDrawer === undefined) {
@@ -138,7 +140,7 @@ const ContentTagsDrawer = ({ id, onClose }) => {
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentName }</h3>
? <h3>{ contentData.displayName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner

View File

@@ -1,12 +1,7 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
fireEvent,
render,
waitFor,
screen,
act, render, fireEvent, screen,
} from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
@@ -15,7 +10,7 @@ import {
useContentData,
useTaxonomyTagsData,
} from './data/apiHooks';
import { getTaxonomyListData } from '../taxonomy/data/api';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import messages from './messages';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
@@ -28,7 +23,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
// FIXME: replace these mocks with API mocks
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
@@ -52,30 +46,20 @@ jest.mock('./data/apiHooks', () => ({
})),
}));
jest.mock('../taxonomy/data/api', () => ({
// By default, the mock taxonomy list will never load (promise never resolves):
getTaxonomyListData: jest.fn(),
jest.mock('../taxonomy/data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
}));
const queryClient = new QueryClient();
const RootWrapper = (params) => (
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ContentTagsDrawer {...params} />
</QueryClientProvider>
<ContentTagsDrawer {...params} />
</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: {
@@ -100,7 +84,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
getTaxonomyListData.mockResolvedValue({
useTaxonomyListDataResponse.mockReturnValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -164,6 +148,7 @@ 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];
@@ -196,6 +181,7 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
@@ -232,7 +218,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
getTaxonomyListData.mockResolvedValue({
useTaxonomyListDataResponse.mockReturnValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -247,7 +233,6 @@ 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');
@@ -256,11 +241,10 @@ describe('<ContentTagsDrawer />', () => {
});
});
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
it('should test adding a content tag to the staged tags for a taxonomy', () => {
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];
@@ -283,11 +267,10 @@ describe('<ContentTagsDrawer />', () => {
expect(getAllByText('Tag 3').length).toBe(2);
});
it('should test removing a staged content from a taxonomy', async () => {
it('should test removing a staged content from a taxonomy', () => {
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];
@@ -314,7 +297,7 @@ describe('<ContentTagsDrawer />', () => {
expect(getAllByText('Tag 3').length).toBe(1);
});
it('should test clearing staged tags for a taxonomy', async () => {
it('should test clearing staged tags for a taxonomy', () => {
setupMockDataForStagedTagsTesting();
const {
@@ -323,7 +306,6 @@ 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,120 +114,10 @@ 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 ? (
@@ -241,39 +131,24 @@ const ContentTagsDropDownSelector = ({
) : null }
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
{tagPages.data?.map((tagData, i) => (
<div key={tagData.value} className="mt-1 ml-1 dropdown-selector-tag-encapsulator">
{tagPages.data?.map((tagData) => (
<React.Fragment key={tagData.value}>
<div
className="d-flex flex-row"
style={{
minHeight: '44px',
}}
>
{/* 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) },
)
}
>
<div className="d-flex">
<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>
@@ -283,7 +158,8 @@ const ContentTagsDropDownSelector = ({
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="-1"
tabIndex="0"
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
/>
</div>
)}
@@ -302,18 +178,17 @@ const ContentTagsDropDownSelector = ({
/>
)}
</div>
</React.Fragment>
))}
{ hasMorePages
? (
<div>
<Button
tabIndex="0"
variant="tertiary"
iconBefore={Add}
onClick={loadMoreTags}
className="mb-2 ml-1 taxonomy-tags-load-more-button px-0 text-info-500"
className="mb-2 taxonomy-tags-load-more-button px-0 text-info-500"
>
<FormattedMessage {...messages.loadMoreTagsButtonText} />
</Button>

View File

@@ -32,8 +32,3 @@
.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,6 +199,68 @@ 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,7 +30,6 @@ 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;
@@ -75,14 +74,9 @@ export async function getContentTaxonomyTagsCount(contentId) {
* @returns {Promise<import("./types.mjs").ContentData>}
*/
export async function getContentData(contentId) {
let url;
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
} else if (contentId.startsWith('course-v1:')) {
url = getCourseContentDataApiURL(contentId);
} else {
url = getXBlockContentDataApiURL(contentId);
}
const url = contentId.startsWith('lb:')
? getLibraryContentDataApiUrl(contentId)
: getXBlockContentDataApiURL(contentId);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

View File

@@ -11,6 +11,7 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
@@ -105,6 +106,17 @@ 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)
@@ -138,13 +150,8 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
onSettled: /* istanbul ignore next */ () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
let contentPattern;
if (contentId.includes('course-v1')) {
contentPattern = contentId;
} else {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] });
},
});
};

View File

@@ -6,6 +6,7 @@ import {
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
useContentTaxonomyTagsCount,
} from './apiHooks';
import { updateContentTaxonomyTags } from './api';
@@ -134,6 +135,24 @@ 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} XBlockData
* @typedef {Object} ContentData
* @property {string} id
* @property {string} displayName
* @property {string} category
@@ -58,12 +58,3 @@
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
*/
/**
* @typedef {Object} CourseData
* @property {string} courseDisplayNameWithDefault
*/
/**
* @typedef {XBlockData | CourseData} ContentData
*/

View File

@@ -25,25 +25,9 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
defaultMessage: 'No tags found with the search term "{searchTerm}"',
},
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}',
taxonomyTagsCheckboxAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
defaultMessage: '{tag} checkbox',
},
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: contentTagsCount,
isSuccess: isContentTagsCountLoaded,
} = useContentTagsCount(contentId || '');
data: contentTaxonomyTagsCount,
isSuccess: isContentTaxonomyTagsCountLoaded,
} = useContentTaxonomyTagsCount(contentId || '');
return (
<Stack
@@ -25,8 +25,8 @@ const TagsSidebarHeader = () => {
<h3 className="course-unit-sidebar-header-title m-0">
{intl.formatMessage(messages.tagsSidebarTitle)}
</h3>
{ isContentTagsCountLoaded
&& <TagCount count={contentTagsCount} />}
{ isContentTaxonomyTagsCountLoaded
&& <TagCount count={contentTaxonomyTagsCount} />}
</Stack>
);
};

View File

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

View File

@@ -1,5 +1,4 @@
// @ts-check
import React, { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -53,6 +52,7 @@ 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,6 +113,7 @@ const CourseOutline = ({ courseId }) => {
mfeProctoredExamSettingsUrl,
handleDismissNotification,
advanceSettingsUrl,
prevContainerInfo,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
@@ -132,6 +133,27 @@ 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
@@ -246,6 +268,7 @@ const CourseOutline = ({ courseId }) => {
) : null}
</TransitionReplace>
<SubHeader
className="mt-5"
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={(
@@ -284,6 +307,7 @@ const CourseOutline = ({ courseId }) => {
items={sections}
setSections={setSections}
restoreSectionList={restoreSectionList}
prevContainerInfo={prevContainerInfo}
handleSectionDragAndDrop={handleSectionDragAndDrop}
handleSubsectionDragAndDrop={handleSubsectionDragAndDrop}
handleUnitDragAndDrop={handleUnitDragAndDrop}
@@ -295,6 +319,7 @@ const CourseOutline = ({ courseId }) => {
>
{sections.map((section, sectionIndex) => (
<SectionCard
id={section.id}
key={section.id}
section={section}
index={sectionIndex}
@@ -373,6 +398,7 @@ const CourseOutline = ({ courseId }) => {
onOrderChange={updateUnitOrderByIndex}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
/>
))}
</SortableContext>
@@ -456,7 +482,6 @@ const CourseOutline = ({ courseId }) => {
variant="danger"
icon={WarningIcon}
title={intl.formatMessage(messages.alertErrorTitle)}
description=""
aria-hidden="true"
/>
)}

View File

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

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
'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

@@ -4,3 +4,4 @@ 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,7 +1,5 @@
// @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 {
@@ -10,22 +8,18 @@ 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,
@@ -34,6 +28,7 @@ const CardHeader = ({
hasChanges,
onClickPublish,
onClickConfigure,
onClickManageTags,
onClickMenuButton,
onClickEdit,
isFormOpen,
@@ -55,18 +50,16 @@ 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) {
@@ -98,148 +91,134 @@ 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="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="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>
)}
{ 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 && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-publish-button`}
disabled={isDisabledPublish}
onClick={onClickPublish}
as={Hyperlink}
target="_blank"
destination={proctoringExamConfigurationLink}
href={proctoringExamConfigurationLink}
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
>
{intl.formatMessage(messages.menuPublish)}
{intl.formatMessage(messages.menuProctoringLinkText)}
</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-configure-button`}
onClick={onClickConfigure}
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
onClick={onClickManageTags}
>
{intl.formatMessage(messages.menuConfigure)}
{intl.formatMessage(messages.menuManageTags)}
</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 && (
{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-duplicate-button`}
onClick={onClickDuplicate}
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
onClick={onClickMoveUp}
disabled={!actions.allowMoveUp}
>
{intl.formatMessage(messages.menuDuplicate)}
{intl.formatMessage(messages.menuMoveUp)}
</Dropdown.Item>
)}
{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}
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
onClick={onClickMoveDown}
disabled={!actions.allowMoveDown}
>
{intl.formatMessage(messages.menuDelete)}
{intl.formatMessage(messages.menuMoveDown)}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</div>
</>
)}
{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>
<Sheet
position="right"
show={isManageTagsDrawerOpen}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
>
<ContentTagsDrawer
id={cardId}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
/>
</Sheet>
</>
</div>
);
};
@@ -252,6 +231,8 @@ CardHeader.defaultProps = {
discussionEnabled: false,
discussionsSettings: {},
parentInfo: {},
onClickManageTags: null,
tagsCount: undefined,
cardId: '',
};
@@ -262,6 +243,7 @@ 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,
@@ -296,6 +278,7 @@ CardHeader.propTypes = {
isTimeLimited: PropTypes.bool,
graded: PropTypes.bool,
}),
tagsCount: PropTypes.number,
};
export default CardHeader;

View File

@@ -2,9 +2,7 @@ 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';
@@ -20,15 +18,9 @@ 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,
@@ -37,6 +29,7 @@ const cardHeaderProps = {
onClickMenuButton: onClickMenuButtonMock,
onClickPublish: onClickPublishMock,
onClickEdit: onClickEditMock,
onClickManageTags: onClickManageTagsMock,
isFormOpen: false,
onEditSubmit: jest.fn(),
closeForm: closeFormMock,
@@ -56,8 +49,6 @@ const cardHeaderProps = {
},
};
const queryClient = new QueryClient();
const renderComponent = (props, entry = '/') => {
const titleComponent = (
<TitleButton
@@ -71,15 +62,13 @@ const renderComponent = (props, entry = '/') => {
return render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<CardHeader
{...cardHeaderProps}
titleComponent={titleComponent}
{...props}
/>
</MemoryRouter>
</QueryClientProvider>
<MemoryRouter initialEntries={[entry]}>
<CardHeader
{...cardHeaderProps}
titleComponent={titleComponent}
{...props}
/>
</MemoryRouter>,
</IntlProvider>,
);
};
@@ -181,32 +170,14 @@ describe('<CardHeader />', () => {
expect(onClickPublishMock).toHaveBeenCalled();
});
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',
});
it('calls onClickManageTags when the menu is clicked', async () => {
renderComponent();
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
const manageTagsMenuItem = await screen.findByText(messages.menuManageTags.defaultMessage);
fireEvent.click(manageTagsMenuItem);
// Check if the drawer is open
expect(screen.getByTestId('drawer-close-button')).toBeInTheDocument();
await act(async () => fireEvent.click(manageTagsMenuItem));
expect(onClickManageTagsMock).toHaveBeenCalled();
});
it('calls onClickEdit when the button is clicked', async () => {
@@ -293,33 +264,19 @@ describe('<CardHeader />', () => {
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
});
it('should render tag count if is not zero and the waffle flag is enabled', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
it('should render tag count if is not zero', () => {
renderComponent({
...cardHeaderProps,
tagsCount: 17,
});
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();
expect(screen.getByText('17')).toBeInTheDocument();
});
it('should not render tag count if is zero', () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
renderComponent({
...cardHeaderProps,
tagsCount: 0,
});
mockGetTagsCount.mockResolvedValue({ 12345: 0 });
renderComponent();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
});

View File

@@ -29,6 +29,7 @@ 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
@@ -472,3 +473,18 @@ 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

@@ -0,0 +1,40 @@
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

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ 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';
@@ -35,34 +34,30 @@ const section = {
const onEditSectionSubmit = jest.fn();
const queryClient = new QueryClient();
const renderComponent = (props) => render(
<AppProvider store={store}>
<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>
<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>,
</AppProvider>,
);

View File

@@ -2,38 +2,16 @@ 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, Sheet, Stack, useToggle,
Button, Hyperlink, Form, Stack,
} 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,
@@ -70,135 +48,109 @@ const StatusBar = ({
socialSharing: socialSharingUrl,
} = useHelpUrls(['contentHighlights', 'socialSharing']);
const { data: courseTagCount } = useContentTagsCount(courseId);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
if (isLoading) {
return null;
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
return (
<>
<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)}>
<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>
)}
<Hyperlink
className="small"
destination={scheduleDestination()}
className="small ml-2"
destination={contentHighlightsUrl}
target="_blank"
showLaunchIcon={false}
>
{courseReleaseDateObj.isValid() ? (
<FormattedDate
value={courseReleaseDateObj}
year="numeric"
month="short"
day="2-digit"
hour="numeric"
minute="numeric"
/>
) : courseReleaseDate}
{intl.formatMessage(messages.highlightEmailsLink)}
</Hyperlink>
</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>
</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>
<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>
)}
<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 ml-2"
destination={contentHighlightsUrl}
className="small"
destination={socialSharingUrl}
target="_blank"
showLaunchIcon={false}
>
{intl.formatMessage(messages.highlightEmailsLink)}
{intl.formatMessage(messages.videoSharingLink)}
</Hyperlink>
</div>
</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>
</Form.Group>
)}
</Stack>
<Sheet
position="right"
show={isManageTagsDrawerOpen}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
>
<ContentTagsDrawer
id={courseId}
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
/>
</Sheet>
</>
)}
</Stack>
);
};

View File

@@ -3,8 +3,6 @@ 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';
@@ -13,7 +11,7 @@ import { VIDEO_SHARING_OPTIONS } from '../constants';
let store;
const mockPathname = '/foo-bar';
const courseId = 'course-v1:123';
const courseId = '123';
const isLoading = false;
const openEnableHighlightsModalMock = jest.fn();
const handleVideoSharingOptionChange = jest.fn();
@@ -25,11 +23,6 @@ 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',
@@ -52,22 +45,18 @@ const statusBarData = {
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
};
const queryClient = new QueryClient();
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<StatusBar
courseId={courseId}
isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
statusBarData={statusBarData}
{...props}
/>
</IntlProvider>
</QueryClientProvider>
<IntlProvider locale="en">
<StatusBar
courseId={courseId}
isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
statusBarData={statusBarData}
{...props}
/>
</IntlProvider>
</AppProvider>,
);
@@ -144,23 +133,4 @@ 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,16 +41,6 @@ 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,5 +1,4 @@
// @ts-check
import React, {
import {
useContext, useEffect, useState, useRef,
} from 'react';
import PropTypes from 'prop-types';
@@ -166,7 +165,6 @@ const SubsectionCard = ({
<CardHeader
title={displayName}
status={subsectionStatus}
cardId={id}
hasChanges={hasChanges}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}

View File

@@ -8,7 +8,6 @@ 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';
@@ -53,39 +52,36 @@ const subsection = {
};
const onEditSubectionSubmit = jest.fn();
const queryClient = new QueryClient();
const renderComponent = (props, entry = '/') => render(
<AppProvider store={store} wrapWithRouter={false}>
<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>
<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>,
</AppProvider>,
);

View File

@@ -1,8 +1,7 @@
// @ts-check
import React, { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { useToggle, Sheet } from '@openedx/paragon';
import { isEmpty } from 'lodash';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
@@ -12,6 +11,7 @@ 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,11 +31,13 @@ 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,
@@ -127,63 +129,77 @@ 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,
}}
>
<div
className="unit-card"
data-testid="unit-card"
ref={currentRef}
<>
<SortableItem
id={id}
category={category}
key={id}
isDraggable={isDraggable}
isDroppable={actions.childAddable}
componentStyle={{
background: '#fdfdfd',
...borderStyle,
}}
>
<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}
/>
<div className="unit-card__content item-children" data-testid="unit-card__content">
<XBlockStatus
isSelfPaced={isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
blockData={unit}
<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>
</div>
</SortableItem>
</SortableItem>
<Sheet
position="right"
show={showManageTags}
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
>
<ContentTagsDrawer
id={id}
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
/>
</Sheet>
</>
);
};
UnitCard.defaultProps = {
discussionsSettings: {},
tagsCount: undefined,
};
UnitCard.propTypes = {
@@ -240,6 +256,7 @@ UnitCard.propTypes = {
providerType: PropTypes.string,
enableGradedUnits: PropTypes.bool,
}),
tagsCount: PropTypes.number,
};
export default UnitCard;

View File

@@ -7,7 +7,6 @@ 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';
@@ -50,33 +49,29 @@ const unit = {
isHeaderVisible: true,
};
const queryClient = new QueryClient();
const renderComponent = (props) => render(
<AppProvider store={store}>
<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>
<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>,
</AppProvider>,
);

View File

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

View File

@@ -26,10 +26,7 @@ const VideoThumbnail = ({
intl,
}) => {
const fileInputControl = useFileInput({
onAddFile: (files) => {
const [file] = files;
handleAddThumbnail(file, id);
},
onAddFile: (file) => handleAddThumbnail(file, id),
setSelectedRows: () => {},
setAddOpen: () => false,
});
@@ -49,30 +46,18 @@ const VideoThumbnail = ({
const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded;
return (
<div className="video-thumbnail row justify-content-center align-itmes-center">
{allowThumbnailUpload && showThumbnail && <div className="thumbnail-overlay" />}
<div data-testid={`video-thumbnail-${id}`} className="video-thumbnail row justify-content-center align-itmes-center">
{allowThumbnailUpload && <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="add-thumbnail" data-testid={`video-thumbnail-${id}`}>
<Button
variant="primary"
size="sm"
onClick={fileInputControl.click}
tabIndex="0"
>
{addThumbnailMessage}
</Button>
</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
@@ -91,12 +76,24 @@ const VideoThumbnail = ({
</>
)}
{allowThumbnailUpload && (
<FileInput
key="video-thumbnail-upload"
fileInput={fileInputControl}
supportedFileFormats={supportedFiles}
allowMultiple={false}
/>
<>
<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}
/>
</>
)}
</div>
);

View File

@@ -218,12 +218,6 @@ 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,8 +32,7 @@ const Transcript = ({
}, [transcript]);
const input = useFileInput({
onAddFile: (files) => {
const [file] = files;
onAddFile: (file) => {
handleTranscript({
file,
language,

View File

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

View File

@@ -8,7 +8,6 @@ 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.
@@ -44,18 +43,3 @@ 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,7 +2,6 @@ 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,
@@ -10,8 +9,6 @@ import {
getCreateOrRerunCourseUrl,
getCourseRerunUrl,
getCourseRerun,
getTagsCount,
getTagsCountApiUrl,
} from './api';
let axiosMock;
@@ -75,19 +72,4 @@ 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, getTagsCount } from './api';
import { getOrganizations } from './api';
/**
* Builds the query to get a list of available organizations
@@ -12,23 +12,4 @@ export const 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
});
};
export default useOrganizationListData;

View File

@@ -14,7 +14,6 @@ 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';
@@ -38,21 +37,6 @@ 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,
@@ -117,8 +101,6 @@ subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({
handlers: {
config: () => {
@@ -143,6 +125,8 @@ initialize({
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false',
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
ENABLE_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 || 'false',
ENABLE_HOME_PAGE_LIBRARY_API_V2: process.env.ENABLE_HOME_PAGE_LIBRARY_API_V2 || 'false',
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
}, 'CourseAuthoringConfig');
},

View File

@@ -26,6 +26,9 @@ import { useStudioHome } from './hooks';
import AlertMessage from '../generic/alert-message';
const StudioHome = ({ intl }) => {
const isPaginationCoursesEnabled = getConfig().ENABLE_HOME_PAGE_COURSE_API_V2 === 'true';
const isPaginationLibrariesEnabled = getConfig().ENABLE_HOME_PAGE_LIBRARY_API_V2 === 'true';
const {
isLoadingPage,
isFailedLoadingPage,
@@ -39,7 +42,7 @@ const StudioHome = ({ intl }) => {
hasAbilityToCreateNewCourse,
setShowNewCourseContainer,
dispatch,
} = useStudioHome();
} = useStudioHome(isPaginationCoursesEnabled);
const {
userIsActive,
@@ -139,6 +142,8 @@ const StudioHome = ({ intl }) => {
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing}
dispatch={dispatch}
isPaginationCoursesEnabled={isPaginationCoursesEnabled}
isPaginationLibrariesEnabled={isPaginationLibrariesEnabled}
/>
</section>
</Layout.Element>

View File

@@ -12,6 +12,7 @@ module.exports = {
rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023',
run: '2023',
url: '/course/course-v1:MachineLearning+123+2023',
cmsLink: '//localhost:18010/courses/course-v1:MachineLearning+123+2023',
},
{
courseKey: 'course-v1:Design+123+e.g.2025',
@@ -22,6 +23,7 @@ module.exports = {
rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025',
run: 'e.g.2025',
url: '/course/course-v1:Design+123+e.g.2025',
cmsLink: '//localhost:18010/courses/course-v1:Design+123+e.g.2025',
},
],
canCreateOrganizations: true,

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
@@ -43,6 +43,7 @@ describe('<CardItem />', () => {
const { getByText } = render(<RootWrapper {...props} />);
expect(getByText(`${props.org} / ${props.number} / ${props.run}`)).toBeInTheDocument();
});
it('should render correct links for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText } = render(<RootWrapper {...props} />);
@@ -53,6 +54,19 @@ describe('<CardItem />', () => {
const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render correct links for non-library course pagination', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText, getByTestId } = render(<RootWrapper {...props} isPaginated />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const dropDownMenu = getByTestId('toggle-dropdown');
fireEvent.click(dropDownMenu);
const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', props.rerunLink);
const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render course details for library course', () => {
const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true };
const { getByText } = render(<RootWrapper {...props} />);

View File

@@ -1,7 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { ActionRow, Card, Hyperlink } from '@openedx/paragon';
import {
Card,
Hyperlink,
Dropdown,
IconButton,
ActionRow,
} from '@openedx/paragon';
import { MoreHoriz } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
@@ -10,7 +17,17 @@ import { getStudioHomeData } from '../data/selectors';
import messages from '../messages';
const CardItem = ({
intl, displayName, lmsLink, rerunLink, org, number, run, isLibraries, url,
intl,
displayName,
lmsLink,
rerunLink,
org,
number,
run,
isLibraries,
courseKey,
isPaginated,
url,
}) => {
const {
allowCourseReruns,
@@ -41,16 +58,45 @@ const CardItem = ({
)}
subtitle={subtitle}
actions={showActions && (
<ActionRow>
{isShowRerunLink && (
<Hyperlink className="small" destination={rerunLink}>
{intl.formatMessage(messages.btnReRunText)}
isPaginated ? (
<Dropdown>
<Dropdown.Toggle
as={IconButton}
iconAs={MoreHoriz}
variant="primary"
data-testid="toggle-dropdown"
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item href={rerunLink}>
{messages.btnReRunText.defaultMessage}
</Dropdown.Item>
)}
<Dropdown.Item href={lmsLink}>
{intl.formatMessage(messages.viewLiveBtnText)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<ActionRow>
{isShowRerunLink && (
<Hyperlink
className="small"
destination={rerunLink}
key={`action-row-rerunLink-${courseKey}`}
>
{intl.formatMessage(messages.btnReRunText)}
</Hyperlink>
)}
<Hyperlink
className="small ml-3"
destination={lmsLink}
key={`action-row-lmsLink-${courseKey}`}
>
{intl.formatMessage(messages.viewLiveBtnText)}
</Hyperlink>
)}
<Hyperlink className="small ml-3" destination={lmsLink}>
{intl.formatMessage(messages.viewLiveBtnText)}
</Hyperlink>
</ActionRow>
</ActionRow>
)
)}
/>
</Card>
@@ -59,6 +105,8 @@ const CardItem = ({
CardItem.defaultProps = {
isLibraries: false,
isPaginated: false,
courseKey: '',
rerunLink: '',
lmsLink: '',
run: '',
@@ -74,6 +122,8 @@ CardItem.propTypes = {
number: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
isLibraries: PropTypes.bool,
courseKey: PropTypes.string,
isPaginated: PropTypes.bool,
};
export default injectIntl(CardItem);

View File

@@ -20,12 +20,30 @@ export async function getStudioHomeCourses(search) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`);
return camelCaseObject(data);
}
/**
* Get's studio home courses.
* @param {string} search - Query string parameters for filtering the courses.
* @param {object} customParams - Additional custom parameters for the API request.
* @returns {Promise<Object>} - A Promise that resolves to the response data containing the studio home courses.
* Note: We are changing /api/contentstore/v1 to /api/contentstore/v2 due to upcoming breaking changes.
* Features such as pagination, filtering, and ordering are better handled in the new version.
* Please refer to this PR for further details: https://github.com/openedx/edx-platform/pull/34173
*/
export async function getStudioHomeCoursesV2(search, customParams) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParams });
return camelCaseObject(data);
}
export async function getStudioHomeLibraries() {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/libraries`);
return camelCaseObject(data);
}
export async function getStudioHomeLibrariesV2(customParams) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/libraries`, { params: customParams });
return camelCaseObject(data);
}
/**
* Handle course notification requests.
* @param {string} url

View File

@@ -11,6 +11,7 @@ import {
sendRequestForCourseCreator,
getApiBaseUrl,
getStudioHomeCourses,
getStudioHomeCoursesV2,
getStudioHomeLibraries,
} from './api';
import { generateGetStudioCoursesApiResponse, generateGetStudioHomeDataApiResponse, generateGetStuioHomeLibrariesApiResponse } from '../factories/mockApiResponses';
@@ -43,7 +44,7 @@ describe('studio-home api calls', () => {
expect(result).toEqual(expected);
});
fit('should get studio courses data', async () => {
it('should get studio courses data', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCourses('');
@@ -53,6 +54,16 @@ describe('studio-home api calls', () => {
expect(result).toEqual(expected);
});
it('should get studio courses data v2', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCoursesV2('');
const expected = generateGetStudioCoursesApiResponse();
expect(axiosMock.history.get[0].url).toEqual(apiLink);
expect(result).toEqual(expected);
});
it('should get studio libraries data', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`;
axiosMock.onGet(apiLink).reply(200, generateGetStuioHomeLibrariesApiResponse());

View File

@@ -1,3 +1,4 @@
export const getStudioHomeData = state => state.studioHome.studioHomeData;
export const getLoadingStatuses = (state) => state.studioHome.loadingStatuses;
export const getSavingStatuses = (state) => state.studioHome.savingStatuses;
export const getStudioHomeCoursesParams = (state) => state.studioHome.studioHomeCoursesRequestParams;

View File

@@ -17,6 +17,9 @@ const slice = createSlice({
deleteNotificationSavingStatus: '',
},
studioHomeData: {},
studioHomeCoursesRequestParams: {
currentPage: 1,
},
},
reducers: {
updateLoadingStatuses: (state, { payload }) => {
@@ -34,10 +37,23 @@ const slice = createSlice({
state.studioHomeData.archivedCourses = archivedCourses;
state.studioHomeData.inProcessCourseActions = inProcessCourseActions;
},
fetchCourseDataSuccessV2: (state, { payload }) => {
const { courses, archivedCourses = [], inProcessCourseActions } = payload.results;
const { numPages, count } = payload;
state.studioHomeData.courses = courses;
state.studioHomeData.archivedCourses = archivedCourses;
state.studioHomeData.inProcessCourseActions = inProcessCourseActions;
state.studioHomeData.numPages = numPages;
state.studioHomeData.coursesCount = count;
},
fetchLibraryDataSuccess: (state, { payload }) => {
const { libraries } = payload;
state.studioHomeData.libraries = libraries;
},
updateStudioHomeCoursesCustomParams: (state, { payload }) => {
const { currentPage } = payload;
state.studioHomeCoursesRequestParams.currentPage = currentPage;
},
},
});
@@ -46,7 +62,9 @@ export const {
updateLoadingStatuses,
fetchStudioHomeDataSuccess,
fetchCourseDataSuccess,
fetchCourseDataSuccessV2,
fetchLibraryDataSuccess,
updateStudioHomeCoursesCustomParams,
} = slice.actions;
export const {

View File

@@ -0,0 +1,42 @@
import { reducer, updateStudioHomeCoursesCustomParams } from './slice'; // Assuming the file is named slice.js
import { RequestStatus } from '../../data/constants';
describe('updateStudioHomeCoursesCustomParams action', () => {
const initialState = {
loadingStatuses: {
studioHomeLoadingStatus: RequestStatus.IN_PROGRESS,
courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS,
courseLoadingStatus: RequestStatus.IN_PROGRESS,
libraryLoadingStatus: RequestStatus.IN_PROGRESS,
},
savingStatuses: {
courseCreatorSavingStatus: '',
deleteNotificationSavingStatus: '',
},
studioHomeData: {},
studioHomeCoursesRequestParams: {
currentPage: 1,
},
};
it('should return the initial state', () => {
const result = reducer(undefined, { type: undefined });
expect(result).toEqual(initialState);
});
it('should update the currentPage in studioHomeCoursesRequestParams', () => {
const newState = {
...initialState,
studioHomeCoursesRequestParams: {
currentPage: 2,
},
};
const payload = {
currentPage: 2,
};
const result = reducer(initialState, updateStudioHomeCoursesCustomParams(payload));
expect(result).toEqual(newState);
});
});

View File

@@ -5,6 +5,8 @@ import {
handleCourseNotification,
getStudioHomeCourses,
getStudioHomeLibraries,
getStudioHomeLibrariesV2,
getStudioHomeCoursesV2,
} from './api';
import {
fetchStudioHomeDataSuccess,
@@ -12,9 +14,10 @@ import {
updateLoadingStatuses,
updateSavingStatuses,
fetchLibraryDataSuccess,
fetchCourseDataSuccessV2,
} from './slice';
function fetchStudioHomeData(search, hasHomeData) {
function fetchStudioHomeData(search, hasHomeData, requestParams = {}, isPaginationEnabled = false) {
return async (dispatch) => {
dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS }));
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.IN_PROGRESS }));
@@ -30,8 +33,14 @@ function fetchStudioHomeData(search, hasHomeData) {
}
}
try {
const coursesData = await getStudioHomeCourses(search || '');
dispatch(fetchCourseDataSuccess(coursesData));
if (isPaginationEnabled) {
const coursesData = await getStudioHomeCoursesV2(search || '', requestParams);
dispatch(fetchCourseDataSuccessV2(coursesData));
} else {
const coursesData = await getStudioHomeCourses(search || '');
dispatch(fetchCourseDataSuccess(coursesData));
}
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.FAILED }));
@@ -39,12 +48,14 @@ function fetchStudioHomeData(search, hasHomeData) {
};
}
function fetchLibraryData() {
function fetchLibraryData(isPaginationEnabled = false, requestParams = {}) {
return async (dispatch) => {
dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.IN_PROGRESS }));
try {
const libraryData = await getStudioHomeLibraries();
const libraryData = isPaginationEnabled
? await getStudioHomeLibrariesV2(requestParams)
: await getStudioHomeLibraries();
dispatch(fetchLibraryDataSuccess(libraryData));
dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {

View File

@@ -15,6 +15,9 @@ export const initialState = {
deleteNotificationSavingStatus: '',
},
studioHomeData: {},
studioHomeCoursesRequestParams: {
currentPage: 1,
},
},
};
@@ -93,6 +96,38 @@ export const generateGetStudioCoursesApiResponse = () => ({
inProcessCourseActions: [],
});
export const generateGetStudioCoursesApiResponseV2 = () => ({
count: 5,
next: null,
previous: null,
numPages: 2,
results: {
courses: [
{
courseKey: 'course-v1:HarvardX+123+2023',
displayName: 'Managing Risk in the Information Age',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '123',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: 'org.0/course_0/Run_0',
displayName: 'Run 0',
lmsLink: null,
number: 'course_0',
org: 'org.0',
rerunLink: null,
run: 'Run_0',
url: null,
},
],
inProcessCourseActions: [],
},
});
export const generateGetStuioHomeLibrariesApiResponse = () => ({
libraries: [
{

View File

@@ -10,13 +10,15 @@ import {
getLoadingStatuses,
getSavingStatuses,
getStudioHomeData,
getStudioHomeCoursesParams,
} from './data/selectors';
import { updateSavingStatuses } from './data/slice';
const useStudioHome = () => {
const useStudioHome = (isPaginated = false) => {
const location = useLocation();
const dispatch = useDispatch();
const studioHomeData = useSelector(getStudioHomeData);
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
const newCourseData = useSelector(getCourseData);
const { studioHomeLoadingStatus } = useSelector(getLoadingStatuses);
const savingCreateRerunStatus = useSelector(getSavingStatus);
@@ -29,10 +31,19 @@ const useStudioHome = () => {
const isFailedLoadingPage = studioHomeLoadingStatus === RequestStatus.FAILED;
useEffect(() => {
dispatch(fetchStudioHomeData(location.search ?? ''));
setShowNewCourseContainer(false);
if (!isPaginated) {
dispatch(fetchStudioHomeData(location.search ?? ''));
setShowNewCourseContainer(false);
}
}, [location.search]);
useEffect(() => {
if (isPaginated) {
const { currentPage } = studioHomeCoursesParams;
dispatch(fetchStudioHomeData(location.search ?? '', false, { page: currentPage }, true));
}
}, [studioHomeCoursesParams.currentPage]);
useEffect(() => {
if (courseCreatorSavingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' }));

View File

@@ -17,6 +17,7 @@ import {
initialState,
generateGetStudioHomeDataApiResponse,
generateGetStudioCoursesApiResponse,
generateGetStudioCoursesApiResponseV2,
generateGetStuioHomeLibrariesApiResponse,
} from '../factories/mockApiResponses';
import { getApiBaseUrl, getStudioHomeApiUrl } from '../data/api';
@@ -28,12 +29,20 @@ const { studioShortName } = studioHomeMock;
let axiosMock;
let store;
const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`;
const RootWrapper = () => (
const mockDispatch = jest.fn();
const RootWrapper = (overrideProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TabsSection intl={{ formatMessage: jest.fn() }} dispatch={jest.fn()} />
<TabsSection
intl={{ formatMessage: jest.fn() }}
dispatch={mockDispatch}
isPaginationCoursesEnabled={false}
{...overrideProps}
/>
</IntlProvider>
</AppProvider>
);
@@ -116,6 +125,36 @@ describe('<TabsSection />', () => {
expect(screen.getByText(tabMessages.courseTabErrorMessage.defaultMessage)).toBeVisible();
});
it('should render pagination when there are courses', async () => {
render(<RootWrapper isPaginationCoursesEnabled />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLinkV2).reply(200, generateGetStudioCoursesApiResponseV2());
await executeThunk(fetchStudioHomeData('', true, {}, true), store.dispatch);
const data = generateGetStudioCoursesApiResponseV2();
const coursesLength = data.results.courses.length;
const totalItems = data.count;
const paginationInfoText = `Showing ${coursesLength} of ${totalItems}`;
expect(screen.getByText(studioHomeMock.courses[0].displayName)).toBeVisible();
const pagination = screen.getByRole('navigation');
const paginationInfo = screen.getByTestId('pagination-info');
expect(paginationInfo.textContent).toContain(paginationInfoText);
expect(pagination).toBeVisible();
});
it('should not render pagination when there are not courses', async () => {
const data = generateGetStudioCoursesApiResponseV2();
data.results.courses = [];
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLinkV2).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
const pagination = screen.queryByRole('navigation');
expect(pagination).not.toBeInTheDocument();
});
});
describe('archived tab', () => {

View File

@@ -2,11 +2,12 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Row } from '@openedx/paragon';
import { Icon, Row, Pagination } from '@openedx/paragon';
import { Error } from '@openedx/paragon/icons';
import { COURSE_CREATOR_STATES } from '../../../constants';
import { getStudioHomeData } from '../../data/selectors';
import { getStudioHomeData, getStudioHomeCoursesParams } from '../../data/selectors';
import { updateStudioHomeCoursesCustomParams } from '../../data/slice';
import CardItem from '../../card-item';
import CollapsibleStateWithAction from '../../collapsible-state-with-action';
import { sortAlphabeticallyArray } from '../utils';
@@ -23,12 +24,17 @@ const CoursesTab = ({
isShowProcessing,
isLoading,
isFailed,
dispatch,
numPages,
coursesCount,
isEnabledPagination,
}) => {
const intl = useIntl();
const {
courseCreatorStatus,
optimizationEnabled,
} = useSelector(getStudioHomeData);
const { currentPage } = useSelector(getStudioHomeCoursesParams);
const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted;
const showCollapsible = [
COURSE_CREATOR_STATES.denied,
@@ -36,6 +42,9 @@ const CoursesTab = ({
COURSE_CREATOR_STATES.unrequested,
].includes(courseCreatorStatus);
const handlePageSelected = (page) => dispatch(updateStudioHomeCoursesCustomParams({ currentPage: page }));
const hasCourses = coursesDataItems?.length > 0;
if (isLoading) {
return (
<Row className="m-0 mt-4 justify-content-center">
@@ -58,30 +67,55 @@ const CoursesTab = ({
) : (
<>
{isShowProcessing && <ProcessingCourses />}
{coursesDataItems?.length ? (
sortAlphabeticallyArray(coursesDataItems).map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
{hasCourses && isEnabledPagination && (
<div className="d-flex justify-content-end">
<p data-testid="pagination-info">
{intl.formatMessage(messages.coursesPaginationInfo, {
length: coursesDataItems.length,
total: coursesCount,
})}
</p>
</div>
)}
{hasCourses ? (
<>
{sortAlphabeticallyArray(coursesDataItems).map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
cmsLink,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
cmsLink={cmsLink}
isPaginated={isEnabledPagination}
/>
),
)}
{numPages > 1 && isEnabledPagination && (
<Pagination
className="d-flex justify-content-center"
paginationLabel="pagination navigation"
pageCount={numPages}
currentPage={currentPage}
onPageSelect={handlePageSelected}
/>
),
)
)}
</>
) : (!optimizationEnabled && (
<ContactAdministrator
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
@@ -101,6 +135,12 @@ const CoursesTab = ({
);
};
CoursesTab.defaultProps = {
numPages: 0,
coursesCount: 0,
isEnabledPagination: false,
};
CoursesTab.propTypes = {
coursesDataItems: PropTypes.arrayOf(
PropTypes.shape({
@@ -119,6 +159,10 @@ CoursesTab.propTypes = {
isShowProcessing: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
isFailed: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
numPages: PropTypes.number,
coursesCount: PropTypes.number,
isEnabledPagination: PropTypes.bool,
};
export default CoursesTab;

View File

@@ -13,7 +13,13 @@ import { RequestStatus } from '../../data/constants';
import { fetchLibraryData } from '../data/thunks';
const TabsSection = ({
intl, showNewCourseContainer, onClickNewCourse, isShowProcessing, dispatch,
intl,
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
dispatch,
isPaginationCoursesEnabled,
isPaginationLibrariesEnabled,
}) => {
const TABS_LIST = {
courses: 'courses',
@@ -25,6 +31,7 @@ const TabsSection = ({
libraryAuthoringMfeUrl,
redirectToLibraryAuthoringMfe,
courses, librariesEnabled, libraries, archivedCourses,
numPages, coursesCount,
} = useSelector(getStudioHomeData);
const {
courseLoadingStatus,
@@ -52,6 +59,10 @@ const TabsSection = ({
isShowProcessing={isShowProcessing}
isLoading={isLoadingCourses}
isFailed={isFailedCoursesPage}
dispatch={dispatch}
numPages={numPages}
coursesCount={coursesCount}
isEnabledPagination={isPaginationCoursesEnabled}
/>
</Tab>,
);
@@ -84,6 +95,7 @@ const TabsSection = ({
libraries={libraries}
isLoading={isLoadingLibraries}
isFailed={isFailedLibrariesPage}
isEnabledPagination={isPaginationLibrariesEnabled}
/>
)}
</Tab>,
@@ -97,7 +109,7 @@ const TabsSection = ({
if (tab === TABS_LIST.libraries && redirectToLibraryAuthoringMfe) {
window.location.assign(libraryAuthoringMfeUrl);
} else if (tab === TABS_LIST.libraries && !redirectToLibraryAuthoringMfe) {
dispatch(fetchLibraryData());
dispatch(fetchLibraryData(isPaginationLibrariesEnabled));
}
setTabKey(tab);
};
@@ -114,12 +126,19 @@ const TabsSection = ({
);
};
TabsSection.defaultProps = {
isPaginationCoursesEnabled: false,
isPaginationLibrariesEnabled: false,
};
TabsSection.propTypes = {
intl: intlShape.isRequired,
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
isPaginationCoursesEnabled: PropTypes.bool,
isPaginationLibrariesEnabled: PropTypes.bool,
};
export default injectIntl(TabsSection);

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.courses.tab.error.message',
defaultMessage: 'Failed to fetch courses. Please try again later.',
},
coursesPaginationInfo: {
id: 'course-authoring.studio-home.courses.pagination.info',
defaultMessage: 'Showing {length} of {total}',
},
librariesTabErrorMessage: {
id: 'course-authoring.studio-home.libraries.tab.error.message',
defaultMessage: 'Failed to fetch libraries. Please try again later.',

View File

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

View File

@@ -4,17 +4,13 @@ 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,
} from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { apiUrls } from './data/api';
import { getTaxonomyTemplateApiUrl } from './data/api';
import TaxonomyListPage from './TaxonomyListPage';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import { TaxonomyContext } from './common/context';
@@ -31,12 +27,14 @@ 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(),
}));
@@ -84,8 +82,7 @@ describe('<TaxonomyListPage />', () => {
});
it('shows the spinner before the query is complete', async () => {
// Simulate an API request that times out:
axiosMock.onGet(listTaxonomiesUrl).reply(new Promise(() => {}));
useIsTaxonomyListDataLoaded.mockReturnValue(false);
await act(async () => {
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
@@ -94,50 +91,61 @@ describe('<TaxonomyListPage />', () => {
});
it('shows the data table after the query is complete', async () => {
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
canAddTaxonomy: false,
});
await act(async () => {
const { getByTestId, queryByText } = render(<RootWrapper />);
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
const { getByTestId } = render(<RootWrapper />);
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
});
});
it.each(['CSV', 'JSON'])('downloads the taxonomy template %s', async (fileFormat) => {
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); });
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
canAddTaxonomy: false,
});
const { findByRole } = render(<RootWrapper />);
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(apiUrls.taxonomyTemplate(fileFormat.toLowerCase()));
expect(templateButton.href).toBe(getTaxonomyTemplateApiUrl(fileFormat.toLowerCase()));
});
it('disables the import taxonomy button if not permitted', async () => {
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: false });
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [],
canAddTaxonomy: false,
});
const { queryByText, getByRole } = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
const { getByRole } = render(<RootWrapper />);
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).toBeDisabled();
});
it('calls the import taxonomy action when the import button is clicked', async () => {
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: true });
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [],
canAddTaxonomy: true,
});
const { getByRole } = render(<RootWrapper />);
const importButton = getByRole('button', { name: 'Import' });
// Once the API response is received and rendered, the Import button should be enabled:
await waitFor(() => { expect(importButton).not.toBeDisabled(); });
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 () => {
axiosMock.onGet(listTaxonomiesUrl).reply(200, {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [{
id: 1,
name: 'Taxonomy',
@@ -155,10 +163,7 @@ 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
@@ -179,29 +184,13 @@ describe('<TaxonomyListPage />', () => {
});
it('should fetch taxonomies with correct params for org filters', async () => {
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, {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
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, getByText, queryByText } = render(<RootWrapper />);
const { getByRole } = render(<RootWrapper />);
// Open the taxonomies org filter select menu
const taxonomiesFilterSelectMenu = await getByRole('button', { name: 'All taxonomies' });
@@ -209,28 +198,22 @@ describe('<TaxonomyListPage />', () => {
// Check that the 'Unassigned' option is correctly called
fireEvent.click(getByRole('link', { name: 'Unassigned' }));
await waitFor(() => {
expect(getByText('Unassigned Taxonomy A')).toBeInTheDocument();
});
expect(useTaxonomyListDataResponse).toBeCalledWith('Unassigned');
// 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' }));
await waitFor(() => {
expect(getByText('Org1 Taxonomy B')).toBeInTheDocument();
});
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 1');
// 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' }));
await waitFor(() => {
expect(queryByText('Org1 Taxonomy B')).not.toBeInTheDocument();
expect(queryByText('Org2 Taxonomy C')).toBeInTheDocument();
});
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 2');
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
@@ -238,8 +221,6 @@ 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' }));
await waitFor(() => {
expect(getByText(taxonomies[0].description)).toBeInTheDocument();
});
expect(useTaxonomyListDataResponse).toBeCalledWith('All taxonomies');
});
});

View File

@@ -3,123 +3,71 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
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)));
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);
}
}
return url.href;
};
export const ALL_TAXONOMIES = '__all';
export const UNASSIGNED = '__unassigned';
export const getExportTaxonomyApiUrl = (pk, format) => new URL(
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
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/`),
};
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;
/**
* Get list of taxonomies.
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
* @param {string} org Optioanl organization query param
* @returns {Promise<import("./types.mjs").TaxonomyListData>}
*/
export async function getTaxonomyListData(org) {
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomyList(org));
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl(org));
return camelCaseObject(data);
}
/**
* Delete a Taxonomy
* @param {number} taxonomyId
* @returns {Promise<void>}
* @param {number} pk
* @returns {Promise<Object>}
*/
export async function deleteTaxonomy(taxonomyId) {
await getAuthenticatedHttpClient().delete(apiUrls.taxonomy(taxonomyId));
export async function deleteTaxonomy(pk) {
await getAuthenticatedHttpClient().delete(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));
/** Get a Taxonomy
* @param {number} pk
* @returns {Promise<import("./types.mjs").TaxonomyData>}
*/
export async function getTaxonomy(pk) {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyApiUrl(pk));
return camelCaseObject(data);
}
/**
* Downloads the file of the exported taxonomy
* @param {number} taxonomyId The ID of the taxonomy
* @param {'json'|'csv'} format Which format to use for the export file.
* @param {number} pk
* @param {string} format
* @returns {void}
*/
export function getTaxonomyExportFile(taxonomyId, format) {
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
export function getTaxonomyExportFile(pk, format) {
window.location.href = getExportTaxonomyApiUrl(pk, format);
}

View File

@@ -1,4 +1,3 @@
// @ts-check
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -6,9 +5,11 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { taxonomyListMock } from '../__mocks__';
import {
apiUrls,
getExportTaxonomyApiUrl,
getTaxonomyExportFile,
getTaxonomyListApiUrl,
getTaxonomyListData,
getTaxonomyApiUrl,
getTaxonomy,
deleteTaxonomy,
} from './api';
@@ -16,6 +17,7 @@ import {
let axiosMock;
describe('taxonomy api calls', () => {
const { location } = window;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -33,47 +35,50 @@ 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(apiUrls.taxonomyList(org)).reply(200, taxonomyListMock);
axiosMock.onGet(getTaxonomyListApiUrl(org)).reply(200, taxonomyListMock);
const result = await getTaxonomyListData(org);
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomyList(org));
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl(org));
expect(result).toEqual(taxonomyListMock);
});
it('should delete a taxonomy', async () => {
const taxonomyId = 123;
axiosMock.onDelete(apiUrls.taxonomy(taxonomyId)).reply(200);
await deleteTaxonomy(taxonomyId);
axiosMock.onDelete(getTaxonomyApiUrl()).reply(200);
await deleteTaxonomy();
expect(axiosMock.history.delete[0].url).toEqual(apiUrls.taxonomy(taxonomyId));
expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl());
});
it('should call get taxonomy', async () => {
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
await getTaxonomy(1);
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomy(1));
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyApiUrl(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));
// Restore the location object of window:
window.location = origLocation;
getTaxonomyExportFile(pk, format);
expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format));
});
});

View File

@@ -1,206 +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 { 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

@@ -0,0 +1,106 @@
// @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,110 +1,78 @@
// @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 { useQuery, useMutation } from '@tanstack/react-query';
import { act } from '@testing-library/react';
import {
useImportPlan,
useImportTags,
useImportNewTaxonomy,
useTaxonomyListDataResponse,
useIsTaxonomyListDataLoaded,
useDeleteTaxonomy,
} from './apiHooks';
import { deleteTaxonomy } from './api';
let axiosMock;
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.mock('./api', () => ({
deleteTaxonomy: jest.fn(),
}));
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
/*
* 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 emptyFile = new File([], 'empty.csv');
const result = useTaxonomyListDataResponse();
describe('import taxonomy api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
expect(result).toEqual({ data: 'data' });
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
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,
});
const result = useTaxonomyListDataResponse();
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));
expect(result).toBeUndefined();
});
});
describe('useIsTaxonomyListDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const result = useIsTaxonomyListDataLoaded();
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);
});
});
});

View File

@@ -1,7 +1,7 @@
// @ts-check
/**
* @typedef {Object} TaxonomyData Metadata about a taxonomy
* @typedef {Object} TaxonomyData
* @property {number} id
* @property {string} name
* @property {string} description
@@ -20,13 +20,14 @@
*/
/**
* @typedef {Object} TaxonomyListData The list of taxonomies
* @typedef {Object} TaxonomyListData
* @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(/** @type {'csv'|'json'} */('csv'));
const [outputFormat, setOutputFormat] = useState('csv');
const onClickExport = React.useCallback(() => {
onClose();

View File

@@ -1,5 +1,5 @@
// @ts-check
import React, { useState, useContext, useMemo } from 'react';
import React, { useState, useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
useToggle,
@@ -22,11 +22,10 @@ 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 { useImportTags, useImportPlan } from '../data/apiHooks';
import { planImportTags, useImportTags } from './data/api';
import messages from './messages';
const linebreak = <> <br /> <br /> </>;
@@ -74,17 +73,20 @@ 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 (
@@ -145,6 +147,7 @@ UploadStep.propTypes = {
}),
setFile: PropTypes.func.isRequired,
importPlanError: PropTypes.string,
setImportPlanError: PropTypes.func.isRequired,
};
UploadStep.defaultProps = {
@@ -225,28 +228,35 @@ 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 = React.useCallback(() => {
setCurrentStep('plan');
}, []);
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 confirmImportTags = async () => {
disableDialog();
@@ -316,8 +326,8 @@ const ImportTagsWizard = ({
onClose={onClose}
size="lg"
>
{(isDialogDisabled) && (
// This div is used to prevent the user from interacting with the dialog while the import is happening
{isDialogDisabled && (
// This div is used to prevent the user from interacting with the dialog while it is disabled
<div className="position-absolute w-100 h-100 d-block zindex-9" />
)}
@@ -331,7 +341,8 @@ const ImportTagsWizard = ({
<UploadStep
file={file}
setFile={setFile}
importPlanError={/** @type {Error|undefined} */(importPlanResult.error)?.message}
importPlanError={importPlanError}
setImportPlanError={setImportPlanError}
/>
<PlanStep importPlan={importPlan} />
<ConfirmStep importPlan={importPlan} />
@@ -358,16 +369,11 @@ const ImportTagsWizard = ({
<Button variant="tertiary" onClick={onClose}>
{intl.formatMessage(messages.importWizardButtonCancel)}
</Button>
{
importPlanResult.isLoading ? <LoadingSpinner />
: (
<LoadingButton
label={intl.formatMessage(messages.importWizardButtonImport)}
disabled={!file || importPlanResult.isLoading || !!importPlanResult.error}
onClick={generatePlan}
/>
)
}
<LoadingButton
label={intl.formatMessage(messages.importWizardButtonImport)}
disabled={!file || !!importPlanError}
onClick={generatePlan}
/>
</Stepper.ActionRow>
<Stepper.ActionRow eventKey="plan">

View File

@@ -1,12 +1,9 @@
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,
@@ -16,18 +13,29 @@ 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 = {
@@ -37,9 +45,6 @@ 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',
@@ -72,7 +77,6 @@ describe('<ImportTagsWizard />', () => {
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
@@ -125,7 +129,7 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('upload-step')).toBeInTheDocument();
// Continue flow
let importButton = getByRole('button', { name: 'Import' });
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).toHaveAttribute('aria-disabled', 'true');
// Invalid file type
@@ -134,56 +138,48 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('dropzone')).toBeInTheDocument();
expect(importButton).toHaveAttribute('aria-disabled', 'true');
const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' });
// Correct file type
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: 'Import plan' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } });
const fileJson = new File(['file contents'], 'example.json', { type: 'application/gzip' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
expect(getByText('example1.json')).toBeInTheDocument();
expect(getByText('example.json')).toBeInTheDocument();
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
// Clear file
fireEvent.click(getByTestId('clear-file-button'));
expect(await findByTestId('dropzone')).toBeInTheDocument();
// Reselect file
// 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'] } });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], 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
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');
expect(planImportTags).toHaveBeenCalledWith(taxonomy.id, fileJson);
expect(await findByText('Test error')).toBeInTheDocument();
const errorAlert = getByText('Test error');
// Reselect file to clear the error
fireEvent.click(getByTestId('clear-file-button'));
expect(errorAlert).not.toBeInTheDocument();
// 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'] } });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
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');
});
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);
fireEvent.click(importButton);
@@ -192,6 +188,7 @@ 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();
@@ -208,9 +205,9 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('confirm-step')).toBeInTheDocument();
if (expectedResult === 'success') {
axiosMock.onPut(doImportUrl).replyOnce(200, {});
mockUseImportTagsMutate.mockResolvedValueOnce({});
} else {
axiosMock.onPut(doImportUrl).replyOnce(400, { error: 'Test error' });
mockUseImportTagsMutate.mockRejectedValueOnce(new Error('Test error'));
}
const confirmButton = getByRole('button', { name: 'Yes, import file' });
@@ -218,24 +215,24 @@ describe('<ImportTagsWizard />', () => {
expect(confirmButton).not.toHaveAttribute('aria-disabled', 'true');
});
act(() => { fireEvent.click(confirmButton); });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockUseImportTagsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, file: fileJson });
});
if (expectedResult === 'success') {
// Toast message shown
await waitFor(() => {
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
});
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
} else {
// Alert message shown
await waitFor(() => {
expect(mockSetAlertProps).toBeCalledWith(
expect.objectContaining({
variant: 'danger',
title: 'Import error',
description: 'Test error',
}),
);
});
expect(mockSetAlertProps).toBeCalledWith(
expect.objectContaining({
variant: 'danger',
title: 'Import error',
description: 'Test error',
}),
);
}
});
});

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
// @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

@@ -0,0 +1,88 @@
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,5 +1,6 @@
// @ts-check
import messages from './messages';
import messages from '../messages';
import { importNewTaxonomy } from './api';
/*
* This function get a file from the user. It does this by creating a
@@ -37,12 +38,7 @@ const selectFile = async () => new Promise((resolve) => {
});
/* istanbul ignore next */
/**
* @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
export const importTaxonomy = async (intl) => { // 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
@@ -96,30 +92,27 @@ export const importTaxonomy = async (intl, importMutation) => { // eslint-disabl
return;
}
const name = getTaxonomyName();
if (name == null) {
const taxonomyName = getTaxonomyName();
if (taxonomyName == null) {
return;
}
const exportId = getTaxonomyExportId();
if (exportId == null) {
const taxonomyExportId = getTaxonomyExportId();
if (taxonomyExportId == null) {
return;
}
const description = getTaxonomyDescription();
if (description == null) {
const taxonomyDescription = getTaxonomyDescription();
if (taxonomyDescription == null) {
return;
}
importMutation.mutateAsync({
name,
exportId,
description,
file,
}).then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
}).catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file)
.then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
})
.catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
};

View File

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

View File

@@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import { useOrganizationListData } from '../../generic/data/apiHooks';
import { TaxonomyContext } from '../common/context';
import { useTaxonomyDetails } from '../data/apiHooks';
import { useTaxonomyDetailDataResponse } 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 { data: taxonomy } = useTaxonomyDetails(taxonomyId);
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const manageOrgMutation = useManageOrgs();

View File

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

View File

@@ -0,0 +1,52 @@
// @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

@@ -0,0 +1,27 @@
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

@@ -1,41 +0,0 @@
// @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

@@ -0,0 +1,41 @@
// @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

@@ -0,0 +1,45 @@
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 { useTaxonomyDetails } from '../data/apiHooks';
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks';
import SystemDefinedBadge from '../system-defined-badge';
const TaxonomyDetailPage = () => {
@@ -25,11 +25,8 @@ const TaxonomyDetailPage = () => {
const { taxonomyId: taxonomyIdString } = useParams();
const taxonomyId = Number(taxonomyIdString);
const {
data: taxonomy,
isError,
isFetched,
} = useTaxonomyDetails(taxonomyId);
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const { isError, isFetched } = useTaxonomyDetailDataStatus(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 { apiUrls } from '../data/api';
import { getTaxonomyApiUrl } 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(apiUrls.taxonomy(1)).reply(() => new Promise());
axiosMock.onGet(getTaxonomyApiUrl(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(apiUrls.taxonomy(1)).reply(200);
axiosMock.onGet(getTaxonomyApiUrl(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(apiUrls.taxonomy(1)).replyOnce(200, {
await axiosMock.onGet(getTaxonomyApiUrl(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(apiUrls.taxonomy(1)).replyOnce(200, {
axiosMock.onGet(getTaxonomyApiUrl(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(apiUrls.taxonomy(1)).replyOnce(200, {
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',