Skip to content

Commit d90ca7a

Browse files
m0ngghpregnantboy
andauthored
[PLU-315]: feat: add flexible date comparison for conditions (#790)
## Problem Dates cannot be compared now for conditions. ## Solution Added description to each option instead of separating them into group for now because it is clear enough (Zapier also does this) <img width="383" alt="image" src="https://github.com/user-attachments/assets/04cc62d7-619c-4ddb-8099-956a33bd3c26"> To maintain backwards compatibility, add 2 options: `before` and `after` to compare dates: decided to allow any datetime format because it is not realistic to check for a similar datetime format before comparing. - Convert the datetime to a timestamp for easy comparison - Throw step error if the datetime is invalid - Added unit tests ## Screenshots ### Before https://github.com/user-attachments/assets/2f771b1e-75cd-46c5-a4c3-cc684ec1d9b8 ### After https://github.com/user-attachments/assets/6a3122a0-7868-49ff-a050-3588fbaa46bd ## Tests - [x] Other conditions still work - [x] Before and after datetime works for more common fields e.g. formsg date field and formsg submission time --------- Co-authored-by: Ian Chen <[email protected]>
1 parent 06aa505 commit d90ca7a

File tree

5 files changed

+163
-27
lines changed

5 files changed

+163
-27
lines changed
Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DateTime } from 'luxon'
1+
import { generateTimestampFromFormats } from '@/helpers/generate-timestamp-from-formats'
22

