From febdc4b0c74195b6300956a8867c5d8ddb289048 Mon Sep 17 00:00:00 2001 From: Tommy Petty Date: Thu, 25 Jan 2024 13:33:37 -0500 Subject: [PATCH] bug(core): fix issue with date formatting in validation message (#5551) * bug(i18n): fix issue with date formatting in validation message * bug(i18n): better fix with tests * bug(i18n): fix date drift on validation and date validator tests * fix(core): fix type issues and add tests for datetime input (#5558) * fix(core): update date validator tests to use strings * chore(core): use centrally defined date constants --------- Co-authored-by: Binoy Patel --- packages/@sanity/util/src/legacyDateFormat.ts | 7 +- .../datetime/preview/DatetimePreview.tsx | 7 +- .../core/form/inputs/DateInputs/DateInput.tsx | 11 +- .../form/inputs/DateInputs/DateTimeInput.tsx | 10 +- .../validation/validators/dateValidator.ts | 45 ++++---- .../__snapshots__/dates.test.ts.snap | 105 ++++++++++++++++++ packages/sanity/test/validation/dates.test.ts | 102 +++++++++++++++++ 7 files changed, 245 insertions(+), 42 deletions(-) create mode 100644 packages/sanity/test/validation/__snapshots__/dates.test.ts.snap create mode 100644 packages/sanity/test/validation/dates.test.ts diff --git a/packages/@sanity/util/src/legacyDateFormat.ts b/packages/@sanity/util/src/legacyDateFormat.ts index d6f00ee232e..ec19091122a 100644 --- a/packages/@sanity/util/src/legacyDateFormat.ts +++ b/packages/@sanity/util/src/legacyDateFormat.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/no-shadow */ import moment from 'moment' +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD' +export const DEFAULT_TIME_FORMAT = 'HH:mm' + export type ParseResult = {isValid: boolean; date?: Date; error?: string} & ( | {isValid: true; date: Date} | {isValid: false; error?: string} @@ -9,7 +12,9 @@ export type ParseResult = {isValid: boolean; date?: Date; error?: string} & ( // todo: find a way to get rid of moment there. // note: the format comes form peoples schemas, so we need to deprecate it for a while and // find a way to tell people that they need to change it -export function format(input: Date, format: string) { +export function format(input: Date, format: string, useUTC = false) { + if (useUTC) return moment.utc(input).format(format) + return moment(input).format(format) } diff --git a/packages/sanity/src/core/field/types/datetime/preview/DatetimePreview.tsx b/packages/sanity/src/core/field/types/datetime/preview/DatetimePreview.tsx index ea075af38ff..9f3a8b2440c 100644 --- a/packages/sanity/src/core/field/types/datetime/preview/DatetimePreview.tsx +++ b/packages/sanity/src/core/field/types/datetime/preview/DatetimePreview.tsx @@ -5,9 +5,6 @@ import React from 'react' import styled from 'styled-components' import {FieldPreviewComponent} from '../../../preview' -const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD' -const DEFAULT_TIME_FORMAT = 'HH:mm' - const DatetimeWrapper = styled.div` display: inline-block; word-wrap: break-word; @@ -26,8 +23,8 @@ export const DatetimePreview: FieldPreviewComponent = function DatetimeP function formatDateTime(value: string, schemaType: StringSchemaType): string { const {options, name} = schemaType - const dateFormat = options?.dateFormat || DEFAULT_DATE_FORMAT - const timeFormat = options?.timeFormat || DEFAULT_TIME_FORMAT + const dateFormat = options?.dateFormat || legacyDateFormat.DEFAULT_DATE_FORMAT + const timeFormat = options?.timeFormat || legacyDateFormat.DEFAULT_TIME_FORMAT return legacyDateFormat.format( new Date(value), diff --git a/packages/sanity/src/core/form/inputs/DateInputs/DateInput.tsx b/packages/sanity/src/core/form/inputs/DateInputs/DateInput.tsx index c958ec716f4..6cf31a29af5 100644 --- a/packages/sanity/src/core/form/inputs/DateInputs/DateInput.tsx +++ b/packages/sanity/src/core/form/inputs/DateInputs/DateInput.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo} from 'react' -import {format, parse} from '@sanity/util/legacyDateFormat' +import {format, parse, DEFAULT_DATE_FORMAT} from '@sanity/util/legacyDateFormat' import {set, unset} from '../../patch' import {StringInputProps} from '../../types' import {useTranslation} from '../../../i18n' @@ -12,13 +12,8 @@ import {getCalendarLabels} from './utils' * @beta */ export type DateInputProps = StringInputProps -// This is the format dates are stored on -const VALUE_FORMAT = 'YYYY-MM-DD' -// default to how they are stored -const DEFAULT_DATE_FORMAT = VALUE_FORMAT - -const deserialize = (value: string) => parse(value, VALUE_FORMAT) -const serialize = (date: Date) => format(date, VALUE_FORMAT) +const deserialize = (value: string) => parse(value, DEFAULT_DATE_FORMAT) +const serialize = (date: Date) => format(date, DEFAULT_DATE_FORMAT) /** * @hidden diff --git a/packages/sanity/src/core/form/inputs/DateInputs/DateTimeInput.tsx b/packages/sanity/src/core/form/inputs/DateInputs/DateTimeInput.tsx index 347f6af1c55..50b2f8e1fb0 100644 --- a/packages/sanity/src/core/form/inputs/DateInputs/DateTimeInput.tsx +++ b/packages/sanity/src/core/form/inputs/DateInputs/DateTimeInput.tsx @@ -1,4 +1,9 @@ -import {format, parse} from '@sanity/util/legacyDateFormat' +import { + format, + parse, + DEFAULT_DATE_FORMAT, + DEFAULT_TIME_FORMAT, +} from '@sanity/util/legacyDateFormat' import {getMinutes, setMinutes, parseISO} from 'date-fns' import React, {useCallback, useMemo} from 'react' import {set, unset} from '../../patch' @@ -26,9 +31,6 @@ interface SchemaOptions { * @beta */ export type DateTimeInputProps = StringInputProps -const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD' -const DEFAULT_TIME_FORMAT = 'HH:mm' - function parseOptions(options: SchemaOptions = {}): ParsedOptions { return { dateFormat: options.dateFormat || DEFAULT_DATE_FORMAT, diff --git a/packages/sanity/src/core/validation/validators/dateValidator.ts b/packages/sanity/src/core/validation/validators/dateValidator.ts index 437d382a513..90a9ec78756 100644 --- a/packages/sanity/src/core/validation/validators/dateValidator.ts +++ b/packages/sanity/src/core/validation/validators/dateValidator.ts @@ -1,5 +1,5 @@ import {Validators} from '@sanity/types' -import formatDate from 'date-fns/format' +import * as legacyDateFormat from '@sanity/util/legacyDateFormat' import {genericValidators} from './genericValidator' function isRecord(obj: unknown): obj is Record { @@ -17,25 +17,18 @@ interface DateTimeOptions { timeFormat?: string } -const getFormattedDate = (type = '', value: string | number | Date, options?: DateTimeOptions) => { - let format = 'yyyy-MM-dd' - if (options && options.dateFormat) { - format = options.dateFormat - } - - if (type === 'date') { - // If the type is date only - return formatDate(new Date(value), format) - } - - // If the type is datetime - if (options && options.timeFormat) { - format += ` ${options.timeFormat}` - } else { - format += ' HH:mm' - } - - return formatDate(new Date(value), format) +const getFormattedDate = (type = '', value: Date, options?: DateTimeOptions) => { + const dateFormat = options?.dateFormat || legacyDateFormat.DEFAULT_DATE_FORMAT + const timeFormat = options?.timeFormat || legacyDateFormat.DEFAULT_TIME_FORMAT + + // adding the time information in the date only case causes timezone information to be kept + // instead of it being assumed to be UTC. This was a problem because midnight UTC is the previous + // day in many other timezones resulting in the date displayed to be the previous day. + return legacyDateFormat.format( + value, + type === 'date' ? dateFormat : `${dateFormat} ${timeFormat}`, + type === 'date', + ) } function parseDate(date: unknown): Date | null @@ -67,11 +60,13 @@ export const dateValidators: Validators = { min: (minDate, value, message, {type, i18n}) => { const dateVal = parseDate(value) + const minDateVal = parseDate(minDate, true) + if (!dateVal) { return true // `type()` should catch parse errors } - if (!value || dateVal >= parseDate(minDate, true)) { + if (!value || dateVal >= minDateVal) { return true } @@ -89,7 +84,7 @@ export const dateValidators: Validators = { // validator is available as `providedMinDate`. This because the formatted date is likely // what the developer wants to present to the user i18n.t('validation:date.minimum', { - minDate: getFormattedDate(type.name, minDate, dateTimeOptions), + minDate: getFormattedDate(type.name, minDateVal, dateTimeOptions), providedMinDate: minDate, }) ) @@ -97,11 +92,13 @@ export const dateValidators: Validators = { max: (maxDate, value, message, {type, i18n}) => { const dateVal = parseDate(value) + const maxDateVal = parseDate(maxDate, true) + if (!dateVal) { return true // `type()` should catch parse errors } - if (!value || dateVal <= parseDate(maxDate, true)) { + if (!value || dateVal <= maxDateVal) { return true } @@ -119,7 +116,7 @@ export const dateValidators: Validators = { // validator is available as `providedMaxDate`. This because the formatted date is likely // what the developer wants to present to the user i18n.t('validation:date.maximum', { - maxDate: getFormattedDate(type.name, maxDate, dateTimeOptions), + maxDate: getFormattedDate(type.name, maxDateVal, dateTimeOptions), providedMaxDate: maxDate, }) ) diff --git a/packages/sanity/test/validation/__snapshots__/dates.test.ts.snap b/packages/sanity/test/validation/__snapshots__/dates.test.ts.snap new file mode 100644 index 00000000000..fad5548e855 --- /dev/null +++ b/packages/sanity/test/validation/__snapshots__/dates.test.ts.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`date with custom format max length constraint: Must be at or before 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or before 01-01-2024", + }, + "level": "error", + "message": "Must be at or before 01-01-2024", + "path": Array [], + }, +] +`; + +exports[`date with custom format min length constraint: Must be at or after 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or after 01-01-2024", + }, + "level": "error", + "message": "Must be at or after 01-01-2024", + "path": Array [], + }, +] +`; + +exports[`date with default format max length constraint: Must be at or before 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or before 2024-01-01", + }, + "level": "error", + "message": "Must be at or before 2024-01-01", + "path": Array [], + }, +] +`; + +exports[`date with default format min length constraint: Must be at or after 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or after 2024-01-01", + }, + "level": "error", + "message": "Must be at or after 2024-01-01", + "path": Array [], + }, +] +`; + +exports[`datetime with custom format max length constraint: Must be at or before 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or before 1st. January 2024 09:31", + }, + "level": "error", + "message": "Must be at or before 1st. January 2024 09:31", + "path": Array [], + }, +] +`; + +exports[`datetime with custom format min length constraint: Must be at or after 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or after 1st. January 2024 09:31", + }, + "level": "error", + "message": "Must be at or after 1st. January 2024 09:31", + "path": Array [], + }, +] +`; + +exports[`datetime with default format max length constraint: Must be at or before 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or before 2024-01-01 09:31", + }, + "level": "error", + "message": "Must be at or before 2024-01-01 09:31", + "path": Array [], + }, +] +`; + +exports[`datetime with default format min length constraint: Must be at or after 1`] = ` +Array [ + Object { + "item": Object { + "message": "Must be at or after 2024-01-01 09:31", + }, + "level": "error", + "message": "Must be at or after 2024-01-01 09:31", + "path": Array [], + }, +] +`; diff --git a/packages/sanity/test/validation/dates.test.ts b/packages/sanity/test/validation/dates.test.ts new file mode 100644 index 00000000000..530ee010b8e --- /dev/null +++ b/packages/sanity/test/validation/dates.test.ts @@ -0,0 +1,102 @@ +import {getFallbackLocaleSource} from '../../src/core/i18n/fallback' +import {Rule} from '../../src/core/validation' + +describe('date', () => { + describe('with default format', () => { + const context: any = {client: {}, i18n: getFallbackLocaleSource(), type: {name: 'date'}} + + test('min length constraint', async () => { + const rule = Rule.dateTime().min('2024-01-01') + await expect(rule.validate('2023-12-31', context)).resolves.toMatchSnapshot( + 'Must be at or after', + ) + await expect(rule.validate('2024-01-02', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01', context)).resolves.toHaveLength(0) + }) + + test('max length constraint', async () => { + const rule = Rule.dateTime().max('2024-01-01') + await expect(rule.validate('2024-01-02', context)).resolves.toMatchSnapshot( + 'Must be at or before', + ) + await expect(rule.validate('2023-12-31', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01', context)).resolves.toHaveLength(0) + }) + }) + + describe('with custom format', () => { + const context: any = { + client: {}, + i18n: getFallbackLocaleSource(), + type: {name: 'date', options: {dateFormat: 'MM-DD-YYYY'}}, + } + + test('min length constraint', async () => { + const rule = Rule.dateTime().min('2024-01-01') + await expect(rule.validate('2023-12-31', context)).resolves.toMatchSnapshot( + 'Must be at or after', + ) + await expect(rule.validate('2024-01-02', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01', context)).resolves.toHaveLength(0) + }) + + test('max length constraint', async () => { + const rule = Rule.dateTime().max('2024-01-01') + await expect(rule.validate('2024-01-02', context)).resolves.toMatchSnapshot( + 'Must be at or before', + ) + await expect(rule.validate('2023-12-31', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01', context)).resolves.toHaveLength(0) + }) + }) +}) + +describe('datetime', () => { + describe('with default format', () => { + const context: any = {client: {}, i18n: getFallbackLocaleSource(), type: {name: 'datetime'}} + + test('min length constraint', async () => { + const rule = Rule.dateTime().min('2024-01-01T17:31:00.000Z') + await expect(rule.validate('2023-12-31T17:31:00.000Z', context)).resolves.toMatchSnapshot( + 'Must be at or after', + ) + await expect(rule.validate('2024-01-02T17:31:00.000Z', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01T17:31:00.000Z', context)).resolves.toHaveLength(0) + }) + + test('max length constraint', async () => { + const rule = Rule.dateTime().max('2024-01-01T17:31:00.000Z') + await expect(rule.validate('2024-01-02T17:31:00.000Z', context)).resolves.toMatchSnapshot( + 'Must be at or before', + ) + await expect(rule.validate('2023-12-23T17:31:00.000Z', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01T17:31:00.000Z', context)).resolves.toHaveLength(0) + }) + }) + + describe('with custom format', () => { + const context: any = { + client: {}, + i18n: getFallbackLocaleSource(), + type: {name: 'datetime', options: {dateFormat: 'Do. MMMM YYYY'}}, + } + + test('min length constraint', async () => { + const rule = Rule.dateTime().min('2024-01-01T17:31:00.000Z') + await expect(rule.validate('2023-12-31T17:31:00.000Z', context)).resolves.toMatchSnapshot( + 'Must be at or after', + ) + await expect(rule.validate('2024-01-02T17:31:00.000Z', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01T17:31:00.000Z', context)).resolves.toHaveLength(0) + }) + + test('max length constraint', async () => { + const rule = Rule.dateTime().max('2024-01-01T17:31:00.000Z') + await expect(rule.validate('2024-01-02T17:31:00.000Z', context)).resolves.toMatchSnapshot( + 'Must be at or before', + ) + await expect(rule.validate('2023-12-31T17:31:00.000Z', context)).resolves.toHaveLength(0) + await expect(rule.validate('2024-01-01T17:31:00.000Z', context)).resolves.toHaveLength(0) + }) + }) +})