fix: normalize date-time field input on backend to prevent timeline crash (#22035)
## Context Reported via support ([private-issues#477](https://github.com/twentyhq/private-issues/issues/477)): a customer saw **"Invalid Configuration"** in red on a record's **Timeline** tab. The dev console was flooded with: ``` RangeError: Cannot parse: 2026-05-07 at Temporal.Instant.from (...) at RecordFieldComponent ... ``` ## Root cause A `DATE_TIME` field in their workspace holds **date-only** values like `2026-05-07`. `validateDateTimeFieldOrThrow` (the write-path validator) **accepts** date-only formats — `'yyyy-MM-dd'` is in `ACCEPTED_DATE_TIME_FORMATS` — and **returns the raw input string unchanged**, with no normalization. So a date-only string passes validation and propagates verbatim into the mutation response and the timeline event payload. On render, `DateTimeDisplay` builds the timezone hint with `Temporal.Instant.from(value)`. That's strict — it requires a full instant (time + offset/`Z`) and throws `RangeError` on a bare date. The throw escapes into the page-layout widget error boundary, which renders the **"Invalid Configuration"** fallback and breaks the whole timeline. ## Fix **Backend (root cause) — normalize on write.** `validateDateTimeFieldOrThrow` now canonicalizes every accepted value to a full ISO 8601 instant, so a date-only value can never reach storage, the mutation response, or timeline events for a `DATE_TIME` field: - strict ISO-8601 carrying an offset/`Z` -> kept as its exact instant (server-timezone-independent) - zoneless / date-only / lenient formats -> interpreted as **UTC** (date-only -> midnight UTC), deterministically Lenient input is preserved — parsing still uses date-fns for the ~20 accepted formats (which `Temporal.Instant.from` cannot parse); only the *output* is canonicalized, via Temporal. | input | before (stored raw) | after (normalized) | |---|---|---| | `2026-05-07` | `2026-05-07` | `2026-05-07T00:00:00Z` | | `2026-05-07T12:00:00+02:00` | `2026-05-07T12:00:00+02:00` | `2026-05-07T10:00:00Z` | | `2026-05-07T12:00:00.000Z` | `2026-05-07T12:00:00.000Z` | `2026-05-07T12:00:00Z` | | `January 15, 2024` | `January 15, 2024` | `2024-01-15T00:00:00Z` | **Frontend (existing data) — Temporal-native guard.** Existing workspaces already have date-only values stored in events, so the backend fix alone won't un-break the reporting customer's timeline. `DateTimeDisplay` now parses the value via a new `parseStringToInstantOrNull` helper (Temporal `Instant.from` with a `PlainDate` start-of-day-UTC fallback) and only renders the timezone hint when valid — so stored bad data renders gracefully instead of crashing. This replaces the initial `new Date()` guard with a Temporal-native one, in line with the codebase's Temporal migration. ## Tests - `validate-date-time-field-or-throw.util.spec.ts` updated to assert the normalized instant output, incl. explicit date-only -> midnight-UTC cases. - `parseStringToInstantOrNull.test.ts` — unit coverage for the frontend helper (instant, offset, date-only, unparseable). - `DateTimeDisplay.stories.tsx` — story rendering a date-only value under a non-system timezone (the previously-crashing path).
This commit is contained in:
+21
-21
@@ -186,7 +186,7 @@ export const successfulInputsByFieldMetadataType: {
|
||||
{ input: { dateTimeField: null }, expectedOutput: { dateTimeField: null } },
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13T10:30:00.000Z' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000Z' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
@@ -194,83 +194,83 @@ export const successfulInputsByFieldMetadataType: {
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13T10:30:00.000+02:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000+02:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T08:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13T10:30:00+02:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00+02:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T08:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13T10:30:00.000' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13T10:30:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13 10:30:00.000' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13 10:30:00.000' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13 10:30:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13 10:30:00' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13 10:30' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13 10:30' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-01-13' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '20250113' },
|
||||
expectedOutput: { dateTimeField: '20250113' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025.01.13' },
|
||||
expectedOutput: { dateTimeField: '2025.01.13' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025/01/13' },
|
||||
expectedOutput: { dateTimeField: '2025/01/13' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '01-13-2025' },
|
||||
expectedOutput: { dateTimeField: '01-13-2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '01/13/2025' },
|
||||
expectedOutput: { dateTimeField: '01/13/2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '01.13.2025' },
|
||||
expectedOutput: { dateTimeField: '01.13.2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: 'January 13, 2025' },
|
||||
expectedOutput: { dateTimeField: 'January 13, 2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: 'Jan 13, 2025' },
|
||||
expectedOutput: { dateTimeField: 'Jan 13, 2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '13 January 2025' },
|
||||
expectedOutput: { dateTimeField: '13 January 2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '13 Jan 2025' },
|
||||
expectedOutput: { dateTimeField: '13 Jan 2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '13-Jan-2025' },
|
||||
expectedOutput: { dateTimeField: '13-Jan-2025' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
input: { dateTimeField: '2025-Jan-13' },
|
||||
expectedOutput: { dateTimeField: '2025-Jan-13' },
|
||||
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
|
||||
},
|
||||
],
|
||||
[FieldMetadataType.BOOLEAN]: [
|
||||
|
||||
+73
-53
@@ -2,102 +2,122 @@ import { validateDateTimeFieldOrThrow } from 'src/engine/api/common/common-args-
|
||||
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
describe('validateDateTimeFieldOrThrow', () => {
|
||||
describe('valid inputs', () => {
|
||||
describe('valid inputs (normalized to a canonical instant)', () => {
|
||||
it('should return null when value is null', () => {
|
||||
const result = validateDateTimeFieldOrThrow(null, 'testField');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO datetime string with Z timezone', () => {
|
||||
const datetimeString = '2024-01-15T10:30:00Z';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should keep the instant for an ISO datetime string with Z timezone', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15T10:30:00Z',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO datetime string with milliseconds and Z timezone', () => {
|
||||
const datetimeString = '2024-01-15T10:30:00.000Z';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should drop a zero millisecond fraction', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15T10:30:00.000Z',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO datetime string with timezone offset', () => {
|
||||
const datetimeString = '2024-01-15T10:30:00+02:00';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should convert a timezone offset to its UTC instant', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15T10:30:00+02:00',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T08:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO datetime string with milliseconds and timezone offset', () => {
|
||||
const datetimeString = '2024-01-15T10:30:00.000+02:00';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should convert a timezone offset with milliseconds to its UTC instant', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15T10:30:00.000+02:00',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T08:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO datetime string without timezone', () => {
|
||||
const datetimeString = '2024-01-15T10:30:00';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should interpret a zoneless ISO datetime as UTC', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15T10:30:00',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO datetime string with milliseconds without timezone', () => {
|
||||
const datetimeString = '2024-01-15T10:30:00.000';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should interpret a zoneless ISO datetime with milliseconds as UTC', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15T10:30:00.000',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid datetime with space separator', () => {
|
||||
const datetimeString = '2024-01-15 10:30:00';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should normalize a datetime with a space separator', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15 10:30:00',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid datetime with space separator and milliseconds', () => {
|
||||
const datetimeString = '2024-01-15 10:30:00.000';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should normalize a datetime with a space separator and milliseconds', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15 10:30:00.000',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid datetime with space separator without seconds', () => {
|
||||
const datetimeString = '2024-01-15 10:30';
|
||||
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
|
||||
it('should normalize a datetime with a space separator without seconds', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'2024-01-15 10:30',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(datetimeString);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO date string (will be treated as midnight)', () => {
|
||||
const dateString = '2024-01-15';
|
||||
const result = validateDateTimeFieldOrThrow(dateString, 'testField');
|
||||
it('should normalize a date-only value to midnight UTC', () => {
|
||||
const result = validateDateTimeFieldOrThrow('2024-01-15', 'testField');
|
||||
|
||||
expect(result).toBe(dateString);
|
||||
expect(result).toBe('2024-01-15T00:00:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid ISO compact date string', () => {
|
||||
const dateString = '20240115';
|
||||
const result = validateDateTimeFieldOrThrow(dateString, 'testField');
|
||||
it('should normalize a compact date-only value to midnight UTC', () => {
|
||||
const result = validateDateTimeFieldOrThrow('20240115', 'testField');
|
||||
|
||||
expect(result).toBe(dateString);
|
||||
expect(result).toBe('2024-01-15T00:00:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a valid date with full month name', () => {
|
||||
const dateString = 'January 15, 2024';
|
||||
const result = validateDateTimeFieldOrThrow(dateString, 'testField');
|
||||
it('should normalize a date with a full month name to midnight UTC', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
'January 15, 2024',
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(dateString);
|
||||
expect(result).toBe('2024-01-15T00:00:00Z');
|
||||
});
|
||||
|
||||
it('should return the value when it is a Date object', () => {
|
||||
const dateObject = new Date('2024-01-15T10:30:00Z');
|
||||
const result = validateDateTimeFieldOrThrow(dateObject, 'testField');
|
||||
it('should normalize a Date object to its instant', () => {
|
||||
const result = validateDateTimeFieldOrThrow(
|
||||
new Date('2024-01-15T10:30:00Z'),
|
||||
'testField',
|
||||
);
|
||||
|
||||
expect(result).toBe(dateObject);
|
||||
expect(result).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-22
@@ -3,34 +3,13 @@ import { inspect } from 'util';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDate, isNull, isString } from '@sniptt/guards';
|
||||
import { isValid, parse } from 'date-fns';
|
||||
import { ACCEPTED_DATE_FORMATS } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
const ACCEPTED_DATE_FORMATS = [
|
||||
'yyyy-MM-dd',
|
||||
'yyyyMMdd',
|
||||
'yyyy.MM.dd',
|
||||
'yyyy/MM/dd',
|
||||
'MM-dd-yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'MM.dd.yyyy',
|
||||
'MMMM d, yyyy',
|
||||
'MMM d, yyyy',
|
||||
'd MMMM yyyy',
|
||||
'd MMM yyyy',
|
||||
'dd-MMM-yyyy',
|
||||
'yyyy-MMM-dd',
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
];
|
||||
|
||||
const isValidDateFormat = (value: string): boolean => {
|
||||
for (const format of ACCEPTED_DATE_FORMATS) {
|
||||
const parsed = parse(value, format, new Date());
|
||||
|
||||
+26
-50
@@ -2,64 +2,19 @@ import { inspect } from 'util';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDate, isNull, isString } from '@sniptt/guards';
|
||||
import { isValid, parse } from 'date-fns';
|
||||
import { isValid } from 'date-fns';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { parseToInstantOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
const ACCEPTED_DATE_TIME_FORMATS = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx",
|
||||
"yyyy-MM-dd'T'HH:mm:ssxxx",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd',
|
||||
'yyyyMMdd',
|
||||
'yyyy.MM.dd',
|
||||
'yyyy/MM/dd',
|
||||
'MM-dd-yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'MM.dd.yyyy',
|
||||
'MMMM d, yyyy',
|
||||
'MMM d, yyyy',
|
||||
'd MMMM yyyy',
|
||||
'd MMM yyyy',
|
||||
'dd-MMM-yyyy',
|
||||
'yyyy-MMM-dd',
|
||||
];
|
||||
|
||||
const isValidDateTimeFormat = (value: string): boolean => {
|
||||
for (const format of ACCEPTED_DATE_TIME_FORMATS) {
|
||||
const parsed = parse(value, format, new Date());
|
||||
|
||||
if (isValid(parsed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const validateDateTimeFieldOrThrow = (
|
||||
const throwInvalidDateTimeFieldException = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
): unknown => {
|
||||
if (isNull(value)) return null;
|
||||
|
||||
if (isDate(value) && isValid(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isString(value) && isValidDateTimeFormat(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
): never => {
|
||||
const inspectedValue = inspect(value);
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
@@ -70,3 +25,24 @@ export const validateDateTimeFieldOrThrow = (
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const validateDateTimeFieldOrThrow = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
): unknown => {
|
||||
if (isNull(value)) return null;
|
||||
|
||||
if (isDate(value) && isValid(value)) {
|
||||
return Temporal.Instant.fromEpochMilliseconds(value.getTime()).toString();
|
||||
}
|
||||
|
||||
if (isString(value)) {
|
||||
try {
|
||||
return parseToInstantOrThrow(value).toString();
|
||||
} catch {
|
||||
throwInvalidDateTimeFieldException(value, fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
throwInvalidDateTimeFieldException(value, fieldName);
|
||||
};
|
||||
|
||||
+1
-22
@@ -3,34 +3,13 @@ import { inspect } from 'util';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDate, isNull, isString } from '@sniptt/guards';
|
||||
import { isValid, parse } from 'date-fns';
|
||||
import { ACCEPTED_DATE_FORMATS } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
const ACCEPTED_DATE_FORMATS = [
|
||||
'yyyy-MM-dd',
|
||||
'yyyyMMdd',
|
||||
'yyyy.MM.dd',
|
||||
'yyyy/MM/dd',
|
||||
'MM-dd-yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'MM.dd.yyyy',
|
||||
'MMMM d, yyyy',
|
||||
'MMM d, yyyy',
|
||||
'd MMMM yyyy',
|
||||
'd MMM yyyy',
|
||||
'dd-MMM-yyyy',
|
||||
'yyyy-MMM-dd',
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
];
|
||||
|
||||
const isValidDateFormat = (value: string): boolean => {
|
||||
for (const format of ACCEPTED_DATE_FORMATS) {
|
||||
const parsed = parse(value, format, new Date());
|
||||
|
||||
+1
-25
@@ -3,37 +3,13 @@ import { inspect } from 'util';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDate, isNull, isString } from '@sniptt/guards';
|
||||
import { isValid, parse } from 'date-fns';
|
||||
import { ACCEPTED_DATE_TIME_FORMATS } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
const ACCEPTED_DATE_TIME_FORMATS = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx",
|
||||
"yyyy-MM-dd'T'HH:mm:ssxxx",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd',
|
||||
'yyyyMMdd',
|
||||
'yyyy.MM.dd',
|
||||
'yyyy/MM/dd',
|
||||
'MM-dd-yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'MM.dd.yyyy',
|
||||
'MMMM d, yyyy',
|
||||
'MMM d, yyyy',
|
||||
'd MMMM yyyy',
|
||||
'd MMM yyyy',
|
||||
'dd-MMM-yyyy',
|
||||
'yyyy-MMM-dd',
|
||||
];
|
||||
|
||||
const isValidDateTimeFormat = (value: string): boolean => {
|
||||
for (const format of ACCEPTED_DATE_TIME_FORMATS) {
|
||||
const parsed = parse(value, format, new Date());
|
||||
|
||||
+10
@@ -227,6 +227,16 @@ export const successfulCreateInputByFieldMetadataType: {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
dateTimeField: '2026-05-07',
|
||||
},
|
||||
validateInput: (record: Record<string, any>) => {
|
||||
const date = new Date(record.dateTimeField);
|
||||
|
||||
return date.toISOString() === '2026-05-07T00:00:00.000Z';
|
||||
},
|
||||
},
|
||||
],
|
||||
[FieldMetadataType.BOOLEAN]: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { parseToInstantOrThrow } from '../parseToInstantOrThrow';
|
||||
|
||||
describe('parseToInstantOrThrow', () => {
|
||||
it.each([
|
||||
['2024-01-15T10:30:00Z', '2024-01-15T10:30:00Z'],
|
||||
['2024-01-15T10:30:00.000Z', '2024-01-15T10:30:00Z'],
|
||||
['2024-01-15T10:30:00+02:00', '2024-01-15T08:30:00Z'],
|
||||
['2024-01-15T10:30:00', '2024-01-15T10:30:00Z'],
|
||||
['2024-01-15 10:30:00', '2024-01-15T10:30:00Z'],
|
||||
['2024-01-15 10:30', '2024-01-15T10:30:00Z'],
|
||||
['2024-01-15', '2024-01-15T00:00:00Z'],
|
||||
['20240115', '2024-01-15T00:00:00Z'],
|
||||
['01/15/2024', '2024-01-15T00:00:00Z'],
|
||||
['01-15-2024', '2024-01-15T00:00:00Z'],
|
||||
['January 15, 2024', '2024-01-15T00:00:00Z'],
|
||||
])('should parse %s to the instant %s', (input, expected) => {
|
||||
expect(parseToInstantOrThrow(input).toString()).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([['2024'], ['2024-01'], ['not-a-date'], ['']])(
|
||||
'should throw for the unparseable value %s',
|
||||
(input) => {
|
||||
expect(() => parseToInstantOrThrow(input)).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export const NON_ISO_DATE_FORMATS = [
|
||||
'yyyy.MM.dd',
|
||||
'yyyy/MM/dd',
|
||||
'MM-dd-yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'MM.dd.yyyy',
|
||||
'MMMM d, yyyy',
|
||||
'MMM d, yyyy',
|
||||
'd MMMM yyyy',
|
||||
'd MMM yyyy',
|
||||
'dd-MMM-yyyy',
|
||||
'yyyy-MMM-dd',
|
||||
];
|
||||
|
||||
export const ACCEPTED_DATE_FORMATS = [
|
||||
'yyyy-MM-dd',
|
||||
'yyyyMMdd',
|
||||
...NON_ISO_DATE_FORMATS,
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
];
|
||||
|
||||
export const ACCEPTED_DATE_TIME_FORMATS = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx",
|
||||
"yyyy-MM-dd'T'HH:mm:ssxxx",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd',
|
||||
'yyyyMMdd',
|
||||
...NON_ISO_DATE_FORMATS,
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
import { isValid, parse } from 'date-fns';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { NON_ISO_DATE_FORMATS } from '@/utils/date/dateInputFormats';
|
||||
import { turnJSDateToPlainDate } from '@/utils/date/turnJSDateToPlainDate';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
|
||||
const getIsoInstant = (stringDateTime: string): Temporal.Instant | null => {
|
||||
try {
|
||||
return Temporal.Instant.from(stringDateTime);
|
||||
} catch {
|
||||
try {
|
||||
return Temporal.PlainDateTime.from(stringDateTime)
|
||||
.toZonedDateTime('UTC')
|
||||
.toInstant();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const parseToInstantOrThrow = (
|
||||
stringDateTime: string,
|
||||
): Temporal.Instant => {
|
||||
const isoInstant = getIsoInstant(stringDateTime);
|
||||
|
||||
if (isDefined(isoInstant)) {
|
||||
return isoInstant;
|
||||
}
|
||||
|
||||
for (const format of NON_ISO_DATE_FORMATS) {
|
||||
const parsedDate = parse(stringDateTime, format, new Date());
|
||||
|
||||
if (isValid(parsedDate)) {
|
||||
return turnJSDateToPlainDate(parsedDate)
|
||||
.toZonedDateTime('UTC')
|
||||
.toInstant();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cannot parse date-time string as Instant: "${stringDateTime}"`,
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,11 @@ export { interpolateCommandMenuItemTemplate } from './command-menu-items/interpo
|
||||
export { resolveObjectMetadataLabel } from './command-menu-items/resolveObjectMetadataLabel';
|
||||
export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty';
|
||||
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
|
||||
export {
|
||||
NON_ISO_DATE_FORMATS,
|
||||
ACCEPTED_DATE_FORMATS,
|
||||
ACCEPTED_DATE_TIME_FORMATS,
|
||||
} from './date/dateInputFormats';
|
||||
export { isDateWithoutTime } from './date/isDateWithoutTime';
|
||||
export { isPlainDateAfter } from './date/isPlainDateAfter';
|
||||
export { isPlainDateBefore } from './date/isPlainDateBefore';
|
||||
@@ -36,6 +41,7 @@ export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';
|
||||
export { isPlainDateInSameMonth } from './date/isPlainDateInSameMonth';
|
||||
export { isPlainDateInWeekend } from './date/isPlainDateInWeekend';
|
||||
export { isSamePlainDate } from './date/isSamePlainDate';
|
||||
export { parseToInstantOrThrow } from './date/parseToInstantOrThrow';
|
||||
export { parseToPlainDateOrThrow } from './date/parseToPlainDateOrThrow';
|
||||
export { sortPlainDate } from './date/sortPlainDate';
|
||||
export { turnJSDateToPlainDate } from './date/turnJSDateToPlainDate';
|
||||
|
||||
Reference in New Issue
Block a user