From 4963124b33f8f4f2bf321dce630d9ffa97656164 Mon Sep 17 00:00:00 2001 From: Christofer Date: Tue, 17 Oct 2023 16:46:22 +0000 Subject: [PATCH 01/18] feat: System-defined tooltip added --- src/taxonomy/TaxonomyCard.jsx | 25 ++++++++++++++++++++++--- src/taxonomy/messages.js | 8 ++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/taxonomy/TaxonomyCard.jsx b/src/taxonomy/TaxonomyCard.jsx index 203782365d..53ac777c52 100644 --- a/src/taxonomy/TaxonomyCard.jsx +++ b/src/taxonomy/TaxonomyCard.jsx @@ -2,6 +2,8 @@ import React from 'react'; import { Badge, Card, + OverlayTrigger, + Popover, } from '@edx/paragon'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; @@ -16,12 +18,29 @@ const TaxonomyCard = ({ className, original, intl }) => { const orgsCountEnabled = () => orgsCount !== undefined && orgsCount !== 0; + const getSystemBadgeToolTip = () => ( + + + {intl.formatMessage(messages.systemTaxonomyPopoverTitle)} + + + {intl.formatMessage(messages.systemTaxonomyPopoverBody)} + + + ); + const getHeaderSubtitle = () => { if (systemDefined) { return ( - - {intl.formatMessage(messages.systemDefinedBadge)} - + + + {intl.formatMessage(messages.systemDefinedBadge)} + + ); } if (orgsCountEnabled()) { diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index a15d762838..c92cdc788f 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -29,6 +29,14 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.spinner.loading', defaultMessage: 'Loading', }, + 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.', + }, }); export default messages; From 40773976622376cbad3244f7ebba2617e4711eb1 Mon Sep 17 00:00:00 2001 From: Christofer Date: Wed, 18 Oct 2023 13:48:39 +0000 Subject: [PATCH 02/18] feat: Taxonomy card menu added. Export menu item added --- src/index.scss | 1 + src/taxonomy/TaxonomyCard.jsx | 41 ++++++++++++++++++++++++++---- src/taxonomy/TaxonomyCard.scss | 17 +++++++++++++ src/taxonomy/TaxonomyCard.test.jsx | 22 +++++++++++++++- src/taxonomy/messages.js | 8 ++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/index.scss b/src/index.scss index f35f140870..dcbbeaab97 100755 --- a/src/index.scss +++ b/src/index.scss @@ -20,3 +20,4 @@ @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; @import "files-and-uploads/table-components/GalleryCard"; +@import "taxonomy/TaxonomyCard.scss"; diff --git a/src/taxonomy/TaxonomyCard.jsx b/src/taxonomy/TaxonomyCard.jsx index 53ac777c52..c4d4c25d5a 100644 --- a/src/taxonomy/TaxonomyCard.jsx +++ b/src/taxonomy/TaxonomyCard.jsx @@ -1,21 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Badge, + IconButton, Card, OverlayTrigger, Popover, + ModalPopup, + Menu, + Icon, + MenuItem, } from '@edx/paragon'; +import { MoreVert } from '@edx/paragon/icons'; 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 [menuIsOpen, setMenuIsOpen] = useState(false); + const [menuTarget, setMenuTarget] = useState(null); + const orgsCountEnabled = () => orgsCount !== undefined && orgsCount !== 0; const getSystemBadgeToolTip = () => ( @@ -53,10 +61,33 @@ const TaxonomyCard = ({ className, original, intl }) => { return undefined; }; + const onClickExport = () => { + setMenuIsOpen(false); + }; + const getHeaderActions = () => ( - // Menu button - // TODO Add functionality to this menu - undefined + <> + setMenuIsOpen(true)} + ref={setMenuTarget} + src={MoreVert} + iconAs={Icon} + alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })} + data-testid={`taxonomy-card-menu-button-${id}`} + /> + setMenuIsOpen(false)} + > + + + {intl.formatMessage(messages.taxonomyCardExportMenu)} + + + + ); return ( diff --git a/src/taxonomy/TaxonomyCard.scss b/src/taxonomy/TaxonomyCard.scss index 35e198abe8..b4839bd1c2 100644 --- a/src/taxonomy/TaxonomyCard.scss +++ b/src/taxonomy/TaxonomyCard.scss @@ -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; + } } diff --git a/src/taxonomy/TaxonomyCard.test.jsx b/src/taxonomy/TaxonomyCard.test.jsx index d4efe90bc1..a4e192342a 100644 --- a/src/taxonomy/TaxonomyCard.test.jsx +++ b/src/taxonomy/TaxonomyCard.test.jsx @@ -2,7 +2,7 @@ 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'; @@ -12,6 +12,7 @@ import TaxonomyCard from './TaxonomyCard'; let store; const data = { + id: 1, name: 'Taxonomy 1', description: 'This is a description', }; @@ -81,4 +82,23 @@ describe('', async () => { const { getByText } = render(); expect(getByText('Assigned to 6 orgs')).toBeInTheDocument(); }); + + test('should open and close menu on button click', () => { + const { getByTestId, getByText } = render(); + + // 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 open + 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(); + }); }); diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index c92cdc788f..f64a56156c 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -37,6 +37,14 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.popover.system-defined.body', defaultMessage: 'This is a system-level taxonomy and is enabled by default.', }, + 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; From e87c545853e69edfaae3cbe9b8fd3e8ff9935de8 Mon Sep 17 00:00:00 2001 From: Christofer Date: Wed, 18 Oct 2023 18:26:39 +0000 Subject: [PATCH 03/18] feat: Modal for export taxonomy --- src/index.scss | 2 +- src/taxonomy/TaxonomyListPage.jsx | 2 +- src/taxonomy/messages.js | 24 +++++ src/taxonomy/modals/ExportModal.jsx | 79 ++++++++++++++++ .../{ => taxonomy-card}/TaxonomyCard.jsx | 93 +++++++++---------- .../{ => taxonomy-card}/TaxonomyCard.scss | 0 .../{ => taxonomy-card}/TaxonomyCard.test.jsx | 24 ++++- .../taxonomy-card/TaxonomyCardMenu.jsx | 58 ++++++++++++ 8 files changed, 229 insertions(+), 53 deletions(-) create mode 100644 src/taxonomy/modals/ExportModal.jsx rename src/taxonomy/{ => taxonomy-card}/TaxonomyCard.jsx (54%) rename src/taxonomy/{ => taxonomy-card}/TaxonomyCard.scss (100%) rename src/taxonomy/{ => taxonomy-card}/TaxonomyCard.test.jsx (81%) create mode 100644 src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx diff --git a/src/index.scss b/src/index.scss index dcbbeaab97..430007166e 100755 --- a/src/index.scss +++ b/src/index.scss @@ -20,4 +20,4 @@ @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; @import "files-and-uploads/table-components/GalleryCard"; -@import "taxonomy/TaxonomyCard.scss"; +@import "taxonomy/taxonomy-card/TaxonomyCard.scss"; diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 5d73ef6644..0d3f0be220 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -10,7 +10,7 @@ import { StudioFooter } from '@edx/frontend-component-footer'; import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import messages from './messages'; -import TaxonomyCard from './TaxonomyCard'; +import TaxonomyCard from './taxonomy-card/TaxonomyCard'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; const TaxonomyListPage = ({ intl }) => { diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index f64a56156c..24f80bf6da 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -45,6 +45,30 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.menu.alt', defaultMessage: '{name} menu', }, + 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; diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/modals/ExportModal.jsx new file mode 100644 index 0000000000..360151dc1b --- /dev/null +++ b/src/taxonomy/modals/ExportModal.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { + ActionRow, + Button, + Form, + ModalDialog, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import messages from '../messages'; + +const ExportModal = ({ + taxonomyId, + isOpen, + onClose, + intl, +}) => { + const [modalSize, setModalSize] = useState('csv'); + + return ( + + + + {intl.formatMessage(messages.exportModalTitle)} + + + + + + {intl.formatMessage(messages.exportModalBodyDescription)} + + setModalSize(e.target.value)} + > + + {intl.formatMessage(messages.taxonomyCSVFormat)} + + + {intl.formatMessage(messages.taxonomyJSONFormat)} + + + + + + + + {intl.formatMessage(messages.taxonomyModalsCancelLabel)} + + + + + + ); +}; + +ExportModal.propTypes = { + taxonomyId: PropTypes.number.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(ExportModal); diff --git a/src/taxonomy/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx similarity index 54% rename from src/taxonomy/TaxonomyCard.jsx rename to src/taxonomy/taxonomy-card/TaxonomyCard.jsx index c4d4c25d5a..df340fe1cf 100644 --- a/src/taxonomy/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx @@ -1,31 +1,36 @@ import React, { useState } from 'react'; import { Badge, - IconButton, Card, OverlayTrigger, Popover, - ModalPopup, - Menu, - Icon, - MenuItem, } from '@edx/paragon'; -import { MoreVert } from '@edx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import messages from './messages'; +import messages from '../messages'; +import TaxonomyCardMenu from './TaxonomyCardMenu'; +import ExportModal from '../modals/ExportModal'; const TaxonomyCard = ({ className, original, intl }) => { const { id, name, description, systemDefined, orgsCount, } = original; - const [menuIsOpen, setMenuIsOpen] = useState(false); - const [menuTarget, setMenuTarget] = useState(null); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); const orgsCountEnabled = () => orgsCount !== undefined && orgsCount !== 0; + const onClickMenuItem = (menuName) => { + switch (menuName) { + case 'export': + setIsExportModalOpen(true); + break; + default: + break; + } + }; + const getSystemBadgeToolTip = () => ( @@ -61,52 +66,42 @@ const TaxonomyCard = ({ className, original, intl }) => { return undefined; }; - const onClickExport = () => { - setMenuIsOpen(false); - }; - const getHeaderActions = () => ( + + ); + + const renderModals = () => ( + // eslint-disable-next-line react/jsx-no-useless-fragment <> - setMenuIsOpen(true)} - ref={setMenuTarget} - src={MoreVert} - iconAs={Icon} - alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })} - data-testid={`taxonomy-card-menu-button-${id}`} - /> - setMenuIsOpen(false)} - > - - - {intl.formatMessage(messages.taxonomyCardExportMenu)} - - - + {isExportModalOpen && ( + setIsExportModalOpen(false)} + /> + )} ); return ( - - - - - {description} - - - + <> + + + + + {description} + + + + {renderModals()} + ); }; diff --git a/src/taxonomy/TaxonomyCard.scss b/src/taxonomy/taxonomy-card/TaxonomyCard.scss similarity index 100% rename from src/taxonomy/TaxonomyCard.scss rename to src/taxonomy/taxonomy-card/TaxonomyCard.scss diff --git a/src/taxonomy/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx similarity index 81% rename from src/taxonomy/TaxonomyCard.test.jsx rename to src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index a4e192342a..91e76c984a 100644 --- a/src/taxonomy/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -5,7 +5,7 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { render, fireEvent } from '@testing-library/react'; import PropTypes from 'prop-types'; -import initializeStore from '../store'; +import initializeStore from '../../store'; import TaxonomyCard from './TaxonomyCard'; @@ -92,7 +92,7 @@ describe('', async () => { // Click on the menu button to open fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); - // Menu open + // Menu opened expect(getByTestId('taxonomy-card-menu-1')).toBeInTheDocument(); // Click on any element to close the menu @@ -101,4 +101,24 @@ describe('', async () => { // Menu closed expect(() => getByTestId('taxonomy-card-menu-1')).toThrow(); }); + + test('should open export modal on export menu click', () => { + const { getByTestId, getByText } = render(); + + // 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(); + }); }); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx new file mode 100644 index 0000000000..87e1722f4e --- /dev/null +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -0,0 +1,58 @@ +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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import messages from '../messages'; + +const TaxonomyCardMenu = ({ + id, name, onClickMenuItem, intl, +}) => { + const [menuIsOpen, setMenuIsOpen] = useState(false); + const [menuTarget, setMenuTarget] = useState(null); + + const onClickItem = (menuName) => { + setMenuIsOpen(false); + onClickMenuItem(menuName); + }; + + return ( + <> + setMenuIsOpen(true)} + ref={setMenuTarget} + src={MoreVert} + iconAs={Icon} + alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })} + data-testid={`taxonomy-card-menu-button-${id}`} + /> + setMenuIsOpen(false)} + > + + onClickItem('export')}> + {intl.formatMessage(messages.taxonomyCardExportMenu)} + + + + + ); +}; + +TaxonomyCardMenu.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + onClickMenuItem: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(TaxonomyCardMenu); From 49224207c0ef2bcbaca38340bbceecc5c2d380c9 Mon Sep 17 00:00:00 2001 From: Christofer Date: Thu, 19 Oct 2023 18:41:30 +0000 Subject: [PATCH 04/18] feat: Connect with export API --- src/taxonomy/api/hooks/api.js | 43 ++++++++++++++++--- src/taxonomy/api/hooks/api.test.js | 2 +- src/taxonomy/api/hooks/selectors.js | 9 +++- src/taxonomy/api/hooks/selectors.test.js | 4 +- src/taxonomy/api/types.mjs | 7 +++ src/taxonomy/modals/ExportModal.jsx | 22 ++++++++-- src/taxonomy/taxonomy-card/TaxonomyCard.jsx | 23 ++++++++-- .../taxonomy-card/TaxonomyCard.test.jsx | 4 ++ src/utils.js | 9 ++++ 9 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js index 6da57c1719..3efaea019a 100644 --- a/src/taxonomy/api/hooks/api.js +++ b/src/taxonomy/api/hooks/api.js @@ -1,20 +1,53 @@ // @ts-check -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { downloadDataAsFile } from '../../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyListApiUrl = new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; +const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; +const getExportTaxonomyApiUrl = (pk, format) => new URL( + `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}`, + getApiBaseUrl(), +).href; /** * @returns {import("../types.mjs").UseQueryResult} */ -const useTaxonomyListData = () => ( +export const useTaxonomyListData = () => ( useQuery({ queryKey: ['taxonomyList'], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl) + queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl()) .then(camelCaseObject), }) ); -export default useTaxonomyListData; +export const useExportTaxonomy = () => { + /** + * Calls the export request and downloads the file. + * + * Extra logic is needed to download the exported file, + * because it is not possible to download the file using the Content-Disposition header + * Ref: https://medium.com/@drevets/you-cant-prompt-a-file-download-with-the-content-disposition-header-using-axios-xhr-sorry-56577aa706d6 + * + * @param {import("../types.mjs").ExportRequestParams} params + * @returns {Promise} + */ + const exportTaxonomy = async (params) => { + const { pk, format, name } = params; + const response = await getAuthenticatedHttpClient().get(getExportTaxonomyApiUrl(pk, format)); + const contentType = response.headers['content-type']; + let fileExtension = ''; + let data; + if (contentType === 'application/json') { + fileExtension = 'json'; + data = JSON.stringify(response.data, null, 2); + } else { + fileExtension = 'csv'; + data = response.data; + } + downloadDataAsFile(data, contentType, `${name}.${fileExtension}`); + }; + + return useMutation(exportTaxonomy); +}; diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js index aee14b8a7f..a5d54c6077 100644 --- a/src/taxonomy/api/hooks/api.test.js +++ b/src/taxonomy/api/hooks/api.test.js @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import useTaxonomyListData from './api'; +import { useTaxonomyListData } from './api'; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), diff --git a/src/taxonomy/api/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js index 6908827caf..970ae49392 100644 --- a/src/taxonomy/api/hooks/selectors.js +++ b/src/taxonomy/api/hooks/selectors.js @@ -1,5 +1,8 @@ // @ts-check -import useTaxonomyListData from './api'; +import { + useTaxonomyListData, + useExportTaxonomy, +} from './api'; /** * @returns {import("../types.mjs").TaxonomyListData | undefined} @@ -18,3 +21,7 @@ export const useTaxonomyListDataResponse = () => { export const useIsTaxonomyListDataLoaded = () => ( useTaxonomyListData().status === 'success' ); + +export const useExportTaxonomyMutation = () => ( + useExportTaxonomy() +); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/api/hooks/selectors.test.js index b513b9b735..a8e3716032 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/api/hooks/selectors.test.js @@ -1,9 +1,9 @@ import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './selectors'; -import useTaxonomyListData from './api'; +import { useTaxonomyListData } from './api'; jest.mock('./api', () => ({ __esModule: true, - default: jest.fn(), + useTaxonomyListData: jest.fn(), })); describe('useTaxonomyListDataResponse', () => { diff --git a/src/taxonomy/api/types.mjs b/src/taxonomy/api/types.mjs index 980939b255..36e47dbe10 100644 --- a/src/taxonomy/api/types.mjs +++ b/src/taxonomy/api/types.mjs @@ -27,6 +27,13 @@ * @property {TaxonomyListData} data */ +/** + * @typedef {Object} ExportRequestParams + * @property {number} pk + * @property {string} format + * @property {string} name + */ + /** * @typedef {Object} UseQueryResult * @property {Object} data diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/modals/ExportModal.jsx index 360151dc1b..3627648c9b 100644 --- a/src/taxonomy/modals/ExportModal.jsx +++ b/src/taxonomy/modals/ExportModal.jsx @@ -8,17 +8,30 @@ import { import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from '../messages'; +import { useExportTaxonomyMutation } from '../api/hooks/selectors'; const ExportModal = ({ taxonomyId, + taxonomyName, isOpen, onClose, intl, }) => { - const [modalSize, setModalSize] = useState('csv'); + const [outputFormat, setOutputFormat] = useState('csv'); + const exportMutation = useExportTaxonomyMutation(); + + const onClickExport = () => { + onClose(); + exportMutation.mutate({ + pk: taxonomyId, + format: outputFormat, + name: taxonomyName, + }); + }; return ( setModalSize(e.target.value)} + value={outputFormat} + onChange={(e) => setOutputFormat(e.target.value)} > {intl.formatMessage(messages.taxonomyModalsCancelLabel)} - @@ -71,6 +84,7 @@ const ExportModal = ({ ExportModal.propTypes = { taxonomyId: PropTypes.number.isRequired, + taxonomyName: PropTypes.string.isRequired, isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, intl: intlShape.isRequired, diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx index df340fe1cf..48e6688d91 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx @@ -66,9 +66,24 @@ const TaxonomyCard = ({ className, original, intl }) => { return undefined; }; - const getHeaderActions = () => ( - - ); + 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 ( + + ); + }; const renderModals = () => ( // eslint-disable-next-line react/jsx-no-useless-fragment @@ -77,6 +92,8 @@ const TaxonomyCard = ({ className, original, intl }) => { setIsExportModalOpen(false)} + taxonomyId={id} + taxonomyName={name} /> )} diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 91e76c984a..1844075f6a 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -9,6 +9,10 @@ import initializeStore from '../../store'; import TaxonomyCard from './TaxonomyCard'; +jest.mock('../api/hooks/selectors', () => ({ + useExportTaxonomyMutation: jest.fn(), +})); + let store; const data = { diff --git a/src/utils.js b/src/utils.js index 67a37e6db7..d1dc1bfc5f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -256,3 +256,12 @@ export const isValidDate = (date) => { return Boolean(formattedValue.length <= 10); }; + +export const downloadDataAsFile = (data, contentType, fileName) => { + const url = window.URL.createObjectURL(new Blob([data], { type: contentType })); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); +}; From 5e12a8f7ee1d86f82f5cb5c906894f06123d8bca Mon Sep 17 00:00:00 2001 From: Christofer Date: Fri, 20 Oct 2023 17:20:53 +0000 Subject: [PATCH 05/18] test: Tests for API and selectors --- src/taxonomy/api/hooks/api.test.js | 56 +++++++++++++++++++++--- src/taxonomy/api/hooks/selectors.test.js | 17 ++++++- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js index a5d54c6077..a34ede3da6 100644 --- a/src/taxonomy/api/hooks/api.test.js +++ b/src/taxonomy/api/hooks/api.test.js @@ -1,20 +1,30 @@ -import { useQuery } from '@tanstack/react-query'; -import { useTaxonomyListData } from './api'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useTaxonomyListData, useExportTaxonomy } from './api'; +import { downloadDataAsFile } from '../../../utils'; + +const mockHttpClient = { + get: jest.fn(), +}; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), + useMutation: jest.fn(), })); jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: jest.fn(), + getAuthenticatedHttpClient: jest.fn(() => mockHttpClient), +})); + +jest.mock('../../../utils', () => ({ + downloadDataAsFile: jest.fn(), })); -describe('taxonomy API: useTaxonomyListData', () => { +describe('taxonomy API', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should call useQuery with the correct parameters', () => { + it('useTaxonomyListData should call useQuery with the correct parameters', () => { useTaxonomyListData(); expect(useQuery).toHaveBeenCalledWith({ @@ -22,4 +32,40 @@ describe('taxonomy API: useTaxonomyListData', () => { queryFn: expect.any(Function), }); }); + + it('useExportTaxonomy should export data correctly', async () => { + useMutation.mockImplementation((exportFunc) => exportFunc); + + const mockResponseJson = { + headers: { + 'content-type': 'application/json', + }, + data: { tags: 'tags' }, + }; + const mockResponseCsv = { + headers: { + 'content-type': 'text', + }, + data: 'This is a CSV', + }; + + const exportTaxonomy = useExportTaxonomy(); + + mockHttpClient.get.mockResolvedValue(mockResponseJson); + await exportTaxonomy({ pk: 1, format: 'json', name: 'testFile' }); + + expect(downloadDataAsFile).toHaveBeenCalledWith( + JSON.stringify(mockResponseJson.data, null, 2), + 'application/json', + 'testFile.json', + ); + + mockHttpClient.get.mockResolvedValue(mockResponseCsv); + await exportTaxonomy({ pk: 1, format: 'csv', name: 'testFile' }); + expect(downloadDataAsFile).toHaveBeenCalledWith( + mockResponseCsv.data, + 'text', + 'testFile.csv', + ); + }); }); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/api/hooks/selectors.test.js index a8e3716032..7654006922 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/api/hooks/selectors.test.js @@ -1,9 +1,14 @@ -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './selectors'; -import { useTaxonomyListData } from './api'; +import { + useTaxonomyListDataResponse, + useIsTaxonomyListDataLoaded, + useExportTaxonomyMutation, +} from './selectors'; +import { useTaxonomyListData, useExportTaxonomy } from './api'; jest.mock('./api', () => ({ __esModule: true, useTaxonomyListData: jest.fn(), + useExportTaxonomy: jest.fn(), })); describe('useTaxonomyListDataResponse', () => { @@ -41,3 +46,11 @@ describe('useIsTaxonomyListDataLoaded', () => { expect(result).toBe(false); }); }); + +describe('useExportTaxonomyMutation', () => { + it('should trigger useExportTaxonomy', () => { + useExportTaxonomyMutation(); + + expect(useExportTaxonomy).toHaveBeenCalled(); + }); +}); From 09aa8b699b6b25488dfb4abf739cb29b4744f590 Mon Sep 17 00:00:00 2001 From: Christofer Date: Fri, 20 Oct 2023 18:22:10 +0000 Subject: [PATCH 06/18] feat: Use windows.location.href to call the export endpoint --- src/taxonomy/api/hooks/api.js | 37 ++------------ src/taxonomy/api/hooks/api.test.js | 54 +++++++++------------ src/taxonomy/api/hooks/selectors.js | 6 +-- src/taxonomy/api/hooks/selectors.test.js | 14 +++--- src/taxonomy/modals/ExportModal.jsx | 11 +---- src/taxonomy/taxonomy-card/TaxonomyCard.jsx | 1 - src/utils.js | 9 ---- 7 files changed, 39 insertions(+), 93 deletions(-) diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js index 3efaea019a..b524bd197f 100644 --- a/src/taxonomy/api/hooks/api.js +++ b/src/taxonomy/api/hooks/api.js @@ -1,13 +1,12 @@ // @ts-check -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { downloadDataAsFile } from '../../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; -const getExportTaxonomyApiUrl = (pk, format) => new URL( - `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}`, +export const getExportTaxonomyApiUrl = (pk, format) => new URL( + `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, getApiBaseUrl(), ).href; @@ -22,32 +21,6 @@ export const useTaxonomyListData = () => ( }) ); -export const useExportTaxonomy = () => { - /** - * Calls the export request and downloads the file. - * - * Extra logic is needed to download the exported file, - * because it is not possible to download the file using the Content-Disposition header - * Ref: https://medium.com/@drevets/you-cant-prompt-a-file-download-with-the-content-disposition-header-using-axios-xhr-sorry-56577aa706d6 - * - * @param {import("../types.mjs").ExportRequestParams} params - * @returns {Promise} - */ - const exportTaxonomy = async (params) => { - const { pk, format, name } = params; - const response = await getAuthenticatedHttpClient().get(getExportTaxonomyApiUrl(pk, format)); - const contentType = response.headers['content-type']; - let fileExtension = ''; - let data; - if (contentType === 'application/json') { - fileExtension = 'json'; - data = JSON.stringify(response.data, null, 2); - } else { - fileExtension = 'csv'; - data = response.data; - } - downloadDataAsFile(data, contentType, `${name}.${fileExtension}`); - }; - - return useMutation(exportTaxonomy); +export const exportTaxonomy = (pk, format) => { + window.location.href = getExportTaxonomyApiUrl(pk, format); }; diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js index a34ede3da6..b712f05869 100644 --- a/src/taxonomy/api/hooks/api.test.js +++ b/src/taxonomy/api/hooks/api.test.js @@ -1,6 +1,5 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; -import { useTaxonomyListData, useExportTaxonomy } from './api'; -import { downloadDataAsFile } from '../../../utils'; +import { useQuery } from '@tanstack/react-query'; +import { useTaxonomyListData, exportTaxonomy } from './api'; const mockHttpClient = { get: jest.fn(), @@ -19,12 +18,12 @@ jest.mock('../../../utils', () => ({ downloadDataAsFile: jest.fn(), })); -describe('taxonomy API', () => { +describe('useTaxonomyListData', () => { afterEach(() => { jest.clearAllMocks(); }); - it('useTaxonomyListData should call useQuery with the correct parameters', () => { + it('should call useQuery with the correct parameters', () => { useTaxonomyListData(); expect(useQuery).toHaveBeenCalledWith({ @@ -32,40 +31,31 @@ describe('taxonomy API', () => { queryFn: expect.any(Function), }); }); +}); - it('useExportTaxonomy should export data correctly', async () => { - useMutation.mockImplementation((exportFunc) => exportFunc); +describe('exportTaxonomy', () => { + const { location } = window; - const mockResponseJson = { - headers: { - 'content-type': 'application/json', - }, - data: { tags: 'tags' }, - }; - const mockResponseCsv = { - headers: { - 'content-type': 'text', - }, - data: 'This is a CSV', + beforeAll(() => { + delete window.location; + window.location = { + href: '', }; + }); - const exportTaxonomy = useExportTaxonomy(); + afterAll(() => { + window.location = location; + }); - mockHttpClient.get.mockResolvedValue(mockResponseJson); - await exportTaxonomy({ pk: 1, format: 'json', name: 'testFile' }); + it('should set window.location.href correctly', () => { + const pk = 1; + const format = 'json'; - expect(downloadDataAsFile).toHaveBeenCalledWith( - JSON.stringify(mockResponseJson.data, null, 2), - 'application/json', - 'testFile.json', - ); + exportTaxonomy(pk, format); - mockHttpClient.get.mockResolvedValue(mockResponseCsv); - await exportTaxonomy({ pk: 1, format: 'csv', name: 'testFile' }); - expect(downloadDataAsFile).toHaveBeenCalledWith( - mockResponseCsv.data, - 'text', - 'testFile.csv', + expect(window.location.href).toEqual( + 'http://localhost:18010/api/content_tagging/' + + 'v1/taxonomies/1/export/?output_format=json&download=1', ); }); }); diff --git a/src/taxonomy/api/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js index 970ae49392..b2c678be78 100644 --- a/src/taxonomy/api/hooks/selectors.js +++ b/src/taxonomy/api/hooks/selectors.js @@ -1,7 +1,7 @@ // @ts-check import { useTaxonomyListData, - useExportTaxonomy, + exportTaxonomy, } from './api'; /** @@ -22,6 +22,6 @@ export const useIsTaxonomyListDataLoaded = () => ( useTaxonomyListData().status === 'success' ); -export const useExportTaxonomyMutation = () => ( - useExportTaxonomy() +export const callExportTaxonomy = (pk, format) => ( + exportTaxonomy(pk, format) ); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/api/hooks/selectors.test.js index 7654006922..772b39c4d8 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/api/hooks/selectors.test.js @@ -1,14 +1,14 @@ import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, - useExportTaxonomyMutation, + callExportTaxonomy, } from './selectors'; -import { useTaxonomyListData, useExportTaxonomy } from './api'; +import { useTaxonomyListData, exportTaxonomy } from './api'; jest.mock('./api', () => ({ __esModule: true, useTaxonomyListData: jest.fn(), - useExportTaxonomy: jest.fn(), + exportTaxonomy: jest.fn(), })); describe('useTaxonomyListDataResponse', () => { @@ -47,10 +47,10 @@ describe('useIsTaxonomyListDataLoaded', () => { }); }); -describe('useExportTaxonomyMutation', () => { - it('should trigger useExportTaxonomy', () => { - useExportTaxonomyMutation(); +describe('callExportTaxonomy', () => { + it('should trigger exportTaxonomy', () => { + callExportTaxonomy(1, 'csv'); - expect(useExportTaxonomy).toHaveBeenCalled(); + expect(exportTaxonomy).toHaveBeenCalled(); }); }); diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/modals/ExportModal.jsx index 3627648c9b..31dfd16449 100644 --- a/src/taxonomy/modals/ExportModal.jsx +++ b/src/taxonomy/modals/ExportModal.jsx @@ -8,25 +8,19 @@ import { import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from '../messages'; -import { useExportTaxonomyMutation } from '../api/hooks/selectors'; +import { callExportTaxonomy } from '../api/hooks/selectors'; const ExportModal = ({ taxonomyId, - taxonomyName, isOpen, onClose, intl, }) => { const [outputFormat, setOutputFormat] = useState('csv'); - const exportMutation = useExportTaxonomyMutation(); const onClickExport = () => { onClose(); - exportMutation.mutate({ - pk: taxonomyId, - format: outputFormat, - name: taxonomyName, - }); + callExportTaxonomy(taxonomyId, outputFormat); }; return ( @@ -84,7 +78,6 @@ const ExportModal = ({ ExportModal.propTypes = { taxonomyId: PropTypes.number.isRequired, - taxonomyName: PropTypes.string.isRequired, isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, intl: intlShape.isRequired, diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx index 48e6688d91..73a6145c7e 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx @@ -93,7 +93,6 @@ const TaxonomyCard = ({ className, original, intl }) => { isOpen={isExportModalOpen} onClose={() => setIsExportModalOpen(false)} taxonomyId={id} - taxonomyName={name} /> )} diff --git a/src/utils.js b/src/utils.js index d1dc1bfc5f..67a37e6db7 100644 --- a/src/utils.js +++ b/src/utils.js @@ -256,12 +256,3 @@ export const isValidDate = (date) => { return Boolean(formattedValue.length <= 10); }; - -export const downloadDataAsFile = (data, contentType, fileName) => { - const url = window.URL.createObjectURL(new Blob([data], { type: contentType })); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', fileName); - document.body.appendChild(link); - link.click(); -}; From 1925faef18fb2ef6bf0ec3e5709072a5299af82a Mon Sep 17 00:00:00 2001 From: Christofer Date: Fri, 20 Oct 2023 19:39:20 +0000 Subject: [PATCH 07/18] test: ExportModal.test added --- src/taxonomy/modals/ExportModal.test.jsx | 53 +++++++++++++++++++ src/taxonomy/taxonomy-card/TaxonomyCard.jsx | 1 + .../taxonomy-card/TaxonomyCard.test.jsx | 4 -- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 src/taxonomy/modals/ExportModal.test.jsx diff --git a/src/taxonomy/modals/ExportModal.test.jsx b/src/taxonomy/modals/ExportModal.test.jsx new file mode 100644 index 0000000000..31f8217018 --- /dev/null +++ b/src/taxonomy/modals/ExportModal.test.jsx @@ -0,0 +1,53 @@ +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, fireEvent } from '@testing-library/react'; +import ExportModal from './ExportModal'; +import initializeStore from '../../store'; +import { callExportTaxonomy } from '../api/hooks/selectors'; + +const onClose = jest.fn(); +let store; +const taxonomyId = 1; + +jest.mock('../api/hooks/selectors', () => ({ + callExportTaxonomy: jest.fn(), +})); + +const ExportModalComponent = () => ( + + + + + +); + +describe('', async () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('should render the modal', () => { + const { getByText } = render(); + expect(getByText('Select format to export')).toBeInTheDocument(); + }); + + it('should call export endpoint', () => { + const { getByText } = render(); + + fireEvent.click(getByText('JSON file')); + fireEvent.click(getByText('Export')); + + expect(onClose).toHaveBeenCalled(); + expect(callExportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); + }); +}); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx index 73a6145c7e..0b024a96ae 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx @@ -27,6 +27,7 @@ const TaxonomyCard = ({ className, original, intl }) => { setIsExportModalOpen(true); break; default: + /* istanbul ignore next */ break; } }; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 1844075f6a..91e76c984a 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -9,10 +9,6 @@ import initializeStore from '../../store'; import TaxonomyCard from './TaxonomyCard'; -jest.mock('../api/hooks/selectors', () => ({ - useExportTaxonomyMutation: jest.fn(), -})); - let store; const data = { From 0be804c079df1e8964355b265cee6f02afb3335d Mon Sep 17 00:00:00 2001 From: Christofer Date: Sat, 21 Oct 2023 03:03:03 +0000 Subject: [PATCH 08/18] style: Delete unnecesary code --- src/taxonomy/api/hooks/api.test.js | 5 ----- src/taxonomy/api/types.mjs | 7 ------- 2 files changed, 12 deletions(-) diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js index b712f05869..b3dc0045d1 100644 --- a/src/taxonomy/api/hooks/api.test.js +++ b/src/taxonomy/api/hooks/api.test.js @@ -7,17 +7,12 @@ const mockHttpClient = { jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), - useMutation: jest.fn(), })); jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(() => mockHttpClient), })); -jest.mock('../../../utils', () => ({ - downloadDataAsFile: jest.fn(), -})); - describe('useTaxonomyListData', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/src/taxonomy/api/types.mjs b/src/taxonomy/api/types.mjs index 36e47dbe10..980939b255 100644 --- a/src/taxonomy/api/types.mjs +++ b/src/taxonomy/api/types.mjs @@ -27,13 +27,6 @@ * @property {TaxonomyListData} data */ -/** - * @typedef {Object} ExportRequestParams - * @property {number} pk - * @property {string} format - * @property {string} name - */ - /** * @typedef {Object} UseQueryResult * @property {Object} data From 5af8d66329a25ee5287b3f83a959ef9d63d7852e Mon Sep 17 00:00:00 2001 From: Christofer Date: Mon, 30 Oct 2023 16:53:34 +0000 Subject: [PATCH 09/18] docs: README updated with taxonomy feature --- README.rst | 19 ++++++++++++++++++ .../feature-tagging-taxonomy-pages.png | Bin 0 -> 65673 bytes 2 files changed, 19 insertions(+) create mode 100644 docs/readme-images/feature-tagging-taxonomy-pages.png diff --git a/README.rst b/README.rst index 471600a11a..b4a3d7dbdd 100644 --- a/README.rst +++ b/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 ********** diff --git a/docs/readme-images/feature-tagging-taxonomy-pages.png b/docs/readme-images/feature-tagging-taxonomy-pages.png new file mode 100644 index 0000000000000000000000000000000000000000..e27c469c51a8e99aa39545be8a5b01ddd1a2e73c GIT binary patch literal 65673 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∈(;}e?&GEQ-ZwQu=#$P?R@mch?6`6^a_Sgx{>kG%1<&O11 zB}QL@@;?c@W|aWrtmEXN96kVq%zCzh2jA%AWH%Yy!*uCa_yV4t;aS;K4aV|md23lV zstcW>{n*qfcx8hkvP-7G^N0~ofoyM=**n>@$;X;=GHC?xhp3eJmoC*Pc*csegR%aO zD@PMuZ1>c^xbp|uzXy{XuOs8`iZjVd(d(B0ahhsyo+@*D_)tkiZPNM6ZACd6VCPtV ztoOH_e;FJ20siCVUzY#>;}PQ_MPzE)YGR`R?%}OGy7y8vt&QHU1)bjXqUV=u#4)nj z@+u~Kjo*?n+S-lMvT!0E-p`rVIN=dwPC#DAJ|(*80`{^>@GI7jAyx>MN z6&6adQgqO%9t1`7hR98CCKp}2{QU_TN_^du2g%MeyWb_+$mh=S?%eYJTkVMW7Lu(t zJU@8|*5pJucQwQy$JL#Dzt{y8KQpK+B1zGorSLzZ=?NI^NUqr1gx>V2Of5kWpI!#u zol3a=oQ8Hq1>T{~O|v%_6yg!!cprZ8Z`PU+{}nJm$aKol1->$${CelN}=jlB0?;>gw^`*fj?cVEO*d5ZX?(dqt>FG~xm?V|&j>LL;Vl;0azE*$XL-am0J zFQ?~?*++J7C`bJ1M2~-D7zKW?3^-#@Fw#Q{k=UH1qvAS0HC()D=TAU*ymgs4JASh> z&Uv_r<#M&!Np&J%86bIYY2;!ygqQEXCL(yTXMWPd(QlC2@aV8}`m!^d=n_~yjn=t5 z0$d4sDpGhlPkutxo^SIdeC5qst;LefnQ+^gDn}+Gui1uE5_++gDBzw>sxB)niTdO> z!S2>es|ELmV%ZHtR-Z{U^bcac3Gh8t4Ty0hynEzZuhX#Tk#_I5&&%(B4^=zaqetkz zgX=Y4cLuHsc(!)u5b6=lUx-J4$Hf5?f^U=tO7kRCK+Y8*woNL%wyDq@d_ zB!qjq7ULR*HSCr^Tj@3Q@^C_tRwM!O?bTGX760;!yI=&{nPz)5g`k|{)Pq~GY4)be zy!%ViioLnVRGy<_=iC~qfpHSoJt3Pz`g!#O$mC%mlp4)?a?YnA2e#edR zI&h@ceQ7lgv!z2xHBe#8H!ujk#=>tm(AAGoex}dpXJe-Sv!NfORd%tiW^NxyUL_f` za1mDM%8>Zt>!NV5*Oy@CdWFb*IM<1E?#TZqoL0S)sZFvcPl-{ zL$Pl27w<*(dpO2#`!J?NlLrLKJZH3IWHevY#@SH)0(>BHzvo)#yfP=y=?Sx6T8pdm z$0}@HzFw9%8pz4oV50Ik+BedfIb{;j$+>0EwuUnWT&+|P!>=)XkWK8T>Sr1;wJ!_| zZ|*9*FQVfv0=?2Nf?lZJ5XSm*uH49uvCF|%PdL9m@;hJAO9~|RK0>P8)S4|MwtHp<-zj7tdTqwh zMIPJ6W#$*+7$B0Bb94Ay;(n5NMEPtq&CPY^k@Ks|oPUnKdRY_zKPr29{}IF9 zZ<&EcRf){Ioch}&qk!!0`|1NdJFZf*TSk$c@#Vz*`t7^jX+NY=Bg>wn7^WY4w@)Pw zg6_yvD{tpi<%H8Q>@Gb>Dgwz`FYV&n4HlZ%vSe}NfodqNTkn-#?i z&daNql6h^oyN8Ud-9o_92q8f+5O~TH;(z+%|BXSBh5m%#KTO#*LZF{cIurQ-N4!}- z2nf4482&zXX~wbeu2ywU0kN%(cWI#`Q9yk2)|JY2t#66pVB2Z>oTw#ms3h>Thx`^C zw`0^c%apT@f>_zzCfsKH%+d2U@Fv(O;Fu)Las7Bu_j2&!QR?FyUjCVorX$Wvr4b2d zPEFML=AmIczrN(0+vxVH5tP%qlT@a~O(y8IXtQKO7KLiXN_UUt)tstBj>VYjdd8~2sUd_tgp!OY~@os z32{6c3%txsp}J3k-a7VNx(wlI=L~o0SXnNJ2}*a4bE?&1>5kk)*WcSBm)6FE0R&B@ z$Vzdc81aMel-^ESw63FgD96n-j<*K+VwVAvK>VL3q0dbE{${5_Q}`K<4u!*FbKg*g zKze2-Ju&+6oDLLHx?u!gzt<~F=svu=3S4y*zanJ_zo#3qR)D^ORc;3wZez=sC&POd z1KaHQ&3MwiZjO4qZ;rx!t4V9);*bCoa;YeOnDT~68(SbiKjY#CBEC~f<>+b|nJUwJ z^ecP!n#D;BYY8}VR&-@av$xVl7AH9s(K60RfK;J!uH1)2_d=gu3yPWIqI zVC2vT&2p5BIQl&!Q;J&0)=hO;8&sC?Ya$!i&#te2@cM$^#LQy@QFJba>fYJJ#(lZp5x;>+%@;>(5@nIwx@WQhc!{^X!7UndkwU2b2NI=WSK`T6sONx_s;8JC0QDC~nc$Brmc53F}dV zf8}|g0HSS!ddx6r4tSd=?69uJCEZT(eA6U7V4;KXgtoudjwvveAY)@VV=>hwrL%Br z2HF#g^DHzN{S1C|=C&+6FR5qvRL%w!cKFHS-9h7Bu-inE6Jt{(o^PoV+b`nWUhu$r z-cFS(y+tko*arD8ST!XUsBCeP9z?rEudQ`SN6%(uGw%!*S!_o_CPI0oKCOQtY|YqgBzIPdU6XuWQ_|Nyx*$@sY(Qp!Ofr&ACzKUaC=D$zdAX ziS-u`$fCy8tKnp71QNwn?l=L*ZF+xL<<}O)WE?XWmzu{7p|`Fjk&BX1V(ecd z9vcvPP&nqgIOtS4pIxFvBd`>k+reJxgyQ+yoQjFc9_0=Q#!}EUojf^kdp|L#lDY3^P(*y|ftSuO0I_eL})sE4ZnUkJNHTF1XTJT@wBA zm~Sw8^uT@GWfg&LlhKCahymfCF*81A4H?Uszb16`18Ft8Z1=6DwT)1FkTdV=TFia% zr|BQev?yc5h<@y%EYBN1EqDHN6QMi+Z6ulE6D?ixs4+5o43pq8mP^M5J>CAvc8~uu z!U4KPWyvlzXF=Pi`p}eXMD#GyV$Pb!veXgKS`;=0azC+{8oo9Ornhv&B!wq3V$x0h zLh9fk=XIX4p4gQ?NqXkplKkcjV_Pu8v2sEUMV8D6R zQ80Y0?R~;SlBLOQ|NQ2VDYUuK?Gr}MFu^w&k9#`qa{9wwg7?8h7a;%y{IwlNWSEYo zscP8MtV|laR<0H0FUr+Zkqg!De_z_O6y?sS?w9%;Shrc{{tau~Wy5sUVeiifKrHTq%A(>p?6#ndZ@!q~Zc z7dy*p^x5(~nuN+vP<-^PZol=$PRFT}u-n2JiAz6iM5o$@s_+xH_X^&wkGoVJVn*(= z1Y0wZJ8izNS8`X=+0H9EW-DrbbbO%=iwB22qGpeYM&D^Jc+$e2dHqe^W9ZHEJa-$2 zi4wW#!3w5svv1W_kqVXTjBbbX*7>3Q#>8Hd(4HH)!6h#pFnkvY(blm?zsW7|vy?A` zE^a32lTcSM7<+9k*SlV|Pd?~~Qa6Lcg`L>{1%YpFqLlBAnFHV>7+2(_6VXZS#WRS&30 zVA(BeqjW&X)?hQbdH1U<*HX2CEI$bnar6Ah&B1cn%|bvSI*DMlCDnI3Ph(!??@MA$ zrO~4BQtO5x7M3)}lk^8HZ=iZ$nS&}^f0vf zVX%a?PnCjHbF`a!t#B79{iYcyB|WB8IN}rOwh4R<1oLjHHmR_1l*h6Vl4m@8JeV3{ z>t2b*DF94Y54)p#%%n^;4>VXfC3vAFo?ZDG(vLnz4*{YR7Mf+`Vn&{PviS=sJ( ztA-aGu7XoXy+WjPJ$y_y4wk@WdLRBPHmkL}G1jSUI^Jv+H5DZe4?Fm%OuF7puzZM{ z(lCkR<@QS5c*04!w!DH?S&&vuicFFsgWhI5M}wJTaJ~IhqG6C4$*!)Zmla9AMkBzD zv+l}6UKx$))q$6I6#6%(tr~mn=DEm+*>w0zD|wjTd4{~n?sF-!IkW-enE~*JyiDsO zbyMiLaIF#x)B6KICb}>?8yi+o*`5gQ1$|GwYYcCZpE8Nvx@9<8eSR7Zen%##lTgkO z?y+U2z}<0)E#1krvmV@u9v+>hF%+e|p~5Z7Q&hS9j80NsSs@z2#>1S@{hFfh$^}Q#Yu!b zaF5pZNMd2kZN(p31X=ZdK%>6ETg1Ml5FQ)l2ll`GxWF%RMW*}J!S4H4;c&|q%H8d6 z5*BSKya#Cy69q>8Ta$S(8k^$tO*N`5GAwSMnp6Wlwlox;nQ_?x3&ae=ezQ(7_8#TU z6h3sZtQtOpZ<(ozPL+vunpD=Y?3csm!!wp@ zz1Il^lNH_2uApF~H|4hLM3vQF4@o^-w^)quGra`HNkq<6AzC#3<{O+L!;GPP(zY%8 zJUVtVrEZVS;g|NY-ILm#RilQ-yn|2REj9e^xU+Aibh5i|0coH&MD-sD3H*xNv_iCd z2Sil1C>vWB%NrV$#773(kYky{Gy3VtvSBx)(b?Ya^KqEGuzK=ke<8UdCb@;`$DGlP zJhvM1jrUi?XQB0x8;K{GUnC@MOWNauCsTTqHhfv;TTO=Xd&p*7PoHcSGOE;46rE=| z%h$w%mO6Jfd|}y*X(xScI!18~RgqIuYx>0DGEp%%h%b^h^wvt7U}tGMn$jwsbmh7d z(rdD8=^`^mdP2MmbwY6oV(F(j!UIQmsm{A2?Ey-LqPSWbc=qgaLJ~X=xjn;7;>;q{ z+)BsfAr7DY-_&Lei)9{E05)WRm!kdn7jr8J0T$uf*FF@@Z%`tkkJ<{9Qdbu(cc#kT zP2CUx|MH(rhal;{Kl1c{JTg`4hVXZ0qNVj@*uu7_V5N8ucw=vfG@#%74R*hNYd!+_ z=>H3k3{7J}{$304&*lAZz1npeg3oYml&h<&-9L|TGaVE6>F~FRT=RW{mMp%9aZ*|a z_Hi7ac)nSjfR3$*u3lR|A7Fx(Ms9o3t`~r`#ILTZB$}8xSj+;4oK*~(Wvv*b1Jp!E zr-P#=_WO(0zd9Bmg2VelZqhaFJhvoc*@+!&&-t&lgcI~w>YDOqeh*5M;GrB?!Qb7q z+jlLmg{AmL>KMzC%SAD7}9PfQPem<;oU3sGZnw?-oM^l(urx&2tfy)TU znB>OycRZ*k{O!ib<(&jcli4e?(#ekk;-Pp-L{6!Yl0vgPWbJ?KM`-;^?{opyCy$JY zInV?`Lk5_1h1M|CzVWQ*rw7HMBcKC{2sL@s-af;QtZi9~>_$B0s3izqgWzhaw3Zby;l9tY2L| z;LcThxaN2m201-op);#`_4GE=+U|2%kE-z#c?AZu0gnFXnKK$DmxGVav!7L8ROkx| z$eg1XtjqVAa8!m&>tR)ieSC_M`S}LxBiDgrZtJN0eYz~^oX;V$Y}!Yj_yiDL#n<5K zj_9jIhhJ=X;Muz!tX@x@8TaN;MKbIkg?*F3)6*lPm-t~xe0H#|w{P(ia5S_l@6ls= z^tN}Y7|rWUM*<^ny;Fs>y~CoXN7S1rsJ)1)DDu{qubk|}S4Ri7O+X;2kt!W)bf{T$IHu%wF|UV!D-Hi?5E%Oi;w! ztzwTihsy7jce<%Ont(7tvtM)G5^!t{5>9jQyL3zj1-2gtUyksN;a}~VDANpWw&@s# znT1e}c&)RKyYoG7JqMOsR#%0Vuh_e+?4R-q@?l1v=*JPdwveB%h<}&YyP%dF%-_J* zc$3|p{y|#bgFLTxtb7K4E`%iGA#q0ZB0lsbyf5`d24Jl$V~WRBBm(gXIej7=oD_G} zT?~Cv0Ut5RKe;o<7+Ad69g%0?`%zH4O@XP2>0DZj3s12dv&t>vjBu6CkNR@Y)nL}#ZkS1AUtulrv5K1s-1(+ZVd(4nJ~Fb9X?NieQZVRTF8AV zFsa1Rnqt*7tY{e6$+wO|!Uf4@rJP@3zzFHq?jU@%nagco(+hQxixD~ve_~~R8c%!O z*M4ix6gxh?xf;(2rZ$csXZ%qHjhg8foU+==LOgQ=HGez$K#r?1wpt=h_YUXTs-yj5 zB@|>MRXTS2l?#UQlNZ5Y6ApnX-gV~O!s4)`PdO=ha@Jx#!OWAYol2ja zuz1ZnL)?7)l?w!!YdF~z2#x#=*WM02(!V9lW)*q)O7?kfJEktFs#6=)`uhPAZzmI+ zGdC@D=iUCis;$RaVvjP(2uazO;^1wM7D@VzwP2Elib0Z&XzXXm#ON>7jjUQ;mpW(L ziXNOuEk%08;WW$pzrGYXQXhJ>yGeb!s2SVB>#|iFw{~Y2Zfm-J!yQ|+q2ZAq&A0YU z>~-x#la2MD)yi^n4!6h#6Q=Q(;;rBfhFGo5cvBdHjO^nJ0q2Pv*#OuhMRdB;8vgJ6 zCC%7(g^1)<6z!V&vSoA_Yn3^%DU7k2=XiFdMNS0DVbq@1LKS(SRR<&dIXY4@Ne)Vp z-tTC#)kc4QVxtQk?X{Tws4L{dC2u+)=|bqi7H(Z^bnE7;kqW{GxIn?>By zZl$l>R0g=&ZS?D3u(;m-=|OaGyRmFRuZ}!6SaD0A1OxXu!t)w&(*Q*U3acJGf2xbNjO&}F19ric@uxV4`lfZK1szLVbVe2~V$HPlPUK$h(bx5wk=isl!ClAbi%F)*lvK;}mYIOG-il9vrHbluBV2)zX%J#i!ClAsvVk zE+X=k+-;dly3O~)@DYA?NpS(&*p9`BLP#~Lt^&O-_w9lwSw<*69r2(h4-ZdAF*Mfe zC)bNH5-}Bts~U1P^X${(=$C$5Wc*!~weQ!4_g21>70Z zwKZgKDL{I$2iNha-A*6O>CN*BL8|K~Ns0kF+?E_)f9#Mvf&01Qrd5IR!8T7qgpzBb zVR`#K*Yw;Nm3KAY?daj!(WZqEK{ezpT&rF z^BARwhB61%=deg4#qPpb_#q2HLtL){Jtb&(dgl^4yo^-MMGfOyvnAE-uLSLi(G6Rb zFXKZ_Ph%3R8RF2X8R(vN`&!vI_SSBvsaFvRv(#|%6x7q?gq)p`XTG4Fj+p$2sv#K` zfQV&Ar)Hv)iwL$Mu_oJbz+rK=@FUPFQGa5T!iCQBJy>8AF zidVe8vEUti*%eTS#|ugKPkXD5f!(|iIUm-3M=n$~=~z4boYSiT@BS^S-mx9F1~QE+ zzc&e&i;&Myg4|fOq1>9|?+E8VpP14x;)XvGg>(VKpj(5?Wr!GXhTH}qDVPkD+SYzB;2MLrMg->-h!7TnPRLYC22)0t}Diy{3Ji^LN!r z{q3RDK*0tMmunj!xyO%$ZA>JY4{m=#=bq6Vi5%v2(`U`pnRAx}sk22oc}IW`5$}0} zSEg~w`?IAh65FyRD?9#9H%n6C^oEfV!-LW9nW&TK$Vih`n8+@lSe2#8f2cz|uCb8?|*_mLdmsR|>;a^9J!?KISFp&N8_B~chq z4k-@sxv4TRy3O3Xq~E--h+vn!aVM_u8u0`VdW)uJ^~$S~mGg>MZj6+Yv@DB9uGFr{ zn%dGwRLoJ0b5k1mgVtg4naATR7=w!rm$WfGAHND(Uig}&MfG5fk0D$8_B+ipcIM#t z8~K_pO(PW9oUfMJt#~X|UJjpeEN-Q}_^>7t299PY;hG%j(pic`$O3wKvTXO3z9t%; zM^*d|ecd49qAkCz%!Az*!m(663l!SMsyeqRaA8tM_~#kQp_$LNy%`FxWBjog@V@3T?FDSMmqGK`yoHjoluoE$Zk#SBW!Km7$L-& zyll*5UnV|Au6LNVS2VsuC$iLyBuAsCt1|i~FsbI)lqVYCp0%U)^Z1Z}C0tWIQ;KdK z)0?fPFD^0b_?uvt711m@q-w171#dT3SsF(n6=sxs@P6E65tO7R5W~B4WvgsmL`{;up6SRUi z`(jhWly1WYZu`-v(EEtMUA=b@Y+U4L+{c~p7l|Ztq9dJh9+{k2Q~^+?RG)*X?4|n# zj-jJd|(TwlC!>f@qL(apk8@fQIv8|h1^4?gUw}$HjU80B$+(>j3 zKP#rZCMl^q-~-=F?j7Mh-y&YUyJoaaes;0SvY%70u}?)K&^Ww%jSE3TRt6=0Ykcit zn4&Wm6b@s(i&LZmm)zg~xY~?@Ie_?y-0GAbm{E3|YRYmLd}Hnm6= zpm<6IF1hL+#8WiLdDr%LpP!DTK;z9_oWWMb-+UT+OQvXYwS(WDb4NA2Nno0*n&4tF zd?VJ}Gr9~F$8Hgj{|xCA0GotCu?QK(AZ9ftfXjM)4qzW2(#?Dc@)RGFWMuj-j=(T#s4g9({`|+Ny2==X@ zfKE*M8!D{RhU{8Cz6Rzy!RH(Bq@=sM^G0cT)Gz_Z#ly`Zvew&kz1cl}=zS+ug-F6X zhk`cks_xznQr_(y71u1AQsp7FhKr`<5QDUnHwpQWV16ow?W`r40OU5{ z_KNQNuHx`r-ZslSMRlj<^2)cpkZS*9lurwJmJ?yP`5a%>`K`_c2JHCp%zM}a7`CN+ zyj#=uTzrJc-1!Mn@w2B@>>K75OU9@>7JMb@5xXk->{8A6AN=|{e*Ro1A=s1}>5O73 z%Db@vC2VbUR;TFn&sR6?Y+JBxL9f|~dQa;KqcNbHE_l2V^s~ zi)7Dqz*3eU!b7;NG<8__MqfvBXEzK}t?*>P06WqbUo`AN@>$pF48)8BqsDB~hZY>I z+@)kzQN~~_lpFi5ywmLD_Girj*lYWT_^z&6XQi47(85pla|98;ZPh+7<^S5kBH6_~ zRdy#{jb6p&2TjAa!r1g}ryUTXFI>bG6WlaMH}mLQPL%5W>u!?WESkMj=?y|ZEi{Wz zF(_!YUok6;=}2b8C7i~N+$29-)~{DS+3>N}U+;kJOLmRtkX{R@so5bu3_ z59fJcOq9~!1G)X_VC0FQAY3n^Q)pQY9jgnnDmC)DLwaOOtDiBwB<`n7|eBGy~%zZza^tXl{ z)!5pVCTUktQs61%_o^r-k|`YTySVMVRbK1sJwr zu!`lnPQ?&0_Sw)|#oIeYvqr|m>q98#a$jQV;UUY_cfqPBqh(cm0wV+a)Mn$}VXG#> zwW&${1A`u)lTdZ8IpYz3iUMo)ba?wZ=gD(=KD}YlFw89YZeZf3?Z_OP`!Bk5&l0qGJi1ruU{g;B|{24GGzF(erhBRQLK-D0PiNvV= zu{rQ`9DaMz_qq_qwqI=!Vu$o|?u9Uz#)UV}Ja$CU5=o(u-}>7-CkfAl$UH|kMqK(t zL^JXv2TQhgCLgXGsRO`P0QMjc{bmR45A1+b zD09=Cf;6TpVys|%`>}~>A93@nNmD_YGZOs%B!4@h2+G zIr5s7b*8%p^4dKE;avRm)4sfYu7>M`zO}%)GD?cX*Wue9P50NO+wgp8BlVK0l~%nG zg@na5#Vw)>?EReAp@* zHj9@sm3?S5E%oi7kYv!Dgo2D_^1hX~sd@3ahG9ju)>6^JQ%o$OC(DaZU2Cjs8?j?? z;C-F5e0mgu^SOfJ*Lia9b=g${m6Q_lsT%vQCJV-e-&;k`V1d*lagi z?x@b;SgB!KxwN0%>*0a8?ZoGEy_0shZH5_}v3oazr4r2)(HT0PK4eG5^V|O(1OC@e z)Y{qW7im}CeXf#hhcm`aAfUFmF*X?)w3prOOwUuo*aEwc6ki!DCSupVdlm zGtm-*c6|FxV^trknUQUUe31d~DcKwJaD}u>x+i9>Z6T|I?%b;!){2xiFCu00UWS0s zzPhd=4t0lWnSA`mKp$FnFsT)w<^FY=+0~Ut*QW!wA^CVtGY;e8SJxLKusxU4-b&&k z4;9fbm73Ph>5iNXyw9i{d{_nqc3ptF{XAekG8?L%I~(1-dT6T zh6(y(yShoIQA9NK5;Z+^pvtV}##V0%l{L@7OlcSBLDjmTHvk^6(`H*UB&Ze}=Iz zW;>qPS;rP3xr%NZoL=MxTw=HNG}@AUNw1+R7T1* z29i3CcHZfWP2AEjLY=RX@tAvX_XS2TDG>``#qFzyXlZ@|VGRBMMhhtz-RxRaR)=5KS8i_9*_hG#|4$6y9 z0W!UzJR{=N(Osm23wF+XWjA!w7Q`?OmWDm(?1 zaNruSRMpjeOQneTrV%~3*%yOOPUZT)p!E_}2B+UUkf00iYkTsKgXoif)CpFpm-Hs+`K20?X(zhP5sRr7=@Fwl2_hW=@1hJd5j$tK29 z0Gxf#BV*})C2pERA>kC~dd}!xijQ<-TvY zfD%+bbA43WWjryzqBadk+vTbMsw<4D_aFd~WL+C-qThc0WN&i;AjplV!*U7F@< zN%@|Bn-vdy^Y?4P3E)Eiv5SxJ|H$50kKI2+S)$`t1vN`>j3t+*TWsK&0QrvzcvfuQmlEp5kVhWG z-AIF_?6_Gs@B8jcdRf|Ji{m%3yIF@v+yuvFuP_TW({=*e)f*IBU_mnmJb1i=u|-}N z{DLzZSI4M3a~QKS3g+Eu0ZHa&KG`k1k8OFaA?alTzaawa_c(&{^~|gtvQrl%y9-ay zyE@%$I9ZZY)AwXp2&zvJ-_r~}@@0+UgW#bmQ=!nfN`qptYLnmK}1tHoW9Td;PJ3+j9 zalfNs(fEoLF!$5GfDi}2c;r0qLP!s**toUS8A|qB0hq029O_F1Od;YlW9#F)@6W&9 z!Pgnb?Fc<#<&yk}@MNO5BHWbgXzQ_h$!?5~8my-x$X~F5ni^nMqoT@Og8Y&uJg$B= z!1H++2}|C3&?G+UkV|$c$)L@lVxmC$VM#C0-JM$E*E0Uj4fiLf&XNedxaKFW?3=rm zZo%(=YYoocG z$KSQ1nGJ-qI7SN$OQe}!G5S~IC?7Ak8$i-AN4BhJW(EVVpf9#V|$3WKwOJ9<(~geUosM<0rf6i$z6Dg0x>0k#i~uN~AHUVK086UCO!12uM^9CAk)|Lw zK~k}nN;j4N5WF>%Ri_oVe+gQ@YhBd%^Qp(Ks;%$axm~W<`n=v_k80;^ z^$kQBRlxBoj5Zb0Kxl^inEWim9MyOft0&u!(`BO7o?Tv(@J7w0Hqat~m2#~4AqE=1 zizb|3(4cPY_HX8+ZFOtDaUpJE`W?GQPp;&(YE1}y=yq+=<6$C~KQ;CZADx6S@!mIT z*B^~DoWKFiAcWU5NNHVglh;?hn4N-(jwdTvDVa^TP zu7f6Lt4nxpWI=virCPvIq%!`#}(cUugtZKKaGozLF3AKI?5t6S;bY z=9d<6!TYBgbmj+PmxcP~CT39!3-Jx~T_k=DFO; zQBK6WaHF44Q}4KwX=eqOP*JmD=|5Rjdx=tDR&iVzR15*% zL9=!sc>M2jKyKdVZtcLaLganE@k4fYTx?#MbA%QwIGn&GZjKu16cWUx0^vJ=o}A}* zy4#6veH6BweEjBoT zTmw!}*q}%9>NnvRe~x^>7uFCZkCue=>sp69m_{EBh6)q{2~>RS7fQGZIl$Sr`@h8TJ-6&YD&hBp=1!vEL^8=+7I`H#jnq~|@#-B`0~tX_r$B$9;JYwAeq%Qt=hD#;_Z(XE&;i)Mt6$AM-7@0^DMbXsShVOzhScr*JD0s{ zBvvKcw>!vgd5HOP;#)_Dl&-vF^iVH_l~$O8Tiw3x^MXIJwQTu50yJ1Yzgk(mAKZc` zE?y`VP}!}u7Eg4DyvtPF}(9f~jP{$`ro&tcB<^74zigS*E1 z$y$CV!fo_=_speBa)l>pyo%H~Bgv5A=LlXP*v)_EGmP?I z?D28Iz)l&@#@TbSsg#Fv)8SL-!r-KA4h9!S#8k!-P^hnRX^UcMKAnjc< zZX6?t)FQ$6_9N{q1>!N~@`{KE!&RwVoko#9pY{YenTkr1b7T(xgSJF~_yUB>qp?L@ z04N^8z{3w*QwlB?fe^fRmy|b2L@_StBLAjyBGejfhs3Q+l(=q|8Pd?6G52 zyB+D_1Z@axo*jOeNOFLg2?Ycknr|8r?eGUQng`@q@uBir)x8s85+AkG_aZ3#hqhGw zuAzXOzwH*}6s;k0Px)56q~@!a7%rnTC-B{G4;!YDvJ;mRGRVTE-RPc25cBOV(=-MY zWM&WlBEN6QW)#SykV#lOyOWp`x-j&!OJLvuZjFD>0Ks33LZ(3=je4S3ox}c3$>hu; z8#u>Tl2$}Mf^o8+qXKK#D1IKO(}``W8?@KErZ}4B2o$eqfjV8{`2B0hLa7~Bydq9f zJJ1E3t0k>1!$<+;R3`Sjq)PvsM6I1h-hvw2yx(2GUP_8$v$LyeY8u+?XB#*pMgjPD z_%&0ljf|p8rka+XZgKP~Bo{_I-hd*DtHw&CQ@)>bAB>o{9))M#@j_Acc%3L` zoY+s<T<+qRQUcXvXcX(Ak=~wAG$0ZqV5O36RjcBYMYsU&TwgcxoVGf1zcuO{S zdG`&Js}3HrJsi+U-Z#!!<&1xHod~_5z?<7W_waap^oM*5e4Et|zE@u}XCH7ME@9|N zuP`R}!!hs{?YmWys>E$lYpeM6wfKIH7Wz-WT}{+z0^4Y|QtrgoeZHdfUp*pZGSO`P zLf#`w#Rd_Hz?*P$pxZDPDg-o6gjubCs&Vn^^|~j5-`S+rmiQKAL>y877pLF$H`LO8 zzODmv$nmNg*!PI(R12PP-;c4W8`$2kn}BIEt9coUobj!l8zv*+>@SjituqHTvHddV zShyR!5m6rS%bzBQ++PkV4F|`EUUQL$PYnwfXtA$ifk^fRfTC6Xf7M?)?4AG;sN8Rq z;OGHLZUd6~J)o`&{oOeF3Vmf=JGi+oP$Nu3d-P;`7eCJBqT$@#8KAQd8G*GVsicWd zH~c-VbAmKh-)q%`)+tkf_%s>*s|XJ0s{>lQ3FXlO2MPHl*fklRTX{g%7yQREN5Az0 zAbSBXw@#g3@%!)|NDBzRqxt{z9u;J-C^{zk2rzKS&9h(p$X2UYg51QWz+*vSm}hPy z?H9>>-1T2HyOz)0k>ekR)OB9ZQkGJ@J7w*GiPD9eAJ(0gP02taCJyoqs=5-ZMpGsE zljFn=BAbU;CG!z12U+L2T{0^F-`=)aU6@-oPTd+{Ep-&I!eaoFm|ed~h}rf&uSP2! z$_LPH@wcET_E%Yx;1E;L;BDxEOp-Coi;p#4+>-y+5LAa(@V0jh3pgQJ zU|x|W_8j4n;JUGKa6unju4MW8ATdK3qBC#Ngakw1)iPAu`{otT8871S$5{H4^+Fvw zmx;=>y-iS_uDvvt=E$Bw~)x7&;ceM6x62x>ZleKJ_e z*IzU)nlgqih=KQZk;Yc6PER{lAG!>fvoDkWmu-dwdX|^7dT4`USGY($y&DkvH5{pz zX8zesoSXqI`>M?W=}aaM=q@;uFWA(p;ubMdgl3M*Ek(1uV>!s?V6^!GL59O0$eA9m z|0=6~LqpJai;F*!HLW82Os>DV8t0xRCNII?N4}CCb)YEY7yFL6FK`j3HJ1)dWJt(Q zMvTH@1_ zla7R&o!Gr~3-@Qq!5**FtCbd1vUk6GvAmAsQNJa)8*O;7*i+0F-WGOKUr0njL@m@O z$yHp-GB9%WnF7ksckfTNJa?*VEEiu@P6HPb}e0dw%=$iWk;kF!1wJ*P|E$$K1I=sR%m$k<+lKKEm; z?CV)s-?f+JagBmK0G$0786L(5zz5g)5A=Z0$mR&J*qX9BYnJHE_{w*E8DRI#PXB{s zj^_T6+f8(p1Xk#dKLa3`=HK#d!K!L6xjf-sHXz`54$DJ)L^`|uhEp9rR4RipI>`Gu zgbeTgdyrwQahc|zC#7Ha#q+VkDdW=JJqMX)Lw#2V2Xnb{+uEFk?us`l`+nnjgtf_b z8MgPn4t^rCW)C(kpzd^{h^!_2aBVn8U(ES<_U+8J=Af=_=lI?4x59U9>-tA(5yi>+ z0p{?2d*AC9ITJdBx9=!#YX1D0c6Ph`Cao@C7rE(Kv1$Iq()}gD^ta5mZMyYAtZYkb z!2#i9(ixWzT*6lB3aS;KwldnP;rx3v|2om)^m$(X9zk7thI{w6(%wILlSKy2`f*mij306J6(4mL=+fR5 z?&_NxoJqV_3zr*KbGke?`nbc(I(Db}ZeJkkqt5d`#s=Bm0xNeqFI%uD`7pv>3wO(U zmG1Q-jTM?}WUVjo>{0RQY~YbE3t3pk*e21n@SRr`@cNESq(JCbp* zCG3`i-^=Dl=TB5`y|#Mvm~$m)%_dco`1^-@&lL>mrQ@$&|DqW6@WA+15+TW*tV=^I zOXxc8imdVb(rhu==7wzYAzhz5lEyi9KD}N2n@6>~G+=B)j>hHBqj8~ea1*$@HKTTw zjWe}3F|y1hLR0g z-a%gAH=GOswLYJ;0G^mjh2zhEH?1v$ZsxFfOdS-m@Q?C7XcFbAs~B~43yr(>Q0MM} z*FMPfB%vnP!QyZR;fD#}{bDYb){wug3|&H0DF zXL`?<93H~*?(Ql_;^C?>`OZH$sQ(GqJ>Lj70CF{fWYHpI`O|kl>U|wsY=RJ~umA41 zLT_p!j5O01vO8QVo~k)s*(8_!t99oCh2_yK(aoilJb5qD(){VFD6AyKcm8(k)m~G7 zD@WuJUG91r9Rl{Y8LLt|aIjS_2G?h&_pbmR2U0iLxA@1OUoPb#b!9uo=SFc$=>X~l z97josPU-T1{k4mcUsTS^aY@LjP>iD-kf(+5%3uEX^xb?B`EtK<0sd{c;b4XW|Nbqg z#b6eAcNy;5gT%{sAEXI9@MlQOo4EML$i+XluB$8x^juMqe!k7O&@o=?69o`Vwd2b^ z$_ZQ{)<7%)Y=Zr=-oXz@$`w2YZrMQItr`GrYCX}e^co zMkSSSn5J>PS6PK}3o!9!#eNpP zOcC%olm|d|28%%1!D|@qvBnTc*oybhIR$FKp$fmH&V}u3?Kcd5+a%$GJ%dkiTn2ca z0h=R|tNpJ6X2+R~4%)cMo$~`fxesolqNzY)%7m$xFp#CQvs}00zc;xr6uN_-^xXLp z6ZE_5R90MKUgyz5PCTIGX5j~mO*&=uc-vc-&#sR9O?vqIw^M7#VLvP=)EM9elws3h zJ3{wD*CCUpe(J!Q)XzBTPlo+TD)xI0b(fn;oXXi<_G{u~JAdhH_&kwFHxyF`w9gwL zrhwuXN+6;@W_W4^kmQ{2I$o=2G1*^m@|*kB7kISWI(0ATjhito+x}eG8c_3zAG0L9 zQzwr~t$awE1R*DQ?YH-M^!CP}*4vincCKTuy${jz9@|{*K@GzinFj*}kXRJZ7%;9t ziNIFLej6=Dl3ig3MQ(PpPOwoqRcdR1eD+w^fgMV9$c{K#VS7et_e&K}#D@H@MoI&X ztT-6O8V-58gT4grfL9uKV$p7sq0~68h1tNZq>%$jCr#UlW|{6?`oije(7icHjst-r zUB3faC>93~T_C=XGnoN%YI3qDXBWk#@&f_+$+0h81hh48-CZ82NX2fv60kC!9<>henz(D%} zZ(VsO92G0|wIPJ?@0I4|^ zSm0!nkiXWxaUz!_Z;0D?ZOg$gR20_&)ox|~`KaurkV$IdaK!m+#wD-es=3dKd9exzOR2H-ngFMmw_Nb|$dL?}=zOFAEC9;8vInKiI5 zScncje|Q(oGrv1wC)ldHUisPBD0X&s26Fc1HSK6;Pxw#MH@Mzq^Lb43@!pT_wTk^s zbkjl@b>;M)%Z*#FKfN0))N;Grbot{^F&~$o?w|DM$(9Q$aBJLF>gC#Nz!u03#>Gir zq?t+!7=P9mM}KDT=gPh&T`^UQN(|d)fR~ialW`V>KTjVOu{pKDkaLno1|8D6Lfzh? zzWmNa^W*iJy23C33#bo(wz1+*mnj}yOj9P4+(dJJ2%MiCmR!g|NRK_J=#TbJk%brz zf(y@!6GzuAu>n#!h1^fw=*^ZJl$Z?djJBZpSV~9_Epcny^X+WXJuYwQ+`)z9r4f-3 zfWfECLfkf5<3hcMJZNaRyI2({360yctRWC>no~zNd}Z#iprLiN>Ya3JO8N9ma#51z z#iiC~ckON9Kl68wyX5<3jp^gdX`!VPG&`S~l#lLw(eQrvwENV7s8Ui=a$`@>NWRzC z*9V5-@caw8cfjdqKcfO^K0cV96gax+V~_UnhT8|HRe0rVv_}_wRC{$L0Im$u)THdw&U6QBk@q^)-|oeFZS_E78^e?VK@cU z_&@h$Yt#us#h}-gMlRg`b+ho{6tS)e+n);AYeOpZ1Af+yjCs?T8+$xyr)~jLkA|&w zq(_I4EuF7hs9F5ZW2tDG?S*B)y1~kvo@+Q|Hcq;C-)2%K5C!x^lx&pBzK=T(+D*;5 z=EHW>D;lBS0A0D1`waf)=mKI48ej45^>GC>G+!?q$xGrgJI(|fLm9zN+W)km2LD%S zeJ!5VlJ9@sy28{(*|4MzExPqIz#>}DGFpeefA?hj4HE-REc`$W6ore4d0~!q@W>nB z&||q)To4I6k$Kf8CdRRA7X*5jiR$%qW;9W7a@Ml~z0V8Kh{zy#Fcn`{FsP}i=|D4f zkQ|1m)NNB_b2a_;M-%(EJ-2#XYc`)(YlaimRGIumuXgTwn~S`5S<_F3egrr9#++mX z`D3qS1cxpxxMw*#S&h^;C)$dQndT3?Lakg$?ijqH@4U0M=?=%tentg#fI#-k*~|n>^%$T+N2Vgd`XZ8T&^T+*m)R9B z3~3t^pCRYU$XGIq=|&fqRX+5|dYv&6y3i62^~6;$iaY5Oe4ePLeu897V3SwV7z8j6fQi;&>`*z+sbY|uD!{H|7Gid}E) zU0}QSgX2Z&TVVQEe65cljzKBo$zb7F!4?i;DAmm;OtK`d*;0&&pvtRfWdS)8wdg}# z&>eRx)wO6Tt3RE1rEO^JYhhbro>w3OZ2+nw_uDeA>9ymj{&=y*fgz?G$Hl1lz()-% z=L0}#b(@y1rNdN@&`_n2Z0QC7zs>qJ1s3fy@QGfcb1Ff4PmeX2@<}LINF{=~x_cw^ zaHa??2|;oSd+6qS=&To`Yj#50x=aX4{)d4sWeE+6B*ZzC)I8BCt~XDumCsDvNQro4 zm59$uxLNRH>Z@_72=s^klE*Ol!)+Ou9vLFE0h;}mU(K$t`!J3p^nK&R)@7DuUC;v{ zWz4Jzlhzstn?X=FY?!&|K-@3Ogb!NX0@Uq+n8Ci+6K7kHRaS2+P;(+C;P zw}|bAe!7OQ4_)40wwE6(;q7(vQOFMPR#|P^B9Bb(P)ZP5^zSWwT&+7N>t_b)ZJS-7 z_Pb9Ra$IH4X@soGG$=^%#lZJ!lFqMQvuQf8Amcx0s36za2NH-qi+`|`pSvt*jGR2W z5Uu3zIOE(&<^2M?-hHToczuJk)z5`TsO?7(qzT>P5N^A)BynSdPD~==@*p56;*boA zi$~c_e8c9l@qo1nR;f7EYdKP$-9L|8D|^0wj)T}ygQYCPdKYI_wOlb({jbCK9JBdB zy&O672rYJpXL1m*C>B@nDVpbfP|dJy8#)go-gKL9|D7^$zm}YVx9eWgPADjV7Bo*a zP2j#jFIp`wEV~3uUsC6Mw}y+pootei+XaQi1eY=h zW*O})@r934#B-Xzmt{b3QIl+0u!VjOPOXiZ{y9puy}H?6oWrlERES;`8XB(KW;;*3 z)^L`0{8k2~M2&&!^oDnzPEG#URF6ciG2I`|h$BEN3zWpfRu*tB-D_87RVy&#OQ40o z8jSk^V-+zAf5VXAqHC|zw;}(;u+jo~;1G`o>owccKiu(2- z9B1oc%qQ!)Qv5cGXQ)cPSz1|;`tG3*BQNirr8~k z3=w%iFX8v|Cp5SDhTU$LVqzfa*k|@31)i}Jn{S4UKzXlTJxmGQ8N$s6ewqq$|9wRO z{V?mjE$H&7HDAe76T7V8K>>|ehmic47WXI3MBPw0J9!g}F*AJk>cd&FEf@K^y^IB? z!Ku;c$xJnuv*bVngzh{TEsMvp)L8MIQ@J`sD^fgk1Q) z$;*gWZ7Nu^4agkhtrz?N9J%f^0v#PUw^&3+kMh6)B_(16AuUs0(ogB zeqW@PsWGH-5$XlPaxC)G^Jl-7wGO8?$Qj(|t!foPSE{-aN8P?#o$Z|um>Yw3?3%kK zsM6t_%FRl&sT%^vX7S)z%|jWUoVHeHdXPGGxQ% z*F+NW!{^tQaNnjmJ%Z1Q%az&hX%RR(2H4Z=$j|VcIfM4;iK4%YiHq;NdT^Fd4Aj&Z zsY)ji*|JTB7-bH9?V3eF$4;9l@jyL^-3?>SzO<_oW9%;JPt4}^zXc}LNg}pUYkt0- zw$pY}ZDuIc3pA+6v7vf0HSKe=2y3`=aiQvI6M3PhilF}1u2syBuXLNCJ=zW+>})TD z%S5|>7MLgoOZJwDB<%n|OXU?+X8`FQK(t!f(k9~{i&Z}GQUe2dYRPXX^g{!Tx ze?hIeR7LBE8`Ll}b@cYCC&0$Wn3%%18O=c@a%pX^UmIto$rgP2VcZ;0syfl2v6J!~ znPHZ*one&(pQ?d%+M2jMNWC1idMd>QA30dFogOe;h;aoM2-WL9c<)Txm%_dql#~pC zF^vxdJ9}wa;5S$9Se!l`WD*$!OS82i8el*VUXp4=oU5X(n761|b5CGfmHWNK6+-;P zh2y?kUjh)Aky8QkVLp~)ad1bM;&{&@Jaqz_!e`)3=)kk?j5mJ$aJxB)S>#Hps7yVu z>2QN&mz;`U5d7N835dmw6{Q`SlD@n=jAb|u%o;j4t-Tq(AhRy%fqCWMwp``I zR4P0sN9R^}wjMWe9@i_`1H=#3g)P8VQp;}qin|lQo{P=RENpB;&n$8y8|_-Bf~&QC z%;JEsIhq{p32eVI$?rBdOp%zh#iya_E<^g4yqKRyy0G#0ZDL~UJkoHK&>Bx%z;MSp zbNCxOQP%Saf=6)&GHwy4e;pslQ=J^3K%5nmsVgrlcMjhPc()Bf+NA;onz zpIopxPqyPyunHi4t#TDzA0fTbIL$f;>?^YK3MeR@L1cR0kMv(8H{uYg1d+3RKb8+s zo9Kz7cd~La5^&9WAgNF?TRXW>&XpBlkoCKdsK3eW2Ulr*@U7Zsnw8(wk_;oXqnM2T#U$g|VDNiYQ6zq0dF`V_1V@?Du zil9^efIjxxV_nCjx)1JFT9f6UzPWTt_u5N}*3@vjx3F)8fz1bGjE9{44V!_1U_b9< z8;ZBHBIeLLdG6RvZUe09;UOu1IB>xxt^5Nj^yhmVwWA_qd^$`didsa7Io*!VxqVqU zcdM*W^yu!QjtQbZJl~PH;fNvz-)X`jVg|kVhL(KbjFxd&lR1!`zY?Ko#=e7IT9uQm|C zf#RE1`m%Y_T7?nZc48vpMYdW<`5;Pz#bo(IHzahp6uF_83uSr40*P_(y^^Mz8Lpg_l%CTfTD_X?hqs&D`Y(bqMb~GOvqGjR zOM5wsF}iN^+zQl~)<|ha$Iu|SUdDi1Shf(v&v5;940OlaN6@kdA4(o(ETP91^lT0D zVv02b_N<(e!o5H<48`c#F~vLBu^|DJ&O5!9!QuWqX=U7-b zpdU&#_WdS@(%E?C)L%ySDC^;VE-VUHNC|ES5ru8bXJH@U8e*`RyaMS4M{^5Z3G~y6 zzP#4`ALSE4xO^`=PoTJ5kzon)W{L$dsh3-9*aWrmVXY5AesmT(R|#S(EolC!-r6B=|>}q8}VA8>OAKo5&DNH(YQ@Z*#~fj%&Lm5`!0YKarO_v36w zD|1NEaT6aRb`}%(k2B&&YAtOLy1${h>}3-?mFvzfG8#ncr9#ln%+DS{!=_ zMC@2Shgu@LTwvw&N!!|ew}~RFd|Wgw6V94Te)hODXlQSkgcQk07lS3wtdr51nEvi~FH@?ZpLN+Cjt%d~x^7*~`t9&mVt>~MTidgmO@lm z+niM3dPDr@kT;caYbVkeBs$En`Nf5k9kpRNg6>qTT(h(@++;o~9Z|8z zztc6DcY?VbgdWM{#?*@ry3D+`*aspYr2?LOdY)nEjFjKue z8+Nkg+0%x;{>I0+4(Kl{YhT++10PNb=INOSOgibU*;{4~^Zgv$&D=#lQz}+}&6$s# zUx2`iHUfyJU%dVZd^|krJJSA<`9nw^a4)}(|C89gUw!&Nhrj%YiN894TJubyP9bj7 z-{R6h!3uUOb{RsSjjftM1rfvW)sR`4B9BeZYx)=r3Rl(Ti9jc%;9x}9;Q#hL4uFV}V;n8zUlTAHo2pu{>^GsQtNzaRvk;qE>{VeT{ z82mYQ2#l%q7=zLkm_<7^6s>iNOTiU_7vD#F{od1*>ttXitg2e58>!5hk#SXfrTYSX zGM1>Xmfyn?Cp{w`6dKACQ#oW`B>8R`RuvNai-`V+QQD@Pvc8**ZRtQV(|(9#*l;pF z-=J$$jGWwV;cy>@Q_gJduU{LIr2Y7ePuFI<#YOoN%gQRu=bec}ZOLMd`G;}u8KH6W z91H0W#qAXXEwp#9{2F-f10J8BX;81oWc-wFhMH=CZz?Y_V}`H1Bvdw34J&b5FWBk6 zgen(L{`}Lpz%#cmzrBU3ca9%f)-&>H2u_Jz5maYzWK)TZ;p=17^XG;B{9JJkc)3pt z7K%V5TlL1k6@iy>V|%gCY4iJ!#BQyXNX#x2eSZ1k;kQ3$6CE(lNyrjod5io^RviA@ zUfxZX#oz3oiGHX#&C|P_TvuS;xQ6m-bt|T{Mt8W0VZKg6Wd@Z%zNbMsqno?zV*><; z2yYYiX4{iE3b*ZLi@WW?jp}0b^tFqkD!OLSnovIdg($- zVq(`7DM3>(VuCZ(X75@RjM7>;Mndb!j>7Sru&rlRb$Z#`B1m1@;BGzdCvs+cEHSr} z#Ybj%sVSu?8pOcncs7pll(3-5obHhUOOzg(R~w&81j{U*jpb#ri0_&M=?uOrfc13e zV7??j9G7Fcnxm_FdqfQivY6|8x<{{3hsRD%jVf0EQSeai>{ymzL-r?r2+|PS7ziq* zTHati8l4xMkAZj<)O|Lf7JKk<&%ftEpor;|>GqJ51X z*p{`2)Tql{+G=*H@%)twASQhwX|6SQiAnHHLm&od_tF1ooHxOKi#f2p4$y!juUP;%zaN5MAY0GZ5ZeseS zxyS{5WnHvVObaml8Z7~I8CU1AF~zd^T+F`fR`K*B`qMlC2s5)y&u!OjYl+)p7kY=5MDKT_bt1<9u}5xQ>i zao)Y7bSF}II?-8Gt{Og7skf>9rqsfe_3J{ih*170WbdqeQ8M*|gYs6=Sz0VLUprOa z5!6gEWa?G(BA-1UPWH)Hwu?#Bri+A{ej=tQJhc|$vq5Ml6>9_AFQGqsR&D$Bl{-Z0I(k6qO^G+C@UuuI5+5T;5x8o}Gy5 z-0cxS4X2ijgLMT9FC$3$Ve>CbC#vsojvULL?fszgH5wZ8+l6)N=_&6p&MDds0{nRv z{*5D+;48O>IQ-usbcxCWo}fUzh0*Oyyj+}Dl_^d}+B>AP>shX|cvNMk&P{UG2oww15SkU(l=fd8O!5{;_AUTsw z8{cHc75g*mUUO2Bgu73?2DKZ0(#s18v-ekgy5YAv%$H&6Dn;KJ8S8efQ+sx_f**}> z1}_AK+M4f+4Mr+`Nc>cM(ZDuou}@|tbJC;0xeIJy$Zt1mBXGs&oQ@-jL6w)KKeMa$ z>1?|srYKQ$tCX&5XycC$9fRS*xM6v`PKofF%DW=)JAcXJp6&k0_UC8PyYRe9(_31P zWlcst6trvjkSB+zgKqKUdNn51F=&ZksuvScU?n88vN7~`J@;pNgZ*hb4l13U#$YbS zW!aB@DqP8l)}rZL`Iof>pLAYwY%t#bJV=kp8<45ZNlwI6T8nn>z7|QMuQ2poyGvi; z%+Y19ldWQ`DR2j#rOvCtlWrOxJv`@?u8$Nf8PD|~WfathsKY~faOzGsYU(NDE)vd2 zy!F#v#_lSLS3RMk6&|I|eUV4ywoz4Y6?)Vr#WzJT;fcM`Z6jxV*dl+0;W-`A_m6Ea zR~#dM$%6&gc#OY3LE@z6&P|q_tJwMZ>f6iS^AlMzP*>8^2B$YIQ|xUja`refWGkNz zyN0SJl+9wUJGf3oxK4~yN%@VL!E*k?TTIMgL5)w zbZHHWw?gLl=ca#n$9h%y1PJY%l*zfgpJ^cd%VM8W25_U$vq~wS@`2*06nUNtfOpoX zKiv%V*z1!=?J#r0i-U4iSt7CD5t9{YH%|rno(P1Px1DO|nZk}J16p*3hK{sA(m2oD z`9RW29S3aWj=&d2ttTd~f+KS8L3A z%~44x?ODR<~4gz_%n7cv&mGuOfb0;WW~rR68_;LR^%zu)HdlHlC% zn#A6CZ);7qlV~}?-|@L#g3as9TSA%Z^+L2k8o5j1KtrH!pUj3R`S{JKRfI4^HQ#_G zMm`n}^Bv4!VIKE+>%hyO)Liy%;m6RhLR0x~;d5N;;m=2!h@A*`@nggBCJMm+WuI>9 z1}bd@y53<%UB$3QUtYW#Jd;HQjeu3$X%%8s!kLzY)uVZ@`d`;gS1hj4rr*%Q zEH}O@uG5Z*DAY4u9y03m>kTrCjd8TMwnVXM6eg!d*?W8IiFjZFiyxqaAuYuV-w}Ey zlBE;btZRjiJ|hm`#0%r}XT&mi69&;yJ03XbZP8M^8P11Kn-i^(5!8p6iq>W-1vU8_ zEv(qY-va;9%o!pD|+H{O6 z?e)2H*ISo&Gie4zEk zij8aI{L;Vz4qb=lI|c!XqbYV5Q3rHHegUvS}v@ z%=dfW{W8#2Vy7_h^goaZ+H6qclQMS7>m#lb1kD#KU0%L$+BH_qwoVm_z^q)=*^3$P zX$cOmz}(1bQe23|vngl@B*`K}?1s}GH6)K7Q zE^EEQ>FOOAOh1sa-p+;Q=zWt^y(c#|OXC ze?G#Z<&N`)4z8PGJh1D39sib0ppTe)Ks-jk){2D=JurXa9ORXW%>V3IV_JF?p0bZ@ zM*ISy6+l$?S8M+%Iq5K~=4-0n<41BQ=+z%zR#O!=nW=U+Y7C4s1q0z09u#6_yu4|` z%T5|5CHy&ig$p^JzgByDTa_QutoUYV5R2%yf9Q~;QMzWaU_4S;Dsc|8+HbZklt@bH zDL9U4o{BbJXYQ(yiUN^f2Kj@PfvVCa;6?pgjzQkzp2}vQ`mtW!$8nNox_AUcr>IW( z)U=l>hAT(~wMb-Rfi}H&@T;S+np$(>?{)u*yd?p)%iS}d^Q@>?v4Shk#v-azieI%i z9LrG+C(?VMXjx{2RfScRZi)Vv_y9XODxlmagjUhHSrS{|8p9F=^4iOYT)!fEuMftm z8=$Hp98bdb6ePFeS-q#tjhUcCf*;;S{ju1^b47JLP(43{Y%EqJCNXhq6l)`tx&qen zwbHqCqDeY%Z_(p#KI1mYmj|CZMY$^T^RA92Z&A8j&y>2IW#R}IGTMRuXP}(xfzr_| z$Z~Y3sWsN`{NQPUidf!qw0*AWDU~;GMz1c^X-rC`E*eM8d1A)QVXPLa!)A{;Sx3%` z{j%y-QGi-x8YZm%38p_^6D4303ps|g$|*gP%Xhq71jBeS*5aLcl~=9|Bo6!qYB}c* zKrJ`xNODH@Q@sEH!;X$Jn?!Fw3!YQ(iwEL$0Wd|$(yr)?NK+n?{`;?JN5@(W;ZBk{ty6xrgGwEwpIcYrb@R_wm zKXmsQ)QC%+IeVJ(2v|dv>!LAOZpGtw)VVnbGF=5}v3CW1dfl zXJQ+P!m-ZQGF`vaDOLzD4u(nlvD0mq-0Bsi?)Nr3rF5kG6QA<^?^TfKe>E%f{z0?u zBy|nTI@~C=cEl?eW32-8k&SNV^ZH8Z8bH_d98JnEMzvL{R~g7ctjmq8pQ^t7x;ZQG z{_%683PWpri~sE;6&=;pnJR|wUXe*L&TW#vWIr7RBt5<*@O$uRT?kjcWD;9=TZz#~ zyxr*gSc|>V2pws_Q}0qjSH&%89j8TNjI%H!7-MPJ=k2d*QoHc=kI!C-+Yif^>aO;F zcW;O0-Aq7;!~(Wlxc0wx7J-YC2GU+rFlMlfzG~O4$|5;4nCBanVtd2Am)iNgxORi8 zb*T?PGPPMT-6z)=@5&By1A#a9ir>C`*BUct^VLS~VbxOSZIhZ{!YRiZ{EeoT!VKZM zXw07xuTFGd6`D78!7PK5e8HU|p-=Wc>7yWyAXf=qc^18}A^y7WbtWUJ|4H})fbhA~ z9%aX#Pd|Br{%XvH#S%GqJ9CLA#qrYmUxO4z*5mP}wiLXDG=Kh&GsY&#o7kc*-do&f zE?1`}le2E87_}=^y|zvXxbdMM4_^}+T3kDgyvzX#d-l*F2vrJR@{Mt-NFe3e{(>e? z0T=x7B_`4)$BNMtb0R1|VJI16*)$yf7FE1&0(Iq~}%~tY)xD{o>o}JHD>}L(;9e2cTeo z9si!h+Dx`-w4Rwc++d%x>A`&)b02f}zWUW8L3;!A~; z>A&VBsW(T=7o1h?d53a8j{l}OO_%r=xhSmxE6i(lEOrHbs8YrW_zf|#naSaT&2Nd( zJJv2HJ6Kiogx3a%-jy#*Rs0;f zI7FzamZt0R)RvD^msg2g^$4{q+{2r(=b2vulNk>b6LARVT|E z0z7bgE?(wLUJo1bSLYkndjcvFG>R8~Q%otOvnhpVmFFAUoOiIaHSUjY4hV1Yw9U3J zZ+{-I^LA*_zyHr!>)~)=DJ{gIfBj!EzjdG z{&q_^mPMlgo}cAGWW4k?nJbBZ)}`5qR3|tM*rn#KUqshEEv!g}viW}I!cVXn zSFP0cou9d88p*_opNRj}tlt3MeCiKjjB|I=mG7zrRUyfTqzG|mHr&-FdBi$iMk^$-MKDbC<$PX=)Yv&2xN6+8unaF zUAwI9|Bs|Zk6E;Bazme>fU?*H48O^TTA8(5Bki|!Ez{W|PHVg=tjveRYTS|KP8Yh| z*%-CTfBsF0D(}T@>1lgoUC!Kp>TKX%(J7jb=ILW7=x;GZU#+OoK@_iFT5@Crmg6dj z5*!n3Ba_3)3p^T>Qv3VT@DpX&k`LDNYEs74zdMfkn@5-pb6R&cI#;B08pyu`*IipP z35N0@JJX`=?_BMRUVT-H1uwZuIoQ>V&Al1oa0b;C>l1u)zvn(6D|c7$HP)qsVE$3~ zUk{M?E2)`<`FNgxiimmITqS%_|1uno;RMK0kq&HMseVx0$y0J`4E#?5Y)7(E9JkxK zw_Jy(Qn;jM*3oyyJxn;|BpAhw33p)Hx>4E>CZlJ88DItZHL@N)y!)wPVZK8*Xsq7= z@i6eLi@k$`O0k|Q_2~vOH`e?5fMpZZgp=28#o#ruXwX^l&_gC)9T3w(Uvb=5JTWN}{~-$gDr2-Z+PwdA;+;}Kqn_udcd{@uK|lCnFq6;B`uBEl$jE>N-`F5*YCd@uU@>Nl2zMI! zEgFaoZe?KPV9zPB9f5YMbuft(#Q-e&zTRyEH zAyg4xQ172C4JN1)Tw*Wz9A5d5O4J{6b7?&1mMp(iIiNqFx%4G#>e>7|75C>C+4#Zw zTp9yh-VeE>vsvVRtE}^Xz4%AyN|ka@cY|BxPTuoL-+LO9G+XEV3+Kc|t)_l#KN09= zgPx!K&6sm_a61R}x>p}z_j9>fwpJv$gTob_u4Ow{QMoTXuBXGn-n+-ZV$?{L6jHLQ z_*qu0ftPYQgn&Jq(?aeZ?1NnJL?@QNN1^u(16o9cdbis%(Njm0 zTWB`hI%vc7_8hk^sS0^lE~{l>MD$95uraV*O?Q$q(ui-eYn*{&#+K?T4$da5+J@Dt zsE!KynbaEP07OpCmZ#NjXn_)u$FHiQ;%?4}vdKb^d}l-JC+=5tCg)`Q(7L+03mQ8DrDu-=Sap%#!1X>$;gJPcy3)Mo|W4KPa{Z`*XW^7$uwxS znEJd}DmXKU=%|sB%6gNF$k||H>3B-@y)AEy`Mmfz!)j9DL~Ux%)VOTJ*2E`WchuHE zEv~C$!*&>tnRj1vBoCI5^w;-i^o*NnA3gD^&aPaxySGP67geQ(2OIDi{S21DZYlI^ z?0v&oTvPSBGm@Rw{hixlre}IfN039kL#E-qy5@w`RhVw4p_6Q9Qvg^Em1i92%ng6AkudxFkdR; z{eXGZFnU17yW!rz?(}M*3NO}?cfrOFotl>l-_+M(>2P2Far0~Y&WZ)DKwzASbwoAH zHCi;zwG{|>@vN*zk1Tmo_9Ybj4Tb7tf@mj0rXBh| zQKrh{XovMHtMUdHtc6mB&P)cIR)dY<$==cnY_-Ovda<#-gxsd`!G+t!dHNyKzNg4m zaUP?U4c&)^Z)#U~|788}&P(FlH;uGW%~?2VuTO|_fue=@t~KZ@JbGZwUeLAwq%(b* zqDM2nlh%|7Ykdi>C5dX)>l>W;~tlYNHM@8Dhf%7=v$~y#;OYi>xm6N}q8o9FIoa2N!4)Nwi_`>sFt5mByMFkm$bN!N25Q{TI~ z1ruXoZr2n>83|cvEu^jgS@j)dFSjt;~-DW<0`LPL&K4x^7w(C za6);Y6yAx>Xbf7AO`6VHf@$FK&POId05AbB#XMIqNqVz$$jZA}y?&cI&|wi#%5^Pc z^q}N&lo_p?ooAp763LfNwU;X%7Lnb*t{X!b3kx2zo33O;z%yr?)75cv!cC&7#rflT zO&OFvlKOsaO8<1`CkU5Ao|strMADp5`pz8~;iah3@@&7g6dhb>T?f!${ILcbR8fH^ zaWUs_zaFQz;(zFk0k|XBn_M2pN{PdQw5{U}4(^OoedG}y3rVGvy+H5NXA(EC-7MZ1c=)|=kRY_94E+ZXlU3vxb z4jfXR=$+>RLW4QP751I}9^E{Wb>-nqvg<7GrI}))_1yiPGoQ;(JEd7t~3bGs7{C6%F8)?U;?l=^ByXF_BV~LtT-}!JPl-;ISm) z?s(Q?X&ojcOm)7&2eGiRLnrUQ49v*AwEd23-m_aH>C)mv49UXQ*cln(yE?NPk}0$U z)d76)ovi`cK}%yu6_W7P?w~k9WRB+&a+OI^Ec|NN2}!Tk!z4ICAYI`&HK}?g`d2u9 z`q{4^@6b#ibI4-*F@T1iy!-1(*vl_gkE8r|H2{N>6jj@xg=@{1W47yZ=`GrZPmD7dd(Op0%-Hu=r>I zDk;xcZMqd+xjdQ z{E;x>c)P7|Fg>mF4N7a>+mbYe#1fesckae5pUYo+gLj!t8mZxjH>pdc483XdqP|uF zuPF3H^{4a8J39{gYT^&X_G10Oh?zF{&T`0geD{?@8z%+y73FVvibbvWu77JLb#*DI zBQx_dlLs+THQXcY4G_#WMz4;p;vOj|S31c}MF z3#cE-aZOCD>Kq5V4%@NMuFf@#U2V!K?how9f|u}j*=_dg*CKm85gtD1l|4g&36b8nxr8OH+2TmBaZe=AnBLrqJL&hC-DmA8nxy-CO zoB(dkh)^=jDy$9Yjo~;pJeUHKlHe{}ci$i^U|45Rdku0R>)YntZp&KOB&weFQ7iN?DuuR zuQ}eL%W*5D?6;*iD(q5Vj~ve5!e2rf?aoAv1?-x45MHYU6iOsxFfz1OK90;zF?QP$ zy=#Ql&b*N)Rq~w!Q4Qy!wQLr9h>VC4bsyWCaZRdcViO89@FvHoM*x`mf+d@QGC}!D zN^ZlkrU+4G5xvpQVTyj>n^Fm-j4;`KQI&jSjav;%%bSTC71#+?)kn;(?B;)C4WRB3 zM=SGpo^zTk^u{F2_IFqNig3nQZ*?+_NPE{%$05wpyPu2&P&xx7Z-!+wwx?6i%PP-f z{t@BaB)i<^?}h(idW8Vf`@jc@L_(5jb>Q`@xvFAV)rvZ#m;D|7`~m7LEd$ZU&u(R^ z&P|qH+K@wbbM@nB zjk&%HUAm_UsX~t&nOKWfix;>+1@}GK`rmZ%n0izd2SrigA$0+?>oBURbc6s-93p_k z#rt6uby-==v(jv>Do@-{jc453yrHJv zXJBFJ7RvZcoJW&teqtVklqC^bwkM1qt<0MK4~Ne*+~+Q`=V0dp_nHaxvl1XR=tM-k ziSfuv?6=XD=B?J!@5LU#m@=QH>U-& zCdhDmT$qxmN~lzzjKmdNz z#v~l@AIO{G$m3%6j*gyts+M`|x}rOCG9Cn1>D3UN7(w)AmP>Aox10%R7_M~{WnI)=l>4cK0c?YX!R~I2T@AiXa@PK`tn?#*lqit_ z8CzqgneVWaR`6e-p&k+-koO1J*2fRD$sL^?OA8x_O!cn;gZ6B^9Oj5mu-%qAUw1^= z`|dXW;l-bz@!R>oNkHv^yva)wBUp^y{*xXx*xZl=^mr3>NJB&qMN#5NhbOaB`P*bet3}J7!CFGOK4i{6errDbPVr-aEaCNmpi~^YiZHrMTmdt{oH?;T+vy^!P^RwV4@PcyH;0 zPpN%=xuSTM=iQ!gWt^P6zSl*Ct9!6)o9&_+2ng!OHjDdvHoDtAwGU~#C)XUE!ULP@ zsMKr9ZtpQa+$uuuWa^E8*Dfhfv4%SPH=3vfK-iap@rxI&Q?st6kJwt_sZ%pgJTb!# z{dc>s6cx;Ku6feK{qf)-I6@G)1I8eK2z1bnP~7ExZ1P!A&yyKX3zDW7{KEM9Mx*0G zpWnx!R{a(#?XREl(l#rmXLxI-tZyrLv>{$xXsJt`6n-)@FRX$cm)LlB&8n>5+)Nbw69O!J^p0%(Z!Ep%KbP?0WC%OWsr2{ z!`PHXQQJ9Yct`joLfcc9-n`&|cFwKptzG`we$!+yy=95;IKerXN$;Ah$-Np~j2>7G zHD;Dnp$CwS6+EdZsywHU!8tH&)6=(=x?0QOQ>BHo03@N=uz;ZQ&|qJCE|+(ZcahVW z+kg5xBXdXFNhw9w;+#0cAL$C+062+HbF7whe{o<`vv{Jx^t_z17I)p0azE4`s7en> zhANS>h&zwU{xD~Oy*m-?=@<-0|_>$rQU)i(4l#-OR zdK;)FiwSq}J;ZsvyK(mq&_970pdDXuq-K#YGt7_Au@0E3`Al+O_A?6M?OM_vpfsu1o^di%Uq!=<%AM@`E#RL|GQ}qM*CMIZ@X1R#)B+5o{=*K1)h zVKVM@XA`ojuj?~xe-efCQ@lOHCZW@Evi@J;O9`B-O%V7!2lpH%aXD-{eaow>)E)1=Mh%S@1(CoI?BYm7>MrnX~eKa$$X3`HNstpzl3q-;&l6#s*tS9J!b&-5^YR6i*IRPbn`lA2zIp_ts{NgmSO+(sEj_CxqVQ4<8)M3R})s@4Q>`1`VwULyyk!&n$f1*{9F=8SB1g=ZJbQ&G&>R*l_ zRB?Ch`U2~m@1Rq{H%lpZa_%}OmZA3!O0Yw$KvX<#e`8We-aN-vgeeY#_A*~ivXZL; z#3zh28h$gHeKk1vAEVhwQjQX}1u?3&_TYj_`ttz`W|v9TTFP|qW3)|FgkrW*K~!F%182;L zSFMNstTudaZTc1sf!w{6TuCWdc(9QEkUX@H@~wgWNCrXs;G;j3Uq!$5+;3i_b;|F%lqZiE3LFe!dcd20K=B~Nt? zhE3YE9avIL_s8@ul=&_m!Xt&(Z_#raZ67fkF|(&dx)W{brmYXED$R|;;)uS4(Rj?E zlESS}ykB+B+JvAwAfmc%o>O(4rkdc<3)`WUgF!ZjB=EHt z5?wD(Sxb{J zc^L}+!m*#eB)u8w+>~Gw9swFR*V6prGcevqBK@mE{oI*prswU#tbKgAi+vL#GiiAt zK^6RD?$wP4nV%<)X>p1A4cIybjHfGAYnz69+rk6R~|A$Y-Az&0t!_U(F0YUg&JNOvnu|MXPnu zwq~N&==Y57?!_IW7ctW}r^@K*E&0+F4|JA#9rgRXSG1zWRN!y|A6U73v*%)UO&mGJ z9VpXIGOY5Q4@_tg;B(8Hsg;<9y5Q%PLC2qtJQ}!Y$sb3EiXx($J?hA>1|(FUW+$u-l#)D$G*nOv za`Y16QcBi1aq%EkskF5_C0hbc9cFL-WUOb8l^xie4>a<4|D^x;{cs73zxm zA4<)I&ARob-g$lYck9V?q*wRGlF>(k{kgsrrl-S zZ_>ZBncvxbLNRe$_BFymaZJ}DKvRDX{FYVzqZz$r5>`HlFil60qvvB@J|@2F#uiRx_<{NlaEDb?iBXf}OkzB9k;ukQp_)SsfF! z|CKdkFRVKm+I_ZW%1j!A6DPscr#zPdVC4 z#tzXT1)FpWP+g8rL6N`02~_8`C>c)q*q)q|(p&%r09~5f4W!Q^@9lN?A}87ke>s)n z=9{9w8l8^MsKL6Asx*ovP&7L1yDCu0yWM}`>OrdUkyM1$l%~yx>zQ;&#h~t(+T2jS zcyR@&CFCP;^Yvo@j=e{nEQk~-C^}Voqkyd$)CYkQVifvOS5`d8W^Bu^Ao@N3%_JbOl;2F~`uH_9 z3bW&im%6PG-`f_7xgQKgnRT>&HP6>5T{_UJS^EmQoYe-Q3C*&&#z0t~0q;k=%+~0? zPY8s9Ac0J5e6GMsibzjP<*w2Yt)caK0)DrUH9I zqtQ548$SPHKWGf@KQUO*oKj*>jPT3LMu0+tb+FC**;pX)w^=0sNBUw3d2d>6*Vv2=D96vpY6T)t_bNW z<3rOa7uExEZN$1&u|gX9(c3{`b103#bphw~?hi%(je_VK0mSO3PKP~veN*+L`JRCP KzWVbozx@{{Q3}2Q literal 0 HcmV?d00001 From a21fac2c7f4d724e5c73323aa6e19d70a1f902cb Mon Sep 17 00:00:00 2001 From: Christofer Date: Tue, 31 Oct 2023 17:19:11 +0000 Subject: [PATCH 10/18] style: TaxonomyCard updated to a better code style --- src/taxonomy/taxonomy-card/TaxonomyCard.jsx | 130 +++++++++++--------- 1 file changed, 74 insertions(+), 56 deletions(-) diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx index 0b024a96ae..f5b616e488 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx @@ -5,24 +5,76 @@ import { OverlayTrigger, Popover, } from '@edx/paragon'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 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 '../modals/ExportModal'; -const TaxonomyCard = ({ className, original, intl }) => { +const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0; + +const HeaderSubtitle = ({ + id, showSystemBadge, orgsCount, +}) => { + const intl = useIntl(); + const getSystemToolTip = () => ( + + + {intl.formatMessage(messages.systemTaxonomyPopoverTitle)} + + + {intl.formatMessage(messages.systemTaxonomyPopoverBody)} + + + ); + + // Show system defined badge + if (showSystemBadge) { + return ( + + + {intl.formatMessage(messages.systemDefinedBadge)} + + + ); + } + + // Or show orgs count + if (orgsCountEnabled(orgsCount)) { + return ( +
+ {intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })} +
+ ); + } + + // Or none + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +}; + +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); - const orgsCountEnabled = () => orgsCount !== undefined && orgsCount !== 0; - const onClickMenuItem = (menuName) => { switch (menuName) { + // Add here more menu items case 'export': setIsExportModalOpen(true); break; @@ -32,41 +84,6 @@ const TaxonomyCard = ({ className, original, intl }) => { } }; - const getSystemBadgeToolTip = () => ( - - - {intl.formatMessage(messages.systemTaxonomyPopoverTitle)} - - - {intl.formatMessage(messages.systemTaxonomyPopoverBody)} - - - ); - - const getHeaderSubtitle = () => { - if (systemDefined) { - return ( - - - {intl.formatMessage(messages.systemDefinedBadge)} - - - ); - } - if (orgsCountEnabled()) { - return ( -
- {intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })} -
- ); - } - return undefined; - }; - const getHeaderActions = () => { if (systemDefined) { // We don't show the export menu, because the system-taxonomies @@ -86,17 +103,12 @@ const TaxonomyCard = ({ className, original, intl }) => { ); }; - const renderModals = () => ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - {isExportModalOpen && ( - setIsExportModalOpen(false)} - taxonomyId={id} - /> - )} - + const renderExportModal = () => isExportModalOpen && ( + setIsExportModalOpen(false)} + taxonomyId={id} + /> ); return ( @@ -104,12 +116,19 @@ const TaxonomyCard = ({ className, original, intl }) => { + )} actions={getHeaderActions()} /> @@ -117,7 +136,7 @@ const TaxonomyCard = ({ className, original, intl }) => { - {renderModals()} + {renderExportModal()} ); }; @@ -135,7 +154,6 @@ TaxonomyCard.propTypes = { systemDefined: PropTypes.bool, orgsCount: PropTypes.number, }).isRequired, - intl: intlShape.isRequired, }; -export default injectIntl(TaxonomyCard); +export default TaxonomyCard; From 61864d3187a343edb2eaa56b643e7114cb237d12 Mon Sep 17 00:00:00 2001 From: Christofer Date: Tue, 31 Oct 2023 17:27:43 +0000 Subject: [PATCH 11/18] style: injectIntl replaced by useIntl on taxonomy pages and components --- src/taxonomy/TaxonomyListPage.jsx | 11 +++++------ src/taxonomy/modals/ExportModal.jsx | 7 +++---- src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx | 9 +++++---- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 0d3f0be220..ec3434c47f 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -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 './taxonomy-card/TaxonomyCard'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; -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; diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/modals/ExportModal.jsx index 31dfd16449..1f6ca9943a 100644 --- a/src/taxonomy/modals/ExportModal.jsx +++ b/src/taxonomy/modals/ExportModal.jsx @@ -6,7 +6,7 @@ import { ModalDialog, } from '@edx/paragon'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import { callExportTaxonomy } from '../api/hooks/selectors'; @@ -14,8 +14,8 @@ const ExportModal = ({ taxonomyId, isOpen, onClose, - intl, }) => { + const intl = useIntl(); const [outputFormat, setOutputFormat] = useState('csv'); const onClickExport = () => { @@ -80,7 +80,6 @@ ExportModal.propTypes = { taxonomyId: PropTypes.number.isRequired, isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, - intl: intlShape.isRequired, }; -export default injectIntl(ExportModal); +export default ExportModal; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx index 87e1722f4e..7a8421e41b 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -8,12 +8,13 @@ import { } from '@edx/paragon'; import { MoreVert } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; const TaxonomyCardMenu = ({ - id, name, onClickMenuItem, intl, + id, name, onClickMenuItem, }) => { + const intl = useIntl(); const [menuIsOpen, setMenuIsOpen] = useState(false); const [menuTarget, setMenuTarget] = useState(null); @@ -39,6 +40,7 @@ const TaxonomyCardMenu = ({ onClose={() => setMenuIsOpen(false)} > + {/* Add more menu items here */} onClickItem('export')}> {intl.formatMessage(messages.taxonomyCardExportMenu)} @@ -52,7 +54,6 @@ TaxonomyCardMenu.propTypes = { id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, onClickMenuItem: PropTypes.func.isRequired, - intl: intlShape.isRequired, }; -export default injectIntl(TaxonomyCardMenu); +export default TaxonomyCardMenu; From b09b86a2caf2ab3d26f56097725b02d48de80120 Mon Sep 17 00:00:00 2001 From: Christofer Date: Tue, 31 Oct 2023 18:02:57 +0000 Subject: [PATCH 12/18] refactor: Move and rename taxonomy UI components to match 0002 ADR --- src/taxonomy/TaxonomyListPage.jsx | 2 +- .../ExportModal.test.jsx | 2 +- .../index.jsx} | 2 +- src/taxonomy/export-modal/messages.js | 30 ++++++++++++ src/taxonomy/messages.js | 48 ------------------- .../taxonomy-card/TaxonomyCard.test.jsx | 2 +- .../taxonomy-card/TaxonomyCardMenu.jsx | 2 +- .../{TaxonomyCard.jsx => index.jsx} | 4 +- src/taxonomy/taxonomy-card/messages.js | 30 ++++++++++++ 9 files changed, 67 insertions(+), 55 deletions(-) rename src/taxonomy/{modals => export-modal}/ExportModal.test.jsx (97%) rename src/taxonomy/{modals/ExportModal.jsx => export-modal/index.jsx} (98%) create mode 100644 src/taxonomy/export-modal/messages.js rename src/taxonomy/taxonomy-card/{TaxonomyCard.jsx => index.jsx} (97%) create mode 100644 src/taxonomy/taxonomy-card/messages.js diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index ec3434c47f..481a7943ae 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -10,7 +10,7 @@ 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 './taxonomy-card/TaxonomyCard'; +import TaxonomyCard from './taxonomy-card'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; const TaxonomyListPage = () => { diff --git a/src/taxonomy/modals/ExportModal.test.jsx b/src/taxonomy/export-modal/ExportModal.test.jsx similarity index 97% rename from src/taxonomy/modals/ExportModal.test.jsx rename to src/taxonomy/export-modal/ExportModal.test.jsx index 31f8217018..9ddcfdea6f 100644 --- a/src/taxonomy/modals/ExportModal.test.jsx +++ b/src/taxonomy/export-modal/ExportModal.test.jsx @@ -3,7 +3,7 @@ import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; import { render, fireEvent } from '@testing-library/react'; -import ExportModal from './ExportModal'; +import ExportModal from '.'; import initializeStore from '../../store'; import { callExportTaxonomy } from '../api/hooks/selectors'; diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/export-modal/index.jsx similarity index 98% rename from src/taxonomy/modals/ExportModal.jsx rename to src/taxonomy/export-modal/index.jsx index 1f6ca9943a..27bc8987a3 100644 --- a/src/taxonomy/modals/ExportModal.jsx +++ b/src/taxonomy/export-modal/index.jsx @@ -7,7 +7,7 @@ import { } from '@edx/paragon'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../messages'; +import messages from './messages'; import { callExportTaxonomy } from '../api/hooks/selectors'; const ExportModal = ({ diff --git a/src/taxonomy/export-modal/messages.js b/src/taxonomy/export-modal/messages.js new file mode 100644 index 0000000000..5992f0557a --- /dev/null +++ b/src/taxonomy/export-modal/messages.js @@ -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; diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index 24f80bf6da..82467e09ba 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -17,58 +17,10 @@ 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', }, - 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.', - }, - taxonomyCardExportMenu: { - id: 'course-authoring.taxonomy-list.menu.export.label', - defaultMessage: 'Export', - }, - taxonomyMenuAlt: { - id: 'course-authoring.taxonomy-list.menu.alt', - defaultMessage: '{name} menu', - }, - 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; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 91e76c984a..64cf26150b 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -7,7 +7,7 @@ import PropTypes from 'prop-types'; import initializeStore from '../../store'; -import TaxonomyCard from './TaxonomyCard'; +import TaxonomyCard from '.'; let store; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx index 7a8421e41b..7f677bd4a4 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -9,7 +9,7 @@ import { import { MoreVert } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../messages'; +import messages from './messages'; const TaxonomyCardMenu = ({ id, name, onClickMenuItem, diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/index.jsx similarity index 97% rename from src/taxonomy/taxonomy-card/TaxonomyCard.jsx rename to src/taxonomy/taxonomy-card/index.jsx index f5b616e488..d789cbd6ad 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -8,9 +8,9 @@ import { import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../messages'; +import messages from './messages'; import TaxonomyCardMenu from './TaxonomyCardMenu'; -import ExportModal from '../modals/ExportModal'; +import ExportModal from '../export-modal'; const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0; diff --git a/src/taxonomy/taxonomy-card/messages.js b/src/taxonomy/taxonomy-card/messages.js new file mode 100644 index 0000000000..6886c2f99c --- /dev/null +++ b/src/taxonomy/taxonomy-card/messages.js @@ -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; From df8432dbe9c86b5bebb35fc5e158d20b98024bd6 Mon Sep 17 00:00:00 2001 From: Christofer Date: Tue, 31 Oct 2023 21:03:25 +0000 Subject: [PATCH 13/18] refactor: Move api to data to match with 0002 ADR --- src/taxonomy/TaxonomyListPage.jsx | 2 +- src/taxonomy/TaxonomyListPage.test.jsx | 4 +- src/taxonomy/__mocks__/index.js | 2 + src/taxonomy/__mocks__/taxonomyListMock.js | 50 +++++++++++++++ src/taxonomy/api/hooks/api.js | 26 -------- src/taxonomy/api/hooks/api.test.js | 56 ----------------- src/taxonomy/api/hooks/selectors.js | 27 -------- src/taxonomy/data/api.js | 29 +++++++++ src/taxonomy/data/api.test.js | 61 +++++++++++++++++++ src/taxonomy/data/thunks.js | 14 +++++ src/taxonomy/{api => data}/types.mjs | 0 .../export-modal/ExportModal.test.jsx | 9 +-- src/taxonomy/export-modal/index.jsx | 4 +- src/taxonomy/hooks.jsx | 34 +++++++++++ .../selectors.test.js => hooks.test.jsx} | 26 ++++---- 15 files changed, 212 insertions(+), 132 deletions(-) create mode 100644 src/taxonomy/__mocks__/index.js create mode 100644 src/taxonomy/__mocks__/taxonomyListMock.js delete mode 100644 src/taxonomy/api/hooks/api.js delete mode 100644 src/taxonomy/api/hooks/api.test.js delete mode 100644 src/taxonomy/api/hooks/selectors.js create mode 100644 src/taxonomy/data/api.js create mode 100644 src/taxonomy/data/api.test.js create mode 100644 src/taxonomy/data/thunks.js rename src/taxonomy/{api => data}/types.mjs (100%) create mode 100644 src/taxonomy/hooks.jsx rename src/taxonomy/{api/hooks/selectors.test.js => hooks.test.jsx} (60%) diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 481a7943ae..c9751b2f6e 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -11,7 +11,7 @@ import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import messages from './messages'; import TaxonomyCard from './taxonomy-card'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './hooks'; const TaxonomyListPage = () => { const intl = useIntl(); diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index 99c947b75e..34be17a1ee 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -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 './hooks'; let store; -jest.mock('./api/hooks/selectors', () => ({ +jest.mock('./hooks', () => ({ useTaxonomyListDataResponse: jest.fn(), useIsTaxonomyListDataLoaded: jest.fn(), })); diff --git a/src/taxonomy/__mocks__/index.js b/src/taxonomy/__mocks__/index.js new file mode 100644 index 0000000000..bbbeb62a2d --- /dev/null +++ b/src/taxonomy/__mocks__/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as taxonomyListMock } from './taxonomyListMock'; diff --git a/src/taxonomy/__mocks__/taxonomyListMock.js b/src/taxonomy/__mocks__/taxonomyListMock.js new file mode 100644 index 0000000000..5eab0af023 --- /dev/null +++ b/src/taxonomy/__mocks__/taxonomyListMock.js @@ -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, + }, + ], +}; diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js deleted file mode 100644 index b524bd197f..0000000000 --- a/src/taxonomy/api/hooks/api.js +++ /dev/null @@ -1,26 +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; -export const getExportTaxonomyApiUrl = (pk, format) => new URL( - `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, - getApiBaseUrl(), -).href; - -/** - * @returns {import("../types.mjs").UseQueryResult} - */ -export const useTaxonomyListData = () => ( - useQuery({ - queryKey: ['taxonomyList'], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl()) - .then(camelCaseObject), - }) -); - -export const exportTaxonomy = (pk, format) => { - window.location.href = getExportTaxonomyApiUrl(pk, format); -}; diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js deleted file mode 100644 index b3dc0045d1..0000000000 --- a/src/taxonomy/api/hooks/api.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useTaxonomyListData, exportTaxonomy } from './api'; - -const mockHttpClient = { - get: jest.fn(), -}; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: jest.fn(() => mockHttpClient), -})); - -describe('useTaxonomyListData', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should call useQuery with the correct parameters', () => { - useTaxonomyListData(); - - expect(useQuery).toHaveBeenCalledWith({ - queryKey: ['taxonomyList'], - queryFn: expect.any(Function), - }); - }); -}); - -describe('exportTaxonomy', () => { - const { location } = window; - - beforeAll(() => { - delete window.location; - window.location = { - href: '', - }; - }); - - afterAll(() => { - window.location = location; - }); - - it('should set window.location.href correctly', () => { - const pk = 1; - const format = 'json'; - - exportTaxonomy(pk, format); - - expect(window.location.href).toEqual( - 'http://localhost:18010/api/content_tagging/' - + 'v1/taxonomies/1/export/?output_format=json&download=1', - ); - }); -}); diff --git a/src/taxonomy/api/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js deleted file mode 100644 index b2c678be78..0000000000 --- a/src/taxonomy/api/hooks/selectors.js +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-check -import { - useTaxonomyListData, - exportTaxonomy, -} 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' -); - -export const callExportTaxonomy = (pk, format) => ( - exportTaxonomy(pk, format) -); diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js new file mode 100644 index 0000000000..513f7fdaf9 --- /dev/null +++ b/src/taxonomy/data/api.js @@ -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} + */ +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); +} diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js new file mode 100644 index 0000000000..5928c70098 --- /dev/null +++ b/src/taxonomy/data/api.test.js @@ -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)); + }); +}); diff --git a/src/taxonomy/data/thunks.js b/src/taxonomy/data/thunks.js new file mode 100644 index 0000000000..44afd37722 --- /dev/null +++ b/src/taxonomy/data/thunks.js @@ -0,0 +1,14 @@ +// @ts-check +import { getTaxonomyExportFile } from './api'; + +/** + * Downloads the file of the exported taxonomy + * @param {number} pk + * @param {string} format + * @returns {void} + */ +const exportTaxonomy = (pk, format) => ( + getTaxonomyExportFile(pk, format) +); + +export default exportTaxonomy; diff --git a/src/taxonomy/api/types.mjs b/src/taxonomy/data/types.mjs similarity index 100% rename from src/taxonomy/api/types.mjs rename to src/taxonomy/data/types.mjs diff --git a/src/taxonomy/export-modal/ExportModal.test.jsx b/src/taxonomy/export-modal/ExportModal.test.jsx index 9ddcfdea6f..c9f4908911 100644 --- a/src/taxonomy/export-modal/ExportModal.test.jsx +++ b/src/taxonomy/export-modal/ExportModal.test.jsx @@ -5,14 +5,15 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { render, fireEvent } from '@testing-library/react'; import ExportModal from '.'; import initializeStore from '../../store'; -import { callExportTaxonomy } from '../api/hooks/selectors'; +import exportTaxonomy from '../data/thunks'; const onClose = jest.fn(); let store; const taxonomyId = 1; -jest.mock('../api/hooks/selectors', () => ({ - callExportTaxonomy: jest.fn(), +jest.mock('../data/thunks', () => ({ + __esModule: true, + default: jest.fn(), })); const ExportModalComponent = () => ( @@ -48,6 +49,6 @@ describe('', async () => { fireEvent.click(getByText('Export')); expect(onClose).toHaveBeenCalled(); - expect(callExportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); + expect(exportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); }); }); diff --git a/src/taxonomy/export-modal/index.jsx b/src/taxonomy/export-modal/index.jsx index 27bc8987a3..7fea965225 100644 --- a/src/taxonomy/export-modal/index.jsx +++ b/src/taxonomy/export-modal/index.jsx @@ -8,7 +8,7 @@ import { import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import { callExportTaxonomy } from '../api/hooks/selectors'; +import exportTaxonomy from '../data/thunks'; const ExportModal = ({ taxonomyId, @@ -20,7 +20,7 @@ const ExportModal = ({ const onClickExport = () => { onClose(); - callExportTaxonomy(taxonomyId, outputFormat); + exportTaxonomy(taxonomyId, outputFormat); }; return ( diff --git a/src/taxonomy/hooks.jsx b/src/taxonomy/hooks.jsx new file mode 100644 index 0000000000..1958830f82 --- /dev/null +++ b/src/taxonomy/hooks.jsx @@ -0,0 +1,34 @@ +// @ts-check +import { useQuery } from '@tanstack/react-query'; +import { getTaxonomyListData } from './data/api'; + +/** + * Builds the query yo get the taxonomy list + * @returns {import("./data/types.mjs").UseQueryResult} + */ +const useTaxonomyListData = () => ( + useQuery({ + queryKey: ['taxonomyList'], + queryFn: getTaxonomyListData, + }) +); + +/** + * Gets the taxonomy list data + * @returns {import("./data/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' +); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/hooks.test.jsx similarity index 60% rename from src/taxonomy/api/hooks/selectors.test.js rename to src/taxonomy/hooks.test.jsx index 772b39c4d8..d915f55c99 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/hooks.test.jsx @@ -1,27 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, - callExportTaxonomy, -} from './selectors'; -import { useTaxonomyListData, exportTaxonomy } from './api'; - -jest.mock('./api', () => ({ - __esModule: true, - useTaxonomyListData: jest.fn(), - exportTaxonomy: jest.fn(), +} from './hooks'; + +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(); @@ -31,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(); @@ -39,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(); @@ -47,10 +44,11 @@ describe('useIsTaxonomyListDataLoaded', () => { }); }); -describe('callExportTaxonomy', () => { +/* describe('callExportTaxonomy', () => { it('should trigger exportTaxonomy', () => { callExportTaxonomy(1, 'csv'); expect(exportTaxonomy).toHaveBeenCalled(); }); }); +*/ From 1abf0342616a8c0c4167a5209afb3c7abdab02ac Mon Sep 17 00:00:00 2001 From: Christofer Chavez Date: Mon, 6 Nov 2023 16:16:19 +0000 Subject: [PATCH 14/18] test: Refactor ExportModal tests --- .../export-modal/ExportModal.test.jsx | 54 ------------------- .../taxonomy-card/TaxonomyCard.test.jsx | 26 ++++++++- 2 files changed, 24 insertions(+), 56 deletions(-) delete mode 100644 src/taxonomy/export-modal/ExportModal.test.jsx diff --git a/src/taxonomy/export-modal/ExportModal.test.jsx b/src/taxonomy/export-modal/ExportModal.test.jsx deleted file mode 100644 index c9f4908911..0000000000 --- a/src/taxonomy/export-modal/ExportModal.test.jsx +++ /dev/null @@ -1,54 +0,0 @@ -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, fireEvent } from '@testing-library/react'; -import ExportModal from '.'; -import initializeStore from '../../store'; -import exportTaxonomy from '../data/thunks'; - -const onClose = jest.fn(); -let store; -const taxonomyId = 1; - -jest.mock('../data/thunks', () => ({ - __esModule: true, - default: jest.fn(), -})); - -const ExportModalComponent = () => ( - - - - - -); - -describe('', async () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - }); - - it('should render the modal', () => { - const { getByText } = render(); - expect(getByText('Select format to export')).toBeInTheDocument(); - }); - - it('should call export endpoint', () => { - const { getByText } = render(); - - fireEvent.click(getByText('JSON file')); - fireEvent.click(getByText('Export')); - - expect(onClose).toHaveBeenCalled(); - expect(exportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); - }); -}); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 64cf26150b..b984fd667c 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -6,17 +6,23 @@ import { render, fireEvent } from '@testing-library/react'; import PropTypes from 'prop-types'; import initializeStore from '../../store'; - +import exportTaxonomy from '../data/thunks'; import TaxonomyCard from '.'; let store; +const taxonomyId = 1; const data = { - id: 1, + id: taxonomyId, name: 'Taxonomy 1', description: 'This is a description', }; +jest.mock('../data/thunks', () => ({ + __esModule: true, + default: jest.fn(), +})); + const TaxonomyCardComponent = ({ original }) => ( @@ -121,4 +127,20 @@ describe('', async () => { // Modal closed expect(() => getByText('Select format to export')).toThrow(); }); + + test('should export a taxonomy', () => { + const { getByTestId, getByText } = render(); + + // 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(exportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); + }); }); From 5c4c87ade73b83e4599fbe9854c1672f73a86369 Mon Sep 17 00:00:00 2001 From: Christofer Chavez Date: Mon, 6 Nov 2023 16:51:14 +0000 Subject: [PATCH 15/18] chore: Fix validations --- src/index.scss | 3 +-- src/taxonomy/data/thunks.js | 3 ++- src/taxonomy/taxonomy-card/index.jsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.scss b/src/index.scss index 1206534858..3aed647989 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,6 +19,5 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; -@import "files-and-uploads/table-components/GalleryCard"; -@import "taxonomy/taxonomy-card/TaxonomyCard.scss"; +@import "taxonomy/taxonomy-card/TaxonomyCard"; @import "files-and-videos"; diff --git a/src/taxonomy/data/thunks.js b/src/taxonomy/data/thunks.js index 44afd37722..635f5c5ab2 100644 --- a/src/taxonomy/data/thunks.js +++ b/src/taxonomy/data/thunks.js @@ -7,8 +7,9 @@ import { getTaxonomyExportFile } from './api'; * @param {string} format * @returns {void} */ +/* istanbul ignore next */ const exportTaxonomy = (pk, format) => ( - getTaxonomyExportFile(pk, format) + getTaxonomyExportFile(pk, format) ); export default exportTaxonomy; diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index d789cbd6ad..4844b0cd78 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -78,8 +78,8 @@ const TaxonomyCard = ({ className, original }) => { case 'export': setIsExportModalOpen(true); break; + /* istanbul ignore next */ default: - /* istanbul ignore next */ break; } }; From 7e514b69b48e8eef8614d895c84848354b7d50b3 Mon Sep 17 00:00:00 2001 From: Christofer Chavez Date: Mon, 6 Nov 2023 17:32:49 +0000 Subject: [PATCH 16/18] chore: Lint --- src/taxonomy/data/thunks.js | 4 ++-- src/taxonomy/taxonomy-card/index.jsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/taxonomy/data/thunks.js b/src/taxonomy/data/thunks.js index 635f5c5ab2..aeb7fba40a 100644 --- a/src/taxonomy/data/thunks.js +++ b/src/taxonomy/data/thunks.js @@ -7,9 +7,9 @@ import { getTaxonomyExportFile } from './api'; * @param {string} format * @returns {void} */ -/* istanbul ignore next */ +/* istanbul ignore next */ const exportTaxonomy = (pk, format) => ( - getTaxonomyExportFile(pk, format) + getTaxonomyExportFile(pk, format) ); export default exportTaxonomy; diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index 4844b0cd78..c578800809 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -78,7 +78,7 @@ const TaxonomyCard = ({ className, original }) => { case 'export': setIsExportModalOpen(true); break; - /* istanbul ignore next */ + /* istanbul ignore next */ default: break; } From 080b03ec36a5d019f475bf31a9a5fe5791973e9b Mon Sep 17 00:00:00 2001 From: Christofer Chavez Date: Wed, 8 Nov 2023 15:53:11 +0000 Subject: [PATCH 17/18] refactor: Moving hooks to apiHooks --- src/taxonomy/TaxonomyListPage.jsx | 2 +- src/taxonomy/TaxonomyListPage.test.jsx | 4 +- src/taxonomy/data/apiHooks.jsx | 46 +++++++++++++++++++ .../apiHooks.test.jsx} | 11 +---- src/taxonomy/data/thunks.js | 15 ------ src/taxonomy/export-modal/index.jsx | 4 +- src/taxonomy/hooks.jsx | 34 -------------- .../taxonomy-card/TaxonomyCard.test.jsx | 9 ++-- src/taxonomy/taxonomy-card/index.jsx | 17 +++---- 9 files changed, 63 insertions(+), 79 deletions(-) create mode 100644 src/taxonomy/data/apiHooks.jsx rename src/taxonomy/{hooks.test.jsx => data/apiHooks.test.jsx} (85%) delete mode 100644 src/taxonomy/data/thunks.js delete mode 100644 src/taxonomy/hooks.jsx diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index c9751b2f6e..98e446e45b 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -11,7 +11,7 @@ import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import messages from './messages'; import TaxonomyCard from './taxonomy-card'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './hooks'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks'; const TaxonomyListPage = () => { const intl = useIntl(); diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index 34be17a1ee..8e68347568 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -7,11 +7,11 @@ import { act, render } from '@testing-library/react'; import initializeStore from '../store'; import TaxonomyListPage from './TaxonomyListPage'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './hooks'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks'; let store; -jest.mock('./hooks', () => ({ +jest.mock('./data/apiHooks', () => ({ useTaxonomyListDataResponse: jest.fn(), useIsTaxonomyListDataLoaded: jest.fn(), })); diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx new file mode 100644 index 0000000000..40eadf3d1d --- /dev/null +++ b/src/taxonomy/data/apiHooks.jsx @@ -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' +); diff --git a/src/taxonomy/hooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx similarity index 85% rename from src/taxonomy/hooks.test.jsx rename to src/taxonomy/data/apiHooks.test.jsx index d915f55c99..66715b04c9 100644 --- a/src/taxonomy/hooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, -} from './hooks'; +} from './apiHooks'; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), @@ -43,12 +43,3 @@ describe('useIsTaxonomyListDataLoaded', () => { expect(result).toBe(false); }); }); - -/* describe('callExportTaxonomy', () => { - it('should trigger exportTaxonomy', () => { - callExportTaxonomy(1, 'csv'); - - expect(exportTaxonomy).toHaveBeenCalled(); - }); -}); -*/ diff --git a/src/taxonomy/data/thunks.js b/src/taxonomy/data/thunks.js deleted file mode 100644 index aeb7fba40a..0000000000 --- a/src/taxonomy/data/thunks.js +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-check -import { getTaxonomyExportFile } from './api'; - -/** - * Downloads the file of the exported taxonomy - * @param {number} pk - * @param {string} format - * @returns {void} - */ -/* istanbul ignore next */ -const exportTaxonomy = (pk, format) => ( - getTaxonomyExportFile(pk, format) -); - -export default exportTaxonomy; diff --git a/src/taxonomy/export-modal/index.jsx b/src/taxonomy/export-modal/index.jsx index 7fea965225..d380aea6e9 100644 --- a/src/taxonomy/export-modal/index.jsx +++ b/src/taxonomy/export-modal/index.jsx @@ -8,7 +8,7 @@ import { import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import exportTaxonomy from '../data/thunks'; +import { getTaxonomyExportFile } from '../data/api'; const ExportModal = ({ taxonomyId, @@ -20,7 +20,7 @@ const ExportModal = ({ const onClickExport = () => { onClose(); - exportTaxonomy(taxonomyId, outputFormat); + getTaxonomyExportFile(taxonomyId, outputFormat); }; return ( diff --git a/src/taxonomy/hooks.jsx b/src/taxonomy/hooks.jsx deleted file mode 100644 index 1958830f82..0000000000 --- a/src/taxonomy/hooks.jsx +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-check -import { useQuery } from '@tanstack/react-query'; -import { getTaxonomyListData } from './data/api'; - -/** - * Builds the query yo get the taxonomy list - * @returns {import("./data/types.mjs").UseQueryResult} - */ -const useTaxonomyListData = () => ( - useQuery({ - queryKey: ['taxonomyList'], - queryFn: getTaxonomyListData, - }) -); - -/** - * Gets the taxonomy list data - * @returns {import("./data/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' -); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index b984fd667c..79f72eeb2e 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -6,7 +6,7 @@ import { render, fireEvent } from '@testing-library/react'; import PropTypes from 'prop-types'; import initializeStore from '../../store'; -import exportTaxonomy from '../data/thunks'; +import { getTaxonomyExportFile } from '../data/api'; import TaxonomyCard from '.'; let store; @@ -18,9 +18,8 @@ const data = { description: 'This is a description', }; -jest.mock('../data/thunks', () => ({ - __esModule: true, - default: jest.fn(), +jest.mock('../data/api', () => ({ + getTaxonomyExportFile: jest.fn(), })); const TaxonomyCardComponent = ({ original }) => ( @@ -141,6 +140,6 @@ describe('', async () => { // Modal closed expect(() => getByText('Select format to export')).toThrow(); - expect(exportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); + expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json'); }); }); diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index c578800809..150f36e686 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -72,18 +72,15 @@ const TaxonomyCard = ({ className, original }) => { const intl = useIntl(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const onClickMenuItem = (menuName) => { - switch (menuName) { - // Add here more menu items - case 'export': - setIsExportModalOpen(true); - break; - /* istanbul ignore next */ - default: - break; - } + // 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 From 16233fb4e7d40f57b8e8a3e8d9fa12c4336f3b53 Mon Sep 17 00:00:00 2001 From: Christofer Chavez Date: Tue, 14 Nov 2023 17:02:15 +0000 Subject: [PATCH 18/18] style: Nit on return null --- src/taxonomy/taxonomy-card/index.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index 150f36e686..75a8673daa 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -54,8 +54,7 @@ const HeaderSubtitle = ({ } // Or none - // eslint-disable-next-line react/jsx-no-useless-fragment - return <>; + return null; }; HeaderSubtitle.propTypes = {