diff --git a/src/taxonomy/__mocks__/taxonomyListMock.js b/src/taxonomy/__mocks__/taxonomyListMock.js index 5eab0af023..9ea13d6ab0 100644 --- a/src/taxonomy/__mocks__/taxonomyListMock.js +++ b/src/taxonomy/__mocks__/taxonomyListMock.js @@ -15,6 +15,12 @@ module.exports = { allowFreeText: false, systemDefined: true, visibleToAuthors: false, + userPermissions: { + canAdd: true, + canView: true, + canChange: false, + canDelete: false, + }, }, { id: -1, @@ -25,6 +31,12 @@ module.exports = { allowFreeText: false, systemDefined: true, visibleToAuthors: true, + userPermissions: { + canAdd: true, + canView: true, + canChange: false, + canDelete: false, + }, }, { id: 1, @@ -35,6 +47,12 @@ module.exports = { allowFreeText: false, systemDefined: false, visibleToAuthors: true, + userPermissions: { + canAdd: true, + canView: true, + canChange: true, + canDelete: true, + }, }, { id: 2, @@ -45,6 +63,12 @@ module.exports = { allowFreeText: false, systemDefined: false, visibleToAuthors: true, + userPermissions: { + canAdd: true, + canView: true, + canChange: true, + canDelete: true, + }, }, ], }; diff --git a/src/taxonomy/data/types.mjs b/src/taxonomy/data/types.mjs index b892592a5e..6348470f0e 100644 --- a/src/taxonomy/data/types.mjs +++ b/src/taxonomy/data/types.mjs @@ -1,5 +1,13 @@ // @ts-check +/** + * @typedef {Object} UserPermissions + * @property {boolean} canAdd + * @property {boolean} canView + * @property {boolean} canChange + * @property {boolean} canDelete + **/ + /** * @typedef {Object} TaxonomyData * @property {number} id @@ -12,6 +20,7 @@ * @property {boolean} visibleToAuthors * @property {number} tagsCount * @property {string[]} orgs + * @property {UserPermissions} userPermissions */ /** diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 6cbbf4c630..33d6ed8aac 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -3,7 +3,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import PropTypes from 'prop-types'; import initializeStore from '../../store'; @@ -16,6 +16,12 @@ const data = { id: taxonomyId, name: 'Taxonomy 1', description: 'This is a description', + userPermissions: { + canAdd: true, + canView: true, + canChange: true, + canDelete: true, + }, }; const queryClient = new QueryClient(); @@ -40,6 +46,12 @@ TaxonomyCardComponent.propTypes = { systemDefined: PropTypes.bool, orgsCount: PropTypes.number, onDeleteTaxonomy: PropTypes.func, + userPermissions: PropTypes.shape({ + canAdd: PropTypes.bool, + canView: PropTypes.bool, + canChange: PropTypes.bool, + canDelete: PropTypes.bool, + }), }).isRequired, }; @@ -62,6 +74,33 @@ describe('', async () => { expect(getByText(data.description)).toBeInTheDocument(); }); + it('should show the ⋮ menu', () => { + const { getByTestId, queryByTestId } = render(); + + // Menu closed/doesn't exist yet + expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument(); + + // Click on the menu button to open + fireEvent.click(getByTestId('taxonomy-menu-button')); + + // Menu opened + expect(getByTestId('taxonomy-menu')).toBeVisible(); + expect(getByTestId('taxonomy-menu-import')).toBeVisible(); + expect(getByTestId('taxonomy-menu-export')).toBeVisible(); + expect(getByTestId('taxonomy-menu-delete')).toBeVisible(); + + // Click on button again to close the menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + + // Menu closed + // Jest bug: toBeVisible() isn't checking opacity correctly + // expect(getByTestId('taxonomy-menu')).not.toBeVisible(); + expect(getByTestId('taxonomy-menu').style.opacity).toEqual('0'); + + // Menu button still visible + expect(getByTestId('taxonomy-menu-button')).toBeVisible(); + }); + it('not show the system-defined badge with normal taxonomies', () => { const { queryByText } = render(); expect(queryByText('System-level')).not.toBeInTheDocument(); diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index 1449730ae5..3d6ac2b2c6 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -149,6 +149,12 @@ TaxonomyCard.propTypes = { systemDefined: PropTypes.bool, orgsCount: PropTypes.number, tagsCount: PropTypes.number, + userPermissions: PropTypes.shape({ + canAdd: PropTypes.bool.isRequired, + canView: PropTypes.bool.isRequired, + canChange: PropTypes.bool.isRequired, + canDelete: PropTypes.bool.isRequired, + }).isRequired, }).isRequired, }; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx index 1f245356b6..2c9bc0913f 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { initializeMockApp } from '@edx/frontend-platform'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import { useTaxonomyDetailData } from './data/api'; import initializeStore from '../../store'; @@ -89,12 +89,62 @@ describe('', async () => { name: 'Test taxonomy', description: 'This is a description', systemDefined: true, + userPreferences: { + canAdd: true, + canView: true, + canChange: true, + canDelete: true, + }, }, }); const { getByRole } = render(); expect(getByRole('heading')).toHaveTextContent('Test taxonomy'); }); + it('should show the Action menu with options', () => { + useTaxonomyDetailData.mockReturnValue({ + isSuccess: true, + isFetched: true, + isError: false, + data: { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + systemDefined: true, + userPreferences: { + canAdd: true, + canView: true, + canChange: true, + canDelete: true, + }, + }, + }); + const { getByTestId, queryByTestId } = render(); + + // Menu closed/doesn't exist yet + expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument(); + + // Click on the menu button to open + fireEvent.click(getByTestId('taxonomy-menu-button')); + + // Menu opened + expect(getByTestId('taxonomy-menu')).toBeVisible(); + expect(getByTestId('taxonomy-menu-import')).toBeVisible(); + expect(getByTestId('taxonomy-menu-export')).toBeVisible(); + expect(getByTestId('taxonomy-menu-delete')).toBeVisible(); + + // Click on button again to close the menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + + // Menu closed + // Jest bug: toBeVisible() isn't checking opacity correctly + // expect(getByTestId('taxonomy-menu')).not.toBeVisible(); + expect(getByTestId('taxonomy-menu').style.opacity).toEqual('0'); + + // Menu button still visible + expect(getByTestId('taxonomy-menu-button')).toBeVisible(); + }); + it('should show system defined badge', async () => { useTaxonomyDetailData.mockReturnValue({ isSuccess: true, @@ -105,6 +155,7 @@ describe('', async () => { name: 'Test taxonomy', description: 'This is a description', systemDefined: true, + userPreferences: {}, }, }); const { getByText } = render(); @@ -121,6 +172,7 @@ describe('', async () => { name: 'Test taxonomy', description: 'This is a description', systemDefined: false, + userPreferences: {}, }, }); const { queryByText } = render(); diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 0db273cf46..27442b736a 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -9,7 +9,7 @@ import { IconButton, } from '@edx/paragon'; import { MoreVert } from '@edx/paragon/icons'; -import { omitBy } from 'lodash'; +import { pickBy } from 'lodash'; import PropTypes from 'prop-types'; import { useNavigate } from 'react-router-dom'; @@ -50,7 +50,7 @@ const TaxonomyMenu = ({ * @typedef {Object} MenuItem * @property {string} title - The title of the menu item * @property {() => void} action - The action to perform when the menu item is clicked - * @property {boolean} [hide] - Whether or not to hide the menu item + * @property {boolean} [show] - Whether or not to show the menu item * * @constant * @type {Record} @@ -59,23 +59,22 @@ const TaxonomyMenu = ({ import: { title: intl.formatMessage(messages.importMenu), action: () => importTaxonomyTags(taxonomy.id, intl), - // Hide import menu item if taxonomy is system defined or allows free text - hide: taxonomy.systemDefined || taxonomy.allowFreeText, + show: taxonomy.userPermissions.canChange, }, export: { title: intl.formatMessage(messages.exportMenu), action: exportModalOpen, + show: taxonomy.userPermissions.canView, }, delete: { title: intl.formatMessage(messages.deleteMenu), action: deleteDialogOpen, - // Hide delete menu item if taxonomy is system defined - hide: taxonomy.systemDefined, + show: taxonomy.userPermissions.canDelete, }, }; // Remove hidden menu items - menuItems = omitBy(menuItems, (value) => value.hide); + menuItems = pickBy(menuItems, (value) => value.show); const renderModals = () => ( <> @@ -136,9 +135,13 @@ TaxonomyMenu.propTypes = { taxonomy: PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, - systemDefined: PropTypes.bool.isRequired, - allowFreeText: PropTypes.bool.isRequired, tagsCount: PropTypes.number.isRequired, + userPermissions: PropTypes.shape({ + canAdd: PropTypes.bool.isRequired, + canView: PropTypes.bool.isRequired, + canChange: PropTypes.bool.isRequired, + canDelete: PropTypes.bool.isRequired, + }).isRequired, }).isRequired, iconMenu: PropTypes.bool, }; diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx index c8f398aa94..03ac3b73c1 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -32,9 +32,11 @@ const queryClient = new QueryClient(); const mockSetToastMessage = jest.fn(); const TaxonomyMenuComponent = ({ - systemDefined, - allowFreeText, iconMenu, + canAdd, + canView, + canChange, + canDelete, }) => { const context = useMemo(() => ({ toastMessage: null, @@ -50,9 +52,10 @@ const TaxonomyMenuComponent = ({ taxonomy={{ id: taxonomyId, name: taxonomyName, - systemDefined, - allowFreeText, tagsCount: 0, + userPermissions: { + canAdd, canView, canChange, canDelete, + }, }} iconMenu={iconMenu} /> @@ -65,13 +68,17 @@ const TaxonomyMenuComponent = ({ TaxonomyMenuComponent.propTypes = { iconMenu: PropTypes.bool.isRequired, - systemDefined: PropTypes.bool, - allowFreeText: PropTypes.bool, + canAdd: PropTypes.bool, + canView: PropTypes.bool, + canChange: PropTypes.bool, + canDelete: PropTypes.bool, }; TaxonomyMenuComponent.defaultProps = { - systemDefined: false, - allowFreeText: false, + canAdd: true, + canView: true, + canChange: true, + canDelete: true, }; each([true, false]).describe('', async (iconMenu) => { @@ -115,36 +122,36 @@ each([true, false]).describe('', async (iconMenu) => expect(getByTestId('taxonomy-menu-button')).toBeVisible(); }); - test('doesnt show systemDefined taxonomies disabled menus', () => { - const { getByTestId, queryByTestId } = render(); - - // Menu closed/doesn't exist yet - expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument(); + test('Shows menu actions that user is permitted', () => { + const { getByTestId, queryByTestId } = render(); // Click on the menu button to open fireEvent.click(getByTestId('taxonomy-menu-button')); - // Menu opened - expect(getByTestId('taxonomy-menu')).toBeVisible(); - - // Check that the import menu is not show - expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument(); + // Ensure that the menu items are present + expect(queryByTestId('taxonomy-menu-export')).toBeInTheDocument(); + expect(queryByTestId('taxonomy-menu-import')).toBeInTheDocument(); + expect(queryByTestId('taxonomy-menu-delete')).toBeInTheDocument(); }); - test('doesnt show freeText taxonomies disabled menus', () => { - const { getByTestId, queryByTestId } = render(); - - // Menu closed/doesn't exist yet - expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument(); + test('Hides menu actions that user is not permitted', () => { + const { getByTestId, queryByTestId } = render( + , + ); // Click on the menu button to open fireEvent.click(getByTestId('taxonomy-menu-button')); - // Menu opened - expect(getByTestId('taxonomy-menu')).toBeVisible(); - - // Check that the import menu is not show + // Ensure no menu items are found + expect(queryByTestId('taxonomy-menu-export')).not.toBeInTheDocument(); expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument(); + expect(queryByTestId('taxonomy-menu-delete')).not.toBeInTheDocument(); }); test('should open export modal on export menu click', () => {