feat: Taxonomy export menu [FC-0036] (#645)

* feat: System-defined tooltip added
* feat: Taxonomy card menu added. Export menu item added
* feat: Modal for export taxonomy
* feat: Connect with export API
* test: Tests for API and selectors
* feat: Use windows.location.href to call the export endpoint
* test: ExportModal.test added
* style: Delete unnecessary code
* docs: README updated with taxonomy feature
* style: TaxonomyCard updated to a better code style
* style: injectIntl replaced by useIntl on taxonomy pages and components
* refactor: Move and rename taxonomy UI components to match 0002 ADR
* refactor: Move api to data to match with 0002 ADR
* test: Refactor ExportModal tests
* chore: Fix validations
* chore: Lint
* refactor: Moving hooks to apiHooks
* style: Nit on return null

---------

Co-authored-by: Rômulo Penido <romulo@dash.dev.br>
Co-authored-by: Christofer Chavez <christofer@example.com>
This commit is contained in:
Chris Chávez
2023-11-14 13:08:37 -05:00
committed by GitHub
parent 7c7ea1fbc2
commit 1ee80b68ec
24 changed files with 670 additions and 176 deletions

View File

@@ -250,6 +250,25 @@ Requirements
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
Feature: Tagging/Taxonomy Pages
================================
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
Configuration
-------------
In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
Developing
**********

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -18,4 +18,5 @@
@import "course-updates/CourseUpdates";
@import "export-page/CourseExportPage";
@import "import-page/CourseImportPage";
@import "taxonomy/taxonomy-card/TaxonomyCard";
@import "files-and-videos";

View File

@@ -1,79 +0,0 @@
import React from 'react';
import {
Badge,
Card,
} from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import messages from './messages';
import './TaxonomyCard.scss';
const TaxonomyCard = ({ className, original, intl }) => {
const {
id, name, description, systemDefined, orgsCount,
} = original;
const orgsCountEnabled = () => orgsCount !== undefined && orgsCount !== 0;
const getHeaderSubtitle = () => {
if (systemDefined) {
return (
<Badge variant="light">
{intl.formatMessage(messages.systemDefinedBadge)}
</Badge>
);
}
if (orgsCountEnabled()) {
return (
<div className="font-italic">
{intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })}
</div>
);
}
return undefined;
};
const getHeaderActions = () => (
// Menu button
// TODO Add functionality to this menu
undefined
);
return (
<Card className={classNames('taxonomy-card', className)} data-testid={`taxonomy-card-${id}`}>
<Card.Header
title={name}
subtitle={getHeaderSubtitle()}
actions={getHeaderActions()}
/>
<Card.Body className={classNames('taxonomy-card-body', {
'taxonomy-card-body-overflow-m': !systemDefined && !orgsCountEnabled(),
'taxonomy-card-body-overflow-sm': systemDefined || orgsCountEnabled(),
})}
>
<Card.Section>
{description}
</Card.Section>
</Card.Body>
</Card>
);
};
TaxonomyCard.defaultProps = {
className: '',
};
TaxonomyCard.propTypes = {
className: PropTypes.string,
original: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
description: PropTypes.string,
systemDefined: PropTypes.bool,
orgsCount: PropTypes.number,
}).isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(TaxonomyCard);

View File

