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..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,66 @@ 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(); + + 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: [ diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 5b0fe4fe7d0..02eaac76daa 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 { + syncCollectingEventEndDate(collectingEvent); + 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 === endDatePrecision; + + collectingEventDateRangeSyncState.set(collectingEvent, { + ...state, + endDatePrecision: isSynced ? endDatePrecision : undefined, + isEndDatePrecisionManual: !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); + syncCollectingEventEndDatePrecision(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: { @@ -201,9 +325,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', @@ -378,6 +503,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 ( @@ -429,9 +565,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',