From a9ff375cc01db3309ec46a8078438b5a347d10dc Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 3 Jun 2025 02:44:52 -0700 Subject: [PATCH] fix(ui): clear miliseconds in date fields unless theyre explicitly provided in the display format (#12650) Fixes https://github.com/payloadcms/payload/issues/12532 Normally we clear any values when picking a date such that your hour, minutes and seconds are normalised to 0 unless specified. Equally when you specify a time we will normalise seconds so that only minutes are relevant as configured. Miliseconds were never removed from the actual date value and whatever milisecond the editor was in was that value that was being added. There's this [abandoned issue](https://github.com/Hacker0x01/react-datepicker/issues/1991) from the UI library `react-datepicker` as it's not something configurable. This fixes that problem by making sure that miliseconds are always 0 unless the `displayFormat` includes `SSS` as an intention to show and customise them. This also caused [issues with scheduled jobs](https://github.com/payloadcms/payload/issues/12566) if things were slightly out of order or not being scheduled in the expected time interval. --- .../ui/src/elements/DatePicker/DatePicker.tsx | 7 ++ test/fields/collections/Date/e2e.spec.ts | 64 +++++++++++++++++++ test/fields/collections/Date/index.ts | 10 +++ test/fields/payload-types.ts | 2 + 4 files changed, 83 insertions(+) diff --git a/packages/ui/src/elements/DatePicker/DatePicker.tsx b/packages/ui/src/elements/DatePicker/DatePicker.tsx index 714ec023d7..c8c182b69d 100644 --- a/packages/ui/src/elements/DatePicker/DatePicker.tsx +++ b/packages/ui/src/elements/DatePicker/DatePicker.tsx @@ -65,6 +65,13 @@ const DatePicker: React.FC = (props) => { const tzOffset = incomingDate.getTimezoneOffset() / 60 newDate.setHours(12 - tzOffset, 0) } + + if (newDate instanceof Date && !dateFormat.includes('SSS')) { + // Unless the dateFormat includes milliseconds, set milliseconds to 0 + // This is to ensure that the timestamp is consistent with the displayFormat + newDate.setMilliseconds(0) + } + if (typeof onChangeFromProps === 'function') { onChangeFromProps(newDate) } diff --git a/test/fields/collections/Date/e2e.spec.ts b/test/fields/collections/Date/e2e.spec.ts index 773d91a169..53e78f8b68 100644 --- a/test/fields/collections/Date/e2e.spec.ts +++ b/test/fields/collections/Date/e2e.spec.ts @@ -111,6 +111,70 @@ describe('Date', () => { await expect(dateField).toHaveValue('') }) + test('should clear miliseconds from dates with time', async () => { + await page.goto(url.create) + const dateField = page.locator('#field-default input') + await expect(dateField).toBeVisible() + // Fill in required fields, this is just to make sure saving is possible + await dateField.fill('02/07/2023') + const dateWithTz = page.locator('#field-dayAndTimeWithTimezone .react-datepicker-wrapper input') + + await dateWithTz.fill('08/12/2027 10:00 AM') + + const dropdownControlSelector = `#field-dayAndTimeWithTimezone .rs__control` + const timezoneOptionSelector = `#field-dayAndTimeWithTimezone .rs__menu .rs__option:has-text("London")` + + await page.click(dropdownControlSelector) + await page.click(timezoneOptionSelector) + + // Test the time field + const timeField = page.locator('#field-timeOnly input') + await timeField.fill('08/12/2027 10:00:00.123 AM') + + await saveDocAndAssert(page) + + const id = page.url().split('/').pop() + + const { doc } = await client.findByID({ id: id!, auth: true, slug: 'date-fields' }) + + await expect(() => { + // Ensure that the time field does not contain milliseconds + expect(doc?.timeOnly).toContain('00:00.000Z') + }).toPass() + }) + + test("should keep miliseconds when they're provided in the date format", async () => { + await page.goto(url.create) + const dateField = page.locator('#field-default input') + await expect(dateField).toBeVisible() + // Fill in required fields, this is just to make sure saving is possible + await dateField.fill('02/07/2023') + const dateWithTz = page.locator('#field-dayAndTimeWithTimezone .react-datepicker-wrapper input') + + await dateWithTz.fill('08/12/2027 10:00 AM') + + const dropdownControlSelector = `#field-dayAndTimeWithTimezone .rs__control` + const timezoneOptionSelector = `#field-dayAndTimeWithTimezone .rs__menu .rs__option:has-text("London")` + + await page.click(dropdownControlSelector) + await page.click(timezoneOptionSelector) + + // Test the time field + const timeField = page.locator('#field-timeOnlyWithMiliseconds input') + await timeField.fill('6:00.00.625 PM') + + await saveDocAndAssert(page) + + const id = page.url().split('/').pop() + + const { doc } = await client.findByID({ id: id!, auth: true, slug: 'date-fields' }) + + await expect(() => { + // Ensure that the time with miliseconds field contains the exact miliseconds specified + expect(doc?.timeOnlyWithMiliseconds).toContain('625Z') + }).toPass() + }) + describe('localized dates', () => { describe('EST', () => { test.use({ diff --git a/test/fields/collections/Date/index.ts b/test/fields/collections/Date/index.ts index 4e4b21a546..a5f94152e2 100644 --- a/test/fields/collections/Date/index.ts +++ b/test/fields/collections/Date/index.ts @@ -24,6 +24,16 @@ const DateFields: CollectionConfig = { }, }, }, + { + name: 'timeOnlyWithMiliseconds', + type: 'date', + admin: { + date: { + pickerAppearance: 'timeOnly', + displayFormat: 'h:mm.ss.SSS aa', + }, + }, + }, { name: 'timeOnlyWithCustomFormat', type: 'date', diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 66f6622451..a2a6340ec5 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -903,6 +903,7 @@ export interface DateField { id: string; default: string; timeOnly?: string | null; + timeOnlyWithMiliseconds?: string | null; timeOnlyWithCustomFormat?: string | null; dayOnly?: string | null; dayAndTime?: string | null; @@ -2486,6 +2487,7 @@ export interface CustomRowIdSelect { export interface DateFieldsSelect { default?: T; timeOnly?: T; + timeOnlyWithMiliseconds?: T; timeOnlyWithCustomFormat?: T; dayOnly?: T; dayAndTime?: T;