From 18ca2d7ecff9fbcd2bd1ef98b576676cbb49f3c7 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 1 May 2026 15:53:47 -0500 Subject: [PATCH 1/6] Auto-populate Collecting Event end date from start date --- .../components/DataModel/businessRuleDefs.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 5b0fe4fe7d0..b5d0f30918f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -40,6 +40,7 @@ import type { Collection } from './specifyTable'; import { tables } from './tables'; import type { BorrowMaterial, + CollectingEvent, CollectionObject, CollectionObjectGroup, CollectionObjectGroupJoin, @@ -83,6 +84,129 @@ type MappedBusinessRuleDefs = { readonly [TABLE in keyof Tables]?: BusinessRuleDefs; }; +type CollectingEventDateRangeSyncState = { + readonly endDate?: string | null; + readonly endDatePrecision?: number | null; + readonly isEndDatePrecisionManual?: boolean; +}; + +const collectingEventDateRangeSyncState = new WeakMap< + SpecifyResource, + CollectingEventDateRangeSyncState +>(); + +const normalizeDateValue = (value: string | null | undefined): string | null => + typeof value === 'string' && value.length > 0 ? value : null; + +const normalizeDatePrecision = ( + value: number | null | undefined +): number | null => (typeof value === 'number' ? value : null); + +const getCollectingEventDateRangeSyncState = ( + collectingEvent: SpecifyResource +): CollectingEventDateRangeSyncState => { + const state = collectingEventDateRangeSyncState.get(collectingEvent); + if (state !== undefined) return state; + + const newState = {}; + collectingEventDateRangeSyncState.set(collectingEvent, newState); + return newState; +}; + +function initializeCollectingEventDateRangeSync( + collectingEvent: SpecifyResource +): void { + updateCollectingEventDateRangeSyncState(collectingEvent); + updateCollectingEventDatePrecisionSyncState(collectingEvent); +} + +function updateCollectingEventDateRangeSyncState( + collectingEvent: SpecifyResource +): void { + const state = getCollectingEventDateRangeSyncState(collectingEvent); + const startDate = normalizeDateValue(collectingEvent.get('startDate')); + const endDate = normalizeDateValue(collectingEvent.get('endDate')); + + collectingEventDateRangeSyncState.set(collectingEvent, { + ...state, + endDate: + startDate !== null && startDate === endDate ? endDate : undefined, + }); +} + +function updateCollectingEventDatePrecisionSyncState( + collectingEvent: SpecifyResource +): void { + const state = getCollectingEventDateRangeSyncState(collectingEvent); + const startDatePrecision = normalizeDatePrecision( + collectingEvent.get('startDatePrecision') + ); + const endDatePrecision = normalizeDatePrecision( + collectingEvent.get('endDatePrecision') + ); + const isSynced = + startDatePrecision !== null && startDatePrecision === endDatePrecision; + + collectingEventDateRangeSyncState.set(collectingEvent, { + ...state, + endDatePrecision: isSynced ? endDatePrecision : undefined, + isEndDatePrecisionManual: + startDatePrecision !== null && endDatePrecision !== null && !isSynced, + }); +} + +function syncCollectingEventEndDate( + collectingEvent: SpecifyResource +): void { + const state = getCollectingEventDateRangeSyncState(collectingEvent); + const startDate = normalizeDateValue(collectingEvent.get('startDate')); + const endDate = normalizeDateValue(collectingEvent.get('endDate')); + + if (endDate === null || endDate === state.endDate) { + collectingEventDateRangeSyncState.set(collectingEvent, { + ...state, + endDate: startDate, + }); + collectingEvent.set('endDate', startDate); + syncCollectingEventEndDatePrecision(collectingEvent, true); + } else if (startDate !== null && startDate === endDate) + updateCollectingEventDateRangeSyncState(collectingEvent); +} + +function syncCollectingEventEndDatePrecision( + collectingEvent: SpecifyResource, + force = false +): void { + const state = getCollectingEventDateRangeSyncState(collectingEvent); + const startDate = normalizeDateValue(collectingEvent.get('startDate')); + const endDate = normalizeDateValue(collectingEvent.get('endDate')); + const startDatePrecision = normalizeDatePrecision( + collectingEvent.get('startDatePrecision') + ); + const endDatePrecision = normalizeDatePrecision( + collectingEvent.get('endDatePrecision') + ); + + if (startDate === null || startDate !== endDate) return; + if (state.isEndDatePrecisionManual === true) return; + if ( + force || + endDatePrecision === null || + endDatePrecision === state.endDatePrecision || + state.endDatePrecision === undefined + ) { + collectingEventDateRangeSyncState.set(collectingEvent, { + ...state, + endDatePrecision: startDatePrecision, + }); + collectingEvent.set('endDatePrecision', startDatePrecision); + } else if ( + startDatePrecision !== null && + startDatePrecision === endDatePrecision + ) + updateCollectingEventDatePrecisionSyncState(collectingEvent); +} + export const businessRuleDefs: MappedBusinessRuleDefs = { Address: { fieldChecks: { @@ -378,6 +502,17 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { onAdded: onAddedEnsureBoolInCollection('isPrimary'), }, + CollectingEvent: { + customInit: initializeCollectingEventDateRangeSync, + fieldChecks: { + startDate: syncCollectingEventEndDate, + startDatePrecision: (collectingEvent): void => + syncCollectingEventEndDatePrecision(collectingEvent), + endDate: updateCollectingEventDateRangeSyncState, + endDatePrecision: updateCollectingEventDatePrecisionSyncState, + }, + }, + Component: { customInit: (component: SpecifyResource): void => { if ( From 6177822aded7c1f327f17ca664ad8bc063d7a066 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 1 May 2026 16:19:41 -0500 Subject: [PATCH 2/6] Add auto-populate unit tests --- .../DataModel/__tests__/businessRules.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 26226f396f9..928506c0831 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -498,6 +498,56 @@ describe.skip('Dependent Collections isPrimary', () => { }); describe('Collecting Event', () => { + test('copies start date into an empty end date', () => { + const collectingEvent = new tables.CollectingEvent.Resource(); + + collectingEvent.set('startDate', '2026-04-27'); + + expect(collectingEvent.get('endDate')).toBe('2026-04-27'); + }); + + test('updates an automatically copied end date when start date changes', () => { + const collectingEvent = new tables.CollectingEvent.Resource(); + + collectingEvent.set('startDate', '2026-04-27'); + collectingEvent.set('startDate', '2026-04-28'); + + expect(collectingEvent.get('endDate')).toBe('2026-04-28'); + }); + + test('preserves a manually changed end date', () => { + const collectingEvent = new tables.CollectingEvent.Resource(); + + collectingEvent.set('startDate', '2026-04-27'); + collectingEvent.set('endDate', '2026-04-29'); + collectingEvent.set('startDate', '2026-04-28'); + + expect(collectingEvent.get('endDate')).toBe('2026-04-29'); + }); + + test('keeps end date precision with an automatically copied end date', () => { + const collectingEvent = new tables.CollectingEvent.Resource(); + + collectingEvent.set('startDatePrecision', 2); + collectingEvent.set('endDatePrecision', 1, { silent: true }); + collectingEvent.set('startDate', '2026-04-01'); + collectingEvent.set('startDatePrecision', 3); + + expect(collectingEvent.get('endDate')).toBe('2026-04-01'); + expect(collectingEvent.get('endDatePrecision')).toBe(3); + }); + + test('preserves a manually changed end date precision', () => { + const collectingEvent = new tables.CollectingEvent.Resource(); + + collectingEvent.set('startDate', '2026-04-01'); + collectingEvent.set('startDatePrecision', 2); + collectingEvent.set('endDatePrecision', 1); + collectingEvent.set('startDatePrecision', 3); + + expect(collectingEvent.get('endDatePrecision')).toBe(1); + }); + test('Removing Collector sets first Collector as primary', () => { const collectingEvent = new tables.CollectingEvent.Resource({ collectors: [ From 591ecb1d0fca4e47e6b6c0738d62c68c7ae120be Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 1 May 2026 21:22:54 +0000 Subject: [PATCH 3/6] Lint code with ESLint and Prettier Triggered by 6177822aded7c1f327f17ca664ad8bc063d7a066 on branch refs/heads/issue-8044 --- .../components/DataModel/businessRuleDefs.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index b5d0f30918f..71a626b880f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -129,8 +129,7 @@ function updateCollectingEventDateRangeSyncState( collectingEventDateRangeSyncState.set(collectingEvent, { ...state, - endDate: - startDate !== null && startDate === endDate ? endDate : undefined, + endDate: startDate !== null && startDate === endDate ? endDate : undefined, }); } @@ -325,9 +324,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { return undefined; }, catalogNumber: async (resource): Promise => { - const preferences = await import( - '../Preferences/collectionPreferences' - ).then(({ collectionPreferences }) => collectionPreferences); + const preferences = + await import('../Preferences/collectionPreferences').then( + ({ collectionPreferences }) => collectionPreferences + ); const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get( 'uniqueCatalogNumberAccrossComponentAndCO', @@ -564,9 +564,10 @@ export const businessRuleDefs: MappedBusinessRuleDefs = { return undefined; }, catalogNumber: async (resource): Promise => { - const preferences = await import( - '../Preferences/collectionPreferences' - ).then(({ collectionPreferences }) => collectionPreferences); + const preferences = + await import('../Preferences/collectionPreferences').then( + ({ collectionPreferences }) => collectionPreferences + ); const uniqueCatalogNumberAccrossComponentAndCOPref = preferences.get( 'uniqueCatalogNumberAccrossComponentAndCO', From 2e6b5fb625472ab09a9d80ec31b26371e71d99bf Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 1 May 2026 16:58:13 -0500 Subject: [PATCH 4/6] Fix syncing issue --- .../js_src/lib/components/DataModel/businessRuleDefs.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 71a626b880f..17cc039e24d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -116,6 +116,7 @@ const getCollectingEventDateRangeSyncState = ( function initializeCollectingEventDateRangeSync( collectingEvent: SpecifyResource ): void { + syncCollectingEventEndDate(collectingEvent); updateCollectingEventDateRangeSyncState(collectingEvent); updateCollectingEventDatePrecisionSyncState(collectingEvent); } @@ -143,14 +144,12 @@ function updateCollectingEventDatePrecisionSyncState( const endDatePrecision = normalizeDatePrecision( collectingEvent.get('endDatePrecision') ); - const isSynced = - startDatePrecision !== null && startDatePrecision === endDatePrecision; + const isSynced = startDatePrecision === endDatePrecision; collectingEventDateRangeSyncState.set(collectingEvent, { ...state, endDatePrecision: isSynced ? endDatePrecision : undefined, - isEndDatePrecisionManual: - startDatePrecision !== null && endDatePrecision !== null && !isSynced, + isEndDatePrecisionManual: !isSynced, }); } From b849b623a6084e194c88c69d67452592c91b069e Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 1 May 2026 16:58:34 -0500 Subject: [PATCH 5/6] Add unit test for syncing between dates --- .../DataModel/__tests__/businessRules.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 928506c0831..5c5f5bef12b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -498,6 +498,16 @@ describe.skip('Dependent Collections isPrimary', () => { }); describe('Collecting Event', () => { + test('copies start date into end date during initialization', () => { + const collectingEvent = new tables.CollectingEvent.Resource({ + startDate: '2026-04-27', + startDatePrecision: 2, + }); + + expect(collectingEvent.get('endDate')).toBe('2026-04-27'); + expect(collectingEvent.get('endDatePrecision')).toBe(2); + }); + test('copies start date into an empty end date', () => { const collectingEvent = new tables.CollectingEvent.Resource(); From fe7e10608606bb47fe7ddc1e3f456babb2910d69 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 1 May 2026 17:07:37 -0500 Subject: [PATCH 6/6] Call sync after calling update --- .../js_src/lib/components/DataModel/businessRuleDefs.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 17cc039e24d..02eaac76daa 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -167,8 +167,10 @@ function syncCollectingEventEndDate( }); collectingEvent.set('endDate', startDate); syncCollectingEventEndDatePrecision(collectingEvent, true); - } else if (startDate !== null && startDate === endDate) + } else if (startDate !== null && startDate === endDate) { updateCollectingEventDateRangeSyncState(collectingEvent); + syncCollectingEventEndDatePrecision(collectingEvent); + } } function syncCollectingEventEndDatePrecision(