@graphcommerce/next-ui 11.0.0-canary.45 → 11.0.0-canary.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/Intl/DateTimeFormat/toDate.test.ts +154 -0
- package/Intl/DateTimeFormat/toDate.ts +39 -1
- package/package.json +8 -8
- package/tsconfig.json +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 11.0.0-canary.47
|
|
4
|
+
|
|
5
|
+
## 11.0.0-canary.46
|
|
6
|
+
|
|
7
|
+
### Patch Changes
|
|
8
|
+
|
|
9
|
+
- [#2668](https://github.com/graphcommerce-org/graphcommerce/pull/2668) [`b822462`](https://github.com/graphcommerce-org/graphcommerce/commit/b82246240a932751bcf8fd7063f67fb8927a5cd4) - Fix two bugs in `toDate`:
|
|
10
|
+
|
|
11
|
+
- ISO datetime strings with a timezone offset (e.g. `2024-01-15T10:30:00-05:00`) were being corrupted into `Invalid Date`, because `replace(/-/g, '/')` was applied to every string instead of only to plain `YYYY-MM-DD` dates
|
|
12
|
+
- Strings in Magento's `DATETIME_SLASH_PHP_FORMAT` (`d/m/Y H:i:s`) were misparsed as `MM/DD/YYYY` by the native `Date` constructor, silently producing the wrong date or `undefined` ([@hsngdz](https://github.com/hsngdz))
|
|
13
|
+
|
|
3
14
|
## 11.0.0-canary.45
|
|
4
15
|
|
|
5
16
|
## 10.1.0-canary.44
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { toDate } from './toDate'
|
|
2
|
+
|
|
3
|
+
describe('toDate', () => {
|
|
4
|
+
describe('null/undefined input', () => {
|
|
5
|
+
it('returns undefined for null', () => {
|
|
6
|
+
expect(toDate(null)).toBeUndefined()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns undefined for undefined', () => {
|
|
10
|
+
expect(toDate(undefined)).toBeUndefined()
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('Date input', () => {
|
|
15
|
+
it('returns the same Date instance when given a valid Date', () => {
|
|
16
|
+
const input = new Date(2024, 0, 15)
|
|
17
|
+
expect(toDate(input)).toBe(input)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns undefined for an invalid Date instance', () => {
|
|
21
|
+
const invalid = new Date('not a date')
|
|
22
|
+
expect(toDate(invalid)).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('number input', () => {
|
|
27
|
+
it('parses a valid timestamp', () => {
|
|
28
|
+
const timestamp = new Date(2024, 0, 15).getTime()
|
|
29
|
+
const result = toDate(timestamp)
|
|
30
|
+
expect(result).toBeInstanceOf(Date)
|
|
31
|
+
expect(result?.getTime()).toBe(timestamp)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns undefined for NaN', () => {
|
|
35
|
+
expect(toDate(Number.NaN)).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('string input: YYYY-MM-DD', () => {
|
|
40
|
+
it('parses a date-only string as local time (not UTC)', () => {
|
|
41
|
+
const result = toDate('2024-01-15')
|
|
42
|
+
expect(result).toBeInstanceOf(Date)
|
|
43
|
+
expect(result?.getFullYear()).toBe(2024)
|
|
44
|
+
expect(result?.getMonth()).toBe(0)
|
|
45
|
+
expect(result?.getDate()).toBe(15)
|
|
46
|
+
expect(result?.getHours()).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('string input: ISO datetime with offset', () => {
|
|
51
|
+
it('does not mangle a timezone offset (regression test)', () => {
|
|
52
|
+
const result = toDate('2024-01-15T10:30:00-05:00')
|
|
53
|
+
expect(result).toBeInstanceOf(Date)
|
|
54
|
+
expect(result?.toISOString()).toBe('2024-01-15T15:30:00.000Z')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('parses a UTC ISO datetime string', () => {
|
|
58
|
+
const result = toDate('2024-01-15T10:30:00Z')
|
|
59
|
+
expect(result).toBeInstanceOf(Date)
|
|
60
|
+
expect(result?.toISOString()).toBe('2024-01-15T10:30:00.000Z')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('string input: d/m/Y H:i:s (Magento DATETIME_SLASH_PHP_FORMAT)', () => {
|
|
65
|
+
it('parses day-first date with time', () => {
|
|
66
|
+
const result = toDate('15/01/2024 10:30:00')
|
|
67
|
+
expect(result).toBeInstanceOf(Date)
|
|
68
|
+
expect(result?.getFullYear()).toBe(2024)
|
|
69
|
+
expect(result?.getMonth()).toBe(0) // January
|
|
70
|
+
expect(result?.getDate()).toBe(15)
|
|
71
|
+
expect(result?.getHours()).toBe(10)
|
|
72
|
+
expect(result?.getMinutes()).toBe(30)
|
|
73
|
+
expect(result?.getSeconds()).toBe(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('parses day-first date without time, defaulting to midnight', () => {
|
|
77
|
+
const result = toDate('15/01/2024')
|
|
78
|
+
expect(result).toBeInstanceOf(Date)
|
|
79
|
+
expect(result?.getFullYear()).toBe(2024)
|
|
80
|
+
expect(result?.getMonth()).toBe(0)
|
|
81
|
+
expect(result?.getDate()).toBe(15)
|
|
82
|
+
expect(result?.getHours()).toBe(0)
|
|
83
|
+
expect(result?.getMinutes()).toBe(0)
|
|
84
|
+
expect(result?.getSeconds()).toBe(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats an unambiguous day value as day-first, not month-first', () => {
|
|
88
|
+
// 25 can only be a day, proving the parser is day-first and not
|
|
89
|
+
// falling back to the native Date constructor's MM/DD assumption
|
|
90
|
+
const result = toDate('25/12/2024 08:00:00')
|
|
91
|
+
expect(result?.getMonth()).toBe(11) // December
|
|
92
|
+
expect(result?.getDate()).toBe(25)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('correctly parses an ambiguous d/m value (both parts <= 12)', () => {
|
|
96
|
+
// regression: native `new Date('01/02/2024')` would parse this as
|
|
97
|
+
// Jan 2 (MM/DD); Magento's format means this is 1 Feb
|
|
98
|
+
const result = toDate('01/02/2024')
|
|
99
|
+
expect(result?.getMonth()).toBe(1) // February
|
|
100
|
+
expect(result?.getDate()).toBe(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('pads single-digit day/month correctly', () => {
|
|
104
|
+
const result = toDate('5/6/2024 09:05:03')
|
|
105
|
+
expect(result?.getMonth()).toBe(5) // June
|
|
106
|
+
expect(result?.getDate()).toBe(5)
|
|
107
|
+
expect(result?.getHours()).toBe(9)
|
|
108
|
+
expect(result?.getMinutes()).toBe(5)
|
|
109
|
+
expect(result?.getSeconds()).toBe(3)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('invalid string input', () => {
|
|
114
|
+
it('returns undefined for garbage input', () => {
|
|
115
|
+
expect(toDate('not a date')).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('returns undefined for empty string', () => {
|
|
119
|
+
expect(toDate('')).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('invalid calendar dates (silent JS normalization)', () => {
|
|
124
|
+
it('rejects Feb 31 instead of rolling over to March', () => {
|
|
125
|
+
expect(toDate('31/02/2026')).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('rejects April 31 (April has 30 days)', () => {
|
|
129
|
+
expect(toDate('31/04/2026')).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects an out-of-range month', () => {
|
|
133
|
+
expect(toDate('25/13/2026')).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('rejects nonsense day/month values', () => {
|
|
137
|
+
expect(toDate('99/99/2026')).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('rejects Feb 30 in ISO date-only format too', () => {
|
|
141
|
+
expect(toDate('2026-02-30')).toBeUndefined()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('accepts a valid leap day', () => {
|
|
145
|
+
const result = toDate('29/02/2024') // 2024 is a leap year
|
|
146
|
+
expect(result?.getMonth()).toBe(1)
|
|
147
|
+
expect(result?.getDate()).toBe(29)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('rejects Feb 29 on a non-leap year', () => {
|
|
151
|
+
expect(toDate('29/02/2026')).toBeUndefined()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -1,12 +1,50 @@
|
|
|
1
1
|
export type DateValue = Date | string | number | null | undefined
|
|
2
2
|
|
|
3
|
+
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
4
|
+
const DMY_RE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds a Date from numeric components and verifies it round-trips. JS silently normalizes
|
|
8
|
+
* overflow (Feb 31 -> Mar 3), so this is the only reliable way to reject nonsense input like
|
|
9
|
+
* "31/02/2026" or "99/99/2026".
|
|
10
|
+
*/
|
|
11
|
+
function fromComponents(
|
|
12
|
+
y: number,
|
|
13
|
+
m: number, // 1-indexed
|
|
14
|
+
d: number,
|
|
15
|
+
h = 0,
|
|
16
|
+
min = 0,
|
|
17
|
+
s = 0,
|
|
18
|
+
): Date | undefined {
|
|
19
|
+
const date = new Date(y, m - 1, d, h, min, s)
|
|
20
|
+
const valid =
|
|
21
|
+
date.getFullYear() === y &&
|
|
22
|
+
date.getMonth() === m - 1 &&
|
|
23
|
+
date.getDate() === d &&
|
|
24
|
+
date.getHours() === h &&
|
|
25
|
+
date.getMinutes() === min &&
|
|
26
|
+
date.getSeconds() === s
|
|
27
|
+
return valid ? date : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
3
30
|
export function toDate(value: DateValue): Date | undefined {
|
|
4
31
|
let date: Date | undefined
|
|
5
32
|
|
|
6
33
|
if (value instanceof Date) {
|
|
7
34
|
date = value
|
|
8
35
|
} else if (typeof value === 'string') {
|
|
9
|
-
|
|
36
|
+
const dmy = value.match(DMY_RE)
|
|
37
|
+
const ymd = value.match(DATE_ONLY_RE)
|
|
38
|
+
|
|
39
|
+
if (dmy) {
|
|
40
|
+
const [, d, m, y, h = '0', min = '0', s = '0'] = dmy
|
|
41
|
+
date = fromComponents(Number(y), Number(m), Number(d), Number(h), Number(min), Number(s))
|
|
42
|
+
} else if (ymd) {
|
|
43
|
+
const [, y, m, d] = ymd
|
|
44
|
+
date = fromComponents(Number(y), Number(m), Number(d))
|
|
45
|
+
} else {
|
|
46
|
+
date = new Date(value)
|
|
47
|
+
}
|
|
10
48
|
} else if (typeof value === 'number') {
|
|
11
49
|
date = new Date(value)
|
|
12
50
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@graphcommerce/next-ui",
|
|
3
3
|
"homepage": "https://www.graphcommerce.org/",
|
|
4
4
|
"repository": "github:graphcommerce-org/graphcommerce",
|
|
5
|
-
"version": "11.0.0-canary.
|
|
5
|
+
"version": "11.0.0-canary.47",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"prettier": "@graphcommerce/prettier-config-pwa",
|
|
8
8
|
"eslintConfig": {
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"@emotion/react": "^11.14.0",
|
|
44
44
|
"@emotion/server": "^11.11.0",
|
|
45
45
|
"@emotion/styled": "^11.14.1",
|
|
46
|
-
"@graphcommerce/eslint-config-pwa": "^11.0.0-canary.
|
|
47
|
-
"@graphcommerce/framer-next-pages": "^11.0.0-canary.
|
|
48
|
-
"@graphcommerce/framer-scroller": "^11.0.0-canary.
|
|
49
|
-
"@graphcommerce/framer-utils": "^11.0.0-canary.
|
|
50
|
-
"@graphcommerce/image": "^11.0.0-canary.
|
|
51
|
-
"@graphcommerce/prettier-config-pwa": "^11.0.0-canary.
|
|
52
|
-
"@graphcommerce/typescript-config-pwa": "^11.0.0-canary.
|
|
46
|
+
"@graphcommerce/eslint-config-pwa": "^11.0.0-canary.47",
|
|
47
|
+
"@graphcommerce/framer-next-pages": "^11.0.0-canary.47",
|
|
48
|
+
"@graphcommerce/framer-scroller": "^11.0.0-canary.47",
|
|
49
|
+
"@graphcommerce/framer-utils": "^11.0.0-canary.47",
|
|
50
|
+
"@graphcommerce/image": "^11.0.0-canary.47",
|
|
51
|
+
"@graphcommerce/prettier-config-pwa": "^11.0.0-canary.47",
|
|
52
|
+
"@graphcommerce/typescript-config-pwa": "^11.0.0-canary.47",
|
|
53
53
|
"@lingui/core": "^5",
|
|
54
54
|
"@lingui/macro": "^5",
|
|
55
55
|
"@lingui/react": "^5",
|
package/tsconfig.json
CHANGED