diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx index 1ef5ed6f12..4b620677db 100644 --- a/src/library-authoring/library-info/LibraryInfo.test.tsx +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -291,7 +291,7 @@ describe('', () => { expect(screen.getByText('Settings')).toBeInTheDocument(); }); - it('renders PublicReadToggle when user can manage team', async () => { + it('renders PublicReadToggle when user can manage team', async () => { render(); const allowSwitch = await screen.findByRole('switch', { name: /allow public read/i }); expect(allowSwitch).toBeInTheDocument(); diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 063971ee08..eb50241106 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -67,12 +67,13 @@ export const apiUrls = { pageIndex, pageSize, fullDepth, disablePagination, }: { pageIndex: number | null; pageSize: number | null; fullDepth?: boolean; disablePagination?: boolean }) => { if (disablePagination) { - return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0 }); + return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, include_counts: 'true' }); } return makeUrl(`${taxonomyId}/tags/`, { page: (pageIndex ?? 0) + 1, page_size: pageSize ?? 10, full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, + include_counts: 'true', }); }, /** diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 753503cf3f..3207f5665a 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -13,10 +13,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } from './api'; import * as api from './api'; import type { QueryOptions, TagListData } from './types'; -import { useIntl } from '@edx/frontend-platform/i18n'; // Query key patterns. Allows an easy way to clear all data related to a given taxonomy. // https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst @@ -97,6 +97,7 @@ export const useDeleteTaxonomy = () => { export const useTaxonomyDetails = (taxonomyId: number) => useQuery({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), queryFn: () => api.getTaxonomy(taxonomyId), + refetchOnMount: 'always', }); /** @@ -194,6 +195,7 @@ export const useTagListData = (taxonomyId: number, options: QueryOptions) => { return camelCaseObject(data) as TagListData; }, enabled, + refetchOnMount: 'always', }); }; @@ -228,9 +230,13 @@ export const useCreateTag = (taxonomyId: number) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + refetchType: 'none', }); // In the metadata, 'tagsCount' (and possibly other fields) will have changed: - queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), + refetchType: 'none', + }); }, }); }; diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index a7616a2ab7..6074dc1e23 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -61,6 +61,7 @@ const mockTagsResponse = { descendant_count: 14, _id: 1001, sub_tags_url: '/request/to/load/subtags/1', + usage_count: 1, }, { ...tagDefaults, @@ -69,6 +70,7 @@ const mockTagsResponse = { descendant_count: 10, _id: 1002, sub_tags_url: '/request/to/load/subtags/2', + usage_count: 0, }, { ...tagDefaults, @@ -77,6 +79,7 @@ const mockTagsResponse = { descendant_count: 5, _id: 1003, sub_tags_url: '/request/to/load/subtags/3', + usage_count: 3, }, { ...tagDefaults, @@ -86,6 +89,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'root tag 1', + usage_count: 1, }, { ...tagDefaults, @@ -95,6 +99,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'the child tag', + usage_count: null, }, ], }; @@ -107,7 +112,7 @@ const mockTagsPaginationResponse = { start: 0, results: [], }; -const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000'; +const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&include_counts=true'; const subTagsResponse = { next: null, previous: null, @@ -217,6 +222,13 @@ describe('', () => { expect(rows.length).toBe(3 + 1); // 3 items plus header expect(within(rows[0]).getAllByRole('columnheader')[0].textContent).toEqual('Tag name'); expect(within(rows[1]).getAllByRole('cell')[0].textContent).toEqual('root tag 1'); + expect(within(rows[0]).getAllByRole('columnheader')[1].textContent).toEqual('Usage Count'); + }); + + it('should render usage count correctly for root tag', async () => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + expect(within(rows[1]).getAllByRole('cell')[1].textContent).toEqual('1'); }); it('should render page correctly with subtags', async () => { @@ -226,6 +238,36 @@ describe('', () => { expect(childTag).toBeInTheDocument(); }); + it('should render usage count correctly for sub tag', async () => { + // Expand all tags and await for child tag to render + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(5 + 1); // 5 items plus header + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual('1'); + }); + + it('should render usage count as empty/no content when usage count is "0"', async () => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual(''); + }); + + it('should render usage count as empty/no when usage count is "null"', async () => { + // Expand all tags and await for child tag to render + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(5 + 1); // 5 items plus header + expect(within(rows[4]).getAllByRole('cell')[1].textContent).toEqual(''); + }); + it('should not render pagination footer if too few results', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); renderTagListTable(); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 01795b8917..01bfdbfe11 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -3,7 +3,6 @@ import React, { useMemo, useEffect, } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; import type { PaginationState } from '@tanstack/react-table'; import { useTagListData, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; @@ -40,7 +39,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // TODO: Simpler approaches have been suggested. Two options are to just use simple React state: // `isCurrentlyEditingTag` and `lastCreatedTag`, or to use optimistic updates. // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. - const intl = useIntl(); const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 2e9b7ecadb..b85c8a4696 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -5,6 +5,10 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.column.value.header', defaultMessage: 'Tag name', }, + tagListColumnCountHeader: { + id: 'course-authoring.tag-list.column.count.header', + defaultMessage: 'Usage Count', + }, tagListError: { id: 'course-authoring.tag-list.error', defaultMessage: 'Error: unable to load child tags', diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index c70b415a69..2fad2e25c6 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -1,4 +1,5 @@ import { + Bubble, Button, Icon, IconButton, @@ -24,6 +25,7 @@ interface TagListRowData extends TreeRowData { depth: number; childCount: number; descendantCount: number; + usageCount?: number; isNew?: boolean; isEditing?: boolean; } @@ -47,6 +49,17 @@ interface GetColumnsArgs { creatingParentId: RowId | null; } +const UsageCountDisplay = ({ row }: { row: Row }) => { + const count = asTagListRowData(row).usageCount ?? 0; + return ( + count > 0 && ( + + {count} + + ) + ); +}; + interface ActionsHeaderProps { onStartDraft: () => void; setDraftError: (error: string) => void; @@ -120,7 +133,7 @@ const ActionsMenu = ({ rowData, startSubtagDraft, disableAddSubtag }: ActionsMen ); -} +}; function getColumns({ setIsCreatingTopTag, @@ -135,6 +148,7 @@ function getColumns({ }: GetColumnsArgs): TreeColumnDef[] { const canAddSubtag = (row: Row) => row.depth < maxDepth; const draftInProgressHintId = 'tag-list-draft-in-progress-hint'; + const intl = useIntl(); return [ { @@ -153,6 +167,11 @@ function getColumns({ ); }, }, + { + id: 'count', + header: intl.formatMessage(messages.tagListColumnCountHeader), + cell: UsageCountDisplay, + }, { id: 'actions', header: () => (