diff --git a/src/generic/Loading.jsx b/src/generic/Loading.jsx
index 7f109a245c..9c696b013c 100644
--- a/src/generic/Loading.jsx
+++ b/src/generic/Loading.jsx
@@ -2,27 +2,24 @@ import React from 'react';
import { Spinner } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
+export const LoadingSpinner = () => (
+
+ )}
+ />
+);
+
const Loading = () => (
-
-
-
-
- )}
- />
+
+
);
diff --git a/src/generic/utils.js b/src/generic/utils.js
index 27236e8aa8..c24e5a1646 100644
--- a/src/generic/utils.js
+++ b/src/generic/utils.js
@@ -1,10 +1,16 @@
import { isEmpty } from 'lodash';
-const getPageHeadTitle = (courseName, pageName) => {
- if (isEmpty(courseName)) {
+/**
+ * Generate the string for the page
+ * @param {string} courseOrSectionName The name of the course, or the section of the MFE that the user is in currently
+ * @param {string} pageName The name of the current page
+ * @returns {string} The combined title
+ */
+const getPageHeadTitle = (courseOrSectionName, pageName) => {
+ if (isEmpty(courseOrSectionName)) {
return `${pageName} | ${process.env.SITE_NAME}`;
}
- return `${pageName} | ${courseName} | ${process.env.SITE_NAME}`;
+ return `${pageName} | ${courseOrSectionName} | ${process.env.SITE_NAME}`;
};
export default getPageHeadTitle;
diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx
index 79ca9982aa..20287b693f 100644
--- a/src/taxonomy/TaxonomyListPage.jsx
+++ b/src/taxonomy/TaxonomyListPage.jsx
@@ -6,7 +6,10 @@ import {
Spinner,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { Helmet } from 'react-helmet';
+
import SubHeader from '../generic/sub-header/SubHeader';
+import getPageHeadTitle from '../generic/utils';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
@@ -35,6 +38,9 @@ const TaxonomyListPage = () => {
return (
<>
+
+ {getPageHeadTitle('', intl.formatMessage(messages.headerTitle))}
+
getConfig().STUDIO_BASE_URL;
export const getTaxonomyListApiUrl = (org) => {
const url = new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl());
url.searchParams.append('enabled', 'true');
+ url.searchParams.append('page_size', '500'); // For the tagging MVP, we don't paginate the taxonomy list
if (org !== undefined) {
url.searchParams.append('org', org);
}
diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx
index 09a6a26d63..2fa3c042a0 100644
--- a/src/taxonomy/tag-list/TagListTable.jsx
+++ b/src/taxonomy/tag-list/TagListTable.jsx
@@ -1,14 +1,46 @@
// ts-check
-import { useIntl } from '@edx/frontend-platform/i18n';
-import {
- DataTable,
-} from '@edx/paragon';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+import { DataTable } from '@edx/paragon';
import _ from 'lodash';
import Proptypes from 'prop-types';
import { useState } from 'react';
+import { LoadingSpinner } from '../../generic/Loading';
import messages from './messages';
import { useTagListDataResponse, useTagListDataStatus } from './data/apiHooks';
+import { useSubTags } from './data/api';
+
+const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => {
+ const subTagsData = useSubTags(taxonomyId, parentTagValue);
+
+ if (subTagsData.isLoading) {
+ return ;
+ }
+ if (subTagsData.isError) {
+ return ;
+ }
+
+ return (
+
+ {subTagsData.data.results.map(tagData => (
+ -
+ {tagData.value} {tagData.childCount > 0 ? `(${tagData.childCount})` : null}
+
+ ))}
+
+ );
+};
+
+SubTagsExpanded.propTypes = {
+ taxonomyId: Proptypes.number.isRequired,
+ parentTagValue: Proptypes.string.isRequired,
+};
+
+/**
+ * An "Expand" toggle to show/hide subtags, but one which is hidden if the given tag row has no subtags.
+ */
+const OptionalExpandLink = ({ row }) => (row.values.childCount > 0 ? : null);
+OptionalExpandLink.propTypes = DataTable.ExpandRow.propTypes;
const TagListTable = ({ taxonomyId }) => {
const intl = useIntl();
@@ -34,11 +66,24 @@ const TagListTable = ({ taxonomyId }) => {
itemCount={tagList?.count || 0}
pageCount={tagList?.numPages || 0}
initialState={options}
+ isExpandable
+ // This is a temporary "bare bones" solution for brute-force loading all the child tags. In future we'll match
+ // the Figma design and do something more sophisticated.
+ renderRowSubComponent={({ row }) => }
columns={[
{
Header: intl.formatMessage(messages.tagListColumnValueHeader),
accessor: 'value',
},
+ {
+ id: 'expander',
+ Header: DataTable.ExpandAll,
+ Cell: OptionalExpandLink,
+ },
+ {
+ Header: intl.formatMessage(messages.tagListColumnChildCountHeader),
+ accessor: 'childCount',
+ },
]}
>
@@ -50,7 +95,7 @@ const TagListTable = ({ taxonomyId }) => {
};
TagListTable.propTypes = {
- taxonomyId: Proptypes.string.isRequired,
+ taxonomyId: Proptypes.number.isRequired,
};
export default TagListTable;
diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx
index e9d5015dd4..dd79c107fa 100644
--- a/src/taxonomy/tag-list/TagListTable.test.jsx
+++ b/src/taxonomy/tag-list/TagListTable.test.jsx
@@ -1,29 +1,84 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
-import { render } from '@testing-library/react';
+import { render, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import MockAdapter from 'axios-mock-adapter';
-import { useTagListData } from './data/api';
import initializeStore from '../../store';
import TagListTable from './TagListTable';
let store;
-
-jest.mock('./data/api', () => ({
- useTagListData: jest.fn(),
-}));
+let axiosMock;
+const queryClient = new QueryClient();
const RootWrapper = () => (
-
+
+
+
);
-describe('', async () => {
- beforeEach(async () => {
+const tagDefaults = { depth: 0, external_id: null, parent_value: null };
+const mockTagsResponse = {
+ next: null,
+ previous: null,
+ count: 3,
+ num_pages: 1,
+ current_page: 1,
+ start: 0,
+ results: [
+ {
+ ...tagDefaults,
+ value: 'two level tag 1',
+ child_count: 1,
+ _id: 1001,
+ sub_tags_url: '/request/to/load/subtags/1',
+ },
+ {
+ ...tagDefaults,
+ value: 'two level tag 2',
+ child_count: 1,
+ _id: 1002,
+ sub_tags_url: '/request/to/load/subtags/2',
+ },
+ {
+ ...tagDefaults,
+ value: 'two level tag 3',
+ child_count: 1,
+ _id: 1003,
+ sub_tags_url: '/request/to/load/subtags/3',
+ },
+ ],
+};
+const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?page=1';
+const subTagsResponse = {
+ next: null,
+ previous: null,
+ count: 1,
+ num_pages: 1,
+ current_page: 1,
+ start: 0,
+ results: [
+ {
+ ...tagDefaults,
+ depth: 1,
+ value: 'the child tag',
+ child_count: 0,
+ _id: 1111,
+ sub_tags_url: null,
+ },
+ ],
+};
+const subTagsUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&parent_tag=two+level+tag+1';
+
+describe('', () => {
+ beforeAll(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -32,36 +87,45 @@ describe('', async () => {
roles: [],
},
});
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+ beforeEach(async () => {
store = initializeStore();
+ axiosMock.reset();
});
it('shows the spinner before the query is complete', async () => {
- useTagListData.mockReturnValue({
- isLoading: true,
- isFetched: false,
- });
- const { getByRole } = render();
- const spinner = getByRole('status');
+ // Simulate an actual slow response from the API:
+ let resolveResponse;
+ const promise = new Promise(resolve => { resolveResponse = resolve; });
+ axiosMock.onGet(rootTagsListUrl).reply(() => promise);
+ const result = render();
+ const spinner = result.getByRole('status');
expect(spinner.textContent).toEqual('loading');
+ resolveResponse([200, {}]);
+ await waitFor(() => {
+ expect(result.getByText('No results found')).toBeInTheDocument();
+ });
});
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' },
- ],
- },
+ axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse);
+ const result = render();
+ await waitFor(() => {
+ expect(result.getByText('two level tag 1')).toBeInTheDocument();
});
- const { getAllByRole } = render();
- const rows = getAllByRole('row');
+ const rows = result.getAllByRole('row');
expect(rows.length).toBe(3 + 1); // 3 items plus header
});
+
+ it('should render page correctly with subtags', async () => {
+ axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse);
+ axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse);
+ const result = render();
+ const expandButton = result.getAllByLabelText('Expand row')[0];
+ expandButton.click();
+ await waitFor(() => {
+ expect(result.getByText('the child tag')).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/taxonomy/tag-list/data/api.js b/src/taxonomy/tag-list/data/api.js
index 570e29ce48..6561256e9a 100644
--- a/src/taxonomy/tag-list/data/api.js
+++ b/src/taxonomy/tag-list/data/api.js
@@ -25,3 +25,22 @@ export const useTagListData = (taxonomyId, options) => {
},
});
};
+
+/**
+ * Temporary hook to load *all* the subtags of a given tag in a taxonomy.
+ * Doesn't handle pagination or anything. This is meant to be replaced by
+ * something more sophisticated later, as we improve the "taxonomy details" page.
+ * @param {number} taxonomyId
+ * @param {string} parentTagValue
+ * @returns {import('@tanstack/react-query').UseQueryResult}
+ */
+export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
+ queryKey: ['subtagsList', taxonomyId, parentTagValue],
+ queryFn: async () => {
+ const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
+ url.searchParams.set('full_depth_threshold', '10000'); // Load as deeply as we can
+ url.searchParams.set('parent_tag', parentTagValue);
+ const response = await getAuthenticatedHttpClient().get(url.href);
+ return camelCaseObject(response.data);
+ },
+});
diff --git a/src/taxonomy/tag-list/messages.js b/src/taxonomy/tag-list/messages.js
index 5832fdb465..82db9caba7 100644
--- a/src/taxonomy/tag-list/messages.js
+++ b/src/taxonomy/tag-list/messages.js
@@ -9,6 +9,14 @@ const messages = defineMessages({
id: 'course-authoring.tag-list.column.value.header',
defaultMessage: 'Value',
},
+ tagListColumnChildCountHeader: {
+ id: 'course-authoring.tag-list.column.value.header',
+ defaultMessage: '# child tags',
+ },
+ tagListError: {
+ id: 'course-authoring.tag-list.error',
+ defaultMessage: 'Error: unable to load child tags',
+ },
});
export default messages;
diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx
index a22fb0bd57..177bac326d 100644
--- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx
+++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx
@@ -6,10 +6,12 @@ import {
Container,
Layout,
} from '@edx/paragon';
+import { Helmet } from 'react-helmet';
import { Link, useParams } from 'react-router-dom';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import Loading from '../../generic/Loading';
+import getPageHeadTitle from '../../generic/utils';
import SubHeader from '../../generic/sub-header/SubHeader';
import taxonomyMessages from '../messages';
import TaxonomyDetailMenu from './TaxonomyDetailMenu';
@@ -20,9 +22,12 @@ import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './da
const TaxonomyDetailPage = () => {
const intl = useIntl();
- const { taxonomyId } = useParams();
- const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
+ const { taxonomyId: taxonomyIdString } = useParams();
+ const taxonomyId = Number(taxonomyIdString);
+
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
+ const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
+
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
if (!isFetched) {
@@ -74,6 +79,9 @@ const TaxonomyDetailPage = () => {
return (
<>
+
+ {getPageHeadTitle(intl.formatMessage(taxonomyMessages.headerTitle), taxonomy.name)}
+