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;