33
const VALID_DATETIME_FORMATS = [
44
'yyyy-MM-dd HH:mm',
@@ -8,22 +8,5 @@ const VALID_DATETIME_FORMATS = [
88

99
export default function generateTimestamp(date: string, time: string): number {
1010
const datetimeString = `${date} ${time}`
11-
// check through our accepted formats
12-
for (const datetimeFormat of VALID_DATETIME_FORMATS) {
13-
// check both en-SG and en-US because Sept accepted for SG but Sep accepted for US
14-
let datetime = DateTime.fromFormat(datetimeString, datetimeFormat, {
15-
locale: 'en-SG',
16-
})
17-
if (datetime.isValid) {
18-
return datetime.toMillis()
19-
}
20-
21-
datetime = DateTime.fromFormat(datetimeString, datetimeFormat, {
22-
locale: 'en-US',
23-
})
24-
if (datetime.isValid) {
25-
return datetime.toMillis()
26-
}
27-
}
28-
return NaN
11+
return generateTimestampFromFormats(datetimeString, VALID_DATETIME_FORMATS)
2912
}

packages/backend/src/apps/toolbox/__tests__/common/condition-is-true.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,38 @@ describe('Condition is true', () => {
9494
expect(result).toEqual(expectedResult)
9595
})
9696

97+
// check all date formats
98+
it.each([
99+
{ text: '05/11/24', expectedResult: true }, // 'dd/LL/yy'
100+
{ text: '03/11/2024', expectedResult: false }, // 'dd/LL/yyyy'
101+
{ text: '05 Nov 2024', expectedResult: true }, // 'dd LLL yyyy'
102+
{ text: '03 November 2024', expectedResult: false }, // 'dd LLLL yyyy'
103+
])('supports before', ({ text, expectedResult }) => {
104+
const result = conditionIsTrue({
105+
field: '04 Nov 2024',
106+
is: 'is',
107+
condition: 'before',
108+
text,
109+
})
110+
111+
expect(result).toEqual(expectedResult)
112+
})
113+
114+
it.each([
115+
{ text: '2024/11/03', expectedResult: true }, // 'yyyy/LL/dd'
116+
{ text: '04 Nov 2024 12:01 AM', expectedResult: false }, // 'dd LLL yyyy hh:mm a'
117+
{ text: '03 Nov 2024 11:59:59 PM', expectedResult: true }, // 'dd LLL yyyy hh:mm:ss a'
118+
])('supports after', ({ text, expectedResult }) => {
119+
const result = conditionIsTrue({
120+
field: '04 Nov 2024',
121+
is: 'is',
122+
condition: 'after',
123+
text,
124+
})
125+
126+
expect(result).toEqual(expectedResult)
127+
})
128+
97129
it.each([
98130
{ field: 'hello', text: 9.9, condition: 'equals', expectedResult: true },
99131
{ field: 10, text: 10, condition: 'gte', expectedResult: false },
@@ -135,6 +167,25 @@ describe('Condition is true', () => {
135167
},
136168
)
137169

170+
it.each([
171+
{ field: 10, condition: 'gte', text: 'abc' },
172+
{ field: 'abc', condition: 'lt', text: 10 },
173+
{ field: '04 Sep 2024', condition: 'before', text: 'abc' },
174+
{ field: '123', condition: 'before', text: '04 Nov 2024' },
175+
])(
176+
'throws an error for invalid field or value for comparison',
177+
({ field, condition, text }) => {
178+
expect(() =>
179+
conditionIsTrue({
180+
field,
181+
is: 'is',
182+
condition,
183+
text,
184+
}),
185+
).toThrowError()
186+
},
187+
)
188+
138189
it('throws an error for unsupported conditions', () => {
139190
expect(() =>
140191
conditionIsTrue({

packages/backend/src/apps/toolbox/common/condition-is-true.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import type { IJSONObject, IJSONValue } from '@plumber/types'
22

3+
import { generateTimestampFromFormats } from '@/helpers/generate-timestamp-from-formats'
4+
35
function compareNumbers(
46
field: IJSONValue,
57
condition: 'gte' | 'gt' | 'lte' | 'lt',
68
value: IJSONValue,
79
): boolean {
810
// WARNING: can only compare safely up till Number.MAX_SAFE_INTEGER but BigInt cannot compare floats...
9-
if (isNaN(Number(field)) || isNaN(Number(value))) {
10-
throw new Error('Non-number used in field or value for comparison')
11+
if (isNaN(Number(field))) {
12+
throw new Error('Non-number used in field for comparison')
13+
}
14+
15+
if (isNaN(Number(value))) {
16+
throw new Error('Non-number used in value for comparison')
1117
}
18+
1219
switch (condition) {
1320
case 'gte':
1421
return Number(field) >= Number(value)
@@ -21,6 +28,46 @@ function compareNumbers(
2128
}
2229
}
2330

31+
// support only formatter date formats
32+
const VALID_DATETIME_FORMATS = [
33+
'dd/LL/yy',
34+
'dd/LL/yyyy',
35+
'dd LLL yyyy',
36+
'dd LLLL yyyy',
37+
'yyyy/LL/dd',
38+
'dd LLL yyyy hh:mm a',
39+
'dd LLL yyyy hh:mm:ss a',
40+
]
41+
42+
function compareDates(
43+
field: IJSONValue,
44+
condition: 'before' | 'after',
45+
value: IJSONValue,
46+
) {
47+
const fieldTimestamp = generateTimestampFromFormats(
48+
field as string,
49+
VALID_DATETIME_FORMATS,
50+
)
51+
if (isNaN(fieldTimestamp)) {
52+
throw new Error('Invalid date used in field for comparison')
53+
}
54+
55+
const valueTimestamp = generateTimestampFromFormats(
56+
value as string,
57+
VALID_DATETIME_FORMATS,
58+
)
59+
if (isNaN(valueTimestamp)) {
60+
throw new Error('Invalid date used in value for comparison')
61+
}
62+
63+
switch (condition) {
64+
case 'before':
65+
return fieldTimestamp < valueTimestamp
66+
case 'after':
67+
return fieldTimestamp > valueTimestamp
68+
}
69+
}
70+
2471
export default function conditionIsTrue(conditionArgs: IJSONObject): boolean {
2572
// `value` is named `text` for legacy reasons.
2673
const { field, is, condition, text: value } = conditionArgs
@@ -30,6 +77,10 @@ export default function conditionIsTrue(conditionArgs: IJSONObject): boolean {
3077
case 'equals':
3178
result = field === value
3279
break
80+
case 'before':
81+
case 'after':
82+
result = compareDates(field, condition, value)
83+
break
3384
case 'gte':
3485
case 'gt':
3586
case 'lte':

packages/backend/src/apps/toolbox/common/get-condition-args.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,39 @@ export default function getConditionArgs({
3939
variables: false,
4040
showOptionValue: false,
4141
options: [
42-
{ label: 'Equals to', value: 'equals' },
43-
{ label: 'Greater than ', value: 'gt' },
44-
{ label: 'Greater than or equals to', value: 'gte' },
45-
{ label: 'Less than', value: 'lt' },
46-
{ label: 'Less than or equals to', value: 'lte' },
47-
{ label: 'Contains', value: 'contains' },
42+
{
43+
label: 'Equals to',
44+
value: 'equals',
45+
},
4846
{ label: 'Empty', value: 'empty' },
47+
{
48+
label: 'Contains',
49+
value: 'contains',
50+
description: 'Used for text',
51+
},
52+
53+
{ label: 'Greater than', value: 'gt', description: 'Used for numbers' },
54+
{
55+
label: 'Greater than or equals to',
56+
value: 'gte',
57+
description: 'Used for numbers',
58+
},
59+
{ label: 'Less than', value: 'lt', description: 'Used for numbers' },
60+
{
61+
label: 'Less than or equals to',
62+
value: 'lte',
63+
description: 'Used for numbers',
64+
},
65+
{
66+
label: 'Before',
67+
value: 'before',
68+
description: 'Used for dates',
69+
},
70+
{
71+
label: 'After',
72+
value: 'after',
73+
description: 'Used for dates',
74+
},
4975
],
5076
},
5177
{
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { DateTime } from 'luxon'
2+
3+
export function generateTimestampFromFormats(
4+
datetimeString: string,
5+
datetimeFormats: string[],
6+
): number {
7+
// check through the list of formats
8+
for (const datetimeFormat of datetimeFormats) {
9+
// check both en-SG and en-US because Sept accepted for SG but Sep accepted for US
10+
let datetime = DateTime.fromFormat(datetimeString, datetimeFormat, {
11+
locale: 'en-SG',
12+
})
13+
if (datetime.isValid) {
14+
return datetime.toMillis()
15+
}
16+
17+
datetime = DateTime.fromFormat(datetimeString, datetimeFormat, {
18+
locale: 'en-US',
19+
})
20+
if (datetime.isValid) {
21+
return datetime.toMillis()
22+
}
23+
}
24+
return NaN
25+
}

0 commit comments

Comments
 (0)