diff --git a/package-lock.json b/package-lock.json index 6c3f5d31..4218634f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,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", @@ -1946,9 +1946,9 @@ "peer": true }, "node_modules/@code0-tech/triangulum": { - "version": "0.26.2", - "resolved": "https://registry.npmjs.org/@code0-tech/triangulum/-/triangulum-0.26.2.tgz", - "integrity": "sha512-CFL2WFzJQuxn2KltPyZdjkmQFWgv6ZweqgZK/OmJ/B+lUhV2+ITxPcoqGzvR5b0Elnvt6nRQjcy6etvlYIHDWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@code0-tech/triangulum/-/triangulum-0.27.0.tgz", + "integrity": "sha512-eierLMovuDIq+l9lfVD+P9xHgT0YCgxRWxvhUDYOVLAkdzzIcH7wsRa0dKXBgOaVdGyWC4glFe7m7nzeQafWLg==", "peerDependencies": { "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2668386432-315fff7e9283580109793944cb24db116d9cc264", "@typescript/vfs": "^1.6.4", diff --git a/package.json b/package.json index 950211fc..4d58dc8e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(flow)/namespace/[namespaceId]/project/[projectId]/flow/[flowId]/page.tsx b/src/app/(flow)/namespace/[namespaceId]/project/[projectId]/flow/[flowId]/page.tsx index 0b05d6fd..ef7f3e7d 100644 --- a/src/app/(flow)/namespace/[namespaceId]/project/[projectId]/flow/[flowId]/page.tsx +++ b/src/app/(flow)/namespace/[namespaceId]/project/[projectId]/flow/[flowId]/page.tsx @@ -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"; @@ -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(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() }, []) @@ -57,15 +60,16 @@ export default function Page() { }, [tab, reactFlow]) return + + + }> +
+ + {endpoint} + +
+ + + + + )} +
+ + {triggerSchema && triggerSchema.input !== "generic" && ( + <> + validate("input")} + /> + + + )} + + + + + + + +} diff --git a/src/packages/ce/src/flow/components/FlowExecutionSubscriberComponent.tsx b/src/packages/ce/src/flow/components/FlowExecutionSubscriberComponent.tsx new file mode 100644 index 00000000..f323e8fb --- /dev/null +++ b/src/packages/ce/src/flow/components/FlowExecutionSubscriberComponent.tsx @@ -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 = ({execution}) => { + + const flowService = useService(FlowService) + const removeExecution = useFlowExecutionStore(s => s.removeExecution) + + useSubscription(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 +} diff --git a/src/packages/ce/src/flow/components/FlowExecutionWatcherComponent.tsx b/src/packages/ce/src/flow/components/FlowExecutionWatcherComponent.tsx new file mode 100644 index 00000000..2e89e881 --- /dev/null +++ b/src/packages/ce/src/flow/components/FlowExecutionWatcherComponent.tsx @@ -0,0 +1,23 @@ +"use client" + +import React from "react" +import {useFlowExecutionStore} from "@edition/flow/hooks/Flow.execution.hook" +import {FlowExecutionSubscriberComponent} from "@edition/flow/components/FlowExecutionSubscriberComponent" + +/** + * Keeps one subscription alive per pending manual execution. Each subscriber writes + * the incoming execution result into the flow store and drops the pending entry. + */ +export const FlowExecutionWatcherComponent: React.FC = () => { + + const executions = useFlowExecutionStore(s => s.executions) + + return <> + {executions.map(execution => ( + + ))} + +} diff --git a/src/packages/ce/src/flow/components/FlowWorkerProvider.tsx b/src/packages/ce/src/flow/components/FlowWorkerProvider.tsx index 98414ba7..75bb2223 100644 --- a/src/packages/ce/src/flow/components/FlowWorkerProvider.tsx +++ b/src/packages/ce/src/flow/components/FlowWorkerProvider.tsx @@ -1,6 +1,6 @@ import React from "react" import {DataType, Flow, FunctionDefinition, LiteralValue, NodeFunction} from "@code0-tech/sagittarius-graphql-types"; -import {NodeSchema} from "@code0-tech/triangulum"; +import {SignatureSchema} from "@code0-tech/triangulum"; interface Deferred { resolve: (value: any) => void @@ -182,4 +182,4 @@ export const useValueExtractionAction = () => useWorkerAction("value_extraction"); export const useSchemaAction = () => - useWorkerAction("schema"); \ No newline at end of file + useWorkerAction("schema"); \ No newline at end of file diff --git a/src/packages/ce/src/flow/components/builder/FlowBuilderComponent.tsx b/src/packages/ce/src/flow/components/builder/FlowBuilderComponent.tsx index b45498f2..7acb112c 100644 --- a/src/packages/ce/src/flow/components/builder/FlowBuilderComponent.tsx +++ b/src/packages/ce/src/flow/components/builder/FlowBuilderComponent.tsx @@ -27,7 +27,6 @@ import {FlowPanelLayoutComponent} from "@edition/flow/components/panels/FlowPane import {FlowPanelControlComponent} from "@edition/flow/components/panels/FlowPanelControlComponent"; import {FlowPanelUpdateComponent} from "@edition/flow/components/panels/FlowPanelUpdateComponent"; import {FunctionNodeSquareComponent} from "@edition/function/components/nodes/FunctionNodeSquareComponent"; -import {FlowPanelDefinitionComponent} from "@edition/flow/components/panels/FlowPanelDefinitionComponent"; /** * Dynamically layouts a tree of nodes and their parameter nodes for a flow-based editor. @@ -785,7 +784,6 @@ const InternalFlowBuilder: React.FC = (props) => { - ) : null} diff --git a/src/packages/ce/src/flow/components/panels/FlowPanelControlComponent.tsx b/src/packages/ce/src/flow/components/panels/FlowPanelControlComponent.tsx index 043b4fe9..c283d78d 100644 --- a/src/packages/ce/src/flow/components/panels/FlowPanelControlComponent.tsx +++ b/src/packages/ce/src/flow/components/panels/FlowPanelControlComponent.tsx @@ -28,7 +28,7 @@ import {SuggestionDialogComponent} from "@edition/function/components/suggestion import {useHotkeys} from "react-hotkeys-hook"; import {useSelectedFunctionNode} from "@edition/function/hooks/FunctionNode.selected.hook"; import {useFunctionSuggestions} from "@edition/function/hooks/Function.suggestion.hook"; -import {IconArrowBigUp, IconBackspace, IconLetterA, IconLetterQ} from "@tabler/icons-react"; +import {IconArrowBigUp, IconBackspace, IconLetterA, IconLetterQ, IconLetterX} from "@tabler/icons-react"; import {HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger} from "@radix-ui/react-hover-card"; import 'ldrs/react/ChaoticOrbit.css' import {AIChatComponent} from "@edition/ai/components/AIChatComponent"; @@ -36,6 +36,7 @@ import {mapAiGenerationFlowToFlowInput} from "@edition/ai/util/AI.flow.mapper"; import {addIslandSuccessNotification} from "@code0-tech/pictor/dist/components/island/Island.hook"; import {useFlowCompareStore} from "@edition/flow/hooks/Flow.compare.hook"; import {FlowView} from "@edition/flow/services/Flow.view"; +import {FlowExecuteDialogComponent} from "@edition/flow/components/FlowExecuteDialogComponent"; export interface FlowPanelControlComponentProps { namespaceId: Namespace['id'] @@ -130,6 +131,7 @@ export const FlowPanelControlComponent: React.FC }, [flowId, flowService, flowStore, selectedNode]) const [aiOpen, setAiOpen] = React.useState(false) + const [executeDialogOpen, setExecuteDialogOpen] = React.useState(false) useHotkeys('shift+a', (keyboardEvent) => { if (selectedNode && !selectedNode.data.functionId) setSuggestionDialogOpen(true) @@ -151,6 +153,12 @@ export const FlowPanelControlComponent: React.FC keyboardEvent.preventDefault() }, []) + useHotkeys('shift+x', (keyboardEvent) => { + setExecuteDialogOpen(prevState => !prevState) + keyboardEvent.stopPropagation() + keyboardEvent.preventDefault() + }, []) + return @@ -169,7 +177,7 @@ export const FlowPanelControlComponent: React.FC paddingSize={"xxs"} variant={"none"} color={"error"}> - Delete current node + Delete node @@ -192,7 +200,7 @@ export const FlowPanelControlComponent: React.FC }} color={"tertiary"}> - Add next node + Add node + @@ -217,8 +225,26 @@ export const FlowPanelControlComponent: React.FC + + + diff --git a/src/packages/ce/src/flow/components/panels/FlowPanelDefinitionComponent.tsx b/src/packages/ce/src/flow/components/panels/FlowPanelDefinitionComponent.tsx deleted file mode 100644 index c18fe4c5..00000000 --- a/src/packages/ce/src/flow/components/panels/FlowPanelDefinitionComponent.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from "react"; -import {Panel} from "@xyflow/react"; -import {ButtonGroup} from "@code0-tech/pictor/dist/components/button-group/ButtonGroup"; -import { - Button, - Dialog, - DialogContent, - DialogOverlay, - DialogPortal, - DialogTrigger, - Spacing, - Text, useService, useStore -} from "@code0-tech/pictor"; -import {InputWrapper} from "@code0-tech/pictor/dist/components/form/InputWrapper"; -import {IconCheck, IconCopy, IconPlayerPlay} from "@tabler/icons-react"; -import {useParams} from "next/navigation"; -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 {useCopyToClipboard} from "@uidotdev/usehooks"; -import {Flow, Namespace, NamespaceProject} from "@code0-tech/sagittarius-graphql-types"; - -export const FlowPanelDefinitionComponent: React.FC = () => { - - const params = useParams() - 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 [copiedText, copyToClipboard] = useCopyToClipboard(); - const hasCopiedText = Boolean(copiedText); - - const namespaceIndex = params.namespaceId as any as number - const projectIndex = params.projectId as any as number - const flowIndex = params.flowId as any as number - const namespaceId: Namespace['id'] = `gid://sagittarius/Namespace/${namespaceIndex}` - const projectId: NamespaceProject['id'] = `gid://sagittarius/NamespaceProject/${projectIndex}` - const flowId: Flow['id'] = `gid://sagittarius/Flow/${flowIndex}` - - 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] - ) - - 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) => { - 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) - } - - return module?.definitions?.nodes?.[0] && - - - - - - - - - - - setting?.flowSettingIdentifier === "httpMethod")?.value ? ( - - {flow?.settings?.nodes?.find(setting => setting?.flowSettingIdentifier === "httpMethod")?.value} - - ) : undefined} - right={ - - - - }> -
- - {endpoint} - -
- -
-
-
-
-
-
- -} \ No newline at end of file diff --git a/src/packages/ce/src/flow/hooks/Flow.execution.hook.ts b/src/packages/ce/src/flow/hooks/Flow.execution.hook.ts new file mode 100644 index 00000000..81f4b07a --- /dev/null +++ b/src/packages/ce/src/flow/hooks/Flow.execution.hook.ts @@ -0,0 +1,27 @@ +import {create} from "zustand" +import {Flow, Namespace, NamespaceProject} from "@code0-tech/sagittarius-graphql-types" + +export interface FlowExecution { + executionIdentifier: string + flowId: Flow['id'] + namespaceId: Namespace['id'] + projectId: NamespaceProject['id'] +} + +interface FlowExecutionState { + executions: FlowExecution[] + addExecution: (execution: FlowExecution) => void + removeExecution: (executionIdentifier: string) => void +} + +export const useFlowExecutionStore = create((setState) => ({ + executions: [], + addExecution: (execution) => setState((state) => ({ + executions: state.executions.some(e => e.executionIdentifier === execution.executionIdentifier) + ? state.executions + : [...state.executions, execution] + })), + removeExecution: (executionIdentifier) => setState((state) => ({ + executions: state.executions.filter(e => e.executionIdentifier !== executionIdentifier) + })), +})) diff --git a/src/packages/ce/src/flow/hooks/Flow.nodes.hook.ts b/src/packages/ce/src/flow/hooks/Flow.nodes.hook.ts index 58ef3098..e9ac0b5e 100644 --- a/src/packages/ce/src/flow/hooks/Flow.nodes.hook.ts +++ b/src/packages/ce/src/flow/hooks/Flow.nodes.hook.ts @@ -43,7 +43,7 @@ export const useFlowNodes = (flowId: Flow["id"], namespaceId?: Namespace["id"], flowId: flowId, nodeId: undefined, color: hashToColor(flowId!), - schema: flowSchema?.filter(nodeSchema => nodeSchema?.some(schema => !schema.nodeId))?.flat()! + schema: flowSchema?.find(signatureSchema => !signatureSchema.nodeId)?.parameters ?? [] }, }) @@ -73,7 +73,7 @@ export const useFlowNodes = (flowId: Flow["id"], namespaceId?: Namespace["id"], flowId: flowId, index: globalIndex, color: hashToColor(nodeId), - schema: flowSchema?.filter(nodeSchema => nodeSchema?.some(schema => schema.nodeId === node.id))?.flat()! + schema: flowSchema?.find(signatureSchema => signatureSchema.nodeId === node.id)?.parameters ?? [] }, }) } diff --git a/src/packages/ce/src/flow/hooks/Flow.schema.hook.ts b/src/packages/ce/src/flow/hooks/Flow.schema.hook.ts index 59c8b394..3597e2ec 100644 --- a/src/packages/ce/src/flow/hooks/Flow.schema.hook.ts +++ b/src/packages/ce/src/flow/hooks/Flow.schema.hook.ts @@ -5,13 +5,13 @@ import React from "react"; import {useSchemaAction} from "@edition/flow/components/FlowWorkerProvider"; import {DatatypeService} from "@edition/datatype/services/Datatype.service"; import {FunctionService} from "@edition/function/services/Function.service"; -import {NodeSchema} from "@code0-tech/triangulum"; +import {SignatureSchema} from "@code0-tech/triangulum"; export const useFlowSchema = ( flowId: Flow['id'], namespaceId: Namespace['id'], projectId: NamespaceProject['id'], -): NodeSchema[][] | undefined => { +): SignatureSchema[] | undefined => { const flowService = useService(FlowService) const flowStore = useStore(FlowService) const dataTypeStore = useStore(DatatypeService) @@ -20,7 +20,7 @@ export const useFlowSchema = ( const functionStore = useStore(FunctionService) const {execute} = useSchemaAction() - const [schema, setSchema] = React.useState([]); + const [schema, setSchema] = React.useState([]); const flow = React.useMemo( () => flowService.getById(flowId, {namespaceId, projectId}), @@ -61,7 +61,7 @@ export const useFlowSchema = ( Promise.all([triggerSchema!, ...schemas!]).then((value) => { if (cancelled) return - setSchema(value as NodeSchema[][]) + setSchema(value as SignatureSchema[]) }) return () => { diff --git a/src/packages/ce/src/flow/hooks/Flow.view.hook.ts b/src/packages/ce/src/flow/hooks/Flow.view.hook.ts new file mode 100644 index 00000000..ddfd2fb4 --- /dev/null +++ b/src/packages/ce/src/flow/hooks/Flow.view.hook.ts @@ -0,0 +1,18 @@ +import {create} from "zustand" + +/** + * Shared state for the flow view side panels (file / execution). Lifting this out of + * the page component lets deeply nested components (e.g. the definition panel dialog) + * open a specific panel without prop drilling. + */ +interface FlowViewState { + tab?: string + setTab: (tab?: string) => void + toggleTab: (tab: string) => void +} + +export const useFlowViewStore = create((setState) => ({ + tab: undefined, + setTab: (tab) => setState({tab}), + toggleTab: (tab) => setState((state) => ({tab: state.tab === tab ? undefined : tab})), +})) diff --git a/src/packages/ce/src/flow/services/Flow.service.ts b/src/packages/ce/src/flow/services/Flow.service.ts index 4e21c518..c58e8272 100644 --- a/src/packages/ce/src/flow/services/Flow.service.ts +++ b/src/packages/ce/src/flow/services/Flow.service.ts @@ -1,5 +1,6 @@ import {ReactiveArrayService, ReactiveArrayStore} from "@code0-tech/pictor"; import { + ExecutionResult, FlowInput, FlowSetting, FlowType, @@ -13,6 +14,8 @@ import { NamespacesProjectsFlowsCreatePayload, NamespacesProjectsFlowsDeleteInput, NamespacesProjectsFlowsDeletePayload, + NamespacesProjectsFlowsTriggerExecutionInput, + NamespacesProjectsFlowsTriggerExecutionPayload, NamespacesProjectsFlowsUpdateInput, NamespacesProjectsFlowsUpdatePayload, NodeFunction, @@ -29,6 +32,7 @@ import flowQuery from "@edition/flow/services/queries/Flow.query.graphql"; import flowCreateMutation from "@edition/flow/services/mutations/Flow.create.mutation.graphql"; import flowDeleteMutation from "@edition/flow/services/mutations/Flow.delete.mutation.graphql"; import flowUpdateMutation from "@edition/flow/services/mutations/Flow.update.mutation.graphql"; +import flowTriggerExecutionMutation from "@edition/flow/services/mutations/Flow.triggerExecution.mutation.graphql"; import {View} from "@code0-tech/pictor/dist/utils/view"; import {FlowView} from "@edition/flow/services/Flow.view"; @@ -537,4 +541,32 @@ export class FlowService extends ReactiveArrayService { + const result = await this.client.mutate({ + mutation: flowTriggerExecutionMutation, + variables: { + ...payload + } + }) + + return result.data?.namespacesProjectsFlowsTriggerExecution ?? undefined + } + + addExecutionResult(flowId: FlowView['id'], executionResult: ExecutionResult): void { + const flow = this.getById(flowId) + const index = this.values().findIndex(f => f.id === flowId) + if (!flow || index < 0) return + + const existingNodes = flow.executionResults?.nodes ?? [] + if (existingNodes.some(node => node?.id === executionResult.id)) return + + flow.executionResults = { + __typename: "ExecutionResultConnection", + ...flow.executionResults, + nodes: [...existingNodes, executionResult], + } + + this.set(index, new View(flow)) + } + } \ No newline at end of file diff --git a/src/packages/ce/src/flow/services/mutations/Flow.triggerExecution.mutation.graphql b/src/packages/ce/src/flow/services/mutations/Flow.triggerExecution.mutation.graphql new file mode 100644 index 00000000..490e82d8 --- /dev/null +++ b/src/packages/ce/src/flow/services/mutations/Flow.triggerExecution.mutation.graphql @@ -0,0 +1,17 @@ +mutation flowTriggerExecution($flowId: FlowID!, $input: JSON!, $runtimeId: RuntimeID!) { + namespacesProjectsFlowsTriggerExecution(input: { + flowId: $flowId + input: $input + runtimeId: $runtimeId + }) { + errors { + ...on Error { + errorCode + details { + __typename + } + } + } + executionIdentifier + } +} diff --git a/src/packages/ce/src/flow/services/subscriptions/Flow.executionResult.subscription.graphql b/src/packages/ce/src/flow/services/subscriptions/Flow.executionResult.subscription.graphql new file mode 100644 index 00000000..89ceef15 --- /dev/null +++ b/src/packages/ce/src/flow/services/subscriptions/Flow.executionResult.subscription.graphql @@ -0,0 +1,77 @@ +subscription flowExecutionResult($executionIdentifier: String!) { + namespacesProjectsFlowsExecutionResult(executionIdentifier: $executionIdentifier) { + executionResult { + __typename + id + createdAt + updatedAt + startedAt + finishedAt + input + success + flow { + __typename + id + } + error { + __typename + category + code + dependencies + details + message + timestamp + version + } + nodeResults(first: 50) { + __typename + count + nodes { + __typename + id + createdAt + updatedAt + startedAt + finishedAt + position + success + parameterResults { + __typename + id + createdAt + updatedAt + value + position + } + functionDefinition { + __typename + id + } + nodeFunction { + __typename + id + functionDefinition { + __typename + id + } + } + error { + __typename + category + code + dependencies + details + message + timestamp + version + } + } + pageInfo { + __typename + endCursor + hasNextPage + } + } + } + } +} diff --git a/src/packages/ce/src/flow/views/FlowExecutionResultView.tsx b/src/packages/ce/src/flow/views/FlowExecutionResultView.tsx index 9d0c5bc9..d374cb32 100644 --- a/src/packages/ce/src/flow/views/FlowExecutionResultView.tsx +++ b/src/packages/ce/src/flow/views/FlowExecutionResultView.tsx @@ -57,6 +57,11 @@ import Link from "next/link"; import {FlowTypeService} from "@edition/flowtype/services/FlowType.service"; import {icon} from "@core/util/icons"; import {formatDistanceToNow} from "date-fns"; +import {useFlowExecutionStore} from "@edition/flow/hooks/Flow.execution.hook"; +import {IconLoader2} from "@tabler/icons-react"; +import {motion} from "framer-motion"; +import {LineWobble} from 'ldrs/react' +import 'ldrs/react/LineWobble.css' export interface NodeGanttItem extends GanttItem { data?: { @@ -155,6 +160,33 @@ export const FlowExecutionResultView: React.FC = () => { [flow?.executionResults?.nodes] ) + const executions = useFlowExecutionStore(s => s.executions) + const pendingExecutions = React.useMemo( + () => executions.filter(execution => execution.flowId === flowId), + [executions, flowId] + ) + + // When a manual execution is triggered elsewhere (the definition panel) its pending + // entry appears here; focus its loading tab so the user sees the spinner immediately. + const previousPendingRef = React.useRef([]) + React.useEffect(() => { + const currentIds = pendingExecutions.map(execution => execution.executionIdentifier) + const newlyAdded = currentIds.find(id => !previousPendingRef.current.includes(id)) + if (newlyAdded) setActiveTab(newlyAdded) + previousPendingRef.current = currentIds + }, [pendingExecutions]) + + // Once the pending execution resolves its loading tab disappears; fall back to the + // newest result so the freshly arrived execution result becomes visible. + React.useEffect(() => { + if (!activeTab) return + const stillPending = pendingExecutions.some(execution => execution.executionIdentifier === activeTab) + const isResult = flowExecutionResults.some(result => result?.id === activeTab) + if (!stillPending && !isResult) { + setActiveTab(flowExecutionResults[0]?.id ?? undefined) + } + }, [pendingExecutions, flowExecutionResults, activeTab]) + const ganttItems = React.useMemo>( () => { return new Map(flowExecutionResults.map(result => [result?.id, [ @@ -256,6 +288,21 @@ export const FlowExecutionResultView: React.FC = () => { ) : null } > + {pendingExecutions.map(execution => ( + + + + + + + Executing... + + + + ))} {Array.from(ganttItems)?.map(([id, items]) => { const execution = flowExecutionResults.find(execution => execution?.id === id) @@ -275,6 +322,34 @@ export const FlowExecutionResultView: React.FC = () => { })} }> <> + {pendingExecutions.map(execution => ( + + + + + + Executing flow + + + + We are running your flow. This may take a few moments to load every data object. + + + + ))} { ganttItems.size > 0 ? Array.from(ganttItems)?.map(([id, items]) => { return { }} - }) : ( + }) : pendingExecutions.length > 0 ? null : ( = ( Settings of @{user?.username ?? ""} - + Edit the general settings, permissions and security for user @{user?.username ?? ""} - + - -