Skip to content

Commit ddbeced

Browse files
committed
chore: allow bypass delay date on retry
1 parent ba6619c commit ddbeced

File tree

4 files changed

+162
-23
lines changed

4 files changed

+162
-23
lines changed

packages/backend/src/apps/delay/__tests__/delay-until.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type IGlobalVariable } from '@plumber/types'
22

3+
import { DateTime } from 'luxon'
34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
45

56
import StepError from '@/errors/step'
@@ -8,13 +9,15 @@ import delayUntilAction from '../actions/delay-until'
89
import delayApp from '../index'
910

1011
const PAST_DATE = '2023-11-08'
11-
const VALID_DATE = '2025-12-31' // long long time later
12+
const VALID_DATE = '2026-12-31' // long long time later
1213
const VALID_TIME = '12:00'
1314
const DEFAULT_TIME = '00:00'
1415
const INVALID_TIME = '25:00'
16+
const INVALID_DATE = '2025-12-32'
1517

1618
const mocks = vi.hoisted(() => ({
1719
setActionItem: vi.fn(),
20+
getLastExecutionStep: vi.fn(),
1821
}))
1922

2023
describe('Delay until action', () => {
@@ -36,6 +39,7 @@ describe('Delay until action', () => {
3639
name: delayApp.name,
3740
},
3841
setActionItem: mocks.setActionItem,
42+
getLastExecutionStep: mocks.getLastExecutionStep,
3943
} as unknown as IGlobalVariable
4044
})
4145

@@ -88,6 +92,8 @@ describe('Delay until action', () => {
8892
delayUntilTime: DEFAULT_TIME,
8993
}
9094

95+
mocks.getLastExecutionStep.mockResolvedValue(null)
96+
9197
// throw step error
9298
await expect(delayUntilAction.run($)).rejects.toThrowError(StepError)
9399
})
@@ -98,7 +104,82 @@ describe('Delay until action', () => {
98104
delayUntilTime: INVALID_TIME,
99105
}
100106

107+
mocks.getLastExecutionStep.mockResolvedValue(null)
108+
101109
// throw step error
102110
await expect(delayUntilAction.run($)).rejects.toThrowError(StepError)
103111
})
112+
113+
describe('retry logic', () => {
114+
it('uses current date when retrying after invalid timestamp error', async () => {
115+
$.step.parameters = {
116+
delayUntil: INVALID_DATE,
117+
delayUntilTime: VALID_TIME,
118+
}
119+
120+
// Mock last execution step to indicate a retry for invalid timestamp
121+
mocks.getLastExecutionStep.mockResolvedValue({
122+
errorDetails: {
123+
name: 'Invalid timestamp entered',
124+
},
125+
})
126+
127+
const result = await delayUntilAction.run($)
128+
const expectedDate = DateTime.now().toFormat('yyyy-MM-dd')
129+
130+
expect(result).toBeFalsy()
131+
expect(mocks.setActionItem).toBeCalledWith({
132+
raw: { delayUntil: expectedDate, delayUntilTime: DEFAULT_TIME },
133+
})
134+
})
135+
136+
it('allows past timestamp when retrying after past timestamp error', async () => {
137+
$.step.parameters = {
138+
delayUntil: PAST_DATE,
139+
delayUntilTime: DEFAULT_TIME,
140+
}
141+
142+
// Mock last execution step to indicate a retry for past timestamp
143+
mocks.getLastExecutionStep.mockResolvedValue({
144+
errorDetails: {
145+
name: 'Delay until timestamp entered is in the past',
146+
},
147+
})
148+
149+
const result = await delayUntilAction.run($)
150+
151+
expect(result).toBeFalsy()
152+
expect(mocks.setActionItem).toBeCalledWith({
153+
raw: { delayUntil: PAST_DATE, delayUntilTime: DEFAULT_TIME },
154+
})
155+
})
156+
157+
it('throws error when retrying with different error type', async () => {
158+
$.step.parameters = {
159+
delayUntil: INVALID_DATE,
160+
delayUntilTime: VALID_TIME,
161+
}
162+
163+
// Mock last execution step with different error type
164+
mocks.getLastExecutionStep.mockResolvedValue({
165+
errorDetails: {
166+
name: 'Some other error',
167+
},
168+
})
169+
170+
await expect(delayUntilAction.run($)).rejects.toThrowError(StepError)
171+
})
172+
173+
it('handles retry when last execution step has no error details', async () => {
174+
$.step.parameters = {
175+
delayUntil: INVALID_DATE,
176+
delayUntilTime: VALID_TIME,
177+
}
178+
179+
// Mock last execution step without error details
180+
mocks.getLastExecutionStep.mockResolvedValue({})
181+
182+
await expect(delayUntilAction.run($)).rejects.toThrowError(StepError)
183+
})
184+
})
104185
})

packages/backend/src/apps/delay/actions/delay-until/index.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import StepError from '@/errors/step'
66

77
import generateTimestamp from '../../helpers/generate-timestamp'
88

