Repository: openops-cloud/openops
Pull request #359: Add connection picker to workflow import
Author: login: rmn-snpk
Created: 2025-04-02T13:01:42Z | Merged: 2025-04-04T13:41:51Z

<!--
Ensure the title clearly reflects what was changed.
Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes.
-->

Fixes OPS-1446.

<!--
Why was it changed?
Any relevant context or links?
Add refactoring notes (if applicable), preferably as comments in the code (if you refactored code, explain what was moved/changed and why).
-->

## Testing Checklist

Check all that apply:

- [X] I tested the feature thoroughly, including edge cases

- [X] I verified all affected areas still work as expected

- [X] Automated tests were added/updated if necessary

- [X] Changes are backwards compatible with any existing data, otherwise a migration script is provided

## Visual Changes (if applicable)

https://www.loom.com/share/51e5c985e6d749ab8115684e68d8ee24?sid=56c4f898-077a-4087-83d3-3867cd79f8d5


Diff:
diff --git a/packages/react-ui/src/app/features/flows/components/flow-actions-menu.tsx b/packages/react-ui/src/app/features/flows/components/flow-actions-menu.tsx
index e10adf4fd3..992046b4a4 100644
--- a/packages/react-ui/src/app/features/flows/components/flow-actions-menu.tsx
+++ b/packages/react-ui/src/app/features/flows/components/flow-actions-menu.tsx
@@ -26,7 +26,7 @@ import { Link } from 'react-router-dom';
 import { ConfirmationDeleteDialog } from '@/app/common/components/delete-dialog';
 import { userSettingsHooks } from '@/app/common/hooks/user-settings-hooks';
 import { SEARCH_PARAMS } from '@/app/constants/search-params';
-import { ImportFlowDialog } from '@/app/features/flows/components/import-flow-dialog';
+import { ImportFlowDialog } from '@/app/features/flows/components/import-flow-dialog/import-flow-dialog';
 import { useRefetchFolderTree } from '@/app/features/folders/hooks/refetch-folder-tree';
 import { authenticationSession } from '@/app/lib/authentication-session';
 import { flowsApi } from '../lib/flows-api';
diff --git a/packages/react-ui/src/app/features/flows/components/flows-page-header.tsx b/packages/react-ui/src/app/features/flows/components/flows-page-header.tsx
index 705dcdbdfc..b28ecf65f2 100644
--- a/packages/react-ui/src/app/features/flows/components/flows-page-header.tsx
+++ b/packages/react-ui/src/app/features/flows/components/flows-page-header.tsx
@@ -1,5 +1,5 @@
 import { CreateNewFlowInFolder } from '@/app/features/flows/components/create-new-flow-in-folder';
-import { ImportFlowDialog } from '@/app/features/flows/components/import-flow-dialog';
+import { ImportFlowDialog } from '@/app/features/flows/components/import-flow-dialog/import-flow-dialog';
 import { SelectFlowTemplateDialog } from '@/app/features/templates/components/select-flow-template-dialog';
 import { Button, PageHeader } from '@openops/components/ui';
 import { t } from 'i18next';
