diff --git a/src/index.jsx b/src/index.jsx
index f671a90f84..dec441acad 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 { TaxonomyDetailPage, TaxonomyListPage } from './taxonomy';
import 'react-datepicker/dist/react-datepicker.css';
import './index.scss';
@@ -71,12 +71,22 @@ const App = () => {
}}
/>
{process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
-
-
-
+ <>
+
+ {
+ const { params: { taxonomyId } } = match;
+ return (
+
+ );
+ }}
+ />
+ >
)}
+
+ No match (404)
+
diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js
index b524bd197f..02f59d32da 100644
--- a/src/taxonomy/api/hooks/api.js
+++ b/src/taxonomy/api/hooks/api.js
@@ -5,10 +5,18 @@ 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(
+const getExportTaxonomyApiUrl = (pk, format) => new URL(
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
getApiBaseUrl(),
).href;
+const getTaxonomyDetailApiUrl = (taxonomyId) => new URL(
+ `api/content_tagging/v1/taxonomies/${taxonomyId}/`,
+ getApiBaseUrl(),
+).href;
+const getTagListApiUrl = (taxonomyId) => new URL(
+ `api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`,
+ getApiBaseUrl(),
+).href;
/**
* @returns {import("../types.mjs").UseQueryResult}
@@ -24,3 +32,29 @@ export const useTaxonomyListData = () => (
export const exportTaxonomy = (pk, format) => {
window.location.href = getExportTaxonomyApiUrl(pk, format);
};
+
+/**
+ * @param {number} taxonomyId
+ * @returns {import('@tanstack/react-query').UseQueryResult}
+ */
+export const useTaxonomyDetailData = (taxonomyId) => (
+ useQuery({
+ queryKey: ['taxonomyList', taxonomyId],
+ queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyDetailApiUrl(taxonomyId))
+ .then(camelCaseObject)
+ .then((response) => response.data),
+ })
+);
+
+/**
+ * @param {number} taxonomyId
+ * @returns {import('@tanstack/react-query').UseQueryResult}
+ */
+export const useTagListData = (taxonomyId) => (
+ useQuery({
+ queryKey: ['tagList', taxonomyId],
+ queryFn: () => getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId))
+ .then(camelCaseObject)
+ .then((response) => response.data),
+ })
+);
diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js
index b3dc0045d1..b712f05869 100644
--- a/src/taxonomy/api/hooks/api.test.js
+++ b/src/taxonomy/api/hooks/api.test.js
@@ -7,12 +7,17 @@ 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/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js
index b2c678be78..baa19305c0 100644
--- a/src/taxonomy/api/hooks/selectors.js
+++ b/src/taxonomy/api/hooks/selectors.js
@@ -1,6 +1,8 @@
// @ts-check
import {
+ useTaxonomyDetailData,
useTaxonomyListData,
+ useTagListData,
exportTaxonomy,
} from './api';
@@ -25,3 +27,67 @@ export const useIsTaxonomyListDataLoaded = () => (
export const callExportTaxonomy = (pk, format) => (
exportTaxonomy(pk, format)
);
+
+/**
+ * @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;
+};
+
+/**
+ * @param {number} taxonomyId
+ * @returns {Pick}
+ */
+export const useTagListDataStatus = (taxonomyId) => {
+ const {
+ isError,
+ error,
+ isFetched,
+ isSuccess,
+ } = useTagListData(taxonomyId);
+ return {
+ isError,
+ error,
+ isFetched,
+ isSuccess,
+ };
+};
+
+/**
+ * @param {number} taxonomyId
+ * @returns {import("../types.mjs").TaxonomyData | undefined}
+ */
+export const useTagListDataResponse = (taxonomyId) => {
+ const { isSuccess, data } = useTagListData(taxonomyId);
+ if (isSuccess) {
+ return data;
+ }
+
+ return undefined;
+};
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/index.js b/src/taxonomy/index.js
index c857f10e6c..f47e0315fc 100644
--- a/src/taxonomy/index.js
+++ b/src/taxonomy/index.js
@@ -1,2 +1,2 @@
-// eslint-disable-next-line import/prefer-default-export
export { default as TaxonomyListPage } from './TaxonomyListPage';
+export { default as TaxonomyDetailPage } from './taxonomy-detail/TaxonomyDetailPage';
diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx
index 0b024a96ae..a5af87f695 100644
--- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx
+++ b/src/taxonomy/taxonomy-card/TaxonomyCard.jsx
@@ -7,6 +7,8 @@ import {
} from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+
import classNames from 'classnames';
import messages from '../messages';
import TaxonomyCardMenu from './TaxonomyCardMenu';
@@ -101,7 +103,8 @@ const TaxonomyCard = ({ className, original, intl }) => {
return (
<>
-
+
+
setMenuIsOpen(true)}
ref={setMenuTarget}
diff --git a/src/taxonomy/taxonomy-detail/TagListTable.jsx b/src/taxonomy/taxonomy-detail/TagListTable.jsx
new file mode 100644
index 0000000000..e1731e78c9
--- /dev/null
+++ b/src/taxonomy/taxonomy-detail/TagListTable.jsx
@@ -0,0 +1,48 @@
+import {
+ DataTable,
+ TextFilter,
+} from '@edx/paragon';
+import Proptypes from 'prop-types';
+
+import { useTagListDataResponse, useTagListDataStatus } from '../api/hooks/selectors';
+
+const TagListTable = ({ taxonomyId }) => {
+ const useTagListData = () => {
+ const { isError, isFetched } = useTagListDataStatus(taxonomyId);
+ const tagList = useTagListDataResponse(taxonomyId);
+ return { isError, isFetched, tagList };
+ };
+
+ const { tagList } = useTagListData(taxonomyId);
+
+ if (!tagList || !tagList.results) {
+ return 'Loading...';
+ }
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+TagListTable.propTypes = {
+ taxonomyId: Proptypes.number.isRequired,
+};
+
+export default TagListTable;
diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailContent.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailContent.jsx
new file mode 100644
index 0000000000..02da58e171
--- /dev/null
+++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailContent.jsx
@@ -0,0 +1,133 @@
+import React, { useState } from 'react';
+import {
+ Breadcrumb,
+ Container,
+ Layout,
+} from '@edx/paragon';
+import Proptypes from 'prop-types';
+
+import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
+import Loading from '../../generic/Loading';
+import SubHeader from '../../generic/sub-header/SubHeader';
+import TaxonomyDetailMenu from './TaxonomyDetailMenu';
+import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
+import TagListTable from './TagListTable';
+import ExportModal from '../modals/ExportModal';
+import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../api/hooks/selectors';
+
+const TaxonomyDetailContent = ({ taxonomyId }) => {
+ 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 (isError) {
+ return (
+
+ );
+ }
+
+ if (!isFetched) {
+ return (
+
+ );
+ }
+
+ const renderModals = () => (
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ <>
+ {isExportModalOpen && (
+ setIsExportModalOpen(false)}
+ taxonomyId={taxonomyId}
+ taxonomyName={taxonomy.name}
+ />
+ )}
+ >
+ );
+
+ const onClickMenuItem = (menuName) => {
+ switch (menuName) {
+ case 'export':
+ setIsExportModalOpen(true);
+ break;
+ default:
+ break;
+ }
+ };
+
+ const getHeaderActions = () => (
+
+ );
+
+ if (taxonomy) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {renderModals()}
+ >
+ );
+ }
+
+ return undefined;
+};
+
+TaxonomyDetailContent.propTypes = {
+ taxonomyId: Proptypes.number,
+};
+
+TaxonomyDetailContent.defaultProps = {
+ taxonomyId: undefined,
+};
+
+export default TaxonomyDetailContent;
diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailMenu.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailMenu.jsx
new file mode 100644
index 0000000000..def562ffbd
--- /dev/null
+++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailMenu.jsx
@@ -0,0 +1,37 @@
+import {
+ Dropdown,
+ DropdownButton,
+} from '@edx/paragon';
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import messages from '../messages';
+
+const TaxonomyDetailMenu = ({
+ id, name, disabled, onClickMenuItem, intl,
+}) => (
+
+ onClickMenuItem('export')}>
+ {intl.formatMessage(messages.taxonomyCardExportMenu)}
+
+
+);
+
+TaxonomyDetailMenu.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ disabled: PropTypes.bool,
+ onClickMenuItem: PropTypes.func.isRequired,
+ intl: intlShape.isRequired,
+};
+
+TaxonomyDetailMenu.defaultProps = {
+ disabled: false,
+};
+
+export default injectIntl(TaxonomyDetailMenu);
diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx
new file mode 100644
index 0000000000..29676c7188
--- /dev/null
+++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx
@@ -0,0 +1,28 @@
+import Proptypes from 'prop-types';
+
+import Header from '../../header';
+import TaxonomyDetailContent from './TaxonomyDetailContent';
+
+const TaxonomyDetailPage = ({ taxonomyId }) => (
+ <>
+
+
+
+ >
+);
+
+TaxonomyDetailPage.propTypes = {
+ taxonomyId: Proptypes.number,
+};
+
+TaxonomyDetailPage.defaultProps = {
+ taxonomyId: undefined,
+};
+
+export default TaxonomyDetailPage;
diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx
new file mode 100644
index 0000000000..3a27180ff5
--- /dev/null
+++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx
@@ -0,0 +1,27 @@
+import {
+ Card,
+} from '@edx/paragon';
+import Proptypes from 'prop-types';
+
+const TaxonomyDetailSideCard = ({ taxonomy }) => (
+
+
+
+ {taxonomy.name}
+
+
+
+ {taxonomy.description}
+
+
+
+ No copyright added
+
+
+);
+
+TaxonomyDetailSideCard.propTypes = {
+ taxonomy: Proptypes.object.isRequired,
+};
+
+export default TaxonomyDetailSideCard;