Skip to content

Commit 6f6891d

Browse files
committed
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)
1 parent 0e57f0a commit 6f6891d

4 files changed

Lines changed: 106 additions & 9 deletions

File tree

libs/features/deploy/src/utils/deploy-metadata.utils.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { composeQuery, getField, Query } from '@jetstreamapp/soql-parser-js';
3434
import { formatDate } from 'date-fns/format';
3535
import { formatISO } from 'date-fns/formatISO';
36+
import { isValid as isDateValid } from 'date-fns/isValid';
3637
import { parseISO } from 'date-fns/parseISO';
3738
import JSZip from 'jszip';
3839
import localforage from 'localforage';
@@ -387,10 +388,16 @@ const dataTableDateFormatter = (dateOrDateTime: Maybe<Date | string>): ReactNode
387388
let showWarning = false;
388389
let value: string;
389390
if (isDate(dateOrDateTime)) {
391+
if (!isDateValid(dateOrDateTime)) {
392+
return String(dateOrDateTime);
393+
}
390394
value = formatDate(dateOrDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a);
391395
showWarning = dateOrDateTime.getFullYear() <= 1970;
392396
} else if (dateOrDateTime.length === 28) {
393397
const date = parseISO(dateOrDateTime);
398+
if (!isDateValid(date)) {
399+
return dateOrDateTime;
400+
}
394401
value = formatDate(date, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a);
395402
showWarning = date.getFullYear() <= 1970;
396403
} else {

libs/shared/ui-core-shared/src/query-utils.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { groupByFlat } from '@jetstream/shared/utils';
33
import { ErrorResult, ExpressionType, Field, FieldType, Maybe, SalesforceRecord, SoqlQueryFormatOptions } from '@jetstream/types';
44
import { FieldSubquery, HavingClause, Query, WhereClause, composeQuery, getFlattenedFields } from '@jetstreamapp/soql-parser-js';
55
import { formatISO } from 'date-fns/formatISO';
6+
import { isValid as isDateValid } from 'date-fns/isValid';
67
import { parseISO } from 'date-fns/parseISO';
78
import isNil from 'lodash/isNil';
89
import isNumber from 'lodash/isNumber';
@@ -55,11 +56,16 @@ export function transformEditForm(sobjectFields: Field[], record: SalesforceReco
5556
// ensure value is not empty string, as that ends up being coerced to 0
5657
record[fieldName] = null;
5758
} else if (DATE_FIELD_TYPES.has(field.type) && isString(value) && value) {
58-
if (field.type === 'date') {
59-
record[fieldName] = formatISO(parseISO(value), { representation: 'date' });
60-
} else {
61-
// this converts to UTC
62-
record[fieldName] = parseISO(value).toISOString();
59+
// Only transform when the value is a parseable ISO date; otherwise leave it untouched so an
60+
// unparseable value surfaces as a Salesforce validation error rather than throwing here.
61+
const parsedValue = parseISO(value);
62+
if (isDateValid(parsedValue)) {
63+
if (field.type === 'date') {
64+
record[fieldName] = formatISO(parsedValue, { representation: 'date' });
65+
} else {
66+
// this converts to UTC
67+
record[fieldName] = parsedValue.toISOString();
68+
}
6369
}
6470
} else if (TIME_FIELD_TYPES.has(field.type) && isString(value) && value) {
6571
// Without Z, salesforce will modify the timezone
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { dataTableDateFormatter, dataTableTimeFormatter } from '../data-table-formatters';
3+
4+
describe('dataTableDateFormatter', () => {
5+
test('returns null for empty values', () => {
6+
expect(dataTableDateFormatter(null)).toBeNull();
7+
expect(dataTableDateFormatter(undefined)).toBeNull();
8+
expect(dataTableDateFormatter('')).toBeNull();
9+
});
10+
11+
test('formats a valid ISO date (length 10)', () => {
12+
expect(dataTableDateFormatter('2023-11-12')).toBe('2023-11-12');
13+
});
14+
15+
test('formats a valid ISO datetime (length 28)', () => {
16+
const value = '2023-11-12T15:30:45.000+0000';
17+
expect(value.length).toBe(28);
18+
// Exact time depends on the runner's timezone, so assert the formatted shape rather than the wall-clock time.
19+
expect(dataTableDateFormatter(value)).toMatch(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2} (AM|PM)$/);
20+
});
21+
22+
test('formats a valid Date instance', () => {
23+
const result = dataTableDateFormatter(new Date(2023, 10, 12, 15, 30, 45));
24+
expect(result).toBe('2023-11-12 3:30:45 PM');
25+
});
26+
27+
// Regression: an invalid Date instance previously caused formatDate to throw "RangeError: Invalid time value".
28+
// It should fall back to the raw value (coerced to a string) rather than throwing or hiding data.
29+
test('returns the raw value for an invalid Date instance instead of throwing', () => {
30+
const value = new Date('invalid');
31+
expect(() => dataTableDateFormatter(value)).not.toThrow();
32+
expect(dataTableDateFormatter(value)).toBe('Invalid Date');
33+
});
34+
35+
// Regression: a 10-char MM/DD/YYYY value (e.g. from a formula field) is not ISO and previously
36+
// caused parseISO -> Invalid Date -> formatDate to throw "RangeError: Invalid time value".
37+
test('returns the raw value for a 10-char non-ISO date instead of throwing', () => {
38+
expect(() => dataTableDateFormatter('12/11/2023')).not.toThrow();
39+
expect(dataTableDateFormatter('12/11/2023')).toBe('12/11/2023');
40+
expect(dataTableDateFormatter('10/15/2019')).toBe('10/15/2019');
41+
});
42+
43+
test('returns the raw value for an unparseable 28-char value instead of throwing', () => {
44+
const value = 'x'.repeat(28);
45+
expect(() => dataTableDateFormatter(value)).not.toThrow();
46+
expect(dataTableDateFormatter(value)).toBe(value);
47+
});
48+
49+
test('returns other-length strings unchanged', () => {
50+
expect(dataTableDateFormatter('some text')).toBe('some text');
51+
});
52+
});
53+
54+
describe('dataTableTimeFormatter', () => {
55+
test('returns null for empty values', () => {
56+
expect(dataTableTimeFormatter(null)).toBeNull();
57+
expect(dataTableTimeFormatter(undefined)).toBeNull();
58+
expect(dataTableTimeFormatter('')).toBeNull();
59+
});
60+
61+
test('formats a valid 13-char Salesforce time value', () => {
62+
expect(dataTableTimeFormatter('15:30:45.000Z')).toBe('3:30:45 PM');
63+
});
64+
65+
// Regression: a 13-char value that does not match the expected time format should not throw.
66+
test('returns the raw value for an unparseable 13-char time instead of throwing', () => {
67+
const value = 'not-valid-tim';
68+
expect(value.length).toBe(13);
69+
expect(() => dataTableTimeFormatter(value)).not.toThrow();
70+
expect(dataTableTimeFormatter(value)).toBe(value);
71+
});
72+
73+
test('returns other-length strings unchanged', () => {
74+
expect(dataTableTimeFormatter('15:30')).toBe('15:30');
75+
});
76+
});

libs/ui/src/lib/data-table/data-table-formatters.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tracker } from '@jetstream/shared/ui-utils';
44
import { getErrorMessage } from '@jetstream/shared/utils';
55
import { Maybe } from '@jetstream/types';
66
import { formatDate } from 'date-fns/format';
7+
import { isValid as isDateValid } from 'date-fns/isValid';
78
import { parse as parseDate } from 'date-fns/parse';
89
import { parseISO } from 'date-fns/parseISO';
910
import { startOfDay } from 'date-fns/startOfDay';
@@ -18,11 +19,17 @@ export const dataTableDateFormatter = (dateOrDateTime: Maybe<Date | string>): st
1819
if (!dateOrDateTime) {
1920
return null;
2021
} else if (isDate(dateOrDateTime)) {
21-
return formatDate(dateOrDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a);
22+
// Guard against an invalid Date instance so formatDate never throws; fall back to the raw value (coerced to a
23+
// string) instead of null to stay consistent with the string branches and avoid hiding data.
24+
return isDateValid(dateOrDateTime) ? formatDate(dateOrDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a) : String(dateOrDateTime);
2225
} else if (dateOrDateTime.length === 28) {
23-
return formatDate(parseISO(dateOrDateTime), DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a);
26+
const parsedDateTime = parseISO(dateOrDateTime);
27+
return isDateValid(parsedDateTime) ? formatDate(parsedDateTime, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a) : dateOrDateTime;
2428
} else if (dateOrDateTime.length === 10) {
25-
return formatDate(startOfDay(parseISO(dateOrDateTime)), DATE_FORMATS.yyyy_MM_dd);
29+
// A 10-char value is assumed to be an ISO date, but values like "12/11/2023" (MM/DD/YYYY, e.g. from a
30+
// formula field) also match this length and are not parseable as ISO. Fall back to the raw value instead of throwing.
31+
const parsedDate = parseISO(dateOrDateTime);
32+
return isDateValid(parsedDate) ? formatDate(startOfDay(parsedDate), DATE_FORMATS.yyyy_MM_dd) : dateOrDateTime;
2633
} else {
2734
return dateOrDateTime;
2835
}
@@ -42,7 +49,8 @@ export const dataTableTimeFormatter = (value: Maybe<string>): string | null => {
4249
if (!time) {
4350
return null;
4451
} else if (time.length === 13) {
45-
return formatDate(parseDate(time, DATE_FORMATS.HH_mm_ss_ssss_z, new Date()), DATE_FORMATS.HH_MM_SS_a);
52+
const parsedTime = parseDate(time, DATE_FORMATS.HH_mm_ss_ssss_z, new Date());
53+
return isDateValid(parsedTime) ? formatDate(parsedTime, DATE_FORMATS.HH_MM_SS_a) : time;
4654
} else {
4755
return time;
4856
}

0 commit comments

Comments
 (0)