Skip to content

Commit

Permalink
feat: Add argument support in evaluating scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
geekdada committed Nov 25, 2024
1 parent d68aabf commit 03330fd
Show file tree
Hide file tree
Showing 24 changed files with 799 additions and 291 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@hookform/resolvers": "^3.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
Expand Down
2 changes: 2 additions & 0 deletions src/components/ActionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ const ActionsModal = ({
DialogTitle,
DialogFooter,
DialogClose,
DialogDescription,
} = useResponsiveDialog()

return (
<Dialog {...props}>
<DialogContent className="select-none">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{title}</DialogDescription>
</DialogHeader>

<div className="space-y-4">
Expand Down
4 changes: 2 additions & 2 deletions src/components/CodeContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { css } from '@emotion/react'
import { cn } from '@/utils/shadcn'

type CodeContentProps = {
content?: string
} & React.HTMLAttributes<HTMLPreElement>
content?: string | null
} & Omit<React.HTMLAttributes<HTMLPreElement>, 'content'>

const CodeContent = ({ className, content, ...props }: CodeContentProps) => {
return (
Expand Down
8 changes: 4 additions & 4 deletions src/components/ResponsiveDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo, type ReactNode } from 'react'
import React, { memo } from 'react'
import { css } from '@emotion/react'
import tw from 'twin.macro'
import { useMediaQuery } from 'usehooks-ts'
Expand Down Expand Up @@ -28,11 +28,11 @@ const CustomDrawerContent = tw(DrawerContent)`px-6`
const CustomDrawerHeader = tw(DrawerHeader)`px-0`
const CustomDrawerFooter = memo(function CustomDrawerFooter({
children,
}: {
children: ReactNode
}) {
...props
}: React.ComponentPropsWithoutRef<typeof DrawerFooter>) {
return (
<DrawerFooter
{...props}
className="px-0"
css={css`
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
Expand Down
252 changes: 252 additions & 0 deletions src/components/ScriptExecutionProvider/ScriptExecutionProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import React, { createContext, useCallback, useEffect, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import store from 'store2'
import { z } from 'zod'

import CodeContent from '@/components/CodeContent'
import { useResponsiveDialog } from '@/components/ResponsiveDialog'
import { Button } from '@/components/ui/button'
import { useConfirm } from '@/components/UIProvider'
import { EvaluateResult } from '@/types'
import { LastUsedScriptArgument } from '@/utils/constant'
import fetcher from '@/utils/fetcher'

import type { ExecutionOptions, ScriptExecutionContextType } from './types'

export const ScriptExecutionContext = createContext<ScriptExecutionContextType>(
{},
)

export const ScriptExecutionProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const { t } = useTranslation()

const [execution, setExecution] =
useState<ScriptExecutionContextType['execution']>()
const confirm = useConfirm()

const {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
DialogDescription,
} = useResponsiveDialog()

const getLastUsedScriptArgument = useCallback(() => {
return store.get(LastUsedScriptArgument)
}, [])

const setLastUsedScriptArgument = useCallback((argument: string) => {
store.set(LastUsedScriptArgument, argument)
}, [])

const clearLastUsedScriptArgument = useCallback(() => {
store.remove(LastUsedScriptArgument)
}, [])

const evaluateCronScript = useCallback(async (scriptName: string) => {
const res = await fetcher<EvaluateResult>({
url: '/scripting/cron/evaluate',
method: 'POST',
data: {
script_name: scriptName,
},
timeout: 60000,
}).catch((error: Error) => error)

if (res instanceof Error) {
const result = {
isLoading: false,
result: null,
error: res,
done: true,
}

setExecution(result)
return result
}

if (res.exception) {
const result = {
isLoading: false,
result: null,
error: new Error(res.exception),
done: true,
}
setExecution(result)
return result
}

const result = {
isLoading: false,
result: res.output,
error: null,
done: true,
}

setExecution(result)
return result
}, [])

const execute = useCallback(
async (code: string, options: ExecutionOptions = {}) => {
const { timeout = 5 } = options
const confirmation = await confirm({
type: 'form',
title: t('scripting.script_argument'),
description: t('scripting.define_script_argument'),
confirmText: t('scripting.run_script_button_title'),
cancelText: t('common.go_back'),
form: {
argument: z.string().optional(),
saveForLater: z.boolean().optional(),
},
formLabels: {
argument: t('scripting.argument'),
saveForLater: t('scripting.save_for_later'),
},
formDefaultValues: {
argument: getLastUsedScriptArgument() || '',
saveForLater: getLastUsedScriptArgument() ? true : false,
},
formDescriptions: {
saveForLater: t('scripting.save_for_later_description'),
},
})

if (!confirmation) {
return undefined
}

setExecution({ isLoading: true, result: null, error: null, done: false })

if (confirmation.saveForLater && confirmation.argument) {
setLastUsedScriptArgument(confirmation.argument)
} else {
clearLastUsedScriptArgument()
}

const res = await fetcher<EvaluateResult>({
url: '/scripting/evaluate',
method: 'POST',
data: {
script_text: code,
mock_type: 'cron',
argument: confirmation.argument,
},
timeout: timeout * 1000 + 500,
}).catch((error: Error) => error)

if (res instanceof Error) {
const result = {
isLoading: false,
result: null,
error: res,
done: true,
}

setExecution(result)
return result
}

if (res.exception) {
const result = {
isLoading: false,
result: null,
error: new Error(res.exception),
done: true,
}
setExecution(result)
return result
}

const result = {
isLoading: false,
result: res.output,
error: null,
done: true,
}

setExecution(result)
return result
},
[
clearLastUsedScriptArgument,
confirm,
getLastUsedScriptArgument,
setLastUsedScriptArgument,
t,
],
)

const clearExecution = useCallback(() => {
setExecution(undefined)
}, [])

useEffect(() => {
if (execution?.error) {
toast.error(execution.error.message)
}
}, [execution?.error])

return (
<ScriptExecutionContext.Provider
value={{ execution, evaluateCronScript, execute, clearExecution }}
>
{children}

<Dialog
open={execution?.done && !execution?.error}
onOpenChange={(open) => {
if (!open) {
clearExecution()
}
}}
>
<DialogContent className="flex flex-col max-h-[90%]">
<DialogHeader>
<DialogTitle>{t('scripting.result')}</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
{t('scripting.result')}
</DialogDescription>
<div className="w-full overflow-x-hidden overflow-y-scroll">
<CodeContent
content={
execution?.result || t('scripting.success_without_result_text')
}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button autoFocus variant="default">
{t('common.close')}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</ScriptExecutionContext.Provider>
)
}

export const withScriptExecutionProvider = (Component: React.ComponentType) => {
const WrappedComponent = (props: any) => (
<ScriptExecutionProvider>
<Component {...props} />
</ScriptExecutionProvider>
)

WrappedComponent.displayName = `withScriptExecutionProvider(${Component.displayName || Component.name || 'Component'})`

return WrappedComponent
}

export default withScriptExecutionProvider
24 changes: 24 additions & 0 deletions src/components/ScriptExecutionProvider/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useContext } from 'react'

import { ScriptExecutionContext } from './ScriptExecutionProvider'

export const useExecuteScript = () => {
const context = useContext(ScriptExecutionContext)

if (
!context.execute ||
!context.evaluateCronScript ||
!context.clearExecution
) {
throw new Error(
'useExecuteScript must be used within a ScriptExecutionProvider',
)
}

return {
execute: context.execute,
evaluateCronScript: context.evaluateCronScript,
execution: context.execution,
clearExecution: context.clearExecution,
}
}
5 changes: 5 additions & 0 deletions src/components/ScriptExecutionProvider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
ScriptExecutionProvider,
withScriptExecutionProvider,
} from './ScriptExecutionProvider'
export * from './hooks'
22 changes: 22 additions & 0 deletions src/components/ScriptExecutionProvider/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type ExecutionOptions = {
timeout?: number
}

export type ExecutionResult = {
isLoading: boolean
done: boolean
result: string | null
error: Error | null
}

export type ScriptExecutionContextType = {
execution?: ExecutionResult
evaluateCronScript?: (
scriptName: string,
) => Promise<ExecutionResult | undefined>
execute?: (
code: string,
options?: ExecutionOptions,
) => Promise<ExecutionResult | undefined>
clearExecution?: () => void
}
Loading

0 comments on commit 03330fd

Please sign in to comment.