diff --git a/CHANGELOG.md b/CHANGELOG.md index cb61f9d..204c514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [2.0.0-alpha.6](https://github.com/dhis2/multi-calendar-dates/compare/v2.0.0-alpha.5...v2.0.0-alpha.6) (2024-11-21) + + +### Bug Fixes + +* check first week of subsequent year when geting period by date [LIBS-688] ([#74](https://github.com/dhis2/multi-calendar-dates/issues/74)) ([8662fe0](https://github.com/dhis2/multi-calendar-dates/commit/8662fe0e4f263c1abde1b813097e30b9b65ee31e)) + ## [1.3.2](https://github.com/dhis2/multi-calendar-dates/compare/v1.3.1...v1.3.2) (2024-10-09) diff --git a/package.json b/package.json index 90f5259..84b6dd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/multi-calendar-dates", - "version": "1.3.2", + "version": "2.0.0-alpha.6", "license": "BSD-3-Clause", "publishConfig": { "access": "public" diff --git a/src/custom-calendars/nepaliCalendar.ts b/src/custom-calendars/nepaliCalendar.ts index 92d5ac0..fa65533 100644 --- a/src/custom-calendars/nepaliCalendar.ts +++ b/src/custom-calendars/nepaliCalendar.ts @@ -64,12 +64,18 @@ class NepaliCalendar extends Temporal.Calendar { * * A custom implementation of these methods is used to convert the calendar-space arguments to the ISO calendar. */ - dateFromFields(fields: CalendarYMD): Temporal.PlainDate { - const { year, day, month } = _nepaliToIso({ - year: fields.year, - month: fields.month, - day: fields.day, - }) + dateFromFields( + fields: CalendarYMD, + options: Temporal.AssignmentOptions + ): Temporal.PlainDate { + const { year, day, month } = _nepaliToIso( + { + year: fields.year, + month: fields.month, + day: fields.day, + }, + options + ) return new Temporal.PlainDate(year, month, day, this) } yearMonthFromFields(fields: CalendarYMD): Temporal.PlainYearMonth { @@ -96,7 +102,10 @@ const lastSupportedNepaliYear = Number( supportedNepaliYears[supportedNepaliYears.length - 1] ) -const _nepaliToIso = (fields: { day: number; year: number; month: number }) => { +const _nepaliToIso = ( + fields: { day: number; year: number; month: number }, + { overflow }: Temporal.AssignmentOptions = {} +) => { let { year: nepaliYear } = fields if ( @@ -109,6 +118,15 @@ const _nepaliToIso = (fields: { day: number; year: number; month: number }) => { } const { month: nepaliMonth, day: nepaliDay = 1 } = fields + if ( + overflow === 'reject' && + (nepaliMonth < 1 || + nepaliMonth > 12 || + nepaliDay > NEPALI_CALENDAR_DATA[nepaliYear][nepaliMonth]) + ) { + throw new Error('Invalid date in Nepali calendar') + } + let gregorianDayOfYear = 0 let monthCounter = nepaliMonth diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 88149f4..39d6af9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useResolvedDirection } from './useResolvedDirection' export { useDatePicker } from './useDatePicker' +export type { DatePickerOptions, OnDateSelectPayload } from './useDatePicker' diff --git a/src/hooks/useDatePicker.spec.tsx b/src/hooks/useDatePicker.spec.tsx index 5ef7eb3..a02869c 100644 --- a/src/hooks/useDatePicker.spec.tsx +++ b/src/hooks/useDatePicker.spec.tsx @@ -3,6 +3,7 @@ import { render } from '@testing-library/react' import { renderHook } from '@testing-library/react-hooks' import React from 'react' import { SupportedCalendar } from '../types' +import { convertToIso8601 } from '../utils' import localisationHelpers from '../utils/localisationHelpers' import { useDatePicker, UseDatePickerReturn } from './useDatePicker' @@ -270,16 +271,16 @@ describe('useDatePicker hook', () => { }) }) describe('highlighting today', () => { - const getDayByDate: ( - calendarWeekDays: { calendarDate: string; isToday: boolean }[][], + const getDayByDate = ( + calendarWeekDays: { + isToday: boolean + dateValue: string + }[][], dayToFind: string - ) => { calendarDate: string; isToday: boolean }[] = ( - calendarWeekDays, - dayToFind ) => { const days = calendarWeekDays.flatMap((week) => week) - return days.filter((day) => day.calendarDate === dayToFind) + return days.filter((day) => day.dateValue === dayToFind) } it('should highlight today date in a an ethiopic calendar', () => { @@ -485,7 +486,8 @@ describe('clicking a day', () => { // find and click the day passed to the calendar for (let i = 0; i < days.length; i++) { - if (days[i].calendarDate === date) { + const formattedDate = days[i].dateValue + if (formattedDate === date) { days[i].onClick() break } @@ -496,45 +498,36 @@ describe('clicking a day', () => { } it('should call the callback with correct info for Gregorian calendar', () => { const date = '2018-01-22' - const { calendarDate, calendarDateString } = renderForClick({ + const { calendarDateString } = renderForClick({ calendar: 'gregory', date, }) - expect(calendarDate.toString()).toEqual( - '2018-01-22T00:00:00+02:00[Africa/Khartoum][u-ca=gregory]' - ) expect(calendarDateString).toEqual('2018-01-22') }) it('should call the callback with correct info for Ethiopic calendar', () => { const date = '2015-13-02' - const { calendarDate, calendarDateString } = renderForClick({ + const { calendarDateString } = renderForClick({ calendar: 'ethiopic', date, }) expect(calendarDateString).toEqual('2015-13-02') - expect( - calendarDate.withCalendar('iso8601').toLocaleString('en-GB') - ).toMatch('07/09/2023') - - expect( - calendarDate.toLocaleString('en-GB', { - month: 'long', - year: 'numeric', - day: 'numeric', - calendar: 'ethiopic', - }) - ).toEqual('2 Pagumen 2015 ERA1') + const calendarDate = convertToIso8601(calendarDateString, 'ethiopic') + expect(calendarDate).toEqual({ day: 7, month: 9, year: 2023 }) }) it('should call the callback with correct info for a custom (Nepali) calendar', () => { const date = '2077-12-30' - const { calendarDate, calendarDateString } = renderForClick({ + const { calendarDateString } = renderForClick({ calendar: 'nepali', date, }) expect(calendarDateString).toEqual('2077-12-30') expect( localisationHelpers.localiseMonth( - calendarDate, + { + year: 20777, + month: 12, + day: 30, + }, { locale: 'en-NP', calendar: 'nepali', diff --git a/src/hooks/useDatePicker.ts b/src/hooks/useDatePicker.ts index 2f26ab8..3124194 100644 --- a/src/hooks/useDatePicker.ts +++ b/src/hooks/useDatePicker.ts @@ -17,16 +17,14 @@ import { import { useResolvedLocaleOptions } from './internal/useResolvedLocaleOptions' import { useWeekDayLabels } from './internal/useWeekDayLabels' -type DatePickerOptions = { +export type OnDateSelectPayload = { + calendarDateString: string +} | null + +export type DatePickerOptions = { date: string options: PickerOptions - onDateSelect: ({ - calendarDate, - calendarDateString, - }: { - calendarDate: Temporal.ZonedDateTime - calendarDateString: string - }) => void + onDateSelect: (payload: OnDateSelectPayload) => void minDate?: string maxDate?: string format?: 'YYYY-MM-DD' | 'DD-MM-YYYY' @@ -36,26 +34,18 @@ type DatePickerOptions = { export type UseDatePickerReturn = UseNavigationReturnType & { weekDayLabels: string[] calendarWeekDays: { - zdt: Temporal.ZonedDateTime + dateValue: string label: string | number - calendarDate: string onClick: () => void isSelected: boolean | undefined isToday: boolean isInCurrentMonth: boolean }[][] - isValid: boolean - warningMessage: string - errorMessage: string } - type UseDatePickerHookType = (options: DatePickerOptions) => UseDatePickerReturn type ValidatedDate = Temporal.YearOrEraAndEraYear & Temporal.MonthOrMonthCode & { day: number - isValid: boolean - warningMessage: string - errorMessage: string format?: string } @@ -158,7 +148,6 @@ export const useDatePicker: UseDatePickerHookType = ({ const selectDate = useCallback( (zdt: Temporal.ZonedDateTime) => { onDateSelect({ - calendarDate: zdt, calendarDateString: formatDate(zdt, undefined, date.format), }) }, @@ -196,11 +185,10 @@ export const useDatePicker: UseDatePickerHookType = ({ temporalCalendar, temporalTimeZone, ]) - return { + const result: UseDatePickerReturn = { calendarWeekDays: calendarWeekDaysZdts.map((week) => week.map((weekDayZdt) => ({ - zdt: weekDayZdt, - calendarDate: formatDate(weekDayZdt, undefined, date.format), + dateValue: formatDate(weekDayZdt, undefined, format), label: localisationHelpers.localiseWeekLabel( weekDayZdt.withCalendar(localeOptions.calendar), localeOptions @@ -219,8 +207,7 @@ export const useDatePicker: UseDatePickerHookType = ({ ), ...navigation, weekDayLabels, - isValid: date.isValid, - warningMessage: date.warningMessage, - errorMessage: date.errorMessage, } + + return result } diff --git a/src/index.ts b/src/index.ts index 3a11700..719a7b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './period-calculation' export { getNowInCalendar, validateDateString, + DateValidationResult, convertFromIso8601, convertToIso8601, } from './utils' diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 946dd70..dfa74a1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -14,9 +14,6 @@ export const padWithZeroes = (number: number, count = 2) => type DayType = 'endOfMonth' | 'startOfMonth' type customDate = Temporal.PlainDateLike & { - isValid: boolean - warningMessage?: string - errorMessage?: string format?: string } @@ -77,20 +74,16 @@ export const extractAndValidateDateString = ( strictValidation?: boolean format?: 'YYYY-MM-DD' | 'DD-MM-YYYY' } -): Temporal.PlainDateLike & { - isValid: boolean - warningMessage?: string - errorMessage?: string -} => { +): Temporal.PlainDateLike => { if (!date) { return getCurrentDateResult(options) } const validation = validateDateString(date, options) - if (validation.isValid) { - return getValidDateResult(date, validation, options) + if (!validation.error) { + return getValidDateResult(date, options) } else { - return getInvalidDateResult(options, validation.errorMessage) + return getInvalidDateResult(options) } } @@ -102,24 +95,13 @@ const getCurrentDateResult = (options: PickerOptions) => { return { year, month, day, isValid: true } } -const getValidDateResult = ( - date: string, - validation: { - isValid: boolean - warningMessage?: string - errorMessage?: string - }, - options: PickerOptions -) => { +const getValidDateResult = (date: string, options: PickerOptions) => { const { year, month, day, format } = extractDatePartsFromDateString(date) let result: customDate = { year, month, day, format, - isValid: validation.isValid, - warningMessage: validation.warningMessage, - errorMessage: validation.errorMessage, } if (options.calendar === 'ethiopic') { @@ -129,15 +111,12 @@ const getValidDateResult = ( return result } -const getInvalidDateResult = ( - options: PickerOptions, - errorMessage?: string -) => { +const getInvalidDateResult = (options: PickerOptions) => { const { year, month, day } = getNowInCalendar( options.calendar, options.timeZone ) - return { year, month, day, isValid: false, errorMessage } + return { year, month, day } } const adjustForEthiopicCalendar = (result: customDate) => { diff --git a/src/utils/index.ts b/src/utils/index.ts index d5d1036..dbb2769 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,9 @@ export { default as fromAnyDate } from './from-any-date' export { default as getNowInCalendar } from './getNowInCalendar' export * from './helpers' export { default as localisationHelpers } from './localisationHelpers' -export { validateDateString } from './validate-date-string' +export { + validateDateString, + DateValidationResult, +} from './validate-date-string' export { convertFromIso8601, convertToIso8601 } from './convert-date' diff --git a/src/utils/localisationHelpers.ts b/src/utils/localisationHelpers.ts index da44e16..1157dc2 100644 --- a/src/utils/localisationHelpers.ts +++ b/src/utils/localisationHelpers.ts @@ -113,7 +113,11 @@ const localiseWeekLabel = ( } const localiseMonth = ( - zdt: Temporal.ZonedDateTime | Temporal.PlainYearMonth | Temporal.PlainDate, + zdt: + | Temporal.ZonedDateTime + | Temporal.PlainYearMonth + | Temporal.PlainDate + | Temporal.PlainDateLike, localeOptions: PickerOptions, format: Intl.DateTimeFormatOptions ) => { @@ -127,7 +131,7 @@ const localiseMonth = ( ) return isCustom - ? customLocale?.monthNames[zdt.month - 1] + ? customLocale?.monthNames[zdt.month! - 1] : zdt.toLocaleString(localeOptions.locale, format) } diff --git a/src/utils/validate-date-string.spec.ts b/src/utils/validate-date-string.spec.ts index 906c4d9..1763682 100644 --- a/src/utils/validate-date-string.spec.ts +++ b/src/utils/validate-date-string.spec.ts @@ -4,40 +4,40 @@ describe('validateDateString', () => { it('should return an error for an empty date string', () => { const date = '' const validation = validateDateString(date) - expect(validation.errorMessage).toBe('Date is not given') - expect(validation.isValid).toBe(false) + expect(validation.validationText).toBe('Date is not given') + expect(validation.error).toBe(true) }) it('should return an error for incorrect date format with missing parts', () => { const date = '2015-' const validation = validateDateString(date) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date string is invalid, received "2015-"' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should return an error for incorrect date format', () => { const date = '2015-11-2015' const validation = validateDateString(date) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date string is invalid, received "2015-11-2015"' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should validate dd-mm-yyyy is a correct date format', () => { const date = '07-07-2020' const validation = validateDateString(date) - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) }) it('should validate yyyy-mm-dd is a correct date format', () => { const date = '2020-07-07' const validation = validateDateString(date) - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) }) it('should validate min date for yyyy-mm-dd format', () => { @@ -46,10 +46,10 @@ describe('validateDateString', () => { const options = { minDateString: minDate } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date 2015-06-27 is less than the minimum allowed date 2015-06-28.' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should validate max date for yyyy-mm-dd format', () => { @@ -57,10 +57,10 @@ describe('validateDateString', () => { const maxDate = '2018-06-28' const options = { maxDateString: maxDate } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date 2018-06-29 is greater than the maximum allowed date 2018-06-28.' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should validate min date for dd-mm-yyyy format', () => { @@ -69,10 +69,10 @@ describe('validateDateString', () => { const options = { minDateString: minDate } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date 27-06-2015 is less than the minimum allowed date 28-06-2015.' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should validate max date for dd-mm-yyyy format', () => { @@ -80,10 +80,10 @@ describe('validateDateString', () => { const maxDate = '28-06-2018' const options = { maxDateString: maxDate } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date 29-06-2018 is greater than the maximum allowed date 28-06-2018.' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should validate min date for mixed formats', () => { @@ -92,10 +92,10 @@ describe('validateDateString', () => { const options = { minDateString: minDate } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date 28-06-2018 is less than the minimum allowed date 2018-06-29.' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('should validate max date for mixed formats', () => { @@ -104,10 +104,10 @@ describe('validateDateString', () => { const options = { maxDateString: maxDate } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe( + expect(validation.validationText).toBe( 'Date 29-06-2018 is greater than the maximum allowed date 2018-06-28.' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) it('Should give a warning when date is less than the min date and strictValidation is set to false', () => { @@ -116,11 +116,10 @@ describe('validateDateString', () => { const options = { minDateString: minDate, strictValidation: false } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe('') - expect(validation.warningMessage).toBe( + expect(validation.validationText).toBe( 'Date 27-06-2015 is less than the minimum allowed date 28-06-2015.' ) - expect(validation.isValid).toBe(true) + expect(validation.warning).toBe(true) }) it('Should give a warning when date is greater than the max date and strictValidation is set to false"', () => { @@ -129,70 +128,72 @@ describe('validateDateString', () => { const options = { maxDateString: maxDate, strictValidation: false } const validation = validateDateString(date, options) - expect(validation.errorMessage).toBe('') - expect(validation.warningMessage).toBe( + expect(validation.validationText).toBe( 'Date 27-06-2015 is greater than the maximum allowed date 26-06-2015.' ) - expect(validation.isValid).toBe(true) + expect(validation.warning).toBe(true) }) }) describe('validateDateString (gregory)', () => { it('should return valid for a date with dashes as delimiter', () => { const validation = validateDateString('2024-02-02') - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) + expect(validation.warning).toBe(false) }) it('should return valid for a date with dashes as slashes', () => { const validation = validateDateString('2024/02/02') - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) + expect(validation.warning).toBe(false) }) it('should return valid for a date with dashes as dots', () => { const validation = validateDateString('2024.02.02') - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) + expect(validation.warning).toBe(false) }) it('should return an error message for a date with mixed delimiters', () => { - expect(validateDateString('2024/02.02').errorMessage).toBe( + expect(validateDateString('2024/02.02').validationText).toBe( 'Date string is invalid, received "2024/02.02"' ) }) it('should return an error message for a date missing year digits', () => { - expect(validateDateString('200.02.02').errorMessage).toBe( + expect(validateDateString('200.02.02').validationText).toBe( 'Date string is invalid, received "200.02.02"' ) }) it('should return an error message for a date missing month digits', () => { - expect(validateDateString('2000.2.02').errorMessage).toBe( + expect(validateDateString('2000.2.02').validationText).toBe( 'Date string is invalid, received "2000.2.02"' ) }) it('should return an error message for a date missing day digits', () => { - expect(validateDateString('2000.02.2').errorMessage).toBe( + expect(validateDateString('2000.02.2').validationText).toBe( 'Date string is invalid, received "2000.02.2"' ) }) it('should return an error message when the value is out of range', () => { - expect(validateDateString('2025-12-32').errorMessage).toBe( - 'value out of range: 1 <= 32 <= 31' + expect(validateDateString('2025-12-32').validationText).toBe( + 'Invalid date in specified calendar' ) }) it('should return an error for non-leap year February 29', () => { const date = '2019-02-29' const validation = validateDateString(date) - expect(validation.errorMessage).toBe( - 'value out of range: 1 <= 29 <= 28' + expect(validation.validationText).toBe( + 'Invalid date in specified calendar' ) - expect(validation.isValid).toBe(false) + expect(validation.error).toBe(true) }) }) @@ -201,124 +202,130 @@ describe('validateDateString (ethiopic)', () => { const validation = validateDateString('2015-13-06', { calendar: 'ethiopic', }) - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) + expect(validation.warning).toBe(false) }) it('should return valid for a date with dashes as slashes', () => { const validation = validateDateString('2015-13-06', { calendar: 'ethiopic', }) - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) + expect(validation.warning).toBe(false) }) it('should return valid for a date with dashes as dots', () => { const validation = validateDateString('2015-13-06', { calendar: 'ethiopic', }) - expect(validation.errorMessage).toBe('') - expect(validation.isValid).toBe(true) + expect(validation.validationText).toBeUndefined() + expect(validation.error).toBe(false) + expect(validation.warning).toBe(false) }) it('should return an error message for a date with mixed delimiters', () => { expect( validateDateString('2015.13/06', { calendar: 'ethiopic' }) - .errorMessage + .validationText ).toBe('Date string is invalid, received "2015.13/06"') }) it('should return an error message for a date missing year digits', () => { expect( validateDateString('201.13/06', { calendar: 'ethiopic' }) - .errorMessage + .validationText ).toBe('Date string is invalid, received "201.13/06"') }) it('should return an error message for a date missing month digits', () => { expect( validateDateString('201.1/06', { calendar: 'ethiopic' }) - .errorMessage + .validationText ).toBe('Date string is invalid, received "201.1/06"') }) it('should return an error message for a date missing day digits', () => { expect( validateDateString('2015.13/6', { calendar: 'ethiopic' }) - .errorMessage + .validationText ).toBe('Date string is invalid, received "2015.13/6"') }) it('should return an error message when the value is out of range', () => { expect( validateDateString('2015-14-01', { calendar: 'ethiopic' }) - .errorMessage - ).toBe('value out of range: 1 <= 14 <= 13') + .validationText + ).toBe('Invalid date in specified calendar') }) }) describe('validateDateString (nepali)', () => { it('should return valid for a date with dashes as delimiter', () => { expect( - validateDateString('2080-10-29', { calendar: 'nepali' }).isValid - ).toBe(true) + validateDateString('2080-10-29', { calendar: 'nepali' }).error + ).toBe(false) }) it('should return valid for a date with dashes as slashes', () => { expect( - validateDateString('2080/10/29', { calendar: 'nepali' }).isValid - ).toBe(true) + validateDateString('2080/10/29', { calendar: 'nepali' }).error + ).toBe(false) }) it('should return valid for a date with dashes as dots', () => { expect( - validateDateString('2080.10.29', { calendar: 'nepali' }).isValid - ).toBe(true) + validateDateString('2080.10.29', { calendar: 'nepali' }).error + ).toBe(false) }) it('should return an error message for a date with mixed delimiters', () => { expect( validateDateString('2080.10/29', { calendar: 'nepali' }) - .errorMessage + .validationText ).toBe('Date string is invalid, received "2080.10/29"') }) it('should return an error message for a date missing year digits', () => { expect( - validateDateString('280.10.29', { calendar: 'nepali' }).errorMessage + validateDateString('280.10.29', { calendar: 'nepali' }) + .validationText ).toBe('Date string is invalid, received "280.10.29"') }) it('should return an error message for a date missing month digits', () => { expect( - validateDateString('2080.1.29', { calendar: 'nepali' }).errorMessage + validateDateString('2080.1.29', { calendar: 'nepali' }) + .validationText ).toBe('Date string is invalid, received "2080.1.29"') }) it('should return an error message for a date missing day digits', () => { expect( - validateDateString('2080.10.9', { calendar: 'nepali' }).errorMessage + validateDateString('2080.10.9', { calendar: 'nepali' }) + .validationText ).toBe('Date string is invalid, received "2080.10.9"') }) it('should return an error message when day is out of range', () => { expect( validateDateString('2080.04.33', { calendar: 'nepali' }) - .errorMessage - ).toBe('Day 33 is out of range | 1 <= 33 <= 32.') + .validationText + ).toBe('Invalid date in specified calendar') }) it('should return an error message when month is out of range', () => { expect( validateDateString('2080.13.33', { calendar: 'nepali' }) - .errorMessage - ).toBe('Month 13 is out of range | 1 <= 13 <= 12.') + .validationText + ).toBe('Invalid date in specified calendar') }) it('should return an error message when year is out of supported range', () => { expect( validateDateString('2101.04.33', { calendar: 'nepali' }) - .errorMessage - ).toBe('Year 2101 is out of range.') + .validationText + ).toBe('Invalid date in specified calendar') }) }) diff --git a/src/utils/validate-date-string.ts b/src/utils/validate-date-string.ts index aead27a..8105192 100644 --- a/src/utils/validate-date-string.ts +++ b/src/utils/validate-date-string.ts @@ -1,159 +1,166 @@ import i18n from '@dhis2/d2-i18n' import { Temporal } from '@js-temporal/polyfill' import { dhis2CalendarsMap } from '../constants/dhis2CalendarsMap' -import { NEPALI_CALENDAR_DATA } from '../custom-calendars/nepaliCalendarData' import type { SupportedCalendar } from '../types' import { extractDatePartsFromDateString } from './extract-date-parts-from-date-string' -import { getCustomCalendarIfExists } from './helpers' +import { getCustomCalendarIfExists, isCustomCalendar } from './helpers' -function validateNepaliDate(year: number, month: number, day: number) { - const nepaliYearData = NEPALI_CALENDAR_DATA[year] - if (!nepaliYearData) { - return { - isValid: false, - errorMessage: i18n.t(`Year {{year}} is out of range.`, { year }), - } - } - - if (month < 1 || month > 12) { - return { - isValid: false, - errorMessage: i18n.t( - `Month {{month}} is out of range | 1 <= {{month}} <= 12.`, - { month } - ), - } - } - - const daysInMonth = nepaliYearData[month] - - if (day < 1 || day > daysInMonth) { - return { - isValid: false, - errorMessage: i18n.t( - `Day {{day}} is out of range | 1 <= {{day}} <= {{daysInMonth}}.`, - { day, daysInMonth } - ), - } - } +type ValidationOptions = { + calendar?: SupportedCalendar + minDateString?: string + maxDateString?: string + strictValidation?: boolean + format?: 'YYYY-MM-DD' | 'DD-MM-YYYY' +} - return { - isValid: true, - errorMessage: '', - } +export enum DateValidationResult { + INVALID_DATE_IN_CALENDAR = 'INVALID_DATE_IN_CALENDAR', + WRONG_FORMAT = 'WRONG_FORMAT', + LESS_THAN_MIN = 'LESS_THAN_MIN', + MORE_THAN_MAX = 'INVALID_DATE_MORE_THAN_MAX', } -export function validateDateString( +type ValidationResult = + | { + valid: boolean + error: boolean + warning?: never + validationText: string + validationCode: DateValidationResult + } + | { + valid: boolean + warning: boolean + error?: never + validationText: string + validationCode: DateValidationResult + } + | { + valid: true + error: false + warning: false + validationText: undefined + validationCode: undefined + } + +type ValidateDateStringFn = ( dateString: string, - { + options?: ValidationOptions +) => ValidationResult + +export const validateDateString: ValidateDateStringFn = ( + dateString, + options = {} +) => { + const { calendar = 'gregory', minDateString, maxDateString, strictValidation = true, format, - }: { - calendar?: SupportedCalendar - minDateString?: string - maxDateString?: string - strictValidation?: boolean - format?: 'YYYY-MM-DD' | 'DD-MM-YYYY' - } = {} -): { - isValid: boolean - errorMessage?: string - warningMessage?: string - year?: number - month?: number - day?: number -} { + } = options const resolvedCalendar = getCustomCalendarIfExists( dhis2CalendarsMap[calendar] ?? calendar ) + // Will throw if the format of the date is incorrect + if (!dateString) { + return { + valid: false, + error: true, + validationCode: DateValidationResult.WRONG_FORMAT, + validationText: i18n.t(`Date is not given`), + } + } + + let dateParts: { + year: number + month: number + day: number + format: string + } + try { - // Will throw if the format of the date is incorrect - if (!dateString) { - throw new Error(i18n.t(`Date is not given`)) + dateParts = extractDatePartsFromDateString(dateString, format) + } catch (e: any) { + return { + valid: false, + error: true, + validationCode: DateValidationResult.WRONG_FORMAT, + validationText: e?.message, } - const dateParts = extractDatePartsFromDateString(dateString, format) + } - if (resolvedCalendar.toString() === 'nepali') { - const { isValid, errorMessage } = validateNepaliDate( - dateParts.year, - dateParts.month, - dateParts.day - ) + let date: Temporal.PlainDate - if (!isValid) { - throw new Error(errorMessage) - } + // Will throw if the year, month or day is out of range + try { + date = isCustomCalendar(resolvedCalendar) + ? Temporal.Calendar.from(resolvedCalendar).dateFromFields( + dateParts, + { overflow: 'reject' } + ) // need to be handled separately for custom calendars + : Temporal.PlainDate.from( + { ...dateParts, calendar: resolvedCalendar }, + { overflow: 'reject' } + ) + } catch (err) { + return { + valid: false, + error: true, + validationCode: DateValidationResult.INVALID_DATE_IN_CALENDAR, + validationText: i18n.t('Invalid date in specified calendar'), } + } + + const validationType = strictValidation + ? { error: true, valid: false } + : { warning: true, valid: true } + + if (minDateString) { + const minDateParts = extractDatePartsFromDateString(minDateString) + const minDate = Temporal.PlainDate.from({ + ...minDateParts, + calendar: resolvedCalendar, + }) - // Will throw if the year, month or day is out of range - const date = Temporal.PlainDate.from( - { ...dateParts, calendar: resolvedCalendar }, - { overflow: 'reject' } - ) - - let warningMessage = '' - - if (minDateString) { - const minDateParts = extractDatePartsFromDateString(minDateString) - const minDate = Temporal.PlainDate.from({ - ...minDateParts, - calendar: resolvedCalendar, - }) - - if (Temporal.PlainDate.compare(date, minDate) < 0) { - if (strictValidation) { - throw new Error( - i18n.t( - `Date {{dateString}} is less than the minimum allowed date {{minDateString}}.`, - { dateString, minDateString } - ) - ) - } else { - warningMessage = i18n.t( - `Date {{dateString}} is less than the minimum allowed date {{minDateString}}.`, - { dateString, minDateString } - ) - } + if (Temporal.PlainDate.compare(date, minDate) < 0) { + const result: ValidationResult = { + ...validationType, + validationCode: DateValidationResult.LESS_THAN_MIN, + validationText: i18n.t( + `Date {{dateString}} is less than the minimum allowed date {{minDateString}}.`, + { dateString, minDateString } + ), } + + return result } + } + + if (maxDateString) { + const maxDateParts = extractDatePartsFromDateString(maxDateString) + const maxDate = Temporal.PlainDate.from({ + ...maxDateParts, + calendar: resolvedCalendar, + }) - if (maxDateString) { - const maxDateParts = extractDatePartsFromDateString(maxDateString) - const maxDate = Temporal.PlainDate.from({ - ...maxDateParts, - calendar: resolvedCalendar, - }) - - if (Temporal.PlainDate.compare(date, maxDate) > 0) { - if (strictValidation) { - throw new Error( - i18n.t( - `Date {{dateString}} is greater than the maximum allowed date {{maxDateString}}.`, - { dateString, maxDateString } - ) - ) - } else { - warningMessage = i18n.t( - `Date {{dateString}} is greater than the maximum allowed date {{maxDateString}}.`, - { dateString, maxDateString } - ) - } + if (Temporal.PlainDate.compare(date, maxDate) > 0) { + const result: ValidationResult = { + ...validationType, + validationCode: DateValidationResult.MORE_THAN_MAX, + validationText: i18n.t( + `Date {{dateString}} is greater than the maximum allowed date {{maxDateString}}.`, + { dateString, maxDateString } + ), } + + return result } - return { - isValid: true, - errorMessage: '', - warningMessage, - } - } catch (e) { - return { - isValid: false, - errorMessage: (e as Error).message, - warningMessage: '', - } + } + return { + valid: true, + error: false, + warning: false, } }