Skip to content

Commit 3b9c5e1

Browse files
committed
feat: add FlowExecuteDialogComponent for manual workflow execution
1 parent abc4b0a commit 3b9c5e1

1 file changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import React from "react";
2+
import {
3+
Button,
4+
Dialog,
5+
DialogContent,
6+
DialogOverlay,
7+
DialogPortal,
8+
Flex,
9+
Spacing,
10+
Text,
11+
useForm,
12+
useService,
13+
useStore
14+
} from "@code0-tech/pictor";
15+
import {ButtonGroup} from "@code0-tech/pictor/dist/components/button-group/ButtonGroup";
16+
import {InputWrapper} from "@code0-tech/pictor/dist/components/form/InputWrapper";
17+
import {IconCheck, IconCopy} from "@tabler/icons-react";
18+
import {FlowService} from "@edition/flow/services/Flow.service";
19+
import {FlowTypeService} from "@edition/flowtype/services/FlowType.service";
20+
import {ProjectService} from "@edition/project/services/Project.service";
21+
import {ModuleService} from "@edition/module/services/Module.service";
22+
import {DatatypeService} from "@edition/datatype/services/Datatype.service";
23+
import {FunctionService} from "@edition/function/services/Function.service";
24+
import {useCopyToClipboard} from "@uidotdev/usehooks";
25+
import {Flow, LiteralValue, Namespace, NamespaceProject} from "@code0-tech/sagittarius-graphql-types";
26+
import {useSchemaAction} from "@edition/flow/components/FlowWorkerProvider";
27+
import {DataTypeInputComponent} from "@edition/datatype/components/inputs/DataTypeInputComponent";
28+
import {useFlowExecutionStore} from "@edition/flow/hooks/Flow.execution.hook";
29+
import {useFlowViewStore} from "@edition/flow/hooks/Flow.view.hook";
30+
import {Schema} from "@code0-tech/triangulum/dist/util/schema.util";
31+
32+
export interface FlowExecuteDialogComponentProps {
33+
flowId: Flow['id']
34+
namespaceId: Namespace['id']
35+
projectId: NamespaceProject['id']
36+
open?: boolean
37+
onOpenChange?: (open: boolean) => void
38+
}
39+
40+
interface ManualExecutionForm {
41+
input?: LiteralValue
42+
}
43+
44+
export const FlowExecuteDialogComponent: React.FC<FlowExecuteDialogComponentProps> = (props) => {
45+
46+
const {flowId, namespaceId, projectId, open, onOpenChange} = props
47+
48+
const flowService = useService(FlowService)
49+
const flowStore = useStore(FlowService)
50+
const flowTypeService = useService(FlowTypeService)
51+
const flowTypeStore = useStore(FlowTypeService)
52+
const projectService = useService(ProjectService)
53+
const projectStore = useStore(ProjectService)
54+
const moduleService = useService(ModuleService)
55+
const moduleStore = useStore(ModuleService)
56+
const dataTypeService = useService(DatatypeService)
57+
const dataTypeStore = useStore(DatatypeService)
58+
const functionService = useService(FunctionService)
59+
const functionStore = useStore(FunctionService)
60+
61+
const {execute} = useSchemaAction()
62+
const addExecution = useFlowExecutionStore(s => s.addExecution)
63+
const openTab = useFlowViewStore(s => s.setTab)
64+
65+
const [copiedText, copyToClipboard] = useCopyToClipboard();
66+
const hasCopiedText = Boolean(copiedText);
67+
68+
const [triggerSchema, setTriggerSchema] = React.useState<Schema | undefined>(undefined)
69+
const [executing, setExecuting] = React.useState(false)
70+
// Bumping the seed produces a fresh initialValues reference which resets the form
71+
// (there is no explicit reset API on useForm).
72+
const [formSeed, setFormSeed] = React.useState(0)
73+
74+
const initialValues = React.useMemo<ManualExecutionForm>(() => ({input: undefined}), [formSeed])
75+
76+
const [inputs, validate, values] = useForm<ManualExecutionForm>({
77+
initialValues,
78+
})
79+
80+
const flow = React.useMemo(
81+
() => flowService.getById(flowId, {
82+
namespaceId,
83+
projectId
84+
}),
85+
[flowId, flowStore, namespaceId, projectId]
86+
)
87+
88+
const project = React.useMemo(
89+
() => projectService.getById(projectId, {
90+
namespaceId
91+
}),
92+
[projectId, namespaceId, projectStore]
93+
)
94+
95+
const flowType = React.useMemo(
96+
() => flowTypeService.getById(flow?.type?.id, {
97+
namespaceId,
98+
projectId,
99+
runtimeId: project?.primaryRuntime?.id
100+
}),
101+
[flow?.type?.id, namespaceId, projectId, project?.primaryRuntime?.id, flowTypeStore]
102+
)
103+
104+
const module = React.useMemo(
105+
() => moduleService.getById(flowType?.runtimeFlowType?.runtimeModule?.id, {
106+
namespaceId: namespaceId,
107+
projectId: projectId,
108+
runtimeId: project?.primaryRuntime?.id
109+
}),
110+
[flowType?.runtimeFlowType?.runtimeModule?.id, namespaceId, projectId, project?.primaryRuntime?.id, moduleStore]
111+
)
112+
113+
const dataTypes = React.useMemo(
114+
() => dataTypeService.values(),
115+
[dataTypeStore, dataTypeService]
116+
)
117+
118+
const functions = React.useMemo(
119+
() => functionService.values(),
120+
[functionStore, functionService]
121+
)
122+
123+
// The manual execution input is derived from the trigger's return schema (the shape of
124+
// the data the trigger hands to the flow). Resolve it whenever the dialog opens.
125+
React.useEffect(() => {
126+
if (!open || !flow) return
127+
if (dataTypes.length <= 0 || functions.length <= 0) return
128+
129+
let cancelled = false
130+
execute({flow, dataTypes, functions}).then(signatureSchema => {
131+
if (cancelled) return
132+
setTriggerSchema(signatureSchema?.return)
133+
})
134+
135+
return () => {
136+
cancelled = true
137+
}
138+
}, [open, flow, dataTypes.length, functions.length, flow?.editedAt])
139+
140+
let endpoint = `http://${module?.definitions?.nodes?.[0]?.host}:${module?.definitions?.nodes?.[0]?.port}${module?.definitions?.nodes?.[0]?.endpoint}`
141+
.replace("${{project_slug}}", project?.slug ?? "${{project_slug}}")
142+
143+
flow?.settings?.nodes?.forEach(setting => {
144+
endpoint = endpoint.replace(`\${{${setting?.flowSettingIdentifier}}}`, setting?.value)
145+
})
146+
147+
const copyEndpoint = (event: React.MouseEvent<HTMLElement>) => {
148+
if (!navigator?.clipboard?.writeText) {
149+
// Without a secure context there is no Clipboard API and the hook falls back to a
150+
// textarea on document.body, which the modal dialog's focus trap keeps unfocusable,
151+
// so Firefox copies nothing. Run the same fallback inside the dialog instead; the
152+
// copyToClipboard call below still records the copied state.
153+
const dialog = event.currentTarget.closest("[role='dialog']")
154+
if (dialog) {
155+
const textArea = document.createElement("textarea")
156+
textArea.value = endpoint
157+
textArea.style.position = "fixed"
158+
textArea.style.opacity = "0"
159+
dialog.appendChild(textArea)
160+
textArea.select()
161+
document.execCommand("copy")
162+
dialog.removeChild(textArea)
163+
}
164+
}
165+
copyToClipboard(endpoint)
166+
}
167+
168+
const onExecute = React.useCallback(() => {
169+
const runtimeId = project?.primaryRuntime?.id
170+
if (!runtimeId || executing) return
171+
172+
setExecuting(true)
173+
flowService.triggerExecution({
174+
flowId: flowId!,
175+
runtimeId,
176+
input: (values.input?.value ?? {}) as any,
177+
}).then(payload => {
178+
setExecuting(false)
179+
if ((payload?.errors?.length ?? 0) > 0 || !payload?.executionIdentifier) return
180+
181+
addExecution({
182+
executionIdentifier: payload.executionIdentifier,
183+
flowId,
184+
namespaceId,
185+
projectId,
186+
})
187+
188+
setFormSeed(seed => seed + 1)
189+
onOpenChange?.(false)
190+
openTab("execution")
191+
}).catch(() => setExecuting(false))
192+
}, [project?.primaryRuntime?.id, executing, flowService, flowId, values.input, addExecution, namespaceId, projectId, openTab, onOpenChange])
193+
194+
return <Dialog open={open} onOpenChange={(open) => onOpenChange?.(open)}>
195+
<DialogPortal>
196+
<DialogOverlay/>
197+
<DialogContent showCloseButton title={"Manually execute / test your workflow"}>
198+
<Spacing spacing={"xl"}/>
199+
{module?.definitions?.nodes?.[0] && (
200+
<>
201+
<InputWrapper title={"Endpoint"}
202+
description={"The url endpoint to execute this workflow."}
203+
left={flow?.settings?.nodes?.find(setting => setting?.flowSettingIdentifier === "httpMethod")?.value ? (
204+
<Text size={"xs"}>
205+
{flow?.settings?.nodes?.find(setting => setting?.flowSettingIdentifier === "httpMethod")?.value}
206+
</Text>
207+
) : undefined}
208+
right={
209+
<ButtonGroup color={"primary"}>
210+
<Button onClick={copyEndpoint}
211+
paddingSize={"xxs"} variant={"none"} color={"secondary"}>
212+
{hasCopiedText ? <IconCheck size={13}/> :
213+
<IconCopy size={13}/>}
214+
</Button>
215+
</ButtonGroup>
216+
}>
217+
<div style={{
218+
alignSelf: "center",
219+
flex: "1 1 auto"
220+
}}>
221+
<Text>
222+
{endpoint}
223+
</Text>
224+
</div>
225+
226+
</InputWrapper>
227+
<Spacing spacing={"xl"}/>
228+
</>
229+
)}
230+
<hr style={{width: "100%"}} color={"#201e2c"}/>
231+
<Spacing spacing={"xl"}/>
232+
{triggerSchema && triggerSchema.input !== "generic" && (
233+
<>
234+
<DataTypeInputComponent
235+
data-qa-selector={"flow-builder-manual-execution-input"}
236+
title={"Input for manual execution"}
237+
description={"The input passed to the trigger for this manual execution."}
238+
schema={triggerSchema}
239+
clearable
240+
key={"flow-builder-manual-execution-input"}
241+
{...inputs.getInputProps("input")}
242+
onChange={() => validate("input")}
243+
/>
244+
<Spacing spacing={"xl"}/>
245+
</>
246+
)}
247+
<Flex justify={"flex-end"}>
248+
<Button onClick={onExecute}
249+
w={"100%"}
250+
disabled={executing || !project?.primaryRuntime?.id}
251+
color={"tertiary"}
252+
data-qa-selector={"flow-builder-manual-execution-trigger"}>
253+
<Text>
254+
Manually execute workflow
255+
</Text>
256+
</Button>
257+
</Flex>
258+
</DialogContent>
259+
</DialogPortal>
260+
</Dialog>
261+
262+
}

0 commit comments

Comments
 (0)