From 5ee05b866cea3cb3b517a6767ce66ef78b9deec2 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 24 Aug 2026 13:41:06 +0200 Subject: [PATCH 1/7] Feat: Add a new user pref to display synonym counts in taxon tree --- .../js_src/lib/components/Preferences/UserDefinitions.tsx | 8 ++++++++ .../frontend/js_src/lib/localization/preferences.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 39ea28246f4..5c4eec0596f 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -1615,6 +1615,14 @@ export const userPreferenceDefinitions = { defaultValue: true, type: 'java.lang.Boolean', }), + showSynonymCounts: definePref({ + title: preferencesText.showSynonymCounts(), + description: preferencesText.showSynonymCountsDescription(), + requiresReload: true, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), queryField: definePref<'preferredTaxon' | 'taxon'>({ title: preferencesText.queryButtonTaxonField(), description: preferencesText.queryButtonTaxonFieldDescription(), diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.ts b/specifyweb/frontend/js_src/lib/localization/preferences.ts index ede57160f7f..84e4df1642d 100644 --- a/specifyweb/frontend/js_src/lib/localization/preferences.ts +++ b/specifyweb/frontend/js_src/lib/localization/preferences.ts @@ -895,6 +895,13 @@ export const preferencesText = createDictionary({ 'en-us': 'Choose whether the Taxon tree Query button filters by Taxon or Preferred Taxon. Preferred Taxon matches the tree counts; Taxon follows the original determination taxon.', }, + showSynonymCounts: { + 'en-us': 'Show counts for synonymized nodes', + }, + showSynonymCountsDescription: { + 'en-us': + 'Display an additional count of records that were determined as a node, or any of its descendants, and are now synonyms of another node.', + }, welcomePage: { 'en-us': 'Home Page', 'ru-ru': 'Главная страница', From 0473d5681704829a26bb4e754ce9501be47cdfc6 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 24 Aug 2026 13:58:52 +0200 Subject: [PATCH 2/7] Feat: Add synonym count to tree stat endpoint --- specifyweb/backend/trees/stats.py | 35 ++++++++++++++++++++++++++++++- specifyweb/backend/trees/views.py | 7 ++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/trees/stats.py b/specifyweb/backend/trees/stats.py index 885e23e16b6..16d1d963aeb 100644 --- a/specifyweb/backend/trees/stats.py +++ b/specifyweb/backend/trees/stats.py @@ -10,7 +10,7 @@ import logging logger = logging.getLogger(__name__) -def get_tree_stats(treedef, tree, parentid, specify_collection, session_context, using_cte): +def get_tree_stats(treedef, tree, parentid, specify_collection, session_context, using_cte, include_synonym_count=False): tree_table = datamodel.get_table(tree) tree_def_item = getattr(models, tree_table.name + 'TreeDefItem') parentid = None if parentid == 'null' else int(parentid) @@ -112,9 +112,42 @@ def wrap_cte_query(cte_query, query): query = make_joins(query) results = list(query) + if include_synonym_count: + synonym_counts = get_synonym_counts( + tree_node, treedef_col, treedef, parentid, specify_collection, + session) + results = [ + (*row, synonym_counts.get(row[0], 0)) for row in results + ] + logger.debug(str(query)) return results + +def get_synonym_counts(tree_node, treedef_col, treedef, parentid, specify_collection, session): + """Count current determinations that still point directly at a node which has + since been synonymized into a different (preferred) node, aggregated over each + child's whole subtree so parents reflect their synonymized descendants.""" + child = aliased(tree_node) + descendant = aliased(tree_node) + det = aliased(models.Determination) + + query = session.query(child._id, sql.func.count(det._id)) \ + .outerjoin(descendant, sql.and_( + descendant.nodeNumber.between( + child.nodeNumber, child.highestChildNodeNumber), + getattr(descendant, treedef_col) == int(treedef))) \ + .outerjoin(det, sql.and_( + det.isCurrent, + det.collectionMemberId == specify_collection.id, + det.TaxonID == descendant._id, + det.PreferredTaxonID != descendant._id)) \ + .filter(child.ParentID == parentid) \ + .filter(getattr(child, treedef_col) == int(treedef)) \ + .group_by(child._id) + + return dict(query) + class StatsQuerySpecialization( namedtuple('StatsQuerySpecialization', 'collection')): diff --git a/specifyweb/backend/trees/views.py b/specifyweb/backend/trees/views.py index 4e0aaab08a7..a979359e4b6 100644 --- a/specifyweb/backend/trees/views.py +++ b/specifyweb/backend/trees/views.py @@ -268,8 +268,13 @@ def tree_stats(request, treedef, tree, parentid): "Returns tree stats (collection object count) for tree nodes parented by ." using_cte = (tree in ['geography', 'taxon', 'storage']) + include_synonym_count = ( + tree == 'taxon' + and request.GET.get('includeSynonymCount', 'false') == 'true' + ) results = get_tree_stats( - treedef, tree, parentid, request.specify_collection, sqlmodels.session_context, using_cte) + treedef, tree, parentid, request.specify_collection, sqlmodels.session_context, using_cte, + include_synonym_count) return HttpResponse(toJson(results), content_type="application/json") From 7995fdfb076cffc8e9572baa95575e5bab8bab62 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 24 Aug 2026 14:16:55 +0200 Subject: [PATCH 3/7] Feat: Display synonym count in taxon tree --- .../js_src/lib/components/TreeView/Row.tsx | 4 +++- .../js_src/lib/components/TreeView/Tree.tsx | 15 +++++++++++++-- .../frontend/js_src/lib/localization/tree.ts | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx index 4a0f82b6539..d821d3e521c 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx @@ -214,7 +214,9 @@ export function TreeRow({ descendantCount === 0; const hasNoChildrenNodes = - nodeStats?.directCount === 0 && nodeStats.childCount === 0; + nodeStats?.directCount === 0 && + nodeStats.childCount === 0 && + (nodeStats.synonymCount ?? 0) === 0; return (hideEmptyNodes && hasNoChildrenNodes) || isHiddenInFilter ? null : (
  • diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index b3041e6e1f9..26a2c0506d6 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -111,12 +111,23 @@ export function Tree< 'rankThreshold' ); + const [showSynonymCounts] = userPreferences.use( + 'treeEditor', + 'taxon', + 'showSynonymCounts' + ); + const includeSynonymCount = tableName === 'Taxon' && showSynonymCounts; + const getStats = React.useCallback( async (nodeId: number | 'null', rankId: number): Promise => rankId >= statsThreshold - ? fetchStats(`${baseUrl}/${nodeId}/stats/`) + ? fetchStats( + `${baseUrl}/${nodeId}/stats/${ + includeSynonymCount ? '?includeSynonymCount=true' : '' + }` + ) : Promise.resolve({}), - [baseUrl, statsThreshold] + [baseUrl, statsThreshold, includeSynonymCount] ); const treeDefinition = treeDefinitionItems[0].treeDef; diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index 57ac5edfaf1..f920125f383 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -511,6 +511,21 @@ export const treeText = createDictionary({ 'hr-hr': '({directCount:number|formatted})', nb: '({directCount:number|formatted})', }, + nodeStatsWithSynonyms: { + comment: "Used to show tree node's direct, indirect and synonymized usages", + 'en-us': + '({directCount:number|formatted}, {childCount:number|formatted}, {synonymCount:number|formatted})', + }, + leafNodeStatsWithSynonyms: { + comment: "Used to show leaf tree node's direct and synonymized usages", + 'en-us': + '({directCount:number|formatted}, {synonymCount:number|formatted})', + }, + synonymizedCollectionObjectCount: { + comment: + 'Count of records determined as this node or any of its descendants that have been synonymized into another node', + 'en-us': 'Synonymized {collectionObjectTable:string} Count', + }, directCollectionObjectCount: { comment: 'Example: Direct Collection Object count', 'en-us': 'Direct {collectionObjectTable:string} Count', From 4f923014d7fd88dcd13e48f033efe37046ac215b Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 24 Aug 2026 14:17:23 +0200 Subject: [PATCH 4/7] Feat: Display synonym count in taxon tree --- .../js_src/lib/components/TreeView/helpers.ts | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts b/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts index 554fa195ff0..3f8a6c0c894 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts @@ -89,6 +89,8 @@ export type Stats = RR< { readonly directCount: number; readonly childCount: number; + // Only present when the "show counts for synonymized nodes" pref is on + readonly synonymCount: number | undefined; } >; @@ -96,17 +98,18 @@ export type Stats = RR< * Fetch tree node usage stats */ export const fetchStats = async (url: string): Promise => - ajax>(url, { + ajax>(url, { headers: { Accept: 'application/json' }, errorMode: 'silent', }) .then(({ data }) => Object.fromEntries( - data.map(([childId, directCount, allCount]) => [ + data.map(([childId, directCount, allCount, synonymCount]) => [ childId, { directCount, childCount: allCount - directCount, + synonymCount, }, ]) ) @@ -208,30 +211,53 @@ export const formatTreeStats = ( ): { readonly title: string; readonly text: string; -} => ({ - title: filterArray([ - commonText.colonLine({ - label: treeText.directCollectionObjectCount({ - collectionObjectTable: tables.CollectionObject.label, - }), - value: nodeStats.directCount.toString(), - }), - isLeaf - ? undefined - : commonText.colonLine({ - label: treeText.indirectCollectionObjectCount({ - collectionObjectTable: tables.CollectionObject.label, - }), - value: nodeStats.childCount.toString(), +} => { + const { synonymCount } = nodeStats; + return { + title: filterArray([ + commonText.colonLine({ + label: treeText.directCollectionObjectCount({ + collectionObjectTable: tables.CollectionObject.label, }), - ]).join('\n'), - text: isLeaf - ? treeText.leafNodeStats({ directCount: nodeStats.directCount }) - : treeText.nodeStats({ - directCount: nodeStats.directCount, - childCount: nodeStats.childCount, + value: nodeStats.directCount.toString(), }), -}); + isLeaf + ? undefined + : commonText.colonLine({ + label: treeText.indirectCollectionObjectCount({ + collectionObjectTable: tables.CollectionObject.label, + }), + value: nodeStats.childCount.toString(), + }), + typeof synonymCount === 'number' + ? commonText.colonLine({ + label: treeText.synonymizedCollectionObjectCount({ + collectionObjectTable: tables.CollectionObject.label, + }), + value: synonymCount.toString(), + }) + : undefined, + ]).join('\n'), + text: + typeof synonymCount === 'number' + ? isLeaf + ? treeText.leafNodeStatsWithSynonyms({ + directCount: nodeStats.directCount, + synonymCount, + }) + : treeText.nodeStatsWithSynonyms({ + directCount: nodeStats.directCount, + childCount: nodeStats.childCount, + synonymCount, + }) + : isLeaf + ? treeText.leafNodeStats({ directCount: nodeStats.directCount }) + : treeText.nodeStats({ + directCount: nodeStats.directCount, + childCount: nodeStats.childCount, + }), + }; +}; /** * Check if there are any enforced ranks between current tree node parent From a129ea00d9bdce192f5e51453314931c0642f241 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 24 Aug 2026 15:09:50 +0200 Subject: [PATCH 5/7] Test: Add frontend test for synonym display in tree --- .../__tests__/formatTreeStats.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts b/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts new file mode 100644 index 00000000000..b5a968127cb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts @@ -0,0 +1,45 @@ +import { requireContext } from '../../../tests/helpers'; +import { formatTreeStats } from '../helpers'; + +requireContext(); + +describe('formatTreeStats', () => { + test('leaf node without synonym count', () => { + expect( + formatTreeStats( + { directCount: 3, childCount: 0, synonymCount: undefined }, + true + ).text + ).toBe('(3)'); + }); + + test('internal node without synonym count', () => { + expect( + formatTreeStats( + { directCount: 3, childCount: 5, synonymCount: undefined }, + false + ).text + ).toBe('(3, 5)'); + }); + + test('leaf node with synonym count', () => { + expect( + formatTreeStats({ directCount: 0, childCount: 0, synonymCount: 2 }, true) + .text + ).toBe('(0, 2)'); + }); + + test('internal node with synonym count', () => { + expect( + formatTreeStats({ directCount: 3, childCount: 5, synonymCount: 2 }, false) + .text + ).toBe('(3, 5, 2)'); + }); + + test('synonym count is included in the tooltip', () => { + expect( + formatTreeStats({ directCount: 3, childCount: 5, synonymCount: 2 }, false) + .title + ).toContain('2'); + }); +}); From 14fca800c2c6c7ca44f92952ff9b0ceef46b0ad7 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:30:11 -0500 Subject: [PATCH 6/7] fix(trees): make caption readable --- .../TreeView/__tests__/formatTreeStats.test.ts | 10 ++++++---- .../frontend/js_src/lib/components/TreeView/helpers.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts b/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts index b5a968127cb..aa072b4af4d 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts +++ b/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts @@ -37,9 +37,11 @@ describe('formatTreeStats', () => { }); test('synonym count is included in the tooltip', () => { - expect( - formatTreeStats({ directCount: 3, childCount: 5, synonymCount: 2 }, false) - .title - ).toContain('2'); + const title = formatTreeStats( + { directCount: 3, childCount: 5, synonymCount: 2 }, + false + ).title; + expect(title).toContain('2'); + expect(title.split('\n')).toHaveLength(3); }); }); diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts b/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts index 3f8a6c0c894..fc6e277bfff 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts @@ -237,7 +237,7 @@ export const formatTreeStats = ( value: synonymCount.toString(), }) : undefined, - ]).join('\n'), + ]).join('\n\n'), text: typeof synonymCount === 'number' ? isLeaf From c0c4a29698b8064277bb86910ba601c594b19a03 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 20:29:46 +0200 Subject: [PATCH 7/7] Test: Adapt to new UI --- .../lib/components/TreeView/__tests__/formatTreeStats.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts b/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts index aa072b4af4d..81d8cf625ca 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts +++ b/specifyweb/frontend/js_src/lib/components/TreeView/__tests__/formatTreeStats.test.ts @@ -42,6 +42,6 @@ describe('formatTreeStats', () => { false ).title; expect(title).toContain('2'); - expect(title.split('\n')).toHaveLength(3); + expect(title.split('\n')).toHaveLength(5); }); });