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:
19
README.rst
19
README.rst
@@ -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
|
||||
**********
|
||||
|
||||
BIN
docs/readme-images/feature-tagging-taxonomy-pages.png
Normal file
BIN
docs/readme-images/feature-tagging-taxonomy-pages.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
2
src/taxonomy/__mocks__/index.js
Normal file
2
src/taxonomy/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as taxonomyListMock } from './taxonomyListMock';
|
||||
50
src/taxonomy/__mocks__/taxonomyListMock.js
Normal file
50
src/taxonomy/__mocks__/taxonomyListMock.js
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
29
src/taxonomy/data/api.js
Normal 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);
|
||||
}
|
||||
61
src/taxonomy/data/api.test.js
Normal file
61
src/taxonomy/data/api.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
46
src/taxonomy/data/apiHooks.jsx
Normal file
46
src/taxonomy/data/apiHooks.jsx
Normal 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'
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
85
src/taxonomy/export-modal/index.jsx
Normal file
85
src/taxonomy/export-modal/index.jsx
Normal 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;
|
||||
30
src/taxonomy/export-modal/messages.js
Normal file
30
src/taxonomy/export-modal/messages.js
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
59
src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx
Normal file
59
src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx
Normal 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;
|
||||
155
src/taxonomy/taxonomy-card/index.jsx
Normal file
155
src/taxonomy/taxonomy-card/index.jsx
Normal 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;
|
||||
30
src/taxonomy/taxonomy-card/messages.js
Normal file
30
src/taxonomy/taxonomy-card/messages.js
Normal 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;
|
||||
Reference in New Issue
Block a user