diff --git a/src/index.jsx b/src/index.jsx index 0a8dfae4db..9ab2dd977c 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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 'react-datepicker/dist/react-datepicker.css'; import './index.scss'; @@ -53,10 +53,10 @@ const App = () => { } /> } /> {process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( - } - /> + }> + } /> + } /> + )} 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/data/types.mjs b/src/taxonomy/data/types.mjs index 980939b255..be2d86d1c3 100644 --- a/src/taxonomy/data/types.mjs +++ b/src/taxonomy/data/types.mjs @@ -1,6 +1,6 @@ // @ts-check -/** +/** * @typedef {Object} TaxonomyData * @property {number} id * @property {string} name @@ -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/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..0eeb5fe402 --- /dev/null +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -0,0 +1,68 @@ +// 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/selectors'; + +const TagListTable = ({ taxonomyId }) => { + const intl = useIntl(); + + const [options, setOptions] = useState({ + pageIndex: 0, + }); + + const useTagListData = () => { + const { isError, isFetched, isLoading } = useTagListDataStatus(taxonomyId, options); + const tagList = useTagListDataResponse(taxonomyId, options); + return { + isError, + isFetched, + isLoading, + tagList, + }; + }; + + const { tagList, isLoading } = useTagListData(); + + 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..456cb0020f --- /dev/null +++ b/src/taxonomy/tag-list/data/api.js @@ -0,0 +1,26 @@ +// @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: () => getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId, pageIndex)) + .then((response) => response.data) + .then(camelCaseObject), + }); +}; 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/selectors.js b/src/taxonomy/tag-list/data/selectors.js new file mode 100644 index 0000000000..7f793f466c --- /dev/null +++ b/src/taxonomy/tag-list/data/selectors.js @@ -0,0 +1,42 @@ +// @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, + }; +}; + +// ToDo: fix types +/** + * @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/selectors.test.js b/src/taxonomy/tag-list/data/selectors.test.js new file mode 100644 index 0000000000..31a2450303 --- /dev/null +++ b/src/taxonomy/tag-list/data/selectors.test.js @@ -0,0 +1,47 @@ +import { + useTagListDataStatus, + useTagListDataResponse, +} from './selectors'; +import { + useTagListData, +} from './api'; + +jest.mock('./api', () => ({ + useTagListData: jest.fn(), +})); + +describe('useTagListDataStatus', () => { + it('should return status values', () => { + const status = { + error: undefined, + isError: false, + isFetched: true, + isLoading: true, + isSuccess: true, + }; + + useTagListData.mockReturnValueOnce(status); + + const result = useTagListDataStatus(0, {}); + + expect(result).toEqual(status); + }); +}); + +describe('useTagListDataResponse', () => { + it('should return data when status is success', () => { + useTagListData.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + + const result = useTagListDataResponse(0, {}); + + expect(result).toEqual('data'); + }); + + it('should return undefined when status is not success', () => { + useTagListData.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..ac0ce31b3d --- /dev/null +++ b/src/taxonomy/tag-list/index.js @@ -0,0 +1 @@ +export { default as TagListTable } from './TagListTable'; // eslint-disable-line import/prefer-default-export 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/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx index 7f677bd4a4..7cccb24008 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -18,7 +18,8 @@ const TaxonomyCardMenu = ({ const [menuIsOpen, setMenuIsOpen] = useState(false); const [menuTarget, setMenuTarget] = useState(null); - const onClickItem = (menuName) => { + const onClickItem = (e, menuName) => { + e.preventDefault(); setMenuIsOpen(false); onClickMenuItem(menuName); }; @@ -27,7 +28,10 @@ const TaxonomyCardMenu = ({ <> setMenuIsOpen(true)} + onClick={(e) => { + e.preventDefault(); + setMenuIsOpen(true); + }} ref={setMenuTarget} src={MoreVert} iconAs={Icon} @@ -41,7 +45,7 @@ const TaxonomyCardMenu = ({ > {/* Add more menu items here */} - onClickItem('export')}> + onClickItem(e, 'export')}> {intl.formatMessage(messages.taxonomyCardExportMenu)} diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index 150f36e686..c3f1c41346 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'; @@ -110,7 +111,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..6c39a0e23b --- /dev/null +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -0,0 +1,128 @@ +// 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/selectors'; + +const TaxonomyDetailPage = () => { + const intl = useIntl(); + const { taxonomyId } = useParams(); + + const useTaxonomyDetailData = () => { + const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId); + const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); + return { isError, isFetched, taxonomy }; + }; + + const { isError, isFetched, taxonomy } = useTaxonomyDetailData(taxonomyId); + + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + + if (!isFetched) { + return ( + + ); + } + + if (isError || !taxonomy) { + return ( + + ); + } + + const renderModals = () => ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + {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..3a808530b2 --- /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/selectors.js b/src/taxonomy/taxonomy-detail/data/selectors.js new file mode 100644 index 0000000000..31f3361a97 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/selectors.js @@ -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/selectors.test.js b/src/taxonomy/taxonomy-detail/data/selectors.test.js new file mode 100644 index 0000000000..210afc40bf --- /dev/null +++ b/src/taxonomy/taxonomy-detail/data/selectors.test.js @@ -0,0 +1,47 @@ +import { + useTaxonomyDetailData, +} from './api'; +import { + useTaxonomyDetailDataStatus, + useTaxonomyDetailDataResponse, +} from './selectors'; + +jest.mock('./api', () => ({ + __esModule: true, + useTaxonomyDetailData: jest.fn(), +})); + +describe('useTaxonomyDetailDataStatus', () => { + it('should return status values', () => { + const status = { + isError: false, + error: undefined, + isFetched: true, + isSuccess: true, + }; + + useTaxonomyDetailData.mockReturnValueOnce(status); + + const result = useTaxonomyDetailDataStatus(0); + + expect(result).toEqual(status); + }); +}); + +describe('useTaxonomyDetailDataResponse', () => { + it('should return data when status is success', () => { + useTaxonomyDetailData.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + + const result = useTaxonomyDetailDataResponse(); + + expect(result).toEqual('data'); + }); + + it('should return undefined when status is not success', () => { + useTaxonomyDetailData.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;