Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"dependencies": {
"@apollo/client": "^4.0.9",
"@code0-tech/pictor": "^0.11.1",
"@code0-tech/triangulum": "^0.26.2",
"@code0-tech/triangulum": "^0.27.0",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lint": "^6.9.5",
"@icons-pack/react-simple-icons": "^13.13.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from "@code0-tech/pictor/dist/components/resizable/Resizable";
import {Layout} from "@code0-tech/pictor/dist/components/layout/Layout";
import {FlowExecutionResultView} from "@edition/flow/views/FlowExecutionResultView";
import {FlowExecutionWatcherComponent} from "@edition/flow/components/FlowExecutionWatcherComponent";
import {useFlowViewStore} from "@edition/flow/hooks/Flow.view.hook";
import {useHotkeys} from "react-hotkeys-hook";
import {Node, useReactFlow} from "@xyflow/react";

Expand All @@ -28,17 +30,18 @@ export default function Page() {
const flowIndex = params.flowId as any as number
const flowId: Flow['id'] = `gid://sagittarius/Flow/${flowIndex}`

const [tab, setTab] = React.useState<string | undefined>(undefined);
const tab = useFlowViewStore(s => s.tab)
const toggleTab = useFlowViewStore(s => s.toggleTab)
const reactFlow = useReactFlow()

useHotkeys('shift+1', (keyboardEvent) => {
setTab(prevState => prevState === "file" ? undefined : "file")
toggleTab("file")
keyboardEvent.stopPropagation()
keyboardEvent.preventDefault()
}, [])

useHotkeys('shift+2', (keyboardEvent) => {
setTab(prevState => prevState === "execution" ? undefined : "execution")
toggleTab("execution")
keyboardEvent.stopPropagation()
keyboardEvent.preventDefault()
}, [])
Expand All @@ -57,15 +60,16 @@ export default function Page() {
}, [tab, reactFlow])

return <ResizablePanel id={"2"}>
<FlowExecutionWatcherComponent/>
<Layout layoutGap={0} showLayoutSplitter={false} rightContent={
<Flex pl={0.7} style={{flexDirection: "column", gap: "0.7rem"}}>
<Button aria-selected={tab === "file"}
onClick={() => setTab(prevState => prevState === "file" ? undefined : "file")} variant={"none"}
onClick={() => toggleTab("file")} variant={"none"}
paddingSize={"xs"}>
<IconFile size={16}/>
</Button>
<Button aria-selected={tab === "execution"}
onClick={() => setTab(prevState => prevState === "execution" ? undefined : "execution")}
onClick={() => toggleTab("execution")}
variant={"none"}
paddingSize={"xs"}>
<IconPlayerPlay size={16}/>
Expand Down
3 changes: 3 additions & 0 deletions src/packages/ce/src/flow/components/Flow.worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ addEventListener("message", (event) => {
result = 0;
break;
case 'schema':
// getSignatureSchema now returns a SignatureSchema ({nodeId, parameters, return}).
// We forward the whole object so callers can associate parameters by nodeId;
// the return schema is unused for now.
result = getSignatureSchema(payload.flow, payload.dataTypes, payload.functions, payload.nodeId);
break;
}
Expand Down
262 changes: 262 additions & 0 deletions src/packages/ce/src/flow/components/FlowExecuteDialogComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import React from "react";
import {
Button,
Dialog,
DialogContent,
DialogOverlay,
DialogPortal,
Flex,
Spacing,
Text,
useForm,
useService,
useStore
} from "@code0-tech/pictor";
import {ButtonGroup} from "@code0-tech/pictor/dist/components/button-group/ButtonGroup";
import {InputWrapper} from "@code0-tech/pictor/dist/components/form/InputWrapper";
import {IconCheck, IconCopy} from "@tabler/icons-react";
import {FlowService} from "@edition/flow/services/Flow.service";
import {FlowTypeService} from "@edition/flowtype/services/FlowType.service";
import {ProjectService} from "@edition/project/services/Project.service";
import {ModuleService} from "@edition/module/services/Module.service";
import {DatatypeService} from "@edition/datatype/services/Datatype.service";
import {FunctionService} from "@edition/function/services/Function.service";
import {useCopyToClipboard} from "@uidotdev/usehooks";
import {Flow, LiteralValue, Namespace, NamespaceProject} from "@code0-tech/sagittarius-graphql-types";
import {useSchemaAction} from "@edition/flow/components/FlowWorkerProvider";
import {DataTypeInputComponent} from "@edition/datatype/components/inputs/DataTypeInputComponent";
import {useFlowExecutionStore} from "@edition/flow/hooks/Flow.execution.hook";
import {useFlowViewStore} from "@edition/flow/hooks/Flow.view.hook";
import {Schema} from "@code0-tech/triangulum/dist/util/schema.util";

export interface FlowExecuteDialogComponentProps {
flowId: Flow['id']
namespaceId: Namespace['id']
projectId: NamespaceProject['id']
open?: boolean
onOpenChange?: (open: boolean) => void
}

interface ManualExecutionForm {
input?: LiteralValue
}

export const FlowExecuteDialogComponent: React.FC<FlowExecuteDialogComponentProps> = (props) => {

const {flowId, namespaceId, projectId, open, onOpenChange} = props

const flowService = useService(FlowService)
const flowStore = useStore(FlowService)
const flowTypeService = useService(FlowTypeService)
const flowTypeStore = useStore(FlowTypeService)
const projectService = useService(ProjectService)
const projectStore = useStore(ProjectService)
const moduleService = useService(ModuleService)
const moduleStore = useStore(ModuleService)
const dataTypeService = useService(DatatypeService)
const dataTypeStore = useStore(DatatypeService)
const functionService = useService(FunctionService)
const functionStore = useStore(FunctionService)

const {execute} = useSchemaAction()
const addExecution = useFlowExecutionStore(s => s.addExecution)
const openTab = useFlowViewStore(s => s.setTab)

const [copiedText, copyToClipboard] = useCopyToClipboard();
const hasCopiedText = Boolean(copiedText);

const [triggerSchema, setTriggerSchema] = React.useState<Schema | undefined>(undefined)
const [executing, setExecuting] = React.useState(false)
// Bumping the seed produces a fresh initialValues reference which resets the form
// (there is no explicit reset API on useForm).
const [formSeed, setFormSeed] = React.useState(0)

const initialValues = React.useMemo<ManualExecutionForm>(() => ({input: undefined}), [formSeed])

const [inputs, validate, values] = useForm<ManualExecutionForm>({
initialValues,
})

const flow = React.useMemo(
() => flowService.getById(flowId, {
namespaceId,
projectId
}),
[flowId, flowStore, namespaceId, projectId]
)

const project = React.useMemo(
() => projectService.getById(projectId, {
namespaceId
}),
[projectId, namespaceId, projectStore]
)

const flowType = React.useMemo(
() => flowTypeService.getById(flow?.type?.id, {
namespaceId,
projectId,
runtimeId: project?.primaryRuntime?.id
}),
[flow?.type?.id, namespaceId, projectId, project?.primaryRuntime?.id, flowTypeStore]
)

const module = React.useMemo(
() => moduleService.getById(flowType?.runtimeFlowType?.runtimeModule?.id, {
namespaceId: namespaceId,
projectId: projectId,
runtimeId: project?.primaryRuntime?.id
}),
[flowType?.runtimeFlowType?.runtimeModule?.id, namespaceId, projectId, project?.primaryRuntime?.id, moduleStore]
)

const dataTypes = React.useMemo(
() => dataTypeService.values(),
[dataTypeStore, dataTypeService]
)

const functions = React.useMemo(
() => functionService.values(),
[functionStore, functionService]
)

// The manual execution input is derived from the trigger's return schema (the shape of
// the data the trigger hands to the flow). Resolve it whenever the dialog opens.
React.useEffect(() => {
if (!open || !flow) return
if (dataTypes.length <= 0 || functions.length <= 0) return

let cancelled = false
execute({flow, dataTypes, functions}).then(signatureSchema => {
if (cancelled) return
setTriggerSchema(signatureSchema?.return)
})

return () => {
cancelled = true
}
}, [open, flow, dataTypes.length, functions.length, flow?.editedAt])

let endpoint = `http://${module?.definitions?.nodes?.[0]?.host}:${module?.definitions?.nodes?.[0]?.port}${module?.definitions?.nodes?.[0]?.endpoint}`
.replace("${{project_slug}}", project?.slug ?? "${{project_slug}}")

flow?.settings?.nodes?.forEach(setting => {
endpoint = endpoint.replace(`\${{${setting?.flowSettingIdentifier}}}`, setting?.value)
})

const copyEndpoint = (event: React.MouseEvent<HTMLElement>) => {
if (!navigator?.clipboard?.writeText) {
// Without a secure context there is no Clipboard API and the hook falls back to a
// textarea on document.body, which the modal dialog's focus trap keeps unfocusable,
// so Firefox copies nothing. Run the same fallback inside the dialog instead; the
// copyToClipboard call below still records the copied state.
const dialog = event.currentTarget.closest("[role='dialog']")
if (dialog) {
const textArea = document.createElement("textarea")
textArea.value = endpoint
textArea.style.position = "fixed"
textArea.style.opacity = "0"
dialog.appendChild(textArea)
textArea.select()
document.execCommand("copy")
dialog.removeChild(textArea)
}
}
copyToClipboard(endpoint)
}

const onExecute = React.useCallback(() => {
const runtimeId = project?.primaryRuntime?.id
if (!runtimeId || executing) return

setExecuting(true)
flowService.triggerExecution({
flowId: flowId!,
runtimeId,
input: (values.input?.value ?? {}) as any,
}).then(payload => {
setExecuting(false)
if ((payload?.errors?.length ?? 0) > 0 || !payload?.executionIdentifier) return

addExecution({
executionIdentifier: payload.executionIdentifier,
flowId,
namespaceId,
projectId,
})

setFormSeed(seed => seed + 1)
onOpenChange?.(false)
openTab("execution")
}).catch(() => setExecuting(false))
}, [project?.primaryRuntime?.id, executing, flowService, flowId, values.input, addExecution, namespaceId, projectId, openTab, onOpenChange])

return <Dialog open={open} onOpenChange={(open) => onOpenChange?.(open)}>
<DialogPortal>
<DialogOverlay/>
<DialogContent showCloseButton title={"Manually execute / test your workflow"}>
<Spacing spacing={"xl"}/>
{module?.definitions?.nodes?.[0] && (
<>
<InputWrapper title={"Endpoint"}
description={"The url endpoint to execute this workflow."}
left={flow?.settings?.nodes?.find(setting => setting?.flowSettingIdentifier === "httpMethod")?.value ? (
<Text size={"xs"}>
{flow?.settings?.nodes?.find(setting => setting?.flowSettingIdentifier === "httpMethod")?.value}
</Text>
) : undefined}
right={
<ButtonGroup color={"primary"}>
<Button onClick={copyEndpoint}
paddingSize={"xxs"} variant={"none"} color={"secondary"}>
{hasCopiedText ? <IconCheck size={13}/> :
<IconCopy size={13}/>}
</Button>
</ButtonGroup>
}>
<div style={{
alignSelf: "center",
flex: "1 1 auto"
}}>
<Text>
{endpoint}
</Text>
</div>

</InputWrapper>
<Spacing spacing={"xl"}/>
</>
)}
<hr style={{width: "100%"}} color={"#201e2c"}/>
<Spacing spacing={"xl"}/>
{triggerSchema && triggerSchema.input !== "generic" && (
<>
<DataTypeInputComponent
data-qa-selector={"flow-builder-manual-execution-input"}
title={"Input for manual execution"}
description={"The input passed to the trigger for this manual execution."}
schema={triggerSchema}
clearable
key={"flow-builder-manual-execution-input"}
{...inputs.getInputProps("input")}
onChange={() => validate("input")}
/>
<Spacing spacing={"xl"}/>
</>
)}
<Flex justify={"flex-end"}>
<Button onClick={onExecute}
w={"100%"}
disabled={executing || !project?.primaryRuntime?.id}
color={"tertiary"}
data-qa-selector={"flow-builder-manual-execution-trigger"}>
<Text>
Manually execute workflow
</Text>
</Button>
</Flex>
</DialogContent>
</DialogPortal>
</Dialog>

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client"

import React from "react"
import {useSubscription} from "@apollo/client/react"
import {ExecutionResult, Subscription} from "@code0-tech/sagittarius-graphql-types"
import {FlowExecution, useFlowExecutionStore} from "@edition/flow/hooks/Flow.execution.hook"
import {useService} from "@code0-tech/pictor"
import {FlowService} from "@edition/flow/services/Flow.service"
import flowExecutionResultSubscription from "@edition/flow/services/subscriptions/Flow.executionResult.subscription.graphql"

export interface FlowExecutionSubscriberComponentProps {
execution: FlowExecution
}

export const FlowExecutionSubscriberComponent: React.FC<FlowExecutionSubscriberComponentProps> = ({execution}) => {

const flowService = useService(FlowService)
const removeExecution = useFlowExecutionStore(s => s.removeExecution)

useSubscription<Subscription>(flowExecutionResultSubscription, {
variables: {executionIdentifier: execution.executionIdentifier},
onData: (data) => {
const executionResult = data.data.data?.namespacesProjectsFlowsExecutionResult?.executionResult as ExecutionResult | undefined
if (executionResult) {
flowService.addExecutionResult(execution.flowId, executionResult)
removeExecution(execution.executionIdentifier)
}
},
onComplete: () => removeExecution(execution.executionIdentifier),
onError: () => removeExecution(execution.executionIdentifier),
})

return null
}
Loading