@@ -5,15 +5,16 @@ import {
DataTable,
Spinner,
} from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import Header from '../header';
import SubHeader from '../generic/sub-header/SubHeader';
import messages from './messages';
import TaxonomyCard from './TaxonomyCard';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors';
import TaxonomyCard from './taxonomy-card';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
const TaxonomyListPage = ({ intl }) => {
const TaxonomyListPage = () => {
const intl = useIntl();
const useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse();
const isLoaded = useIsTaxonomyListDataLoaded();
@@ -97,8 +98,6 @@ const TaxonomyListPage = ({ intl }) => {
);
};
TaxonomyListPage.propTypes = {
intl: intlShape.isRequired,
};
TaxonomyListPage.propTypes = {};
export default injectIntl(TaxonomyListPage);
export default TaxonomyListPage;

View File

@@ -7,11 +7,11 @@ import { act, render } from '@testing-library/react';
import initializeStore from '../store';
import TaxonomyListPage from './TaxonomyListPage';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
let store;
jest.mock('./api/hooks/selectors', () => ({
jest.mock('./data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
}));

View File

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

View File

@@ -0,0 +1,50 @@
module.exports = {
next: null,
previous: null,
count: 4,
numPages: 1,
currentPage: 1,
start: 0,
results: [
{
id: -2,
name: 'Content Authors',
description: 'Allows tags for any user ID created on the instance.',
enabled: true,
allowMultiple: false,
allowFreeText: false,
systemDefined: true,
visibleToAuthors: false,
},
{
id: -1,
name: 'Languages',
description: 'lang lang lang lang lang lang lang lang',
enabled: true,
allowMultiple: false,
allowFreeText: false,
systemDefined: true,
visibleToAuthors: true,
},
{
id: 1,
name: 'Taxonomy',
description: 'This is a Description',
enabled: true,
allowMultiple: false,
allowFreeText: false,
systemDefined: false,
visibleToAuthors: true,
},
{
id: 2,
name: 'Taxonomy long long long long long long long long long long long long long long long long long long long',
description: 'This is a Description long lon',
enabled: true,
allowMultiple: false,
allowFreeText: false,
systemDefined: false,
visibleToAuthors: true,
},
],
};

View File

@@ -1,20 +0,0 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getTaxonomyListApiUrl = new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href;
/**
* @returns {import("../types.mjs").UseQueryResult}
*/
const useTaxonomyListData = () => (
useQuery({
queryKey: ['taxonomyList'],
queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl)
.then(camelCaseObject),
})
);
export default useTaxonomyListData;

View File

@@ -1,25 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import useTaxonomyListData from './api';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
describe('taxonomy API: useTaxonomyListData', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should call useQuery with the correct parameters', () => {
useTaxonomyListData();
expect(useQuery).toHaveBeenCalledWith({
queryKey: ['taxonomyList'],
queryFn: expect.any(Function),
});
});
});

View File

@@ -1,20 +0,0 @@
// @ts-check
import useTaxonomyListData from './api';
/**
* @returns {import("../types.mjs").TaxonomyListData | undefined}
*/
export const useTaxonomyListDataResponse = () => {
const response = useTaxonomyListData();
if (response.status === 'success') {
return response.data.data;
}
return undefined;
};
/**
* @returns {boolean}
*/
export const useIsTaxonomyListDataLoaded = () => (
useTaxonomyListData().status === 'success'
);

29
src/taxonomy/data/api.js Normal file
View File

@@ -0,0 +1,29 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href;
export const getExportTaxonomyApiUrl = (pk, format) => new URL(
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
getApiBaseUrl(),
).href;
/**
* Get list of taxonomies.
* @returns {Promise<Object>}
*/
export async function getTaxonomyListData() {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl());
return camelCaseObject(data);
}
/**
* Downloads the file of the exported taxonomy
* @param {number} pk
* @param {string} format
* @returns {void}
*/
export function getTaxonomyExportFile(pk, format) {
window.location.href = getExportTaxonomyApiUrl(pk, format);
}

View File

@@ -0,0 +1,61 @@
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { taxonomyListMock } from '../__mocks__';
import {
getTaxonomyListApiUrl,
getExportTaxonomyApiUrl,
getTaxonomyListData,
getTaxonomyExportFile,
} from './api';
let axiosMock;
describe('taxonomy api calls', () => {
const { location } = window;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
beforeAll(() => {
delete window.location;
window.location = {
href: '',
};
});
afterAll(() => {
window.location = location;
});
it('should get taxonomy list data', async () => {
axiosMock.onGet(getTaxonomyListApiUrl()).reply(200, taxonomyListMock);
const result = await getTaxonomyListData();
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl());
expect(result).toEqual(taxonomyListMock);
});
it('should set window.location.href correctly', () => {
const pk = 1;
const format = 'json';
getTaxonomyExportFile(pk, format);
expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format));
});
});

View File