9+
const ERRORS_TO_RETRY = [
10+
'Invalid timestamp entered',
11+
'Delay until timestamp entered is in the past',
12+
]
13+
14+
async function isValidRetry($: any): Promise<boolean> {
15+
const lastExecutionStep = await $.getLastExecutionStep({
16+
sameExecution: true,
17+
})
18+
return ERRORS_TO_RETRY.includes(
19+
lastExecutionStep?.errorDetails?.name as string,
20+
)
21+
}
22+
923
const action: IRawAction = {
1024
name: 'Delay until',
1125
key: 'delayUntil',
@@ -39,21 +53,42 @@ const action: IRawAction = {
3953
? new String(delayUntilTime).trim()
4054
: defaultTime
4155

42-
const delayTimestamp = generateTimestamp(
56+
let delayTimestamp = generateTimestamp(
4357
delayUntilString,
4458
delayUntilTimeString,
4559
)
4660

61+
let dataItem = {
62+
delayUntil: delayUntilString,
63+
delayUntilTime: delayUntilTimeString,
64+
}
65+
66+
/**
67+
* RETRY: we check and only allow manual retries for failures due to:
68+
* - invalid timestamp
69+
* - delay until timestamp entered is in the past
70+
*/
71+
const isRetry = await isValidRetry($)
72+
4773
if (isNaN(delayTimestamp)) {
48-
throw new StepError(
49-
'Invalid timestamp entered',
50-
'Check that the date or time entered is of a valid format.',
51-
$.step.position,
52-
$.app.name,
53-
)
74+
if (isRetry) {
75+
const dateToday = DateTime.now().toFormat('yyyy-MM-dd')
76+
delayTimestamp = generateTimestamp(dateToday, defaultTime)
77+
dataItem = {
78+
delayUntil: dateToday,
79+
delayUntilTime: defaultTime,
80+
}
81+
} else {
82+
throw new StepError(
83+
'Invalid timestamp entered',
84+
'Check that the date or time entered is of a valid format.',
85+
$.step.position,
86+
$.app.name,
87+
)
88+
}
5489
}
5590

56-
if (delayTimestamp < DateTime.now().toMillis()) {
91+
if (delayTimestamp < DateTime.now().toMillis() && !isRetry) {
5792
throw new StepError(
5893
'Delay until timestamp entered is in the past',
5994
'Check that the date and time entered is not in the past.',
@@ -62,11 +97,6 @@ const action: IRawAction = {
6297
)
6398
}
6499

65-
const dataItem = {
66-
delayUntil: delayUntilString,
67-
delayUntilTime: delayUntilTimeString,
68-
}
69-
70100
$.setActionItem({ raw: dataItem })
71101
},
72102
}

packages/frontend/src/components/ExecutionStep/hooks/useExecutionStepStatus.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface UseExecutionStepStatusReturn {
3030
isPartialSuccess: boolean
3131
canRetry: boolean
3232
loading: boolean
33+
customRetryButtonText?: string
3334
}
3435

3536
export function useExecutionStepStatus({
@@ -63,6 +64,22 @@ export function useExecutionStepStatus({
6364
return failureIcon
6465
}, [isPartialSuccess, isStepSuccessful, status])
6566

67+
const customRetryButtonText = useMemo(() => {
68+
// specific to delay until action where we want to allow users to manually
69+
// retry and bypass the errors:
70+
// - Invalid timestamp entered
71+
// - Delay until timestamp entered is in the past
72+
if (
73+
[
74+
'Invalid timestamp entered',
75+
'Delay until timestamp entered is in the past',
76+
].includes(errorDetails?.name)
77+
) {
78+
return 'Retry and send now'
79+
}
80+
return undefined
81+
}, [errorDetails])
82+
6683
return {
6784
app,
6885
appName,
@@ -72,5 +89,6 @@ export function useExecutionStepStatus({
7289
isPartialSuccess,
7390
canRetry,
7491
loading,
92+
customRetryButtonText,
7593
}
7694
}

packages/frontend/src/components/ExecutionStep/index.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,21 @@ export default function ExecutionStep({
4242
}: ExecutionStepProps): React.ReactElement | null {
4343
const { appKey, jobId, errorDetails, status } = executionStep
4444

45-
const { app, appName, statusIcon, hasError, isPartialSuccess, canRetry } =
46-
useExecutionStepStatus({
47-
appKey,
48-
status,
49-
errorDetails,
50-
execution,
51-
jobId,
52-
})
45+
const {
46+
app,
47+
appName,
48+
statusIcon,
49+
hasError,
50+
isPartialSuccess,
51+
canRetry,
52+
customRetryButtonText,
53+
} = useExecutionStepStatus({
54+
appKey,
55+
status,
56+
errorDetails,
57+
execution,
58+
jobId,
59+
})
5360

5461
if (!app) {
5562
return null
@@ -77,7 +84,10 @@ export default function ExecutionStep({
7784
{!isInForEach && canRetry && (
7885
<>
7986
<RetryAllButton execution={execution} />
80-
<RetryButton executionStepId={executionStep.id} />
87+
<RetryButton
88+
executionStepId={executionStep.id}
89+
customButtonText={customRetryButtonText}
90+
/>
8191
</>
8292
)}
8393
</HStack>

0 commit comments

Comments
 (0)