Skip to content

Commit 03330fd

Browse files
committed
feat: Add argument support in evaluating scripts
1 parent d68aabf commit 03330fd

24 files changed

+799
-291
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"@hookform/resolvers": "^3.2.0",
4343
"@radix-ui/react-alert-dialog": "^1.1.1",
4444
"@radix-ui/react-checkbox": "^1.0.4",
45-
"@radix-ui/react-dialog": "^1.1.1",
45+
"@radix-ui/react-dialog": "^1.1.2",
4646
"@radix-ui/react-dropdown-menu": "^2.0.5",
4747
"@radix-ui/react-icons": "^1.3.0",
4848
"@radix-ui/react-label": "^2.0.2",

src/components/ActionsModal.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ const ActionsModal = ({
2929
DialogTitle,
3030
DialogFooter,
3131
DialogClose,
32+
DialogDescription,
3233
} = useResponsiveDialog()
3334

3435
return (
3536
<Dialog {...props}>
3637
<DialogContent className="select-none">
3738
<DialogHeader>
3839
<DialogTitle>{title}</DialogTitle>
40+
<DialogDescription className="sr-only">{title}</DialogDescription>
3941
</DialogHeader>
4042

4143
<div className="space-y-4">

src/components/CodeContent.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { css } from '@emotion/react'
44
import { cn } from '@/utils/shadcn'
55

66
type CodeContentProps = {
7-
content?: string
8-
} & React.HTMLAttributes<HTMLPreElement>
7+
content?: string | null
8+
} & Omit<React.HTMLAttributes<HTMLPreElement>, 'content'>
99

1010
const CodeContent = ({ className, content, ...props }: CodeContentProps) => {
1111
return (

src/components/ResponsiveDialog.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { memo, type ReactNode } from 'react'
1+
import React, { memo } from 'react'
22
import { css } from '@emotion/react'
33
import tw from 'twin.macro'
44
import { useMediaQuery } from 'usehooks-ts'
@@ -28,11 +28,11 @@ const CustomDrawerContent = tw(DrawerContent)`px-6`
2828
const CustomDrawerHeader = tw(DrawerHeader)`px-0`
2929
const CustomDrawerFooter = memo(function CustomDrawerFooter({
3030
children,
31-
}: {
32-
children: ReactNode
33-
}) {
31+
...props
32+
}: React.ComponentPropsWithoutRef<typeof DrawerFooter>) {
3433
return (
3534
<DrawerFooter
35+
{...props}
3636
className="px-0"
3737
css={css`
3838
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import React, { createContext, useCallback, useEffect, useState } from 'react'
2+
import { toast } from 'react-hot-toast'
3+
import { useTranslation } from 'react-i18next'
4+
import store from 'store2'
5+
import { z } from 'zod'
6+
7+
import CodeContent from '@/components/CodeContent'
8+
import { useResponsiveDialog } from '@/components/ResponsiveDialog'
9+
import { Button } from '@/components/ui/button'
10+
import { useConfirm } from '@/components/UIProvider'
11+
import { EvaluateResult } from '@/types'
12+
import { LastUsedScriptArgument } from '@/utils/constant'
13+
import fetcher from '@/utils/fetcher'
14+
15+
import type { ExecutionOptions, ScriptExecutionContextType } from './types'
16+
17+
export const ScriptExecutionContext = createContext<ScriptExecutionContextType>(
18+
{},
19+
)
20+
21+
export const ScriptExecutionProvider = ({
22+
children,
23+
}: {
24+
children: React.ReactNode
25+
}) => {
26+
const { t } = useTranslation()
27+
28+
const [execution, setExecution] =
29+
useState<ScriptExecutionContextType['execution']>()
30+
const confirm = useConfirm()
31+
32+
const {
33+
Dialog,
34+
DialogContent,
35+
DialogHeader,
36+
DialogTitle,
37+
DialogFooter,
38+
DialogClose,
39+
DialogDescription,
40+
} = useResponsiveDialog()
41+
42+
const getLastUsedScriptArgument = useCallback(() => {
43+
return store.get(LastUsedScriptArgument)
44+
}, [])
45+
46+
const setLastUsedScriptArgument = useCallback((argument: string) => {
47+
store.set(LastUsedScriptArgument, argument)
48+
}, [])
49+
50+
const clearLastUsedScriptArgument = useCallback(() => {
51+
store.remove(LastUsedScriptArgument)
52+
}, [])
53+
54+
const evaluateCronScript = useCallback(async (scriptName: string) => {
55+
const res = await fetcher<EvaluateResult>({
56+
url: '/scripting/cron/evaluate',
57+
method: 'POST',
58+
data: {
59+
script_name: scriptName,
60+
},
61+
timeout: 60000,
62+
}).catch((error: Error) => error)
63+
64+
if (res instanceof Error) {
65+
const result = {
66+
isLoading: false,
67+
result: null,
68+
error: res,
69+
done: true,
70+
}
71+
72+
setExecution(result)
73+
return result
74+
}
75+
76+
if (res.exception) {
77+
const result = {
78+
isLoading: false,
79+
result: null,
80+
error: new Error(res.exception),
81+
done: true,
82+
}
83+
setExecution(result)
84+
return result
85+
}
86+
87+
const result = {
88+
isLoading: false,
89+
result: res.output,
90+
error: null,
91+
done: true,
92+
}
93+
94+
setExecution(result)
95+
return result
96+
}, [])
97+
98+
const execute = useCallback(
99+
async (code: string, options: ExecutionOptions = {}) => {
100+
const { timeout = 5 } = options
101+
const confirmation = await confirm({
102+
type: 'form',
103+
title: t('scripting.script_argument'),
104+
description: t('scripting.define_script_argument'),
105+
confirmText: t('scripting.run_script_button_title'),
106+
cancelText: t('common.go_back'),
107+
form: {
108+
argument: z.string().optional(),
109+
saveForLater: z.boolean().optional(),
110+
},
111+
formLabels: {
112+
argument: t('scripting.argument'),
113+
saveForLater: t('scripting.save_for_later'),
114+
},
115+
formDefaultValues: {
116+
argument: getLastUsedScriptArgument() || '',
117+
saveForLater: getLastUsedScriptArgument() ? true : false,
118+
},
119+
formDescriptions: {
120+
saveForLater: t('scripting.save_for_later_description'),
121+
},
122+
})
123+
124+
if (!confirmation) {
125+
return undefined
126+
}
127+
128+
setExecution({ isLoading: true, result: null, error: null, done: false })
129+
130+
if (confirmation.saveForLater && confirmation.argument) {
131+
setLastUsedScriptArgument(confirmation.argument)
132+
} else {
133+
clearLastUsedScriptArgument()
134+
}
135+
136+
const res = await fetcher<EvaluateResult>({
137+
url: '/scripting/evaluate',
138+
method: 'POST',
139+
data: {
140+
script_text: code,
141+
mock_type: 'cron',
142+
argument: confirmation.argument,
143+
},
144+
timeout: timeout * 1000 + 500,
145+
}).catch((error: Error) => error)
146+
147+
if (res instanceof Error) {
148+
const result = {
149+
isLoading: false,
150+
result: null,
151+
error: res,
152+
done: true,
153+
}
154+
155+
setExecution(result)
156+
return result
157+
}
158+
159+
if (res.exception) {
160+
const result = {
161+
isLoading: false,
162+
result: null,
163+
error: new Error(res.exception),
164+
done: true,
165+
}
166+
setExecution(result)
167+
return result
168+
}
169+
170+
const result = {
171+
isLoading: false,
172+
result: res.output,
173+
error: null,
174+
done: true,
175+
}
176+
177+
setExecution(result)
178+
return result
179+
},
180+
[
181+
clearLastUsedScriptArgument,
182+
confirm,
183+
getLastUsedScriptArgument,
184+
setLastUsedScriptArgument,
185+
t,
186+
],
187+
)
188+
189+
const clearExecution = useCallback(() => {
190+
setExecution(undefined)
191+
}, [])
192+
193+
useEffect(() => {
194+
if (execution?.error) {
195+
toast.error(execution.error.message)
196+
}
197+
}, [execution?.error])
198+
199+
return (
200+
<ScriptExecutionContext.Provider
201+
value={{ execution, evaluateCronScript, execute, clearExecution }}
202+
>
203+
{children}
204+
205+
<Dialog
206+
open={execution?.done && !execution?.error}
207+
onOpenChange={(open) => {
208+
if (!open) {
209+
clearExecution()
210+
}
211+
}}
212+
>
213+
<DialogContent className="flex flex-col max-h-[90%]">
214+
<DialogHeader>
215+
<DialogTitle>{t('scripting.result')}</DialogTitle>
216+
</DialogHeader>
217+
<DialogDescription className="sr-only">
218+
{t('scripting.result')}
219+
</DialogDescription>
220+
<div className="w-full overflow-x-hidden overflow-y-scroll">
221+
<CodeContent
222+
content={
223+
execution?.result || t('scripting.success_without_result_text')
224+
}
225+
/>
226+
</div>
227+
<DialogFooter>
228+
<DialogClose asChild>
229+
<Button autoFocus variant="default">
230+
{t('common.close')}
231+
</Button>
232+
</DialogClose>
233+
</DialogFooter>
234+
</DialogContent>
235+
</Dialog>
236+
</ScriptExecutionContext.Provider>
237+
)
238+
}
239+
240+
export const withScriptExecutionProvider = (Component: React.ComponentType) => {
241+
const WrappedComponent = (props: any) => (
242+
<ScriptExecutionProvider>
243+
<Component {...props} />
244+
</ScriptExecutionProvider>
245+
)
246+
247+
WrappedComponent.displayName = `withScriptExecutionProvider(${Component.displayName || Component.name || 'Component'})`
248+
249+
return WrappedComponent
250+
}
251+
252+
export default withScriptExecutionProvider
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useContext } from 'react'
2+
3+
import { ScriptExecutionContext } from './ScriptExecutionProvider'
4+
5+
export const useExecuteScript = () => {
6+
const context = useContext(ScriptExecutionContext)
7+
8+
if (
9+
!context.execute ||
10+
!context.evaluateCronScript ||
11+
!context.clearExecution
12+
) {
13+
throw new Error(
14+
'useExecuteScript must be used within a ScriptExecutionProvider',
15+
)
16+
}
17+
18+
return {
19+
execute: context.execute,
20+
evaluateCronScript: context.evaluateCronScript,
21+
execution: context.execution,
22+
clearExecution: context.clearExecution,
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export {
2+
ScriptExecutionProvider,
3+
withScriptExecutionProvider,
4+
} from './ScriptExecutionProvider'
5+
export * from './hooks'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export type ExecutionOptions = {
2+
timeout?: number
3+
}
4+
5+
export type ExecutionResult = {
6+
isLoading: boolean
7+
done: boolean
8+
result: string | null
9+
error: Error | null
10+
}
11+
12+
export type ScriptExecutionContextType = {
13+
execution?: ExecutionResult
14+
evaluateCronScript?: (
15+
scriptName: string,
16+
) => Promise<ExecutionResult | undefined>
17+
execute?: (
18+
code: string,
19+
options?: ExecutionOptions,
20+
) => Promise<ExecutionResult | undefined>
21+
clearExecution?: () => void
22+
}

0 commit comments

Comments
 (0)