Skip to content

Commit

Permalink
bug(core): fix issue with date formatting in validation message (#5551)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
jtpetty and binoy14 authored Jan 25, 2024
1 parent 66242da commit febdc4b
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 42 deletions.
7 changes: 6 additions & 1 deletion packages/@sanity/util/src/legacyDateFormat.ts
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,8 +23,8 @@ export const DatetimePreview: FieldPreviewComponent<string> = 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),
Expand Down
11 changes: 3 additions & 8 deletions packages/sanity/src/core/form/inputs/DateInputs/DateInput.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 21 additions & 24 deletions packages/sanity/src/core/validation/validators/dateValidator.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -89,19 +84,21 @@ 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,
})
)
},

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
}

Expand All @@ -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,
})
)
Expand Down
105 changes: 105 additions & 0 deletions packages/sanity/test/validation/__snapshots__/dates.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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 [],
},
]
`;
102 changes: 102 additions & 0 deletions packages/sanity/test/validation/dates.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})

2 comments on commit febdc4b

@vercel
Copy link

@vercel vercel bot commented on febdc4b Jan 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

performance-studio – ./

performance-studio-git-next.sanity.build
performance-studio.sanity.build

@vercel
Copy link

@vercel vercel bot commented on febdc4b Jan 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

test-studio – ./

test-studio-git-next.sanity.build
test-studio.sanity.build

Please sign in to comment.