@@ -21,7 +21,7 @@ const FlowsPageHeader = ({
       <PageHeader title={title}>
         <div className="ml-auto flex flex-row gap-2 pr-7">
           {children}
-          <ImportFlowDialog insideBuilder={false}>
+          <ImportFlowDialog>
             <Button variant="outline" className="flex gap-2 items-center">
               <Import className="w-4 h-4" />
               {t('Import Workflow')}
diff --git a/packages/react-ui/src/app/features/flows/components/import-flow-dialog.tsx b/packages/react-ui/src/app/features/flows/components/import-flow-dialog.tsx
deleted file mode 100644
index f0a16e22c4..0000000000
--- a/packages/react-ui/src/app/features/flows/components/import-flow-dialog.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
-  Button,
-  Dialog,
-  DialogContent,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-  DialogTrigger,
-  Input,
-  INTERNAL_ERROR_TOAST,
-  toast,
-} from '@openops/components/ui';
-import {
-  FlowImportTemplate,
-  FlowOperationType,
-  PopulatedFlow,
-} from '@openops/shared';
-import { useMutation } from '@tanstack/react-query';
-import { t } from 'i18next';
-import React, { useRef, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import { flowsApi } from '../lib/flows-api';
-
-import { userSettingsHooks } from '@/app/common/hooks/user-settings-hooks';
-import { SEARCH_PARAMS } from '@/app/constants/search-params';
-import { authenticationSession } from '@/app/lib/authentication-session';
-
-const ImportFlowDialog = ({ children }: { children: React.ReactNode }) => {
-  const navigate = useNavigate();
-  const [file, setFile] = useState<File | null>(null);
-  const fileInputRef = useRef<HTMLInputElement>(null);
-  const { updateHomePageOperationalViewFlag } =
-    userSettingsHooks.useHomePageOperationalView();
-
-  const { mutate: createFlow, isPending } = useMutation<
-    PopulatedFlow,
-    Error,
-    FlowImportTemplate
-  >({
-    mutationFn: async (template: FlowImportTemplate) => {
-      const newFlow = await flowsApi.create({
-        displayName: template.name,
-        projectId: authenticationSession.getProjectId()!,
-      });
-      return await flowsApi.update(newFlow.id, {
-        type: FlowOperationType.IMPORT_FLOW,
-        request: {
-          displayName: template.name,
-          description: template.template.description,
-          trigger: template.template.trigger,
-        },
-      });
-    },
-    onSuccess: (flow) => {
-      updateHomePageOperationalViewFlag();
-      navigate(`/flows/${flow.id}?${SEARCH_PARAMS.viewOnly}=true`);
-    },
-    onError: () => {
-      toast(INTERNAL_ERROR_TOAST);
-    },
-  });
-
-  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    setFile(event.target.files?.[0] || null);
-  };
-
-  const handleSubmit = async () => {
-    if (!file) return;
-
-    const reader = new FileReader();
-    reader.onload = () => {
-      try {
-        const template = JSON.parse(
-          reader.result as string,
-        ) as FlowImportTemplate;
-        // TODO handle overwriting flow when using actions in builder
-        createFlow(template);
-      } catch (error) {
-        toast(INTERNAL_ERROR_TOAST);
-      }
-    };
-    reader.readAsText(file);
-  };
-
-  return (
-    <Dialog>
-      <DialogTrigger asChild>{children}</DialogTrigger>
-      <DialogContent className="sm:max-w-[425px]">
-        <DialogHeader>
-          <DialogTitle>{t('Import Workflow')}</DialogTitle>
-        </DialogHeader>
-        <DialogFooter>
-          <Input
-            type="file"
-            accept=".json"
-            ref={fileInputRef}
-            onChange={handleFileChange}
-            data-testid="importFlowFileInput"
-          />
-          <Button
-            onClick={handleSubmit}
-            loading={isPending}
-            data-testid="importFlowButton"
-          >
-            {t('Import')}
-          </Button>
-        </DialogFooter>
-      </DialogContent>
-    </Dialog>
-  );
-};
-
-export { ImportFlowDialog };
diff --git a/packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog-content.tsx b/packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog-content.tsx
new file mode 100644
index 0000000000..70d676b526
--- /dev/null
+++ b/packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog-content.tsx
@@ -0,0 +1,93 @@
+import { ConnectionsPicker } from '@/app/features/templates/components/connections-picker/connections-picker';
+import { BlockMetadataModelSummary } from '@openops/blocks-framework';
+import {
+  Button,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  FlowTemplateMetadataWithIntegrations,
+  Input,
+} from '@openops/components/ui';
+import { AppConnectionWithoutSensitiveData, Trigger } from '@openops/shared';
+import { t } from 'i18next';
+import React, { useMemo, useRef, useState } from 'react';
+
+type ImportFlowDialogContentProps = {
+  templateWithIntegrations: FlowTemplateMetadataWithIntegrations | null;
+  templateTrigger: Trigger | undefined;
+  resetDialog: () => void;
+  isUseTemplateLoading: boolean;
+  onUseTemplate: (connections: AppConnectionWithoutSensitiveData[]) => void;
+  handleFileParsing: (file: File | null) => void;
+  isImportLoading: boolean;
+};
+
+const ImportFlowDialogContent = ({
+  templateWithIntegrations,
+  templateTrigger,
+  resetDialog,
+  isUseTemplateLoading,
+  onUseTemplate,
+  isImportLoading,
+  handleFileParsing,
+}: ImportFlowDialogContentProps) => {
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [file, setFile] = useState<File | null>(null);
+  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setFile(event.target.files?.[0] ?? null);
+  };
+
+  const onResetClick = () => {
+    setFile(null);
+    resetDialog();
+  };
+
+  const integrations: BlockMetadataModelSummary[] = useMemo(() => {
+    return (
+      templateWithIntegrations?.integrations.filter(
+        (integration): integration is BlockMetadataModelSummary =>
+          !!integration.auth,
+      ) ?? []
+    );
+  }, [templateWithIntegrations]);
+
+  if (templateWithIntegrations && templateTrigger) {
+    return (
+      <ConnectionsPicker
+        close={onResetClick}
+        templateName={templateWithIntegrations?.name ?? ''}
+        templateTrigger={templateTrigger}
+        integrations={integrations}
+        onUseTemplate={onUseTemplate}
+        isUseTemplateLoading={isUseTemplateLoading}
+      ></ConnectionsPicker>
+    );
+  }
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>{t('Import Workflow')}</DialogTitle>
+      </DialogHeader>
+      <DialogFooter>
+        <Input
+          ref={fileInputRef}
+          type="file"
+          accept=".json"
+          onChange={handleFileChange}
+          data-testid="importFlowFileInput"
+        />
+        <Button
+          onClick={() => handleFileParsing(file)}
+          loading={isImportLoading}
+          data-testid="importFlowButton"
+          disabled={!file}
+        >
+          {t('Import')}
+        </Button>
+      </DialogFooter>
+    </>
+  );
+};
+
+ImportFlowDialogContent.displayName = 'ImportFlowDialogContent';
+export { ImportFlowDialogContent };
diff --git a/packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog.tsx b/packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog.tsx
new file mode 100644
index 0000000000..46714f2437
--- /dev/null
+++ b/packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog.tsx
@@ -0,0 +1,114 @@
+import {
+  cn,
+  Dialog,
+  DialogContent,
+  DialogTrigger,
+  INTERNAL_ERROR_TOAST,
+  toast,
+} from '@openops/components/ui';
+import {
+  AppConnectionWithoutSensitiveData,
+  FlowImportTemplate,
+  FlowOperationType,
+} from '@openops/shared';
+import { useMutation } from '@tanstack/react-query';
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { flowsApi } from '../../lib/flows-api';
+
+import { userSettingsHooks } from '@/app/common/hooks/user-settings-hooks';
+import { SEARCH_PARAMS } from '@/app/constants/search-params';
+import { ImportFlowDialogContent } from '@/app/features/flows/components/import-flow-dialog/import-flow-dialog-content';
+import { templatesHooks } from '@/app/features/templates/lib/templates-hooks';
+import { authenticationSession } from '@/app/lib/authentication-session';
+
+const ImportFlowDialog = ({ children }: { children: React.ReactNode }) => {
+  const navigate = useNavigate();
+  const [importedWorkflow, setImportedWorkflow] =
+    useState<FlowImportTemplate | null>(null);
+  const { updateHomePageOperationalViewFlag } =
+    userSettingsHooks.useHomePageOperationalView();
+  const { templateWithIntegrations, isLoading: isImportLoading } =
+    templatesHooks.useSetTemplateIntegrations(importedWorkflow);
+
+  const { mutate: createFlow, isPending: isUseTemplateLoading } = useMutation({
+    mutationFn: async (connections: AppConnectionWithoutSensitiveData[]) => {
+      if (importedWorkflow) {
+        const newFlow = await flowsApi.create({
+          displayName: importedWorkflow.name,
+          projectId: authenticationSession.getProjectId()!,
+        });
+        return await flowsApi.update(newFlow.id, {
+          type: FlowOperationType.IMPORT_FLOW,
+          request: {
+            displayName: importedWorkflow.name,
+            description: importedWorkflow.description,
+            trigger: importedWorkflow.template.trigger,
+            connections,
+          },
+        });
+      }
+      return Promise.reject();
+    },
+    onSuccess: (flow) => {
+      updateHomePageOperationalViewFlag();
+      navigate(`/flows/${flow.id}?${SEARCH_PARAMS.viewOnly}=true`);
+    },
+    onError: () => {
+      toast(INTERNAL_ERROR_TOAST);
+    },
+  });
+
+  const handleFileParsing = async (file: File | null) => {
+    if (!file) return;
+
+    const reader = new FileReader();
+    reader.onload = () => {
+      try {
+        const template = JSON.parse(
+          reader.result as string,
+        ) as FlowImportTemplate;
+        if (template?.name && template?.template?.trigger) {
+          setImportedWorkflow(template);
+        } else {
+          toast(INTERNAL_ERROR_TOAST);
+        }
+      } catch {
+        toast(INTERNAL_ERROR_TOAST);
+      }
+    };
+    reader.readAsText(file);
+  };
+
+  const resetDialog = () => {
+    setImportedWorkflow(null);
+  };
+
+  return (
+    <Dialog onOpenChange={resetDialog}>
+      <DialogTrigger asChild>{children}</DialogTrigger>
+      <DialogContent
+        className={cn({
+          'flex flex-col p-0 transition-none max-2xl:max-w-[1010px] max-w-[846px] max-h-[70vh] overflow-y-auto':
+            templateWithIntegrations,
+        })}
+        onInteractOutside={(e) => {
+          e.preventDefault();
+        }}
+      >
+        <ImportFlowDialogContent
+          templateWithIntegrations={templateWithIntegrations}
+          templateTrigger={importedWorkflow?.template.trigger}
+          resetDialog={resetDialog}
+          isUseTemplateLoading={isUseTemplateLoading}
+          onUseTemplate={createFlow}
+          handleFileParsing={handleFileParsing}
+          isImportLoading={isImportLoading}
+        />
+      </DialogContent>
+    </Dialog>
+  );
+};
+
+export { ImportFlowDialog };
diff --git a/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker-table-columns.tsx b/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker-table-columns.tsx
index 91414fdb3a..2ea5626e68 100644
--- a/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker-table-columns.tsx
+++ b/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker-table-columns.tsx
@@ -95,7 +95,7 @@ const ConnectionPickerTableActions = ({
             <Plus size={16} />
             {t('Create Connection')}
           </DropdownMenuItem>
-          {connectionOptions.map((connection) => {
+          {connectionOptions?.map((connection) => {
             return (
               <DropdownMenuItem
                 key={connection.id}
diff --git a/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx b/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
index 57b792029d..eb2985eb01 100644
--- a/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
+++ b/packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
@@ -12,7 +12,12 @@ import {
   DialogFooter,
   OverflowTooltip,
 } from '@openops/components/ui';
-import { AppConnectionWithoutSensitiveData, isNil } from '@openops/shared';
+import {
+  AppConnectionWithoutSensitiveData,
+  flowHelper,
+  isNil,
+  Trigger,
+} from '@openops/shared';
 import { t } from 'i18next';
 import { ArrowLeft, TriangleAlert } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
@@ -24,14 +29,16 @@ import {
 
 type ConnectionsPickerProps = {
   templateName: string;
+  templateTrigger?: Trigger;
   integrations: BlockMetadataModelSummary[];
   isUseTemplateLoading: boolean;
   close: () => void;
-  onUseTemplate: (connectionIds: string[]) => void;
+  onUseTemplate: (connections: AppConnectionWithoutSensitiveData[]) => void;
 };
 
 const ConnectionsPicker = ({
   templateName,
+  templateTrigger,
   integrations,
   isUseTemplateLoading,
   close,
@@ -75,18 +82,31 @@ const ConnectionsPicker = ({
       groupedConnections
     ) {
       isConnectionListPreselected.current = true;
+      const usedConnectionNames: { [key: string]: string | undefined } =
+        templateTrigger ? flowHelper.getUsedConnections(templateTrigger) : {};
 
       const connections: Record<
         string,
         AppConnectionWithoutSensitiveData | null
       > = {};
       integrations.forEach((integration) => {
-        const options = groupedConnections[integration.name] || [];
-        connections[integration.name] = options[0] || null;
+        const options = groupedConnections[integration.name] ?? [];
+        const usedConnection = usedConnectionNames[integration.name]
+          ? options.find((connection) => {
+              return connection.name === usedConnectionNames[integration.name];
+            })
+          : null;
+        connections[integration.name] = usedConnection ?? options[0] ?? null;
       });
+
       setSelectedConnections(connections);
     }
-  }, [integrations, groupedConnections, setSelectedConnections]);
+  }, [
+    integrations,
+    groupedConnections,
+    setSelectedConnections,
+    templateTrigger,
+  ]);
 
   const tableData: TemplateConnectionTableData[] = useMemo(() => {
     return integrations.map((integration, index) => ({
@@ -133,11 +153,7 @@ const ConnectionsPicker = ({
   };
 
   const onUseTemplateClick = () => {
-    onUseTemplate(
-      Object.values(selectedConnections)
-        .filter((c) => !!c)
-        .map((c) => c.id),
-    );
+    onUseTemplate(Object.values(selectedConnections).filter((c) => !!c));
   };
 
   return (
diff --git a/packages/react-ui/src/app/features/templates/components/select-flow-template-dialog.tsx b/packages/react-ui/src/app/features/templates/components/select-flow-template-dialog.tsx
index f444e12104..0c45252506 100644
--- a/packages/react-ui/src/app/features/templates/components/select-flow-template-dialog.tsx
+++ b/packages/react-ui/src/app/features/templates/components/select-flow-template-dialog.tsx
@@ -29,7 +29,10 @@ import {
   toast,
   VerticalDivider,
 } from '@openops/components/ui';
-import { FlowTemplateDto } from '@openops/shared';
+import {
+  AppConnectionWithoutSensitiveData,
+  FlowTemplateDto,
+} from '@openops/shared';
 import { useMutation } from '@tanstack/react-query';
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
@@ -352,7 +355,7 @@ const SelectFlowTemplateDialog = ({
   });
 
   const { mutate: useTemplate, isPending: isUseTemplatePending } = useMutation({
-    mutationFn: async (connectionIds: string[]) => {
+    mutationFn: async (connections: AppConnectionWithoutSensitiveData[]) => {
       if (!selectedTemplate) {
         return Promise.reject();
       }
@@ -363,10 +366,10 @@ const SelectFlowTemplateDialog = ({
           id: template.id,
           displayName: template.name,
           description: template.description,
-          isSample: template.isSample,
+          isSample: template.isSample ?? false,
           trigger: template.template,
         },
-        connectionIds,
+        connectionIds: connections.map((c) => c.id),
       });
     },
     onSuccess: (flow) => {
diff --git a/packages/react-ui/src/app/features/templates/lib/templates-hooks.ts b/packages/react-ui/src/app/features/templates/lib/templates-hooks.ts
index cea3197893..411a5404b2 100644
--- a/packages/react-ui/src/app/features/templates/lib/templates-hooks.ts
+++ b/packages/react-ui/src/app/features/templates/lib/templates-hooks.ts
@@ -130,6 +130,28 @@ export const templatesHooks = {
       isError,
     };
   },
+
+  useBlocksLookup: () => {
+    const { blocks: blocksMetadata, isLoading } = blocksHooks.useBlocks({
+      searchQuery: '',
+    });
+
+    const blocksLookup = useMemo(() => {
+      if (!blocksMetadata) return {};
+      return blocksMetadata.reduce<Record<string, BlockMetadataModelSummary>>(
+        (map, blockMetadata) => {
+          if (!blockMetadata.categories?.includes(BlockCategory.CORE)) {
+            map[blockMetadata.name] = blockMetadata;
+          }
+          return map;
+        },
+        {},
+      );
+    }, [blocksMetadata]);
+
+    return { blocksLookup, isLoading };
+  },
+
   useTemplatesMetadataWithIntegrations: ({
     enabled = true,
     useCloudTemplates = false,
@@ -156,40 +178,41 @@ export const templatesHooks = {
       tags,
       gettingStartedTemplateFilter,
     });
-    const { blocks: blocksMetadata, isLoading: isBlocksLoading } =
-      blocksHooks.useBlocks({
-        searchQuery: '',
-      });
-
-    const templatesWithIntegrations: FlowTemplateMetadataWithIntegrations[] =
-      useMemo(() => {
-        if (!templates || !blocksMetadata) {
-          return [];
-        }
-
-        const blocksLookup = blocksMetadata.reduce((map, blockMetadata) => {
-          if (!blockMetadata.categories?.includes(BlockCategory.CORE)) {
-            map[blockMetadata.name] = blockMetadata;
-          }
-          return map;
-        }, {} as Record<string, BlockMetadataModelSummary>);
-
-        return templates.map((template) => {
-          const updatedIntegrations = (template.blocks || [])
-            .map((blockName) => blocksLookup[blockName])
-            .filter(Boolean);
-
-          return {
-            ...template,
-            integrations: updatedIntegrations,
-          };
-        });
-      }, [templates, blocksMetadata]);
+    const { blocksLookup, isLoading: isBlocksLoading } =
+      templatesHooks.useBlocksLookup();
+
+    const templatesWithIntegrations = useMemo(() => {
+      if (!templates) return [];
+      return templates.map((template) => ({
+        ...template,
+        integrations: (template.blocks ?? [])
+          .map((blockName) => blocksLookup[blockName])
+          .filter(Boolean),
+      }));
+    }, [templates, blocksLookup]);
 
     return {
       templatesWithIntegrations,
-      isLoading: isTemplatesLoading || isBlocksLoading,
+      isLoading: isTemplatesLoading ?? isBlocksLoading,
       refetch,
     };
   },
+
+  useSetTemplateIntegrations: (template: FlowTemplateMetadata | null) => {
+    const { blocksLookup, isLoading: isBlocksLoading } =
+      templatesHooks.useBlocksLookup();
+
+    const templateWithIntegrations: FlowTemplateMetadataWithIntegrations | null =
+      useMemo(() => {
+        if (!template) return null;
+        return {
+          ...template,
+          integrations: (template.blocks ?? [])
+            .map((blockName) => blocksLookup[blockName])
+            .filter(Boolean),
+        };
+      }, [template, blocksLookup]);
+
+    return { templateWithIntegrations, isLoading: isBlocksLoading };
+  },
 };
diff --git a/packages/shared/src/lib/flows/flow-helper.ts b/packages/shared/src/lib/flows/flow-helper.ts
index 43530d4daf..8e64b1a543 100644
--- a/packages/shared/src/lib/flows/flow-helper.ts
+++ b/packages/shared/src/lib/flows/flow-helper.ts
@@ -1,7 +1,10 @@
 import { TypeCompiler } from '@sinclair/typebox/compiler';
 import semver from 'semver';
 import { AppConnectionWithoutSensitiveData } from '../app-connection/app-connection';
-import { addConnectionBrackets } from '../app-connection/connections-utils';
+import {
+  addConnectionBrackets,
+  removeConnectionBrackets,
+} from '../app-connection/connections-utils';
 import { applyFunctionToValuesSync, isNil, isString } from '../common';
 import { ApplicationError, ErrorCode } from '../common/application-error';
 import {
@@ -581,7 +584,7 @@ function addAction(
           );
         }
 
-        parentStep.branches = parentStep.branches || [];
+        parentStep.branches = parentStep.branches ?? [];
         const branch: SplitBranch | undefined = parentStep.branches.find(
           (x) => x.optionId == request.branchNodeId,
         );
@@ -737,16 +740,14 @@ const prefillConnection = (
   action: Action,
   connections?: AppConnectionWithoutSensitiveData[],
 ): Action => {
-  if (
-    Array.isArray(connections) &&
-    connections.length > 0 &&
-    'blockName' in action.settings
-  ) {
+  if (Array.isArray(connections) && 'blockName' in action.settings) {
     const blockName = action.settings.blockName;
     const connection = connections.find((c) => c.blockName === blockName);
 
     if (connection && 'input' in action.settings) {
       action.settings.input['auth'] = addConnectionBrackets(connection.name);
+    } else {
+      action.settings.input['auth'] = undefined;
     }
   }
 
@@ -1091,6 +1092,23 @@ const removeConnection = (step: Step): Step => {
   return step;
 };
 
+function getUsedConnections(step: Trigger | Action) {
+  return flowHelper
+    .getAllSteps(step)
+    .filter((step) => {
+      return step.settings.blockName && step.settings.input.auth;
+    })
+    .reduce((usedConnectionNames: { [key: string]: string }, step) => {
+      const connection = removeConnectionBrackets(step.settings.input.auth);
+
+      if (connection) {
+        usedConnectionNames[step.settings.blockName] = connection;
+      }
+
+      return usedConnectionNames;
+    }, {});
+}
+
 export const flowHelper = {
   isValid,
   apply(
@@ -1182,4 +1200,5 @@ export const flowHelper = {
   findPathToStep,
   truncateFlow,
   clearStepTestData,
+  getUsedConnections,
 };
diff --git a/packages/shared/test/flow/flow-helper.test.ts b/packages/shared/test/flow/flow-helper.test.ts
index e7ffc88ec0..6b6be7f4db 100644
--- a/packages/shared/test/flow/flow-helper.test.ts
+++ b/packages/shared/test/flow/flow-helper.test.ts
@@ -1405,7 +1405,7 @@ describe('getImportOperations', () => {
     );
   });
 
-  it('should not change connection when block name does not match', () => {
+  it('should unset connection when block name does not match and connections are provided', () => {
     const actionWithoutBlockName = {
       ...mockSlackAction,
       settings: { ...mockSlackAction.settings, blockName: 'something-else' },
@@ -1419,6 +1419,20 @@ describe('getImportOperations', () => {
       [mockConnection],
     ) as OperationsResponse;
 
+    expect(result[0].request.action.settings.input['auth']).toBe(undefined);
+  });
+
+  it('should not change connection when connections are not provided', () => {
+    const actionWithoutBlockName = {
+      ...mockSlackAction,
+      settings: { ...mockSlackAction.settings, blockName: 'something-else' },
+    };
+
+    const result = flowHelper.getImportOperations({
+      ...mockTrigger,
+      nextAction: actionWithoutBlockName,
+    }) as OperationsResponse;
+
     expect(result[0].request.action.settings.input['auth']).toBe(
       "{{connections['initial-slack-connection']}}",
     );
@@ -2793,3 +2807,56 @@ describe('bulkAddActions', () => {
     expect(branch?.nextAction.nextAction.name).toEqual(action.nextAction.name);
   });
 });
+
+describe('getUsedConnections', () => {
+  const actionWithBrokenConnections = {
+    settings: {
+      blockName: '',
+      input: { auth: "{{connections['connection1']}}" },
+    },
+    nextAction: {
+      settings: {
+        blockName: 'step2',
+        input: {},
+      },
+    },
+  } as unknown as Action;
+  it('returns used connection names mapped by block name', () => {
+    const action = {
+      settings: {
+        blockName: 'step1',
+        input: { auth: "{{connections['connection1']}}" },
+      },
+      nextAction: {
+        settings: {
+          blockName: 'step2',
+          input: { auth: "{{connections['connection2']}}" },
+        },
+      },
+    } as unknown as Action;
+
+    const result = flowHelper.getUsedConnections(action);
+
+    expect(result).toEqual({
+      step1: 'connection1',
+      step2: 'connection2',
+    });
+  });
+
+  it('ignores steps without blockName or auth', () => {
+    const result = flowHelper.getUsedConnections(actionWithBrokenConnections);
+
+    expect(result).toEqual({});
+  });
+
+  it('ignores steps where removeConnectionBrackets returns falsy', () => {
+    const result = flowHelper.getUsedConnections(actionWithBrokenConnections);
+
+    expect(result).toEqual({});
+  });
+
+  it('returns an empty object if getAllSteps returns empty', () => {
+    const result = flowHelper.getUsedConnections(actionWithBrokenConnections);
+    expect(result).toEqual({});
+  });
+});


Reviews:
user: login: rmn-snpk
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T12:03:40Z
user: login: rmn-snpk
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T13:34:06Z
user: login: rmn-snpk
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T13:35:49Z
user: login: rmn-snpk
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T13:38:16Z
user: login: cezudas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T08:54:30Z
user: login: cezudas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:00:34Z
user: login: alexandrudanpop
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T11:57:38Z
user: login: alexandrudanpop
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T11:58:35Z
user: login: alexandrudanpop
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T12:08:29Z
user: login: rmn-snpk
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T13:14:54Z
user: login: rmn-snpk
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T13:29:14Z
user: login: alexandrudanpop
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:40:14Z

Review comments:
diff_hunk: @@ -75,37 +83,74 @@ const ImportFlowDialog = ({ children }: { children: React.ReactNode }) => {
           reader.result as string,
         ) as FlowImportTemplate;
         // TODO handle overwriting flow when using actions in builder
-        createFlow(template);
-      } catch (error) {
+        if (template?.name && template?.template?.trigger) {
+          setImportedWorkflow(template);
+        } else {
+          toast(INTERNAL_ERROR_TOAST);
+        }
path: packages/react-ui/src/app/features/flows/components/import-flow-dialog.tsx
user: login: rmn-snpk
body: Validation to check if it's a workflow or not
created_at: 2025-04-03T12:03:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,90 @@
+import { ConnectionsPicker } from '@/app/features/templates/components/connections-picker/connections-picker';
+import { BlockMetadataModelSummary } from '@openops/blocks-framework';
+import {
+  Button,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  FlowTemplateMetadataWithIntegrations,
+  Input,
+} from '@openops/components/ui';
+import { AppConnectionWithoutSensitiveData, Trigger } from '@openops/shared';
+import { t } from 'i18next';
+import React, { useMemo, useRef, useState } from 'react';
+
+type ImportFlowDialogContentProps = {
+  templateWithIntegrations: FlowTemplateMetadataWithIntegrations | null;
+  templateTrigger: Trigger | undefined;
+  resetDialog: () => void;
+  isUseTemplateLoading: boolean;
+  onUseTemplate: (connections: AppConnectionWithoutSensitiveData[]) => void;
+  handleFileParsing: (file: File | null) => void;
+  isImportLoading: boolean;
+};
+
+const ImportFlowDialogContent = ({
+  templateWithIntegrations,
+  templateTrigger,
+  resetDialog,
+  isUseTemplateLoading,
+  onUseTemplate,
+  isImportLoading,
+  handleFileParsing,
+}: ImportFlowDialogContentProps) => {
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [file, setFile] = useState<File | null>(null);
+  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setFile(event.target.files?.[0] ?? null);
+  };
+
+  const onResetClick = () => {
+    setFile(null);
+    resetDialog();
+  };
+
+  const integrations = useMemo(() => {
+    return templateWithIntegrations?.integrations.filter(
+      (integration) => !!integration.auth,
+    ) as BlockMetadataModelSummary[];
+  }, [templateWithIntegrations]);
+
+  if (templateWithIntegrations && templateTrigger) {
+    return (
+      <ConnectionsPicker
+        close={onResetClick}
+        templateName={templateWithIntegrations?.name ?? ''}
+        templateTrigger={templateTrigger}
+        integrations={integrations}
+        onUseTemplate={onUseTemplate}
+        isUseTemplateLoading={isUseTemplateLoading}
+      ></ConnectionsPicker>
+    );
+  }
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>{t('Import Workflow')}</DialogTitle>
+      </DialogHeader>
+      <DialogFooter>
+        <Input
+          ref={fileInputRef}
+          type="file"
+          accept=".json"
+          onChange={handleFileChange}
+          data-testid="importFlowFileInput"
+        />
+        <Button
+          onClick={() => handleFileParsing(file)}
+          loading={isImportLoading}
+          data-testid="importFlowButton"
+          disabled={!file}
+        >
+          {t('Import')}
+        </Button>
+      </DialogFooter>
+    </>
path: packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog-content.tsx
user: login: rmn-snpk
body: Just a moved this from `ImportFlowDialog`
created_at: 2025-04-03T13:34:06Z
author_association: NONE
diff_hunk: @@ -363,10 +366,10 @@ const SelectFlowTemplateDialog = ({
           id: template.id,
           displayName: template.name,
           description: template.description,
-          isSample: template.isSample,
+          isSample: template.isSample ?? false,
           trigger: template.template,
         },
-        connectionIds,
+        connectionIds: connections.map((c) => c.id),
path: packages/react-ui/src/app/features/templates/components/select-flow-template-dialog.tsx
user: login: rmn-snpk
body: I updated `ConnectionsPicker` so now it returns `AppConnectionWithoutSensitiveData` instead of connection ids. We need it for import operation
created_at: 2025-04-03T13:35:48Z
author_association: NONE
diff_hunk: @@ -737,16 +737,14 @@ const prefillConnection = (
   action: Action,
   connections?: AppConnectionWithoutSensitiveData[],
 ): Action => {
-  if (
-    Array.isArray(connections) &&
-    connections.length > 0 &&
-    'blockName' in action.settings
-  ) {
+  if (Array.isArray(connections) && 'blockName' in action.settings) {
     const blockName = action.settings.blockName;
     const connection = connections.find((c) => c.blockName === blockName);
 
     if (connection && 'input' in action.settings) {
       action.settings.input['auth'] = addConnectionBrackets(connection.name);
+    } else {
+      action.settings.input['auth'] = undefined;
path: packages/shared/src/lib/flows/flow-helper.ts
user: login: rmn-snpk
body: Now, if connections array is provided we unset connection which were not provided.
created_at: 2025-04-03T13:38:16Z
author_association: NONE
diff_hunk: @@ -130,6 +130,25 @@ export const templatesHooks = {
       isError,
     };
   },
+
+  useBlocksLookup: () => {
+    const { blocks: blocksMetadata, isLoading } = blocksHooks.useBlocks({
+      searchQuery: '',
+    });
+
+    const blocksLookup = useMemo(() => {
+      if (!blocksMetadata) return {};
+      return blocksMetadata.reduce((map, blockMetadata) => {
+        if (!blockMetadata.categories?.includes(BlockCategory.CORE)) {
+          map[blockMetadata.name] = blockMetadata;
+        }
+        return map;
+      }, {} as Record<string, BlockMetadataModelSummary>);
path: packages/react-ui/src/app/features/templates/lib/templates-hooks.ts
user: login: cezudas
body: Nitpicking - "as" can be avoided here:

   ```
 const blocksLookup = useMemo(() => {
      if (!blocksMetadata) return {};
      return blocksMetadata.reduce<Record<string, BlockMetadataModelSummary>>(
        (map, blockMetadata) => {
          if (!blockMetadata.categories?.includes(BlockCategory.CORE)) {
            map[blockMetadata.name] = blockMetadata;
          }
          return map;
        },
        {},
      );
    }, [blocksMetadata]);
```
created_at: 2025-04-04T08:54:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,90 @@
+import { ConnectionsPicker } from '@/app/features/templates/components/connections-picker/connections-picker';
+import { BlockMetadataModelSummary } from '@openops/blocks-framework';
+import {
+  Button,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  FlowTemplateMetadataWithIntegrations,
+  Input,
+} from '@openops/components/ui';
+import { AppConnectionWithoutSensitiveData, Trigger } from '@openops/shared';
+import { t } from 'i18next';
+import React, { useMemo, useRef, useState } from 'react';
+
+type ImportFlowDialogContentProps = {
+  templateWithIntegrations: FlowTemplateMetadataWithIntegrations | null;
+  templateTrigger: Trigger | undefined;
+  resetDialog: () => void;
+  isUseTemplateLoading: boolean;
+  onUseTemplate: (connections: AppConnectionWithoutSensitiveData[]) => void;
+  handleFileParsing: (file: File | null) => void;
+  isImportLoading: boolean;
+};
+
+const ImportFlowDialogContent = ({
+  templateWithIntegrations,
+  templateTrigger,
+  resetDialog,
+  isUseTemplateLoading,
+  onUseTemplate,
+  isImportLoading,
+  handleFileParsing,
+}: ImportFlowDialogContentProps) => {
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [file, setFile] = useState<File | null>(null);
+  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setFile(event.target.files?.[0] ?? null);
+  };
+
+  const onResetClick = () => {
+    setFile(null);
+    resetDialog();
+  };
+
+  const integrations = useMemo(() => {
path: packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog-content.tsx
user: login: cezudas
body: even safer
```
const integrations: BlockMetadataModelSummary[] = useMemo(() => {
  return (
    templateWithIntegrations?.integrations.filter(
      (integration): integration is BlockMetadataModelSummary =>
        !!integration.auth,
    ) ?? []
  );
}, [templateWithIntegrations]);
```
created_at: 2025-04-04T09:00:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,15 +83,34 @@ const ConnectionsPicker = ({
       groupedConnections
     ) {
       isConnectionListPreselected.current = true;
+      const usedConnectionNames: { [key: string]: string | undefined } = {};
+
+      if (templateTrigger) {
+        flowHelper
+          .getAllSteps(templateTrigger)
+          .filter((step) => {
+            return step.settings.blockName && step.settings.input.auth;
+          })
+          .forEach((step) => {
+            usedConnectionNames[step.settings.blockName] =
+              removeConnectionBrackets(step.settings.input.auth);
+          });
+      }
 
       const connections: Record<
         string,
         AppConnectionWithoutSensitiveData | null
       > = {};
       integrations.forEach((integration) => {
-        const options = groupedConnections[integration.name] || [];
-        connections[integration.name] = options[0] || null;
+        const options = groupedConnections[integration.name] ?? [];
+        const usedConnection = usedConnectionNames[integration.name]
+          ? options.find((connection) => {
+              return connection.name === usedConnectionNames[integration.name];
+            })
+          : null;
+        connections[integration.name] = usedConnection ?? options[0] ?? null;
       });
+
       setSelectedConnections(connections);
     }
   }, [integrations, groupedConnections, setSelectedConnections]);
path: packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
user: login: alexandrudanpop
body: ![image](https://github.com/user-attachments/assets/d4f50a68-db66-45e5-95cf-d1e4fc627071)

created_at: 2025-04-04T11:57:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,15 +83,34 @@ const ConnectionsPicker = ({
       groupedConnections
     ) {
       isConnectionListPreselected.current = true;
+      const usedConnectionNames: { [key: string]: string | undefined } = {};
+
+      if (templateTrigger) {
+        flowHelper
+          .getAllSteps(templateTrigger)
+          .filter((step) => {
+            return step.settings.blockName && step.settings.input.auth;
+          })
+          .forEach((step) => {
+            usedConnectionNames[step.settings.blockName] =
+              removeConnectionBrackets(step.settings.input.auth);
+          });
+      }
 
       const connections: Record<
         string,
         AppConnectionWithoutSensitiveData | null
       > = {};
       integrations.forEach((integration) => {
-        const options = groupedConnections[integration.name] || [];
-        connections[integration.name] = options[0] || null;
+        const options = groupedConnections[integration.name] ?? [];
+        const usedConnection = usedConnectionNames[integration.name]
+          ? options.find((connection) => {
+              return connection.name === usedConnectionNames[integration.name];
+            })
+          : null;
+        connections[integration.name] = usedConnection ?? options[0] ?? null;
       });
path: packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
user: login: alexandrudanpop
body: would be nice if this logic was extracted from the component (with some tests) 
created_at: 2025-04-04T11:58:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  cn,
+  Dialog,
+  DialogContent,
+  DialogTrigger,
+  INTERNAL_ERROR_TOAST,
+  toast,
+} from '@openops/components/ui';
+import {
+  AppConnectionWithoutSensitiveData,
+  FlowImportTemplate,
+  FlowOperationType,
+} from '@openops/shared';
+import { useMutation } from '@tanstack/react-query';
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { flowsApi } from '../../lib/flows-api';
+
+import { userSettingsHooks } from '@/app/common/hooks/user-settings-hooks';
+import { SEARCH_PARAMS } from '@/app/constants/search-params';
+import { ImportFlowDialogContent } from '@/app/features/flows/components/import-flow-dialog/import-flow-dialog-content';
+import { templatesHooks } from '@/app/features/templates/lib/templates-hooks';
+import { authenticationSession } from '@/app/lib/authentication-session';
+
+const ImportFlowDialog = ({ children }: { children: React.ReactNode }) => {
+  const navigate = useNavigate();
+  const [importedWorkflow, setImportedWorkflow] =
+    useState<FlowImportTemplate | null>(null);
+  const { updateHomePageOperationalViewFlag } =
+    userSettingsHooks.useHomePageOperationalView();
+  const { templateWithIntegrations, isLoading: isImportLoading } =
+    templatesHooks.useSetTemplateIntegrations(importedWorkflow);
+
+  const { mutate: createFlow, isPending: isUseTemplateLoading } = useMutation({
+    mutationFn: async (connections: AppConnectionWithoutSensitiveData[]) => {
+      if (importedWorkflow) {
+        const newFlow = await flowsApi.create({
+          displayName: importedWorkflow.name,
+          projectId: authenticationSession.getProjectId()!,
+        });
+        return await flowsApi.update(newFlow.id, {
+          type: FlowOperationType.IMPORT_FLOW,
+          request: {
+            displayName: importedWorkflow.name,
+            description: importedWorkflow.description,
+            trigger: importedWorkflow.template.trigger,
+            connections,
+          },
+        });
+      }
+      return Promise.reject();
+    },
+    onSuccess: (flow) => {
+      updateHomePageOperationalViewFlag();
+      navigate(`/flows/${flow.id}?${SEARCH_PARAMS.viewOnly}=true`);
+    },
+    onError: () => {
+      toast(INTERNAL_ERROR_TOAST);
+    },
+  });
+
+  const handleFileParsing = async (file: File | null) => {
+    if (!file) return;
+
+    const reader = new FileReader();
+    reader.onload = () => {
+      try {
+        const template = JSON.parse(
+          reader.result as string,
+        ) as FlowImportTemplate;
+        // TODO handle overwriting flow when using actions in builder
path: packages/react-ui/src/app/features/flows/components/import-flow-dialog/import-flow-dialog.tsx
user: login: alexandrudanpop
body: we should fix this if it's an issue in the builder
created_at: 2025-04-04T12:08:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,15 +83,34 @@ const ConnectionsPicker = ({
       groupedConnections
     ) {
       isConnectionListPreselected.current = true;
+      const usedConnectionNames: { [key: string]: string | undefined } = {};
+
+      if (templateTrigger) {
+        flowHelper
+          .getAllSteps(templateTrigger)
+          .filter((step) => {
+            return step.settings.blockName && step.settings.input.auth;
+          })
+          .forEach((step) => {
+            usedConnectionNames[step.settings.blockName] =
+              removeConnectionBrackets(step.settings.input.auth);
+          });
+      }
 
       const connections: Record<
         string,
         AppConnectionWithoutSensitiveData | null
       > = {};
       integrations.forEach((integration) => {
-        const options = groupedConnections[integration.name] || [];
-        connections[integration.name] = options[0] || null;
+        const options = groupedConnections[integration.name] ?? [];
+        const usedConnection = usedConnectionNames[integration.name]
+          ? options.find((connection) => {
+              return connection.name === usedConnectionNames[integration.name];
+            })
+          : null;
+        connections[integration.name] = usedConnection ?? options[0] ?? null;
       });
+
       setSelectedConnections(connections);
     }
   }, [integrations, groupedConnections, setSelectedConnections]);
path: packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
user: login: rmn-snpk
body: done
created_at: 2025-04-04T13:14:54Z
author_association: NONE
diff_hunk: @@ -75,15 +83,34 @@ const ConnectionsPicker = ({
       groupedConnections
     ) {
       isConnectionListPreselected.current = true;
+      const usedConnectionNames: { [key: string]: string | undefined } = {};
+
+      if (templateTrigger) {
+        flowHelper
+          .getAllSteps(templateTrigger)
+          .filter((step) => {
+            return step.settings.blockName && step.settings.input.auth;
+          })
+          .forEach((step) => {
+            usedConnectionNames[step.settings.blockName] =
+              removeConnectionBrackets(step.settings.input.auth);
+          });
+      }
 
       const connections: Record<
         string,
         AppConnectionWithoutSensitiveData | null
       > = {};
       integrations.forEach((integration) => {
-        const options = groupedConnections[integration.name] || [];
-        connections[integration.name] = options[0] || null;
+        const options = groupedConnections[integration.name] ?? [];
+        const usedConnection = usedConnectionNames[integration.name]
+          ? options.find((connection) => {
+              return connection.name === usedConnectionNames[integration.name];
+            })
+          : null;
+        connections[integration.name] = usedConnection ?? options[0] ?? null;
       });
path: packages/react-ui/src/app/features/templates/components/connections-picker/connections-picker.tsx
user: login: rmn-snpk
body: I moved the first part to `flowHelper` and added tests. As for the second part, this logic is too specific and unlikely to be reused, so I don't think we need to do anything about it.
created_at: 2025-04-04T13:29:14Z
author_association: NONE

PR comments:
user: login: linear[bot]
created_at: 2025-04-02T13:01:45Z
body: <p><a href="https://linear.app/openops/issue/OPS-1446/use-the-connection-picker-when-importing-templates">OPS-1446 Use the connection picker when importing templates</a></p>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-04T13:39:49Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=359) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=359&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [1 Accepted issue](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=359&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=openops-cloud_openops&pullRequest=359&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=359&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=359&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=359)


author_association: NONE