diff --git a/specifyweb/frontend/js_src/lib/components/Core/Contexts.tsx b/specifyweb/frontend/js_src/lib/components/Core/Contexts.tsx index a1dfd528931..e3a63715e13 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/Contexts.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/Contexts.tsx @@ -213,6 +213,9 @@ ErrorContext.displayName = 'ErrorContext'; export const ReadOnlyContext = React.createContext(false); ReadOnlyContext.displayName = 'ReadOnlyContext'; +export const RecordMergingContext = React.createContext(true); +RecordMergingContext.displayName = 'RecordMergingContext'; + /** If true, form is rendered in a search dialog - required fields are not enforced */ export const SearchDialogContext = React.createContext(false); SearchDialogContext.displayName = 'SearchDialogContext'; diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/RecordMerging.test.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/RecordMerging.test.tsx new file mode 100644 index 00000000000..3047c5f01ee --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/RecordMerging.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { requireContext } from '../../../tests/helpers'; +import { TableDataView } from '..'; + +requireContext(); + +jest.mock('../queries', () => ({ + ...jest.requireActual('../queries'), + useDataViewQueries: () => [{ version: 1, queries: {} }, jest.fn()], +})); +jest.mock('../../Permissions/helpers', () => ({ + ...jest.requireActual('../../Permissions/helpers'), + hasPermission: () => true, +})); +jest.mock('../../Permissions/PermissionDenied', () => ({ + ProtectedTable: ({ children }: { readonly children: React.ReactNode }) => + children, +})); +jest.mock('../../QueryBuilder/ResultsWrapper', () => { + const actualReact = jest.requireActual('react'); + const { RecordMergingContext } = jest.requireActual('../../Core/Contexts'); + return { + ...jest.requireActual('../../QueryBuilder/ResultsWrapper'), + QueryResultsWrapper: () => ( +
+ {actualReact.useContext(RecordMergingContext) + ? 'Merging enabled' + : 'Merging disabled'} +
+ ), + }; +}); + +test('disables merging throughout Data Views', () => { + render( + + + } + /> + + + ); + expect(screen.getByText('Merging disabled')).toBeInTheDocument(); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index d3e5697e304..15fb780cca5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -7,6 +7,7 @@ import { useResponsiveSplitView } from '../../hooks/useResponsiveSplitView'; import { H2 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; +import { RecordMergingContext } from '../Core/Contexts'; import { getTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import { raise } from '../Errors/Crash'; @@ -45,13 +46,15 @@ export function TableDataView(): JSX.Element { return table === undefined ? ( ) : ( - - {hasPermission('/querybuilder/query', 'execute') ? ( - - ) : ( - - )} - + + + {hasPermission('/querybuilder/query', 'execute') ? ( + + ) : ( + + )} + + ); } @@ -181,15 +184,6 @@ function LoadedDataViewFromTable({ restoreScrollTopRef.current = resultsScrollRef.current.scrollTop; setRefreshToken((token) => token + 1); }, []); - const handleMerged = React.useCallback((): void => { - /* - * Merging removes the selected records. Clear the preview before the - * refreshed results arrive so it does not try to load deleted records. - */ - setSelectedIds([]); - setSelectedIndex(0); - handleRefresh(); - }, [handleRefresh]); const handleCloseQueryEditor = (): void => setQueryData(undefined); const handleOpenQueryEditor = (): void => { setIsQueryDirty(false); @@ -276,7 +270,6 @@ function LoadedDataViewFromTable({ setSelectedIds([]); setSelectedIndex(0); }} - onMerged={handleMerged} onSortChange={(newFields): void => { setRuntimeFields(unParseQueryFields(table.name, newFields)); setQueryRunCount((count) => count + 1); diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx index b7f5d50a3cc..f32ba1590c3 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx @@ -12,6 +12,7 @@ import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; +import { RecordMergingContext } from '../Core/Contexts'; import { toTable } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -131,7 +132,8 @@ function MetaDialog({ readonly onClose: () => void; }): JSX.Element { const subView = React.useContext(SubViewContext); - const canMergeTable = canMerge(resource.specifyTable); + const canMergeTable = + React.useContext(RecordMergingContext) && canMerge(resource.specifyTable); return ( diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/RecordMergingAvailability.test.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/RecordMergingAvailability.test.tsx new file mode 100644 index 00000000000..55d11d8569d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/RecordMergingAvailability.test.tsx @@ -0,0 +1,140 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import React from 'react'; + +import { requireContext } from '../../../tests/helpers'; +import { RecordMergingContext } from '../../Core/Contexts'; +import { tables } from '../../DataModel/tables'; +import { defaultDataViewQuery } from '../../DataViews/queries'; +import { FormMeta } from '../../FormMeta'; +import { formsText } from '../../../localization/forms'; +import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import { parseQueryFields, queryFieldsToFieldSpecs } from '../helpers'; +import { QueryResults } from '../Results'; + +requireContext(); + +jest.mock('../../Permissions/helpers', () => ({ + ...jest.requireActual('../../Permissions/helpers'), + hasPermission: () => true, + hasTablePermission: () => true, + hasToolPermission: () => false, +})); +jest.mock('../../Permissions/PermissionDenied', () => ({ + ProtectedTool: () => null, + ProtectedAction: () => null, +})); +jest.mock('../../Merging', () => ({ + RecordMergingLink: () => , +})); +jest.mock('../ToForms', () => ({ QueryToForms: () => null })); +jest.mock('../ToMap', () => ({ QueryToMap: () => null })); +jest.mock('../ResultsTable', () => ({ QueryResultsTable: () => null })); +jest.mock('../../FormMeta/MergeRecord', () => ({ + MergeRecord: () => , +})); +jest.mock('../../FormMeta/AutoNumbering', () => ({ + AutoNumbering: () => null, +})); +jest.mock('../../FormMeta/CarryForward', () => ({ + ...jest.requireActual('../../FormMeta/CarryForward'), + CarryForwardConfig: () => null, +})); +jest.mock('../../FormMeta/Clone', () => ({ + CloneConfig: () => null, + AddButtonConfig: () => null, +})); +jest.mock('../../FormMeta/Definition', () => ({ Definition: () => null })); +jest.mock('../../FormMeta/EditHistory', () => ({ EditHistory: () => null })); +jest.mock('../../FormMeta/PickListUsages', () => ({ + PickListUsages: () => null, +})); +jest.mock('../../FormMeta/QueryTreeUsages', () => ({ + QueryTreeUsages: () => null, +})); +jest.mock('../../FormMeta/ReadOnlyMode', () => ({ ReadOnlyMode: () => null })); +jest.mock('../../FormMeta/ShareRecord', () => ({ ShareRecord: () => null })); +jest.mock('../../FormCommands', () => ({ GenerateLabel: () => null })); +jest.mock('../../FormFields/Checkbox', () => ({ PrintOnSave: () => null })); + +test('hides merging in Data Views without changing Query Builder availability', async () => { + const fields = parseQueryFields(defaultDataViewQuery('Agent').fields); + const fieldSpecs = queryFieldsToFieldSpecs('Agent', fields).map( + ([, fieldSpec]) => fieldSpec + ); + const results = ( + [[1], [2]]} + fetchCount={undefined} + totalCount={2} + fieldSpecs={fieldSpecs} + displayedFields={fields} + allFields={fields} + initialData={[[1], [2]]} + selectedRows={[new Set([1, 2]), jest.fn()]} + onReRun={jest.fn()} + createRecordSet={undefined} + extraButtons={undefined} + /> + ); + const { rerender } = render(results); + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Merge selected records' }) + ).toBeInTheDocument() + ); + + await act(async () => + rerender( + + {results} + + ) + ); + expect( + screen.queryByRole('button', { name: 'Merge selected records' }) + ).not.toBeInTheDocument(); +}); + +test('hides the preview form merge action when merging is disabled', async () => { + const formMeta = ( + + ); + const { rerender } = render(formMeta, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: formsText.formMeta() })) + ); + expect( + screen.getByRole('button', { name: 'Merge this record' }) + ).toBeInTheDocument(); + await act(async () => + rerender( + + {formMeta} + + ) + ); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: formsText.formMeta() })) + ); + expect( + screen.queryByRole('button', { name: 'Merge this record' }) + ).not.toBeInTheDocument(); +});