From ab97fee8a91f5760867dea0a7cec624181abf7f0 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 4 Sep 2026 14:16:10 -0500
Subject: [PATCH 01/28] [test]: rough sketch of unit test
---
.../WbUtils/__tests__/datasetVariants.test.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
new file mode 100644
index 00000000000..f6138d54fdb
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -0,0 +1,10 @@
+import { hasPermission } from '../../Permissions/helpers';
+import { userPreferences } from '../../Preferences/userPreferences';
+import { datasetVariants } from '../datasetVariants';
+
+jest.mock('../../Permissions/helpers', () => ({
+ hasPermission: jest.fn(),
+}));
+
+const mockedHasPermission = hasPermission as jest.Mock;
+
From f31587bf76d2a6f3cc460c9122a85b1f5480161e Mon Sep 17 00:00:00 2001
From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com>
Date: Mon, 7 Sep 2026 08:29:23 +0200
Subject: [PATCH 02/28] Potential fix for pull request finding 'CodeQL / Unused
variable, import, function or class'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
.../lib/components/WbUtils/__tests__/datasetVariants.test.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index f6138d54fdb..a11a8efec53 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -1,5 +1,4 @@
import { hasPermission } from '../../Permissions/helpers';
-import { userPreferences } from '../../Preferences/userPreferences';
import { datasetVariants } from '../datasetVariants';
jest.mock('../../Permissions/helpers', () => ({
From 57fb8d5148ecf36f86c8906947fc20e83b6ebb3f Mon Sep 17 00:00:00 2001
From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com>
Date: Mon, 7 Sep 2026 08:30:34 +0200
Subject: [PATCH 03/28] Potential fix for pull request finding 'CodeQL / Unused
variable, import, function or class'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
.../lib/components/WbUtils/__tests__/datasetVariants.test.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index a11a8efec53..cc8720b9213 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -5,5 +5,3 @@ jest.mock('../../Permissions/helpers', () => ({
hasPermission: jest.fn(),
}));
-const mockedHasPermission = hasPermission as jest.Mock;
-
From d6188dc51f0397be17d9564b6d8e86dd4503d77d Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Tue, 8 Sep 2026 15:15:23 -0500
Subject: [PATCH 04/28] [test]: WIP of disable relationships for batch edit and
enable rollback in user preferences
---
.../WbUtils/__tests__/datasetVariants.test.ts | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index f6138d54fdb..426477af43f 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -8,3 +8,15 @@ jest.mock('../../Permissions/helpers', () => ({
const mockedHasPermission = hasPermission as jest.Mock;
+const setBatchEditPreferences = (
+ enableRelationships: boolean,
+ showRollback: boolean
+): void => {
+ userPreferences.set(
+ 'batchEdit',
+ 'editor',
+ 'enableRelationships',
+ enableRelationships
+ );
+ userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback);
+};
\ No newline at end of file
From 341e3e54a4e8472e658cbe2c855846abf5bce7ad Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Tue, 8 Sep 2026 16:19:15 -0500
Subject: [PATCH 05/28] test commit
---
.../lib/components/WbUtils/__tests__/datasetVariants.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index 426477af43f..0cca996878c 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -1,3 +1,4 @@
+// test
import { hasPermission } from '../../Permissions/helpers';
import { userPreferences } from '../../Preferences/userPreferences';
import { datasetVariants } from '../datasetVariants';
@@ -19,4 +20,4 @@ const setBatchEditPreferences = (
enableRelationships
);
userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback);
-};
\ No newline at end of file
+};
From 0cc0e4569a6a031bd39d5cb5b020a3a633fc9ac6 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Thu, 10 Sep 2026 17:13:42 -0500
Subject: [PATCH 06/28] more WIP of disable relationships for batch edit and
enable rollback in user preferences
---
.../WbUtils/__tests__/datasetVariants.test.ts | 41 ++++++++++++++++++-
1 file changed, 40 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index 0cca996878c..79d9fd7f0a8 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -1,4 +1,3 @@
-// test
import { hasPermission } from '../../Permissions/helpers';
import { userPreferences } from '../../Preferences/userPreferences';
import { datasetVariants } from '../datasetVariants';
@@ -21,3 +20,43 @@ const setBatchEditPreferences = (
);
userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback);
};
+
+beforeAll(() => {
+ jest.useFakeTimers();
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
+
+describe('batch edit rollback availability', () => {
+ test.each([
+ [false, true, true, true],
+ [true, true, true, false],
+ [false, false, true, false],
+ [false, true, false, false],
+ [true, false, true, false],
+ [true, true, false, false],
+ [false, false, false, false],
+ [true, false, false, false],
+ ])(
+ 'enableRelationships=%s showRollback=%s permission=%s -> canUndo=%s',
+ (enableRelationships, showRollback, permission, expected) => {
+ setBatchEditPreferences(enableRelationships, showRollback);
+ mockedHasPermission.mockReturnValue(permission);
+
+ expect(datasetVariants.batchEdit.canUndo()).toBe(expected);
+ }
+ );
+
+ test('checks the batch edit rollback permission', () => {
+ setBatchEditPreferences(false, true);
+ mockedHasPermission.mockReturnValue(true);
+
+ datasetVariants.batchEdit.canUndo();
+
+ expect(mockedHasPermission).toHaveBeenCalledWith(
+ '/batch_edit/dataset',
+ 'rollback'
+ );
+ });
\ No newline at end of file
From 1c3de4dfa757f24be0f765479805275384772db7 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 11 Sep 2026 09:47:24 -0500
Subject: [PATCH 07/28] [test]: disable relationships for batch edit and enable
rollback in user preferences
---
.../WbUtils/__tests__/datasetVariants.test.ts | 38 ++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index 79d9fd7f0a8..8e7af4fd268 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -59,4 +59,40 @@ describe('batch edit rollback availability', () => {
'/batch_edit/dataset',
'rollback'
);
- });
\ No newline at end of file
+ });
+
+ test('does not consult permissions when relationships are enabled', () => {
+ setBatchEditPreferences(true, true);
+ mockedHasPermission.mockReturnValue(true);
+
+ expect(datasetVariants.batchEdit.canUndo()).toBe(false);
+ expect(mockedHasPermission).not.toHaveBeenCalled();
+ });
+
+ test('rollback is hidden based off default preferences', () => {
+ expect(
+ userPreferences.definition('batchEdit', 'editor', 'enableRelationships')
+ .defaultValue
+ ).toBe(true);
+ expect(
+ userPreferences.definition('batchEdit', 'editor', 'showRollback')
+ .defaultValue
+ ).toBe(true);
+
+ setBatchEditPreferences(true, true);
+ mockedHasPermission.mockReturnValue(true);
+
+ expect(datasetVariants.batchEdit.canUndo()).toBe(false);
+ });
+
+ test('workbench rollback is not affected by batch edit preferences', () => {
+ setBatchEditPreferences(true, true);
+ mockedHasPermission.mockReturnValue(true);
+
+ expect(datasetVariants.workbench.canUndo()).toBe(true);
+ expect(mockedHasPermission).toHaveBeenCalledWith(
+ '/workbench/dataset',
+ 'unupload'
+ );
+ });
+});
\ No newline at end of file
From 954e48058d664ca6dcfbcc9a96ebffab84c4f8b6 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 11 Sep 2026 10:16:48 -0500
Subject: [PATCH 08/28] [fix]: small change for BE relationships and rollback
permissions
---
.../WbUtils/__tests__/datasetVariants.test.ts | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index 8e7af4fd268..8a52e434f7f 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -21,6 +21,9 @@ const setBatchEditPreferences = (
userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback);
};
+// Drop every explicitly set preference so lookups fall back to the defaults
+const resetPreferences = (): void => userPreferences.setRaw({});
+
beforeAll(() => {
jest.useFakeTimers();
});
@@ -70,6 +73,8 @@ describe('batch edit rollback availability', () => {
});
test('rollback is hidden based off default preferences', () => {
+ resetPreferences();
+
expect(
userPreferences.definition('batchEdit', 'editor', 'enableRelationships')
.defaultValue
@@ -79,10 +84,16 @@ describe('batch edit rollback availability', () => {
.defaultValue
).toBe(true);
- setBatchEditPreferences(true, true);
- mockedHasPermission.mockReturnValue(true);
+ // Unset preferences resolve to the declared defaults
+ expect(
+ userPreferences.get('batchEdit', 'editor', 'enableRelationships')
+ ).toBe(true);
+ expect(userPreferences.get('batchEdit', 'editor', 'showRollback')).toBe(
+ true
+ );
expect(datasetVariants.batchEdit.canUndo()).toBe(false);
+ expect(mockedHasPermission).not.toHaveBeenCalled();
});
test('workbench rollback is not affected by batch edit preferences', () => {
From 320ae37e9f2dc5034474394a534bd614f1ecf745 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 11 Sep 2026 12:27:59 -0500
Subject: [PATCH 09/28] [test]: WIP of create a query; very relationsips are
not editable
---
.../BatchEdit/__tests__/index.test.ts | 73 +++++++++++++++++++
1 file changed, 73 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts
new file mode 100644
index 00000000000..21f43c77a05
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts
@@ -0,0 +1,73 @@
+import { requireContext } from '../../../tests/helpers';
+import type { RA } from '../../../utils/types';
+import { tables } from '../../DataModel/tables';
+import type { QueryField } from '../../QueryBuilder/helpers';
+import type { MappingPath } from '../../WbPlanView/Mapper';
+import { buildBatchEditFromQueryBody } from '../index';
+
+requireContext();
+
+const queryField = (
+ mappingPath: MappingPath,
+ isDisplay: boolean = true
+): QueryField => ({
+ id: 0,
+ mappingPath,
+ sortType: undefined,
+ isDisplay,
+ filters: [],
+});
+
+/* A query over Collection Object that reaches through a to-one relationship
+ * (accession), a to-many one (accession agents), and an unchecked field.
+ */
+const fields: RA = [
+ queryField(['catalogNumber']),
+ queryField(['guid'], false), // Unchecked in the query builder
+ queryField(['accession', 'accessionNumber']),
+ queryField(['accession', 'accessionAgents', '#1', 'role']),
+];
+
+const buildBody = (
+ hasRelationships: boolean,
+ extraFields: RA = fields
+) =>
+ buildBatchEditFromQueryBody({
+ query: new tables.SpQuery.Resource({ name: 'Test Query' }),
+ limit: 5000,
+ fields: extraFields,
+ baseTableName: 'CollectionObject',
+ dataSetName: 'Test Query - Fri Sep 11 2026',
+ recordSetId: 42,
+ treeDefsFilter: {},
+ hasRelationships,
+ });
+
+describe('buildBatchEditFromQueryBody', () => {
+ test('captions describe each displayed field with relationships', () => {
+ expect(buildBody(true).captions).toEqual([
+ 'Cat #',
+ 'Accession #',
+ 'Accession Agents - Role',
+ ]);
+ });
+
+ test('fields hidden in the query builder get no caption', () => {
+ const captions = buildBody(true).captions;
+ expect(captions).toHaveLength(3);
+ expect(captions).not.toContain('Collection Object - GUID');
+ });
+
+ // Covers front end half of verifying relationships are not editable.
+ test('omitRelationships is the inverse of the preference', () => {
+ expect(buildBody(true).omitrelationships).toBe(false);
+ expect(buildBody(false).omitrelationships).toBe(true);
+ });
+
+ test('carries the data set name, limit and record set through', () => {
+ const body = buildBody(true);
+ expect(body.name).toBe('Test Query - Fri Sep 11 2026');
+ expect(body.limit).toBe(5000);
+ expect(body.recordsetid).toBe(42);
+ });
+});
From a316757487f387091819461cebeee72601e8f8eb Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Thu, 17 Sep 2026 15:11:39 -0500
Subject: [PATCH 10/28] [test]: WIP of 'test search and replace behavior'
---
.../WbUtils/__tests__/Utils.test.ts | 71 +++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
new file mode 100644
index 00000000000..de46abf43e9
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
@@ -0,0 +1,71 @@
+import type React from 'react';
+import type { RA } from '../../../utils/types';
+import type { Workbench } from '../../WorkBench/WbView';
+import { WbUtils } from '../Utils';
+
+type Cell = {
+ readonly value: string;
+ readonly readOnly?: boolean;
+ readonly isSearchResult?: boolean;
+};
+
+const cell = (value: string, extra: Omit| = {}): Cell => ({
+ value,
+ isSearchResult: true,
+ ...extra,
+});
+
+function buildWorkbench(
+ grid: RA>,
+ selected: readonly [number, number] = [0, 0]
+) {
+ const setDataAtCell = jest.fn();
+ const at = (row: number, col: number): Cell | undefined => grid[row]?.[col];
+
+ const workbench = {
+ hot: {
+ toVisualRow: (row: number) => row,
+ toVisualColumn: (col: number) => col,
+ toPhysicalRow: (row: number) => row,
+ toPhysicalColumn: (col: number) => col,
+ getDataAtCell: (row: number, col: number) => at(row, col)?.value ?? '',
+ getCellMeta: (row: number, col: number) => ({
+ readOnly: at(row, col)?.readOnly === true,
+ }),
+ getSelectedLast: () => selected,
+ setDataAtCell,
+ },
+ cells: {
+ cellMeta: Object.fromEntries(
+ grid.map((row, rowIndex) => [
+ rowIndex,
+ Object.fromEntries(row.map((cell, colIndex) => [colIndex, cell])),
+ ])
+ ),
+ getCellMetaFromArray: (meta: Cell, key: string) =>
+ key === 'isSearchResult' ? meta.isSearchResult === true : undefined,
+ cellIsType: (meta: Cell | undefined, type: string) =>
+ type === 'searchResults' && meta?.isSearchResult === true,
+ // Navigating away is out of scope here, so there is nothing to walk
+ getCellMetaObject: () => [],
+ },
+ };
+
+ return { setDataAtCell, workbench: workbench as unknown as Workbench };
+}
+
+const enterKey = { key: 'Enter' } as React.KeyboardEvent;
+const replacement = { value: 'new' } as HTMLInputElement;
+
+const buildUtils = (
+ workbench: Workbench,
+ replaceMode: 'replaceAll' | 'replaceNext'
+): WbUtils => {
+ const utils = new WbUtils(workbench, { current: null });
+ utils.searchQuery = 'old';
+ utils.searchPreferences = {
+ ...utils.searchPreferences,
+ replace: { replaceMode },
+ };
+ return utils;
+};
\ No newline at end of file
From b39af5c741af19ef3cab6abb57de6884d394c770 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 18 Sep 2026 08:47:36 -0500
Subject: [PATCH 11/28] [test]: test search and replace behavior
---
.../WbUtils/__tests__/Utils.test.ts | 73 ++++++++++++++++++-
1 file changed, 71 insertions(+), 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
index de46abf43e9..255443d8f54 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
@@ -46,7 +46,6 @@ function buildWorkbench(
key === 'isSearchResult' ? meta.isSearchResult === true : undefined,
cellIsType: (meta: Cell | undefined, type: string) =>
type === 'searchResults' && meta?.isSearchResult === true,
- // Navigating away is out of scope here, so there is nothing to walk
getCellMetaObject: () => [],
},
};
@@ -68,4 +67,74 @@ const buildUtils = (
replace: { replaceMode },
};
return utils;
-};
\ No newline at end of file
+};
+
+describe('replaceCells, replace all', () => {
+ test('replaces every editable cell that matched the search', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([
+ [cell('old'), cell('old')],
+ ]);
+ buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).toHaveBeenCalledWith([
+ [0, 0, 'new'],
+ [0, 1, 'new'],
+ ]);
+ });
+
+ test('leaves read only cells alone', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([
+ [cell('old'), cell('old', { readOnly: true })],
+ ]);
+ buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).toHaveBeenCalledWith([[0, 0, 'new']]);
+ });
+
+ test('replaces nothing when every match is read only', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([
+ [cell('old', { readOnly: true }), cell('old', { readOnly: true })],
+ ]);
+ buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).toHaveBeenCalledWith([]);
+ });
+
+ test('skips cells that did not match the search', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([
+ [cell('old'), cell('other', { isSearchResult: false })],
+ ]);
+ buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).toHaveBeenCalledWith([[0, 0, 'new']]);
+ });
+
+ test('skips empty cells so defaults are not overwritten', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([
+ [cell('old'), cell('')],
+ ]);
+ buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).toHaveBeenCalledWith([[0, 0, 'new']]);
+ });
+
+ test('ignores keys other than Enter', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([[cell('old')]]);
+ buildUtils(workbench, 'replaceAll').replaceCells(
+ { key: 'a' } as React.KeyboardEvent,
+ replacement
+ );
+ expect(setDataAtCell).not.toHaveBeenCalled();
+ });
+});
+
+describe('replaceCells, replace next', () => {
+ test('replaces the selected cell when it is editable', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([[cell('old')]]);
+ buildUtils(workbench, 'replaceNext').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).toHaveBeenCalledWith(0, 0, 'new');
+ });
+
+ test('refuses to replace the selected cell when it is read only', () => {
+ const { workbench, setDataAtCell } = buildWorkbench([
+ [cell('old', { readOnly: true })],
+ ]);
+ buildUtils(workbench, 'replaceNext').replaceCells(enterKey, replacement);
+ expect(setDataAtCell).not.toHaveBeenCalled();
+ });
+});
From debdaf267782064728a9fa005687236c52933abc Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 18 Sep 2026 12:17:09 -0500
Subject: [PATCH 12/28] [test]: click Batch Edit & verify query needs to be
saved before you can batch edit
---
.../__tests__/BatchEditFromQuery.test.tsx | 157 ++++++++++++++++++
1 file changed, 157 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
new file mode 100644
index 00000000000..cadfd2e4dc4
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { batchEditText } from '../../../localization/batchEdit';
+import { commonText } from '../../../localization/common';
+import { queryText } from '../../../localization/query';
+import { overrideAjax } from '../../../tests/ajax';
+import { requireContext } from '../../../tests/helpers';
+import { mount } from '../../../tests/reactUtils';
+import type { RA } from '../../../utils/types';
+import { LoadingContext } from '../../Core/Contexts';
+import { UnloadProtectsContext } from '../../Router/UnloadProtect';
+import { tables } from '../../DataModel/tables';
+import type { QueryField } from '../../QueryBuilder/helpers';
+import type { MappingPath } from '../../WbPlanView/Mapper';
+import { BatchEditFromQuery } from '../index';
+
+requireContext();
+
+const datasetId = 7;
+
+async function withoutActWarnings(
+ callback: () => Promise
+): Promise {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation();
+ try {
+ await callback();
+ } finally {
+ consoleError.mockRestore();
+ }
+}
+
+overrideAjax('/api/workbench/dataset/?isupdate=1', []);
+overrideAjax('/stored_query/batch_edit/', { id: datasetId }, {
+ method: 'POST',
+});
+
+const queryField = (mappingPath: MappingPath): QueryField => ({
+ id: 0,
+ mappingPath,
+ sortType: undefined,
+ isDisplay: true,
+ filters: [],
+});
+
+const buildQuery = (contextName: string = 'CollectionObject') =>
+ new tables.SpQuery.Resource({
+ name: 'Test Query',
+ contextName,
+ contextTableId: tables.CollectionObject.tableId,
+ });
+
+function render({
+ saveRequired = false,
+ baseTableName = 'CollectionObject' as const,
+ fields = [queryField(['catalogNumber'])] as RA,
+ contextName,
+ needsSaved = false,
+}: {
+ readonly saveRequired?: boolean;
+ readonly baseTableName?: 'Collection' | 'CollectionObject';
+ readonly fields?: RA;
+ readonly contextName?: string;
+ readonly needsSaved?: boolean;
+} = {}) {
+ const query = buildQuery(contextName);
+ if (needsSaved) query.set('name', 'Edited but never saved');
+ const handleLoading = (promise: Promise): void => {
+ void promise;
+ };
+ return mount(
+
+
+
+
+
+ }
+ path="/"
+ />
+ Data set opened |
}
+ path="/specify/workbench/:id"
+ />
+
+
+
+
+ );
+}
+
+describe('the Batch Edit button', () => {
+ test('is offered for a random query', () => {
+ const { getByRole } = render();
+ expect(
+ getByRole('button', { name: batchEditText.batchEdit() })
+ ).toBeEnabled();
+ });
+
+ test('is disabled when the query is based on a hierarchy table', () => {
+ const { getByRole } = render({
+ baseTableName: 'Collection',
+ fields: [queryField(['collectionName'])],
+ });
+ const button = getByRole('button', { name: batchEditText.batchEdit() });
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute('title', batchEditText.batchEditDisabled());
+ });
+
+ test('is disabled for a query over the audit log', () => {
+ const { getByRole } = render({ contextName: 'SpAuditLog' });
+ expect(
+ getByRole('button', { name: batchEditText.batchEdit() })
+ ).toBeDisabled();
+ });
+
+ test('creates the data set and opens it', async () => {
+ const { getByRole, findByText, user } = render();
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ expect(await findByText('Data set opened')).toBeInTheDocument();
+ });
+ });
+});
+
+describe('the unsaved query guard', () => {
+ test('warns instead of batch editing when the query builder has changes', async () => {
+ const { getByRole, queryByText, user } = render({ saveRequired: true });
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ expect(getByRole('dialog')).toHaveTextContent(
+ queryText.unsavedChangesInQuery()
+ );
+ expect(queryByText('Data set opened')).toBeNull();
+ });
+
+ test('warns when the query resource itself is unsaved', async () => {
+ const { getByRole, user } = render({ needsSaved: true });
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ expect(getByRole('dialog')).toHaveTextContent(
+ queryText.unsavedChangesInQuery()
+ );
+ });
+
+ test('the warning can be dismissed', async () => {
+ const { getByRole, queryByRole, user } = render({ saveRequired: true });
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ await user.click(getByRole('button', { name: commonText.close() }));
+ expect(queryByRole('dialog')).toBeNull();
+ });
+ });
+});
From 82e7f69db957f5ff98543b33b7f981162ab0cc0a Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Tue, 22 Sep 2026 15:09:17 -0500
Subject: [PATCH 13/28] [test]: verify relationships are not editable (backend)
---
.../stored_queries/tests/test_batch_edit.py | 51 +++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/specifyweb/backend/stored_queries/tests/test_batch_edit.py b/specifyweb/backend/stored_queries/tests/test_batch_edit.py
index 477af0f5970..b2e70cbe36d 100644
--- a/specifyweb/backend/stored_queries/tests/test_batch_edit.py
+++ b/specifyweb/backend/stored_queries/tests/test_batch_edit.py
@@ -2342,4 +2342,55 @@ def test_column_key_collision(self):
(headers, rows, packs, plan, order) = run_batch_edit_query(props)
self.assertEqual(headers, expected_captions)
+
+ # Tests to verify batch edit relationships are not editable.
+ def _make_determination(self):
+ return models.Determination.objects.create(
+ collectionobject=self.collectionobjects[0],
+ remarks="Remarks for collection object 1, det 1",
+ )
+
+ def _run_omitting_relationships(self, omit_relationships: bool):
+ base_table = "collectionobject"
+ query_paths = [
+ ["catalognumber"],
+ ["cataloger", "firstname"],
+ ["determinations", "remarks"],
+ ]
+ added = [(base_table, *path) for path in query_paths]
+ query_fields = [
+ BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0)
+ for path in added
+ ]
+ props = self.build_props(query_fields, base_table)
+ props["omit_relationships"] = omit_relationships
+ (headers, rows, packs, plan, order) = run_batch_edit_query(props)
+ return headers, plan["uploadable"]["uploadTable"]
+
+ @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter)
+ def test_relationships_are_editable_by_default(self):
+ self._make_determination()
+ (headers, upload_table) = self._run_omitting_relationships(False)
+ self.assertIn("cataloger", upload_table["toOne"])
+ self.assertIn("determinations", upload_table["toMany"])
+
+ @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter)
+ def test_omitting_relationships_removes_them_from_the_upload_plan(self):
+ self._make_determination()
+ (headers, upload_table) = self._run_omitting_relationships(True)
+ self.assertEqual(upload_table["toOne"], {})
+ self.assertEqual(upload_table["toMany"], {})
+
+ @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter)
+ def test_base_table_fields_stay_editable_without_relationships(self):
+ self._make_determination()
+ (headers, upload_table) = self._run_omitting_relationships(True)
+ self.assertIn("catalognumber", upload_table["wbcols"])
+
+ @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter)
+ def test_relationship_columns_are_still_shown(self):
+ self._make_determination()
+ (shown_headers, _) = self._run_omitting_relationships(False)
+ (omitted_headers, _) = self._run_omitting_relationships(True)
+ self.assertEqual(omitted_headers, shown_headers)
From 194dd873959a2ff57a289f8aa951d6d280ebea18 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Thu, 24 Sep 2026 12:52:30 -0500
Subject: [PATCH 14/28] [fix]: updated dependencies so debdaf2 should pass
frontend tests
---
.../BatchEdit/__tests__/BatchEditFromQuery.test.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
index cadfd2e4dc4..b21d15d2ecd 100644
--- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
@@ -68,7 +68,10 @@ function render({
void promise;
};
return mount(
-
+
From 58c75dbf1e87395eb57d13e603eecda813f8a574 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Thu, 24 Sep 2026 14:25:26 -0500
Subject: [PATCH 15/28] [test]: verify you can roll back
---
.../upload/tests/test_batch_edit_rollback.py | 116 ++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
new file mode 100644
index 00000000000..1aac1ae38b2
--- /dev/null
+++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
@@ -0,0 +1,116 @@
+import json
+
+from specifyweb.backend.stored_queries.batch_edit import (
+ make_dataset,
+ run_batch_edit_query,
+)
+from specifyweb.backend.stored_queries.queryfield import QueryField
+from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec
+from specifyweb.backend.stored_queries.tests.test_batch_edit import props_builder
+from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup
+from specifyweb.backend.workbench.models import Spdataset
+from specifyweb.backend.workbench.tasks import unupload
+from specifyweb.backend.workbench.upload.tests.base import UploadTestsBase
+from specifyweb.backend.workbench.upload.upload import (
+ do_upload_dataset,
+ rollback_batch_edit,
+)
+from specifyweb.backend.workbench.views import regularize_rows
+from specifyweb.specify.models import Collectionobject
+
+"""
+Creating a batch edit data set also creates a backup through the make_dataset
+function. Rolling back (rollback_batch_edit) re-uploads the backedup values over
+the committed records, then unuploads the data set
+"""
+
+ORIGINAL_REMARKS = "Remarks before batch edit"
+EDITED_REMARKS = "Remarks changed by batch edit"
+
+class BatchEditRollbackTests(SQLAlchemySetup, UploadTestsBase):
+ def setUp(self):
+ super().setUp()
+ self.build_props = props_builder(
+ self, BatchEditRollbackTests.test_session_context
+ )
+ Collectionobject.objects.all().delete()
+ self.co = Collectionobject.objects.create(
+ catalognumber="7924".zfill(9),
+ remarks=ORIGINAL_REMARKS,
+ collection=self.collection,
+ )
+
+ def _query_field(self, path) -> QueryField:
+ return QueryField(
+ fieldspec=QueryFieldSpec.from_path(("Collectionobject", *path)),
+ op_num=8,
+ value=None,
+ negate=False,
+ display=True,
+ format_name=None,
+ sort_type=0,
+ )
+
+ def _commit_edit(self) -> Spdataset:
+ props = self.build_props(
+ [self._query_field(["catalognumber"]), self._query_field(["remarks"])],
+ "Collectionobject",
+ )
+ headers, rows, packs, plan_json, visual_order = run_batch_edit_query(props)
+ rows_with_packs = [
+ [*row, json.dumps({"batch_edit": pack})] for row, pack in zip(rows, packs)
+ ]
+ original_rows = regularize_rows(len(headers), rows_with_packs, skip_empty=False)
+
+ dataset_id, _ = make_dataset(
+ user=self.specifyuser,
+ collection=self.collection,
+ name="rollback",
+ headers=headers,
+ regularized_rows=original_rows,
+ agent=self.agent,
+ json_upload_plan=plan_json,
+ visual_order=visual_order,
+ )
+ dataset = Spdataset.objects.get(id=dataset_id)
+
+ row_index = next(
+ index
+ for index, pack in enumerate(packs)
+ if pack["self"]["id"] == self.co.id
+ )
+ remarks_column = next(
+ index
+ for index, header in enumerate(headers)
+ if header.lower().endswith("remarks")
+ )
+ edited_rows = [list(row) for row in dataset.data]
+ edited_rows[row_index][remarks_column] = EDITED_REMARKS
+ dataset.data = edited_rows
+ dataset.save()
+
+ do_upload_dataset(
+ self.collection,
+ self.agent.id,
+ dataset,
+ no_commit=False,
+ allow_partial=False,
+ )
+ dataset.refresh_from_db()
+ return dataset
+
+ def test_commit_applies_the_edit(self):
+ self._commit_edit()
+ self.co.refresh_from_db()
+ self.assertEqual(self.co.remarks, EDITED_REMARKS)
+
+ def test_rollback_restores_the_original_values(self):
+ dataset = self._commit_edit()
+ rollback_batch_edit(dataset, self.collection, self.agent)
+ self.co.refresh_from_db()
+ self.assertEqual(self.co.remarks, ORIGINAL_REMARKS)
+
+ def test_rollback_does_not_delete_edited_records(self):
+ dataset = self._commit_edit()
+ rollback_batch_edit(dataset, self.collection, self.agent)
+ self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists())
From ba1d99f0284e9f8fb59e552aa50f08e4f48bec67 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Thu, 24 Sep 2026 14:52:26 -0500
Subject: [PATCH 16/28] [fix]: fix for 58c75db failed tests
---
.../upload/tests/test_batch_edit_rollback.py | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
index 1aac1ae38b2..3e0cb61045f 100644
--- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
+++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
@@ -1,18 +1,13 @@
import json
-
-from specifyweb.backend.stored_queries.batch_edit import (
- make_dataset,
- run_batch_edit_query,
-)
+from specifyweb.backend.stored_queries.batch_edit import make_dataset, run_batch_edit_query # type: ignore
from specifyweb.backend.stored_queries.queryfield import QueryField
from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec
from specifyweb.backend.stored_queries.tests.test_batch_edit import props_builder
from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup
from specifyweb.backend.workbench.models import Spdataset
-from specifyweb.backend.workbench.tasks import unupload
from specifyweb.backend.workbench.upload.tests.base import UploadTestsBase
from specifyweb.backend.workbench.upload.upload import (
- do_upload_dataset,
+ do_upload_dataset,
rollback_batch_edit,
)
from specifyweb.backend.workbench.views import regularize_rows
@@ -20,8 +15,8 @@
"""
Creating a batch edit data set also creates a backup through the make_dataset
-function. Rolling back (rollback_batch_edit) re-uploads the backedup values over
-the committed records, then unuploads the data set
+function. Rolling back (rollback_batch_edit) re-uploads the backed up values over
+the committed records, calling upon unupload to roll back the data set
"""
ORIGINAL_REMARKS = "Remarks before batch edit"
@@ -113,4 +108,4 @@ def test_rollback_restores_the_original_values(self):
def test_rollback_does_not_delete_edited_records(self):
dataset = self._commit_edit()
rollback_batch_edit(dataset, self.collection, self.agent)
- self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists())
+ self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists())
\ No newline at end of file
From b142f1857d0012c87bf082f8798b2b3f31d33093 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 09:31:05 -0500
Subject: [PATCH 17/28] [test]: verify you can roll back
---
.../upload/tests/test_batch_edit_rollback.py | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
index 3e0cb61045f..392d16316c1 100644
--- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
+++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py
@@ -12,6 +12,7 @@
)
from specifyweb.backend.workbench.views import regularize_rows
from specifyweb.specify.models import Collectionobject
+from specifyweb.backend.workbench.tasks import unupload
"""
Creating a batch edit data set also creates a backup through the make_dataset
@@ -108,4 +109,17 @@ def test_rollback_restores_the_original_values(self):
def test_rollback_does_not_delete_edited_records(self):
dataset = self._commit_edit()
rollback_batch_edit(dataset, self.collection, self.agent)
- self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists())
\ No newline at end of file
+ self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists())
+
+ def test_rollback_makes_the_data_set_rolled_back(self):
+ dataset = self._commit_edit()
+ dataset.uploaderstatus = {"operation": "unuploading", "taskid": None}
+ dataset.save()
+ unupload(self.collection.id, dataset.id, self.agent.id)
+ dataset.refresh_from_db()
+ self.assertTrue(dataset.rolledback)
+ self.assertIsNone(dataset.uploaderstatus)
+ self.assertTrue(dataset.get_dataset_as_dict()["rolledback"])
+ self.assertTrue(dataset.get_dataset_as_dict()["isupdate"])
+ self.co.refresh_from_db()
+ self.assertEqual(self.co.remarks, ORIGINAL_REMARKS)
\ No newline at end of file
From 0ed0dffeca1e26999a0cb0193c6f67dfd4bdf059 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 09:56:32 -0500
Subject: [PATCH 18/28] [test]: verify after upload and roll back that the data
set is read only
---
.../WorkBench/__tests__/DataSetMeta.test.tsx | 72 +++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx
diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx
new file mode 100644
index 00000000000..a02f94983a9
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { batchEditText } from '../../../localization/batchEdit';
+import { mount } from '../../../tests/reactUtils';
+import { localized } from '../../../utils/types';
+import { UnloadProtectsContext } from '../../Router/UnloadProtect';
+import type { Dataset } from '../../WbPlanView/Wrapped';
+import { DataSetName } from '../DataSetMeta';
+
+const buildDataset = ({
+ isupdate,
+ rolledback,
+}: {
+ readonly isupdate: boolean;
+ readonly rolledback: boolean;
+}): Dataset => ({
+ id: 1,
+ name: localized('Data Set'),
+ timestampcreated: '',
+ timestampmodified: '',
+ createdbyagent: '/api/specify/agent/1/',
+ importedfilename: '',
+ modifiedbyagent: null,
+ remarks: '',
+ uploadresult: null,
+ uploaderstatus: null,
+ columns: ['Catalog Number', 'Remarks'],
+ rowresults: null,
+ rows: [['100', 'A remark']],
+ uploadplan: null,
+ visualorder: null,
+ isupdate,
+ rolledback,
+ usesattachments: false,
+ attachments: null,
+});
+
+const render = (dataset: Dataset) =>
+ mount(
+
+
+
+ );
+
+describe('DataSetName', () => {
+ test('warns that a rolled back batch edit data set cannot be edited', () => {
+ const { getByText } = render(
+ buildDataset({ isupdate: true, rolledback: true })
+ );
+ expect(
+ getByText(batchEditText.cannotEditAfterRollback())
+ ).toBeInTheDocument();
+ });
+
+ test('does not warn before the batch edit data set is rolled back', () => {
+ const { queryByText } = render(
+ buildDataset({ isupdate: true, rolledback: false })
+ );
+ expect(
+ queryByText(batchEditText.cannotEditAfterRollback())
+ ).not.toBeInTheDocument();
+ });
+
+ test('does not warn for a rolled back workbench data set', () => {
+ // Workbench data sets can still be edited and uploaded after a rollback.
+ const { queryByText } = render(
+ buildDataset({ isupdate: false, rolledback: true })
+ );
+ expect(
+ queryByText(batchEditText.cannotEditAfterRollback())
+ ).not.toBeInTheDocument();
+ });
+});
From b19a0294bd0935134fb9ac5b0ebd84030c81eb80 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 10:51:40 -0500
Subject: [PATCH 19/28] [test]: enable relationships in user preferences
---
.../__tests__/BatchEditFromQuery.test.tsx | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
index b21d15d2ecd..1e6d215b736 100644
--- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
@@ -6,10 +6,12 @@ import { queryText } from '../../../localization/query';
import { overrideAjax } from '../../../tests/ajax';
import { requireContext } from '../../../tests/helpers';
import { mount } from '../../../tests/reactUtils';
+import * as ajaxModule from '../../../utils/ajax';
import type { RA } from '../../../utils/types';
import { LoadingContext } from '../../Core/Contexts';
import { UnloadProtectsContext } from '../../Router/UnloadProtect';
import { tables } from '../../DataModel/tables';
+import { userPreferences } from '../../Preferences/userPreferences';
import type { QueryField } from '../../QueryBuilder/helpers';
import type { MappingPath } from '../../WbPlanView/Mapper';
import { BatchEditFromQuery } from '../index';
@@ -158,3 +160,46 @@ describe('the unsaved query guard', () => {
});
});
});
+
+// Enable raltionships in user preferences.
+function mockEnableRelationships(enabled: boolean): void {
+ const realGet = userPreferences.get.bind(userPreferences);
+ jest.spyOn(userPreferences, 'get').mockImplementation(((
+ category: string,
+ subcategory: string,
+ item: string
+ ) =>
+ category === 'batchEdit' &&
+ subcategory === 'editor' &&
+ item === 'enableRelationships'
+ ? enabled
+ : realGet(category as never, subcategory as never, item as never)) as never);
+}
+
+describe('the enable relationships preference', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test.each([
+ [true, false],
+ [false, true],
+ ])(
+ 'enableRelationships=%s asks the back end for omitRelationships=%s',
+ async (enableRelationships, omitRelationships) => {
+ mockEnableRelationships(enableRelationships);
+ const ajax = jest.spyOn(ajaxModule, 'ajax');
+ const { getByRole, findByText, user } = render();
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ expect(await findByText('Data set opened')).toBeInTheDocument();
+ });
+ expect(ajax).toHaveBeenCalledWith(
+ '/stored_query/batch_edit/',
+ expect.objectContaining({
+ body: expect.objectContaining({ omitrelationships: omitRelationships }),
+ })
+ );
+ }
+ );
+});
From 56ef41989a94c910d75c53f713ace75a6cc5681a Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 11:16:06 -0500
Subject: [PATCH 20/28] [test]: ensure you can validate the data set (frontend)
---
.../WbUtils/__tests__/datasetVariants.test.ts | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
index 8a52e434f7f..aae95d8f326 100644
--- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -84,7 +84,7 @@ describe('batch edit rollback availability', () => {
.defaultValue
).toBe(true);
- // Unset preferences resolve to the declared defaults
+ // Unset preferences resolve to the defaults.
expect(
userPreferences.get('batchEdit', 'editor', 'enableRelationships')
).toBe(true);
@@ -106,4 +106,25 @@ describe('batch edit rollback availability', () => {
'unupload'
);
});
+});
+
+describe('batch edit validation availability', () => {
+ test.each([true, false])(
+ 'follows the batch edit validate permission (%s)',
+ (permission) => {
+ mockedHasPermission.mockReturnValue(permission);
+ expect(datasetVariants.batchEdit.canValidate()).toBe(permission);
+ expect(mockedHasPermission).toHaveBeenCalledWith(
+ '/batch_edit/dataset',
+ 'validate'
+ );
+ }
+ );
+
+ test('does not depend on the relationships preference', () => {
+ // Unlike rollback, validating is allowed when relationships are enabled.
+ setBatchEditPreferences(true, true);
+ mockedHasPermission.mockReturnValue(true);
+ expect(datasetVariants.batchEdit.canValidate()).toBe(true);
+ });
});
\ No newline at end of file
From b76a13651ce4a84e855446ef275de15a6943d451 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 11:23:57 -0500
Subject: [PATCH 21/28] [test]: ensure you can validate the data set (backend)
---
.../upload/tests/test_batch_edit_table.py | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
index db7a8c79504..d61e1773a7a 100644
--- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
+++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
@@ -761,6 +761,91 @@ def test_batch_edit_attachment_dataset_commit_updates_original_record(self):
self.assertEqual(Attachment.objects.count(), initial_attachment_count)
self.enforce_in_log(self.co_1_attachment.id, "attachment", "UPDATE")
+ def _dataset_editing_a_preparation(self):
+ query_paths = [
+ ["catalognumber"],
+ ["preparations", "countamt"],
+ ["preparations", "text1"],
+ ]
+ query_fields = [
+ self.make_query(QueryFieldSpec.from_path(("Collectionobject", *path)), 0)
+ for path in query_paths
+ ]
+ props = self._build_props(query_fields, "Collectionobject")
+
+ (headers, rows, packs, plan_json, visual_order) = run_batch_edit_query(props)
+
+ mapped_rows = [
+ [*row, json.dumps({"batch_edit": pack})] for (row, pack) in zip(rows, packs)
+ ]
+ regularized_rows = regularize_rows(len(headers), mapped_rows, skip_empty=False)
+ row_index = next(
+ index
+ for index, pack in enumerate(packs)
+ if pack["self"]["id"] == self.co_1.id
+ )
+
+ dataset_rows = [row[:] for row in regularized_rows]
+ dataset_rows[row_index][headers.index("Preparation text1")] = "Edited by batch edit"
+
+ dataset_id, _ = make_dataset(
+ user=self.specifyuser,
+ collection=self.collection,
+ name="validate-batch-edit",
+ headers=headers,
+ regularized_rows=dataset_rows,
+ agent=self.agent,
+ json_upload_plan=plan_json,
+ visual_order=visual_order,
+ )
+ return Spdataset.objects.get(id=dataset_id), row_index
+
+ def test_validating_reports_changes_to_related_records(self):
+ dataset, row_index = self._dataset_editing_a_preparation()
+
+ results = do_upload_dataset(
+ self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False
+ )
+
+ self.assertIsInstance(
+ results[row_index].toMany["preparations"][0].record_result, Updated
+ )
+
+ def test_validating_does_not_save_changes(self):
+ dataset, _ = self._dataset_editing_a_preparation()
+
+ do_upload_dataset(
+ self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False
+ )
+
+ self.co_1_prep_1.refresh_from_db()
+ self.assertEqual(self.co_1_prep_1.text1, "Value for preparation")
+
+ def test_validating_does_not_mark_the_data_set_uploaded(self):
+ dataset, _ = self._dataset_editing_a_preparation()
+
+ do_upload_dataset(
+ self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False
+ )
+
+ dataset.refresh_from_db()
+ self.assertIsNotNone(dataset.rowresults)
+ self.assertIsNone(dataset.uploadresult)
+ self.assertFalse(dataset.was_uploaded())
+
+ def test_a_validated_data_set_can_be_committed(self):
+ dataset, _ = self._dataset_editing_a_preparation()
+
+ do_upload_dataset(
+ self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False
+ )
+ do_upload_dataset(
+ self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False
+ )
+
+ self.co_1_prep_1.refresh_from_db()
+ self.assertEqual(self.co_1_prep_1.text1, "Edited by batch edit")
+
def enforce_in_log(
self,
record_id,
From 67e0078f128ad51728dcd184f69e5e77ec94a7ae Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 12:29:21 -0500
Subject: [PATCH 22/28] [fix]: fix for failing frontend test in b0ffbbd
---
.../BatchEdit/__tests__/BatchEditFromQuery.test.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
index 1e6d215b736..c70eb30e142 100644
--- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
@@ -71,9 +71,7 @@ function render({
};
return mount(
+ initialEntries={['/']}>
From 31d6bc43710b6cd8fd1d544f2ca8cdeb3575cba0 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 12:52:10 -0500
Subject: [PATCH 23/28] [test]: make a tree query with a rank that is not the
lowest (frontend)
---
.../__tests__/missingRanksUtils.test.ts | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts
new file mode 100644
index 00000000000..3889d2b0eb4
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts
@@ -0,0 +1,55 @@
+import { requireContext } from '../../../tests/helpers';
+import type { RA } from '../../../utils/types';
+import { tables } from '../../DataModel/tables';
+import { QueryFieldSpec } from '../../QueryBuilder/fieldSpec';
+import type { MappingPath } from '../../WbPlanView/Mapper';
+import { anyTreeRank, formatTreeRank } from '../../WbPlanView/mappingHelpers';
+import { findAllMissing } from '../missingRanksUtils';
+
+requireContext();
+
+const atRank = (rank: string, field = 'name'): MappingPath => [
+ formatTreeRank(rank),
+ field,
+];
+
+const taxonQuery = (...paths: RA): RA =>
+ paths.map((path) => QueryFieldSpec.fromPath('Taxon', path));
+
+const missing = (...ranks: RA): RA =>
+ ranks.map((rank) => `${rank} - ${tables.Taxon.strictGetField('name').label}`);
+
+describe('findAllMissing', () => {
+ test('a query at a rank that is not the lowest is missing every rank below it', () => {
+ expect(findAllMissing(taxonQuery(atRank('Genus')))).toEqual({
+ Taxon: { Taxonomy: missing('Subgenus', 'Species', 'Subspecies') },
+ });
+ });
+
+ test('a query at the lowest rank is missing nothing', () => {
+ expect(findAllMissing(taxonQuery(atRank('Subspecies')))).toEqual({});
+ });
+
+ test('ranks between two queried ranks are still required', () => {
+ expect(
+ findAllMissing(taxonQuery(atRank('Genus'), atRank('Species')))
+ ).toEqual({
+ Taxon: { Taxonomy: missing('Subgenus', 'Subspecies') },
+ });
+ });
+
+ test('a rank queried without its name counts as missing', () => {
+ expect(findAllMissing(taxonQuery(atRank('Genus', 'author')))).toEqual({
+ Taxon: { Taxonomy: missing('Genus', 'Subgenus', 'Species', 'Subspecies') },
+ });
+ });
+
+ test('ignores fields that are not tied to a specific rank', () => {
+ expect(
+ findAllMissing([
+ QueryFieldSpec.fromPath('CollectionObject', ['catalogNumber']),
+ ...taxonQuery([formatTreeRank(anyTreeRank), 'name']),
+ ])
+ ).toEqual({});
+ });
+});
From e18b7be881a1694343cad2fe5d1acdf96b6def3c Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 13:11:39 -0500
Subject: [PATCH 24/28] [test]: verify there is a missing rank dialog
---
.../__tests__/BatchEditFromQuery.test.tsx | 68 ++++++++++++++++++-
1 file changed, 67 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
index c70eb30e142..82d257b381f 100644
--- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx
@@ -1,7 +1,9 @@
+import { within } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { batchEditText } from '../../../localization/batchEdit';
import { commonText } from '../../../localization/common';
+import { interactionsText } from '../../../localization/interactions';
import { queryText } from '../../../localization/query';
import { overrideAjax } from '../../../tests/ajax';
import { requireContext } from '../../../tests/helpers';
@@ -14,6 +16,7 @@ import { tables } from '../../DataModel/tables';
import { userPreferences } from '../../Preferences/userPreferences';
import type { QueryField } from '../../QueryBuilder/helpers';
import type { MappingPath } from '../../WbPlanView/Mapper';
+import { formatTreeRank } from '../../WbPlanView/mappingHelpers';
import { BatchEditFromQuery } from '../index';
requireContext();
@@ -59,7 +62,7 @@ function render({
needsSaved = false,
}: {
readonly saveRequired?: boolean;
- readonly baseTableName?: 'Collection' | 'CollectionObject';
+ readonly baseTableName?: 'Collection' | 'CollectionObject' | 'Taxon';
readonly fields?: RA;
readonly contextName?: string;
readonly needsSaved?: boolean;
@@ -201,3 +204,66 @@ describe('the enable relationships preference', () => {
}
);
});
+
+const renderTaxonQuery = (rank: string) =>
+ render({
+ baseTableName: 'Taxon',
+ contextName: 'Taxon',
+ fields: [queryField([formatTreeRank(rank), 'name'])],
+ });
+
+const missingRank = (rank: string): string =>
+ `${rank} - ${tables.Taxon.strictGetField('name').label}`;
+
+describe('the missing rank dialog', () => {
+ test('appears when ranks are missing', async () => {
+ const { getByRole, findByRole, queryByText, user } = renderTaxonQuery('Genus');
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ const dialog = await findByRole('dialog');
+ expect(dialog).toHaveTextContent(batchEditText.missingRanksInQuery());
+ expect(
+ within(dialog)
+ .getAllByRole('listitem')
+ .map((item) => item.textContent)
+ ).toEqual(['Subgenus', 'Species', 'Subspecies'].map(missingRank));
+ expect(queryByText('Data set opened')).toBeNull();
+ });
+ });
+
+ test('does not appear when the query already reaches the lowest rank', async () => {
+ const { getByRole, findByText, queryByRole, user } =
+ renderTaxonQuery('Subspecies');
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ expect(await findByText('Data set opened')).toBeInTheDocument();
+ expect(queryByRole('dialog')).toBeNull();
+ });
+ });
+
+ test('continuing creates the data set', async () => {
+ const { getByRole, findByRole, findByText, user } = renderTaxonQuery('Genus');
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ const dialog = await findByRole('dialog');
+ await user.click(
+ within(dialog).getByRole('button', { name: interactionsText.continue() })
+ );
+ expect(await findByText('Data set opened')).toBeInTheDocument();
+ });
+ });
+
+ test('closing it does not create the data set', async () => {
+ const { getByRole, findByRole, queryByRole, queryByText, user } =
+ renderTaxonQuery('Genus');
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ const dialog = await findByRole('dialog');
+ await user.click(
+ within(dialog).getByRole('button', { name: commonText.close() })
+ );
+ expect(queryByRole('dialog')).toBeNull();
+ expect(queryByText('Data set opened')).toBeNull();
+ });
+ });
+});
From 63dccc92c8297c5dc63003287feda17b3e086be4 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 13:39:28 -0500
Subject: [PATCH 25/28] [test]: with a rank that is in multiple trees you can
select which tree(s) to add
---
.../BatchEdit/__tests__/MissingRanks.test.tsx | 173 ++++++++++++++++++
1 file changed, 173 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx
new file mode 100644
index 00000000000..e63abdcc37f
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx
@@ -0,0 +1,173 @@
+import { within } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+
+import { batchEditText } from '../../../localization/batchEdit';
+import { interactionsText } from '../../../localization/interactions';
+import { overrideAjax } from '../../../tests/ajax';
+import specifyTrees from '../../../tests/ajax/static/trees/specify_trees.json';
+import { requireContext } from '../../../tests/helpers';
+import { mount } from '../../../tests/reactUtils';
+import * as ajaxModule from '../../../utils/ajax';
+import type { RA } from '../../../utils/types';
+import { LoadingContext } from '../../Core/Contexts';
+import { tables } from '../../DataModel/tables';
+import { UnloadProtectsContext } from '../../Router/UnloadProtect';
+import { formatTreeRank } from '../../WbPlanView/mappingHelpers';
+import { BatchEditFromQuery } from '../index';
+
+const [taxonomy] = specifyTrees.Taxon;
+const plantTreeUri = '/api/specify/taxontreedef/2/';
+const plantTaxonomy = {
+ definition: {
+ ...taxonomy.definition,
+ id: 2,
+ name: 'Plant Taxonomy',
+ resource_uri: plantTreeUri,
+ },
+ ranks: taxonomy.ranks
+ .filter(({ name }) =>
+ ['Taxonomy Root', 'Kingdom', 'Genus', 'Species'].includes(name)
+ )
+ .map((rank) => ({
+ ...rank,
+ id: rank.id + 100,
+ treedef: plantTreeUri,
+ resource_uri: `/api/specify/taxontreedefitem/${rank.id + 100}/`,
+ })),
+};
+
+overrideAjax('/trees/specify_trees/', {
+ ...specifyTrees,
+ Taxon: [taxonomy, plantTaxonomy],
+});
+
+requireContext();
+
+overrideAjax('/api/workbench/dataset/?isupdate=1', []);
+overrideAjax('/stored_query/batch_edit/', { id: 7 }, { method: 'POST' });
+
+async function withoutActWarnings(
+ callback: () => Promise
+): Promise {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation();
+ try {
+ await callback();
+ } finally {
+ consoleError.mockRestore();
+ }
+}
+
+function renderGenusQuery() {
+ const query = new tables.SpQuery.Resource({
+ name: 'Test Query',
+ contextName: 'Taxon',
+ contextTableId: tables.Taxon.tableId,
+ });
+ const handleLoading = (promise: Promise): void => {
+ void promise;
+ };
+ return mount(
+
+
+
+
+
+ }
+ path="/"
+ />
+ Data set opened}
+ path="/specify/workbench/:id"
+ />
+
+
+
+
+ );
+}
+
+// Open the dialog, click each named tree's checkbox in turn, then continue
+async function continueWithTrees(...trees: RA) {
+ const ajax = jest.spyOn(ajaxModule, 'ajax');
+ const { getByRole, findByRole, findByText, user } = renderGenusQuery();
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ const dialog = await findByRole('dialog');
+ for (const tree of trees)
+ await user.click(
+ within(dialog).getByRole('checkbox', { name: `${tree}:` })
+ );
+ await user.click(
+ within(dialog).getByRole('button', { name: interactionsText.continue() })
+ );
+ await findByText('Data set opened');
+ });
+ return ajax;
+}
+
+const expectTreeFilter = (
+ ajax: jest.SpyInstance,
+ treedefsfilter: unknown
+): void =>
+ expect(ajax).toHaveBeenCalledWith(
+ '/stored_query/batch_edit/',
+ expect.objectContaining({
+ body: expect.objectContaining({ treedefsfilter }),
+ })
+ );
+
+describe('picking trees in the missing rank dialog', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test('offers a checkbox for each tree the rank is in', async () => {
+ const { getByRole, findByRole, user } = renderGenusQuery();
+ await withoutActWarnings(async () => {
+ await user.click(getByRole('button', { name: batchEditText.batchEdit() }));
+ const dialog = await findByRole('dialog');
+ expect(dialog).toHaveTextContent(batchEditText.pickTreesToFilter());
+ expect(
+ within(dialog).getByRole('checkbox', { name: 'Taxonomy:' })
+ ).toBeInTheDocument();
+ expect(
+ within(dialog).getByRole('checkbox', { name: 'Plant Taxonomy:' })
+ ).toBeInTheDocument();
+ });
+ });
+
+ test('sends only the tree that was picked', async () => {
+ const ajax = await continueWithTrees('Plant Taxonomy');
+ expectTreeFilter(ajax, { taxon: [2] });
+ });
+
+ test('sends every tree that was picked', async () => {
+ const ajax = await continueWithTrees('Taxonomy', 'Plant Taxonomy');
+ expectTreeFilter(ajax, { taxon: [1, 2] });
+ });
+
+ test('clicking a picked tree again removes it', async () => {
+ const ajax = await continueWithTrees(
+ 'Plant Taxonomy',
+ 'Taxonomy',
+ 'Plant Taxonomy'
+ );
+ expectTreeFilter(ajax, { taxon: [1] });
+ });
+});
From 8a5d01e63aaed5d00e581c3b31aad2112ae0de89 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 13:41:27 -0500
Subject: [PATCH 26/28] [test]: verify if no trees are selected in the dialog
that all trees are added (frontend)
---
.../lib/components/BatchEdit/__tests__/MissingRanks.test.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx
index e63abdcc37f..2fbbf353b9f 100644
--- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx
@@ -170,4 +170,9 @@ describe('picking trees in the missing rank dialog', () => {
);
expectTreeFilter(ajax, { taxon: [1] });
});
+
+ test('sends no filter when no tree is picked', async () => {
+ const ajax = await continueWithTrees();
+ expectTreeFilter(ajax, {});
+ });
});
From 54125adb00711e17c1807da5ffc664ff8ba1add6 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 14:14:06 -0500
Subject: [PATCH 27/28] [test]: verify ranks of selected trees are added
correctly
---
.../upload/tests/test_batch_edit_table.py | 90 +++++++++++++++++++
1 file changed, 90 insertions(+)
diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
index 48c5f90d148..10e257bd8b0 100644
--- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
+++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
@@ -1104,6 +1104,96 @@ def test_a_validated_data_set_can_be_committed(self):
self.co_1_prep_1.refresh_from_db()
self.assertEqual(self.co_1_prep_1.text1, "Edited by batch edit")
+
+ def _make_plant_tree(self):
+ plant_tree = get_table("Taxontreedef").objects.create(
+ name="Plant ttd", discipline=self.discipline
+ )
+ plant_tree.treedefitems.create(name="Taxonomy Root", rankid=0)
+ plant_tree.treedefitems.create(name="Kingdom", rankid=10)
+ plant_tree.treedefitems.create(name="Genus", rankid=180)
+ plant_tree.treedefitems.create(name="Species", rankid=220)
+ plant_tree.treedefitems.create(name="Variety", rankid=240)
+ return plant_tree
+
+ def _tree_ranks_for_genus_query(self, treedefsfilter):
+ query_fields = fields_from_json(
+ [
+ {
+ "tablelist": "1",
+ "stringid": "1.collectionobject.catalogNumber",
+ "fieldname": "catalogNumber",
+ "isrelfld": False,
+ "sorttype": 0,
+ "position": 0,
+ "isdisplay": True,
+ "operstart": 8,
+ "startvalue": "",
+ "isnot": False,
+ },
+ {
+ "tablelist": "1,9-determinations,4",
+ "stringid": "1,9-determinations,4.taxon.Genus",
+ "fieldname": "Genus",
+ "isrelfld": False,
+ "sorttype": 0,
+ "position": 1,
+ "isdisplay": True,
+ "operstart": 8,
+ "startvalue": "",
+ "isnot": False,
+ },
+ ]
+ )
+ get_table("Determination").objects.create(
+ collectionobject=self.co_1, remarks="A determination"
+ )
+ props = self._build_props(query_fields, "Collectionobject")
+ props["treedefsfilter"] = treedefsfilter
+
+ (headers, rows, packs, plan_json, visual_order) = run_batch_edit_query(props)
+
+ found = set()
+
+ def collect(node):
+ if isinstance(node, dict):
+ for rank_key in node.get("treeRecord", {}).get("ranks", {}):
+ tree_name, rank_name = rank_key.split(RANK_KEY_DELIMITER)[:2]
+ found.add((tree_name, rank_name))
+ for value in node.values():
+ collect(value)
+ elif isinstance(node, list):
+ for value in node:
+ collect(value)
+
+ collect(plan_json)
+ return found
+
+ def _default_tree_ranks(self):
+ return {
+ (self.taxontreedef.name, rank)
+ for rank in ["Genus", "Subgenus", "Species", "Subspecies"]
+ }
+
+ def _plant_tree_ranks(self):
+ return {("Plant ttd", rank) for rank in ["Genus", "Species", "Variety"]}
+
+ def test_only_the_selected_trees_ranks_are_added(self):
+ plant_tree = self._make_plant_tree()
+
+ ranks = self._tree_ranks_for_genus_query({"taxon": [plant_tree.id]})
+
+ self.assertEqual(ranks, self._plant_tree_ranks())
+
+ def test_every_selected_trees_ranks_are_added(self):
+ plant_tree = self._make_plant_tree()
+
+ ranks = self._tree_ranks_for_genus_query(
+ {"taxon": [self.taxontreedef.id, plant_tree.id]}
+ )
+
+ self.assertEqual(ranks, self._default_tree_ranks() | self._plant_tree_ranks())
+
def enforce_in_log(
self,
record_id,
From db11ea4398a126dceb7c3c6838d717144a40dbd3 Mon Sep 17 00:00:00 2001
From: kwhuber
Date: Fri, 25 Sep 2026 14:17:55 -0500
Subject: [PATCH 28/28] [test]: verify if no trees are selected in the dialog
that all trees are added (backend)
---
.../workbench/upload/tests/test_batch_edit_table.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
index 10e257bd8b0..282beec9085 100644
--- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
+++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py
@@ -1194,6 +1194,13 @@ def test_every_selected_trees_ranks_are_added(self):
self.assertEqual(ranks, self._default_tree_ranks() | self._plant_tree_ranks())
+ def test_no_trees_selected_adds_every_tree(self):
+ self._make_plant_tree()
+
+ ranks = self._tree_ranks_for_genus_query({})
+
+ self.assertEqual(ranks, self._default_tree_ranks() | self._plant_tree_ranks())
+
def enforce_in_log(
self,
record_id,