diff --git a/src/components/EditSections/EditUserInfo/EditUserInfo.js b/src/components/EditSections/EditUserInfo/EditUserInfo.js index 36e1f8cfa..7ef58d5e6 100644 --- a/src/components/EditSections/EditUserInfo/EditUserInfo.js +++ b/src/components/EditSections/EditUserInfo/EditUserInfo.js @@ -1,5 +1,4 @@ import get from 'lodash/get'; -import moment from 'moment-timezone'; import PropTypes from 'prop-types'; import React from 'react'; import { Field } from 'react-final-form'; @@ -89,7 +88,7 @@ class EditUserInfo extends React.Component { setRecalculatedExpirationDate = (startCalcToday) => { const { form: { change } } = this.props; - const recalculatedDate = this.calculateNewExpirationDate(startCalcToday).format('L'); + const recalculatedDate = this.calculateNewExpirationDate(startCalcToday).format('YYYY-MM-DD'); const parsedRecalculatedDate = this.parseExpirationDate(recalculatedDate); change('expirationDate', parsedRecalculatedDate); @@ -102,16 +101,36 @@ class EditUserInfo extends React.Component { this.setState({ showUserTypeModal: false }); } + /** + * Calculates a new expiration date by adding the patron group's offset days + * to either today's date or the existing expiration date. + * + * All calculations are performed in the tenant's timezone to ensure + * consistent date arithmetic regardless of the user's browser timezone. + * + * @param {boolean} startCalcToday - If true, calculate from today; if false, from existing expiration date + * @returns {object} dayjs object representing the new expiration date in tenant timezone + */ calculateNewExpirationDate = (startCalcToday) => { - const { initialValues } = this.props; - const now = Date.now(); - const expirationDate = initialValues.expirationDate ? new Date(initialValues.expirationDate) : now; + const { + initialValues, + stripes, + } = this.props; + const { + timezone = 'UTC', + } = stripes; + + // Use tenant timezone for date calculations to ensure consistency + const now = dayjs().tz(timezone); + const expirationDate = initialValues.expirationDate ? dayjs.tz(initialValues.expirationDate, timezone) : now; const offsetOfSelectedPatronGroup = this.state.selectedPatronGroup ? this.getPatronGroupOffset() : ''; - const shouldRecalculateFromToday = startCalcToday || initialValues.expirationDate === undefined || expirationDate <= now; - const baseDate = shouldRecalculateFromToday ? dayjs() : dayjs(expirationDate); + const shouldRecalculateFromToday = startCalcToday || initialValues.expirationDate === undefined || expirationDate.isSameOrBefore(now); + const baseDate = shouldRecalculateFromToday ? now : expirationDate; + + const result = baseDate.add(offsetOfSelectedPatronGroup, 'd'); - return baseDate.add(offsetOfSelectedPatronGroup, 'd'); + return result; } getPatronGroupOffset = () => { @@ -119,16 +138,65 @@ class EditUserInfo extends React.Component { return get(selectedPatronGroup, 'expirationOffsetInDays', ''); }; + /** + * Parses expiration date input and converts to UTC ISO string for consistent storage. + * + * This function handles both: + * 1. ISO date strings (YYYY-MM-DD) from recalculation - converted to UTC end-of-day + * 2. Date strings from manual datepicker input - parsed and converted to UTC end-of-day + * + * We store dates as UTC end-of-day to ensure the user gets the full day of access. + * The timezone-aware comparison logic in getNowAndExpirationEndOfDayInTenantTz + * handles proper expiration timing based on the tenant's timezone. + * This ensures consistent display in the datepicker regardless of timezone. + * + * @param {string} expirationDate - Date input (ISO string from calculation or datepicker input) + * @returns {string} UTC ISO string for storage (end of day) + */ parseExpirationDate = (expirationDate) => { - const { - stripes: { - timezone, - }, - } = this.props; + if (!expirationDate) return expirationDate; - return expirationDate - ? moment.tz(expirationDate, timezone).endOf('day').toDate().toISOString() - : expirationDate; + const dateToStore = dayjs.utc(expirationDate).endOf('day').toISOString(); + + return dateToStore; + }; + + /** + * Creates timezone-aware date objects for expiration comparison. + * + * This function extracts just the date part (YYYY-MM-DD) from the UTC stored expiration date because: + * 1. Dates are stored as UTC end-of-day (e.g., "2025-07-31T23:59:59.999Z") and not end-of-day for some old data + * 2. We need to compare the expiration date against the current time in the tenant's timezone + * 3. A user should expire at midnight in their local timezone, not at a UTC-based time + * + * @param {string} expDate - UTC ISO string of the expiration date + * @returns {Object} Object with nowInTenantTz and expirationEndOfDayInTenantTz dayjs instances + */ + getNowAndExpirationEndOfDayInTenantTz = (expDate) => { + const { stripes } = this.props; + const timezone = stripes.timezone || 'UTC'; + + // Use `dayjs.utc` rather than `dayjs` to avoid month and day shifting due to local timezone. + // For example, if timezone is UTC+3 and the `expirationDate` is 2025-07-31T23:59:59.999Z, + // `dayjs('2025-07-31T23:59:59.999Z')` will be parsed as 2025-08-01T02:59:59+03:00 + // with the 3 hours offset applied (month and day are changed). + // The `dayjs.utc` ensures that we get the exact date and time as stored in the database without any timezone adjustments. + // `dayjs.utc('2025-07-31T23:59:59.999Z')` returns 2025-07-31T23:59:59Z. + const expirationDate = dayjs.utc(expDate); + // Format using `.format('YYYY-MM-DD')` to get just the date part, since the time can be incorrect for some old data. + const expirationDateString = expirationDate.format('YYYY-MM-DD'); + + // Create end of day in tenant timezone for that date. Use `timezone`, otherwise when switching the timezone in the settings, + // it will not be taken into account in the calculations. + const expirationEndOfDayInTenantTz = dayjs.tz(expirationDateString, timezone).endOf('day'); + // Same here, if you switch the timezone in the settings, `dayjs()` without timezone specified will use your local timezone, + // not the one selected in the settings. + const nowInTenantTz = dayjs().tz(timezone); + + return { + nowInTenantTz, + expirationEndOfDayInTenantTz, + }; }; render() { @@ -151,16 +219,21 @@ class EditUserInfo extends React.Component { const { barcode } = initialValues; const isUserExpired = () => { - const expirationDate = new Date(initialValues.expirationDate); - const now = Date.now(); - return expirationDate <= now; + if (!initialValues.expirationDate) return false; + + const { nowInTenantTz, expirationEndOfDayInTenantTz } = this.getNowAndExpirationEndOfDayInTenantTz(initialValues.expirationDate); + + return expirationEndOfDayInTenantTz.isBefore(nowInTenantTz); }; const willUserExtend = () => { const expirationDate = form.getFieldState('expirationDate')?.value ?? ''; - const currentExpirationDate = new Date(expirationDate); - const now = Date.now(); - return currentExpirationDate >= now; + + if (!expirationDate) return false; + + const { nowInTenantTz, expirationEndOfDayInTenantTz } = this.getNowAndExpirationEndOfDayInTenantTz(expirationDate); + + return expirationEndOfDayInTenantTz.isAfter(nowInTenantTz); }; const isStatusFieldDisabled = () => { diff --git a/src/components/EditSections/EditUserInfo/EditUserInfo.test.js b/src/components/EditSections/EditUserInfo/EditUserInfo.test.js index 4fd7697f7..51bf1232b 100644 --- a/src/components/EditSections/EditUserInfo/EditUserInfo.test.js +++ b/src/components/EditSections/EditUserInfo/EditUserInfo.test.js @@ -73,6 +73,22 @@ jest.mock('./components', () => ({ const onSubmit = jest.fn(); +const getDayjsMock = (date) => { + const dayjsObj = jest.requireActual('@folio/stripes/components').dayjs(date); + + return { + ...dayjsObj, + format: jest.fn().mockReturnValue('05/04/2027'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2027-05-04T03:59:59.999Z'), + isSameOrBefore: jest.fn().mockReturnValue(false), + isBefore: jest.fn().mockReturnValue(true), + isAfter: jest.fn().mockReturnValue(true), + }; +}; + const arrayMutators = { concat: jest.fn(), move: jest.fn(), @@ -169,17 +185,12 @@ const props = { describe('Render Edit User Information component', () => { beforeEach(() => { + jest.clearAllMocks(); isConsortiumEnabled.mockClear().mockReturnValue(false); - dayjs.mockImplementation((date) => { - const dayjsObj = jest.requireActual('@folio/stripes/components').dayjs(date); - - return { - ...dayjsObj, - format: jest.fn().mockReturnValue('05/04/2027'), - add: jest.fn().mockReturnThis(), - }; - }); + dayjs.mockImplementation(getDayjsMock); + dayjs.tz = jest.fn(getDayjsMock); + dayjs.utc = jest.fn(getDayjsMock); }); it('Must be rendered', () => { @@ -194,21 +205,11 @@ describe('Render Edit User Information component', () => { describe('when "Reset" (recalculate) button is clicked', () => { it('should change expiration date', async () => { - dayjs.mockImplementation((date) => { - const dayjsObj = jest.requireActual('@folio/stripes/components').dayjs(date); - - return { - ...dayjsObj, - format: jest.fn().mockReturnValue('06/05/2025'), - add: jest.fn().mockReturnThis(), - }; - }); - renderEditUserInfo(props); await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'))); - expect(changeMock).toHaveBeenCalledWith('expirationDate', '2025-06-05T03:59:59.999Z'); + expect(changeMock).toHaveBeenCalledWith('expirationDate', '2027-05-04T03:59:59.999Z'); }); }); @@ -347,4 +348,560 @@ describe('Render Edit User Information component', () => { expect(screen.queryByText('Profile Picture')).not.toBeInTheDocument(); }); }); + + describe('parseExpirationDate', () => { + describe('when recalculating expiration date by hitting the "Reset" button', () => { + it('should convert dates to UTC end-of-day ', async () => { + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2025-07-31T23:59:59.999Z') + }; + + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + renderEditUserInfo(props); + + await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'))); + await userEvent.click(screen.getByText('ui-users.information.recalculate.modal.button')); + + expect(dayjs.utc).toHaveBeenCalled(); + expect(mockUtcDayjs.endOf).toHaveBeenCalledWith('day'); + expect(mockUtcDayjs.toISOString).toHaveBeenCalled(); + }); + + it('should process recalculated dates with correct format', async () => { + const mockCalculatedDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-07-31'), + endOf: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(false), + }; + + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2025-07-31T23:59:59.999Z') + }; + + dayjs.mockImplementation(() => mockCalculatedDate); + dayjs.tz = jest.fn().mockReturnValue(mockCalculatedDate); + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + renderEditUserInfo(props); + + await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'))); + await userEvent.click(screen.getByText('ui-users.information.recalculate.modal.button')); + + expect(mockCalculatedDate.format).toHaveBeenCalledWith('YYYY-MM-DD'); + expect(dayjs.utc).toHaveBeenCalledWith('2025-07-31'); + expect(mockUtcDayjs.endOf).toHaveBeenCalledWith('day'); + expect(changeMock).toHaveBeenCalledWith('expirationDate', '2025-07-31T23:59:59.999Z'); + }); + }); + }); + + describe('getNowAndExpirationEndOfDayInTenantTz', () => { + it('should handle timezone-aware expiration comparison for expired users', () => { + const expiredDate = '2023-01-15T23:59:59.999Z'; + + const mockUtcDayjs = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2023-01-15') + }; + + const mockExpirationEndOfDay = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(true), + isAfter: jest.fn().mockReturnValue(false), + endOf: jest.fn().mockReturnThis() + }; + + const mockNowInTz = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true) + }; + + const mockDayjsBase = { + ...getDayjsMock(), + tz: jest.fn().mockReturnValue(mockNowInTz) + }; + + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + dayjs.tz = jest.fn().mockReturnValue(mockExpirationEndOfDay); + dayjs.mockReturnValue(mockDayjsBase); + + const propsWithExpiredUser = { + ...props, + initialValues: { + ...props.initialValues, + expirationDate: expiredDate, + } + }; + + renderEditUserInfo(propsWithExpiredUser); + + expect(dayjs.utc).toHaveBeenCalledWith(expiredDate); + expect(mockUtcDayjs.format).toHaveBeenCalledWith('YYYY-MM-DD'); + expect(dayjs.tz).toHaveBeenCalledWith('2023-01-15', 'America/New_York'); + expect(screen.getByText('ui-users.errors.userExpired')).toBeInTheDocument(); + }); + + it('should handle timezone-aware expiration comparison for active users', () => { + const futureDate = '2026-12-31T23:59:59.999Z'; + + const mockUtcDayjs = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2026-12-31') + }; + + const mockExpirationEndOfDay = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true), + endOf: jest.fn().mockReturnThis() + }; + + const mockNowInTz = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(true), + isAfter: jest.fn().mockReturnValue(false) + }; + + const mockDayjsBase = { + ...getDayjsMock(), + tz: jest.fn().mockReturnValue(mockNowInTz) + }; + + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + dayjs.tz = jest.fn().mockReturnValue(mockExpirationEndOfDay); + dayjs.mockReturnValue(mockDayjsBase); + + const propsWithActiveUser = { + ...props, + initialValues: { + ...props.initialValues, + expirationDate: futureDate, + } + }; + + renderEditUserInfo(propsWithActiveUser); + + expect(dayjs.utc).toHaveBeenCalledWith(futureDate); + expect(mockUtcDayjs.format).toHaveBeenCalledWith('YYYY-MM-DD'); + expect(dayjs.tz).toHaveBeenCalledWith('2026-12-31', 'America/New_York'); + expect(screen.queryByText('ui-users.errors.userExpired')).not.toBeInTheDocument(); + }); + + it('should use tenant timezone', () => { + const timezone = 'America/Vancouver'; + + const mockUtcDayjs = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-07-15') + }; + + const mockTimezoneObj = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true), + endOf: jest.fn().mockReturnThis() + }; + + const mockNowInTz = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true) + }; + + const mockDayjsBase = { + ...getDayjsMock(), + tz: jest.fn().mockReturnValue(mockNowInTz) + }; + + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + dayjs.tz = jest.fn().mockReturnValue(mockTimezoneObj); + dayjs.mockReturnValue(mockDayjsBase); + + const timezoneProps = { + ...props, + stripes: { + ...props.stripes, + timezone + }, + initialValues: { + ...props.initialValues, + expirationDate: '2025-07-15T23:59:59.999Z' + } + }; + + renderEditUserInfo(timezoneProps); + + expect(dayjs.tz).toHaveBeenCalledWith('2025-07-15', timezone); + expect(mockDayjsBase.tz).toHaveBeenCalledWith(timezone); + }); + + it('should extract date part correctly to avoid time inconsistencies', () => { + // Test with old data that might not be end-of-day + const inconsistentTimeData = '2025-06-15T14:30:22.123Z'; + + const mockUtcDayjs = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-06-15') + }; + + const mockTimezoneObj = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true), + endOf: jest.fn().mockReturnThis() + }; + + const mockNowInTz = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true) + }; + + const mockDayjsBase = { + ...getDayjsMock(), + tz: jest.fn().mockReturnValue(mockNowInTz) + }; + + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + dayjs.tz = jest.fn().mockReturnValue(mockTimezoneObj); + dayjs.mockReturnValue(mockDayjsBase); + + const propsWithInconsistentTime = { + ...props, + initialValues: { + ...props.initialValues, + expirationDate: inconsistentTimeData, + } + }; + + renderEditUserInfo(propsWithInconsistentTime); + + expect(dayjs.utc).toHaveBeenCalledWith(inconsistentTimeData); + expect(mockUtcDayjs.format).toHaveBeenCalledWith('YYYY-MM-DD'); + expect(dayjs.tz).toHaveBeenCalledWith('2025-06-15', 'America/New_York'); + expect(mockTimezoneObj.endOf).toHaveBeenCalledWith('day'); + }); + + it('should handle UTC fallback when timezone is not specified', () => { + const mockUtcDayjs = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-08-01') + }; + + const mockTimezoneObj = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true), + endOf: jest.fn().mockReturnThis() + }; + + const mockNowInTz = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true) + }; + + const mockDayjsBase = { + ...getDayjsMock(), + tz: jest.fn().mockReturnValue(mockNowInTz) + }; + + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + dayjs.tz = jest.fn().mockReturnValue(mockTimezoneObj); + dayjs.mockReturnValue(mockDayjsBase); + + const propsWithoutTimezone = { + ...props, + stripes: { + ...props.stripes, + timezone: undefined, + }, + initialValues: { + ...props.initialValues, + expirationDate: '2025-08-01T23:59:59.999Z' + } + }; + + renderEditUserInfo(propsWithoutTimezone); + + expect(dayjs.tz).toHaveBeenCalledWith('2025-08-01', 'UTC'); + expect(mockDayjsBase.tz).toHaveBeenCalledWith('UTC'); + }); + + it('should handle form field expiration date changes for willUserExtend logic', async () => { + const futureDate = '2026-01-01T23:59:59.999Z'; + const expiredInitialDate = '2023-01-01T23:59:59.999Z'; + + const mockUtcDayjsForInitial = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2023-01-01'), + }; + + const mockUtcDayjsForFuture = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2026-01-01'), + }; + + const mockExpiredEndOfDay = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(true), + isAfter: jest.fn().mockReturnValue(false), + endOf: jest.fn().mockReturnThis() + }; + + const mockFutureEndOfDay = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(false), + isAfter: jest.fn().mockReturnValue(true), + endOf: jest.fn().mockReturnThis() + }; + + const mockNowInTz = { + ...getDayjsMock(), + isBefore: jest.fn().mockReturnValue(true), + isAfter: jest.fn().mockReturnValue(false) + }; + + const mockForm = { + ...props.form, + getFieldState: jest.fn().mockReturnValue({ value: futureDate }) + }; + + const mockDayjsBase = { + ...getDayjsMock(), + tz: jest.fn().mockReturnValue(mockNowInTz) + }; + + dayjs.utc = jest.fn() + .mockImplementation((date) => { + if (date === expiredInitialDate) return mockUtcDayjsForInitial; + if (date === futureDate) return mockUtcDayjsForFuture; + return mockUtcDayjsForInitial; + }); + + dayjs.tz = jest.fn() + .mockImplementation((date) => { + if (date === '2023-01-01') return mockExpiredEndOfDay; + if (date === '2026-01-01') return mockFutureEndOfDay; + return mockExpiredEndOfDay; + }); + + dayjs.mockReturnValue(mockDayjsBase); + + const propsWithExtendingUser = { + ...props, + form: mockForm, + initialValues: { + ...props.initialValues, + expirationDate: expiredInitialDate, + } + }; + + renderEditUserInfo(propsWithExtendingUser); + + expect(screen.getByText('ui-users.information.recalculate.will.reactivate.user')).toBeInTheDocument(); + expect(mockForm.getFieldState).toHaveBeenCalledWith('expirationDate'); + }); + }); + + describe('calculateNewExpirationDate', () => { + it('should calculate from today when startCalcToday is true', async () => { + const mockCalculatedDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-08-05'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(false), + }; + + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z') + }; + + dayjs.mockImplementation(() => mockCalculatedDate); + dayjs.tz = jest.fn().mockReturnValue(mockCalculatedDate); + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + renderEditUserInfo(props); + + await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'))); + await userEvent.click(screen.getByText('ui-users.information.recalculate.modal.button')); + + expect(dayjs().tz).toHaveBeenCalledWith('America/New_York'); + expect(mockCalculatedDate.add).toHaveBeenCalledWith(730, 'd'); + expect(mockCalculatedDate.format).toHaveBeenCalledWith('YYYY-MM-DD'); + }); + + it('should calculate from existing expiration date when startCalcToday is false and date is in future', async () => { + const futureDate = '2026-01-01T23:59:59.999Z'; + + const mockExistingDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2026-01-01'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(false), + }; + + const mockNowDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-08-05'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(true), + }; + + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2026-01-01T23:59:59.999Z') + }; + + dayjs.mockImplementation(() => mockNowDate); + dayjs.tz = jest.fn() + .mockReturnValueOnce(mockExistingDate) + .mockReturnValue(mockExistingDate); + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + const propsWithFutureDate = { + ...props, + initialValues: { + ...props.initialValues, + expirationDate: futureDate, + } + }; + + renderEditUserInfo(propsWithFutureDate); + + await userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')); + + expect(dayjs.tz).toHaveBeenCalledWith(futureDate, 'America/New_York'); + expect(mockExistingDate.add).toHaveBeenCalledWith(730, 'd'); + }); + + it('should fallback to today when existing expiration date is expired', async () => { + const expiredDate = '2023-01-01T23:59:59.999Z'; + + const mockExpiredDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2023-01-01'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(true), + }; + + const mockNowDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-08-05'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(false), + }; + + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z') + }; + + dayjs.mockImplementation(() => mockNowDate); + dayjs.tz = jest.fn() + .mockReturnValueOnce(mockExpiredDate) + .mockReturnValue(mockNowDate); + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + const propsWithExpiredDate = { + ...props, + initialValues: { + ...props.initialValues, + expirationDate: expiredDate, + } + }; + + renderEditUserInfo(propsWithExpiredDate); + + await userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')); + + expect(mockNowDate.add).toHaveBeenCalledWith(730, 'd'); + }); + + it('should handle undefined expiration date by using today', async () => { + const mockNowDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-08-05'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(false), + }; + + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z') + }; + + dayjs.mockImplementation(() => mockNowDate); + dayjs.tz = jest.fn().mockReturnValue(mockNowDate); + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + const propsWithoutDate = { + ...props, + initialValues: { + ...props.initialValues, + expirationDate: undefined, + } + }; + + renderEditUserInfo(propsWithoutDate); + + await userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')); + + expect(mockNowDate.add).toHaveBeenCalledWith(730, 'd'); + }); + + it('should use tenant timezone for calculations', async () => { + const timezone = 'Asia/Tokyo'; + + const mockCalculatedDate = { + ...getDayjsMock(), + format: jest.fn().mockReturnValue('2025-08-05'), + add: jest.fn().mockReturnThis(), + tz: jest.fn().mockReturnThis(), + isSameOrBefore: jest.fn().mockReturnValue(false), + }; + + const mockUtcDayjs = { + ...getDayjsMock(), + endOf: jest.fn().mockReturnThis(), + toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z') + }; + + dayjs.mockImplementation(() => mockCalculatedDate); + dayjs.tz = jest.fn().mockReturnValue(mockCalculatedDate); + dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs); + + const timezoneProps = { + ...props, + stripes: { + ...props.stripes, + timezone, + }, + }; + + renderEditUserInfo(timezoneProps); + + userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')); + + expect(dayjs().tz).toHaveBeenCalledWith(timezone); + }); + }); }); diff --git a/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js b/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js index ab484fc18..88f0c2157 100644 --- a/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js +++ b/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js @@ -57,12 +57,15 @@ class ActionsDropdown extends React.Component { patronGroup, history, } = this.props; + const { + timezone = 'UTC', + } = stripes; const itemStatusName = loan?.item?.status?.name; const itemDetailsLink = `/inventory/view/${loan.item?.instanceId}/${loan.item?.holdingsRecordId}/${loan.itemId}`; const loanPolicyLink = `/settings/circulation/loan-policies/${loan.loanPolicyId}`; const buttonDisabled = !stripes.hasPerm('ui-users.feesfines.actions.all'); - const isUserActive = checkUserActive(user); + const isUserActive = checkUserActive(user, timezone); const isVirtualUser = isDcbUser(user); const isVirtualItem = isDcbItem(loan?.item); const isItemAbsent = !loan?.item; diff --git a/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js b/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js index bb1b74d82..24761a1dc 100644 --- a/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js +++ b/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js @@ -6,7 +6,10 @@ import { } from 'lodash'; import { useIntl } from 'react-intl'; -import { IfPermission } from '@folio/stripes/core'; +import { + IfPermission, + useStripes, +} from '@folio/stripes/core'; import { Button, Dropdown, @@ -54,9 +57,14 @@ const OpenLoansSubHeader = ({ patronGroup }) => { const intl = useIntl(); + const stripes = useStripes(); const [toggleDropdownState, setToggleDropdownState] = useState(false); + const { + timezone = 'UTC', + } = stripes; + const headers = [ 'action', 'dueDate', @@ -124,7 +132,7 @@ const OpenLoansSubHeader = ({ const countRenews = getRenewalPatronBlocksFromPatronBlocks(patronBlocks); const onlyClaimedReturnedItemsSelected = hasEveryLoanItemStatus(checkedLoans, itemStatuses.CLAIMED_RETURNED); const onlyLostyItemsSelected = hasAnyLoanItemStatus(checkedLoans, lostItemStatuses); - const isUserActive = checkUserActive(user); + const isUserActive = checkUserActive(user, timezone); const isVirtualUser = isDcbUser(user); const checkedLoansArray = Object.values(checkedLoans); diff --git a/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js b/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js index 7465cd357..3e7e84ca1 100644 --- a/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js +++ b/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js @@ -11,6 +11,7 @@ import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import { Field } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; +import { withStripes } from '@folio/stripes/core'; import { Row, Col, @@ -35,6 +36,9 @@ class ProxyEditItem extends React.Component { onDelete: PropTypes.func, change: PropTypes.func.isRequired, formValues: PropTypes.object, + stripes: PropTypes.shape({ + timezone: PropTypes.string, + }).isRequired, }; constructor() { @@ -96,11 +100,15 @@ class ProxyEditItem extends React.Component { const { index, namespace, + stripes, } = this.props; + const { + timezone = 'UTC', + } = stripes; const formValues = this.state.formValues; - this.toggleStatus(!getWarning(formValues, namespace, index)); + this.toggleStatus(!getWarning(formValues, namespace, index, timezone)); } optionsFor = (list) => { @@ -244,4 +252,4 @@ class ProxyEditItem extends React.Component { } } -export default ProxyEditItem; +export default withStripes(ProxyEditItem); diff --git a/src/components/util/getProxySponsorWarning.js b/src/components/util/getProxySponsorWarning.js index 11aeb7414..2d467ef94 100644 --- a/src/components/util/getProxySponsorWarning.js +++ b/src/components/util/getProxySponsorWarning.js @@ -1,8 +1,9 @@ import React from 'react'; -import moment from 'moment'; import { FormattedMessage } from 'react-intl'; import { get } from 'lodash'; +import { dayjs } from '@folio/stripes/components'; + /** * getProxySponsorWarning * Return a warning for the given namespace-index pair if any one of these @@ -26,24 +27,24 @@ import { get } from 'lodash'; * * @return empty string indicates no warnings; a string contains a warning message. */ -export default function getProxySponsorWarning(values, namespace, index) { +export default function getProxySponsorWarning(values, namespace, index, timezone = 'UTC') { const proxyRel = values[namespace][index] || {}; - const today = moment().endOf('day'); + const today = dayjs().tz(timezone).endOf('day'); let warning = ''; // proxy/sponsor user expired - if (get(proxyRel, 'user.expirationDate') && moment(proxyRel.user.expirationDate).isSameOrBefore(today, 'day')) { + if (get(proxyRel, 'user.expirationDate') && dayjs.tz(proxyRel.user.expirationDate, timezone).isSameOrBefore(today, 'day')) { warning = ; } // current user expired - if (values.expirationDate && moment(values.expirationDate).isSameOrBefore(today, 'day')) { + if (values.expirationDate && dayjs.tz(values.expirationDate, timezone).isSameOrBefore(today, 'day')) { warning = ; } // proxy relationship expired if (get(proxyRel, 'proxy.expirationDate') && - moment(proxyRel.proxy.expirationDate).isSameOrBefore(today, 'day')) { + dayjs.tz(proxyRel.proxy.expirationDate, timezone).isSameOrBefore(today, 'day')) { warning = ; } diff --git a/src/components/util/getProxySponsorWarning.test.js b/src/components/util/getProxySponsorWarning.test.js index def566dd8..e82dbfd3c 100644 --- a/src/components/util/getProxySponsorWarning.test.js +++ b/src/components/util/getProxySponsorWarning.test.js @@ -3,6 +3,8 @@ import getProxySponsorWarning from './getProxySponsorWarning'; describe('Proxy Sponsor component', () => { it('getProxySponsorWarning', async () => { + const timezone = 'Pacific/Fakaofo'; + const values = { proxies: [ { @@ -23,7 +25,7 @@ describe('Proxy Sponsor component', () => { expirationDate: '2022-05-30T03:22:53.897+00:00', }; - const data = getProxySponsorWarning(values, 'proxies', 0); + const data = getProxySponsorWarning(values, 'proxies', 0, timezone); expect(data).toBeTruthy(); }); }); diff --git a/src/components/util/util.js b/src/components/util/util.js index aff9c3453..f278d6c17 100644 --- a/src/components/util/util.js +++ b/src/components/util/util.js @@ -3,7 +3,10 @@ import { FormattedMessage } from 'react-intl'; import { every, orderBy } from 'lodash'; import queryString from 'query-string'; -import { NoValue } from '@folio/stripes/components'; +import { + dayjs, + NoValue, +} from '@folio/stripes/components'; import { USER_TYPES, @@ -176,11 +179,9 @@ export function getValue(value) { return value || ''; } -// Given a user record, test whether the user is active. Checking the `active` property ought to -// be sufficient, but test the expiration date as well just to be sure. -export function checkUserActive(user) { +export function checkUserActive(user = {}, timezone = 'UTC') { if (user.expirationDate == null || user.expirationDate === undefined) return user.active; - return user.active && (new Date(user.expirationDate) >= new Date()); + return user.active && (dayjs.tz(user.expirationDate, timezone).isAfter(dayjs().tz(timezone), 'day')); } export const getContributors = (account, instance) => { diff --git a/src/views/LoanDetails/LoanDetails.js b/src/views/LoanDetails/LoanDetails.js index 1ada68518..1a54b2cec 100644 --- a/src/views/LoanDetails/LoanDetails.js +++ b/src/views/LoanDetails/LoanDetails.js @@ -422,6 +422,9 @@ class LoanDetails extends React.Component { isLoading, showErrorCallout, } = this.props; + const { + timezone = 'UTC', + } = stripes; const { patronBlockedModal, @@ -528,7 +531,7 @@ class LoanDetails extends React.Component {

); const patronBlocksForModal = getRenewalPatronBlocksFromPatronBlocks(patronBlocks); - const isUserActive = user ? checkUserActive(user) : false; + const isUserActive = user ? checkUserActive(user, timezone) : false; const borrower = user ? getFullName(user) : ; const isVirtualPatron = isDcbUser(user); diff --git a/src/views/UserEdit/UserEdit.js b/src/views/UserEdit/UserEdit.js index b8c32d3ba..5809981af 100644 --- a/src/views/UserEdit/UserEdit.js +++ b/src/views/UserEdit/UserEdit.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import moment from 'moment'; import { v4 as uuidv4 } from 'uuid'; import { FormattedMessage } from 'react-intl'; @@ -12,7 +11,10 @@ import { get, } from 'lodash'; -import { LoadingView } from '@folio/stripes/components'; +import { + dayjs, + LoadingView, +} from '@folio/stripes/components'; import { CalloutContext, withOkapiKy, @@ -286,6 +288,9 @@ class UserEdit extends React.Component { resources, stripes, } = this.props; + const { + timezone = 'UTC', + } = stripes; const propertiesToOmit = ['creds', 'proxies', 'sponsors', 'permissions', 'servicePoints', 'preferredServicePoint', 'assignedRoleIds', 'initialAssignedRoleIds', 'tenantId']; const user = cloneDeep(userFormData); @@ -329,7 +334,7 @@ class UserEdit extends React.Component { } const data = omit(user, propertiesToOmit); - const today = moment().endOf('day'); + const today = dayjs().tz(timezone).endOf('day'); const curActive = user.active; const prevActive = prevUser.active; const formattedCustomFieldsPayload = this.formatCustomFieldsPayload(data.customFields); @@ -341,7 +346,7 @@ class UserEdit extends React.Component { if (curActive !== prevActive || !user.expirationDate) { data.active = curActive; } else { - data.active = (moment(user.expirationDate).endOf('day').isSameOrAfter(today)); + data.active = dayjs.tz(user.expirationDate, timezone).endOf('day').isSameOrAfter(today); } this.updateUserData(data, user); diff --git a/src/views/UserEdit/UserForm.js b/src/views/UserEdit/UserForm.js index 271d091c2..afaef7476 100644 --- a/src/views/UserEdit/UserForm.js +++ b/src/views/UserEdit/UserForm.js @@ -357,6 +357,10 @@ class UserForm extends React.Component { isCreateKeycloakUserConfirmationOpen, onCancelKeycloakConfirmation } = this.props; + const { + timezone = 'UTC', + } = stripes; + const isEditing = !!initialValues.id; const selectedPatronGroup = form.getFieldState('patronGroup')?.value; const firstMenu = this.getAddFirstMenu(); @@ -527,7 +531,7 @@ class UserForm extends React.Component { ['sponsors', 'proxies'].forEach(namespace => { if (values[namespace]) { values[namespace].forEach((_, index) => { - const warning = getProxySponsorWarning(values, namespace, index); + const warning = getProxySponsorWarning(values, namespace, index, timezone); if (warning) { mutators.setFieldData(`${namespace}[${index}].proxy.status`, { warning });