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();
+ });
+ });
+});
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);
+ });
+});
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..255443d8f54
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts
@@ -0,0 +1,140 @@
+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,
+ 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;
+};
+
+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();
+ });
+});
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..8a52e434f7f
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts
@@ -0,0 +1,109 @@
+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;
+
+const setBatchEditPreferences = (
+ enableRelationships: boolean,
+ showRollback: boolean
+): void => {
+ userPreferences.set(
+ 'batchEdit',
+ 'editor',
+ 'enableRelationships',
+ enableRelationships
+ );
+ 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();
+});
+
+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'
+ );
+ });
+
+ 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', () => {
+ resetPreferences();
+
+ expect(
+ userPreferences.definition('batchEdit', 'editor', 'enableRelationships')
+ .defaultValue
+ ).toBe(true);
+ expect(
+ userPreferences.definition('batchEdit', 'editor', 'showRollback')
+ .defaultValue
+ ).toBe(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', () => {
+ setBatchEditPreferences(true, true);
+ mockedHasPermission.mockReturnValue(true);
+
+ expect(datasetVariants.workbench.canUndo()).toBe(true);
+ expect(mockedHasPermission).toHaveBeenCalledWith(
+ '/workbench/dataset',
+ 'unupload'
+ );
+ });
+});
\ No newline at end of file
|