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 = ({
>
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;