diff --git a/src/index.jsx b/src/index.jsx index c35d0f92f3..37c0e9b6f9 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -7,7 +7,7 @@ import { import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider, @@ -22,7 +22,7 @@ import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; import { StudioHome } from './studio-home'; import CourseRerun from './course-rerun'; -import { TaxonomyListPage } from './taxonomy'; +import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy'; import { ContentTagsDrawer } from './content-tags-drawer'; import 'react-datepicker/dist/react-datepicker.css'; @@ -55,10 +55,14 @@ const App = () => { } /> {process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( <> - } - /> + {/* TODO: remove this redirect once Studio's link is updated */} + } /> + }> + } /> + + }> + } /> + } diff --git a/src/taxonomy/TaxonomyLayout.jsx b/src/taxonomy/TaxonomyLayout.jsx new file mode 100644 index 0000000000..eb992b2b42 --- /dev/null +++ b/src/taxonomy/TaxonomyLayout.jsx @@ -0,0 +1,14 @@ +import { StudioFooter } from '@edx/frontend-component-footer'; +import { Outlet } from 'react-router-dom'; + +import Header from '../header'; + +const TaxonomyLayout = () => ( +
+
+ + +
+); + +export default TaxonomyLayout; diff --git a/src/taxonomy/TaxonomyLayout.test.jsx b/src/taxonomy/TaxonomyLayout.test.jsx new file mode 100644 index 0000000000..924e7465e9 --- /dev/null +++ b/src/taxonomy/TaxonomyLayout.test.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { IntlProvider } 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 initializeStore from '../store'; +import TaxonomyLayout from './TaxonomyLayout'; + +let store; + +jest.mock('../header', () => jest.fn(() =>
)); +jest.mock('@edx/frontend-component-footer', () => ({ + StudioFooter: jest.fn(() =>
), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + Outlet: jest.fn(() =>
), +})); + +const RootWrapper = () => ( + + + + + +); + +describe('', async () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('should render page correctly', async () => { + const { getByTestId } = render(); + expect(getByTestId('mock-header')).toBeInTheDocument(); + expect(getByTestId('mock-content')).toBeInTheDocument(); + expect(getByTestId('mock-footer')).toBeInTheDocument(); + }); +}); diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 98e446e45b..79ca9982aa 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -5,9 +5,7 @@ import { DataTable, Spinner, } from '@edx/paragon'; -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'; @@ -37,14 +35,6 @@ const TaxonomyListPage = () => { return ( <> - -
{ )}
- ); }; diff --git a/src/taxonomy/export-modal/index.jsx b/src/taxonomy/export-modal/index.jsx index d380aea6e9..76fd71c2fc 100644 --- a/src/taxonomy/export-modal/index.jsx +++ b/src/taxonomy/export-modal/index.jsx @@ -67,7 +67,11 @@ const ExportModal = ({ {intl.formatMessage(messages.taxonomyModalsCancelLabel)} - diff --git a/src/taxonomy/index.js b/src/taxonomy/index.js index c857f10e6c..356c532411 100644 --- a/src/taxonomy/index.js +++ b/src/taxonomy/index.js @@ -1,2 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export { default as TaxonomyListPage } from './TaxonomyListPage'; +export { default as TaxonomyLayout } from './TaxonomyLayout'; +export { TaxonomyDetailPage } from './taxonomy-detail'; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx new file mode 100644 index 0000000000..09a6a26d63 --- /dev/null +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -0,0 +1,56 @@ +// ts-check +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTable, +} from '@edx/paragon'; +import _ from 'lodash'; +import Proptypes from 'prop-types'; +import { useState } from 'react'; + +import messages from './messages'; +import { useTagListDataResponse, useTagListDataStatus } from './data/apiHooks'; + +const TagListTable = ({ taxonomyId }) => { + const intl = useIntl(); + const [options, setOptions] = useState({ + pageIndex: 0, + }); + const { isLoading } = useTagListDataStatus(taxonomyId, options); + const tagList = useTagListDataResponse(taxonomyId, options); + + const fetchData = (args) => { + if (!_.isEqual(args, options)) { + setOptions({ ...args }); + } + }; + + return ( + + + + + + + ); +}; + +TagListTable.propTypes = { + taxonomyId: Proptypes.string.isRequired, +}; + +export default TagListTable; diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx new file mode 100644 index 0000000000..e9d5015dd4 --- /dev/null +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { IntlProvider } 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 { useTagListData } from './data/api'; +import initializeStore from '../../store'; +import TagListTable from './TagListTable'; + +let store; + +jest.mock('./data/api', () => ({ + useTagListData: jest.fn(), +})); + +const RootWrapper = () => ( + + + + + +); + +describe('', async () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('shows the spinner before the query is complete', async () => { + useTagListData.mockReturnValue({ + isLoading: true, + isFetched: false, + }); + const { getByRole } = render(); + const spinner = getByRole('status'); + expect(spinner.textContent).toEqual('loading'); + }); + + it('should render page correctly', async () => { + useTagListData.mockReturnValue({ + isSuccess: true, + isFetched: true, + isError: false, + data: { + count: 3, + numPages: 1, + results: [ + { value: 'Tag 1' }, + { value: 'Tag 2' }, + { value: 'Tag 3' }, + ], + }, + }); + const { getAllByRole } = render(); + const rows = getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + }); +}); diff --git a/src/taxonomy/tag-list/data/api.js b/src/taxonomy/tag-list/data/api.js new file mode 100644 index 0000000000..570e29ce48 --- /dev/null +++ b/src/taxonomy/tag-list/data/api.js @@ -0,0 +1,27 @@ +// @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 getTagListApiUrl = (taxonomyId, page) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/tags/?page=${page + 1}`, + getApiBaseUrl(), +).href; + +// ToDo: fix types +/** + * @param {number} taxonomyId + * @param {import('./types.mjs').QueryOptions} options + * @returns {import('@tanstack/react-query').UseQueryResult} + */ // eslint-disable-next-line import/prefer-default-export +export const useTagListData = (taxonomyId, options) => { + const { pageIndex } = options; + return useQuery({ + queryKey: ['tagList', taxonomyId, pageIndex], + queryFn: async () => { + const { data } = await getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId, pageIndex)); + return camelCaseObject(data); + }, + }); +}; diff --git a/src/taxonomy/tag-list/data/api.test.js b/src/taxonomy/tag-list/data/api.test.js new file mode 100644 index 0000000000..de9e06080f --- /dev/null +++ b/src/taxonomy/tag-list/data/api.test.js @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import { + useTagListData, +} 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('useTagListData', () => { + it('should call useQuery with the correct parameters', () => { + useTagListData('1', { pageIndex: 3 }); + + expect(useQuery).toHaveBeenCalledWith({ + queryKey: ['tagList', '1', 3], + queryFn: expect.any(Function), + }); + }); +}); diff --git a/src/taxonomy/tag-list/data/apiHooks.jsx b/src/taxonomy/tag-list/data/apiHooks.jsx new file mode 100644 index 0000000000..eb4cd066d4 --- /dev/null +++ b/src/taxonomy/tag-list/data/apiHooks.jsx @@ -0,0 +1,41 @@ +// @ts-check +import { + useTagListData, +} from './api'; + +/* eslint-disable max-len */ +/** + * @param {number} taxonomyId + * @param {import("./types.mjs").QueryOptions} options + * @returns {Pick} + */ /* eslint-enable max-len */ +export const useTagListDataStatus = (taxonomyId, options) => { + const { + error, + isError, + isFetched, + isLoading, + isSuccess, + } = useTagListData(taxonomyId, options); + return { + error, + isError, + isFetched, + isLoading, + isSuccess, + }; +}; + +/** + * @param {number} taxonomyId + * @param {import("./types.mjs").QueryOptions} options + * @returns {import("./types.mjs").TagListData | undefined} + */ +export const useTagListDataResponse = (taxonomyId, options) => { + const { isSuccess, data } = useTagListData(taxonomyId, options); + if (isSuccess) { + return data; + } + + return undefined; +}; diff --git a/src/taxonomy/tag-list/data/apiHooks.test.jsx b/src/taxonomy/tag-list/data/apiHooks.test.jsx new file mode 100644 index 0000000000..331d3a8ba6 --- /dev/null +++ b/src/taxonomy/tag-list/data/apiHooks.test.jsx @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { + useTagListDataStatus, + useTagListDataResponse, +} from './apiHooks'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), +})); + +describe('useTagListDataStatus', () => { + it('should return status values', () => { + const status = { + error: undefined, + isError: false, + isFetched: true, + isLoading: true, + isSuccess: true, + }; + + useQuery.mockReturnValueOnce(status); + + const result = useTagListDataStatus(0, {}); + + expect(result).toEqual(status); + }); +}); + +describe('useTagListDataResponse', () => { + it('should return data when status is success', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + + const result = useTagListDataResponse(0, {}); + + expect(result).toEqual('data'); + }); + + it('should return undefined when status is not success', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); + + const result = useTagListDataResponse(0, {}); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/taxonomy/tag-list/data/types.mjs b/src/taxonomy/tag-list/data/types.mjs new file mode 100644 index 0000000000..63f6550c29 --- /dev/null +++ b/src/taxonomy/tag-list/data/types.mjs @@ -0,0 +1,28 @@ +// @ts-check + +/** + * @typedef {Object} QueryOptions + * @property {number} pageIndex + */ + +/** + * @typedef {Object} TagListData + * @property {number} childCount + * @property {number} depth + * @property {string} externalId + * @property {number} id + * @property {string | null} parentValue + * @property {string | null} subTagsUrl + * @property {string} value + */ + +/** + * @typedef {Object} TagData + * @property {number} count + * @property {number} currentPage + * @property {string} next + * @property {number} numPages + * @property {string} previous + * @property {TagListData[]} results + * @property {number} start + */ diff --git a/src/taxonomy/tag-list/index.js b/src/taxonomy/tag-list/index.js new file mode 100644 index 0000000000..deaa3266e5 --- /dev/null +++ b/src/taxonomy/tag-list/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as TagListTable } from './TagListTable'; diff --git a/src/taxonomy/tag-list/messages.js b/src/taxonomy/tag-list/messages.js new file mode 100644 index 0000000000..5832fdb465 --- /dev/null +++ b/src/taxonomy/tag-list/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + noResultsFoundMessage: { + id: 'course-authoring.tag-list.no-results-found.message', + defaultMessage: 'No results found', + }, + tagListColumnValueHeader: { + id: 'course-authoring.tag-list.column.value.header', + defaultMessage: 'Value', + }, +}); + +export default messages; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 79f72eeb2e..fd61bf0cdb 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -89,22 +89,27 @@ describe('', async () => { }); test('should open and close menu on button click', () => { - const { getByTestId, getByText } = render(); + const { getByTestId } = render(); - // Menu closed + // Menu closed/doesn't exist yet expect(() => getByTestId('taxonomy-card-menu-1')).toThrow(); // Click on the menu button to open fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); // Menu opened - expect(getByTestId('taxonomy-card-menu-1')).toBeInTheDocument(); + expect(getByTestId('taxonomy-card-menu-1')).toBeVisible(); - // Click on any element to close the menu - fireEvent.click(getByText('Export')); + // Click on button again to close the menu + fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); // Menu closed - expect(() => getByTestId('taxonomy-card-menu-1')).toThrow(); + // Jest bug: toBeVisible() isn't checking opacity correctly + // expect(getByTestId('taxonomy-card-menu-1')).not.toBeVisible(); + expect(getByTestId('taxonomy-card-menu-1').style.opacity).toEqual('0'); + + // Menu button still visible + expect(getByTestId('taxonomy-card-menu-button-1')).toBeVisible(); }); test('should open export modal on export menu click', () => { @@ -115,7 +120,7 @@ describe('', async () => { // Click on export menu fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); - fireEvent.click(getByText('Export')); + fireEvent.click(getByTestId('taxonomy-card-menu-export-1')); // Modal opened expect(getByText('Select format to export')).toBeInTheDocument(); @@ -132,11 +137,11 @@ describe('', async () => { // Click on export menu fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); - fireEvent.click(getByText('Export')); + fireEvent.click(getByTestId('taxonomy-card-menu-export-1')); // Select JSON format and click on export fireEvent.click(getByText('JSON file')); - fireEvent.click(getByText('Export')); + fireEvent.click(getByTestId('export-button-1')); // 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 index 7f677bd4a4..1b27c8ee61 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -1,10 +1,8 @@ -import React, { useState } from 'react'; +import React from 'react'; import { + Dropdown, IconButton, - ModalPopup, - Menu, Icon, - MenuItem, } from '@edx/paragon'; import { MoreVert } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; @@ -15,38 +13,34 @@ const TaxonomyCardMenu = ({ id, name, onClickMenuItem, }) => { const intl = useIntl(); - const [menuIsOpen, setMenuIsOpen] = useState(false); - const [menuTarget, setMenuTarget] = useState(null); - const onClickItem = (menuName) => { - setMenuIsOpen(false); + const onClickItem = (e, menuName) => { + e.preventDefault(); onClickMenuItem(menuName); }; return ( - <> - setMenuIsOpen(true)} - ref={setMenuTarget} + ev.preventDefault()}> + - setMenuIsOpen(false)} - > - - {/* Add more menu items here */} - onClickItem('export')}> - {intl.formatMessage(messages.taxonomyCardExportMenu)} - - - - + + {/* Add more menu items here */} + onClickItem(e, 'export')} + > + {intl.formatMessage(messages.taxonomyCardExportMenu)} + + + ); }; diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index 75a8673daa..f1080e9e12 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -6,6 +6,7 @@ import { Popover, } from '@edx/paragon'; import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; @@ -109,7 +110,13 @@ const TaxonomyCard = ({ className, original }) => { return ( <> - + { + const intl = useIntl(); + + return ( + + onClickMenuItem('export')}> + {intl.formatMessage(messages.exportMenu)} + + + ); +}; + +TaxonomyDetailMenu.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + disabled: PropTypes.bool, + onClickMenuItem: PropTypes.func.isRequired, +}; + +TaxonomyDetailMenu.defaultProps = { + disabled: false, +}; + +export default TaxonomyDetailMenu; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx new file mode 100644 index 0000000000..a22fb0bd57 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -0,0 +1,116 @@ +// ts-check +import React, { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Breadcrumb, + Container, + Layout, +} from '@edx/paragon'; +import { Link, useParams } from 'react-router-dom'; + +import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert'; +import Loading from '../../generic/Loading'; +import SubHeader from '../../generic/sub-header/SubHeader'; +import taxonomyMessages from '../messages'; +import TaxonomyDetailMenu from './TaxonomyDetailMenu'; +import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; +import { TagListTable } from '../tag-list'; +import ExportModal from '../export-modal'; +import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks'; + +const TaxonomyDetailPage = () => { + const intl = useIntl(); + const { taxonomyId } = useParams(); + const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId); + const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + + if (!isFetched) { + return ( + + ); + } + + if (isError || !taxonomy) { + return ( + + ); + } + + const renderModals = () => isExportModalOpen && ( + setIsExportModalOpen(false)} + taxonomyId={taxonomy.id} + taxonomyName={taxonomy.name} + /> + ); + + const onClickMenuItem = (menuName) => { + switch (menuName) { + case 'export': + setIsExportModalOpen(true); + break; + default: + break; + } + }; + + const getHeaderActions = () => ( + + ); + + return ( + <> +
+ + + + +
+
+ + + + + + + + + + +
+ {renderModals()} + + ); +}; + +export default TaxonomyDetailPage; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx new file mode 100644 index 0000000000..085ea59b85 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { fireEvent, render } from '@testing-library/react'; + +import { useTaxonomyDetailData } from './data/api'; +import initializeStore from '../../store'; +import TaxonomyDetailPage from './TaxonomyDetailPage'; + +let store; + +jest.mock('./data/api', () => ({ + useTaxonomyDetailData: jest.fn(), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useParams: () => ({ + taxonomyId: '1', + }), +})); + +jest.mock('./TaxonomyDetailSideCard', () => jest.fn(() => <>Mock TaxonomyDetailSideCard)); +jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable)); + +const RootWrapper = () => ( + + + + + +); + +describe('', async () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('shows the spinner before the query is complete', async () => { + useTaxonomyDetailData.mockReturnValue({ + isFetched: false, + }); + const { getByRole } = render(); + const spinner = getByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); + + it('shows the connector error component if got some error', async () => { + useTaxonomyDetailData.mockReturnValue({ + isFetched: true, + isError: true, + }); + const { getByTestId } = render(); + expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); + }); + + it('should render page and page title correctly', async () => { + useTaxonomyDetailData.mockReturnValue({ + isSuccess: true, + isFetched: true, + isError: false, + data: { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + systemDefined: false, + }, + }); + const { getByRole } = render(); + expect(getByRole('heading')).toHaveTextContent('Test taxonomy'); + }); + + it('should open export modal on export menu click', () => { + useTaxonomyDetailData.mockReturnValue({ + isSuccess: true, + isFetched: true, + isError: false, + data: { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + }, + }); + + const { getByRole, getByText } = render(); + + // Modal closed + expect(() => getByText('Select format to export')).toThrow(); + + // Click on export menu + fireEvent.click(getByRole('button')); + 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-detail/TaxonomyDetailSideCard.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx new file mode 100644 index 0000000000..57fe8614fd --- /dev/null +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx @@ -0,0 +1,32 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Card, +} from '@edx/paragon'; +import Proptypes from 'prop-types'; + +import messages from './messages'; + +const TaxonomyDetailSideCard = ({ taxonomy }) => { + const intl = useIntl(); + return ( + + + + {taxonomy.name} + + + + {taxonomy.description} + + + ); +}; + +TaxonomyDetailSideCard.propTypes = { + taxonomy: Proptypes.shape({ + name: Proptypes.string.isRequired, + description: Proptypes.string.isRequired, + }).isRequired, +}; + +export default TaxonomyDetailSideCard; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.jsx new file mode 100644 index 0000000000..fb053eca72 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { IntlProvider } 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 PropTypes from 'prop-types'; + +import initializeStore from '../../store'; + +import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; + +let store; + +const data = { + id: 1, + name: 'Taxonomy 1', + description: 'This is a description', +}; + +const TaxonomyCardComponent = ({ taxonomy }) => ( + + + + + +); + +TaxonomyCardComponent.propTypes = { + taxonomy: PropTypes.shape({ + name: PropTypes.string, + description: PropTypes.string, + }).isRequired, +}; + +describe('', async () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('should render title and description of the card', () => { + const { getByText } = render(); + expect(getByText(data.name)).toBeInTheDocument(); + expect(getByText(data.description)).toBeInTheDocument(); + }); +}); diff --git a/src/taxonomy/taxonomy-detail/data/api.js b/src/taxonomy/taxonomy-detail/data/api.js new file mode 100644 index 0000000000..81b7929ec9 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/api.js @@ -0,0 +1,23 @@ +// @ts-check +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useQuery } from '@tanstack/react-query'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +const getTaxonomyDetailApiUrl = (taxonomyId) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/`, + getApiBaseUrl(), +).href; + +/** + * @param {number} taxonomyId + * @returns {import('@tanstack/react-query').UseQueryResult} + */ // eslint-disable-next-line import/prefer-default-export +export const useTaxonomyDetailData = (taxonomyId) => ( + useQuery({ + queryKey: ['taxonomyDetail', taxonomyId], + queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyDetailApiUrl(taxonomyId)) + .then((response) => response.data) + .then(camelCaseObject), + }) +); diff --git a/src/taxonomy/taxonomy-detail/data/api.test.js b/src/taxonomy/taxonomy-detail/data/api.test.js new file mode 100644 index 0000000000..257421680c --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/api.test.js @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import { + useTaxonomyDetailData, +} 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('useTaxonomyDetailData', () => { + it('should call useQuery with the correct parameters', () => { + useTaxonomyDetailData('1'); + + expect(useQuery).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', '1'], + queryFn: expect.any(Function), + }); + }); +}); diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.jsx new file mode 100644 index 0000000000..31f3361a97 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/apiHooks.jsx @@ -0,0 +1,36 @@ +// @ts-check +import { + useTaxonomyDetailData, +} from './api'; + +/** + * @param {number} taxonomyId + * @returns {Pick} + */ +export const useTaxonomyDetailDataStatus = (taxonomyId) => { + const { + isError, + error, + isFetched, + isSuccess, + } = useTaxonomyDetailData(taxonomyId); + return { + isError, + error, + isFetched, + isSuccess, + }; +}; + +/** + * @param {number} taxonomyId + * @returns {import("./types.mjs").TaxonomyData | undefined} + */ +export const useTaxonomyDetailDataResponse = (taxonomyId) => { + const { isSuccess, data } = useTaxonomyDetailData(taxonomyId); + if (isSuccess) { + return data; + } + + return undefined; +}; diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx new file mode 100644 index 0000000000..e69232c363 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { + useTaxonomyDetailDataStatus, + useTaxonomyDetailDataResponse, +} from './apiHooks'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), +})); + +describe('useTaxonomyDetailDataStatus', () => { + it('should return status values', () => { + const status = { + isError: false, + error: undefined, + isFetched: true, + isSuccess: true, + }; + + useQuery.mockReturnValueOnce(status); + + const result = useTaxonomyDetailDataStatus(0); + + expect(result).toEqual(status); + }); +}); + +describe('useTaxonomyDetailDataResponse', () => { + it('should return data when status is success', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + + const result = useTaxonomyDetailDataResponse(); + + expect(result).toEqual('data'); + }); + + it('should return undefined when status is not success', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); + + const result = useTaxonomyDetailDataResponse(); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/taxonomy/taxonomy-detail/data/types.mjs b/src/taxonomy/taxonomy-detail/data/types.mjs new file mode 100644 index 0000000000..90b2c07acf --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/types.mjs @@ -0,0 +1,19 @@ +// @ts-check + +/** + * @typedef {Object} TaxonomyData + * @property {number} id + * @property {string} name + * @property {boolean} enabled + * @property {boolean} allowMultiple + * @property {boolean} allowFreeText + * @property {boolean} systemDefined + * @property {boolean} visibleToAuthors + * @property {string[]} orgs + */ + +/** + * @typedef {Object} UseQueryResult + * @property {Object} data + * @property {string} status + */ diff --git a/src/taxonomy/taxonomy-detail/index.js b/src/taxonomy/taxonomy-detail/index.js new file mode 100644 index 0000000000..5665033c97 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/index.js @@ -0,0 +1,2 @@ +// ts-check +export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/taxonomy-detail/messages.js b/src/taxonomy/taxonomy-detail/messages.js new file mode 100644 index 0000000000..ec5291f6c0 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/messages.js @@ -0,0 +1,31 @@ +// ts-check +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + taxonomyDetailsHeader: { + id: 'course-authoring.taxonomy-detail.side-card.header', + defaultMessage: 'Taxonomy details', + }, + taxonomyDetailsName: { + id: 'course-authoring.taxonomy-detail.side-card.name', + defaultMessage: 'Title', + }, + taxonomyDetailsDescription: { + id: 'course-authoring.taxonomy-detail.side-card.description', + defaultMessage: 'Description', + }, + actionsButtonLabel: { + id: 'course-authoring.taxonomy-detail.action.button.label', + defaultMessage: 'Actions', + }, + actionsButtonAlt: { + id: 'course-authoring.taxonomy-detail.action.button.alt', + defaultMessage: '{name} actions', + }, + exportMenu: { + id: 'course-authoring.taxonomy-detail.action.export', + defaultMessage: 'Export', + }, +}); + +export default messages;