diff --git a/package-lock.json b/package-lock.json index fa514c9f7a..a4d615c222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "react-responsive": "9.0.2", "react-router": "6.16.0", "react-router-dom": "6.16.0", + "react-select": "^5.8.0", "react-textarea-autosize": "^8.4.1", "react-transition-group": "4.4.5", "redux": "4.0.5", @@ -3012,6 +3013,117 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/react": { + "version": "11.11.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", + "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", + "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "dependencies": { + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3116,6 +3228,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, "node_modules/@formatjs/cli": { "version": "6.2.7", "resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-6.2.7.tgz", @@ -7106,6 +7240,35 @@ "node": ">= 10.14.2" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", @@ -10528,8 +10691,7 @@ "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "node_modules/find-up": { "version": "5.0.0", @@ -14686,6 +14848,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/memory-fs": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", @@ -17518,6 +17685,26 @@ "react-dom": ">=16.8" } }, + "node_modules/react-select": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz", + "integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -19907,6 +20094,11 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/superagent": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", @@ -21914,6 +22106,7 @@ "version": "0.1.0", "peerDependencies": { "@edx/frontend-app-course-authoring": "*", + "@edx/frontend-lib-content-components": "*", "@edx/frontend-platform": "*", "@openedx/paragon": "*", "@reduxjs/toolkit": "*", diff --git a/package.json b/package.json index 2c91d5ae82..c740331c61 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-responsive": "9.0.2", "react-router": "6.16.0", "react-router-dom": "6.16.0", + "react-select": "5.8.0", "react-textarea-autosize": "^8.4.1", "react-transition-group": "4.4.5", "redux": "4.0.5", diff --git a/src/content-tags-drawer/ContentTagsCollapsible.d.ts b/src/content-tags-drawer/ContentTagsCollapsible.d.ts new file mode 100644 index 0000000000..55759439e2 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsible.d.ts @@ -0,0 +1,39 @@ +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 +// and add our custom property 'myCustomProp' to it. + +export interface TagTreeEntry { + explicit: boolean; + children: Record; + canChangeObjecttag: boolean; + canDeleteObjecttag: boolean; +} + +export interface TaxonomySelectProps { + taxonomyId: number; + searchTerm: string; + appliedContentTagsTree: Record; + stagedContentTagsTree: Record; + checkedTags: string[]; + handleCommitStagedTags: () => void; + handleCancelStagedTags: () => void; + handleSelectableBoxChange: React.ChangeEventHandler; +} + +// Unfortunately the only way to specify the custom props we pass into React Select +// is with this global type augmentation. +// https://react-select.com/typescript#custom-select-props +// If in the future other parts of this MFE need to use React Select for different things, +// we should change to using a 'react context' to share this data within , +// rather than using the custom )} - -
- - - {}} - onChange={handleSearchChange} - className="mb-2" - /> - - - -
-
-
{ ); }; -ContentTagsCollapsible.propTypes = { - contentId: PropTypes.string.isRequired, - taxonomyAndTagsData: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - contentTags: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string, - lineage: PropTypes.arrayOf(PropTypes.string), - })), - canTagObject: PropTypes.bool.isRequired, - }).isRequired, -}; - export default ContentTagsCollapsible; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.scss b/src/content-tags-drawer/ContentTagsCollapsible.scss index 3123eebbf4..67a51a77e3 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.scss +++ b/src/content-tags-drawer/ContentTagsCollapsible.scss @@ -27,3 +27,33 @@ .pgn__modal-popup__arrow { visibility: hidden; } + +.add-tags-button:not([disabled]):hover { + background-color: transparent; + color: $info-900 !important; +} + +.cancel-add-tags-button:hover { + background-color: transparent; + color: $gray-300 !important; +} + +.react-select-add-tags__control { + border-radius: 0 !important; +} + +.react-select-add-tags__control--is-focused { + border-color: black !important; + box-shadow: 0 0 0 1px black !important; +} + +.react-select-add-tags__multi-value__remove { + padding-right: 7px !important; + padding-left: 7px !important; + border-radius: 0 3px 3px 0; + + &:hover { + background-color: black !important; + color: white !important; + } +} diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx index 772087c181..1b0ed8602d 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx @@ -51,11 +51,29 @@ const data = { }, ], }, + stagedContentTags: [], + addStagedContentTag: jest.fn(), + removeStagedContentTag: jest.fn(), + setStagedTags: jest.fn(), }; -const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => ( +const ContentTagsCollapsibleComponent = ({ + contentId, + taxonomyAndTagsData, + stagedContentTags, + addStagedContentTag, + removeStagedContentTag, + setStagedTags, +}) => ( - + ); @@ -70,6 +88,10 @@ describe('', () => { jest.useRealTimers(); // Restore real timers after the tests }); + afterEach(() => { + jest.clearAllMocks(); // Reset all mock function call counts after each test case + }); + async function getComponent(updatedData) { const componentData = (!updatedData ? data : updatedData); @@ -77,6 +99,10 @@ describe('', () => { , ); } @@ -130,59 +156,157 @@ describe('', () => { expect(getByText('3')).toBeInTheDocument(); }); - it('should render new tags as they are checked in the dropdown', async () => { + 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 tags" button + // 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 tags" button to open dropdown to select new tags - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // Click on "Add a tag" button to open dropdown to select new tags + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown/mouseUp` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + fireEvent.mouseUp(addTagsButton); // Wait for the dropdown selector for tags to open, - // Tag 3 should only appear there - expect(getByText('Tag 3')).toBeInTheDocument(); - expect(getAllByText('Tag 3').length === 1); + // Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + // Click to check Tag 3 and check the `addStagedContentTag` was called with the correct params const tag3 = getByText('Tag 3'); - fireEvent.click(tag3); - // After clicking on Tag 3, it should also appear in amongst - // the tag bubbles in the tree - expect(getAllByText('Tag 3').length === 2); + const taxonomyId = 123; + const addedStagedTag = { + value: 'Tag%203', + label: 'Tag 3', + }; + expect(data.addStagedContentTag).toHaveBeenCalledTimes(1); + expect(data.addStagedContentTag).toHaveBeenCalledWith(taxonomyId, addedStagedTag); }); - it('should remove tag when they are unchecked in the dropdown', async () => { + 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 tags" button + // Expand the Taxonomy to view applied tags and "Add a tag" button const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; fireEvent.click(expandToggle); - // Check that Tag 2 appears in tag bubbles - expect(getByText('Tag 2')).toBeInTheDocument(); - - // Click on "Add tags" button to open dropdown to select new tags - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // Click on "Add a tag" button to open dropdown to select new tags + const addTagsButton = getByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage); + // Use `mouseDown/mouseup` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(addTagsButton); + fireEvent.mouseUp(addTagsButton); // Wait for the dropdown selector for tags to open, // Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied) - expect(getByText('Tag 3')).toBeInTheDocument(); + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Click to uncheck Tag 3 and check the `removeStagedContentTag` was called with the correct params + fireEvent.click(tag3); + const taxonomyId = 123; + const tagValue = 'Tag%203'; + expect(data.removeStagedContentTag).toHaveBeenCalledTimes(1); + expect(data.removeStagedContentTag).toHaveBeenCalledWith(taxonomyId, tagValue); + }); + + 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, + stagedContentTags: [{ + value: 'Tag%203', + label: 'Tag 3', + }], + }); - // Get the Tag 2 checkbox and click on it - const tag2 = getAllByText('Tag 2')[1]; - fireEvent.click(tag2); + // Expand the Taxonomy to view applied tags and staged tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; - // After clicking on Tag 2, it should be removed from - // the tag bubbles in so only the one in the dropdown appears - expect(getAllByText('Tag 2').length === 1); + fireEvent.click(expandToggle); + + // Click on inline "Add" button and check that the appropriate methods are called + const inlineAdd = getByText(messages.collapsibleInlineAddStagedTagsButtonText.defaultMessage); + fireEvent.click(inlineAdd); + + // Check that `setStagedTags` called with empty tags list to clear staged tags + const taxonomyId = 123; + expect(data.setStagedTags).toHaveBeenCalledTimes(1); + expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []); + }); + + 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, + stagedContentTags: [{ + value: 'Tag%203', + label: 'Tag 3', + }], + }); + + // Expand the Taxonomy to view applied tags and staged tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on dropdown with staged tags to expand it + const selectTagsDropdown = container.getElementsByClassName('react-select-add-tags__control')[0]; + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(selectTagsDropdown); + + // Click on "Add tags" button and check that the appropriate methods are called + const dropdownAdd = getByText(messages.collapsibleAddStagedTagsButtonText.defaultMessage); + fireEvent.click(dropdownAdd); + + // Check that `setStagedTags` called with empty tags list to clear staged tags + const taxonomyId = 123; + expect(data.setStagedTags).toHaveBeenCalledTimes(1); + expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []); + }); + + it('should close dropdown and clear staged tags when clicking "Cancel" inside dropdown', async () => { + // Setup component to have staged tags + const { container, getByText } = await getComponent({ + ...data, + stagedContentTags: [{ + value: 'Tag%203', + label: 'Tag 3', + }], + }); + + // Expand the Taxonomy to view applied tags and staged tags + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on dropdown with staged tags to expand it + const selectTagsDropdown = container.getElementsByClassName('react-select-add-tags__control')[0]; + // Use `mouseDown` instead of `click` since the react-select didn't respond to `click` + fireEvent.mouseDown(selectTagsDropdown); + + // Click on inline "Add" button and check that the appropriate methods are called + const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage); + fireEvent.click(dropdownCancel); + + // Check that `setStagedTags` called with empty tags list to clear staged tags + const taxonomyId = 123; + expect(data.setStagedTags).toHaveBeenCalledTimes(1); + expect(data.setStagedTags).toHaveBeenCalledWith(taxonomyId, []); + + // Check that the dropdown is closed + expect(dropdownCancel).not.toBeInTheDocument(); }); it('should handle search term change', async () => { @@ -190,16 +314,17 @@ describe('', () => { container, getByText, getByRole, getByDisplayValue, } = await getComponent(); - // Expand the Taxonomy to view applied tags and "Add tags" button + // 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 tags" button to open dropdown - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // 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); // Get the search field - const searchField = getByRole('searchbox'); + const searchField = getByRole('combobox'); const searchTerm = 'memo'; @@ -226,14 +351,15 @@ describe('', () => { setupTaxonomyMock(); const { container, getByText, queryByText } = await getComponent(); - // Expand the Taxonomy to view applied tags and "Add tags" button + // 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 tags" button to open dropdown - const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); - fireEvent.click(addTagsButton); + // 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 @@ -250,6 +376,24 @@ describe('', () => { 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 + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + + fireEvent.click(expandToggle); + + // Click on 'x' of applied tag to remove it + const appliedTag = getByText('Tag 2'); + const xButtonAppliedTag = appliedTag.nextSibling; + xButtonAppliedTag.click(); + + // Check that the applied tag has been removed + expect(appliedTag).not.toBeInTheDocument(); + }); + it('should render taxonomy tags data without tags number badge', async () => { const updatedData = { ...data }; updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData }; diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx index aed895c454..6ded9481d3 100644 --- a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -5,80 +5,85 @@ import { cloneDeep } from 'lodash'; import { useContentTaxonomyTagsUpdater } from './data/apiHooks'; +/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */ +/** @typedef {import("./data/types.mjs").Tag} ContentTagData */ +/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */ + /** - * Util function that consolidates two tag trees into one, sorting the keys in - * alphabetical order. + * Util function that sorts the keys of a tree in alphabetical order. * - * @param {object} tree1 - first tag tree - * @param {object} tree2 - second tag tree - * @returns {object} merged tree containing both tree1 and tree2 + * @param {object} tree - tree that needs it's keys sorted + * @returns {object} sorted tree */ -const mergeTrees = (tree1, tree2) => { - const mergedTree = cloneDeep(tree1); - - const sortKeysAlphabetically = (obj) => { - const sortedObj = {}; - Object.keys(obj) - .sort() - .forEach((key) => { - sortedObj[key] = obj[key]; - if (obj[key] && typeof obj[key] === 'object') { - sortedObj[key].children = sortKeysAlphabetically(obj[key].children); - } - }); - return sortedObj; - }; - - const mergeRecursively = (destination, source) => { - Object.entries(source).forEach(([key, sourceValue]) => { - const destinationValue = destination[key]; - - if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') { - mergeRecursively(destinationValue, sourceValue); - } else { - // eslint-disable-next-line no-param-reassign - destination[key] = cloneDeep(sourceValue); +const sortKeysAlphabetically = (tree) => { + const sortedObj = {}; + Object.keys(tree) + .sort() + .forEach((key) => { + sortedObj[key] = tree[key]; + if (tree[key] && typeof tree[key] === 'object') { + sortedObj[key].children = sortKeysAlphabetically(tree[key].children); } }); - }; - - mergeRecursively(mergedTree, tree2); - return sortKeysAlphabetically(mergedTree); + return sortedObj; }; /** - * Util function that removes the tag along with its ancestors if it was - * the only explicit child tag. + * Util function that returns the leafs of a tree. Mainly used to extract the explicit + * tags selected in the staged tags tree * - * @param {object} tree - tag tree to remove the tag from - * @param {string[]} tagsToRemove - full lineage of tag to remove. - * eg: ['grand parent', 'parent', 'tag'] + * @param {object} tree - tree to extract the leaf tags from + * @returns {Array} array of leaf (explicit) tags of provided tree */ -const removeTags = (tree, tagsToRemove) => { - if (!tree || !tagsToRemove.length) { - return; +const getLeafTags = (tree) => { + const leafKeys = []; + + function traverse(node) { + Object.keys(node).forEach(key => { + const child = node[key]; + if (Object.keys(child.children).length === 0) { + leafKeys.push(key); + } else { + traverse(child.children); + } + }); } - const key = tagsToRemove[0]; - if (tree[key]) { - removeTags(tree[key].children, tagsToRemove.slice(1)); - if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) { - // eslint-disable-next-line no-param-reassign - delete tree[key]; - } - } + traverse(tree); + return leafKeys; }; -/* +/** * Handles all the underlying logic for the ContentTagsCollapsible component + * @param {string} contentId The ID of the content we're tagging (e.g. usage key) + * @param {TaxonomyData & {contentTags: ContentTagData[]}} taxonomyAndTagsData + * @param {(taxonomyId: number, tag: {value: string, label: string}) => void} addStagedContentTag + * @param {(taxonomyId: number, tagValue: string) => void} removeStagedContentTag + * @param {{value: string, label: string}[]} stagedContentTags + * @returns {{ + * tagChangeHandler: (tagSelectableBoxValue: string, checked: boolean) => void, + * removeAppliedTagHandler: (tagSelectableBoxValue: string) => void, + * appliedContentTagsTree: Record, + * stagedContentTagsTree: Record, + * contentTagsCount: number, + * checkedTags: any, + * commitStagedTags: () => void, + * updateTags: import('@tanstack/react-query').UseMutationResult + * }} */ -const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { +const useContentTagsCollapsibleHelper = ( + contentId, + taxonomyAndTagsData, + addStagedContentTag, + removeStagedContentTag, + stagedContentTags, +) => { const { id, contentTags, canTagObject, } = taxonomyAndTagsData; - // State to determine whether the tags are being updating so we can make a call + // State to determine whether an applied tag was removed so we make a call // to the update endpoint to the reflect those changes - const [updatingTags, setUpdatingTags] = React.useState(false); + const [removingAppliedTag, setRemoveAppliedTag] = React.useState(false); const updateTags = useContentTaxonomyTagsUpdater(contentId, id); // Keeps track of the content objects tags count (both implicit and explicit) @@ -86,32 +91,55 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { // Keeps track of the tree structure for tags that are add by selecting/unselecting // tags in the dropdowns. - const [addedContentTags, setAddedContentTags] = React.useState({}); + const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({}); // To handle checking/unchecking tags in the SelectableBox - const [checkedTags, { add, remove, clear }] = useCheckboxSetValues(); + const [checkedTags, { add, remove }] = useCheckboxSetValues(); + + // State to keep track of the staged tags (and along with ancestors) that should be removed + const [stagedTagsToRemove, setStagedTagsToRemove] = React.useState(/** @type string[] */([])); - // Handles making requests to the update endpoint whenever the checked tags change + // Handles making requests to the backend when applied tags are removed React.useEffect(() => { // We have this check because this hook is fired when the component first loads // and reloads (on refocus). We only want to make a request to the update endpoint when - // the user is updating the tags. - if (updatingTags) { - setUpdatingTags(false); + // the user removes an applied tag + if (removingAppliedTag) { + setRemoveAppliedTag(false); + + // Filter out staged tags from the checktags so they do not get committed const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1))); - updateTags.mutate({ tags }); + const staged = stagedContentTags.map(t => t.label); + const remainingAppliedTags = tags.filter(t => !staged.includes(t)); + + updateTags.mutate({ tags: remainingAppliedTags }); } - }, [contentId, id, canTagObject, checkedTags]); + }, [contentId, id, canTagObject, checkedTags, stagedContentTags]); + + // Handles the removal of staged content tags based on what was removed + // from the staged tags tree. We are doing it in a useEffect since the removeTag + // method is being called inside a setState of the parent component, which + // was causing warnings + React.useEffect(() => { + stagedTagsToRemove.forEach(tag => removeStagedContentTag(id, tag)); + }, [stagedTagsToRemove, removeStagedContentTag, id]); + + // Handles making requests to the update endpoint when the staged tags need to be committed + const commitStagedTags = React.useCallback(() => { + // Filter out only leaf nodes of staging tree to commit + const explicitStaged = getLeafTags(stagedContentTagsTree); + + // Filter out applied tags that should become implicit because a child tag was committed + const stagedLineages = stagedContentTags.map(st => decodeURIComponent(st.value).split(',').slice(0, -1)).flat(); + const applied = contentTags.map((t) => t.value).filter(t => !stagedLineages.includes(t)); + + updateTags.mutate({ tags: [...applied, ...explicitStaged] }); + }, [contentTags, stagedContentTags, stagedContentTagsTree, updateTags]); // This converts the contentTags prop to the tree structure mentioned above - const appliedContentTags = React.useMemo(() => { + const appliedContentTagsTree = React.useMemo(() => { let contentTagsCounter = 0; - // Clear all the tags that have not been commited and the checked boxes when - // fresh contentTags passed in so the latest state from the backend is rendered - setAddedContentTags({}); - clear(); - // When an error occurs while updating, the contentTags query is invalidated, // hence they will be recalculated, and the updateTags mutation should be reset. if (updateTags.isError) { @@ -134,8 +162,12 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { // Populating the SelectableBox with "selected" (explicit) tags const value = item.lineage.map(l => encodeURIComponent(l)).join(','); - // eslint-disable-next-line no-unused-expressions - isExplicit ? add(value) : remove(value); + // Clear all the existing applied tags + remove(value); + // Add only the explicitly applied tags + if (isExplicit) { + add(value); + } contentTagsCounter += 1; } @@ -147,13 +179,53 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { return resultTree; }, [contentTags, updateTags.isError]); - // This is the source of truth that represents the current state of tags in - // this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in - // the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by - // selecting/unselecting them in the dropdown) change, the tree is recomputed. - const tagsTree = React.useMemo(() => ( - mergeTrees(appliedContentTags, addedContentTags) - ), [appliedContentTags, addedContentTags]); + /** + * Util function that removes the tag along with its ancestors if it was + * the only explicit child tag. It returns a list of staged tags (and ancestors) that + * were unstaged and should be removed + * + * @param {object} tree - tag tree to remove the tag from + * @param {string[]} tagsToRemove - remaining lineage of tag to remove at each recursive level. + * eg: ['grand parent', 'parent', 'tag'] + * @param {boolean} staged - whether we are removing staged tags or not + * @param {string[]} fullLineage - Full lineage of tag being removed + * @returns {string[]} array of staged tag values (with ancestors) that should be removed from staged tree + * + */ + const removeTags = React.useCallback((tree, tagsToRemove, staged, fullLineage) => { + const removedTags = []; + + const traverseAndRemoveTags = (subTree, innerTagsToRemove) => { + if (!subTree || !innerTagsToRemove.length) { + return; + } + const key = innerTagsToRemove[0]; + if (subTree[key]) { + traverseAndRemoveTags(subTree[key].children, innerTagsToRemove.slice(1)); + + if ( + Object.keys(subTree[key].children).length === 0 + && (subTree[key].explicit === false || innerTagsToRemove.length === 1) + ) { + // eslint-disable-next-line no-param-reassign + delete subTree[key]; + + // Remove tags (including ancestors) from staged tags select menu + if (staged) { + // Build value from lineage by traversing beginning till key, then encoding them + const toRemove = fullLineage.slice(0, fullLineage.indexOf(key) + 1).map(item => encodeURIComponent(item)); + if (toRemove.length > 0) { + removedTags.push(toRemove.join(',')); + } + } + } + } + }; + + traverseAndRemoveTags(tree, tagsToRemove); + + return removedTags; + }, []); // Add tag to the tree, and while traversing remove any selected ancestor tags // as they should become implicit @@ -163,6 +235,10 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { tagLineage.forEach(tag => { const isExplicit = selectedTag === tag; + // Clear out the ancestor tags leading to newly selected tag + // as they automatically become implicit + value.push(encodeURIComponent(tag)); + if (!traversal[tag]) { traversal[tag] = { explicit: isExplicit, @@ -174,12 +250,8 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { traversal[tag].explicit = isExplicit; } - // Clear out the ancestor tags leading to newly selected tag - // as they automatically become implicit - value.push(encodeURIComponent(tag)); // eslint-disable-next-line no-unused-expressions isExplicit ? add(value.join(',')) : remove(value.join(',')); - traversal = traversal[tag].children; }); }; @@ -188,26 +260,62 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); const selectedTag = tagLineage.slice(-1)[0]; - const addedTree = { ...addedContentTags }; if (checked) { + const stagedTree = cloneDeep(stagedContentTagsTree); // We "add" the tag to the SelectableBox.Set inside the addTags method - addTags(addedTree, tagLineage, selectedTag); + addTags(stagedTree, tagLineage, selectedTag); + + // Update the staged content tags tree + setStagedContentTagsTree(stagedTree); + + // Add content tag to taxonomy's staged tags select menu + addStagedContentTag( + id, + { + value: tagSelectableBoxValue, + label: selectedTag, + }, + ); } else { // Remove tag from the SelectableBox.Set remove(tagSelectableBoxValue); - // We remove them from both incase we are unselecting from an - // existing applied Tag or a newly added one - removeTags(addedTree, tagLineage); - removeTags(appliedContentTags, tagLineage); + // Remove tag along with it's from ancestors if it's the only child tag + // from the staged tags tree and update the staged content tags tree + setStagedContentTagsTree(prevStagedContentTagsTree => { + const updatedStagedContentTagsTree = cloneDeep(prevStagedContentTagsTree); + const tagsToRemove = removeTags(updatedStagedContentTagsTree, tagLineage, true, tagLineage); + setStagedTagsToRemove(tagsToRemove); + return updatedStagedContentTagsTree; + }); } + }, [ + stagedContentTagsTree, setStagedContentTagsTree, addTags, removeTags, + id, addStagedContentTag, removeStagedContentTag, + ]); - setAddedContentTags(addedTree); - setUpdatingTags(true); - }, []); + const removeAppliedTagHandler = React.useCallback((tagSelectableBoxValue) => { + const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); + + // Remove tag from the SelectableBox.Set + remove(tagSelectableBoxValue); + + // Remove tags from applied tags + const tagsToRemove = removeTags(appliedContentTagsTree, tagLineage, false, tagLineage); + setStagedTagsToRemove(tagsToRemove); + + setRemoveAppliedTag(true); + }, [appliedContentTagsTree, id, removeStagedContentTag]); return { - tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + tagChangeHandler, + removeAppliedTagHandler, + appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree), + stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree), + contentTagsCount, + checkedTags, + commitStagedTags, + updateTags, }; }; diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index c117f6fd29..853930b686 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -1,5 +1,10 @@ // @ts-check -import React, { useMemo, useEffect } from 'react'; +import React, { + useMemo, + useEffect, + useState, + useCallback, +} from 'react'; import PropTypes from 'prop-types'; import { Container, @@ -40,6 +45,32 @@ const ContentTagsDrawer = ({ id, onClose }) => { const org = extractOrgFromContentId(contentId); + const [stagedContentTags, setStagedContentTags] = useState({}); + + // Add a content tags to the staged tags for a taxonomy + const addStagedContentTag = useCallback((taxonomyId, addedTag) => { + setStagedContentTags(prevStagedContentTags => { + const updatedStagedContentTags = { + ...prevStagedContentTags, + [taxonomyId]: [...(prevStagedContentTags[taxonomyId] ?? []), addedTag], + }; + return updatedStagedContentTags; + }); + }, [setStagedContentTags]); + + // Remove a content tag from the staged tags for a taxonomy + const removeStagedContentTag = useCallback((taxonomyId, tagValue) => { + setStagedContentTags(prevStagedContentTags => ({ + ...prevStagedContentTags, + [taxonomyId]: prevStagedContentTags[taxonomyId].filter((t) => t.value !== tagValue), + })); + }, [setStagedContentTags]); + + // Sets the staged content tags for taxonomy to the provided list of tags + const setStagedTags = useCallback((taxonomyId, tagsList) => { + setStagedContentTags(prevStagedContentTags => ({ ...prevStagedContentTags, [taxonomyId]: tagsList })); + }, [setStagedContentTags]); + const useTaxonomyListData = () => { const taxonomyListData = useTaxonomyListDataResponse(org); const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org); @@ -122,7 +153,14 @@ const ContentTagsDrawer = ({ id, onClose }) => { { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded ? taxonomies.map((data) => (
- +
)) diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 0f7f1815af..37fd2343f2 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -8,8 +8,10 @@ import ContentTagsDrawer from './ContentTagsDrawer'; import { useContentTaxonomyTagsData, useContentData, + useTaxonomyTagsData, } from './data/apiHooks'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks'; +import messages from './messages'; const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab'; const mockOnClose = jest.fn(); @@ -33,6 +35,15 @@ jest.mock('./data/apiHooks', () => ({ useContentTaxonomyTagsUpdater: jest.fn(() => ({ isError: false, })), + useTaxonomyTagsData: jest.fn(() => ({ + hasMorePages: false, + tagPages: { + isLoading: true, + isError: false, + canAddTag: false, + data: [], + }, + })), })); jest.mock('../taxonomy/data/apiHooks', () => ({ @@ -47,6 +58,82 @@ const RootWrapper = (params) => ( ); describe('', () => { + const setupMockDataForStagedTagsTesting = () => { + useIsTaxonomyListDataLoaded.mockReturnValue(true); + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], + }, + }); + useTaxonomyListDataResponse.mockReturnValue({ + results: [{ + id: 123, + name: 'Taxonomy 1', + description: 'This is a description 1', + canTagObject: true, + }], + }); + + 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 page and page title correctly', () => { const { getByText } = render(); expect(getByText('Manage tags')).toBeInTheDocument(); @@ -154,6 +241,101 @@ describe('', () => { }); }); + it('should test adding a content tag to the staged tags for a taxonomy', () => { + setupMockDataForStagedTagsTesting(); + + const { container, getByText, getAllByText } = render(); + + // 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); + + // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Check that Tag 3 has been staged, i.e. there should be 2 of them on the page + expect(getAllByText('Tag 3').length).toBe(2); + }); + + it('should test removing a staged content from a taxonomy', () => { + setupMockDataForStagedTagsTesting(); + + const { container, getByText, getAllByText } = render(); + + // 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); + + // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Check that Tag 3 has been staged, i.e. there should be 2 of them on the page + expect(getAllByText('Tag 3').length).toBe(2); + + // Click it again to unstage it and confirm that there is only one on the page + fireEvent.click(tag3); + expect(getAllByText('Tag 3').length).toBe(1); + }); + + it('should test clearing staged tags for a taxonomy', () => { + setupMockDataForStagedTagsTesting(); + + const { + container, + getByText, + getAllByText, + queryByText, + } = render(); + + // 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); + + // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) + expect(getAllByText('Tag 3').length).toBe(1); + + // Click to check Tag 3 + const tag3 = getByText('Tag 3'); + fireEvent.click(tag3); + + // Check that Tag 3 has been staged, i.e. there should be 2 of them on the page + expect(getAllByText('Tag 3').length).toBe(2); + + // Click on the Cancel button in the dropdown to clear the staged tags + const dropdownCancel = getByText(messages.collapsibleCancelStagedTagsButtonText.defaultMessage); + fireEvent.click(dropdownCancel); + + // Check that there are no more Tag 3 on the page, since the staged one is cleared + // and the dropdown has been closed + expect(queryByText('Tag 3')).not.toBeInTheDocument(); + }); + it('should call closeManageTagsDrawer when CloseButton is clicked', async () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index 801597786d..1cb6229966 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -7,10 +7,9 @@ import { } from '@openedx/paragon'; import { SelectableBox } from '@edx/frontend-lib-content-components'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons'; +import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; -import './ContentTagsDropDownSelector.scss'; import { useTaxonomyTagsData } from './data/apiHooks'; @@ -42,7 +41,7 @@ HighlightedText.defaultProps = { }; const ContentTagsDropDownSelector = ({ - taxonomyId, level, lineage, tagsTree, searchTerm, + taxonomyId, level, lineage, appliedContentTagsTree, stagedContentTagsTree, searchTerm, }) => { const intl = useIntl(); @@ -89,13 +88,30 @@ const ContentTagsDropDownSelector = ({ }; const isImplicit = (tag) => { - // Traverse the tags tree using the lineage - let traversal = tagsTree; + // Traverse the applied tags tree using the lineage + let appliedTraversal = appliedContentTagsTree; lineage.forEach(t => { - traversal = traversal[t]?.children || {}; + appliedTraversal = appliedTraversal[t]?.children || {}; }); + const isAppliedImplicit = (appliedTraversal[tag.value] && !appliedTraversal[tag.value].explicit); - return (traversal[tag.value] && !traversal[tag.value].explicit) || false; + // Traverse the staged tags tree using the lineage + let stagedTraversal = stagedContentTagsTree; + lineage.forEach(t => { + stagedTraversal = stagedTraversal[t]?.children || {}; + }); + const isStagedImplicit = (stagedTraversal[tag.value] && !stagedTraversal[tag.value].explicit); + + return isAppliedImplicit || isStagedImplicit || false; + }; + + const isApplied = (tag) => { + // Traverse the applied tags tree using the lineage + let appliedTraversal = appliedContentTagsTree; + lineage.forEach(t => { + appliedTraversal = appliedTraversal[t]?.children || {}; + }); + return !!appliedTraversal[tag.value]; }; const loadMoreTags = useCallback(() => { @@ -131,8 +147,8 @@ const ContentTagsDropDownSelector = ({ aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })} data-selectable-box="taxonomy-tags" value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')} - isIndeterminate={isImplicit(tagData)} - disabled={isImplicit(tagData)} + isIndeterminate={isApplied(tagData) || isImplicit(tagData)} + disabled={isApplied(tagData) || isImplicit(tagData)} > @@ -156,7 +172,8 @@ const ContentTagsDropDownSelector = ({ taxonomyId={taxonomyId} level={level + 1} lineage={[...lineage, tagData.value]} - tagsTree={tagsTree} + appliedContentTagsTree={appliedContentTagsTree} + stagedContentTagsTree={stagedContentTagsTree} searchTerm={searchTerm} /> )} @@ -166,11 +183,12 @@ const ContentTagsDropDownSelector = ({ { hasMorePages ? ( -
+
@@ -197,7 +215,13 @@ ContentTagsDropDownSelector.propTypes = { taxonomyId: PropTypes.number.isRequired, level: PropTypes.number.isRequired, lineage: PropTypes.arrayOf(PropTypes.string), - tagsTree: PropTypes.objectOf( + appliedContentTagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, + stagedContentTagsTree: PropTypes.objectOf( PropTypes.shape({ explicit: PropTypes.bool.isRequired, children: PropTypes.shape({}).isRequired, diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss index 4a3541e10d..4c32ddb4dd 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.scss +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss @@ -4,11 +4,24 @@ .taxonomy-tags-load-more-button { flex: 1; + + &:hover { + background-color: transparent; + color: $info-900 !important; + } } .pgn__selectable_box.taxonomy-tags-selectable-box { box-shadow: none; padding: 0; + + // Override indeterminate [-] (implicit) checkbox styles to match checked checkbox styles + // In the future, this customizability should be implemented in paragon instead + input.pgn__form-checkbox-input { + &:indeterminate { + @extend :checked; /* stylelint-disable-line scss/at-extend-no-missing-placeholder */ + } + } } .pgn__selectable_box.taxonomy-tags-selectable-box:disabled, diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx index 7800e99e77..ee067aa69d 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx @@ -25,10 +25,12 @@ const data = { taxonomyId: 123, level: 0, tagsTree: {}, + appliedContentTagsTree: {}, + stagedContentTagsTree: {}, }; const ContentTagsDropDownSelectorComponent = ({ - taxonomyId, level, lineage, tagsTree, searchTerm, + taxonomyId, level, lineage, tagsTree, searchTerm, appliedContentTagsTree, stagedContentTagsTree, }) => ( ); @@ -53,15 +57,25 @@ describe('', () => { jest.clearAllMocks(); }); + async function getComponent(updatedData) { + const componentData = (!updatedData ? data : updatedData); + + return render( + , + ); + } + it('should render taxonomy tags drop down selector loading with spinner', async () => { await act(async () => { - const { getByRole } = render( - , - ); + const { getByRole } = await getComponent(); const spinner = getByRole('status'); expect(spinner.textContent).toEqual('Loading tags'); // Uses }); @@ -86,14 +100,8 @@ describe('', () => { }); await act(async () => { - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(); + await waitFor(() => { expect(getByText('Tag 1')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0); @@ -120,13 +128,8 @@ describe('', () => { }); await act(async () => { - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(); + await waitFor(() => { expect(getByText('Tag 2')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); @@ -162,13 +165,7 @@ describe('', () => { }, }, }; - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(dataWithTagsTree); await waitFor(() => { expect(getByText('Tag 2')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); @@ -230,13 +227,7 @@ describe('', () => { }, }, }; - const { container, getByText } = render( - , - ); + const { container, getByText } = await getComponent(dataWithTagsTree); await waitFor(() => { expect(getByText('Tag 2')).toBeInTheDocument(); expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); @@ -291,15 +282,7 @@ describe('', () => { const initalSearchTerm = 'test 1'; await act(async () => { - const { rerender } = render( - , - ); + const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm }); await waitFor(() => { expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm); @@ -312,6 +295,8 @@ describe('', () => { level={data.level} tagsTree={data.tagsTree} searchTerm={updatedSearchTerm} + appliedContentTagsTree={{}} + stagedContentTagsTree={{}} />); await waitFor(() => { @@ -326,6 +311,8 @@ describe('', () => { level={data.level} tagsTree={data.tagsTree} searchTerm={cleanSearchTerm} + appliedContentTagsTree={{}} + stagedContentTagsTree={{}} />); await waitFor(() => { @@ -347,15 +334,7 @@ describe('', () => { const searchTerm = 'uncommon search term'; await act(async () => { - const { getByText } = render( - , - ); + const { getByText } = await getComponent({ ...data, searchTerm }); await waitFor(() => { expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm); diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx index 50c2b3560e..b1b0c9b0ba 100644 --- a/src/content-tags-drawer/TagBubble.jsx +++ b/src/content-tags-drawer/TagBubble.jsx @@ -14,7 +14,7 @@ const TagBubble = ({ const handleClick = React.useCallback(() => { if (!implicit && canRemove) { - removeTagHandler(lineage.join(','), false); + removeTagHandler(lineage.join(',')); } }, [implicit, lineage, canRemove, removeTagHandler]); diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js index c54e6b7bcc..4d67ccc729 100644 --- a/src/content-tags-drawer/messages.js +++ b/src/content-tags-drawer/messages.js @@ -33,6 +33,22 @@ const messages = defineMessages({ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label', defaultMessage: 'taxonomy tags selection', }, + collapsibleAddTagsPlaceholderText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text', + defaultMessage: 'Add a tag', + }, + collapsibleAddStagedTagsButtonText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.save-staged-tags', + defaultMessage: 'Add tags', + }, + collapsibleCancelStagedTagsButtonText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.cancel-staged-tags', + defaultMessage: 'Cancel', + }, + collapsibleInlineAddStagedTagsButtonText: { + id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.inline-save-staged-tags', + defaultMessage: 'Add', + }, }); export default messages; diff --git a/src/index.scss b/src/index.scss index feedc94249..79f44950cc 100755 --- a/src/index.scss +++ b/src/index.scss @@ -23,3 +23,5 @@ @import "course-outline/CourseOutline"; @import "course-unit/CourseUnit"; @import "course-checklist/CourseChecklist"; +@import "content-tags-drawer/ContentTagsDropDownSelector"; +@import "content-tags-drawer/ContentTagsCollapsible";