Compare commits
6 Commits
feat--home
...
abdullahwa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb5f51e47 | ||
|
|
6a115797e6 | ||
|
|
f57d40ea34 | ||
|
|
80bf86992d | ||
|
|
1dde30a0a2 | ||
|
|
9a6e12bd3b |
31
package-lock.json
generated
31
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@datadog/browser-rum": "^5.13.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -2323,6 +2324,36 @@
|
||||
"postcss-selector-parser": "^6.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@datadog/browser-core": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@datadog/browser-core/-/browser-core-5.13.0.tgz",
|
||||
"integrity": "sha512-5WciDj4IqpfaFZViJNXxovmDQiwoPZ/UWq4WMW7YafG22XNjrc6XbL5PWuAaG6fqcYFW0peE8g56ji5O78vMSA=="
|
||||
},
|
||||
"node_modules/@datadog/browser-rum": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@datadog/browser-rum/-/browser-rum-5.13.0.tgz",
|
||||
"integrity": "sha512-3xRyKp4rnMWMNhoy/pd8uDpQsNO3dh3nivNk5MzaxKo+mLsJUGFtj9nIq/jN38jl1tOEFbFMcgvCo15bkdHeQw==",
|
||||
"dependencies": {
|
||||
"@datadog/browser-core": "5.13.0",
|
||||
"@datadog/browser-rum-core": "5.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@datadog/browser-logs": "5.13.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@datadog/browser-logs": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@datadog/browser-rum-core": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@datadog/browser-rum-core/-/browser-rum-core-5.13.0.tgz",
|
||||
"integrity": "sha512-MbDbg+ciQlYd2c0gE21Jw1P4R/liLIzPQW96iJGpUBG67ScfYjKyNS0GnKtLwak9rcQJInv3FODhgr2DUfm88w==",
|
||||
"dependencies": {
|
||||
"@datadog/browser-core": "5.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@datadog/browser-rum": "^5.13.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Ref } from 'react';
|
||||
import type {} from 'react-select/base';
|
||||
// This import is necessary for module augmentation.
|
||||
// It allows us to extend the 'Props' interface in the 'react-select/base' module
|
||||
@@ -16,6 +17,9 @@ export interface TaxonomySelectProps {
|
||||
appliedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
stagedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
checkedTags: string[];
|
||||
selectCancelRef: Ref,
|
||||
selectAddRef: Ref,
|
||||
selectInlineAddRef: Ref,
|
||||
handleCommitStagedTags: () => void;
|
||||
handleCancelStagedTags: () => void;
|
||||
handleSelectableBoxChange: React.ChangeEventHandler;
|
||||
|
||||
@@ -41,6 +41,8 @@ const CustomMenu = (props) => {
|
||||
handleCommitStagedTags,
|
||||
handleCancelStagedTags,
|
||||
searchTerm,
|
||||
selectCancelRef,
|
||||
selectAddRef,
|
||||
value,
|
||||
} = props.selectProps;
|
||||
const intl = useIntl();
|
||||
@@ -56,6 +58,7 @@ const CustomMenu = (props) => {
|
||||
className="taxonomy-tags-selectable-box-set"
|
||||
onChange={handleSelectableBoxChange}
|
||||
value={checkedTags}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<ContentTagsDropDownSelector
|
||||
key={`selector-${taxonomyId}`}
|
||||
@@ -70,6 +73,8 @@ const CustomMenu = (props) => {
|
||||
<div className="d-flex flex-row justify-content-end">
|
||||
<div className="d-inline">
|
||||
<Button
|
||||
tabIndex="0"
|
||||
ref={selectCancelRef}
|
||||
variant="tertiary"
|
||||
className="cancel-add-tags-button"
|
||||
onClick={handleCancelStagedTags}
|
||||
@@ -77,6 +82,8 @@ const CustomMenu = (props) => {
|
||||
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
|
||||
</Button>
|
||||
<Button
|
||||
tabIndex="0"
|
||||
ref={selectAddRef}
|
||||
variant="tertiary"
|
||||
className="text-info-500 add-tags-button"
|
||||
disabled={!(value && value.length)}
|
||||
@@ -91,6 +98,13 @@ const CustomMenu = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const disableActionKeys = (e) => {
|
||||
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'Backspace'];
|
||||
if (arrowKeys.includes(e.code)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const CustomLoadingIndicator = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
@@ -110,6 +124,7 @@ const CustomIndicatorsContainer = (props) => {
|
||||
const {
|
||||
value,
|
||||
handleCommitStagedTags,
|
||||
selectInlineAddRef,
|
||||
} = props.selectProps;
|
||||
const intl = useIntl();
|
||||
return (
|
||||
@@ -119,9 +134,12 @@ const CustomIndicatorsContainer = (props) => {
|
||||
<Button
|
||||
variant="dark"
|
||||
size="sm"
|
||||
className="mt-2 mb-2 rounded-0"
|
||||
className="mt-2 mb-2 rounded-0 inline-add-button"
|
||||
onClick={handleCommitStagedTags}
|
||||
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
||||
ref={selectInlineAddRef}
|
||||
tabIndex="0"
|
||||
onKeyDown={disableActionKeys} // To prevent navigating staged tags when button focused
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleInlineAddStagedTagsButtonText) }
|
||||
</Button>
|
||||
@@ -219,8 +237,13 @@ const ContentTagsCollapsible = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData;
|
||||
const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
|
||||
const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false);
|
||||
|
||||
const {
|
||||
tagChangeHandler,
|
||||
removeAppliedTagHandler,
|
||||
@@ -250,9 +273,11 @@ const ContentTagsCollapsible = ({
|
||||
|
||||
const handleSearchChange = React.useCallback((value, { action }) => {
|
||||
if (action === 'input-blur') {
|
||||
// Cancel/clear search if focused away from select input
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
if (!selectMenuIsOpen) {
|
||||
// Cancel/clear search if focused away from select input and menu closed
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
}
|
||||
} else if (action === 'input-change') {
|
||||
if (value === '') {
|
||||
// No need to debounce when search term cleared. Clear debounce function
|
||||
@@ -262,7 +287,7 @@ const ContentTagsCollapsible = ({
|
||||
handleSearch(value);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [selectMenuIsOpen, setSearchTerm, handleSearch]);
|
||||
|
||||
// onChange handler for react-select component, currently only called when
|
||||
// staged tags in the react-select input are removed or fully cleared.
|
||||
@@ -287,14 +312,55 @@ const ContentTagsCollapsible = ({
|
||||
handleStagedTagsMenuChange([]);
|
||||
selectRef.current?.blur();
|
||||
setSearchTerm('');
|
||||
setSelectMenuIsOpen(false);
|
||||
}, [commitStagedTags, handleStagedTagsMenuChange, selectRef, setSearchTerm]);
|
||||
|
||||
const handleCancelStagedTags = React.useCallback(() => {
|
||||
handleStagedTagsMenuChange([]);
|
||||
selectRef.current?.blur();
|
||||
setSearchTerm('');
|
||||
setSelectMenuIsOpen(false);
|
||||
}, [handleStagedTagsMenuChange, selectRef, setSearchTerm]);
|
||||
|
||||
const handleSelectOnKeyDown = (event) => {
|
||||
const focusedElement = event.target;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setSelectMenuIsOpen(false);
|
||||
} else if (event.key === 'Tab') {
|
||||
// Keep the menu open when navigating inside the select menu
|
||||
setSelectMenuIsOpen(true);
|
||||
|
||||
// Determine when to close the menu when navigating with keyboard
|
||||
if (!event.shiftKey) { // Navigating forwards
|
||||
if (focusedElement === selectAddRef.current) {
|
||||
setSelectMenuIsOpen(false);
|
||||
} else if (focusedElement === selectCancelRef.current && selectAddRef.current?.disabled) {
|
||||
setSelectMenuIsOpen(false);
|
||||
}
|
||||
// Navigating backwards
|
||||
// @ts-ignore inputRef actually exists under the current selectRef
|
||||
} else if (event.shiftKey && focusedElement === selectRef.current?.inputRef) {
|
||||
setSelectMenuIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Open the select menu and make sure the search term is cleared when focused
|
||||
const onSelectMenuFocus = React.useCallback(() => {
|
||||
setSelectMenuIsOpen(true);
|
||||
setSearchTerm('');
|
||||
}, [setSelectMenuIsOpen, setSearchTerm]);
|
||||
|
||||
// Handles logic to close the select menu when clicking outside
|
||||
const handleOnBlur = React.useCallback((event) => {
|
||||
// Check if a target we are focusing to is an element in our select menu, if not close it
|
||||
const menuClasses = ['dropdown-selector', 'inline-add-button', 'cancel-add-tags-button'];
|
||||
if (!event.relatedTarget || !menuClasses.some(cls => event.relatedTarget.className?.includes(cls))) {
|
||||
setSelectMenuIsOpen(false);
|
||||
}
|
||||
}, [setSelectMenuIsOpen]);
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
|
||||
@@ -306,6 +372,18 @@ const ContentTagsCollapsible = ({
|
||||
|
||||
{canTagObject && (
|
||||
<Select
|
||||
onBlur={handleOnBlur}
|
||||
styles={{
|
||||
// Overriding 'x' button styles for staged tags when navigating by keyboard
|
||||
multiValueRemove: (base, state) => ({
|
||||
...base,
|
||||
background: state.isFocused ? 'black' : base.background,
|
||||
color: state.isFocused ? 'white' : base.color,
|
||||
}),
|
||||
}}
|
||||
menuIsOpen={selectMenuIsOpen}
|
||||
onFocus={onSelectMenuFocus}
|
||||
onKeyDown={handleSelectOnKeyDown}
|
||||
ref={/** @type {React.RefObject} */(selectRef)}
|
||||
isMulti
|
||||
isLoading={updateTags.isLoading}
|
||||
@@ -332,6 +410,9 @@ const ContentTagsCollapsible = ({
|
||||
handleCommitStagedTags={handleCommitStagedTags}
|
||||
handleCancelStagedTags={handleCancelStagedTags}
|
||||
searchTerm={searchTerm}
|
||||
selectCancelRef={selectCancelRef}
|
||||
selectAddRef={selectAddRef}
|
||||
selectInlineAddRef={selectInlineAddRef}
|
||||
value={stagedContentTags}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,22 +9,89 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import messages from './messages';
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
const taxonomyMockData = {
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 2,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const nestedTaxonomyMockData = {
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1.1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 1',
|
||||
id: 12354,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 1.2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 1',
|
||||
id: 12355,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||
isError: false,
|
||||
mutate: jest.fn(),
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
canAddTag: false,
|
||||
data: [],
|
||||
},
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn((_, parentTagValue) => {
|
||||
// To mock nested call of useTaxonomyData in subtags dropdown
|
||||
if (parentTagValue === 'Tag 1') {
|
||||
return nestedTaxonomyMockData;
|
||||
}
|
||||
return taxonomyMockData;
|
||||
}),
|
||||
}));
|
||||
|
||||
const data = {
|
||||
@@ -107,48 +174,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
);
|
||||
}
|
||||
|
||||
function setupTaxonomyMock() {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
@@ -157,7 +182,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should call `addStagedContentTag` when tag checked in the dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
@@ -189,7 +213,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should call `removeStagedContentTag` when tag staged tag unchecked in the dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
@@ -220,7 +243,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should call `setStagedTags` to clear staged tags when clicking inline "Add" button', async () => {
|
||||
setupTaxonomyMock();
|
||||
// Setup component to have staged tags
|
||||
const { container, getByText } = await getComponent({
|
||||
...data,
|
||||
@@ -246,7 +268,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should call `setStagedTags` to clear staged tags when clicking "Add tags" button in dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
// Setup component to have staged tags
|
||||
const { container, getByText } = await getComponent({
|
||||
...data,
|
||||
@@ -348,7 +369,6 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
});
|
||||
|
||||
it('should close dropdown selector when clicking away', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, queryByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
@@ -376,8 +396,140 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should test keyboard navigation of add tags widget', async () => {
|
||||
const {
|
||||
container,
|
||||
getByText,
|
||||
queryByText,
|
||||
queryAllByText,
|
||||
} = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add a tag" button to open dropdown
|
||||
const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
|
||||
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open, Tag 3 should appear
|
||||
// since it is not applied
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
/*
|
||||
The dropdown data looks like the following:
|
||||
|
||||
│Tag 1
|
||||
│ │
|
||||
│ ├─ Tag 1.1
|
||||
│ │
|
||||
│ │
|
||||
│ └─ Tag 1.2
|
||||
│
|
||||
│Tag 2
|
||||
│
|
||||
│
|
||||
│Tag 3
|
||||
|
||||
*/
|
||||
|
||||
// Press tab to focus on first element in dropdown, Tag 1 should be focused
|
||||
userEvent.tab();
|
||||
const dropdownTag1Div = queryAllByText('Tag 1')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press right arrow to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||
userEvent.keyboard('{arrowright}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||
|
||||
// Press left arrow to collapse Tag 1, Tag 1.1 & Tag 1.2 should not be visible
|
||||
userEvent.keyboard('{arrowleft}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(1);
|
||||
expect(queryByText('Tag 1.2')).not.toBeInTheDocument();
|
||||
|
||||
// Press enter key to expand Tag 1, Tag 1.1 & Tag 1.2 should now be visible
|
||||
userEvent.keyboard('{enter}');
|
||||
expect(queryAllByText('Tag 1.1').length).toBe(2);
|
||||
expect(queryByText('Tag 1.2')).toBeInTheDocument();
|
||||
|
||||
// Press down arrow to navigate to Tag 1.1, it should be focused
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
const dropdownTag1pt1Div = queryAllByText('Tag 1.1')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||
|
||||
// Press down arrow again to navigate to Tag 1.2, it should be fouced
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
const dropdownTag1pt2Div = queryAllByText('Tag 1.2')[0].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press down arrow again to navigate to Tag 2, it should be fouced
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
const dropdownTag2Div = queryAllByText('Tag 2')[1].closest('.dropdown-selector-tag-actions');
|
||||
expect(dropdownTag2Div).toHaveFocus();
|
||||
|
||||
// Press up arrow to navigate back to Tag 1.2, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press up arrow to navigate back to Tag 1.1, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
expect(dropdownTag1pt1Div).toHaveFocus();
|
||||
|
||||
// Press up arrow again to navigate to Tag 1, it should be focused
|
||||
userEvent.keyboard('{arrowup}');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press down arrow twice to navigate to Tag 1.2, it should be focsed
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
userEvent.keyboard('{arrowdown}');
|
||||
expect(dropdownTag1pt2Div).toHaveFocus();
|
||||
|
||||
// Press space key to check Tag 1.2, it should be staged
|
||||
userEvent.keyboard('{space}');
|
||||
const taxonomyId = 123;
|
||||
const addedStagedTag = {
|
||||
value: 'Tag%201,Tag%201.2',
|
||||
label: 'Tag 1.2',
|
||||
};
|
||||
expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag);
|
||||
|
||||
// Press enter key again to uncheck Tag 1.2 (since it's a leaf), it should be unstaged
|
||||
userEvent.keyboard('{enter}');
|
||||
const tagValue = 'Tag%201,Tag%201.2';
|
||||
expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue);
|
||||
|
||||
// Press left arrow to navigate back to Tag 1, it should be focused
|
||||
userEvent.keyboard('{arrowleft}');
|
||||
expect(dropdownTag1Div).toHaveFocus();
|
||||
|
||||
// Press tab key it should jump to cancel button, it should be focused
|
||||
userEvent.tab();
|
||||
const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage);
|
||||
expect(dropdownCancel).toHaveFocus();
|
||||
|
||||
// Press tab again, it should exit and close the select menu, since there are not staged tags
|
||||
userEvent.tab();
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
|
||||
// Press shift tab, focus back on select menu input, it should open the menu
|
||||
userEvent.tab({ shift: true });
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Press shift tab again, it should focus out and close the select menu
|
||||
userEvent.tab({ shift: true });
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
|
||||
// Press tab again, the select menu should open, then press escape, it should close
|
||||
userEvent.tab();
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
userEvent.keyboard('{escape}');
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should remove applied tags when clicking on `x` of tag bubble', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
} from './data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
import { useTaxonomyList } from '../taxonomy/data/apiHooks';
|
||||
import Loading from '../generic/Loading';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
@@ -37,14 +37,9 @@ import Loading from '../generic/Loading';
|
||||
*/
|
||||
const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
const intl = useIntl();
|
||||
// TODO: We can delete this when the iframe is no longer used on edx-platform
|
||||
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
|
||||
const params = useParams();
|
||||
let contentId = id;
|
||||
|
||||
if (contentId === undefined) {
|
||||
// TODO: We can delete this when the iframe is no longer used on edx-platform
|
||||
contentId = params.contentId;
|
||||
}
|
||||
const contentId = id ?? params.contentId;
|
||||
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
@@ -74,18 +69,21 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
setStagedContentTags(prevStagedContentTags => ({ ...prevStagedContentTags, [taxonomyId]: tagsList }));
|
||||
}, [setStagedContentTags]);
|
||||
|
||||
const useTaxonomyListData = () => {
|
||||
const taxonomyListData = useTaxonomyListDataResponse(org);
|
||||
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
|
||||
return { taxonomyListData, isTaxonomyListLoaded };
|
||||
};
|
||||
|
||||
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
|
||||
const {
|
||||
data: contentTaxonomyTagsData,
|
||||
isSuccess: isContentTaxonomyTagsLoaded,
|
||||
} = useContentTaxonomyTagsData(contentId);
|
||||
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
|
||||
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
|
||||
|
||||
let contentName = '';
|
||||
if (isContentDataLoaded) {
|
||||
if ('displayName' in contentData) {
|
||||
contentName = contentData.displayName;
|
||||
} else {
|
||||
contentName = contentData.courseDisplayNameWithDefault;
|
||||
}
|
||||
}
|
||||
|
||||
let onCloseDrawer = onClose;
|
||||
if (onCloseDrawer === undefined) {
|
||||
@@ -140,7 +138,7 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
|
||||
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
|
||||
{ isContentDataLoaded
|
||||
? <h3>{ contentData.displayName }</h3>
|
||||
? <h3>{ contentName }</h3>
|
||||
: (
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act, render, fireEvent, screen,
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
|
||||
import ContentTagsDrawer from './ContentTagsDrawer';
|
||||
@@ -10,7 +15,7 @@ import {
|
||||
useContentData,
|
||||
useTaxonomyTagsData,
|
||||
} from './data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
import { getTaxonomyListData } from '../taxonomy/data/api';
|
||||
import messages from './messages';
|
||||
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
|
||||
@@ -23,6 +28,7 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// FIXME: replace these mocks with API mocks
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
@@ -46,20 +52,30 @@ jest.mock('./data/apiHooks', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../taxonomy/data/apiHooks', () => ({
|
||||
useTaxonomyListDataResponse: jest.fn(),
|
||||
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||
jest.mock('../taxonomy/data/api', () => ({
|
||||
// By default, the mock taxonomy list will never load (promise never resolves):
|
||||
getTaxonomyListData: jest.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = (params) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsDrawer {...params} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ContentTagsDrawer {...params} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ContentTagsDrawer />', () => {
|
||||
beforeEach(async () => {
|
||||
await queryClient.resetQueries();
|
||||
// By default, we mock the API call with a promise that never resolves.
|
||||
// You can override this in specific test.
|
||||
getTaxonomyListData.mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
const setupMockDataForStagedTagsTesting = () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
@@ -84,7 +100,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
@@ -148,7 +164,6 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('shows spinner before the taxonomy tags query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(false);
|
||||
await act(async () => {
|
||||
const { getAllByRole } = render(<RootWrapper />);
|
||||
const spinner = getAllByRole('status')[1];
|
||||
@@ -181,7 +196,6 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
@@ -218,7 +232,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
@@ -233,6 +247,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(getByText('Taxonomy 2')).toBeInTheDocument();
|
||||
const tagCountBadges = container.getElementsByClassName('badge');
|
||||
@@ -241,10 +256,11 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should test adding a content tag to the staged tags for a taxonomy', () => {
|
||||
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const { container, getByText, getAllByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
@@ -267,10 +283,11 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(getAllByText('Tag 3').length).toBe(2);
|
||||
});
|
||||
|
||||
it('should test removing a staged content from a taxonomy', () => {
|
||||
it('should test removing a staged content from a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const { container, getByText, getAllByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
@@ -297,7 +314,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(getAllByText('Tag 3').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should test clearing staged tags for a taxonomy', () => {
|
||||
it('should test clearing staged tags for a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const {
|
||||
@@ -306,6 +323,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
getAllByText,
|
||||
queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add a tag" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
@@ -114,10 +114,120 @@ const ContentTagsDropDownSelector = ({
|
||||
return !!appliedTraversal[tag.value];
|
||||
};
|
||||
|
||||
const isStagedExplicit = (tag) => {
|
||||
// Traverse the staged tags tree using the lineage
|
||||
let stagedTraversal = stagedContentTagsTree;
|
||||
lineage.forEach(t => {
|
||||
stagedTraversal = stagedTraversal[t]?.children || {};
|
||||
});
|
||||
return stagedTraversal[tag.value] && stagedTraversal[tag.value].explicit;
|
||||
};
|
||||
|
||||
// Returns the state of the tag as a string: [Unchecked/Implicit/Checked]
|
||||
const getTagState = (tag) => {
|
||||
if (isApplied(tag) || isStagedExplicit(tag)) {
|
||||
return intl.formatMessage(messages.taxonomyTagChecked);
|
||||
}
|
||||
|
||||
if (isImplicit(tag)) {
|
||||
return intl.formatMessage(messages.taxonomyTagImplicit);
|
||||
}
|
||||
|
||||
return intl.formatMessage(messages.taxonomyTagUnchecked);
|
||||
};
|
||||
|
||||
const isTopOfTagTreeDropdown = (index) => index === 0 && level === 0;
|
||||
|
||||
const loadMoreTags = useCallback(() => {
|
||||
setNumPages((x) => x + 1);
|
||||
}, []);
|
||||
|
||||
const handleKeyBoardNav = (e, hasChildren) => {
|
||||
const keyPressed = e.code;
|
||||
const currentElement = e.target;
|
||||
const encapsulator = currentElement.closest('.dropdown-selector-tag-encapsulator');
|
||||
|
||||
// Get tag value with full lineage, this is URI encoded
|
||||
const tagValueWithLineage = currentElement.querySelector('.pgn__form-checkbox-input')?.value;
|
||||
// Extract and decode the actual tag value
|
||||
let tagValue = tagValueWithLineage.split(',').slice(-1)[0];
|
||||
tagValue = tagValue ? decodeURIComponent(tagValue) : tagValue;
|
||||
|
||||
if (keyPressed === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
if (tagValue && !isOpen(tagValue)) {
|
||||
clickAndEnterHandler(tagValue);
|
||||
}
|
||||
} else if (keyPressed === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
if (tagValue && isOpen(tagValue)) {
|
||||
clickAndEnterHandler(tagValue);
|
||||
} else {
|
||||
// Handles case of jumping out of subtags to previous parent tag
|
||||
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
|
||||
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
|
||||
prevParentTag?.focus();
|
||||
}
|
||||
} else if (keyPressed === 'ArrowUp') {
|
||||
const prevSubTags = encapsulator?.previousElementSibling?.querySelectorAll('.dropdown-selector-tag-actions');
|
||||
const prevSubTag = prevSubTags && prevSubTags[prevSubTags.length - 1];
|
||||
const prevTag = encapsulator?.previousElementSibling?.querySelector('.dropdown-selector-tag-actions');
|
||||
|
||||
if (prevSubTag) {
|
||||
// Handles case of jumping in to subtags
|
||||
prevSubTag.focus();
|
||||
} else if (prevTag) {
|
||||
// Handles case of navigating to previous tag on same level
|
||||
prevTag.focus();
|
||||
} else {
|
||||
// Handles case of jumping out of subtags to previous parent tag
|
||||
const prevParentTagEncapsulator = encapsulator?.parentNode.closest('.dropdown-selector-tag-encapsulator');
|
||||
const prevParentTag = prevParentTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
|
||||
prevParentTag?.focus();
|
||||
}
|
||||
} else if (keyPressed === 'ArrowDown') {
|
||||
const subTagEncapsulator = encapsulator?.querySelector('.dropdown-selector-tag-encapsulator');
|
||||
const nextSubTag = subTagEncapsulator?.querySelector('.dropdown-selector-tag-actions');
|
||||
const nextTag = encapsulator?.nextElementSibling?.querySelector('.dropdown-selector-tag-actions');
|
||||
|
||||
if (nextSubTag) {
|
||||
// Handles case of jumping into subtags
|
||||
nextSubTag.focus();
|
||||
} else if (nextTag) {
|
||||
// Handles case of navigating to next tag on same level
|
||||
nextTag?.focus();
|
||||
} else {
|
||||
// Handles case of jumping out of subtags to next focusable parent tag
|
||||
let nextParentTagEncapsulator = encapsulator?.parentNode?.closest('.dropdown-selector-tag-encapsulator');
|
||||
|
||||
while (nextParentTagEncapsulator) {
|
||||
const nextParentTag = nextParentTagEncapsulator.nextElementSibling?.querySelector(
|
||||
'.dropdown-selector-tag-actions',
|
||||
);
|
||||
if (nextParentTag) {
|
||||
nextParentTag.focus();
|
||||
break;
|
||||
}
|
||||
nextParentTagEncapsulator = nextParentTagEncapsulator.parentNode.closest(
|
||||
'.dropdown-selector-tag-encapsulator',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (keyPressed === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (hasChildren && tagValue) {
|
||||
clickAndEnterHandler(tagValue);
|
||||
} else {
|
||||
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
|
||||
checkbox.click();
|
||||
}
|
||||
} else if (keyPressed === 'Space') {
|
||||
e.preventDefault();
|
||||
const checkbox = currentElement.querySelector('.taxonomy-tags-selectable-box');
|
||||
checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: `${level * 1 }rem` }}>
|
||||
{tagPages.isLoading ? (
|
||||
@@ -131,24 +241,39 @@ const ContentTagsDropDownSelector = ({
|
||||
) : null }
|
||||
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
|
||||
|
||||
{tagPages.data?.map((tagData) => (
|
||||
<React.Fragment key={tagData.value}>
|
||||
{tagPages.data?.map((tagData, i) => (
|
||||
<div key={tagData.value} className="mt-1 ml-1 dropdown-selector-tag-encapsulator">
|
||||
<div
|
||||
className="d-flex flex-row"
|
||||
style={{
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
<div className="d-flex">
|
||||
{/* The tabIndex and onKeyDown are needed to implement custom keyboard navigation */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="d-flex dropdown-selector-tag-actions"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={isTopOfTagTreeDropdown(i) ? 0 : -1} // Only enable tab into top of dropdown tree to set focus
|
||||
onKeyDown={(e) => handleKeyBoardNav(e, tagData.childCount > 0)}
|
||||
aria-label={
|
||||
intl.formatMessage(
|
||||
(isTopOfTagTreeDropdown(i)
|
||||
? messages.taxonomyTagActionInstructionsAriaLabel
|
||||
: messages.taxonomyTagActionsAriaLabel),
|
||||
{ tag: tagData.value, tagState: getTagState(tagData) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectableBox
|
||||
inputHidden={false}
|
||||
type="checkbox"
|
||||
className="d-flex align-items-center taxonomy-tags-selectable-box"
|
||||
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
|
||||
data-selectable-box="taxonomy-tags"
|
||||
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
|
||||
isIndeterminate={isApplied(tagData) || isImplicit(tagData)}
|
||||
disabled={isApplied(tagData) || isImplicit(tagData)}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<HighlightedText text={tagData.value} highlight={searchTerm} />
|
||||
</SelectableBox>
|
||||
@@ -158,8 +283,7 @@ const ContentTagsDropDownSelector = ({
|
||||
<Icon
|
||||
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
|
||||
onClick={() => clickAndEnterHandler(tagData.value)}
|
||||
tabIndex="0"
|
||||
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -178,17 +302,18 @@ const ContentTagsDropDownSelector = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ hasMorePages
|
||||
? (
|
||||
<div>
|
||||
<Button
|
||||
tabIndex="0"
|
||||
variant="tertiary"
|
||||
iconBefore={Add}
|
||||
onClick={loadMoreTags}
|
||||
className="mb-2 taxonomy-tags-load-more-button px-0 text-info-500"
|
||||
className="mb-2 ml-1 taxonomy-tags-load-more-button px-0 text-info-500"
|
||||
>
|
||||
<FormattedMessage {...messages.loadMoreTagsButtonText} />
|
||||
</Button>
|
||||
|
||||
@@ -32,3 +32,8 @@
|
||||
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.dropdown-selector-tag-actions:focus-visible {
|
||||
outline: solid 2px $info-900;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -199,68 +199,6 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = await getComponent(dataWithTagsTree);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector and change search term', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
|
||||
};
|
||||
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
|
||||
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
|
||||
export const getCourseContentDataApiURL = (contentId) => new URL(`/api/contentstore/v1/course_settings/${contentId}`, getApiBaseUrl()).href;
|
||||
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
|
||||
export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
|
||||
|
||||
@@ -74,9 +75,14 @@ export async function getContentTaxonomyTagsCount(contentId) {
|
||||
* @returns {Promise<import("./types.mjs").ContentData>}
|
||||
*/
|
||||
export async function getContentData(contentId) {
|
||||
const url = contentId.startsWith('lb:')
|
||||
? getLibraryContentDataApiUrl(contentId)
|
||||
: getXBlockContentDataApiURL(contentId);
|
||||
let url;
|
||||
if (contentId.startsWith('lb:')) {
|
||||
url = getLibraryContentDataApiUrl(contentId);
|
||||
} else if (contentId.startsWith('course-v1:')) {
|
||||
url = getCourseContentDataApiURL(contentId);
|
||||
} else {
|
||||
url = getXBlockContentDataApiURL(contentId);
|
||||
}
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getContentTaxonomyTagsData,
|
||||
getContentData,
|
||||
updateContentTaxonomyTags,
|
||||
getContentTaxonomyTagsCount,
|
||||
} from './api';
|
||||
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||
@@ -106,17 +105,6 @@ export const useContentTaxonomyTagsData = (contentId) => (
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Build the query to get the count og taxonomy tags applied to the content object
|
||||
* @param {string} contentId The ID of the content object to fetch the count of the applied tags for
|
||||
*/
|
||||
export const useContentTaxonomyTagsCount = (contentId) => (
|
||||
useQuery({
|
||||
queryKey: ['contentTaxonomyTagsCount', contentId],
|
||||
queryFn: () => getContentTaxonomyTagsCount(contentId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the query to get meta data about the content object
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
@@ -150,8 +138,13 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
|
||||
onSettled: /* istanbul ignore next */ () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
/// Invalidate query with pattern on course outline
|
||||
queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] });
|
||||
let contentPattern;
|
||||
if (contentId.includes('course-v1')) {
|
||||
contentPattern = contentId;
|
||||
} else {
|
||||
contentPattern = contentId.replace(/\+type@.*$/, '*');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
useContentTaxonomyTagsUpdater,
|
||||
useContentTaxonomyTagsCount,
|
||||
} from './apiHooks';
|
||||
|
||||
import { updateContentTaxonomyTags } from './api';
|
||||
@@ -135,24 +134,6 @@ describe('useContentTaxonomyTagsData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContentTaxonomyTagsCount', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
const contentId = '123';
|
||||
const result = useContentTaxonomyTagsCount(contentId);
|
||||
|
||||
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||
});
|
||||
|
||||
it('should return failure response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
const contentId = '123';
|
||||
const result = useContentTaxonomyTagsCount(contentId);
|
||||
|
||||
expect(result).toEqual({ isSuccess: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContentData', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentData
|
||||
* @typedef {Object} XBlockData
|
||||
* @property {string} id
|
||||
* @property {string} displayName
|
||||
* @property {string} category
|
||||
@@ -58,3 +58,12 @@
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CourseData
|
||||
* @property {string} courseDisplayNameWithDefault
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {XBlockData | CourseData} ContentData
|
||||
*/
|
||||
|
||||
@@ -25,9 +25,25 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
|
||||
defaultMessage: 'No tags found with the search term "{searchTerm}"',
|
||||
},
|
||||
taxonomyTagsCheckboxAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
|
||||
defaultMessage: '{tag} checkbox',
|
||||
taxonomyTagChecked: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
|
||||
defaultMessage: 'Checked',
|
||||
},
|
||||
taxonomyTagUnchecked: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-unchecked',
|
||||
defaultMessage: 'Unchecked',
|
||||
},
|
||||
taxonomyTagImplicit: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-implicit',
|
||||
defaultMessage: 'Implicit',
|
||||
},
|
||||
taxonomyTagActionInstructionsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-action-instructions.aria.label',
|
||||
defaultMessage: '{tagState} Tag: {tag}. Use the arrow keys to move among the tags in this taxonomy. Press space to select a tag.',
|
||||
},
|
||||
taxonomyTagActionsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-actions.aria.label',
|
||||
defaultMessage: '{tagState} Tag: {tag}',
|
||||
},
|
||||
taxonomyTagsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Stack } from '@openedx/paragon';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import messages from '../messages';
|
||||
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
|
||||
const TagsSidebarHeader = () => {
|
||||
@@ -13,9 +13,9 @@ const TagsSidebarHeader = () => {
|
||||
const contentId = useParams().blockId;
|
||||
|
||||
const {
|
||||
data: contentTaxonomyTagsCount,
|
||||
isSuccess: isContentTaxonomyTagsCountLoaded,
|
||||
} = useContentTaxonomyTagsCount(contentId || '');
|
||||
data: contentTagsCount,
|
||||
isSuccess: isContentTagsCountLoaded,
|
||||
} = useContentTagsCount(contentId || '');
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -25,8 +25,8 @@ const TagsSidebarHeader = () => {
|
||||
<h3 className="course-unit-sidebar-header-title m-0">
|
||||
{intl.formatMessage(messages.tagsSidebarTitle)}
|
||||
</h3>
|
||||
{ isContentTaxonomyTagsCountLoaded
|
||||
&& <TagCount count={contentTaxonomyTagsCount} />}
|
||||
{ isContentTagsCountLoaded
|
||||
&& <TagCount count={contentTagsCount} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import TagsSidebarHeader from './TagsSidebarHeader';
|
||||
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
|
||||
|
||||
jest.mock('../data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsCount: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: 17,
|
||||
})),
|
||||
const mockGetTagsCount = jest.fn();
|
||||
|
||||
jest.mock('../../generic/data/api', () => ({
|
||||
...jest.requireActual('../../generic/data/api'),
|
||||
getTagsCount: () => mockGetTagsCount(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({ blockId: '123' }),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TagsSidebarHeader />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TagsSidebarHeader />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<TagsSidebarHeader>', () => {
|
||||
it('should not render count on loading', () => {
|
||||
it('should render count only after query is complete', async () => {
|
||||
let resolvePromise;
|
||||
mockGetTagsCount.mockReturnValueOnce(new Promise((resolve) => { resolvePromise = resolve; }));
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('17')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render count after query is complete', () => {
|
||||
useContentTaxonomyTagsCount.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: 17,
|
||||
});
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('17')).toBeInTheDocument();
|
||||
resolvePromise({ 123: 17 });
|
||||
expect(await screen.findByText('17')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
// @ts-check
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
} from './drag-helper/utils';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import useUnitTagsCount from './data/apiHooks';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
@@ -113,7 +113,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
mfeProctoredExamSettingsUrl,
|
||||
handleDismissNotification,
|
||||
advanceSettingsUrl,
|
||||
prevContainerInfo,
|
||||
handleSectionDragAndDrop,
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
@@ -133,27 +132,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
const { category } = useSelector(getCurrentItem);
|
||||
const deleteCategory = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
|
||||
|
||||
const unitsIdPattern = useMemo(() => {
|
||||
let pattern = '';
|
||||
sections.forEach((section) => {
|
||||
section.childInfo.children.forEach((subsection) => {
|
||||
subsection.childInfo.children.forEach((unit) => {
|
||||
if (pattern !== '') {
|
||||
pattern += `,${unit.id}`;
|
||||
} else {
|
||||
pattern += unit.id;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return pattern;
|
||||
}, [sections]);
|
||||
|
||||
const {
|
||||
data: unitsTagCounts,
|
||||
isSuccess: isUnitsTagCountsLoaded,
|
||||
} = useUnitTagsCount(unitsIdPattern);
|
||||
|
||||
/**
|
||||
* Move section to new index
|
||||
* @param {any} currentIndex
|
||||
@@ -268,7 +246,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
<SubHeader
|
||||
className="mt-5"
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={(
|
||||
@@ -307,7 +284,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
items={sections}
|
||||
setSections={setSections}
|
||||
restoreSectionList={restoreSectionList}
|
||||
prevContainerInfo={prevContainerInfo}
|
||||
handleSectionDragAndDrop={handleSectionDragAndDrop}
|
||||
handleSubsectionDragAndDrop={handleSubsectionDragAndDrop}
|
||||
handleUnitDragAndDrop={handleUnitDragAndDrop}
|
||||
@@ -319,7 +295,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<SectionCard
|
||||
id={section.id}
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={sectionIndex}
|
||||
@@ -398,7 +373,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOrderChange={updateUnitOrderByIndex}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -482,6 +456,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
variant="danger"
|
||||
icon={WarningIcon}
|
||||
title={intl.formatMessage(messages.alertErrorTitle)}
|
||||
description=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
|
||||
@@ -85,11 +86,13 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./data/apiHooks', () => () => ({
|
||||
data: {},
|
||||
isSuccess: true,
|
||||
jest.mock('./data/api', () => ({
|
||||
...jest.requireActual('./data/api'),
|
||||
getTagsCount: () => jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
// Since jsdom (used by jest) does not support getBoundingClientRect function
|
||||
@@ -104,9 +107,11 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseOutline courseId={courseId} />
|
||||
</IntlProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseOutline courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,4 +4,3 @@ export { default as courseBestPracticesMock } from './courseBestPractices';
|
||||
export { default as courseLaunchMock } from './courseLaunch';
|
||||
export { default as courseSectionMock } from './courseSection';
|
||||
export { default as courseSubsectionMock } from './courseSubsection';
|
||||
export { default as contentTagsCountMock } from './contentTagsCount';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @ts-check
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
@@ -8,18 +10,22 @@ import {
|
||||
Hyperlink,
|
||||
Icon,
|
||||
IconButton,
|
||||
Sheet,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
MoreVert as MoveVertIcon,
|
||||
EditOutline as EditIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import { ContentTagsDrawer } from '../../content-tags-drawer';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { useEscapeClick } from '../../hooks';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
import messages from './messages';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
@@ -28,7 +34,6 @@ const CardHeader = ({
|
||||
hasChanges,
|
||||
onClickPublish,
|
||||
onClickConfigure,
|
||||
onClickManageTags,
|
||||
onClickMenuButton,
|
||||
onClickEdit,
|
||||
isFormOpen,
|
||||
@@ -50,16 +55,18 @@ const CardHeader = ({
|
||||
discussionEnabled,
|
||||
discussionsSettings,
|
||||
parentInfo,
|
||||
tagsCount,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [titleValue, setTitleValue] = useState(title);
|
||||
const cardHeaderRef = useRef(null);
|
||||
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
|
||||
|
||||
const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
|
||||
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||
|
||||
const { data: contentTagCount } = useContentTagsCount(cardId);
|
||||
|
||||
useEffect(() => {
|
||||
const locatorId = searchParams.get('show');
|
||||
if (!locatorId) {
|
||||
@@ -91,134 +98,148 @@ const CardHeader = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="item-card-header"
|
||||
data-testid={`${namePrefix}-card-header`}
|
||||
ref={cardHeaderRef}
|
||||
>
|
||||
{isFormOpen ? (
|
||||
<Form.Group className="m-0 w-75">
|
||||
<Form.Control
|
||||
data-testid={`${namePrefix}-edit-field`}
|
||||
ref={(e) => e && e.focus()}
|
||||
value={titleValue}
|
||||
name="displayName"
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
aria-label="edit field"
|
||||
onBlur={() => onEditSubmit(titleValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEditSubmit(titleValue);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabledEditField}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
<>
|
||||
{titleComponent}
|
||||
<IconButton
|
||||
className="item-card-edit-icon"
|
||||
data-testid={`${namePrefix}-edit-button`}
|
||||
alt={intl.formatMessage(messages.altButtonEdit)}
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto d-flex">
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
<>
|
||||
<div
|
||||
className="item-card-header"
|
||||
data-testid={`${namePrefix}-card-header`}
|
||||
ref={cardHeaderRef}
|
||||
>
|
||||
{isFormOpen ? (
|
||||
<Form.Group className="m-0 w-75">
|
||||
<Form.Control
|
||||
data-testid={`${namePrefix}-edit-field`}
|
||||
ref={(e) => e && e.focus()}
|
||||
value={titleValue}
|
||||
name="displayName"
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
aria-label="edit field"
|
||||
onBlur={() => onEditSubmit(titleValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEditSubmit(titleValue);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabledEditField}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
<>
|
||||
{titleComponent}
|
||||
<IconButton
|
||||
className="item-card-edit-icon"
|
||||
data-testid={`${namePrefix}-edit-button`}
|
||||
alt={intl.formatMessage(messages.altButtonEdit)}
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{ tagsCount > 0 && <TagCount count={tagsCount} onClick={onClickManageTags} /> }
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="item-card-header__menu"
|
||||
id={`${namePrefix}-card-header__menu`}
|
||||
data-testid={`${namePrefix}-card-header__menu-button`}
|
||||
as={IconButton}
|
||||
src={MoveVertIcon}
|
||||
alt={`${namePrefix}-card-header__menu`}
|
||||
iconAs={Icon}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
{isSequential && proctoringExamConfigurationLink && (
|
||||
<div className="ml-auto d-flex">
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
)}
|
||||
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && contentTagCount > 0 && (
|
||||
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
|
||||
)}
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="item-card-header__menu"
|
||||
id={`${namePrefix}-card-header__menu`}
|
||||
data-testid={`${namePrefix}-card-header__menu-button`}
|
||||
as={IconButton}
|
||||
src={MoveVertIcon}
|
||||
alt={`${namePrefix}-card-header__menu`}
|
||||
iconAs={Icon}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
{isSequential && proctoringExamConfigurationLink && (
|
||||
<Dropdown.Item
|
||||
as={Hyperlink}
|
||||
target="_blank"
|
||||
destination={proctoringExamConfigurationLink}
|
||||
href={proctoringExamConfigurationLink}
|
||||
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
|
||||
>
|
||||
{intl.formatMessage(messages.menuProctoringLinkText)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
as={Hyperlink}
|
||||
target="_blank"
|
||||
destination={proctoringExamConfigurationLink}
|
||||
href={proctoringExamConfigurationLink}
|
||||
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
|
||||
data-testid={`${namePrefix}-card-header__menu-publish-button`}
|
||||
disabled={isDisabledPublish}
|
||||
onClick={onClickPublish}
|
||||
>
|
||||
{intl.formatMessage(messages.menuProctoringLinkText)}
|
||||
{intl.formatMessage(messages.menuPublish)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-publish-button`}
|
||||
disabled={isDisabledPublish}
|
||||
onClick={onClickPublish}
|
||||
>
|
||||
{intl.formatMessage(messages.menuPublish)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||
onClick={onClickConfigure}
|
||||
>
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
</Dropdown.Item>
|
||||
{onClickManageTags && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
|
||||
onClick={onClickManageTags}
|
||||
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||
onClick={onClickConfigure}
|
||||
>
|
||||
{intl.formatMessage(messages.menuManageTags)}
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
|
||||
onClick={openManageTagsDrawer}
|
||||
>
|
||||
{intl.formatMessage(messages.menuManageTags)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{isVertical && enableCopyPasteUnits && (
|
||||
<Dropdown.Item onClick={onClickCopy}>
|
||||
{intl.formatMessage(messages.menuCopy)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.duplicable && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
|
||||
onClick={onClickDuplicate}
|
||||
>
|
||||
{intl.formatMessage(messages.menuDuplicate)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.draggable && (
|
||||
<>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
|
||||
onClick={onClickMoveUp}
|
||||
disabled={!actions.allowMoveUp}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveUp)}
|
||||
{isVertical && enableCopyPasteUnits && (
|
||||
<Dropdown.Item onClick={onClickCopy}>
|
||||
{intl.formatMessage(messages.menuCopy)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.duplicable && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
|
||||
onClick={onClickMoveDown}
|
||||
disabled={!actions.allowMoveDown}
|
||||
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
|
||||
onClick={onClickDuplicate}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveDown)}
|
||||
{intl.formatMessage(messages.menuDuplicate)}
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
{actions.deletable && (
|
||||
<Dropdown.Item
|
||||
className="border-top border-light"
|
||||
data-testid={`${namePrefix}-card-header__menu-delete-button`}
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
{intl.formatMessage(messages.menuDelete)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
{actions.draggable && (
|
||||
<>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
|
||||
onClick={onClickMoveUp}
|
||||
disabled={!actions.allowMoveUp}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveUp)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
|
||||
onClick={onClickMoveDown}
|
||||
disabled={!actions.allowMoveDown}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveDown)}
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
{actions.deletable && (
|
||||
<Dropdown.Item
|
||||
className="border-top border-light"
|
||||
data-testid={`${namePrefix}-card-header__menu-delete-button`}
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
{intl.formatMessage(messages.menuDelete)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Sheet
|
||||
position="right"
|
||||
show={isManageTagsDrawerOpen}
|
||||
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
|
||||
>
|
||||
<ContentTagsDrawer
|
||||
id={cardId}
|
||||
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -231,8 +252,6 @@ CardHeader.defaultProps = {
|
||||
discussionEnabled: false,
|
||||
discussionsSettings: {},
|
||||
parentInfo: {},
|
||||
onClickManageTags: null,
|
||||
tagsCount: undefined,
|
||||
cardId: '',
|
||||
};
|
||||
|
||||
@@ -243,7 +262,6 @@ CardHeader.propTypes = {
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
onClickManageTags: PropTypes.func,
|
||||
onClickMenuButton: PropTypes.func.isRequired,
|
||||
onClickEdit: PropTypes.func.isRequired,
|
||||
isFormOpen: PropTypes.bool.isRequired,
|
||||
@@ -278,7 +296,6 @@ CardHeader.propTypes = {
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}),
|
||||
tagsCount: PropTypes.number,
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, waitFor, screen,
|
||||
} from '@testing-library/react';
|
||||
import { setConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import CardHeader from './CardHeader';
|
||||
@@ -18,9 +20,15 @@ const onClickDuplicateMock = jest.fn();
|
||||
const onClickConfigureMock = jest.fn();
|
||||
const onClickMoveUpMock = jest.fn();
|
||||
const onClickMoveDownMock = jest.fn();
|
||||
const onClickManageTagsMock = jest.fn();
|
||||
const closeFormMock = jest.fn();
|
||||
|
||||
const mockGetTagsCount = jest.fn();
|
||||
|
||||
jest.mock('../../generic/data/api', () => ({
|
||||
...jest.requireActual('../../generic/data/api'),
|
||||
getTagsCount: () => mockGetTagsCount(),
|
||||
}));
|
||||
|
||||
const cardHeaderProps = {
|
||||
title: 'Some title',
|
||||
status: ITEM_BADGE_STATUS.live,
|
||||
@@ -29,7 +37,6 @@ const cardHeaderProps = {
|
||||
onClickMenuButton: onClickMenuButtonMock,
|
||||
onClickPublish: onClickPublishMock,
|
||||
onClickEdit: onClickEditMock,
|
||||
onClickManageTags: onClickManageTagsMock,
|
||||
isFormOpen: false,
|
||||
onEditSubmit: jest.fn(),
|
||||
closeForm: closeFormMock,
|
||||
@@ -49,6 +56,8 @@ const cardHeaderProps = {
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props, entry = '/') => {
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
@@ -62,13 +71,15 @@ const renderComponent = (props, entry = '/') => {
|
||||
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
@@ -170,14 +181,32 @@ describe('<CardHeader />', () => {
|
||||
expect(onClickPublishMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickManageTags when the menu is clicked', async () => {
|
||||
it('only shows Manage tags menu if the waffle flag is enabled', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
|
||||
});
|
||||
renderComponent();
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
expect(screen.queryByText(messages.menuManageTags.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows ContentTagsDrawer when the menu is clicked', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
renderComponent();
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const manageTagsMenuItem = await screen.findByText(messages.menuManageTags.defaultMessage);
|
||||
await act(async () => fireEvent.click(manageTagsMenuItem));
|
||||
expect(onClickManageTagsMock).toHaveBeenCalled();
|
||||
fireEvent.click(manageTagsMenuItem);
|
||||
|
||||
// Check if the drawer is open
|
||||
expect(screen.getByTestId('drawer-close-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClickEdit when the button is clicked', async () => {
|
||||
@@ -264,19 +293,33 @@ describe('<CardHeader />', () => {
|
||||
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tag count if is not zero', () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
tagsCount: 17,
|
||||
it('should render tag count if is not zero and the waffle flag is enabled', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
expect(screen.getByText('17')).toBeInTheDocument();
|
||||
mockGetTagsCount.mockResolvedValue({ 12345: 17 });
|
||||
renderComponent();
|
||||
expect(await screen.findByText('17')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shouldn render tag count if the waffle flag is disabled', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
|
||||
});
|
||||
mockGetTagsCount.mockResolvedValue({ 12345: 17 });
|
||||
renderComponent();
|
||||
expect(screen.queryByText('17')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render tag count if is zero', () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
tagsCount: 0,
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
mockGetTagsCount.mockResolvedValue({ 12345: 0 });
|
||||
renderComponent();
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
@@ -473,18 +472,3 @@ export async function dismissNotification(url) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tags count of multiple content by id separated by commas.
|
||||
* @param {string} contentPattern
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getTagsCount(contentPattern) {
|
||||
if (contentPattern) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getTagsCountApiUrl(contentPattern));
|
||||
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { contentTagsCountMock } from '../__mocks__';
|
||||
import { getTagsCountApiUrl, getTagsCount } from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('course outline api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get tags count', async () => {
|
||||
const pattern = 'this,is,a,pattern';
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb06';
|
||||
axiosMock.onGet().reply(200, contentTagsCountMock);
|
||||
const result = await getTagsCount(pattern);
|
||||
expect(axiosMock.history.get[0].url).toEqual(getTagsCountApiUrl(pattern));
|
||||
expect(result).toEqual(contentTagsCountMock);
|
||||
expect(contentTagsCountMock[contentId]).toEqual(15);
|
||||
});
|
||||
|
||||
it('should get null on empty pattenr', async () => {
|
||||
const result = await getTagsCount('');
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
// @ts-check
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTagsCount } from './api';
|
||||
|
||||
/**
|
||||
* Builds the query to get tags count of a group of units.
|
||||
* @param {string} contentPattern The IDs of units separated by commas.
|
||||
*/
|
||||
const useUnitTagsCount = (contentPattern) => (
|
||||
useQuery({
|
||||
queryKey: ['unitTagsCount', contentPattern],
|
||||
queryFn: /* istanbul ignore next */ () => getTagsCount(contentPattern),
|
||||
})
|
||||
);
|
||||
|
||||
export default useUnitTagsCount;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const DragContext = React.createContext({});
|
||||
export const DragContext = React.createContext({ activeId: '', overId: '', children: undefined });
|
||||
|
||||
const DragContextProvider = ({ activeId, overId, children }) => {
|
||||
const contextValue = React.useMemo(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import React, {
|
||||
useContext, useEffect, useState, useRef,
|
||||
} from 'react';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SectionCard from './SectionCard';
|
||||
@@ -34,30 +35,34 @@ const section = {
|
||||
|
||||
const onEditSectionSubmit = jest.fn();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<SectionCard
|
||||
section={section}
|
||||
index={1}
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
onNewSubsectionSubmit={jest.fn()}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SectionCard>
|
||||
</IntlProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<SectionCard
|
||||
section={section}
|
||||
index={1}
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
onNewSubsectionSubmit={jest.fn()}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SectionCard>
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,16 +2,38 @@ import React, { useContext } from 'react';
|
||||
import moment from 'moment/moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform/config';
|
||||
import {
|
||||
Button, Hyperlink, Form, Stack,
|
||||
Button, Hyperlink, Form, Sheet, Stack, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ContentTagsDrawer } from '../../content-tags-drawer';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { useHelpUrls } from '../../help-urls/hooks';
|
||||
import { VIDEO_SHARING_OPTIONS } from '../constants';
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import messages from './messages';
|
||||
import { getVideoSharingOptionText } from '../utils';
|
||||
|
||||
const StatusBarItem = ({ title, children }) => (
|
||||
<div className="d-flex flex-column justify-content-between">
|
||||
<h5>{title}</h5>
|
||||
<div className="d-flex align-items-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
StatusBarItem.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
StatusBarItem.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
const StatusBar = ({
|
||||
statusBarData,
|
||||
isLoading,
|
||||
@@ -48,109 +70,135 @@ const StatusBar = ({
|
||||
socialSharing: socialSharingUrl,
|
||||
} = useHelpUrls(['contentHighlights', 'socialSharing']);
|
||||
|
||||
const { data: courseTagCount } = useContentTagsCount(courseId);
|
||||
|
||||
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal" gap={3.5} className="d-flex align-items-stretch outline-status-bar" data-testid="outline-status-bar">
|
||||
<div className="d-flex flex-column justify-content-between">
|
||||
<h5>{intl.formatMessage(messages.startDateTitle)}</h5>
|
||||
<Hyperlink
|
||||
className="small"
|
||||
destination={scheduleDestination()}
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{courseReleaseDateObj.isValid() ? (
|
||||
<FormattedDate
|
||||
value={courseReleaseDateObj}
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="2-digit"
|
||||
hour="numeric"
|
||||
minute="numeric"
|
||||
/>
|
||||
) : courseReleaseDate}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-between">
|
||||
<h5>{intl.formatMessage(messages.pacingTypeTitle)}</h5>
|
||||
<span className="small">
|
||||
{isSelfPaced
|
||||
? intl.formatMessage(messages.pacingTypeSelfPaced)
|
||||
: intl.formatMessage(messages.pacingTypeInstructorPaced)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-between">
|
||||
<h5>{intl.formatMessage(messages.checklistTitle)}</h5>
|
||||
<Hyperlink
|
||||
className="small"
|
||||
destination={checklistDestination()}
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-between">
|
||||
<h5>{intl.formatMessage(messages.highlightEmailsTitle)}</h5>
|
||||
<div className="d-flex align-items-center">
|
||||
{highlightsEnabledForMessaging ? (
|
||||
<span data-testid="highlights-enabled-span" className="small">
|
||||
{intl.formatMessage(messages.highlightEmailsEnabled)}
|
||||
</span>
|
||||
) : (
|
||||
<Button data-testid="highlights-enable-button" size="sm" onClick={openEnableHighlightsModal}>
|
||||
{intl.formatMessage(messages.highlightEmailsButton)}
|
||||
</Button>
|
||||
)}
|
||||
<>
|
||||
<Stack direction="horizontal" gap={3.5} className="d-flex align-items-stretch outline-status-bar" data-testid="outline-status-bar">
|
||||
<StatusBarItem title={intl.formatMessage(messages.startDateTitle)}>
|
||||
<Hyperlink
|
||||
className="small ml-2"
|
||||
destination={contentHighlightsUrl}
|
||||
target="_blank"
|
||||
className="small"
|
||||
destination={scheduleDestination()}
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.highlightEmailsLink)}
|
||||
{courseReleaseDateObj.isValid() ? (
|
||||
<FormattedDate
|
||||
value={courseReleaseDateObj}
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="2-digit"
|
||||
hour="numeric"
|
||||
minute="numeric"
|
||||
/>
|
||||
) : courseReleaseDate}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
{videoSharingEnabled && (
|
||||
<Form.Group
|
||||
size="sm"
|
||||
className="d-flex flex-column justify-content-between m-0"
|
||||
>
|
||||
<Form.Label
|
||||
className="h5"
|
||||
>{intl.formatMessage(messages.videoSharingTitle)}
|
||||
</Form.Label>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem title={intl.formatMessage(messages.pacingTypeTitle)}>
|
||||
<span className="small">
|
||||
{isSelfPaced
|
||||
? intl.formatMessage(messages.pacingTypeSelfPaced)
|
||||
: intl.formatMessage(messages.pacingTypeInstructorPaced)}
|
||||
</span>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem title={intl.formatMessage(messages.checklistTitle)}>
|
||||
<Hyperlink
|
||||
className="small"
|
||||
destination={checklistDestination()}
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
|
||||
</Hyperlink>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem title={intl.formatMessage(messages.highlightEmailsTitle)}>
|
||||
<div className="d-flex align-items-center">
|
||||
<Form.Control
|
||||
as="select"
|
||||
defaultValue={videoSharingOptions}
|
||||
onChange={(e) => handleVideoSharingOptionChange(e.target.value)}
|
||||
>
|
||||
{Object.values(VIDEO_SHARING_OPTIONS).map((option) => (
|
||||
<option
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{getVideoSharingOptionText(option, messages, intl)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
{highlightsEnabledForMessaging ? (
|
||||
<span data-testid="highlights-enabled-span" className="small">
|
||||
{intl.formatMessage(messages.highlightEmailsEnabled)}
|
||||
</span>
|
||||
) : (
|
||||
<Button data-testid="highlights-enable-button" size="sm" onClick={openEnableHighlightsModal}>
|
||||
{intl.formatMessage(messages.highlightEmailsButton)}
|
||||
</Button>
|
||||
)}
|
||||
<Hyperlink
|
||||
className="small"
|
||||
destination={socialSharingUrl}
|
||||
className="small ml-2"
|
||||
destination={contentHighlightsUrl}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.videoSharingLink)}
|
||||
{intl.formatMessage(messages.highlightEmailsLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</StatusBarItem>
|
||||
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
|
||||
<StatusBarItem title={intl.formatMessage(messages.courseTagsTitle)}>
|
||||
<div className="d-flex align-items-center">
|
||||
<TagCount count={courseTagCount} />
|
||||
{ /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ }
|
||||
<a
|
||||
className="small ml-2"
|
||||
href="#"
|
||||
onClick={openManageTagsDrawer}
|
||||
>
|
||||
{intl.formatMessage(messages.courseManageTagsLink)}
|
||||
</a>
|
||||
</div>
|
||||
</StatusBarItem>
|
||||
)}
|
||||
{videoSharingEnabled && (
|
||||
<Form.Group
|
||||
size="sm"
|
||||
className="d-flex flex-column justify-content-between m-0"
|
||||
>
|
||||
<Form.Label
|
||||
className="h5"
|
||||
>{intl.formatMessage(messages.videoSharingTitle)}
|
||||
</Form.Label>
|
||||
<div className="d-flex align-items-center">
|
||||
<Form.Control
|
||||
as="select"
|
||||
defaultValue={videoSharingOptions}
|
||||
onChange={(e) => handleVideoSharingOptionChange(e.target.value)}
|
||||
>
|
||||
{Object.values(VIDEO_SHARING_OPTIONS).map((option) => (
|
||||
<option
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{getVideoSharingOptionText(option, messages, intl)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
<Hyperlink
|
||||
className="small"
|
||||
destination={socialSharingUrl}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{intl.formatMessage(messages.videoSharingLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</Form.Group>
|
||||
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Sheet
|
||||
position="right"
|
||||
show={isManageTagsDrawerOpen}
|
||||
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
|
||||
>
|
||||
<ContentTagsDrawer
|
||||
id={courseId}
|
||||
onClose={/* istanbul ignore next */ () => closeManageTagsDrawer()}
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform/config';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import StatusBar from './StatusBar';
|
||||
import messages from './messages';
|
||||
@@ -11,7 +13,7 @@ import { VIDEO_SHARING_OPTIONS } from '../constants';
|
||||
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const courseId = 'course-v1:123';
|
||||
const isLoading = false;
|
||||
const openEnableHighlightsModalMock = jest.fn();
|
||||
const handleVideoSharingOptionChange = jest.fn();
|
||||
@@ -23,6 +25,11 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../generic/data/api', () => ({
|
||||
...jest.requireActual('../../generic/data/api'),
|
||||
getTagsCount: jest.fn().mockResolvedValue({ 'course-v1:123': 17 }),
|
||||
}));
|
||||
|
||||
jest.mock('../../help-urls/hooks', () => ({
|
||||
useHelpUrls: () => ({
|
||||
contentHighlights: 'content-highlights-link',
|
||||
@@ -45,18 +52,22 @@ const statusBarData = {
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<StatusBar
|
||||
courseId={courseId}
|
||||
isLoading={isLoading}
|
||||
openEnableHighlightsModal={openEnableHighlightsModalMock}
|
||||
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
|
||||
statusBarData={statusBarData}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<StatusBar
|
||||
courseId={courseId}
|
||||
isLoading={isLoading}
|
||||
openEnableHighlightsModal={openEnableHighlightsModalMock}
|
||||
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
|
||||
statusBarData={statusBarData}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
@@ -133,4 +144,23 @@ describe('<StatusBar />', () => {
|
||||
|
||||
expect(queryByTestId('video-sharing-wrapper')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the tag count if the waffle flag is enabled', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
const { findByText } = renderComponent();
|
||||
|
||||
expect(await findByText('17')).toBeInTheDocument();
|
||||
});
|
||||
it('doesnt renders the tag count if the waffle flag is disabled', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
|
||||
});
|
||||
const { queryByText } = renderComponent();
|
||||
|
||||
expect(queryByText('17')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,16 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.status-bar.highlight-emails.link',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
courseTagsTitle: {
|
||||
id: 'course-authoring.course-outline.status-bar.course-tags',
|
||||
defaultMessage: 'Course tags',
|
||||
description: 'Course tags header in course outline',
|
||||
},
|
||||
courseManageTagsLink: {
|
||||
id: 'course-authoring.course-outline.status-bar.course-manage-tags-link',
|
||||
defaultMessage: 'Manage tags',
|
||||
description: 'Opens the drawer to edit content tags',
|
||||
},
|
||||
videoSharingTitle: {
|
||||
id: 'course-authoring.course-outline.status-bar.video-sharing.title',
|
||||
defaultMessage: 'Video Sharing',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
// @ts-check
|
||||
import React, {
|
||||
useContext, useEffect, useState, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -165,6 +166,7 @@ const SubsectionCard = ({
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
cardId={id}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SubsectionCard from './SubsectionCard';
|
||||
@@ -52,36 +53,39 @@ const subsection = {
|
||||
};
|
||||
|
||||
const onEditSubectionSubmit = jest.fn();
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props, entry = '/') => render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<IntlProvider locale="en">
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={1}
|
||||
isSelfPaced={false}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
isCustomRelativeDatesActive={false}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
namePrefix="subsection"
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SubsectionCard>
|
||||
</IntlProvider>,
|
||||
</MemoryRouter>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<IntlProvider locale="en">
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={1}
|
||||
isSelfPaced={false}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
isCustomRelativeDatesActive={false}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
namePrefix="subsection"
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SubsectionCard>
|
||||
</IntlProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
// @ts-check
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useToggle, Sheet } from '@openedx/paragon';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
@@ -11,7 +12,6 @@ import SortableItem from '../drag-helper/SortableItem';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import { ContentTagsDrawer } from '../../content-tags-drawer';
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
@@ -31,13 +31,11 @@ const UnitCard = ({
|
||||
onOrderChange,
|
||||
onCopyToClipboardClick,
|
||||
discussionsSettings,
|
||||
tagsCount,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
const [showManageTags, setShowManageTags] = useState(false);
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -129,77 +127,63 @@ const UnitCard = ({
|
||||
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
}}
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="unit-card"
|
||||
data-testid="unit-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
<div
|
||||
className="unit-card"
|
||||
data-testid="unit-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={unitStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickManageTags={/* istanbul ignore next */ () => setShowManageTags(true)}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
isVertical
|
||||
enableCopyPasteUnits={enableCopyPasteUnits}
|
||||
onClickCopy={handleCopyClick}
|
||||
discussionEnabled={discussionEnabled}
|
||||
discussionsSettings={discussionsSettings}
|
||||
parentInfo={parentInfo}
|
||||
tagsCount={tagsCount}
|
||||
/>
|
||||
<div className="unit-card__content item-children" data-testid="unit-card__content">
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SortableItem>
|
||||
<Sheet
|
||||
position="right"
|
||||
show={showManageTags}
|
||||
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
|
||||
>
|
||||
<ContentTagsDrawer
|
||||
id={id}
|
||||
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={unitStatus}
|
||||
hasChanges={hasChanges}
|
||||
cardId={id}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
isVertical
|
||||
enableCopyPasteUnits={enableCopyPasteUnits}
|
||||
onClickCopy={handleCopyClick}
|
||||
discussionEnabled={discussionEnabled}
|
||||
discussionsSettings={discussionsSettings}
|
||||
parentInfo={parentInfo}
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
<div className="unit-card__content item-children" data-testid="unit-card__content">
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SortableItem>
|
||||
);
|
||||
};
|
||||
|
||||
UnitCard.defaultProps = {
|
||||
discussionsSettings: {},
|
||||
tagsCount: undefined,
|
||||
};
|
||||
|
||||
UnitCard.propTypes = {
|
||||
@@ -256,7 +240,6 @@ UnitCard.propTypes = {
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
}),
|
||||
tagsCount: PropTypes.number,
|
||||
};
|
||||
|
||||
export default UnitCard;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import UnitCard from './UnitCard';
|
||||
@@ -49,29 +50,33 @@ const unit = {
|
||||
isHeaderVisible: true,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<UnitCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
unit={unit}
|
||||
index={1}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onCopyToClipboardClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<UnitCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
unit={unit}
|
||||
index={1}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onCopyToClipboardClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ jest.mock('@tanstack/react-query', () => ({
|
||||
},
|
||||
isSuccess: true,
|
||||
};
|
||||
} if (queryKey[0] === 'contentTaxonomyTagsCount') {
|
||||
} if (queryKey[0] === 'contentTagsCount') {
|
||||
return {
|
||||
data: 17,
|
||||
isSuccess: true,
|
||||
|
||||
@@ -26,7 +26,10 @@ const VideoThumbnail = ({
|
||||
intl,
|
||||
}) => {
|
||||
const fileInputControl = useFileInput({
|
||||
onAddFile: (file) => handleAddThumbnail(file, id),
|
||||
onAddFile: (files) => {
|
||||
const [file] = files;
|
||||
handleAddThumbnail(file, id);
|
||||
},
|
||||
setSelectedRows: () => {},
|
||||
setAddOpen: () => false,
|
||||
});
|
||||
@@ -46,18 +49,30 @@ const VideoThumbnail = ({
|
||||
const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded;
|
||||
|
||||
return (
|
||||
<div data-testid={`video-thumbnail-${id}`} className="video-thumbnail row justify-content-center align-itmes-center">
|
||||
{allowThumbnailUpload && <div className="thumbnail-overlay" />}
|
||||
<div className="video-thumbnail row justify-content-center align-itmes-center">
|
||||
{allowThumbnailUpload && showThumbnail && <div className="thumbnail-overlay" />}
|
||||
{showThumbnail && !thumbnailError && pageLoadStatus === RequestStatus.SUCCESSFUL ? (
|
||||
<div className="border rounded">
|
||||
<Image
|
||||
style={imageSize}
|
||||
className="m-1 bg-light-300"
|
||||
src={thumbnail}
|
||||
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
|
||||
onError={() => setThumbnailError(true)}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="border rounded">
|
||||
<Image
|
||||
style={imageSize}
|
||||
className="m-1 bg-light-300"
|
||||
src={thumbnail}
|
||||
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
|
||||
onError={() => setThumbnailError(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="add-thumbnail" data-testid={`video-thumbnail-${id}`}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={fileInputControl.click}
|
||||
tabIndex="0"
|
||||
>
|
||||
{addThumbnailMessage}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -76,24 +91,12 @@ const VideoThumbnail = ({
|
||||
</>
|
||||
)}
|
||||
{allowThumbnailUpload && (
|
||||
<>
|
||||
<div className="add-thumbnail">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={fileInputControl.click}
|
||||
tabIndex="0"
|
||||
>
|
||||
{addThumbnailMessage}
|
||||
</Button>
|
||||
</div>
|
||||
<FileInput
|
||||
key="video-thumbnail-upload"
|
||||
fileInput={fileInputControl}
|
||||
supportedFileFormats={supportedFiles}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</>
|
||||
<FileInput
|
||||
key="video-thumbnail-upload"
|
||||
fileInput={fileInputControl}
|
||||
supportedFileFormats={supportedFiles}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -218,6 +218,12 @@ describe('Videos page', () => {
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('should no render thumbnail upload button', async () => {
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const addThumbnailButton = screen.queryByTestId('video-thumbnail-mOckID5');
|
||||
|
||||
expect(addThumbnailButton).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('table actions', () => {
|
||||
|
||||
@@ -32,7 +32,8 @@ const Transcript = ({
|
||||
}, [transcript]);
|
||||
|
||||
const input = useFileInput({
|
||||
onAddFile: (file) => {
|
||||
onAddFile: (files) => {
|
||||
const [file] = files;
|
||||
handleTranscript({
|
||||
file,
|
||||
language,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb01': 10,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb02': 11,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb03': 12,
|
||||
2
src/generic/__mocks__/index.js
Normal file
2
src/generic/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as contentTagsCountMock } from './contentTagsCount';
|
||||
@@ -8,6 +8,7 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
|
||||
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
|
||||
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* Get's organizations data. Returns list of organization names.
|
||||
@@ -43,3 +44,18 @@ export async function createOrRerunCourse(courseData) {
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tags count of multiple content by id separated by commas or a pattern using a '*' wildcard.
|
||||
* @param {string} contentPattern
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getTagsCount(contentPattern) {
|
||||
if (contentPattern) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getTagsCountApiUrl(contentPattern));
|
||||
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { contentTagsCountMock } from '../__mocks__';
|
||||
import {
|
||||
createOrRerunCourse,
|
||||
getApiBaseUrl,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
getCreateOrRerunCourseUrl,
|
||||
getCourseRerunUrl,
|
||||
getCourseRerun,
|
||||
getTagsCount,
|
||||
getTagsCountApiUrl,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
@@ -72,4 +75,19 @@ describe('generic api calls', () => {
|
||||
expect(axiosMock.history.post[0].url).toEqual(getCreateOrRerunCourseUrl());
|
||||
expect(result).toEqual(courseRerunData);
|
||||
});
|
||||
|
||||
it('should get tags count', async () => {
|
||||
const pattern = 'this,is,a,pattern';
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb06';
|
||||
axiosMock.onGet().reply(200, contentTagsCountMock);
|
||||
const result = await getTagsCount(pattern);
|
||||
expect(axiosMock.history.get[0].url).toEqual(getTagsCountApiUrl(pattern));
|
||||
expect(result).toEqual(contentTagsCountMock);
|
||||
expect(contentTagsCountMock[contentId]).toEqual(15);
|
||||
});
|
||||
|
||||
it('should get null on empty pattern', async () => {
|
||||
const result = await getTagsCount('');
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getOrganizations } from './api';
|
||||
import { getOrganizations, getTagsCount } from './api';
|
||||
|
||||
/**
|
||||
* Builds the query to get a list of available organizations
|
||||
@@ -12,4 +12,23 @@ export const useOrganizationListData = () => (
|
||||
})
|
||||
);
|
||||
|
||||
export default useOrganizationListData;
|
||||
/**
|
||||
* Builds the query to get tags count of the whole contentId course and
|
||||
* returns the tags count of the specific contentId.
|
||||
* @param {string} contentId
|
||||
*/
|
||||
export const useContentTagsCount = (contentId) => {
|
||||
let contentPattern;
|
||||
if (contentId.includes('course-v1')) {
|
||||
// If the contentId is a course, we want to get the tags count only for the course
|
||||
contentPattern = contentId;
|
||||
} else {
|
||||
// If the contentId is not a course, we want to get the tags count for all the content of the course
|
||||
contentPattern = contentId.replace(/\+type@.*$/, '*');
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ['contentTagsCount', contentPattern],
|
||||
queryFn: /* istanbul ignore next */ () => getTagsCount(contentPattern),
|
||||
select: (data) => data[contentId] || 0, // Return the tags count of the specific contentId
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import useUnitTagsCount from './apiHooks';
|
||||
import { useContentTagsCount } from './apiHooks';
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
@@ -9,11 +9,11 @@ jest.mock('./api', () => ({
|
||||
getTagsCount: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useUnitTagsCount', () => {
|
||||
describe('useContentTagsCount', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
const pattern = '123';
|
||||
const result = useUnitTagsCount(pattern);
|
||||
const result = useContentTagsCount(pattern);
|
||||
|
||||
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||
});
|
||||
@@ -21,7 +21,7 @@ describe('useUnitTagsCount', () => {
|
||||
it('should return failure response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
const pattern = '123';
|
||||
const result = useUnitTagsCount(pattern);
|
||||
const result = useContentTagsCount(pattern);
|
||||
|
||||
expect(result).toEqual({ isSuccess: false });
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query';
|
||||
import { datadogRum } from '@datadog/browser-rum';
|
||||
|
||||
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
@@ -37,6 +38,21 @@ const App = () => {
|
||||
useEffect(() => {
|
||||
if (process.env.HOTJAR_APP_ID) {
|
||||
try {
|
||||
datadogRum.init({
|
||||
applicationId: 'a3f99dcb-4955-4baa-8341-39a88603ab08',
|
||||
clientToken: 'pubf2e79d946cec4c4413965620ba0e0b72',
|
||||
site: 'datadoghq.com',
|
||||
service: 'edx-frontend-sandbox',
|
||||
env: 'staging',
|
||||
// Specify a version number to identify the deployed version of your application in Datadog
|
||||
version: '1.0.0',
|
||||
sessionSampleRate: 100,
|
||||
sessionReplaySampleRate: 20,
|
||||
trackUserInteractions: true,
|
||||
trackResources: true,
|
||||
trackLongTasks: true,
|
||||
defaultPrivacyLevel: 'mask-user-input',
|
||||
});
|
||||
initializeHotjar({
|
||||
hotjarId: process.env.HOTJAR_APP_ID,
|
||||
hotjarVersion: process.env.HOTJAR_VERSION,
|
||||
@@ -101,6 +117,8 @@ subscribe(APP_INIT_ERROR, (error) => {
|
||||
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
|
||||
});
|
||||
|
||||
|
||||
|
||||
initialize({
|
||||
handlers: {
|
||||
config: () => {
|
||||
|
||||
@@ -24,17 +24,15 @@ import { Helmet } from 'react-helmet';
|
||||
import { useOrganizationListData } from '../generic/data/apiHooks';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { getTaxonomyTemplateApiUrl } from './data/api';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
|
||||
import { ALL_TAXONOMIES, apiUrls, UNASSIGNED } from './data/api';
|
||||
import { useImportNewTaxonomy, useTaxonomyList } from './data/apiHooks';
|
||||
import { importTaxonomy } from './import-tags';
|
||||
import messages from './messages';
|
||||
import TaxonomyCard from './taxonomy-card';
|
||||
|
||||
const ALL_TAXONOMIES = 'All taxonomies';
|
||||
const UNASSIGNED = 'Unassigned';
|
||||
|
||||
const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
const intl = useIntl();
|
||||
const importMutation = useImportNewTaxonomy();
|
||||
return (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
@@ -55,13 +53,13 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
href={getTaxonomyTemplateApiUrl('csv')}
|
||||
href={apiUrls.taxonomyTemplate('csv')}
|
||||
data-testid="taxonomy-download-template-csv"
|
||||
>
|
||||
{intl.formatMessage(messages.downloadTemplateButtonCSVLabel)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
href={getTaxonomyTemplateApiUrl('json')}
|
||||
href={apiUrls.taxonomyTemplate('json')}
|
||||
data-testid="taxonomy-download-template-json"
|
||||
>
|
||||
{intl.formatMessage(messages.downloadTemplateButtonJSONLabel)}
|
||||
@@ -71,7 +69,7 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
</OverlayTrigger>
|
||||
<Button
|
||||
iconBefore={Add}
|
||||
onClick={() => importTaxonomy(intl)}
|
||||
onClick={() => importTaxonomy(intl, importMutation)}
|
||||
data-testid="taxonomy-import-button"
|
||||
disabled={!canAddTaxonomy}
|
||||
>
|
||||
@@ -154,8 +152,10 @@ const TaxonomyListPage = () => {
|
||||
isSuccess: isOrganizationListLoaded,
|
||||
} = useOrganizationListData();
|
||||
|
||||
const taxonomyListData = useTaxonomyListDataResponse(selectedOrgFilter);
|
||||
const isLoaded = useIsTaxonomyListDataLoaded(selectedOrgFilter);
|
||||
const {
|
||||
data: taxonomyListData,
|
||||
isSuccess: isLoaded,
|
||||
} = useTaxonomyList(selectedOrgFilter);
|
||||
const canAddTaxonomy = taxonomyListData?.canAddTaxonomy ?? false;
|
||||
|
||||
const getOrgSelect = () => (
|
||||
|
||||
@@ -4,13 +4,17 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { getTaxonomyTemplateApiUrl } from './data/api';
|
||||
import { apiUrls } from './data/api';
|
||||
import TaxonomyListPage from './TaxonomyListPage';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
|
||||
import { importTaxonomy } from './import-tags';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
@@ -27,14 +31,12 @@ const taxonomies = [{
|
||||
tagsCount: 0,
|
||||
}];
|
||||
const organizationsListUrl = 'http://localhost:18010/organizations';
|
||||
const listTaxonomiesUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/?enabled=true';
|
||||
const listTaxonomiesUnassignedUrl = `${listTaxonomiesUrl}&unassigned=true`;
|
||||
const listTaxonomiesOrg1Url = `${listTaxonomiesUrl}&org=Org+1`;
|
||||
const listTaxonomiesOrg2Url = `${listTaxonomiesUrl}&org=Org+2`;
|
||||
const organizations = ['Org 1', 'Org 2'];
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
...jest.requireActual('./data/apiHooks'),
|
||||
useTaxonomyListDataResponse: jest.fn(),
|
||||
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./import-tags', () => ({
|
||||
importTaxonomy: jest.fn(),
|
||||
}));
|
||||
@@ -82,7 +84,8 @@ describe('<TaxonomyListPage />', () => {
|
||||
});
|
||||
|
||||
it('shows the spinner before the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(false);
|
||||
// Simulate an API request that times out:
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(new Promise(() => {}));
|
||||
await act(async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const spinner = getByRole('status');
|
||||
@@ -91,61 +94,50 @@ describe('<TaxonomyListPage />', () => {
|
||||
});
|
||||
|
||||
it('shows the data table after the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: taxonomies,
|
||||
canAddTaxonomy: false,
|
||||
});
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
|
||||
await act(async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const { getByTestId, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['CSV', 'JSON'])('downloads the taxonomy template %s', async (fileFormat) => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: taxonomies,
|
||||
canAddTaxonomy: false,
|
||||
});
|
||||
const { findByRole } = render(<RootWrapper />);
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
|
||||
const { findByRole, queryByText } = render(<RootWrapper />);
|
||||
// Wait until data has been loaded and rendered:
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
const templateMenu = await findByRole('button', { name: 'Download template' });
|
||||
fireEvent.click(templateMenu);
|
||||
const templateButton = await findByRole('link', { name: `${fileFormat} template` });
|
||||
fireEvent.click(templateButton);
|
||||
|
||||
expect(templateButton.href).toBe(getTaxonomyTemplateApiUrl(fileFormat.toLowerCase()));
|
||||
expect(templateButton.href).toBe(apiUrls.taxonomyTemplate(fileFormat.toLowerCase()));
|
||||
});
|
||||
|
||||
it('disables the import taxonomy button if not permitted', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: [],
|
||||
canAddTaxonomy: false,
|
||||
});
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: false });
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const { queryByText, getByRole } = render(<RootWrapper />);
|
||||
// Wait until data has been loaded and rendered:
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
expect(importButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls the import taxonomy action when the import button is clicked', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: [],
|
||||
canAddTaxonomy: true,
|
||||
});
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: true });
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
expect(importButton).not.toBeDisabled();
|
||||
// Once the API response is received and rendered, the Import button should be enabled:
|
||||
await waitFor(() => { expect(importButton).not.toBeDisabled(); });
|
||||
fireEvent.click(importButton);
|
||||
expect(importTaxonomy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, {
|
||||
results: [{
|
||||
id: 1,
|
||||
name: 'Taxonomy',
|
||||
@@ -163,7 +155,10 @@ describe('<TaxonomyListPage />', () => {
|
||||
getByText,
|
||||
getByRole,
|
||||
getAllByText,
|
||||
queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
// Wait until data has been loaded and rendered:
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
|
||||
expect(getByTestId('taxonomy-orgs-filter-selector')).toBeInTheDocument();
|
||||
// Check that the default filter is set to 'All taxonomies' when page is loaded
|
||||
@@ -184,13 +179,29 @@ describe('<TaxonomyListPage />', () => {
|
||||
});
|
||||
|
||||
it('should fetch taxonomies with correct params for org filters', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: taxonomies,
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
|
||||
const defaults = {
|
||||
id: 1,
|
||||
showSystemBadge: false,
|
||||
canChangeTaxonomy: true,
|
||||
canDeleteTaxonomy: true,
|
||||
tagsCount: 0,
|
||||
description: 'Taxonomy description here',
|
||||
};
|
||||
axiosMock.onGet(listTaxonomiesUnassignedUrl).reply(200, {
|
||||
canAddTaxonomy: false,
|
||||
results: [{ name: 'Unassigned Taxonomy A', ...defaults }],
|
||||
});
|
||||
axiosMock.onGet(listTaxonomiesOrg1Url).reply(200, {
|
||||
canAddTaxonomy: false,
|
||||
results: [{ name: 'Org1 Taxonomy B', ...defaults }],
|
||||
});
|
||||
axiosMock.onGet(listTaxonomiesOrg2Url).reply(200, {
|
||||
canAddTaxonomy: false,
|
||||
results: [{ name: 'Org2 Taxonomy C', ...defaults }],
|
||||
});
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const { getByRole, getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
// Open the taxonomies org filter select menu
|
||||
const taxonomiesFilterSelectMenu = await getByRole('button', { name: 'All taxonomies' });
|
||||
@@ -198,22 +209,28 @@ describe('<TaxonomyListPage />', () => {
|
||||
|
||||
// Check that the 'Unassigned' option is correctly called
|
||||
fireEvent.click(getByRole('link', { name: 'Unassigned' }));
|
||||
|
||||
expect(useTaxonomyListDataResponse).toBeCalledWith('Unassigned');
|
||||
await waitFor(() => {
|
||||
expect(getByText('Unassigned Taxonomy A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the taxonomies org filter select menu again
|
||||
fireEvent.click(taxonomiesFilterSelectMenu);
|
||||
|
||||
// Check that the 'Org 1' option is correctly called
|
||||
fireEvent.click(getByRole('link', { name: 'Org 1' }));
|
||||
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 1');
|
||||
await waitFor(() => {
|
||||
expect(getByText('Org1 Taxonomy B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the taxonomies org filter select menu again
|
||||
fireEvent.click(taxonomiesFilterSelectMenu);
|
||||
|
||||
// Check that the 'Org 2' option is correctly called
|
||||
fireEvent.click(getByRole('link', { name: 'Org 2' }));
|
||||
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 2');
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Org1 Taxonomy B')).not.toBeInTheDocument();
|
||||
expect(queryByText('Org2 Taxonomy C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the taxonomies org filter select menu again
|
||||
fireEvent.click(taxonomiesFilterSelectMenu);
|
||||
@@ -221,6 +238,8 @@ describe('<TaxonomyListPage />', () => {
|
||||
// Check that the 'All' option is correctly called, it should show as
|
||||
// 'All' rather than 'All taxonomies' in the select menu since its not selected
|
||||
fireEvent.click(getByRole('link', { name: 'All' }));
|
||||
expect(useTaxonomyListDataResponse).toBeCalledWith('All taxonomies');
|
||||
await waitFor(() => {
|
||||
expect(getByText(taxonomies[0].description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,71 +3,123 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getTaxonomyListApiUrl = (org) => {
|
||||
const url = new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl());
|
||||
url.searchParams.append('enabled', 'true');
|
||||
if (org !== undefined) {
|
||||
if (org === 'Unassigned') {
|
||||
url.searchParams.append('unassigned', 'true');
|
||||
} else if (org !== 'All taxonomies') {
|
||||
url.searchParams.append('org', org);
|
||||
}
|
||||
const getTaxonomiesV1Endpoint = () => new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl()).href;
|
||||
/**
|
||||
* Helper method for creating URLs for the tagging/taxonomy API. Used only in this file.
|
||||
* @param {string} path The subpath within the taxonomies "v1" REST API namespace
|
||||
* @param {Record<string, string | number>} [searchParams] Query parameters to include
|
||||
*/
|
||||
const makeUrl = (path, searchParams) => {
|
||||
const url = new URL(path, getTaxonomiesV1Endpoint());
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([k, v]) => url.searchParams.append(k, String(v)));
|
||||
}
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const getExportTaxonomyApiUrl = (pk, format) => new URL(
|
||||
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
export const ALL_TAXONOMIES = '__all';
|
||||
export const UNASSIGNED = '__unassigned';
|
||||
|
||||
export const getTaxonomyTemplateApiUrl = (format) => new URL(
|
||||
`api/content_tagging/v1/taxonomies/import/template.${format}`,
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
|
||||
/**
|
||||
* Get the URL for a Taxonomy
|
||||
* @param {number} pk
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTaxonomyApiUrl = (pk) => new URL(`api/content_tagging/v1/taxonomies/${pk}/`, getApiBaseUrl()).href;
|
||||
/** @satisfies {Record<string, (...args: any[]) => string>} */
|
||||
export const apiUrls = {
|
||||
/**
|
||||
* Get the URL of the "list all taxonomies" endpoint
|
||||
* @param {string} [org] Optionally, Filter the list to only show taxonomies assigned to this org
|
||||
*/
|
||||
taxonomyList(org) {
|
||||
const params = {};
|
||||
if (org !== undefined) {
|
||||
if (org === UNASSIGNED) {
|
||||
params.unassigned = 'true';
|
||||
} else if (org !== ALL_TAXONOMIES) {
|
||||
params.org = org;
|
||||
}
|
||||
}
|
||||
return makeUrl('.', { enabled: 'true', ...params });
|
||||
},
|
||||
/**
|
||||
* Get the URL of the API endpoint to download a taxonomy as a CSV/JSON file.
|
||||
* @param {number} taxonomyId The ID of the taxonomy
|
||||
* @param {'json'|'csv'} format Which format to use for the export
|
||||
*/
|
||||
exportTaxonomy: (taxonomyId, format) => makeUrl(`${taxonomyId}/export/`, { output_format: format, download: 1 }),
|
||||
/**
|
||||
* The the URL of the downloadable template file that shows how to format a
|
||||
* taxonomy file.
|
||||
* @param {'json'|'csv'} format The format requested
|
||||
*/
|
||||
taxonomyTemplate: (format) => makeUrl(`import/template.${format}`),
|
||||
/**
|
||||
* Get the URL for a Taxonomy
|
||||
* @param {number} taxonomyId The ID of the taxonomy
|
||||
*/
|
||||
taxonomy: (taxonomyId) => makeUrl(`${taxonomyId}/`),
|
||||
/**
|
||||
* Get the URL for listing the tags of a taxonomy
|
||||
* @param {number} taxonomyId
|
||||
* @param {number} pageIndex Zero-indexed page number
|
||||
* @param {*} pageSize How many tags per page to load
|
||||
*/
|
||||
tagList: (taxonomyId, pageIndex, pageSize) => makeUrl(`${taxonomyId}/tags/`, {
|
||||
page: (pageIndex + 1), page_size: pageSize,
|
||||
}),
|
||||
/**
|
||||
* Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future.
|
||||
* @param {number} taxonomyId
|
||||
* @param {string} parentTagValue
|
||||
*/
|
||||
allSubtagsOf: (taxonomyId, parentTagValue) => makeUrl(`${taxonomyId}/tags/`, {
|
||||
// Load as deeply as we can
|
||||
full_depth_threshold: 10000,
|
||||
parent_tag: parentTagValue,
|
||||
}),
|
||||
/** URL to create a new taxonomy from an import file. */
|
||||
createTaxonomyFromImport: () => makeUrl('import/'),
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
*/
|
||||
tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`),
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
*/
|
||||
tagsPlanImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/plan/`),
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of taxonomies.
|
||||
* @param {string} org Optioanl organization query param
|
||||
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
|
||||
* @returns {Promise<import("./types.mjs").TaxonomyListData>}
|
||||
*/
|
||||
export async function getTaxonomyListData(org) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl(org));
|
||||
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomyList(org));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Taxonomy
|
||||
* @param {number} pk
|
||||
* @returns {Promise<Object>}
|
||||
* @param {number} taxonomyId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteTaxonomy(pk) {
|
||||
await getAuthenticatedHttpClient().delete(getTaxonomyApiUrl(pk));
|
||||
export async function deleteTaxonomy(taxonomyId) {
|
||||
await getAuthenticatedHttpClient().delete(apiUrls.taxonomy(taxonomyId));
|
||||
}
|
||||
|
||||
/** Get a Taxonomy
|
||||
* @param {number} pk
|
||||
* @returns {Promise<import("./types.mjs").TaxonomyData>}
|
||||
*/
|
||||
export async function getTaxonomy(pk) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyApiUrl(pk));
|
||||
/**
|
||||
* Get metadata about a Taxonomy
|
||||
* @param {number} taxonomyId The ID of the taxonomy to get
|
||||
* @returns {Promise<import("./types.mjs").TaxonomyData>}
|
||||
*/
|
||||
export async function getTaxonomy(taxonomyId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomy(taxonomyId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the file of the exported taxonomy
|
||||
* @param {number} pk
|
||||
* @param {string} format
|
||||
* @param {number} taxonomyId The ID of the taxonomy
|
||||
* @param {'json'|'csv'} format Which format to use for the export file.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function getTaxonomyExportFile(pk, format) {
|
||||
window.location.href = getExportTaxonomyApiUrl(pk, format);
|
||||
export function getTaxonomyExportFile(taxonomyId, format) {
|
||||
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
@@ -5,11 +6,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { taxonomyListMock } from '../__mocks__';
|
||||
|
||||
import {
|
||||
getExportTaxonomyApiUrl,
|
||||
apiUrls,
|
||||
getTaxonomyExportFile,
|
||||
getTaxonomyListApiUrl,
|
||||
getTaxonomyListData,
|
||||
getTaxonomyApiUrl,
|
||||
getTaxonomy,
|
||||
deleteTaxonomy,
|
||||
} from './api';
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
let axiosMock;
|
||||
|
||||
describe('taxonomy api calls', () => {
|
||||
const { location } = window;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -35,50 +33,47 @@ describe('taxonomy api calls', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: '',
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
window.location = location;
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'All taxonomies',
|
||||
'Unassigned',
|
||||
'testOrg',
|
||||
])('should get taxonomy list data for \'%s\' org filter', async (org) => {
|
||||
axiosMock.onGet(getTaxonomyListApiUrl(org)).reply(200, taxonomyListMock);
|
||||
axiosMock.onGet(apiUrls.taxonomyList(org)).reply(200, taxonomyListMock);
|
||||
const result = await getTaxonomyListData(org);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl(org));
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomyList(org));
|
||||
expect(result).toEqual(taxonomyListMock);
|
||||
});
|
||||
|
||||
it('should delete a taxonomy', async () => {
|
||||
axiosMock.onDelete(getTaxonomyApiUrl()).reply(200);
|
||||
await deleteTaxonomy();
|
||||
const taxonomyId = 123;
|
||||
axiosMock.onDelete(apiUrls.taxonomy(taxonomyId)).reply(200);
|
||||
await deleteTaxonomy(taxonomyId);
|
||||
|
||||
expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl());
|
||||
expect(axiosMock.history.delete[0].url).toEqual(apiUrls.taxonomy(taxonomyId));
|
||||
});
|
||||
|
||||
it('should call get taxonomy', async () => {
|
||||
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
|
||||
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
|
||||
await getTaxonomy(1);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyApiUrl(1));
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomy(1));
|
||||
});
|
||||
|
||||
it('Export should set window.location.href correctly', () => {
|
||||
const origLocation = window.location;
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
// @ts-ignore
|
||||
window.location = { href: '' };
|
||||
|
||||
const pk = 1;
|
||||
const format = 'json';
|
||||
|
||||
getTaxonomyExportFile(pk, format);
|
||||
expect(window.location.href).toEqual(apiUrls.exportTaxonomy(pk, format));
|
||||
|
||||
expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format));
|
||||
// Restore the location object of window:
|
||||
window.location = origLocation;
|
||||
});
|
||||
});
|
||||
|
||||
206
src/taxonomy/data/apiHooks.js
Normal file
206
src/taxonomy/data/apiHooks.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* This is a file used especially in this `taxonomy` module.
|
||||
*
|
||||
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
|
||||
* This approach accelerates the development.
|
||||
*
|
||||
* In this file you will find two types of hooks:
|
||||
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
|
||||
* Ex. useTaxonomyListData.
|
||||
* - Hooks that calls the query hook, prepare and return the data.
|
||||
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
|
||||
*/
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { apiUrls, ALL_TAXONOMIES } from './api';
|
||||
import * as api from './api';
|
||||
|
||||
// Query key patterns. Allows an easy way to clear all data related to a given taxonomy.
|
||||
// https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst
|
||||
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
|
||||
export const taxonomyQueryKeys = {
|
||||
all: ['taxonomies'],
|
||||
/**
|
||||
* Key for the list of taxonomies, optionally filtered by org.
|
||||
* @param {string} [org] Which org we fetched the taxonomy list for (optional)
|
||||
*/
|
||||
taxonomyList: (org) => [
|
||||
...taxonomyQueryKeys.all, 'taxonomyList', ...(org && org !== ALL_TAXONOMIES ? [org] : []),
|
||||
],
|
||||
/**
|
||||
* Base key for data specific to a single taxonomy. No data is stored directly in this key.
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
*/
|
||||
taxonomy: (taxonomyId) => [...taxonomyQueryKeys.all, 'taxonomy', taxonomyId],
|
||||
/**
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
*/
|
||||
taxonomyMetadata: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'metadata'],
|
||||
/**
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
*/
|
||||
taxonomyTagList: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'tags'],
|
||||
/**
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
* @param {number} pageIndex Which page of tags to load (zero-based)
|
||||
* @param {number} pageSize
|
||||
*/
|
||||
taxonomyTagListPage: (taxonomyId, pageIndex, pageSize) => [
|
||||
...taxonomyQueryKeys.taxonomyTagList(taxonomyId), 'page', pageIndex, pageSize,
|
||||
],
|
||||
/**
|
||||
* Query for loading _all_ the subtags of a particular parent tag
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
* @param {string} parentTagValue
|
||||
*/
|
||||
taxonomyTagSubtagsList: (taxonomyId, parentTagValue) => [
|
||||
...taxonomyQueryKeys.taxonomyTagList(taxonomyId), 'subtags', parentTagValue,
|
||||
],
|
||||
/**
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
* @param {string} fileId Some string to uniquely identify the file we want to upload
|
||||
*/
|
||||
importPlan: (taxonomyId, fileId) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId],
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy list
|
||||
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
|
||||
*/
|
||||
export const useTaxonomyList = (org) => (
|
||||
useQuery({
|
||||
queryKey: taxonomyQueryKeys.taxonomyList(org),
|
||||
queryFn: () => api.getTaxonomyListData(org),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the mutation to delete a taxonomy.
|
||||
* @returns A function that can be used to delete the taxonomy.
|
||||
*/
|
||||
export const useDeleteTaxonomy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync } = useMutation({
|
||||
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
|
||||
mutationFn: async ({ pk }) => api.deleteTaxonomy(pk),
|
||||
onSettled: (_d, _e, args) => {
|
||||
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyList() });
|
||||
queryClient.removeQueries({ queryKey: taxonomyQueryKeys.taxonomy(args.pk) });
|
||||
},
|
||||
});
|
||||
return mutateAsync;
|
||||
};
|
||||
|
||||
/** Builds the query to get the taxonomy detail
|
||||
* @param {number} taxonomyId
|
||||
*/
|
||||
export const useTaxonomyDetails = (taxonomyId) => useQuery({
|
||||
queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId),
|
||||
queryFn: () => api.getTaxonomy(taxonomyId),
|
||||
});
|
||||
|
||||
/**
|
||||
* Use this mutation to import a new taxonomy.
|
||||
*/
|
||||
export const useImportNewTaxonomy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* import("./types.mjs").TaxonomyData,
|
||||
* any,
|
||||
* {
|
||||
* name: string,
|
||||
* exportId: string,
|
||||
* description: string,
|
||||
* file: File,
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: async ({
|
||||
name, exportId, description, file,
|
||||
}) => {
|
||||
const formData = new FormData();
|
||||
formData.append('taxonomy_name', name);
|
||||
formData.append('taxonomy_export_id', exportId);
|
||||
formData.append('taxonomy_description', description);
|
||||
formData.append('file', file);
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(apiUrls.createTaxonomyFromImport(), formData);
|
||||
return camelCaseObject(data);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// There's a new taxonomy, so the list of taxonomies needs to be refreshed:
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taxonomyQueryKeys.taxonomyList(),
|
||||
});
|
||||
queryClient.setQueryData(taxonomyQueryKeys.taxonomyMetadata(data.id), data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the mutation to import tags to an existing taxonomy
|
||||
*/
|
||||
export const useImportTags = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* import("./types.mjs").TaxonomyData,
|
||||
* any,
|
||||
* {
|
||||
* taxonomyId: number,
|
||||
* file: File,
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: async ({ taxonomyId, file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsImport(taxonomyId), formData);
|
||||
return camelCaseObject(data);
|
||||
} catch (/** @type {any} */ err) {
|
||||
throw new Error(err.response?.data?.error || err.message);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taxonomyQueryKeys.taxonomyTagList(data.id),
|
||||
});
|
||||
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
|
||||
queryClient.setQueryData(taxonomyQueryKeys.taxonomyMetadata(data.id), data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Preview the results of importing the given file into an existing taxonomy.
|
||||
* @param {number} taxonomyId The ID of the taxonomy whose tags we're updating.
|
||||
* @param {File|null} file The file that we want to import
|
||||
*/
|
||||
export const useImportPlan = (taxonomyId, file) => useQuery({
|
||||
queryKey: taxonomyQueryKeys.importPlan(taxonomyId, file ? `${file.name}${file.lastModified}${file.size}` : ''),
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").QueryFunction<string|null>}
|
||||
*/
|
||||
queryFn: async () => {
|
||||
if (file === null) {
|
||||
return null;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsPlanImport(taxonomyId), formData);
|
||||
return /** @type {string} */(data.plan);
|
||||
} catch (/** @type {any} */ err) {
|
||||
throw new Error(err.response?.data?.error || err.message);
|
||||
}
|
||||
},
|
||||
retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times!
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* This is a file used especially in this `taxonomy` module.
|
||||
*
|
||||
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
|
||||
* This approach accelerates the development.
|
||||
*
|
||||
* In this file you will find two types of hooks:
|
||||
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
|
||||
* Ex. useTaxonomyListData.
|
||||
* - Hooks that calls the query hook, prepare and return the data.
|
||||
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
|
||||
*/
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTaxonomyListData, deleteTaxonomy, getTaxonomy } from './api';
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy list
|
||||
* @param {string} org Optional organization query param
|
||||
*/
|
||||
const useTaxonomyListData = (org) => (
|
||||
useQuery({
|
||||
queryKey: ['taxonomyList', org],
|
||||
queryFn: () => getTaxonomyListData(org),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the mutation to delete a taxonomy.
|
||||
* @returns An object with the mutation configuration.
|
||||
*/
|
||||
export const useDeleteTaxonomy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate } = useMutation({
|
||||
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
|
||||
mutationFn: async ({ pk }) => deleteTaxonomy(pk),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['taxonomyList'] });
|
||||
},
|
||||
});
|
||||
return mutate;
|
||||
};
|
||||
|
||||
/** Builds the query to get the taxonomy detail
|
||||
* @param {number} taxonomyId
|
||||
*/
|
||||
const useTaxonomyDetailData = (taxonomyId) => (
|
||||
useQuery({
|
||||
queryKey: ['taxonomyDetail', taxonomyId],
|
||||
queryFn: async () => getTaxonomy(taxonomyId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the taxonomy list data
|
||||
* @param {string} org Optional organization query param
|
||||
* @returns {import("./types.mjs").TaxonomyListData | undefined}
|
||||
*/
|
||||
export const useTaxonomyListDataResponse = (org) => {
|
||||
const response = useTaxonomyListData(org);
|
||||
if (response.status === 'success') {
|
||||
return { ...response.data, refetch: response.refetch };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the status of the taxonomy list query
|
||||
* @param {string} org Optional organization param
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const useIsTaxonomyListDataLoaded = (org) => (
|
||||
useTaxonomyListData(org).status === 'success'
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isSuccess">}
|
||||
*/
|
||||
export const useTaxonomyDetailDataStatus = (taxonomyId) => {
|
||||
const {
|
||||
isError,
|
||||
error,
|
||||
isFetched,
|
||||
isSuccess,
|
||||
} = useTaxonomyDetailData(taxonomyId);
|
||||
return {
|
||||
isError,
|
||||
error,
|
||||
isFetched,
|
||||
isSuccess,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @returns {import("./types.mjs").TaxonomyData | undefined}
|
||||
*/
|
||||
export const useTaxonomyDetailDataResponse = (taxonomyId) => {
|
||||
const { isSuccess, data } = useTaxonomyDetailData(taxonomyId);
|
||||
if (isSuccess) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,78 +1,110 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { act } from '@testing-library/react';
|
||||
// @ts-check
|
||||
import React from 'react'; // Required to use JSX syntax without type errors
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { apiUrls } from './api';
|
||||
|
||||
import {
|
||||
useTaxonomyListDataResponse,
|
||||
useIsTaxonomyListDataLoaded,
|
||||
useDeleteTaxonomy,
|
||||
useImportPlan,
|
||||
useImportTags,
|
||||
useImportNewTaxonomy,
|
||||
} from './apiHooks';
|
||||
import { deleteTaxonomy } from './api';
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
useMutation: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
}));
|
||||
let axiosMock;
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
deleteTaxonomy: jest.fn(),
|
||||
}));
|
||||
|
||||
/*
|
||||
* TODO: We can refactor this test: Mock the API response using axiosMock.
|
||||
* Ref: https://github.com/openedx/frontend-app-course-authoring/pull/684#issuecomment-1847694090
|
||||
*/
|
||||
describe('useTaxonomyListDataResponse', () => {
|
||||
it('should return data when status is success', () => {
|
||||
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
|
||||
|
||||
const result = useTaxonomyListDataResponse();
|
||||
|
||||
expect(result).toEqual({ data: 'data' });
|
||||
});
|
||||
|
||||
it('should return undefined when status is not success', () => {
|
||||
useQuery.mockReturnValueOnce({ status: 'error' });
|
||||
|
||||
const result = useTaxonomyListDataResponse();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('useIsTaxonomyListDataLoaded', () => {
|
||||
it('should return true when status is success', () => {
|
||||
useQuery.mockReturnValueOnce({ status: 'success' });
|
||||
const wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const result = useIsTaxonomyListDataLoaded();
|
||||
const emptyFile = new File([], 'empty.csv');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when status is not success', () => {
|
||||
useQuery.mockReturnValueOnce({ status: 'error' });
|
||||
|
||||
const result = useIsTaxonomyListDataLoaded();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteTaxonomy', () => {
|
||||
it('should call the delete function', async () => {
|
||||
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
|
||||
|
||||
const mutation = useDeleteTaxonomy();
|
||||
mutation();
|
||||
|
||||
expect(useMutation).toBeCalled();
|
||||
|
||||
const [config] = useMutation.mock.calls[0];
|
||||
const { mutationFn } = config;
|
||||
|
||||
await act(async () => {
|
||||
await mutationFn({ pk: 1 });
|
||||
expect(deleteTaxonomy).toBeCalledWith(1);
|
||||
describe('import taxonomy api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call import new taxonomy', async () => {
|
||||
const mockResult = {
|
||||
id: 8,
|
||||
name: 'Taxonomy name',
|
||||
exportId: 'taxonomy_export_id',
|
||||
description: 'Taxonomy description',
|
||||
};
|
||||
axiosMock.onPost(apiUrls.createTaxonomyFromImport()).reply(201, mockResult);
|
||||
const { result } = renderHook(() => useImportNewTaxonomy(), { wrapper });
|
||||
const mutateResult = await result.current.mutateAsync({
|
||||
name: 'Taxonomy name',
|
||||
description: 'Taxonomy description',
|
||||
exportId: 'taxonomy_export_id',
|
||||
file: emptyFile,
|
||||
});
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(apiUrls.createTaxonomyFromImport());
|
||||
expect(mutateResult).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should call import tags', async () => {
|
||||
const taxonomy = { id: 1, name: 'taxonomy name' };
|
||||
axiosMock.onPut(apiUrls.tagsImport(1)).reply(200, taxonomy);
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData');
|
||||
|
||||
const { result } = renderHook(() => useImportTags(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({ taxonomyId: 1, file: emptyFile });
|
||||
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsImport(1));
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['taxonomies', 'taxonomy', 1, 'tags'] });
|
||||
expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomies', 'taxonomy', 1, 'metadata'], taxonomy);
|
||||
});
|
||||
|
||||
it('should call plan import tags', async () => {
|
||||
axiosMock.onPut(apiUrls.tagsPlanImport(1)).reply(200, { plan: 'some plan' });
|
||||
const { result } = renderHook(() => useImportPlan(1, emptyFile), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBeFalsy();
|
||||
});
|
||||
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
|
||||
expect(result.current.data).toEqual('some plan');
|
||||
});
|
||||
|
||||
it('should handle errors in plan import tags', async () => {
|
||||
axiosMock.onPut(apiUrls.tagsPlanImport(1)).reply(400, { error: 'test error' });
|
||||
const { result } = renderHook(() => useImportPlan(1, emptyFile), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
expect(result.current.error).toEqual(Error('test error'));
|
||||
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} TaxonomyData
|
||||
* @typedef {Object} TaxonomyData Metadata about a taxonomy
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {string} description
|
||||
@@ -20,14 +20,13 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TaxonomyListData
|
||||
* @typedef {Object} TaxonomyListData The list of taxonomies
|
||||
* @property {string} next
|
||||
* @property {string} previous
|
||||
* @property {number} count
|
||||
* @property {number} numPages
|
||||
* @property {number} currentPage
|
||||
* @property {number} start
|
||||
* @property {function} refetch
|
||||
* @property {boolean} canAddTaxonomy
|
||||
* @property {TaxonomyData[]} results
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ const ExportModal = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [outputFormat, setOutputFormat] = useState('csv');
|
||||
const [outputFormat, setOutputFormat] = useState(/** @type {'csv'|'json'} */('csv'));
|
||||
|
||||
const onClickExport = React.useCallback(() => {
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-check
|
||||
import React, { useState, useContext } from 'react';
|
||||
import React, { useState, useContext, useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
useToggle,
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import LoadingButton from '../../generic/loading-button';
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import { getFileSizeToClosestByte } from '../../utils';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
import { getTaxonomyExportFile } from '../data/api';
|
||||
import { planImportTags, useImportTags } from './data/api';
|
||||
import { useImportTags, useImportPlan } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const linebreak = <> <br /> <br /> </>;
|
||||
@@ -73,20 +74,17 @@ const UploadStep = ({
|
||||
file,
|
||||
setFile,
|
||||
importPlanError,
|
||||
setImportPlanError,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
/** @type {(args: {fileData: FormData}) => void} */
|
||||
const handleFileLoad = ({ fileData }) => {
|
||||
setFile(fileData.get('file'));
|
||||
setImportPlanError(null);
|
||||
};
|
||||
|
||||
const clearFile = (e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
setImportPlanError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -147,7 +145,6 @@ UploadStep.propTypes = {
|
||||
}),
|
||||
setFile: PropTypes.func.isRequired,
|
||||
importPlanError: PropTypes.string,
|
||||
setImportPlanError: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UploadStep.defaultProps = {
|
||||
@@ -228,35 +225,28 @@ const ImportTagsWizard = ({
|
||||
|
||||
const [file, setFile] = useState(/** @type {null|File} */ (null));
|
||||
|
||||
const [importPlan, setImportPlan] = useState(/** @type {null|string[]} */ (null));
|
||||
const [importPlanError, setImportPlanError] = useState(null);
|
||||
|
||||
const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false);
|
||||
|
||||
const importPlanResult = useImportPlan(taxonomy.id, file);
|
||||
|
||||
const importPlan = useMemo(() => {
|
||||
if (!importPlanResult.data) {
|
||||
return null;
|
||||
}
|
||||
let planArrayTemp = importPlanResult.data.split('\n');
|
||||
planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines
|
||||
planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line
|
||||
const planArray = planArrayTemp
|
||||
.filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines
|
||||
.map((line) => line.split(':')[1].trim()); // Get only the action message
|
||||
return /** @type {string[]} */(planArray);
|
||||
}, [importPlanResult.data]);
|
||||
|
||||
const importTagsMutation = useImportTags();
|
||||
|
||||
const generatePlan = async () => {
|
||||
disableDialog();
|
||||
try {
|
||||
if (file) {
|
||||
const plan = await planImportTags(taxonomy.id, file);
|
||||
let planArrayTemp = plan.split('\n');
|
||||
planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines
|
||||
planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line
|
||||
const planArray = planArrayTemp
|
||||
.filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines
|
||||
.map((line) => line.split(':')[1].trim()); // Get only the action message
|
||||
setImportPlan(planArray);
|
||||
setImportPlanError(null);
|
||||
setCurrentStep('plan');
|
||||
}
|
||||
} catch (/** @type {any} */ error) {
|
||||
setImportPlan(null);
|
||||
setImportPlanError(error.message);
|
||||
} finally {
|
||||
enableDialog();
|
||||
}
|
||||
};
|
||||
const generatePlan = React.useCallback(() => {
|
||||
setCurrentStep('plan');
|
||||
}, []);
|
||||
|
||||
const confirmImportTags = async () => {
|
||||
disableDialog();
|
||||
@@ -326,8 +316,8 @@ const ImportTagsWizard = ({
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
>
|
||||
{isDialogDisabled && (
|
||||
// This div is used to prevent the user from interacting with the dialog while it is disabled
|
||||
{(isDialogDisabled) && (
|
||||
// This div is used to prevent the user from interacting with the dialog while the import is happening
|
||||
<div className="position-absolute w-100 h-100 d-block zindex-9" />
|
||||
)}
|
||||
|
||||
@@ -341,8 +331,7 @@ const ImportTagsWizard = ({
|
||||
<UploadStep
|
||||
file={file}
|
||||
setFile={setFile}
|
||||
importPlanError={importPlanError}
|
||||
setImportPlanError={setImportPlanError}
|
||||
importPlanError={/** @type {Error|undefined} */(importPlanResult.error)?.message}
|
||||
/>
|
||||
<PlanStep importPlan={importPlan} />
|
||||
<ConfirmStep importPlan={importPlan} />
|
||||
@@ -369,11 +358,16 @@ const ImportTagsWizard = ({
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{intl.formatMessage(messages.importWizardButtonCancel)}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.importWizardButtonImport)}
|
||||
disabled={!file || !!importPlanError}
|
||||
onClick={generatePlan}
|
||||
/>
|
||||
{
|
||||
importPlanResult.isLoading ? <LoadingSpinner />
|
||||
: (
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.importWizardButtonImport)}
|
||||
disabled={!file || importPlanResult.isLoading || !!importPlanResult.error}
|
||||
onClick={generatePlan}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="plan">
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
@@ -13,29 +16,18 @@ import PropTypes from 'prop-types';
|
||||
import initializeStore from '../../store';
|
||||
import { getTaxonomyExportFile } from '../data/api';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
import { planImportTags } from './data/api';
|
||||
import ImportTagsWizard from './ImportTagsWizard';
|
||||
|
||||
let store;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
let axiosMock;
|
||||
|
||||
jest.mock('../data/api', () => ({
|
||||
...jest.requireActual('../data/api'),
|
||||
getTaxonomyExportFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseImportTagsMutate = jest.fn();
|
||||
|
||||
jest.mock('./data/api', () => ({
|
||||
...jest.requireActual('./data/api'),
|
||||
planImportTags: jest.fn(),
|
||||
useImportTags: jest.fn(() => ({
|
||||
...jest.requireActual('./data/api').useImportTags(),
|
||||
mutateAsync: mockUseImportTagsMutate,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSetToastMessage = jest.fn();
|
||||
const mockSetAlertProps = jest.fn();
|
||||
const context = {
|
||||
@@ -45,6 +37,9 @@ const context = {
|
||||
setAlertProps: mockSetAlertProps,
|
||||
};
|
||||
|
||||
const planImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/plan/';
|
||||
const doImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/';
|
||||
|
||||
const taxonomy = {
|
||||
id: 1,
|
||||
name: 'Test Taxonomy',
|
||||
@@ -77,6 +72,7 @@ describe('<ImportTagsWizard />', () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -129,7 +125,7 @@ describe('<ImportTagsWizard />', () => {
|
||||
expect(getByTestId('upload-step')).toBeInTheDocument();
|
||||
|
||||
// Continue flow
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
let importButton = getByRole('button', { name: 'Import' });
|
||||
expect(importButton).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// Invalid file type
|
||||
@@ -138,48 +134,56 @@ describe('<ImportTagsWizard />', () => {
|
||||
expect(getByTestId('dropzone')).toBeInTheDocument();
|
||||
expect(importButton).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' });
|
||||
|
||||
// Correct file type
|
||||
const fileJson = new File(['file contents'], 'example.json', { type: 'application/gzip' });
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
|
||||
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: 'Import plan' });
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } });
|
||||
expect(await findByTestId('file-info')).toBeInTheDocument();
|
||||
expect(getByText('example.json')).toBeInTheDocument();
|
||||
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
|
||||
expect(getByText('example1.json')).toBeInTheDocument();
|
||||
|
||||
// Clear file
|
||||
fireEvent.click(getByTestId('clear-file-button'));
|
||||
expect(await findByTestId('dropzone')).toBeInTheDocument();
|
||||
|
||||
// Reselect file
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
|
||||
// Simulate error (note: React-Query may start to retrieve the import plan as soon as the file is selected)
|
||||
axiosMock.onPut(planImportUrl).replyOnce(400, { error: 'Test error - details here' });
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example2.json')], types: ['Files'] } });
|
||||
expect(await findByTestId('file-info')).toBeInTheDocument();
|
||||
|
||||
// Simulate error
|
||||
planImportTags.mockRejectedValueOnce(new Error('Test error'));
|
||||
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
|
||||
fireEvent.click(importButton);
|
||||
|
||||
// Check error message
|
||||
expect(planImportTags).toHaveBeenCalledWith(taxonomy.id, fileJson);
|
||||
expect(await findByText('Test error')).toBeInTheDocument();
|
||||
const errorAlert = getByText('Test error');
|
||||
await waitFor(async () => {
|
||||
// Note: import button gets re-created after showing a spinner while the import plan is loaded.
|
||||
importButton = getByRole('button', { name: 'Import' });
|
||||
expect(await findByText('Test error - details here')).toBeInTheDocument();
|
||||
// Because of the import error, we cannot proceed to the next step
|
||||
expect(importButton).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
const errorAlert = getByText('Test error - details here');
|
||||
|
||||
// Reselect file to clear the error
|
||||
fireEvent.click(getByTestId('clear-file-button'));
|
||||
expect(errorAlert).not.toBeInTheDocument();
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
|
||||
|
||||
// Now simulate uploading a correct file.
|
||||
const expectedPlan = 'Import plan for Test import taxonomy\n'
|
||||
+ '--------------------------------\n'
|
||||
+ '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n'
|
||||
+ '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n'
|
||||
+ '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n'
|
||||
+ '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n'
|
||||
+ '#5: Delete tag (external_id=old_tag_1)\n'
|
||||
+ '#6: Delete tag (external_id=old_tag_2)\n';
|
||||
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: expectedPlan });
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example3.json')], types: ['Files'] } });
|
||||
|
||||
expect(await findByTestId('file-info')).toBeInTheDocument();
|
||||
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const expectedPlan = 'Import plan for Test import taxonomy\n'
|
||||
+ '--------------------------------\n'
|
||||
+ '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n'
|
||||
+ '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n'
|
||||
+ '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n'
|
||||
+ '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n'
|
||||
+ '#5: Delete tag (external_id=old_tag_1)\n'
|
||||
+ '#6: Delete tag (external_id=old_tag_2)\n';
|
||||
planImportTags.mockResolvedValueOnce(expectedPlan);
|
||||
await waitFor(() => {
|
||||
// Note: import button gets re-created after showing a spinner while the import plan is loaded.
|
||||
importButton = getByRole('button', { name: 'Import' });
|
||||
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
fireEvent.click(importButton);
|
||||
|
||||
@@ -188,7 +192,6 @@ describe('<ImportTagsWizard />', () => {
|
||||
// Test back button
|
||||
fireEvent.click(getByTestId('back-button'));
|
||||
expect(getByTestId('upload-step')).toBeInTheDocument();
|
||||
planImportTags.mockResolvedValueOnce(expectedPlan);
|
||||
fireEvent.click(getByRole('button', { name: 'Import' }));
|
||||
expect(await findByTestId('plan-step')).toBeInTheDocument();
|
||||
|
||||
@@ -205,9 +208,9 @@ describe('<ImportTagsWizard />', () => {
|
||||
expect(getByTestId('confirm-step')).toBeInTheDocument();
|
||||
|
||||
if (expectedResult === 'success') {
|
||||
mockUseImportTagsMutate.mockResolvedValueOnce({});
|
||||
axiosMock.onPut(doImportUrl).replyOnce(200, {});
|
||||
} else {
|
||||
mockUseImportTagsMutate.mockRejectedValueOnce(new Error('Test error'));
|
||||
axiosMock.onPut(doImportUrl).replyOnce(400, { error: 'Test error' });
|
||||
}
|
||||
|
||||
const confirmButton = getByRole('button', { name: 'Yes, import file' });
|
||||
@@ -215,24 +218,24 @@ describe('<ImportTagsWizard />', () => {
|
||||
expect(confirmButton).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseImportTagsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, file: fileJson });
|
||||
});
|
||||
act(() => { fireEvent.click(confirmButton); });
|
||||
|
||||
if (expectedResult === 'success') {
|
||||
// Toast message shown
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
|
||||
await waitFor(() => {
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
|
||||
});
|
||||
} else {
|
||||
// Alert message shown
|
||||
expect(mockSetAlertProps).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: 'danger',
|
||||
title: 'Import error',
|
||||
description: 'Test error',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockSetAlertProps).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: 'danger',
|
||||
title: 'Import error',
|
||||
description: 'Test error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as taxonomyImportMock } from './taxonomyImportMock'; // eslint-disable-line import/prefer-default-export
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
name: 'Taxonomy name',
|
||||
exportId: 'taxonomy_export_id',
|
||||
description: 'Taxonomy description',
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getTaxonomyImportNewApiUrl = () => new URL(
|
||||
'api/content_tagging/v1/taxonomies/import/',
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTagsImportApiUrl = (taxonomyId) => new URL(
|
||||
`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/`,
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTagsPlanImportApiUrl = (taxonomyId) => new URL(
|
||||
`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/plan/`,
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
|
||||
/**
|
||||
* Import a new taxonomy
|
||||
* @param {string} taxonomyName
|
||||
* @param {string} taxonomyDescription
|
||||
* @param {File} file
|
||||
* @returns {Promise<import('../../data/types.mjs').TaxonomyData>}
|
||||
*/
|
||||
export async function importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file) {
|
||||
// ToDo: transform this to use react-query like useImportTags
|
||||
const formData = new FormData();
|
||||
formData.append('taxonomy_name', taxonomyName);
|
||||
formData.append('taxonomy_export_id', taxonomyExportId);
|
||||
formData.append('taxonomy_description', taxonomyDescription);
|
||||
formData.append('file', file);
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
getTaxonomyImportNewApiUrl(),
|
||||
formData,
|
||||
);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mutation to import tags to an existing taxonomy
|
||||
*/
|
||||
export const useImportTags = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* any,
|
||||
* any,
|
||||
* {
|
||||
* taxonomyId: number
|
||||
* file: File
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: async ({ taxonomyId, file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().put(
|
||||
getTagsImportApiUrl(taxonomyId),
|
||||
formData,
|
||||
);
|
||||
|
||||
return camelCaseObject(data);
|
||||
} catch (/** @type {any} */ err) {
|
||||
throw new Error(err.response?.data || err.message);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['tagList', variables.taxonomyId],
|
||||
});
|
||||
queryClient.setQueryData(['taxonomyDetail', variables.taxonomyId], data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan import tags to an existing taxonomy, overwriting existing tags
|
||||
* @param {number} taxonomyId
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function planImportTags(taxonomyId, file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().put(
|
||||
getTagsPlanImportApiUrl(taxonomyId),
|
||||
formData,
|
||||
);
|
||||
|
||||
return data.plan;
|
||||
} catch (/** @type {any} */ err) {
|
||||
throw new Error(err.response?.data?.error || err.message);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { taxonomyImportMock } from '../__mocks__';
|
||||
|
||||
import {
|
||||
getTaxonomyImportNewApiUrl,
|
||||
getTagsImportApiUrl,
|
||||
getTagsPlanImportApiUrl,
|
||||
importNewTaxonomy,
|
||||
planImportTags,
|
||||
useImportTags,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('import taxonomy api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call import new taxonomy', async () => {
|
||||
axiosMock.onPost(getTaxonomyImportNewApiUrl()).reply(201, taxonomyImportMock);
|
||||
const result = await importNewTaxonomy('Taxonomy name', 'taxonomy_export_id', 'Taxonomy description');
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(getTaxonomyImportNewApiUrl());
|
||||
expect(result).toEqual(taxonomyImportMock);
|
||||
});
|
||||
|
||||
it('should call import tags', async () => {
|
||||
const taxonomy = { id: 1, name: 'taxonomy name' };
|
||||
axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, taxonomy);
|
||||
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData');
|
||||
|
||||
const { result } = renderHook(() => useImportTags(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({ taxonomyId: 1 });
|
||||
expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1));
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['tagList', 1],
|
||||
});
|
||||
expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomyDetail', 1], taxonomy);
|
||||
});
|
||||
|
||||
it('should call plan import tags', async () => {
|
||||
axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(200, { plan: 'plan' });
|
||||
await planImportTags(1);
|
||||
expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1));
|
||||
});
|
||||
|
||||
it('should handle errors in plan import tags', async () => {
|
||||
axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(400, { error: 'test error' });
|
||||
|
||||
expect(planImportTags(1)).rejects.toEqual(Error('test error'));
|
||||
expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1));
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
// @ts-check
|
||||
export { importTaxonomy } from './data/utils';
|
||||
export { importTaxonomy } from './utils';
|
||||
export { default as ImportTagsWizard } from './ImportTagsWizard';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
import messages from '../messages';
|
||||
import { importNewTaxonomy } from './api';
|
||||
import messages from './messages';
|
||||
|
||||
/*
|
||||
* This function get a file from the user. It does this by creating a
|
||||
@@ -38,7 +37,12 @@ const selectFile = async () => new Promise((resolve) => {
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
export const importTaxonomy = async (intl) => { // eslint-disable-line import/prefer-default-export
|
||||
/**
|
||||
* @param {*} intl The react-intl object returned by the useIntl() hook
|
||||
* @param {ReturnType<typeof import('../data/apiHooks').useImportNewTaxonomy>} importMutation The import mutation
|
||||
* returned by the useImportNewTaxonomy() hook.
|
||||
*/
|
||||
export const importTaxonomy = async (intl, importMutation) => { // eslint-disable-line import/prefer-default-export
|
||||
/*
|
||||
* This function is a temporary "Barebones" implementation of the import
|
||||
* functionality with `prompt` and `alert`. It is intended to be replaced
|
||||
@@ -92,27 +96,30 @@ export const importTaxonomy = async (intl) => { // eslint-disable-line import/pr
|
||||
return;
|
||||
}
|
||||
|
||||
const taxonomyName = getTaxonomyName();
|
||||
if (taxonomyName == null) {
|
||||
const name = getTaxonomyName();
|
||||
if (name == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taxonomyExportId = getTaxonomyExportId();
|
||||
if (taxonomyExportId == null) {
|
||||
const exportId = getTaxonomyExportId();
|
||||
if (exportId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taxonomyDescription = getTaxonomyDescription();
|
||||
if (taxonomyDescription == null) {
|
||||
const description = getTaxonomyDescription();
|
||||
if (description == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file)
|
||||
.then(() => {
|
||||
alert(intl.formatMessage(messages.importTaxonomySuccess));
|
||||
})
|
||||
.catch((error) => {
|
||||
alert(intl.formatMessage(messages.importTaxonomyError));
|
||||
console.error(error.response);
|
||||
});
|
||||
importMutation.mutateAsync({
|
||||
name,
|
||||
exportId,
|
||||
description,
|
||||
file,
|
||||
}).then(() => {
|
||||
alert(intl.formatMessage(messages.importTaxonomySuccess));
|
||||
}).catch((error) => {
|
||||
alert(intl.formatMessage(messages.importTaxonomyError));
|
||||
console.error(error.response);
|
||||
});
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { useOrganizationListData } from '../../generic/data/apiHooks';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
import { useTaxonomyDetailDataResponse } from '../data/apiHooks';
|
||||
import { useTaxonomyDetails } from '../data/apiHooks';
|
||||
import { useManageOrgs } from './data/api';
|
||||
import messages from './messages';
|
||||
import './ManageOrgsModal.scss';
|
||||
@@ -83,7 +83,7 @@ const ManageOrgsModal = ({
|
||||
data: organizationListData,
|
||||
} = useOrganizationListData();
|
||||
|
||||
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
|
||||
const { data: taxonomy } = useTaxonomyDetails(taxonomyId);
|
||||
|
||||
const manageOrgMutation = useManageOrgs();
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import Proptypes from 'prop-types';
|
||||
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import messages from './messages';
|
||||
import { useTagListDataResponse, useTagListDataStatus } from './data/apiHooks';
|
||||
import { useSubTags } from './data/api';
|
||||
import { useTagListData, useSubTags } from './data/apiHooks';
|
||||
|
||||
const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => {
|
||||
const subTagsData = useSubTags(taxonomyId, parentTagValue);
|
||||
@@ -69,8 +68,7 @@ const TagListTable = ({ taxonomyId }) => {
|
||||
pageIndex: 0,
|
||||
pageSize: 100,
|
||||
});
|
||||
const { isLoading } = useTagListDataStatus(taxonomyId, options);
|
||||
const tagList = useTagListDataResponse(taxonomyId, options);
|
||||
const { isLoading, data: tagList } = useTagListData(taxonomyId, options);
|
||||
|
||||
const fetchData = (args) => {
|
||||
if (!isEqual(args, options)) {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
// TODO: this file needs to be merged into src/taxonomy/data/api.js
|
||||
// We are creating a mess with so many different /data/[api|types].js files in subfolders.
|
||||
// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
const getTagListApiUrl = (taxonomyId, page, pageSize) => {
|
||||
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
|
||||
url.searchParams.append('page', page + 1);
|
||||
url.searchParams.append('page_size', pageSize);
|
||||
return url.href;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @param {import('./types.mjs').QueryOptions} options
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
|
||||
*/
|
||||
export const useTagListData = (taxonomyId, options) => {
|
||||
const { pageIndex, pageSize } = options;
|
||||
return useQuery({
|
||||
queryKey: ['tagList', taxonomyId, pageIndex],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId, pageIndex, pageSize));
|
||||
return camelCaseObject(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Temporary hook to load *all* the subtags of a given tag in a taxonomy.
|
||||
* Doesn't handle pagination or anything. This is meant to be replaced by
|
||||
* something more sophisticated later, as we improve the "taxonomy details" page.
|
||||
* @param {number} taxonomyId
|
||||
* @param {string} parentTagValue
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
|
||||
*/
|
||||
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
|
||||
queryKey: ['subtagsList', taxonomyId, parentTagValue],
|
||||
queryFn: async () => {
|
||||
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
|
||||
url.searchParams.set('full_depth_threshold', '10000'); // Load as deeply as we can
|
||||
url.searchParams.set('parent_tag', parentTagValue);
|
||||
const response = await getAuthenticatedHttpClient().get(url.href);
|
||||
return camelCaseObject(response.data);
|
||||
},
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
useTagListData,
|
||||
} from './api';
|
||||
|
||||
const mockHttpClient = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(() => mockHttpClient),
|
||||
}));
|
||||
|
||||
describe('useTagListData', () => {
|
||||
it('should call useQuery with the correct parameters', () => {
|
||||
useTagListData('1', { pageIndex: 3 });
|
||||
|
||||
expect(useQuery).toHaveBeenCalledWith({
|
||||
queryKey: ['tagList', '1', 3],
|
||||
queryFn: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/taxonomy/tag-list/data/apiHooks.js
Normal file
41
src/taxonomy/tag-list/data/apiHooks.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
// TODO: this file needs to be merged into src/taxonomy/data/apiHooks.js
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { apiUrls } from '../../data/api';
|
||||
import { taxonomyQueryKeys } from '../../data/apiHooks';
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @param {import('./types.mjs').QueryOptions} options
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
|
||||
*/
|
||||
export const useTagListData = (taxonomyId, options) => {
|
||||
const { pageIndex, pageSize } = options;
|
||||
return useQuery({
|
||||
queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
|
||||
queryFn: async () => {
|
||||
const { data } = await getAuthenticatedHttpClient().get(apiUrls.tagList(taxonomyId, pageIndex, pageSize));
|
||||
return camelCaseObject(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Temporary hook to load *all* the subtags of a given tag in a taxonomy.
|
||||
* Doesn't handle pagination or anything. This is meant to be replaced by
|
||||
* something more sophisticated later, as we improve the "taxonomy details" page.
|
||||
* @param {number} taxonomyId
|
||||
* @param {string} parentTagValue
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
|
||||
*/
|
||||
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
|
||||
queryKey: taxonomyQueryKeys.taxonomyTagSubtagsList(taxonomyId, parentTagValue),
|
||||
queryFn: async () => {
|
||||
const response = await getAuthenticatedHttpClient().get(apiUrls.allSubtagsOf(taxonomyId, parentTagValue));
|
||||
return camelCaseObject(response.data);
|
||||
},
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// @ts-check
|
||||
import {
|
||||
useTagListData,
|
||||
} from './api';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @param {import("./types.mjs").QueryOptions} options
|
||||
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isLoading" | "isSuccess" >}
|
||||
*/ /* eslint-enable max-len */
|
||||
export const useTagListDataStatus = (taxonomyId, options) => {
|
||||
const {
|
||||
error,
|
||||
isError,
|
||||
isFetched,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useTagListData(taxonomyId, options);
|
||||
return {
|
||||
error,
|
||||
isError,
|
||||
isFetched,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @param {import("./types.mjs").QueryOptions} options
|
||||
* @returns {import("./types.mjs").TagListData | undefined}
|
||||
*/
|
||||
export const useTagListDataResponse = (taxonomyId, options) => {
|
||||
const { isSuccess, data } = useTagListData(taxonomyId, options);
|
||||
if (isSuccess) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
useTagListDataStatus,
|
||||
useTagListDataResponse,
|
||||
} from './apiHooks';
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useTagListDataStatus', () => {
|
||||
it('should return status values', () => {
|
||||
const status = {
|
||||
error: undefined,
|
||||
isError: false,
|
||||
isFetched: true,
|
||||
isLoading: true,
|
||||
isSuccess: true,
|
||||
};
|
||||
|
||||
useQuery.mockReturnValueOnce(status);
|
||||
|
||||
const result = useTagListDataStatus(0, {});
|
||||
|
||||
expect(result).toEqual(status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTagListDataResponse', () => {
|
||||
it('should return data when status is success', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
|
||||
const result = useTagListDataResponse(0, {});
|
||||
|
||||
expect(result).toEqual('data');
|
||||
});
|
||||
|
||||
it('should return undefined when status is not success', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
|
||||
const result = useTagListDataResponse(0, {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import taxonomyMessages from '../messages';
|
||||
import { TagListTable } from '../tag-list';
|
||||
import { TaxonomyMenu } from '../taxonomy-menu';
|
||||
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
|
||||
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks';
|
||||
import { useTaxonomyDetails } from '../data/apiHooks';
|
||||
import SystemDefinedBadge from '../system-defined-badge';
|
||||
|
||||
const TaxonomyDetailPage = () => {
|
||||
@@ -25,8 +25,11 @@ const TaxonomyDetailPage = () => {
|
||||
const { taxonomyId: taxonomyIdString } = useParams();
|
||||
const taxonomyId = Number(taxonomyIdString);
|
||||
|
||||
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
|
||||
const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
|
||||
const {
|
||||
data: taxonomy,
|
||||
isError,
|
||||
isFetched,
|
||||
} = useTaxonomyDetails(taxonomyId);
|
||||
|
||||
if (!isFetched) {
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import { getTaxonomyApiUrl } from '../data/api';
|
||||
import { apiUrls } from '../data/api';
|
||||
import initializeStore from '../../store';
|
||||
import TaxonomyDetailPage from './TaxonomyDetailPage';
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('<TaxonomyDetailPage />', () => {
|
||||
|
||||
it('shows the spinner before the query is complete', () => {
|
||||
// Use unresolved promise to keep the Loading visible
|
||||
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(() => new Promise());
|
||||
axiosMock.onGet(apiUrls.taxonomy(1)).reply(() => new Promise());
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
@@ -73,7 +73,7 @@ describe('<TaxonomyDetailPage />', () => {
|
||||
it('shows the connector error component if not taxonomy returned', async () => {
|
||||
// Use empty response to trigger the error. Returning an error do not
|
||||
// work because the query will retry.
|
||||
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
|
||||
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
|
||||
|
||||
const { findByTestId } = render(<RootWrapper />);
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('<TaxonomyDetailPage />', () => {
|
||||
});
|
||||
|
||||
it('should render page and page title correctly', async () => {
|
||||
await axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
|
||||
await axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
|
||||
id: 1,
|
||||
name: 'Test taxonomy',
|
||||
description: 'This is a description',
|
||||
@@ -109,7 +109,7 @@ describe('<TaxonomyDetailPage />', () => {
|
||||
});
|
||||
|
||||
it('should show system defined badge', async () => {
|
||||
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
|
||||
axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
|
||||
id: 1,
|
||||
name: 'Test taxonomy',
|
||||
description: 'This is a description',
|
||||
@@ -125,7 +125,7 @@ describe('<TaxonomyDetailPage />', () => {
|
||||
});
|
||||
|
||||
it('should not show system defined badge', async () => {
|
||||
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
|
||||
axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
|
||||
id: 1,
|
||||
name: 'Test taxonomy',
|
||||
description: 'This is a description',
|
||||
|
||||
Reference in New Issue
Block a user