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