@@ -0,0 +1,46 @@
// @ts-check
/**
* This is a file used especially in this `taxonomy` module.
*
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
* This approach accelerates the development.
*
* In this file you will find two types of hooks:
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
* Ex. useTaxonomyListData.
* - Hooks that calls the query hook, prepare and return the data.
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
*/
import { useQuery } from '@tanstack/react-query';
import { getTaxonomyListData } from './api';
/**
* Builds the query yo get the taxonomy list
* @returns {import("./types.mjs").UseQueryResult}
*/
const useTaxonomyListData = () => (
useQuery({
queryKey: ['taxonomyList'],
queryFn: getTaxonomyListData,
})
);
/**
* Gets the taxonomy list data
* @returns {import("./types.mjs").TaxonomyListData | undefined}
*/
export const useTaxonomyListDataResponse = () => {
const response = useTaxonomyListData();
if (response.status === 'success') {
return response.data;
}
return undefined;
};
/**
* Returns the status of the taxonomy list query
* @returns {boolean}
*/
export const useIsTaxonomyListDataLoaded = () => (
useTaxonomyListData().status === 'success'
);

View File

@@ -1,22 +1,24 @@
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './selectors';
import useTaxonomyListData from './api';
import { useQuery } from '@tanstack/react-query';
import {
useTaxonomyListDataResponse,
useIsTaxonomyListDataLoaded,
} from './apiHooks';
jest.mock('./api', () => ({
__esModule: true,
default: jest.fn(),
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
describe('useTaxonomyListDataResponse', () => {
it('should return data when status is success', () => {
useTaxonomyListData.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
const result = useTaxonomyListDataResponse();
expect(result).toEqual('data');
expect(result).toEqual({ data: 'data' });
});
it('should return undefined when status is not success', () => {
useTaxonomyListData.mockReturnValueOnce({ status: 'error' });
useQuery.mockReturnValueOnce({ status: 'error' });
const result = useTaxonomyListDataResponse();
@@ -26,7 +28,7 @@ describe('useTaxonomyListDataResponse', () => {
describe('useIsTaxonomyListDataLoaded', () => {
it('should return true when status is success', () => {
useTaxonomyListData.mockReturnValueOnce({ status: 'success' });
useQuery.mockReturnValueOnce({ status: 'success' });
const result = useIsTaxonomyListDataLoaded();
@@ -34,7 +36,7 @@ describe('useIsTaxonomyListDataLoaded', () => {
});
it('should return false when status is not success', () => {
useTaxonomyListData.mockReturnValueOnce({ status: 'error' });
useQuery.mockReturnValueOnce({ status: 'error' });
const result = useIsTaxonomyListDataLoaded();

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import {
ActionRow,
Button,
Form,
ModalDialog,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { getTaxonomyExportFile } from '../data/api';
const ExportModal = ({
taxonomyId,
isOpen,
onClose,
}) => {
const intl = useIntl();
const [outputFormat, setOutputFormat] = useState('csv');
const onClickExport = () => {
onClose();
getTaxonomyExportFile(taxonomyId, outputFormat);
};
return (
<ModalDialog
title={intl.formatMessage(messages.exportModalTitle)}
isOpen={isOpen}
onClose={onClose}
size="lg"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.exportModalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="pb-5 mt-2">
<Form.Group>
<Form.Label>
{intl.formatMessage(messages.exportModalBodyDescription)}
</Form.Label>
<Form.RadioSet
name="export-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value)}
>
<Form.Radio
key={`export-csv-format-${taxonomyId}`}
value="csv"
>
{intl.formatMessage(messages.taxonomyCSVFormat)}
</Form.Radio>
<Form.Radio
key={`export-json-format-${taxonomyId}`}
value="json"
>
{intl.formatMessage(messages.taxonomyJSONFormat)}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.taxonomyModalsCancelLabel)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={onClickExport}>
{intl.formatMessage(messages.exportModalSubmitButtonLabel)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
ExportModal.propTypes = {
taxonomyId: PropTypes.number.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ExportModal;

View File

@@ -0,0 +1,30 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
exportModalTitle: {
id: 'course-authoring.taxonomy-list.modal.export.title',
defaultMessage: 'Select format to export',
},
exportModalBodyDescription: {
id: 'course-authoring.taxonomy-list.modal.export.body',
defaultMessage: 'Select the file format in which you would like the taxonomy to be exported:',
},
exportModalSubmitButtonLabel: {
id: 'course-authoring.taxonomy-list.modal.export.submit.label',
defaultMessage: 'Export',
},
taxonomyCSVFormat: {
id: 'course-authoring.taxonomy-list.csv-format',
defaultMessage: 'CSV file',
},
taxonomyJSONFormat: {
id: 'course-authoring.taxonomy-list.json-format',
defaultMessage: 'JSON file',
},
taxonomyModalsCancelLabel: {
id: 'course-authoring.taxonomy-list.modal.cancel',
defaultMessage: 'Cancel',
},
});
export default messages;

View File

@@ -17,14 +17,6 @@ const messages = defineMessages({
id: 'course-authoring.taxonomy-list.select.org.default',
defaultMessage: 'All taxonomies',
},
systemDefinedBadge: {
id: 'course-authoring.taxonomy-list.badge.system-defined.label',
defaultMessage: 'System-level',
},
assignedToOrgsLabel: {
id: 'course-authoring.taxonomy-list.orgs-count.label',
defaultMessage: 'Assigned to {orgsCount} orgs',
},
usageLoadingMessage: {
id: 'course-authoring.taxonomy-list.spinner.loading',
defaultMessage: 'Loading',

View File

@@ -29,4 +29,21 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.taxonomy-menu-item:focus {
/**
* There is a bug in the menu that auto focus the first item.
* We convert the focus style to a normal style.
*/
background-color: white !important;
font-weight: normal !important;
}
.taxonomy-menu-item:focus:hover {
/**
* Check the previous block about the focus.
* This enable a normal hover to focused items.
*/
background-color: $light-500 !important;
}
}

View File

@@ -2,20 +2,26 @@ import React from 'react';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import PropTypes from 'prop-types';
import initializeStore from '../store';
import TaxonomyCard from './TaxonomyCard';
import initializeStore from '../../store';
import { getTaxonomyExportFile } from '../data/api';
import TaxonomyCard from '.';
let store;
const taxonomyId = 1;
const data = {
id: taxonomyId,
name: 'Taxonomy 1',
description: 'This is a description',
};
jest.mock('../data/api', () => ({
getTaxonomyExportFile: jest.fn(),
}));
const TaxonomyCardComponent = ({ original }) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
@@ -81,4 +87,59 @@ describe('<TaxonomyCard />', async () => {
const { getByText } = render(<TaxonomyCardComponent original={cardData} />);
expect(getByText('Assigned to 6 orgs')).toBeInTheDocument();
});
test('should open and close menu on button click', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
// Menu closed
expect(() => getByTestId('taxonomy-card-menu-1')).toThrow();
// Click on the menu button to open
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
// Menu opened
expect(getByTestId('taxonomy-card-menu-1')).toBeInTheDocument();
// Click on any element to close the menu
fireEvent.click(getByText('Export'));
// Menu closed
expect(() => getByTestId('taxonomy-card-menu-1')).toThrow();
});
test('should open export modal on export menu click', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
// Click on export menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByText('Export'));
// Modal opened
expect(getByText('Select format to export')).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
});
test('should export a taxonomy', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
// Click on export menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByText('Export'));
// Select JSON format and click on export
fireEvent.click(getByText('JSON file'));
fireEvent.click(getByText('Export'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json');
});
});

View File

@@ -0,0 +1,59 @@
import React, { useState } from 'react';
import {
IconButton,
ModalPopup,
Menu,
Icon,
MenuItem,
} from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const TaxonomyCardMenu = ({
id, name, onClickMenuItem,
}) => {
const intl = useIntl();
const [menuIsOpen, setMenuIsOpen] = useState(false);
const [menuTarget, setMenuTarget] = useState(null);
const onClickItem = (menuName) => {
setMenuIsOpen(false);
onClickMenuItem(menuName);
};
return (
<>
<IconButton
variant="primary"
onClick={() => setMenuIsOpen(true)}
ref={setMenuTarget}
src={MoreVert}
iconAs={Icon}
alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })}
data-testid={`taxonomy-card-menu-button-${id}`}
/>
<ModalPopup
positionRef={menuTarget}
isOpen={menuIsOpen}
onClose={() => setMenuIsOpen(false)}
>
<Menu data-testid={`taxonomy-card-menu-${id}`}>
{/* Add more menu items here */}
<MenuItem className="taxonomy-menu-item" onClick={() => onClickItem('export')}>
{intl.formatMessage(messages.taxonomyCardExportMenu)}
</MenuItem>
</Menu>
</ModalPopup>
</>
);
};
TaxonomyCardMenu.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
onClickMenuItem: PropTypes.func.isRequired,
};
export default TaxonomyCardMenu;

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import {
Badge,
Card,
OverlayTrigger,
Popover,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import TaxonomyCardMenu from './TaxonomyCardMenu';
import ExportModal from '../export-modal';
const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0;
const HeaderSubtitle = ({
id, showSystemBadge, orgsCount,
}) => {
const intl = useIntl();
const getSystemToolTip = () => (
<Popover id={`system-defined-tooltip-${id}`}>
<Popover.Title as="h5">
{intl.formatMessage(messages.systemTaxonomyPopoverTitle)}
</Popover.Title>
<Popover.Content>
{intl.formatMessage(messages.systemTaxonomyPopoverBody)}
</Popover.Content>
</Popover>
);
// Show system defined badge
if (showSystemBadge) {
return (
<OverlayTrigger
key={`system-defined-overlay-${id}`}
placement="top"
overlay={getSystemToolTip()}
>
<Badge variant="light">
{intl.formatMessage(messages.systemDefinedBadge)}
</Badge>
</OverlayTrigger>
);
}
// Or show orgs count
if (orgsCountEnabled(orgsCount)) {
return (
<div className="font-italic">
{intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })}
</div>
);
}
// Or none
return null;
};
HeaderSubtitle.propTypes = {
id: PropTypes.number.isRequired,
showSystemBadge: PropTypes.bool.isRequired,
orgsCount: PropTypes.number.isRequired,
};
const TaxonomyCard = ({ className, original }) => {
const {
id, name, description, systemDefined, orgsCount,
} = original;
const intl = useIntl();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// Add here more menu item actions
const menuItemActions = {
export: () => setIsExportModalOpen(true),
};
const onClickMenuItem = (menuName) => (
menuItemActions[menuName]?.()
);
const getHeaderActions = () => {
if (systemDefined) {
// We don't show the export menu, because the system-taxonomies
// can't be exported. The API returns and error.
// The entire menu has been hidden because currently only
// the export menu exists.
//
// TODO When adding more menus, change this logic to hide only the export menu.
return undefined;
}
return (
<TaxonomyCardMenu
id={id}
name={name}
onClickMenuItem={onClickMenuItem}
/>
);
};
const renderExportModal = () => isExportModalOpen && (
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
taxonomyId={id}
/>
);
return (
<>
<Card className={classNames('taxonomy-card', className)} data-testid={`taxonomy-card-${id}`}>
<Card.Header
title={name}
subtitle={(
<HeaderSubtitle
id={id}
showSystemBadge={systemDefined}
orgsCount={orgsCount}
intl={intl}
/>
)}
actions={getHeaderActions()}
/>
<Card.Body className={classNames('taxonomy-card-body', {
'taxonomy-card-body-overflow-m': !systemDefined && !orgsCountEnabled(orgsCount),
'taxonomy-card-body-overflow-sm': systemDefined || orgsCountEnabled(orgsCount),
})}
>
<Card.Section>
{description}
</Card.Section>
</Card.Body>
</Card>
{renderExportModal()}
</>
);
};
TaxonomyCard.defaultProps = {
className: '',
};
TaxonomyCard.propTypes = {
className: PropTypes.string,
original: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
description: PropTypes.string,
systemDefined: PropTypes.bool,
orgsCount: PropTypes.number,
}).isRequired,
};
export default TaxonomyCard;

View File

@@ -0,0 +1,30 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
systemTaxonomyPopoverTitle: {
id: 'course-authoring.taxonomy-list.popover.system-defined.title',
defaultMessage: 'System taxonomy',
},
systemTaxonomyPopoverBody: {
id: 'course-authoring.taxonomy-list.popover.system-defined.body',
defaultMessage: 'This is a system-level taxonomy and is enabled by default.',
},
systemDefinedBadge: {
id: 'course-authoring.taxonomy-list.badge.system-defined.label',
defaultMessage: 'System-level',
},
assignedToOrgsLabel: {
id: 'course-authoring.taxonomy-list.orgs-count.label',
defaultMessage: 'Assigned to {orgsCount} orgs',
},
taxonomyCardExportMenu: {
id: 'course-authoring.taxonomy-list.menu.export.label',
defaultMessage: 'Export',
},
taxonomyMenuAlt: {
id: 'course-authoring.taxonomy-list.menu.alt',
defaultMessage: '{name} menu',
},
});
export default messages;