From 6f6891dd70c2d94059bd67d34da2b0463907a0dc Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Wed, 1 Jul 2026 08:42:13 -0600 Subject: [PATCH] fix: improve date handling by validating dates before formatting there were a number of cases where dates would not be in a valid format and we would throw Now we fallback to showing the raw value (e.g. in data table) --- .../src/utils/deploy-metadata.utils.tsx | 7 ++ libs/shared/ui-core-shared/src/query-utils.ts | 16 ++-- .../__tests__/data-table-formatters.spec.ts | 76 +++++++++++++++++++ .../lib/data-table/data-table-formatters.tsx | 16 +++- 4 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 libs/ui/src/lib/data-table/__tests__/data-table-formatters.spec.ts diff --git a/libs/features/deploy/src/utils/deploy-metadata.utils.tsx b/libs/features/deploy/src/utils/deploy-metadata.utils.tsx index 775a0d51a..a44083e77 100644 --- a/libs/features/deploy/src/utils/deploy-metadata.utils.tsx +++ b/libs/features/deploy/src/utils/deploy-metadata.utils.tsx @@ -33,6 +33,7 @@ import { import { composeQuery, getField, Query } from '@jetstreamapp/soql-parser-js'; import { formatDate } from 'date-fns/format'; import { formatISO } from 'date-fns/formatISO'; +import { isValid as isDateValid } from 'date-fns/isValid'; import { parseISO } from 'date-fns/parseISO'; import JSZip from 'jszip'; import localforage from 'localforage'; @@ -387,10 +388,16 @@ const dataTableDateFormatter = (dateOrDateTime: Maybe): ReactNode let showWarning = false; let value: string; if (isDate(dateOrDateTime)) { + if (!isDateValid(dateOrDateTime)) { + return String(dateOrDateTime); + } value = formatDate(dateOrDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a); showWarning = dateOrDateTime.getFullYear() <= 1970; } else if (dateOrDateTime.length === 28) { const date = parseISO(dateOrDateTime); + if (!isDateValid(date)) { + return dateOrDateTime; + } value = formatDate(date, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a); showWarning = date.getFullYear() <= 1970; } else { diff --git a/libs/shared/ui-core-shared/src/query-utils.ts b/libs/shared/ui-core-shared/src/query-utils.ts index 2609e58fd..196a2e5b2 100644 --- a/libs/shared/ui-core-shared/src/query-utils.ts +++ b/libs/shared/ui-core-shared/src/query-utils.ts @@ -3,6 +3,7 @@ import { groupByFlat } from '@jetstream/shared/utils'; import { ErrorResult, ExpressionType, Field, FieldType, Maybe, SalesforceRecord, SoqlQueryFormatOptions } from '@jetstream/types'; import { FieldSubquery, HavingClause, Query, WhereClause, composeQuery, getFlattenedFields } from '@jetstreamapp/soql-parser-js'; import { formatISO } from 'date-fns/formatISO'; +import { isValid as isDateValid } from 'date-fns/isValid'; import { parseISO } from 'date-fns/parseISO'; import isNil from 'lodash/isNil'; import isNumber from 'lodash/isNumber'; @@ -55,11 +56,16 @@ export function transformEditForm(sobjectFields: Field[], record: SalesforceReco // ensure value is not empty string, as that ends up being coerced to 0 record[fieldName] = null; } else if (DATE_FIELD_TYPES.has(field.type) && isString(value) && value) { - if (field.type === 'date') { - record[fieldName] = formatISO(parseISO(value), { representation: 'date' }); - } else { - // this converts to UTC - record[fieldName] = parseISO(value).toISOString(); + // Only transform when the value is a parseable ISO date; otherwise leave it untouched so an + // unparseable value surfaces as a Salesforce validation error rather than throwing here. + const parsedValue = parseISO(value); + if (isDateValid(parsedValue)) { + if (field.type === 'date') { + record[fieldName] = formatISO(parsedValue, { representation: 'date' }); + } else { + // this converts to UTC + record[fieldName] = parsedValue.toISOString(); + } } } else if (TIME_FIELD_TYPES.has(field.type) && isString(value) && value) { // Without Z, salesforce will modify the timezone diff --git a/libs/ui/src/lib/data-table/__tests__/data-table-formatters.spec.ts b/libs/ui/src/lib/data-table/__tests__/data-table-formatters.spec.ts new file mode 100644 index 000000000..5ae817e1d --- /dev/null +++ b/libs/ui/src/lib/data-table/__tests__/data-table-formatters.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; +import { dataTableDateFormatter, dataTableTimeFormatter } from '../data-table-formatters'; + +describe('dataTableDateFormatter', () => { + test('returns null for empty values', () => { + expect(dataTableDateFormatter(null)).toBeNull(); + expect(dataTableDateFormatter(undefined)).toBeNull(); + expect(dataTableDateFormatter('')).toBeNull(); + }); + + test('formats a valid ISO date (length 10)', () => { + expect(dataTableDateFormatter('2023-11-12')).toBe('2023-11-12'); + }); + + test('formats a valid ISO datetime (length 28)', () => { + const value = '2023-11-12T15:30:45.000+0000'; + expect(value.length).toBe(28); + // Exact time depends on the runner's timezone, so assert the formatted shape rather than the wall-clock time. + expect(dataTableDateFormatter(value)).toMatch(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2} (AM|PM)$/); + }); + + test('formats a valid Date instance', () => { + const result = dataTableDateFormatter(new Date(2023, 10, 12, 15, 30, 45)); + expect(result).toBe('2023-11-12 3:30:45 PM'); + }); + + // Regression: an invalid Date instance previously caused formatDate to throw "RangeError: Invalid time value". + // It should fall back to the raw value (coerced to a string) rather than throwing or hiding data. + test('returns the raw value for an invalid Date instance instead of throwing', () => { + const value = new Date('invalid'); + expect(() => dataTableDateFormatter(value)).not.toThrow(); + expect(dataTableDateFormatter(value)).toBe('Invalid Date'); + }); + + // Regression: a 10-char MM/DD/YYYY value (e.g. from a formula field) is not ISO and previously + // caused parseISO -> Invalid Date -> formatDate to throw "RangeError: Invalid time value". + test('returns the raw value for a 10-char non-ISO date instead of throwing', () => { + expect(() => dataTableDateFormatter('12/11/2023')).not.toThrow(); + expect(dataTableDateFormatter('12/11/2023')).toBe('12/11/2023'); + expect(dataTableDateFormatter('10/15/2019')).toBe('10/15/2019'); + }); + + test('returns the raw value for an unparseable 28-char value instead of throwing', () => { + const value = 'x'.repeat(28); + expect(() => dataTableDateFormatter(value)).not.toThrow(); + expect(dataTableDateFormatter(value)).toBe(value); + }); + + test('returns other-length strings unchanged', () => { + expect(dataTableDateFormatter('some text')).toBe('some text'); + }); +}); + +describe('dataTableTimeFormatter', () => { + test('returns null for empty values', () => { + expect(dataTableTimeFormatter(null)).toBeNull(); + expect(dataTableTimeFormatter(undefined)).toBeNull(); + expect(dataTableTimeFormatter('')).toBeNull(); + }); + + test('formats a valid 13-char Salesforce time value', () => { + expect(dataTableTimeFormatter('15:30:45.000Z')).toBe('3:30:45 PM'); + }); + + // Regression: a 13-char value that does not match the expected time format should not throw. + test('returns the raw value for an unparseable 13-char time instead of throwing', () => { + const value = 'not-valid-tim'; + expect(value.length).toBe(13); + expect(() => dataTableTimeFormatter(value)).not.toThrow(); + expect(dataTableTimeFormatter(value)).toBe(value); + }); + + test('returns other-length strings unchanged', () => { + expect(dataTableTimeFormatter('15:30')).toBe('15:30'); + }); +}); diff --git a/libs/ui/src/lib/data-table/data-table-formatters.tsx b/libs/ui/src/lib/data-table/data-table-formatters.tsx index 984f02ae5..ccab96733 100644 --- a/libs/ui/src/lib/data-table/data-table-formatters.tsx +++ b/libs/ui/src/lib/data-table/data-table-formatters.tsx @@ -4,6 +4,7 @@ import { tracker } from '@jetstream/shared/ui-utils'; import { getErrorMessage } from '@jetstream/shared/utils'; import { Maybe } from '@jetstream/types'; import { formatDate } from 'date-fns/format'; +import { isValid as isDateValid } from 'date-fns/isValid'; import { parse as parseDate } from 'date-fns/parse'; import { parseISO } from 'date-fns/parseISO'; import { startOfDay } from 'date-fns/startOfDay'; @@ -18,11 +19,17 @@ export const dataTableDateFormatter = (dateOrDateTime: Maybe): st if (!dateOrDateTime) { return null; } else if (isDate(dateOrDateTime)) { - return formatDate(dateOrDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a); + // Guard against an invalid Date instance so formatDate never throws; fall back to the raw value (coerced to a + // string) instead of null to stay consistent with the string branches and avoid hiding data. + return isDateValid(dateOrDateTime) ? formatDate(dateOrDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a) : String(dateOrDateTime); } else if (dateOrDateTime.length === 28) { - return formatDate(parseISO(dateOrDateTime), DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a); + const parsedDateTime = parseISO(dateOrDateTime); + return isDateValid(parsedDateTime) ? formatDate(parsedDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a) : dateOrDateTime; } else if (dateOrDateTime.length === 10) { - return formatDate(startOfDay(parseISO(dateOrDateTime)), DATE_FORMATS.yyyy_MM_dd); + // A 10-char value is assumed to be an ISO date, but values like "12/11/2023" (MM/DD/YYYY, e.g. from a + // formula field) also match this length and are not parseable as ISO. Fall back to the raw value instead of throwing. + const parsedDate = parseISO(dateOrDateTime); + return isDateValid(parsedDate) ? formatDate(startOfDay(parsedDate), DATE_FORMATS.yyyy_MM_dd) : dateOrDateTime; } else { return dateOrDateTime; } @@ -42,7 +49,8 @@ export const dataTableTimeFormatter = (value: Maybe): string | null => { if (!time) { return null; } else if (time.length === 13) { - return formatDate(parseDate(time, DATE_FORMATS.HH_mm_ss_ssss_z, new Date()), DATE_FORMATS.HH_MM_SS_a); + const parsedTime = parseDate(time, DATE_FORMATS.HH_mm_ss_ssss_z, new Date()); + return isDateValid(parsedTime) ? formatDate(parsedTime, DATE_FORMATS.HH_MM_SS_a) : time; } else { return time; }