From d75e6dc78356c7acade79b9b09b40fd15c868a7f Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Sun, 5 Oct 2025 00:30:48 -0400 Subject: [PATCH 1/4] Fix: merge auto-populate to respect preference toggle --- specifyweb/frontend/js_src/lib/components/Merging/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Merging/index.tsx b/specifyweb/frontend/js_src/lib/components/Merging/index.tsx index e81ccae2f9d..b66e69ad874 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Merging/index.tsx @@ -219,7 +219,7 @@ function Merging({ autoMerge( table, initialRecords.current, - userPreferences.get( + !userPreferences.get( 'recordMerging', 'behavior', 'autoPopulate' From ba1cb7752c85a03cfc202083e6aca6f1ea25ed10 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Mon, 3 Nov 2025 21:45:50 -0500 Subject: [PATCH 2/4] Fix: auto-populate now correctly fills all eligible merge fields when enabled and leaves them blank when disabled --- .../Merging/__tests__/autoMerge.test.ts | 13 +++++ .../lib/components/Merging/autoMerge.ts | 3 +- .../js_src/lib/components/Merging/index.tsx | 54 +++++++++++-------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts index 8edeb9850bb..0691bf7da49 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts @@ -636,4 +636,17 @@ describe('autoMerge', () => { } `); }); + + test('prefers longest string values when auto populating', () => { + const merged = autoMerge( + tables.Agent, + [ + addMissingFields('Agent', { lastName: '' }), + addMissingFields('Agent', { lastName: 'Longer Value' }), + addMissingFields('Agent', { lastName: 'Mid' }), + ] as unknown as RA>, + false + ); + expect(merged.lastName).toBe('Longer Value'); + }); }); diff --git a/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts b/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts index 427a4958d3a..0469fc4c304 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts +++ b/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts @@ -151,7 +151,8 @@ function mergeField( return ( Array.from(nonFalsyValues).sort( sortFunction((string) => - typeof string === 'string' ? string.length : 0 + typeof string === 'string' ? string.length : 0, + true ) )[0] ?? firstValue ); diff --git a/specifyweb/frontend/js_src/lib/components/Merging/index.tsx b/specifyweb/frontend/js_src/lib/components/Merging/index.tsx index b66e69ad874..757fb80d790 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Merging/index.tsx @@ -23,6 +23,7 @@ import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; import { runAllFieldChecks } from '../DataModel/businessRules'; +import { addMissingFields } from '../DataModel/addMissingFields'; import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { fetchResource, resourceEvents } from '../DataModel/resource'; @@ -211,29 +212,38 @@ function Merging({ const [merged, setMerged] = useAsyncState( React.useCallback( - async () => - records === undefined || initialRecords.current === undefined - ? undefined - : postMergeResource( + async () => { + if ( + records === undefined || + initialRecords.current === undefined || + target === undefined + ) + return undefined; + + const shouldAutoPopulate = userPreferences.get( + 'recordMerging', + 'behavior', + 'autoPopulate' + ); + + const mergedPayload = shouldAutoPopulate + ? await postMergeResource( initialRecords.current, - autoMerge( - table, - initialRecords.current, - !userPreferences.get( - 'recordMerging', - 'behavior', - 'autoPopulate' - ), - target.id - ) - ).then(async (merged) => { - const mergedResource = deserializeResource( - merged as SerializedResource - ); - if (merged !== undefined) await runAllFieldChecks(mergedResource); - return mergedResource; - }), - [table, records] + autoMerge(table, initialRecords.current, false, target.id) + ) + : addMissingFields( + table.name, + {} as Partial> + ); + + const mergedResource = deserializeResource( + mergedPayload as SerializedResource + ); + if (mergedPayload !== undefined) + await runAllFieldChecks(mergedResource); + return mergedResource; + }, + [table, records, target] ), true ); From 5cf5124af9554f758cd62508c99274fc2ef770d8 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Tue, 4 Nov 2025 02:50:55 +0000 Subject: [PATCH 3/4] Lint code with ESLint and Prettier Triggered by ba1cb7752c85a03cfc202083e6aca6f1ea25ed10 on branch refs/heads/issue-4869 --- .../lib/components/Merging/autoMerge.ts | 4 +- .../js_src/lib/components/Merging/index.tsx | 64 +++++++++---------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts b/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts index 0469fc4c304..a304b016614 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts +++ b/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts @@ -150,8 +150,8 @@ function mergeField( // Pick the longest value return ( Array.from(nonFalsyValues).sort( - sortFunction((string) => - typeof string === 'string' ? string.length : 0, + sortFunction( + (string) => (typeof string === 'string' ? string.length : 0), true ) )[0] ?? firstValue diff --git a/specifyweb/frontend/js_src/lib/components/Merging/index.tsx b/specifyweb/frontend/js_src/lib/components/Merging/index.tsx index 757fb80d790..2c3f280a947 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Merging/index.tsx @@ -22,8 +22,8 @@ import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; -import { runAllFieldChecks } from '../DataModel/businessRules'; import { addMissingFields } from '../DataModel/addMissingFields'; +import { runAllFieldChecks } from '../DataModel/businessRules'; import type { AnySchema, SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { fetchResource, resourceEvents } from '../DataModel/resource'; @@ -211,40 +211,36 @@ function Merging({ const clones = sortedResources.slice(1); const [merged, setMerged] = useAsyncState( - React.useCallback( - async () => { - if ( - records === undefined || - initialRecords.current === undefined || - target === undefined - ) - return undefined; - - const shouldAutoPopulate = userPreferences.get( - 'recordMerging', - 'behavior', - 'autoPopulate' - ); - - const mergedPayload = shouldAutoPopulate - ? await postMergeResource( - initialRecords.current, - autoMerge(table, initialRecords.current, false, target.id) - ) - : addMissingFields( - table.name, - {} as Partial> - ); + React.useCallback(async () => { + if ( + records === undefined || + initialRecords.current === undefined || + target === undefined + ) + return undefined; + + const shouldAutoPopulate = userPreferences.get( + 'recordMerging', + 'behavior', + 'autoPopulate' + ); + + const mergedPayload = shouldAutoPopulate + ? await postMergeResource( + initialRecords.current, + autoMerge(table, initialRecords.current, false, target.id) + ) + : addMissingFields( + table.name, + {} as Partial> + ); - const mergedResource = deserializeResource( - mergedPayload as SerializedResource - ); - if (mergedPayload !== undefined) - await runAllFieldChecks(mergedResource); - return mergedResource; - }, - [table, records, target] - ), + const mergedResource = deserializeResource( + mergedPayload as SerializedResource + ); + if (mergedPayload !== undefined) await runAllFieldChecks(mergedResource); + return mergedResource; + }, [table, records, target]), true ); From 7a2180c4852693c9dd3637bd8d7e33a3653eb179 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Tue, 4 Nov 2025 21:27:01 -0500 Subject: [PATCH 4/4] Auto-populate for precision fields and merge snapshots updated --- .../Merging/__tests__/autoMerge.test.ts | 32 +++++++++++++++---- .../lib/components/Merging/autoMerge.ts | 20 ++++++++++-- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts index 0691bf7da49..7e686509cb8 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/autoMerge.test.ts @@ -248,7 +248,7 @@ describe('autoMerge', () => { "agentAttachments": [], "agentGeographies": [], "agentSpecialties": [], - "agentType": 1, + "agentType": 0, "catalogerOf": "/api/specify/CollectionObject?cataloger=2305", "collContentContact": null, "collTechContact": null, @@ -257,7 +257,7 @@ describe('autoMerge', () => { "date1": null, "date1Precision": null, "date2": null, - "date2Precision": null, + "date2Precision": 2, "dateOfBirth": null, "dateOfBirthPrecision": null, "dateOfDeath": null, @@ -415,7 +415,7 @@ describe('autoMerge', () => { "agentAttachments": [], "agentGeographies": [], "agentSpecialties": [], - "agentType": 1, + "agentType": 0, "catalogerOf": "/api/specify/CollectionObject?cataloger=2305", "collContentContact": null, "collTechContact": null, @@ -424,7 +424,7 @@ describe('autoMerge', () => { "date1": "2020-01-01", "date1Precision": null, "date2": null, - "date2Precision": null, + "date2Precision": 2, "dateOfBirth": null, "dateOfBirthPrecision": null, "dateOfDeath": null, @@ -582,7 +582,7 @@ describe('autoMerge', () => { "agentAttachments": [], "agentGeographies": [], "agentSpecialties": [], - "agentType": 1, + "agentType": 0, "catalogerOf": "/api/specify/CollectionObject?cataloger=2305", "collContentContact": null, "collTechContact": null, @@ -591,7 +591,7 @@ describe('autoMerge', () => { "date1": null, "date1Precision": null, "date2": null, - "date2Precision": null, + "date2Precision": 2, "dateOfBirth": null, "dateOfBirthPrecision": null, "dateOfDeath": null, @@ -649,4 +649,24 @@ describe('autoMerge', () => { ); expect(merged.lastName).toBe('Longer Value'); }); + + test('fills dependent precision even if newest record lacks it', () => { + const merged = autoMerge( + tables.Agent, + [ + addMissingFields('Agent', { + timestampModified: '2024-01-01', + dateOfDeath: '2023-01-01', + dateOfDeathPrecision: null, + }), + addMissingFields('Agent', { + timestampModified: '2023-01-01', + dateOfDeath: '2023-01-01', + dateOfDeathPrecision: 0, + }), + ] as unknown as RA>, + false + ); + expect(merged.dateOfDeathPrecision).toBe(0); + }); }); diff --git a/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts b/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts index a304b016614..0c405b0cb38 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts +++ b/specifyweb/frontend/js_src/lib/components/Merging/autoMerge.ts @@ -88,7 +88,16 @@ function mergeField( resource[field.name], ]); const values = parentChildValues.map(([_, child]) => child); - const nonFalsyValues = f.unique(values.filter(Boolean)); + const nonFalsyValues = f.unique( + values.filter( + (value) => + value !== null && + value !== undefined && + value !== '' && + // Preserve zeros and false, but drop NaN + value === value + ) + ); const firstValue = nonFalsyValues[0] ?? values[0]; if (field.isRelationship) if (field.isDependent()) @@ -214,10 +223,15 @@ function mergeDependentField( sourceValue: ReturnType ): ReturnType { const sourceField = strictDependentFields()[fieldName]; - const sourceResource = resources.find( + const matchingResources = resources.filter( (resource) => resource[sourceField] === sourceValue ); - return sourceResource?.[fieldName] ?? null; + const preferredResource = + matchingResources.find((resource) => { + const value = resource[fieldName]; + return value !== null && value !== undefined; + }) ?? matchingResources[0]; + return preferredResource?.[fieldName] ?? null; } /**