diff --git a/CODEOWNERS b/CODEOWNERS index b7d40ee60f..9904a5264e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ # The following users are the maintainers of all frontend-app-course-authoring files -* @openedx/teaching-and-learning +* @openedx/2u-tnl diff --git a/package-lock.json b/package-lock.json index fa514c9f7a..5186ae7592 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/plugins/course-apps/teams/GroupEditor.jsx b/plugins/course-apps/teams/GroupEditor.jsx index 3eaedeb040..0e96d6ad65 100644 --- a/plugins/course-apps/teams/GroupEditor.jsx +++ b/plugins/course-apps/teams/GroupEditor.jsx @@ -7,6 +7,7 @@ import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants'; import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor'; import FormikControl from 'CourseAuthoring/generic/FormikControl'; import messages from './messages'; +import { isGroupTypeEnabled } from './utils'; // Maps a team type to its corresponding intl message const TeamTypeNameMessage = { @@ -14,6 +15,10 @@ const TeamTypeNameMessage = { label: messages.groupTypeOpen, description: messages.groupTypeOpenDescription, }, + [GroupTypes.OPEN_MANAGED]: { + label: messages.groupTypeOpenManaged, + description: messages.groupTypeOpenManagedDescription, + }, [GroupTypes.PUBLIC_MANAGED]: { label: messages.groupTypePublicManaged, description: messages.groupTypePublicManagedDescription, @@ -105,7 +110,7 @@ const GroupEditor = ({ onChange={onChange} onBlur={onBlur} > - {Object.values(GroupTypes).map(groupType => ( + {Object.values(GroupTypes).map(groupType => isGroupTypeEnabled(groupType) && ( ({ + ...jest.requireActual('formik'), + useFormikContext: jest.fn(), +})); + +describe('GroupEditor', () => { + const mockIntl = { formatMessage: jest.fn() }; + + const mockGroup = { + id: '1', + name: 'Test Group', + description: 'Test Group Description', + type: 'open', + maxTeamSize: 5, + }; + + const mockProps = { + intl: mockIntl, + fieldNameCommonBase: 'test', + group: mockGroup, + onDelete: jest.fn(), + onChange: jest.fn(), + onBlur: jest.fn(), + errors: {}, + }; + + const renderComponent = (overrideProps = {}) => render( + + + , + ); + + beforeEach(() => { + useFormikContext.mockReturnValue({ + touched: {}, + errors: {}, + handleChange: jest.fn(), + handleBlur: jest.fn(), + setFieldError: jest.fn(), + }); + + jest.clearAllMocks(); + }); + + test('renders without errors', () => { + renderComponent(); + }); + + test('renders the group name and description', () => { + const { getByText } = renderComponent(); + expect(getByText('Test Group')).toBeInTheDocument(); + expect(getByText('Test Group Description')).toBeInTheDocument(); + }); + + describe('group types messages', () => { + test('group type open message', () => { + const { getByLabelText, getByText } = renderComponent(); + const expandButton = getByLabelText('Expand group editor'); + expect(expandButton).toBeInTheDocument(); + fireEvent.click(expandButton); + expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument(); + }); + + test('group type public_managed message', () => { + const publicManagedGroupMock = { + id: '2', + name: 'Test Group', + description: 'Test Group Description', + type: 'public_managed', + maxTeamSize: 5, + }; + const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock }); + const expandButton = getByLabelText('Expand group editor'); + expect(expandButton).toBeInTheDocument(); + fireEvent.click(expandButton); + expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument(); + }); + + test('group type private_managed message', () => { + const privateManagedGroupMock = { + id: '3', + name: 'Test Group', + description: 'Test Group Description', + type: 'private_managed', + maxTeamSize: 5, + }; + const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock }); + const expandButton = getByLabelText('Expand group editor'); + expect(expandButton).toBeInTheDocument(); + fireEvent.click(expandButton); + expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/course-apps/teams/messages.js b/plugins/course-apps/teams/messages.js index eeb809c96d..1caf514e50 100644 --- a/plugins/course-apps/teams/messages.js +++ b/plugins/course-apps/teams/messages.js @@ -93,6 +93,14 @@ const messages = defineMessages({ id: 'authoring.pagesAndResources.teams.group.types.open', defaultMessage: 'Open', }, + groupTypeOpenManaged: { + id: 'authoring.pagesAndResources.teams.group.types.open_managed', + defaultMessage: 'Open managed', + }, + groupTypeOpenManagedDescription: { + id: 'authoring.pagesAndResources.teams.group.types.open_managed.description', + defaultMessage: 'Only course staff can create teams. Learners can see, join and leave teams.', + }, groupTypeOpenDescription: { id: 'authoring.pagesAndResources.teams.group.types.open.description', defaultMessage: 'Learners can create, join, leave, and see other teams', diff --git a/plugins/course-apps/teams/utils.js b/plugins/course-apps/teams/utils.js new file mode 100644 index 0000000000..3bc8b39d35 --- /dev/null +++ b/plugins/course-apps/teams/utils.js @@ -0,0 +1,23 @@ +/* eslint-disable import/prefer-default-export */ +import { getConfig } from '@edx/frontend-platform'; + +import { GroupTypes } from 'CourseAuthoring/data/constants'; + +/** + * Check if a group type is enabled by the current configuration. + * This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted. + * For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type + * @param {string} groupType - the group type to check + * @returns {boolean} - true if the group type is enabled + */ +export const isGroupTypeEnabled = (groupType) => { + const enabledTypesByDefault = [ + GroupTypes.OPEN, + GroupTypes.PUBLIC_MANAGED, + GroupTypes.PRIVATE_MANAGED, + ]; + const enabledTypesByConfig = { + [GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE, + }; + return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false; +}; diff --git a/plugins/course-apps/teams/utils.test.js b/plugins/course-apps/teams/utils.test.js new file mode 100644 index 0000000000..3b7324cc93 --- /dev/null +++ b/plugins/course-apps/teams/utils.test.js @@ -0,0 +1,39 @@ +import { getConfig } from '@edx/frontend-platform'; +import { GroupTypes } from 'CourseAuthoring/data/constants'; +import { isGroupTypeEnabled } from './utils'; + +jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() })); + +describe('teams utils', () => { + describe('isGroupTypeEnabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('returns true if the group type is enabled', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false }); + expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true); + expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true); + expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true); + }); + test('returns false if the OPEN_MANAGED group is not enabled', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false }); + expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false); + }); + + test('returns true if the OPEN_MANAGED group is enabled', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true }); + expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true); + }); + + test('returns false if the group is invalid', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true }); + expect(isGroupTypeEnabled('FOO')).toBe(false); + }); + + test('returns false if the group is null', () => { + getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true }); + expect(isGroupTypeEnabled(null)).toBe(false); + }); + }); +}); 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 82da29b41f..b8609258ce 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, @@ -26,20 +31,49 @@ import Loading from '../generic/Loading'; * It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe. * - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters. * Functions to close the drawer are handled internally. + * TODO: We can delete this method when is no longer used on edx-platform. * - If you want to use it as react component, you need to pass the content id and the close functions * through the component parameters. */ const ContentTagsDrawer = ({ id, onClose }) => { const intl = useIntl(); + // TODO: We can delete this 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 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); @@ -131,7 +165,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/__mocks__/contentTaxonomyTagsCountMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js new file mode 100644 index 0000000000..3ce4d2050a --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js @@ -0,0 +1,3 @@ +module.exports = { + 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20, +}; diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js new file mode 100644 index 0000000000..687e3d357b --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js @@ -0,0 +1,35 @@ +module.exports = { + 'hierarchical taxonomy tag 1': { + children: { + 'hierarchical taxonomy tag 1.7': { + children: { + 'hierarchical taxonomy tag 1.7.59': { + children: {}, + }, + }, + }, + }, + }, + 'hierarchical taxonomy tag 2': { + children: { + 'hierarchical taxonomy tag 2.13': { + children: { + 'hierarchical taxonomy tag 2.13.46': { + children: {}, + }, + }, + }, + }, + }, + 'hierarchical taxonomy tag 3': { + children: { + 'hierarchical taxonomy tag 3.4': { + children: { + 'hierarchical taxonomy tag 3.4.50': { + children: {}, + }, + }, + }, + }, + }, +}; diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js index 5ec3027386..8c4274d643 100644 --- a/src/content-tags-drawer/__mocks__/index.js +++ b/src/content-tags-drawer/__mocks__/index.js @@ -2,3 +2,5 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock'; export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock'; export { default as contentDataMock } from './contentDataMock'; export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock'; +export { default as contentTaxonomyTagsCountMock } from './contentTaxonomyTagsCountMock'; +export { default as contentTaxonomyTagsTreeMock } from './contentTaxonomyTagsTreeMock'; diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index 2d26b4fd01..54319cc103 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -32,6 +32,7 @@ export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_ 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; /** * Get all tags that belong to taxonomy. @@ -55,6 +56,19 @@ export async function getContentTaxonomyTagsData(contentId) { return camelCaseObject(data[contentId]); } +/** + * Get the count of tags that are applied to the content object + * @param {string} contentId The id of the content object to fetch the count of the applied tags for + * @returns {Promise} + */ +export async function getContentTaxonomyTagsCount(contentId) { + const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId)); + if (contentId in data) { + return camelCaseObject(data[contentId]); + } + return 0; +} + /** * Fetch meta data (eg: display_name) about the content object (unit/compoenent) * @param {string} contentId The id of the content object (unit/component) diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index 9fa88dcb79..7ccb353548 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -6,6 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { taxonomyTagsMock, contentTaxonomyTagsMock, + contentTaxonomyTagsCountMock, contentDataMock, updateContentTaxonomyTagsMock, } from '../__mocks__'; @@ -19,6 +20,8 @@ import { getContentTaxonomyTagsData, getContentData, updateContentTaxonomyTags, + getContentTaxonomyTagsCountApiUrl, + getContentTaxonomyTagsCount, } from './api'; let axiosMock; @@ -88,6 +91,24 @@ describe('content tags drawer api calls', () => { expect(result).toEqual(contentTaxonomyTagsMock[contentId]); }); + it('should get content taxonomy tags count', async () => { + const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, contentTaxonomyTagsCountMock); + const result = await getContentTaxonomyTagsCount(contentId); + + expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId)); + expect(result).toEqual(contentTaxonomyTagsCountMock[contentId]); + }); + + it('should get content taxonomy tags count as zero', async () => { + const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, {}); + const result = await getContentTaxonomyTagsCount(contentId); + + expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId)); + expect(result).toEqual(0); + }); + it('should get content data for course component', async () => { const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index 0b311d795f..f393a9edd9 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -11,6 +11,7 @@ import { getContentTaxonomyTagsData, getContentData, updateContentTaxonomyTags, + getContentTaxonomyTagsCount, } from './api'; /** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */ @@ -105,6 +106,18 @@ 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 + */ +// FixMe: remove +// 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) @@ -145,6 +158,9 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => { contentPattern = contentId.replace(/\+type@.*$/, '*'); } queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] }); + // FixMe: remove code below + // queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] }); + // queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] }); }, }); }; diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index 4e12ef5ea5..127d71cc5b 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -6,6 +6,7 @@ import { useContentTaxonomyTagsData, useContentData, useContentTaxonomyTagsUpdater, + useContentTaxonomyTagsCount, } from './apiHooks'; import { updateContentTaxonomyTags } from './api'; @@ -134,6 +135,24 @@ describe('useContentTaxonomyTagsData', () => { }); }); +describe('useContentTaxonomyTagsCount', () => { + it('should return success response', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + const contentId = '123'; + const result = useContentTaxonomyTagsCount(contentId); + + expect(result).toEqual({ isSuccess: true, data: 'data' }); + }); + + it('should return failure response', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); + const contentId = '123'; + const result = useContentTaxonomyTagsCount(contentId); + + expect(result).toEqual({ isSuccess: false }); + }); +}); + describe('useContentData', () => { it('should return success response', () => { useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); diff --git a/src/content-tags-drawer/index.scss b/src/content-tags-drawer/index.scss new file mode 100644 index 0000000000..d179bf86ab --- /dev/null +++ b/src/content-tags-drawer/index.scss @@ -0,0 +1,2 @@ +@import "content-tags-drawer/TagBubble"; +@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls"; diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js index c54e6b7bcc..47a8c1bc86 100644 --- a/src/content-tags-drawer/messages.js +++ b/src/content-tags-drawer/messages.js @@ -33,6 +33,32 @@ const messages = defineMessages({ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label', defaultMessage: 'taxonomy tags selection', }, + manageTagsButton: { + id: 'course-authoring.content-tags-drawer.button.manage', + defaultMessage: 'Manage Tags', + description: 'Label in the button that opens the drawer to edit content tags', + }, + tagsSidebarTitle: { + id: 'course-authoring.course-unit.sidebar.tags.title', + defaultMessage: 'Unit Tags', + description: 'Title of the tags sidebar', + }, + 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/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx new file mode 100644 index 0000000000..29b2e244d6 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx @@ -0,0 +1,112 @@ +// @ts-check +import React, { useState, useMemo } from 'react'; +import { + Card, Stack, Button, Sheet, Collapsible, Icon, +} from '@openedx/paragon'; +import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useParams } from 'react-router-dom'; +import { ContentTagsDrawer } from '..'; + +import messages from '../messages'; +import { useContentTaxonomyTagsData } from '../data/apiHooks'; +import { LoadingSpinner } from '../../generic/Loading'; +import TagsTree from './TagsTree'; + +const TagsSidebarBody = () => { + const intl = useIntl(); + const [showManageTags, setShowManageTags] = useState(false); + const contentId = useParams().blockId; + const onClose = () => setShowManageTags(false); + + const { + data: contentTaxonomyTagsData, + isSuccess: isContentTaxonomyTagsLoaded, + } = useContentTaxonomyTagsData(contentId || ''); + + const buildTagsTree = (contentTags) => { + const resultTree = {}; + contentTags.forEach(item => { + let currentLevel = resultTree; + + item.lineage.forEach((key) => { + if (!currentLevel[key]) { + currentLevel[key] = { + children: {}, + canChangeObjecttag: item.canChangeObjecttag, + canDeleteObjecttag: item.canDeleteObjecttag, + }; + } + + currentLevel = currentLevel[key].children; + }); + }); + + return resultTree; + }; + + const tree = useMemo(() => { + const result = []; + if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) { + contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => { + result.push({ + ...taxonomy, + tags: buildTagsTree(taxonomy.tags), + }); + }); + } + return result; + }, [isContentTaxonomyTagsLoaded, contentTaxonomyTagsData]); + + return ( + <> + + + { isContentTaxonomyTagsLoaded + ? ( + + {tree.map((taxonomy) => ( +
+ } + iconWhenOpen={} + > + + +
+ ))} +
+ ) + : ( +
+ +
+ )} + + +
+
+ + + + + ); +}; + +TagsSidebarBody.propTypes = {}; + +export default TagsSidebarBody; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx new file mode 100644 index 0000000000..32be90bf44 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TagsSidebarBody from './TagsSidebarBody'; +import { useContentTaxonomyTagsData } from '../data/apiHooks'; +import { contentTaxonomyTagsMock } from '../__mocks__'; + +const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + +jest.mock('../data/apiHooks', () => ({ + useContentTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), +})); +jest.mock('../ContentTagsDrawer', () => jest.fn(() =>
Mocked ContentTagsDrawer
)); + +const RootWrapper = () => ( + + + +); + +describe('', () => { + it('shows spinner before the content data query is complete', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('should render data after wuery is complete', () => { + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: contentTaxonomyTagsMock[contentId], + }); + render(); + const taxonomyButton = screen.getByRole('button', { name: /hierarchicaltaxonomy/i }); + expect(taxonomyButton).toBeInTheDocument(); + + /// ContentTagsDrawer must be closed + expect(screen.queryByText('Mocked ContentTagsDrawer')).not.toBeInTheDocument(); + }); + + it('should open ContentTagsDrawer', () => { + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: contentTaxonomyTagsMock[contentId], + }); + render(); + + const manageButton = screen.getByRole('button', { name: /manage tags/i }); + fireEvent.click(manageButton); + + expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss new file mode 100644 index 0000000000..a3c0978f8c --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss @@ -0,0 +1,23 @@ +.tags-sidebar { + .tags-sidebar-body { + .tags-sidebar-taxonomy { + .collapsible-trigger { + font-weight: bold; + border: none; + justify-content: start; + padding-left: 0; + padding-bottom: 0; + + .collapsible-icon { + order: -1; + margin-left: 0; + } + } + + .collapsible-body { + padding-top: 0; + padding-bottom: 0; + } + } + } +} diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx new file mode 100644 index 0000000000..e3927deb89 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx @@ -0,0 +1,36 @@ +// @ts-check +import React from 'react'; +import { Stack } from '@openedx/paragon'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import { useContentTaxonomyTagsCount } from '../data/apiHooks'; +import TagCount from '../../generic/tag-count'; + +const TagsSidebarHeader = () => { + const intl = useIntl(); + const contentId = useParams().blockId; + + const { + data: contentTaxonomyTagsCount, + isSuccess: isContentTaxonomyTagsCountLoaded, + } = useContentTaxonomyTagsCount(contentId || ''); + + return ( + +

+ {intl.formatMessage(messages.tagsSidebarTitle)} +

+ { isContentTaxonomyTagsCountLoaded + && } +
+ ); +}; + +TagsSidebarHeader.propTypes = {}; + +export default TagsSidebarHeader; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx new file mode 100644 index 0000000000..ab0f9339e8 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TagsSidebarHeader from './TagsSidebarHeader'; +import { useContentTaxonomyTagsCount } from '../data/apiHooks'; + +jest.mock('../data/apiHooks', () => ({ + useContentTaxonomyTagsCount: jest.fn(() => ({ + isSuccess: false, + data: 17, + })), +})); + +const RootWrapper = () => ( + + + +); + +describe('', () => { + it('should not render count on loading', () => { + render(); + 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(); + expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument(); + expect(screen.getByText('17')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx new file mode 100644 index 0000000000..df9923271a --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx @@ -0,0 +1,50 @@ +// @ts-check +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@openedx/paragon'; +import { Tag } from '@openedx/paragon/icons'; + +const TagsTree = ({ tags, rootDepth, parentKey }) => { + if (Object.keys(tags).length === 0) { + return null; + } + + // Used to Generate tabs for the parents of this tree + const tabsNumberArray = Array.from({ length: rootDepth }, (_, index) => index + 1); + + return ( +
+ {Object.keys(tags).map((key) => ( +
+
+ { + tabsNumberArray.map((index) => ) + } + {key} +
+ { tags[key].children + && ( + + )} +
+ ))} +
+ ); +}; + +TagsTree.propTypes = { + tags: PropTypes.shape({}).isRequired, + parentKey: PropTypes.string, + rootDepth: PropTypes.number, +}; + +TagsTree.defaultProps = { + rootDepth: 0, + parentKey: undefined, +}; + +export default TagsTree; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx new file mode 100644 index 0000000000..0ca8c5333a --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TagsTree from './TagsTree'; +import { contentTaxonomyTagsTreeMock } from '../__mocks__'; + +describe('', () => { + it('should render component and tags correctly', () => { + render(); + expect(screen.getByText('hierarchical taxonomy tag 1')).toBeInTheDocument(); + expect(screen.getByText('hierarchical taxonomy tag 2.13')).toBeInTheDocument(); + expect(screen.getByText('hierarchical taxonomy tag 3.4.50')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/index.jsx b/src/content-tags-drawer/tags-sidebar-controls/index.jsx new file mode 100644 index 0000000000..98ffc5e7c4 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/index.jsx @@ -0,0 +1,13 @@ +import TagsSidebarHeader from './TagsSidebarHeader'; +import TagsSidebarBody from './TagsSidebarBody'; + +const TagsSidebarControls = () => ( + <> + + + +); + +TagsSidebarControls.propTypes = {}; + +export default TagsSidebarControls; diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 8a117abab3..25691135c4 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -13,6 +13,7 @@ import getPageHeadTitle from '../generic/utils'; import AlertMessage from '../generic/alert-message'; import ProcessingNotification from '../generic/processing-notification'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import Loading from '../generic/Loading'; import AddComponent from './add-component/AddComponent'; import CourseXBlock from './course-xblock/CourseXBlock'; @@ -23,6 +24,9 @@ import Sequence from './course-sequence'; import Sidebar from './sidebar'; import { useCourseUnit } from './hooks'; import messages from './messages'; +import PublishControls from './sidebar/PublishControls'; +import LocationInfo from './sidebar/LocationInfo'; +import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls'; const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); @@ -32,6 +36,7 @@ const CourseUnit = ({ courseId }) => { sequenceId, unitTitle, isQueryPending, + sequenceStatus, savingStatus, isTitleEditFormOpen, isErrorAlert, @@ -57,6 +62,14 @@ const CourseUnit = ({ courseId }) => { return ; } + if (sequenceStatus === RequestStatus.FAILED) { + return ( + + + + ); + } + return ( <> @@ -123,8 +136,15 @@ const CourseUnit = ({ courseId }) => { - - + + + + + + + + + diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 233bb63cc1..5a9685aea3 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -44,6 +44,7 @@ import deleteModalMessages from '../generic/delete-modal/messages'; import courseXBlockMessages from './course-xblock/messages'; import addComponentMessages from './add-component/messages'; import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; +import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; let axiosMock; let store; @@ -59,6 +60,31 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedUsedNavigate, })); +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(({ queryKey }) => { + if (queryKey[0] === 'contentTaxonomyTags') { + return { + data: { + taxonomies: [], + }, + isSuccess: true, + }; + } if (queryKey[0] === 'contentTaxonomyTagsCount') { + return { + data: 17, + isSuccess: true, + }; + } + return { + data: {}, + isSuccess: true, + }; + }), + useQueryClient: jest.fn(() => ({ + setQueryData: jest.fn(), + })), +})); + const RootWrapper = () => ( @@ -92,17 +118,24 @@ describe('', () => { .onGet(getCourseVerticalChildrenApiUrl(blockId)) .reply(200, courseVerticalChildrenMock); await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + axiosMock + .onGet(getContentTaxonomyTagsApiUrl(blockId)) + .reply(200, {}); + axiosMock + .onGet(getContentTaxonomyTagsCountApiUrl(blockId)) + .reply(200, 17); }); it('render CourseUnit component correctly', async () => { - const { getByText, getByRole } = render(); + const { getByText, getByRole, getByTestId } = render(); const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; await waitFor(() => { + const unitHeaderTitle = getByTestId('unit-header-title'); expect(getByText(unitDisplayName)).toBeInTheDocument(); - expect(getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); @@ -136,7 +169,10 @@ describe('', () => { it('checks courseUnit title changing when edit query is successfully', async () => { const { - findByText, queryByRole, getByRole, + findByText, + queryByRole, + getByRole, + getByTestId, } = render(); let editTitleButton = null; let titleEditField = null; @@ -160,8 +196,11 @@ describe('', () => { }); await waitFor(() => { - editTitleButton = getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); - titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + const unitHeaderTitle = getByTestId('unit-header-title'); + editTitleButton = within(unitHeaderTitle) + .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); + titleEditField = within(unitHeaderTitle) + .queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); }); expect(titleEditField).not.toBeInTheDocument(); fireEvent.click(editTitleButton); @@ -299,7 +338,7 @@ describe('', () => { }); it('the sequence unit is updated after changing the unit header', async () => { - const { getAllByTestId, getByRole } = render(); + const { getAllByTestId, getByTestId } = render(); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ @@ -331,10 +370,12 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const editTitleButton = getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); + const unitHeaderTitle = getByTestId('unit-header-title'); + + const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); fireEvent.click(editTitleButton); - const titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + const titleEditField = within(unitHeaderTitle).getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); fireEvent.change(titleEditField, { target: { value: newDisplayName } }); await act(async () => fireEvent.blur(titleEditField)); diff --git a/src/course-unit/__mocks__/courseSectionVertical.js b/src/course-unit/__mocks__/courseSectionVertical.js index ceea70fd55..67d57c2e62 100644 --- a/src/course-unit/__mocks__/courseSectionVertical.js +++ b/src/course-unit/__mocks__/courseSectionVertical.js @@ -289,6 +289,10 @@ module.exports = { }, }, ], + course_sequence_ids: [ + 'block-v1:edx+876+2030+type@sequential+block@297321078a0f4c26a50d671ed87642a6', + 'block-v1:edx+876+2030+type@sequential+block@4e91bdfefd8e4173a03d19c4d91e1936', + ], xblock_info: { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', display_name: 'Getting Started', diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js index 043a693fff..28035e1afd 100644 --- a/src/course-unit/course-sequence/hooks.js +++ b/src/course-unit/course-sequence/hooks.js @@ -3,20 +3,14 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { useWindowSize } from '@openedx/paragon'; import { useModel } from '../../generic/model-store'; -import { - getCourseSectionVertical, - getCourseUnit, - sequenceIdsSelector, -} from '../data/selectors'; +import { getCourseSectionVertical, getSequenceIds } from '../data/selectors'; -export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) { - const sequenceIds = useSelector(sequenceIdsSelector); +export function useSequenceNavigationMetadata(courseId, currentSequenceId, currentUnitId) { const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical); const sequence = useModel('sequences', currentSequenceId); - const { courseId } = useSelector(getCourseUnit); const isFirstUnit = !prevUrl; const isLastUnit = !nextUrl; - + const sequenceIds = useSelector(getSequenceIds); const sequenceIndex = sequenceIds.indexOf(currentSequenceId); const unitIndex = sequence.unitIds.indexOf(currentUnitId); diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx index e7a47ec748..a2fbe55d4e 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -20,6 +20,7 @@ import SequenceNavigationTabs from './SequenceNavigationTabs'; const SequenceNavigation = ({ intl, + courseId, unitId, sequenceId, className, @@ -28,7 +29,7 @@ const SequenceNavigation = ({ const sequenceStatus = useSelector(getSequenceStatus); const { isFirstUnit, isLastUnit, nextLink, previousLink, - } = useSequenceNavigationMetadata(sequenceId, unitId); + } = useSequenceNavigationMetadata(courseId, sequenceId, unitId); const sequence = useModel('sequences', sequenceId); const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; @@ -104,6 +105,7 @@ const SequenceNavigation = ({ SequenceNavigation.propTypes = { intl: intlShape.isRequired, + courseId: PropTypes.string.isRequired, unitId: PropTypes.string, className: PropTypes.string, sequenceId: PropTypes.string, diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 370488ce06..7565a8c0d1 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -1,9 +1,9 @@ import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; import { Button } from '@openedx/paragon'; import { Plus as PlusIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useNavigate } from 'react-router-dom'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice'; import { getCourseId, getSequenceId } from '../../data/selectors'; diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index 80e25dac13..e4b6365424 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ blockAltButtonEdit: { id: 'course-authoring.course-unit.xblock.button.edit.alt', - defaultMessage: 'Edit Item', + defaultMessage: 'Edit', }, blockActionsDropdownAlt: { id: 'course-authoring.course-unit.xblock.button.actions.alt', diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index f7adc33b47..6520d1e1de 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -3,23 +3,13 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { PUBLISH_TYPES } from '../constants'; -import { - normalizeLearningSequencesData, - normalizeMetadata, - normalizeCourseHomeCourseMetadata, - appendBrowserTimezoneToUrl, - normalizeCourseSectionVerticalData, -} from './utils'; +import { normalizeCourseSectionVerticalData } from './utils'; const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getLmsBaseUrl = () => getConfig().LMS_BASE_URL; export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`; export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`; export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; -export const getLearningSequencesOutlineApiUrl = (courseId) => `${getLmsBaseUrl()}/api/learning_sequences/v1/course_outline/${courseId}`; -export const getCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/courseware/course/${courseId}`; -export const getCourseHomeCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/course_home/course_metadata/${courseId}`; export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`; export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; @@ -65,45 +55,6 @@ export async function getCourseSectionVerticalData(unitId) { return normalizeCourseSectionVerticalData(data); } -/** - * Retrieves the outline of learning sequences for a specific course. - * @param {string} courseId - The ID of the course. - * @returns {Promise} A Promise that resolves to the normalized learning sequences outline data. - */ -export async function getLearningSequencesOutline(courseId) { - const { href } = new URL(getLearningSequencesOutlineApiUrl(courseId)); - const { data } = await getAuthenticatedHttpClient().get(href, {}); - - return normalizeLearningSequencesData(data); -} - -/** - * Retrieves metadata for a specific course. - * @param {string} courseId - The ID of the course. - * @returns {Promise} A Promise that resolves to the normalized course metadata. - */ -export async function getCourseMetadata(courseId) { - let courseMetadataApiUrl = getCourseMetadataApiUrl(courseId); - courseMetadataApiUrl = appendBrowserTimezoneToUrl(courseMetadataApiUrl); - const metadata = await getAuthenticatedHttpClient().get(courseMetadataApiUrl); - - return normalizeMetadata(metadata); -} - -/** - * Retrieves metadata for a course's home page. - * @param {string} courseId - The ID of the course. - * @param {string} rootSlug - The root slug for the course. - * @returns {Promise} A Promise that resolves to the normalized course home page metadata. - */ -export async function getCourseHomeCourseMetadata(courseId, rootSlug) { - let courseHomeCourseMetadataApiUrl = getCourseHomeCourseMetadataApiUrl(courseId); - courseHomeCourseMetadataApiUrl = appendBrowserTimezoneToUrl(courseHomeCourseMetadataApiUrl); - const { data } = await getAuthenticatedHttpClient().get(courseHomeCourseMetadataApiUrl); - - return normalizeCourseHomeCourseMetadata(data, rootSlug); -} - /** * Creates a new course XBlock. * @param {Object} options - The options for creating the XBlock. diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 5fa52ac1b4..16619548ff 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,30 +1,9 @@ -import { createSelector } from '@reduxjs/toolkit'; - -import { RequestStatus } from '../../data/constants'; - export const getCourseUnitData = (state) => state.courseUnit.unit; -export const getCourseUnit = (state) => state.courseUnit; export const getSavingStatus = (state) => state.courseUnit.savingStatus; export const getLoadingStatus = (state) => state.courseUnit.loadingStatus; export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus; +export const getSequenceIds = (state) => state.courseUnit.courseSectionVertical.courseSequenceIds; export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical; -export const getCourseUnitComponentTemplates = (state) => state.courseUnit.courseSectionVertical.componentTemplates; -export const getCourseSectionVerticalLoadingStatus = (state) => state - .courseUnit.loadingStatus.courseSectionVerticalLoadingStatus; -export const getCourseStatus = state => state.courseUnit.courseStatus; -export const getCoursewareMeta = state => state.models.coursewareMeta; -export const getSections = state => state.models.sections; -export const getCourseId = state => state.courseDetail.courseId; -export const getSequenceId = state => state.courseUnit.sequenceId; -export const getCourseVerticalChildren = state => state.courseUnit.courseVerticalChildren; -export const sequenceIdsSelector = createSelector( - [getCourseStatus, getCoursewareMeta, getSections, getCourseId], - (courseStatus, coursewareMeta, sections, courseId) => { - if (courseStatus !== RequestStatus.SUCCESSFUL) { - return []; - } - - const sectionIds = coursewareMeta[courseId].sectionIds || []; - return sectionIds.flatMap(sectionId => sections[sectionId].sequenceIds); - }, -); +export const getCourseId = (state) => state.courseDetail.courseId; +export const getSequenceId = (state) => state.courseUnit.sequenceId; +export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index bd4066afcc..0134fcb054 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -52,22 +52,6 @@ const slice = createSlice({ state.sequenceStatus = RequestStatus.FAILED; state.sequenceMightBeUnit = payload.sequenceMightBeUnit || false; }, - fetchCourseRequest: (state, { payload }) => { - state.courseId = payload.courseId; - state.courseStatus = RequestStatus.IN_PROGRESS; - }, - fetchCourseSuccess: (state, { payload }) => { - state.courseId = payload.courseId; - state.courseStatus = RequestStatus.SUCCESSFUL; - }, - fetchCourseFailure: (state, { payload }) => { - state.courseId = payload.courseId; - state.courseStatus = RequestStatus.FAILED; - }, - fetchCourseDenied: (state, { payload }) => { - state.courseId = payload.courseId; - state.courseStatus = RequestStatus.DENIED; - }, fetchCourseSectionVerticalDataSuccess: (state, { payload }) => { state.courseSectionVertical = payload; }, @@ -122,10 +106,6 @@ export const { fetchSequenceRequest, fetchSequenceSuccess, fetchSequenceFailure, - fetchCourseRequest, - fetchCourseSuccess, - fetchCourseFailure, - fetchCourseDenied, fetchCourseSectionVerticalDataSuccess, updateLoadingCourseSectionVerticalDataStatus, changeEditTitleFormOpen, diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 23b39937a1..e18a0cc6d6 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,4 +1,3 @@ -import { logError, logInfo } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform'; import { @@ -7,15 +6,10 @@ import { } from '../../generic/processing-notification/data/slice'; import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; -import { - addModel, updateModel, updateModels, updateModelsMap, addModelsMap, -} from '../../generic/model-store'; +import { updateModel, updateModels } from '../../generic/model-store'; import { getCourseUnitData, editUnitDisplayName, - getCourseMetadata, - getLearningSequencesOutline, - getCourseHomeCourseMetadata, getCourseSectionVerticalData, createCourseXblock, getCourseVerticalChildren, @@ -30,10 +24,6 @@ import { fetchSequenceRequest, fetchSequenceFailure, fetchSequenceSuccess, - fetchCourseRequest, - fetchCourseSuccess, - fetchCourseDenied, - fetchCourseFailure, fetchCourseSectionVerticalDataSuccess, updateLoadingCourseSectionVerticalDataStatus, updateLoadingCourseXblockStatus, @@ -146,88 +136,6 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible) { }; } -export function fetchCourse(courseId) { - return async (dispatch) => { - dispatch(fetchCourseRequest({ courseId })); - Promise.allSettled([ - getCourseMetadata(courseId), - getLearningSequencesOutline(courseId), - getCourseHomeCourseMetadata(courseId, 'courseware'), - ]).then(([ - courseMetadataResult, - learningSequencesOutlineResult, - courseHomeMetadataResult]) => { - if (courseMetadataResult.status === 'fulfilled') { - dispatch(addModel({ - modelType: 'coursewareMeta', - model: courseMetadataResult.value, - })); - } - - if (courseHomeMetadataResult.status === 'fulfilled') { - dispatch(addModel({ - modelType: 'courseHomeMeta', - model: { - id: courseId, - ...courseHomeMetadataResult.value, - }, - })); - } - - if (learningSequencesOutlineResult.status === 'fulfilled') { - const { courses, sections } = learningSequencesOutlineResult.value; - - // This updates the course with a sectionIds array from the Learning Sequence data. - dispatch(updateModelsMap({ - modelType: 'coursewareMeta', - modelsMap: courses, - })); - dispatch(addModelsMap({ - modelType: 'sections', - modelsMap: sections, - })); - } - - const fetchedMetadata = courseMetadataResult.status === 'fulfilled'; - const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled'; - const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled'; - - // Log errors for each request if needed. Outline failures may occur - // even if the course metadata request is successful - if (!fetchedOutline) { - const { response } = learningSequencesOutlineResult.reason; - if (response && response.status === 403) { - // 403 responses are normal - they happen when the learner is logged out. - // We'll redirect them in a moment to the outline tab by calling fetchCourseDenied() below. - logInfo(learningSequencesOutlineResult.reason); - } else { - logError(learningSequencesOutlineResult.reason); - } - } - if (!fetchedMetadata) { - logError(courseMetadataResult.reason); - } - if (!fetchedCourseHomeMetadata) { - logError(courseHomeMetadataResult.reason); - } - if (fetchedMetadata && fetchedCourseHomeMetadata) { - if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) { - // User has access - dispatch(fetchCourseSuccess({ courseId })); - return; - } - // User either doesn't have access or only has partial access - // (can't access course blocks) - dispatch(fetchCourseDenied({ courseId })); - return; - } - - // Definitely an error happening - dispatch(fetchCourseFailure({ courseId })); - }); - }; -} - export function createNewCourseXBlock(body, callback, blockId) { return async (dispatch) => { dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index afd6e0a03a..a37faaa4db 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -3,196 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { PUBLISH_TYPES } from '../constants'; -export function getTimeOffsetMillis(headerDate, requestTime, responseTime) { - // Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference - // Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers - - let timeOffsetMillis = 0; - if (headerDate !== undefined) { - const headerTime = Date.parse(headerDate); - const roundTripMillis = requestTime - responseTime; - const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time - timeOffsetMillis = headerTime - localTime; - } - - return timeOffsetMillis; -} - -export function normalizeMetadata(metadata) { - const requestTime = Date.now(); - const responseTime = requestTime; - const { data, headers } = metadata; - return { - accessExpiration: camelCaseObject(data.access_expiration), - canShowUpgradeSock: data.can_show_upgrade_sock, - contentTypeGatingEnabled: data.content_type_gating_enabled, - courseGoals: camelCaseObject(data.course_goals), - id: data.id, - title: data.name, - offer: camelCaseObject(data.offer), - enrollmentStart: data.enrollment_start, - enrollmentEnd: data.enrollment_end, - end: data.end, - start: data.start, - enrollmentMode: data.enrollment.mode, - isEnrolled: data.enrollment.is_active, - license: data.license, - userTimezone: data.user_timezone, - showCalculator: data.show_calculator, - notes: camelCaseObject(data.notes), - marketingUrl: data.marketing_url, - celebrations: camelCaseObject(data.celebrations), - userHasPassingGrade: data.user_has_passing_grade, - courseExitPageIsActive: data.course_exit_page_is_active, - certificateData: camelCaseObject(data.certificate_data), - entranceExamData: camelCaseObject(data.entrance_exam_data), - timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime), - verifyIdentityUrl: data.verify_identity_url, - verificationStatus: data.verification_status, - linkedinAddToProfileUrl: data.linkedin_add_to_profile_url, - relatedPrograms: camelCaseObject(data.related_programs), - userNeedsIntegritySignature: data.user_needs_integrity_signature, - canAccessProctoredExams: data.can_access_proctored_exams, - learningAssistantEnabled: data.learning_assistant_enabled, - }; -} - -export const appendBrowserTimezoneToUrl = (url) => { - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const urlObject = new URL(url); - if (browserTimezone) { - urlObject.searchParams.append('browser_timezone', browserTimezone); - } - return urlObject.href; -}; - -export function normalizeSequenceMetadata(sequence) { - return { - sequence: { - id: sequence.item_id, - blockType: sequence.tag, - unitIds: sequence.items.map(unit => unit.id), - bannerText: sequence.banner_text, - format: sequence.format, - title: sequence.display_name, - /* - Example structure of gated_content when prerequisites exist: - { - prereq_id: 'id of the prereq section', - prereq_url: 'unused by this frontend', - prereq_section_name: 'Name of the prerequisite section', - gated: true, - gated_section_name: 'Name of this gated section', - */ - gatedContent: camelCaseObject(sequence.gated_content), - isTimeLimited: sequence.is_time_limited, - isProctored: sequence.is_proctored, - isHiddenAfterDue: sequence.is_hidden_after_due, - // Position comes back from the server 1-indexed. Adjust here. - activeUnitIndex: sequence.position ? sequence.position - 1 : 0, - saveUnitPosition: sequence.save_position, - showCompletion: sequence.show_completion, - allowProctoringOptOut: sequence.allow_proctoring_opt_out, - }, - units: sequence.items.map(unit => ({ - id: unit.id, - sequenceId: sequence.item_id, - bookmarked: unit.bookmarked, - complete: unit.complete, - title: unit.page_title, - contentType: unit.type, - graded: unit.graded, - containsContentTypeGatedContent: unit.contains_content_type_gated_content, - })), - }; -} - -export function normalizeLearningSequencesData(learningSequencesData) { - const models = { - courses: {}, - sections: {}, - sequences: {}, - }; - - const now = new Date(); - function isReleased(block) { - // We check whether the backend marks this as accessible because staff users are granted access anyway. - // Note that sections don't have the `accessible` field and will just be checking `effective_start`. - return block.accessible || !block.effective_start || now >= Date.parse(block.effective_start); - } - - // Sequences - Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => { - if (!isReleased(sequence)) { - return; // Don't let the learner see unreleased sequences - } - - models.sequences[seqId] = { - id: seqId, - title: sequence.title, - }; - }); - - // Sections - learningSequencesData.outline.sections.forEach(section => { - // Filter out any ignored sequences (e.g. unreleased sequences) - const availableSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences); - - // If we are unreleased and already stripped out all our children, just don't show us at all. - // (We check both release date and children because children will exist for an unreleased section even for staff, - // so we still want to show this section.) - if (!isReleased(section) && !availableSequenceIds.length) { - return; - } - - models.sections[section.id] = { - id: section.id, - title: section.title, - sequenceIds: availableSequenceIds, - courseId: learningSequencesData.course_key, - }; - - // Add back-references to this section for all child sequences. - availableSequenceIds.forEach(childSeqId => { - models.sequences[childSeqId].sectionId = section.id; - }); - }); - - // Course - models.courses[learningSequencesData.course_key] = { - id: learningSequencesData.course_key, - title: learningSequencesData.title, - sectionIds: Object.entries(models.sections).map(([sectionId]) => sectionId), - - // Scan through all the sequences and look for ones that aren't released yet. - hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(seq => !isReleased(seq)), - }; - - return models; -} - -/** - * Tweak the metadata for consistency - * @param metadata the data to normalize - * @param rootSlug either 'courseware' or 'outline' depending on the context - * @returns {Object} The normalized metadata - */ -export function normalizeCourseHomeCourseMetadata(metadata, rootSlug) { - const data = camelCaseObject(metadata); - return { - ...data, - tabs: data.tabs.map(tab => ({ - // The API uses "courseware" as a slug for both courseware and the outline tab. - // If needed, we switch it to "outline" here for - // use within the MFE to differentiate between course home and courseware. - slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId, - title: tab.title, - url: tab.url, - })), - isMasquerading: data.originalUserIsStaff && !data.isStaff, - }; -} - +// eslint-disable-next-line import/prefer-default-export export function normalizeCourseSectionVerticalData(metadata) { const data = camelCaseObject(metadata); return { diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 9afdd60f1e..4fc5739225 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -27,7 +27,7 @@ const HeaderTitle = ({ }, [unitTitle]); return ( -
+
{isTitleEditFormOpen ? ( {}} + onClick={() => { + }} />
); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index dea7599b89..d1d1edc7bc 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -7,7 +7,6 @@ import { createNewCourseXBlock, fetchCourseUnitQuery, editCourseItemQuery, - fetchCourse, fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, deleteUnitItemQuery, @@ -19,6 +18,7 @@ import { getCourseUnitData, getLoadingStatus, getSavingStatus, + getSequenceStatus, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; @@ -31,6 +31,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const courseUnit = useSelector(getCourseUnitData); const savingStatus = useSelector(getSavingStatus); const loadingStatus = useSelector(getLoadingStatus); + const sequenceStatus = useSelector(getSequenceStatus); const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); const navigate = useNavigate(); @@ -87,7 +88,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { - dispatch(updateQueryPendingStatus(false)); + dispatch(updateQueryPendingStatus(true)); } else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) { toggleErrorAlert(true); } @@ -97,7 +98,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(fetchCourseUnitQuery(blockId)); dispatch(fetchCourseSectionVerticalData(blockId, sequenceId)); dispatch(fetchCourseVerticalChildrenData(blockId)); - dispatch(fetchCourse(courseId)); handleNavigate(sequenceId); }, [courseId, blockId, sequenceId]); @@ -106,6 +106,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { sequenceId, courseUnit, unitTitle, + sequenceStatus, savingStatus, isQueryPending, isErrorAlert, diff --git a/src/course-unit/sidebar/LocationInfo.jsx b/src/course-unit/sidebar/LocationInfo.jsx new file mode 100644 index 0000000000..1d63180883 --- /dev/null +++ b/src/course-unit/sidebar/LocationInfo.jsx @@ -0,0 +1,38 @@ +import { useSelector } from 'react-redux'; +import useCourseUnitData from './hooks'; +import { getCourseUnitData } from '../data/selectors'; +import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; + +const LocationInfo = () => { + const { + title, + locationId, + releaseLabel, + visibilityState, + visibleToStaffOnly, + } = useCourseUnitData(useSelector(getCourseUnitData)); + + return ( + <> + + + + + ); +}; + +LocationInfo.propTypes = {}; + +export default LocationInfo; diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx new file mode 100644 index 0000000000..424594f35b --- /dev/null +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useToggle } from '@openedx/paragon'; +import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import useCourseUnitData from './hooks'; +import { editCourseUnitVisibilityAndData } from '../data/thunk'; +import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; +import { PUBLISH_TYPES } from '../constants'; +import { getCourseUnitData } from '../data/selectors'; +import messages from './messages'; +import ModalNotification from '../../generic/modal-notification'; + +const PublishControls = ({ blockId }) => { + const { + title, + locationId, + releaseLabel, + visibilityState, + visibleToStaffOnly, + } = useCourseUnitData(useSelector(getCourseUnitData)); + const intl = useIntl(); + + const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); + const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); + + const dispatch = useDispatch(); + + const handleCourseUnitVisibility = () => { + closeVisibleModal(); + dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null)); + }; + + const handleCourseUnitDiscardChanges = () => { + closeDiscardModal(); + dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges)); + }; + + const handleCourseUnitPublish = () => { + dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic)); + }; + + return ( + <> + + + + + + + ); +}; + +PublishControls.propTypes = { + blockId: PropTypes.string, +}; + +PublishControls.defaultProps = { + blockId: null, +}; + +export default PublishControls; diff --git a/src/course-unit/sidebar/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss index 954e20d4b2..0fbae7eb6a 100644 --- a/src/course-unit/sidebar/Sidebar.scss +++ b/src/course-unit/sidebar/Sidebar.scss @@ -68,9 +68,9 @@ @extend %base-font-params; } - } - &.is-stuff-only .course-unit-sidebar-date-and-with { - text-decoration: line-through; + &.is-stuff-only .course-unit-sidebar-date-and-with { + text-decoration: line-through; + } } } diff --git a/src/course-unit/sidebar/components/SidebarBody.jsx b/src/course-unit/sidebar/components/SidebarBody.jsx index 679384377d..b7dce23a23 100644 --- a/src/course-unit/sidebar/components/SidebarBody.jsx +++ b/src/course-unit/sidebar/components/SidebarBody.jsx @@ -3,12 +3,18 @@ import { useSelector } from 'react-redux'; import { Card, Stack } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; import { getCourseUnitData } from '../../data/selectors'; import { getPublishInfo } from '../utils'; import messages from '../messages'; import ReleaseInfoComponent from './ReleaseInfoComponent'; -const SidebarBody = ({ releaseLabel, displayUnitLocation, locationId }) => { +const SidebarBody = ({ + releaseLabel, + displayUnitLocation, + locationId, + visibleToStaffOnly, +}) => { const intl = useIntl(); const { editedOn, @@ -19,7 +25,10 @@ const SidebarBody = ({ releaseLabel, displayUnitLocation, locationId }) => { } = useSelector(getCourseUnitData); return ( - + {displayUnitLocation ? ( @@ -55,11 +64,13 @@ SidebarBody.propTypes = { releaseLabel: PropTypes.string.isRequired, displayUnitLocation: PropTypes.bool, locationId: PropTypes.string, + visibleToStaffOnly: PropTypes.bool, }; SidebarBody.defaultProps = { displayUnitLocation: false, locationId: null, + visibleToStaffOnly: false, }; export default SidebarBody; diff --git a/src/course-unit/sidebar/index.jsx b/src/course-unit/sidebar/index.jsx index f2817639b2..a7697c8abd 100644 --- a/src/course-unit/sidebar/index.jsx +++ b/src/course-unit/sidebar/index.jsx @@ -1,102 +1,24 @@ import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; import classNames from 'classnames'; -import { Card, useToggle } from '@openedx/paragon'; -import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { Card } from '@openedx/paragon'; -import ModalNotification from '../../generic/modal-notification'; -import { editCourseUnitVisibilityAndData } from '../data/thunk'; -import { getCourseUnitData } from '../data/selectors'; -import { PUBLISH_TYPES } from '../constants'; -import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; -import useCourseUnitData from './hooks'; -import messages from './messages'; - -const Sidebar = ({ blockId, displayUnitLocation, ...props }) => { - const { - title, - locationId, - releaseLabel, - visibilityState, - visibleToStaffOnly, - } = useCourseUnitData(useSelector(getCourseUnitData)); - const intl = useIntl(); - const dispatch = useDispatch(); - const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); - const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); - - const handleCourseUnitVisibility = () => { - closeVisibleModal(); - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null)); - }; - - const handleCourseUnitDiscardChanges = () => { - closeDiscardModal(); - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges)); - }; - - const handleCourseUnitPublish = () => { - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic)); - }; - - return ( - - - - - - - - ); -}; +const Sidebar = ({ className, children, ...props }) => ( + + {children} + +); Sidebar.propTypes = { - blockId: PropTypes.string, - displayUnitLocation: PropTypes.bool, + className: PropTypes.string, + children: PropTypes.node, }; Sidebar.defaultProps = { - blockId: null, - displayUnitLocation: false, + className: null, + children: null, }; export default Sidebar; diff --git a/src/data/constants.js b/src/data/constants.js index a1b6a906b0..2a630af9a6 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -37,6 +37,7 @@ export const GroupTypes = /** @type {const} */ ({ OPEN: 'open', PUBLIC_MANAGED: 'public_managed', PRIVATE_MANAGED: 'private_managed', + OPEN_MANAGED: 'open_managed', }); export const DivisionSchemes = /** @type {const} */ ({ diff --git a/src/files-and-videos/files-page/FileValidationModal.jsx b/src/files-and-videos/files-page/FileValidationModal.jsx new file mode 100644 index 0000000000..f3a45deeb3 --- /dev/null +++ b/src/files-and-videos/files-page/FileValidationModal.jsx @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Button, + ModalDialog, + useToggle, +} from '@openedx/paragon'; +import { isEmpty } from 'lodash'; + +import messages from './messages'; + +const FileValidationModal = ({ + handleFileOverwrite, + // injected + intl, +}) => { + const [isOpen, open, close] = useToggle(); + + const { duplicateFiles } = useSelector(state => state.assets); + + useEffect(() => { + if (!isEmpty(duplicateFiles)) { + open(); + } + }, [duplicateFiles]); + + return ( + + + + + + + + +
    + {Object.keys(duplicateFiles).map(file =>
  • {file}
  • )} +
+
+ + + + + + + + +
+ ); +}; + +FileValidationModal.propTypes = { + handleFileOverwrite: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(FileValidationModal); diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 16cf5cce0f..6c3ef01b43 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -16,6 +16,7 @@ import { getUsagePaths, resetErrors, updateAssetOrder, + validateAssetFiles, } from './data/thunks'; import messages from './messages'; import FilesPageProvider from './FilesPageProvider'; @@ -30,6 +31,7 @@ import { import { getFileSizeToClosestByte } from '../../utils'; import FileThumbnail from './FileThumbnail'; import FileInfoModalSidebar from './FileInfoModalSidebar'; +import FileValidationModal from './FileValidationModal'; const FilesPage = ({ courseId, @@ -55,9 +57,16 @@ const FilesPage = ({ } = useSelector(state => state.assets); const handleErrorReset = (error) => dispatch(resetErrors(error)); - const handleAddFile = (file) => dispatch(addAssetFile(courseId, file)); const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id)); const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); + const handleAddFile = (files) => { + handleErrorReset({ errorType: 'add' }); + dispatch(validateAssetFiles(courseId, files)); + }; + const handleFileOverwrite = (close, files) => { + Object.values(files).forEach(file => dispatch(addAssetFile(courseId, file, true))); + close(); + }; const handleLockFile = (fileId, locked) => { handleErrorReset({ errorType: 'lock' }); dispatch(updateAssetLock({ courseId, assetId: fileId, locked })); @@ -183,24 +192,27 @@ const FilesPage = ({
{loadingStatus !== RequestStatus.FAILED && ( - + <> + + + )} diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index ec15f993f8..beda688193 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -10,7 +10,7 @@ import userEvent from '@testing-library/user-event'; import ReactDOM from 'react-dom'; import { saveAs } from 'file-saver'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -36,9 +36,12 @@ import { deleteAssetFile, updateAssetLock, getUsagePaths, + validateAssetFiles, } from './data/thunks'; import { getAssetsUrl } from './data/api'; import messages from '../generic/messages'; +import filesPageMessages from './messages'; +import { updateFileValues } from './data/utils'; let axiosMock; let store; @@ -124,12 +127,13 @@ describe('FilesAndUploads', () => { await emptyMockStore(RequestStatus.SUCCESSFUL); const dropzone = screen.getByTestId('files-dropzone'); await act(async () => { + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); axiosMock.onPost(getAssetsUrl(courseId)).reply(204, generateNewAssetApiResponse()); Object.defineProperty(dropzone, 'files', { value: [file], }); fireEvent.drop(dropzone); - await executeThunk(addAssetFile(courseId, file, 0), store.dispatch); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); }); const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); @@ -191,19 +195,106 @@ describe('FilesAndUploads', () => { }); describe('table actions', () => { - it('should upload a single file', async () => { - await mockStore(RequestStatus.SUCCESSFUL); - axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse()); - let addFilesButton; - await waitFor(() => { - addFilesButton = screen.getByLabelText('file-input'); + describe('upload a single file', () => { + it('should upload without duplication modal', async () => { + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); + axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse()); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + const addStatus = store.getState().assets.addingStatus; + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); }); - await act(async () => { - userEvent.upload(addFilesButton, file); - await executeThunk(addAssetFile(courseId, file, 1), store.dispatch); + + it('should show duplicate file modal', async () => { + file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' }); + + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet( + `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`, + ).reply(200, { assets: [{ display_name: 'mOckID6' }] }); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + expect(screen.getByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeVisible(); + }); + + it('should overwrite duplicate file', async () => { + file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' }); + + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet( + `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`, + ).reply(200, { assets: [{ display_name: 'mOckID6' }] }); + const { asset: newDefaultAssetResponse } = generateNewAssetApiResponse(); + const responseData = { + asset: { + ...newDefaultAssetResponse, id: 'mOckID6', + }, + }; + + axiosMock.onPost(getAssetsUrl(courseId)).reply(200, responseData); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + + const overwriteButton = screen.getByText(filesPageMessages.confirmOverwriteButtonLabel.defaultMessage); + await act(async () => { + fireEvent.click(overwriteButton); + }); + + const assetData = store.getState().models.assets.mOckID6; + const { asset: responseAssetData } = responseData; + const [defaultData] = updateFileValues([camelCaseObject(responseAssetData)]); + + expect(screen.queryByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeNull(); + expect(assetData).toEqual(defaultData); + }); + + it('should keep original file', async () => { + file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' }); + + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet( + `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`, + ).reply(200, { assets: [{ display_name: 'mOckID6' }] }); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + + const cancelButton = screen.getByText(filesPageMessages.cancelOverwriteButtonLabel.defaultMessage); + await act(async () => { + fireEvent.click(cancelButton); + }); + + const assetData = store.getState().models.assets.mOckID6; + const defaultAssets = generateFetchAssetApiResponse().assets; + const [defaultData] = updateFileValues([defaultAssets[4]]); + + expect(screen.queryByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeNull(); + expect(assetData).toEqual(defaultData); }); - const addStatus = store.getState().assets.addingStatus; - expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); }); it('should have disabled action buttons', async () => { @@ -503,7 +594,7 @@ describe('FilesAndUploads', () => { it('invalid file size should show error', async () => { const errorMessage = 'File download.png exceeds maximum size of 20 MB.'; await mockStore(RequestStatus.SUCCESSFUL); - + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); axiosMock.onPost(getAssetsUrl(courseId)).reply(413, { error: errorMessage }); const addFilesButton = screen.getByLabelText('file-input'); await act(async () => { @@ -515,8 +606,23 @@ describe('FilesAndUploads', () => { expect(screen.getByText('Error')).toBeVisible(); }); + it('404 validation should show error', async () => { + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(404); + const addFilesButton = screen.getByLabelText('file-input'); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(addAssetFile(courseId, file, 1), store.dispatch); + }); + const addStatus = store.getState().assets.addingStatus; + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + it('404 upload should show error', async () => { await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); axiosMock.onPost(getAssetsUrl(courseId)).reply(404); const addFilesButton = screen.getByLabelText('file-input'); await act(async () => { diff --git a/src/files-and-videos/files-page/data/api.js b/src/files-and-videos/files-page/data/api.js index ded712b836..73140c5f03 100644 --- a/src/files-and-videos/files-page/data/api.js +++ b/src/files-and-videos/files-page/data/api.js @@ -24,6 +24,19 @@ export async function getAssets(courseId, page) { return camelCaseObject(data); } +/** + * Fetches the course custom pages for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function getAssetDetails({ courseId, filenames, fileCount }) { + const params = new URLSearchParams(filenames.map(filename => ['display_name', filename])); + params.append('page_size', fileCount); + const { data } = await getAuthenticatedHttpClient() + .get(`${getAssetsUrl(courseId)}?${params}`); + return camelCaseObject(data); +} + /** * Fetch asset file. * @param {blockId} courseId Course ID for the course to operate on diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index e9bad95817..cb7ad33b7a 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -9,6 +9,7 @@ const slice = createSlice({ initialState: { assetIds: [], loadingStatus: RequestStatus.IN_PROGRESS, + duplicateFiles: [], updatingStatus: '', addingStatus: '', deletingStatus: '', @@ -64,6 +65,9 @@ const slice = createSlice({ addAssetSuccess: (state, { payload }) => { state.assetIds = [payload.assetId, ...state.assetIds]; }, + updateDuplicateFiles: (state, { payload }) => { + state.duplicateFiles = payload.files; + }, updateErrors: (state, { payload }) => { const { error, message } = payload; if (error === 'loading') { @@ -89,6 +93,7 @@ export const { updateErrors, clearErrors, updateEditStatus, + updateDuplicateFiles, } = slice.actions; export const { diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index ded413170c..9b0a623978 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -15,6 +15,7 @@ import { deleteAsset, updateLockStatus, getDownload, + getAssetDetails, } from './api'; import { setAssetIds, @@ -25,11 +26,12 @@ import { updateErrors, clearErrors, updateEditStatus, + updateDuplicateFiles, } from './slice'; -import { updateFileValues } from './utils'; +import { getUploadConflicts, updateFileValues } from './utils'; -export function fetchAddtionalAsstets(courseId, totalCount) { +export function fetchAdditionalAssets(courseId, totalCount) { return async (dispatch) => { let remainingAssetCount = totalCount; let page = 1; @@ -66,7 +68,7 @@ export function fetchAssets(courseId) { assetIds: assets.map(asset => asset.id), })); if (totalCount > 50) { - dispatch(fetchAddtionalAsstets(courseId, totalCount - 50)); + dispatch(fetchAdditionalAssets(courseId, totalCount - 50)); } dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); } catch (error) { @@ -104,7 +106,7 @@ export function deleteAssetFile(courseId, id) { }; } -export function addAssetFile(courseId, file) { +export function addAssetFile(courseId, file, isOverwrite) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); @@ -115,9 +117,11 @@ export function addAssetFile(courseId, file) { modelType: 'assets', model: { ...parsedAssets }, })); - dispatch(addAssetSuccess({ - assetId: asset.id, - })); + if (!isOverwrite) { + dispatch(addAssetSuccess({ + assetId: asset.id, + })); + } dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response && error.response.status === 413) { @@ -131,6 +135,30 @@ export function addAssetFile(courseId, file) { }; } +export function validateAssetFiles(courseId, files) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); + dispatch(updateDuplicateFiles({ files: {} })); + + try { + const filenames = []; + files.forEach(file => filenames.push(file.name)); + await getAssetDetails({ courseId, filenames, fileCount: filenames.length }).then(({ assets }) => { + const [conflicts, newFiles] = getUploadConflicts(files, assets); + if (!isEmpty(newFiles)) { + newFiles.forEach(file => dispatch(addAssetFile(courseId, file))); + } + if (!isEmpty(conflicts)) { + dispatch(updateDuplicateFiles({ files: conflicts })); + } + }); + } catch (error) { + files.forEach(file => dispatch(updateErrors({ error: 'add', message: `Failed to validate ${file.name}.` }))); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); + } + }; +} + export function updateAssetLock({ assetId, courseId, locked }) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS })); diff --git a/src/files-and-videos/files-page/data/utils.js b/src/files-and-videos/files-page/data/utils.js index 993875353d..57d4d7aab2 100644 --- a/src/files-and-videos/files-page/data/utils.js +++ b/src/files-and-videos/files-page/data/utils.js @@ -57,3 +57,17 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { return InsertDriveFile; } }; + +export const getUploadConflicts = (filesToUpload, assets) => { + const filesFound = assets.map(item => item.displayName); + const conflicts = {}; + const newFiles = []; + filesToUpload.forEach(file => { + if (filesFound.includes(file.name)) { + conflicts[file.name] = file; + } else { + newFiles.push(file); + } + }); + return [conflicts, newFiles]; +}; diff --git a/src/files-and-videos/files-page/factories/mockApiResponses.jsx b/src/files-and-videos/files-page/factories/mockApiResponses.jsx index 0ad2f06409..7c6d1bc5cd 100644 --- a/src/files-and-videos/files-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/files-page/factories/mockApiResponses.jsx @@ -22,6 +22,7 @@ export const initialState = { usageMetrics: [], loading: '', }, + duplicateFiles: {}, }, models: { assets: { @@ -101,9 +102,9 @@ export const generateFetchAssetApiResponse = () => ({ displayName: 'mOckID6', locked: false, externalUrl: 'static_tab_1', - portableUrl: 'May 17, 2023 at 22:08 UTC', + portableUrl: '', contentType: 'application/octet-stream', - dateAdded: '', + dateAdded: 'May 17, 2023 at 22:08 UTC', thumbnail: null, }, ], diff --git a/src/files-and-videos/files-page/messages.js b/src/files-and-videos/files-page/messages.js index b06b48d45d..646c4d2c58 100644 --- a/src/files-and-videos/files-page/messages.js +++ b/src/files-and-videos/files-page/messages.js @@ -81,6 +81,26 @@ const messages = defineMessages({ id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.otherCheckbox.label', defaultMessage: 'Other', }, + overwriteConfirmMessage: { + id: 'course-authoring.files-and-videos.overwrite.modal.confirmation-message', + defaultMessage: 'Some of the uploaded files already exist in this course. Do you want to overwrite the following files?', + description: 'The message displayed in the modal shown when uploading files with pre-existing names', + }, + overwriteModalTitle: { + id: 'course-authoring.files-and-videos.overwrite.modal.title', + defaultMessage: 'Overwrite files', + description: 'The title of the modal to confirm overwriting the files', + }, + confirmOverwriteButtonLabel: { + id: 'course-authoring.files-and-videos.overwrite.modal.overwrite-button.label', + defaultMessage: 'Overwrite', + description: 'The message displayed in the button to confirm overwriting the files', + }, + cancelOverwriteButtonLabel: { + id: 'course-authoring.files-and-videos.overwrite.modal.cancel-button.label', + defaultMessage: 'Cancel', + description: 'The message displayed in the button to confirm cancelling the upload', + }, }); export default messages; diff --git a/src/files-and-videos/generic/FileInput.jsx b/src/files-and-videos/generic/FileInput.jsx index 1de18b052a..455853722f 100644 --- a/src/files-and-videos/generic/FileInput.jsx +++ b/src/files-and-videos/generic/FileInput.jsx @@ -12,10 +12,9 @@ export const useFileInput = ({ const addFile = (e) => { const { files } = e.target; setSelectedRows(files); - Object.values(files).forEach(file => { - onAddFile(file); - setAddOpen(); - }); + onAddFile(Object.values(files)); + setAddOpen(); + e.target.value = ''; }; return { click, diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 9907164ef8..60d364c5e9 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -95,14 +95,14 @@ const FileTable = ({ }, [files]); const fileInputControl = useFileInput({ - onAddFile: (file) => handleAddFile(file), + onAddFile: (uploads) => handleAddFile(uploads), setSelectedRows, setAddOpen, }); const handleDropzoneAsset = ({ fileData, handleError }) => { try { const file = fileData.get('file'); - handleAddFile(file); + handleAddFile([file]); } catch (error) { handleError(error); } diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 662577c65c..74cf61d320 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -80,11 +80,14 @@ const VideosPage = ({ const supportedFileFormats = { 'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video }; - const handleAddFile = (file) => dispatch(addVideoFile(courseId, file, videoIds)); + const handleErrorReset = (error) => dispatch(resetErrors(error)); + const handleAddFile = (files) => { + handleErrorReset({ errorType: 'add' }); + files.forEach(file => dispatch(addVideoFile(courseId, file, videoIds))); + }; const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id)); const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); - const handleErrorReset = (error) => dispatch(resetErrors(error)); const handleFileOrder = ({ newFileIdOrder, sortType }) => { dispatch(updateVideoOrder(courseId, newFileIdOrder, sortType)); }; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index becfb9a77a..0a8dde0e9a 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -6,3 +6,4 @@ @import "./create-or-rerun-course/CreateOrRerunCourseForm"; @import "./WysiwygEditor"; @import "./course-stepper/CouseStepper"; +@import "./tag-count/TagCount"; diff --git a/src/index.jsx b/src/index.jsx index 57db4eb380..725cdea0b5 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -112,6 +112,7 @@ initialize({ CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null, ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false', ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true', + ENABLE_OPEN_MANAGED_TEAM_TYPE: process.env.ENABLE_OPEN_MANAGED_TEAM_TYPE === 'true', BBB_LEARN_MORE_URL: process.env.BBB_LEARN_MORE_URL || '', STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null, STUDIO_SHORT_NAME: process.env.STUDIO_SHORT_NAME || null, diff --git a/src/index.scss b/src/index.scss index feedc94249..fc03c9f773 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,7 +19,9 @@ @import "import-page/CourseImportPage"; @import "taxonomy"; @import "files-and-videos"; -@import "content-tags-drawer/TagBubble"; +@import "content-tags-drawer"; @import "course-outline/CourseOutline"; @import "course-unit/CourseUnit"; @import "course-checklist/CourseChecklist"; +@import "content-tags-drawer/ContentTagsDropDownSelector"; +@import "content-tags-drawer/ContentTagsCollapsible";