mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat: Enable cred setup for workflows created from templates (no-changelog) (#8240)
## Summary Enable users to open credential setup for workflows that have been created from templates if they skip it. Next steps (will be their own PRs): - Add telemetry events - Add e2e test - Hide the button when user sets up all the credentials - Change the feature flag to a new one ## Related tickets and issues https://linear.app/n8n/issue/ADO-1637/feature-support-template-credential-setup-for-http-request-nodes-that
This commit is contained in:
parent
df5d07bcb8
commit
3cf6704dbb
|
@ -11,10 +11,7 @@ describe('n8n Form Trigger', () => {
|
|||
|
||||
it("add node by clicking on 'On form submission'", () => {
|
||||
workflowPage.getters.canvasPlusButton().click();
|
||||
cy.get('#node-view-root > div:nth-child(2) > div > div > aside ')
|
||||
.find('span')
|
||||
.contains('On form submission')
|
||||
.click();
|
||||
workflowPage.getters.nodeCreatorNodeItems().contains('On form submission').click();
|
||||
ndv.getters.parameterInput('formTitle').type('Test Form');
|
||||
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
||||
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
|
||||
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { assert } from '@/utils/assert';
|
||||
import CredentialsDropdown from './CredentialsDropdown.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/constants';
|
||||
|
||||
const props = defineProps({
|
||||
appName: {
|
||||
|
@ -31,6 +32,8 @@ const uiStore = useUIStore();
|
|||
const credentialsStore = useCredentialsStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
const wasModalOpenedFromHere = ref(false);
|
||||
|
||||
const availableCredentials = computed(() => {
|
||||
return credentialsStore.getCredentialsByType(props.credentialType);
|
||||
});
|
||||
|
@ -48,27 +51,30 @@ const onCredentialSelected = (credentialId: string) => {
|
|||
};
|
||||
const createNewCredential = () => {
|
||||
uiStore.openNewCredential(props.credentialType, true);
|
||||
wasModalOpenedFromHere.value = true;
|
||||
$emit('credentialModalOpened');
|
||||
};
|
||||
const editCredential = () => {
|
||||
assert(props.selectedCredentialId);
|
||||
uiStore.openExistingCredential(props.selectedCredentialId);
|
||||
wasModalOpenedFromHere.value = true;
|
||||
$emit('credentialModalOpened');
|
||||
};
|
||||
|
||||
listenForCredentialChanges({
|
||||
store: credentialsStore,
|
||||
onCredentialCreated: (credential) => {
|
||||
// TODO: We should have a better way to detect if credential created was due to
|
||||
// user opening the credential modal from this component, as there might be
|
||||
// two CredentialPicker components on the same page with same credential type.
|
||||
if (credential.type !== props.credentialType) {
|
||||
if (!wasModalOpenedFromHere.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emit('credentialSelected', credential.id);
|
||||
},
|
||||
onCredentialDeleted: (deletedCredentialId) => {
|
||||
if (!wasModalOpenedFromHere.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deletedCredentialId !== props.selectedCredentialId) {
|
||||
return;
|
||||
}
|
||||
|
@ -83,6 +89,15 @@ listenForCredentialChanges({
|
|||
}
|
||||
},
|
||||
});
|
||||
|
||||
listenForModalChanges({
|
||||
store: uiStore,
|
||||
onModalClosed(modalName) {
|
||||
if (modalName === CREDENTIAL_EDIT_MODAL_KEY && wasModalOpenedFromHere.value) {
|
||||
wasModalOpenedFromHere.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -154,7 +154,6 @@ export default defineComponent({
|
|||
params: { name: routeWorkflowId },
|
||||
});
|
||||
}
|
||||
// this.modalBus.emit('closeAll');
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
break;
|
||||
default:
|
||||
|
|
|
@ -134,7 +134,6 @@ export default defineComponent({
|
|||
window.addEventListener('keydown', this.onWindowKeydown);
|
||||
|
||||
this.eventBus?.on('close', this.closeDialog);
|
||||
this.eventBus?.on('closeAll', this.uiStore.closeAllModals);
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
|
@ -143,7 +142,6 @@ export default defineComponent({
|
|||
},
|
||||
beforeUnmount() {
|
||||
this.eventBus?.off('close', this.closeDialog);
|
||||
this.eventBus?.off('closeAll', this.uiStore.closeAllModals);
|
||||
window.removeEventListener('keydown', this.onWindowKeydown);
|
||||
},
|
||||
computed: {
|
||||
|
@ -227,6 +225,7 @@ export default defineComponent({
|
|||
|
||||
.modal-content {
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
|
|
@ -162,6 +162,16 @@
|
|||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="SETUP_CREDENTIALS_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<SetupWorkflowCredentialsModal
|
||||
data-test-id="suggested-templates-preview-modal"
|
||||
:modal-name="modalName"
|
||||
:data="data"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -197,6 +207,7 @@ import {
|
|||
MFA_SETUP_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from './AboutModal.vue';
|
||||
|
@ -229,6 +240,7 @@ import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderMo
|
|||
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
||||
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
|
||||
import SuggestedTemplatesPreviewModal from '@/components/SuggestedTemplates/SuggestedTemplatesPreviewModal.vue';
|
||||
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modals',
|
||||
|
@ -263,6 +275,7 @@ export default defineComponent({
|
|||
MfaSetupModal,
|
||||
WorkflowHistoryVersionRestoreModal,
|
||||
SuggestedTemplatesPreviewModal,
|
||||
SetupWorkflowCredentialsModal,
|
||||
},
|
||||
data: () => ({
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
|
@ -294,6 +307,7 @@ export default defineComponent({
|
|||
MFA_SETUP_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { SETUP_CREDENTIALS_MODAL_KEY, TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT } from '@/constants';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { doesNodeHaveCredentialsToFill } from '@/utils/nodes/nodeTransforms';
|
||||
import { computed, onBeforeUnmount } from 'vue';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const posthogStore = usePostHog();
|
||||
const i18n = useI18n();
|
||||
|
||||
const showButton = computed(() => {
|
||||
const isFeatureEnabled = posthogStore.isFeatureEnabled(TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT);
|
||||
const isCreatedFromTemplate = !!workflowsStore.workflow?.meta?.templateId;
|
||||
if (!isFeatureEnabled || !isCreatedFromTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodes = workflowsStore.workflow?.nodes ?? [];
|
||||
return nodes.some((node) => doesNodeHaveCredentialsToFill(nodeTypesStore, node));
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
uiStore.openModal(SETUP_CREDENTIALS_MODAL_KEY);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uiStore.closeModal(SETUP_CREDENTIALS_MODAL_KEY);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-button
|
||||
v-if="showButton"
|
||||
:label="i18n.baseText('nodeView.setupTemplate')"
|
||||
size="large"
|
||||
@click="handleClick()"
|
||||
/>
|
||||
</template>
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts" setup>
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { useSetupWorkflowCredentialsModalState } from '@/components/SetupWorkflowCredentialsModal/useSetupWorkflowCredentialsModalState';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import N8nHeading from 'n8n-design-system/components/N8nHeading';
|
||||
import AppsRequiringCredsNotice from '@/views/SetupWorkflowFromTemplateView/AppsRequiringCredsNotice.vue';
|
||||
import SetupTemplateFormStep from '@/views/SetupWorkflowFromTemplateView/SetupTemplateFormStep.vue';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {};
|
||||
}>();
|
||||
|
||||
const {
|
||||
appCredentials,
|
||||
credentialUsages,
|
||||
selectedCredentialIdByKey,
|
||||
setInitialCredentialSelection,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
} = useSetupWorkflowCredentialsModalState();
|
||||
|
||||
onMounted(() => {
|
||||
setInitialCredentialSelection();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal width="900px" max-height="90%" :name="props.modalName">
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge">
|
||||
{{ i18n.baseText('setupCredentialsModal.title') }}
|
||||
</N8nHeading>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div :class="$style.grid">
|
||||
<div :class="$style.notice" data-test-id="info-callout">
|
||||
<AppsRequiringCredsNotice :app-credentials="appCredentials" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ol :class="$style.appCredentialsContainer">
|
||||
<SetupTemplateFormStep
|
||||
v-for="(credentials, index) in credentialUsages"
|
||||
:key="credentials.key"
|
||||
:class="$style.appCredential"
|
||||
:order="index + 1"
|
||||
:credentials="credentials"
|
||||
:selected-credential-id="selectedCredentialIdByKey[credentials.key]"
|
||||
@credential-selected="setCredential($event.credentialUsageKey, $event.credentialId)"
|
||||
@credential-deselected="unsetCredential($event.credentialUsageKey)"
|
||||
/>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.grid {
|
||||
margin: 0 auto;
|
||||
margin-top: var(--spacing-l);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 768px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.appCredentialsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.appCredential:not(:last-of-type) {
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
border-bottom: 1px solid var(--color-foreground-light);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,129 @@
|
|||
import { computed } from 'vue';
|
||||
import type { INodeCredentialsDetails } from 'n8n-workflow';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||
import { useCredentialSetupState } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
|
||||
export const useSetupWorkflowCredentialsModalState = () => {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
||||
const workflowNodes = computed(() => {
|
||||
return workflowsStore.allNodes;
|
||||
});
|
||||
|
||||
const {
|
||||
appCredentials,
|
||||
credentialOverrides,
|
||||
credentialUsages,
|
||||
credentialsByKey,
|
||||
numFilledCredentials,
|
||||
selectedCredentialIdByKey,
|
||||
setSelectedCredentialId,
|
||||
unsetSelectedCredential,
|
||||
} = useCredentialSetupState(workflowNodes);
|
||||
|
||||
/**
|
||||
* Selects initial credentials. For existing workflows this means using
|
||||
* the credentials that are already set on the nodes.
|
||||
*/
|
||||
const setInitialCredentialSelection = () => {
|
||||
selectedCredentialIdByKey.value = {};
|
||||
|
||||
for (const credUsage of credentialUsages.value) {
|
||||
const typeCredentials = credentialsStore.getCredentialsByType(credUsage.credentialType);
|
||||
// Make sure there is a credential for this type with the given name
|
||||
const credential = typeCredentials.find((cred) => cred.name === credUsage.credentialName);
|
||||
if (!credential) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedCredentialIdByKey.value[credUsage.key] = credential.id;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the given credential to all nodes that use it.
|
||||
*/
|
||||
const setCredential = (credentialKey: TemplateCredentialKey, credentialId: string) => {
|
||||
setSelectedCredentialId(credentialKey, credentialId);
|
||||
|
||||
const usages = credentialsByKey.value.get(credentialKey);
|
||||
if (!usages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialName = credentialsStore.getCredentialById(credentialId)?.name;
|
||||
const credential: INodeCredentialsDetails = {
|
||||
id: credentialId,
|
||||
name: credentialName,
|
||||
};
|
||||
|
||||
usages.usedBy.forEach((node) => {
|
||||
workflowsStore.updateNodeProperties({
|
||||
name: node.name,
|
||||
properties: {
|
||||
position: node.position,
|
||||
credentials: {
|
||||
...node.credentials,
|
||||
[usages.credentialType]: credential,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// We can't use updateNodeCredentialIssues because the previous
|
||||
// step creates a new instance of the node in the store and
|
||||
// `node` no longer points to the correct node.
|
||||
nodeHelpers.updateNodeCredentialIssuesByName(node.name);
|
||||
});
|
||||
|
||||
setInitialCredentialSelection();
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes given credential from all nodes that use it.
|
||||
*/
|
||||
const unsetCredential = (credentialKey: TemplateCredentialKey) => {
|
||||
unsetSelectedCredential(credentialKey);
|
||||
|
||||
const usages = credentialsByKey.value.get(credentialKey);
|
||||
if (!usages) {
|
||||
return;
|
||||
}
|
||||
|
||||
usages.usedBy.forEach((node) => {
|
||||
const credentials = { ...node.credentials };
|
||||
delete credentials[usages.credentialType];
|
||||
|
||||
workflowsStore.updateNodeProperties({
|
||||
name: node.name,
|
||||
properties: {
|
||||
position: node.position,
|
||||
credentials,
|
||||
},
|
||||
});
|
||||
|
||||
// We can't use updateNodeCredentialIssues because the previous
|
||||
// step creates a new instance of the node in the store and
|
||||
// `node` no longer points to the correct node.
|
||||
nodeHelpers.updateNodeCredentialIssuesByName(node.name);
|
||||
});
|
||||
|
||||
setInitialCredentialSelection();
|
||||
};
|
||||
|
||||
return {
|
||||
appCredentials,
|
||||
credentialOverrides,
|
||||
credentialUsages,
|
||||
credentialsByKey,
|
||||
numFilledCredentials,
|
||||
selectedCredentialIdByKey,
|
||||
setInitialCredentialSelection,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
};
|
||||
};
|
|
@ -57,6 +57,7 @@ export const DEBUG_PAYWALL_MODAL_KEY = 'debugPaywall';
|
|||
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
|
||||
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
|
||||
export const SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY = 'suggestedTemplatePreview';
|
||||
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
|
||||
|
||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||
|
||||
|
|
|
@ -1095,6 +1095,7 @@
|
|||
"nodeView.zoomOut": "Zoom Out",
|
||||
"nodeView.zoomToFit": "Zoom to Fit",
|
||||
"nodeView.replaceMe": "Replace Me",
|
||||
"nodeView.setupTemplate": "Set up Template",
|
||||
"contextMenu.node": "node | nodes",
|
||||
"contextMenu.sticky": "sticky note | sticky notes",
|
||||
"contextMenu.selectAll": "Select all",
|
||||
|
@ -2359,5 +2360,6 @@
|
|||
"templateSetup.skip": "Skip",
|
||||
"templateSetup.continue.button": "Continue",
|
||||
"templateSetup.credential.description": "The credential you select will be used in the {0} node of the workflow template. | The credential you select will be used in the {0} nodes of the workflow template.",
|
||||
"templateSetup.continue.button.fillRemaining": "Fill remaining credentials to continue"
|
||||
"templateSetup.continue.button.fillRemaining": "Fill remaining credentials to continue",
|
||||
"setupCredentialsModal.title": "Setup credentials"
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ import {
|
|||
N8N_PRICING_PAGE_URL,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
CloudUpdateLinkSourceType,
|
||||
|
@ -84,67 +85,47 @@ try {
|
|||
}
|
||||
} catch (e) {}
|
||||
|
||||
export type UiStore = ReturnType<typeof useUIStore>;
|
||||
|
||||
export const useUIStore = defineStore(STORES.UI, {
|
||||
state: (): UIState => ({
|
||||
activeActions: [],
|
||||
activeCredentialType: null,
|
||||
theme: savedTheme,
|
||||
modals: {
|
||||
[ABOUT_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[CHAT_EMBED_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[CHANGE_PASSWORD_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[CONTACT_PROMPT_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[CREDENTIAL_SELECT_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
...Object.fromEntries(
|
||||
[
|
||||
ABOUT_MODAL_KEY,
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
CHANGE_PASSWORD_MODAL_KEY,
|
||||
CONTACT_PROMPT_MODAL_KEY,
|
||||
CREDENTIAL_SELECT_MODAL_KEY,
|
||||
DUPLICATE_MODAL_KEY,
|
||||
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
|
||||
PERSONALIZATION_MODAL_KEY,
|
||||
INVITE_USER_MODAL_KEY,
|
||||
TAGS_MANAGER_MODAL_KEY,
|
||||
VALUE_SURVEY_MODAL_KEY,
|
||||
VERSIONS_MODAL_KEY,
|
||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||
DEBUG_PAYWALL_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
].map((modalKey) => [modalKey, { open: false }]),
|
||||
),
|
||||
[DELETE_USER_MODAL_KEY]: {
|
||||
open: false,
|
||||
activeId: null,
|
||||
},
|
||||
[DUPLICATE_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[ONBOARDING_CALL_SIGNUP_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[PERSONALIZATION_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[INVITE_USER_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[TAGS_MANAGER_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[VALUE_SURVEY_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[VERSIONS_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_LM_CHAT_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_SETTINGS_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_SHARE_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_ACTIVE_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: {
|
||||
open: false,
|
||||
mode: '',
|
||||
|
@ -155,9 +136,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
curlCommand: '',
|
||||
httpNodeParameters: '',
|
||||
},
|
||||
[MFA_SETUP_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[LOG_STREAM_MODAL_KEY]: {
|
||||
open: false,
|
||||
data: undefined,
|
||||
|
@ -168,24 +146,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
activeId: null,
|
||||
showAuthSelector: false,
|
||||
},
|
||||
[SOURCE_CONTROL_PUSH_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[SOURCE_CONTROL_PULL_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[EXTERNAL_SECRETS_PROVIDER_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[DEBUG_PAYWALL_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_HISTORY_VERSION_RESTORE]: {
|
||||
open: false,
|
||||
},
|
||||
[SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
},
|
||||
modalStack: [],
|
||||
sidebarMenuCollapsed: true,
|
||||
|
@ -463,17 +423,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
return name !== openModalName;
|
||||
});
|
||||
},
|
||||
closeAllModals(): void {
|
||||
Object.keys(this.modals).forEach((name) => {
|
||||
if (this.modals[name].open) {
|
||||
this.modals[name] = {
|
||||
...this.modals[name],
|
||||
open: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
this.modalStack = [];
|
||||
},
|
||||
draggableStartDragging(type: string, data: string): void {
|
||||
this.draggable = {
|
||||
isDragging: true,
|
||||
|
@ -672,3 +621,44 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function for listening to credential changes in the store
|
||||
*/
|
||||
export const listenForModalChanges = (opts: {
|
||||
store: UiStore;
|
||||
onModalOpened?: (name: keyof Modals) => void;
|
||||
onModalClosed?: (name: keyof Modals) => void;
|
||||
}): void => {
|
||||
const { store, onModalClosed, onModalOpened } = opts;
|
||||
const listeningForActions = ['openModal', 'openModalWithData', 'closeModal'];
|
||||
|
||||
store.$onAction((result) => {
|
||||
const { name, after, args } = result;
|
||||
after(async () => {
|
||||
if (!listeningForActions.includes(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'openModal': {
|
||||
const modalName = args[0];
|
||||
onModalOpened?.(modalName);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'openModalWithData': {
|
||||
const { name: modalName } = args[0] ?? {};
|
||||
onModalOpened?.(modalName);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'closeModal': {
|
||||
const modalName = args[0];
|
||||
onModalClosed?.(modalName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -31,3 +31,12 @@ export function getNodeTypeDisplayableCredentials(
|
|||
|
||||
return displayableCredentials;
|
||||
}
|
||||
|
||||
export function doesNodeHaveCredentialsToFill(
|
||||
nodeTypeProvider: NodeTypeProvider,
|
||||
node: Pick<INodeUi, 'parameters' | 'type' | 'typeVersion'>,
|
||||
): boolean {
|
||||
const requiredCredentials = getNodeTypeDisplayableCredentials(nodeTypeProvider, node);
|
||||
|
||||
return requiredCredentials.length > 0;
|
||||
}
|
||||
|
|
|
@ -12,10 +12,7 @@ import type { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { getFixedNodesList } from '@/utils/nodeViewUtils';
|
||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||
import {
|
||||
getNodesRequiringCredentials,
|
||||
replaceAllTemplateNodeCredentials,
|
||||
} from '@/utils/templates/templateTransforms';
|
||||
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
|
||||
import type { INodeCredentialsDetails } from 'n8n-workflow';
|
||||
import type { RouteLocationRaw, Router } from 'vue-router';
|
||||
import type { TemplatesStore } from '@/stores/templates.store';
|
||||
|
@ -23,6 +20,7 @@ import type { NodeTypesStore } from '@/stores/nodeTypes.store';
|
|||
import type { Telemetry } from '@/plugins/telemetry';
|
||||
import type { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { assert } from '@/utils/assert';
|
||||
import { doesNodeHaveCredentialsToFill } from '@/utils/nodes/nodeTransforms';
|
||||
|
||||
type ExternalHooks = ReturnType<typeof useExternalHooks>;
|
||||
|
||||
|
@ -126,9 +124,9 @@ function hasTemplateCredentials(
|
|||
nodeTypeProvider: NodeTypeProvider,
|
||||
template: ITemplatesWorkflowFull,
|
||||
) {
|
||||
const nodesRequiringCreds = getNodesRequiringCredentials(nodeTypeProvider, template);
|
||||
|
||||
return nodesRequiringCreds.length > 0;
|
||||
return template.workflow.nodes.some((node) =>
|
||||
doesNodeHaveCredentialsToFill(nodeTypeProvider, node),
|
||||
);
|
||||
}
|
||||
|
||||
async function getFullTemplate(templatesStore: TemplatesStore, templateId: string) {
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
ITemplatesWorkflowFull,
|
||||
IWorkflowTemplateNode,
|
||||
IWorkflowTemplateNodeCredentials,
|
||||
} from '@/Interface';
|
||||
import type { IWorkflowTemplateNode, IWorkflowTemplateNodeCredentials } from '@/Interface';
|
||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
|
||||
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
|
||||
|
@ -43,8 +39,12 @@ export const keyFromCredentialTypeAndName = (
|
|||
* different versions of n8n may have different credential formats.
|
||||
*/
|
||||
export const normalizeTemplateNodeCredentials = (
|
||||
credentials: IWorkflowTemplateNodeCredentials,
|
||||
credentials?: IWorkflowTemplateNodeCredentials,
|
||||
): NormalizedTemplateNodeCredentials => {
|
||||
if (!credentials) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(credentials).map(([key, value]) => {
|
||||
return typeof value === 'string' ? [key, value] : [key, value.name];
|
||||
|
@ -133,25 +133,3 @@ export const replaceAllTemplateNodeCredentials = (
|
|||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the nodes in the template that require credentials
|
||||
* and the required credentials for each node.
|
||||
*/
|
||||
export const getNodesRequiringCredentials = (
|
||||
nodeTypeProvider: NodeTypeProvider,
|
||||
template: ITemplatesWorkflowFull,
|
||||
): TemplateNodeWithRequiredCredential[] => {
|
||||
if (!template) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodesWithCredentials: TemplateNodeWithRequiredCredential[] = template.workflow.nodes
|
||||
.map((node) => ({
|
||||
node,
|
||||
requiredCredentials: getNodeTypeDisplayableCredentials(nodeTypeProvider, node),
|
||||
}))
|
||||
.filter(({ requiredCredentials }) => requiredCredentials.length > 0);
|
||||
|
||||
return nodesWithCredentials;
|
||||
};
|
||||
|
|
|
@ -96,6 +96,11 @@
|
|||
@stopExecution="stopExecution"
|
||||
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
||||
/>
|
||||
<Suspense>
|
||||
<div :class="$style.setupCredentialsButtonWrapper">
|
||||
<SetupWorkflowCredentialsButton />
|
||||
</div>
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<NodeCreation
|
||||
v-if="!isReadOnlyRoute && !readOnlyEnv"
|
||||
|
@ -381,6 +386,10 @@ interface AddNodeOptions {
|
|||
|
||||
const NodeCreation = defineAsyncComponent(async () => import('@/components/Node/NodeCreation.vue'));
|
||||
const CanvasControls = defineAsyncComponent(async () => import('@/components/CanvasControls.vue'));
|
||||
const SetupWorkflowCredentialsButton = defineAsyncComponent(
|
||||
async () =>
|
||||
import('@/components/SetupWorkflowCredentialsButton/SetupWorkflowCredentialsButton.vue'),
|
||||
);
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeView',
|
||||
|
@ -393,6 +402,7 @@ export default defineComponent({
|
|||
NodeCreation,
|
||||
CanvasControls,
|
||||
ContextMenu,
|
||||
SetupWorkflowCredentialsButton,
|
||||
},
|
||||
mixins: [moveNodeWorkflow, workflowHelpers, workflowRun, debounceHelper],
|
||||
async beforeRouteLeave(to, from, next) {
|
||||
|
@ -5180,4 +5190,10 @@ export default defineComponent({
|
|||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.setupCredentialsButtonWrapper {
|
||||
position: absolute;
|
||||
left: 35px;
|
||||
top: var(--spacing-s);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import N8nNotice from 'n8n-design-system/components/N8nNotice';
|
||||
import type { AppCredentials } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { formatList } from '@/utils/formatters/listFormatter';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type {
|
||||
AppCredentials,
|
||||
BaseNode,
|
||||
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useSetupTemplateStore();
|
||||
const { appCredentials } = storeToRefs(store);
|
||||
|
||||
const formatApp = (app: AppCredentials) => `<b>${app.credentials.length}x ${app.appName}</b>`;
|
||||
const props = defineProps<{
|
||||
appCredentials: Array<AppCredentials<BaseNode>>;
|
||||
}>();
|
||||
|
||||
const formatApp = (app: AppCredentials<BaseNode>) =>
|
||||
`<b>${app.credentials.length}x ${app.appName}</b>`;
|
||||
|
||||
const appNodeCounts = computed(() => {
|
||||
return formatList(appCredentials.value, {
|
||||
return formatList(props.appCredentials, {
|
||||
formatFn: formatApp,
|
||||
i18n,
|
||||
});
|
||||
|
|
|
@ -8,11 +8,13 @@ import IconSuccess from './IconSuccess.vue';
|
|||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
||||
import { formatList } from '@/utils/formatters/listFormatter';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import type { IWorkflowTemplateNode } from '@/Interface';
|
||||
import type {
|
||||
BaseNode,
|
||||
CredentialUsages,
|
||||
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
|
@ -24,10 +26,22 @@ const props = defineProps({
|
|||
type: Object as PropType<CredentialUsages>,
|
||||
required: true,
|
||||
},
|
||||
selectedCredentialId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'credentialSelected',
|
||||
event: { credentialUsageKey: TemplateCredentialKey; credentialId: string },
|
||||
): void;
|
||||
(e: 'credentialDeselected', event: { credentialUsageKey: TemplateCredentialKey }): void;
|
||||
}>();
|
||||
|
||||
// Stores
|
||||
const setupTemplateStore = useSetupTemplateStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
@ -45,36 +59,24 @@ const appName = computed(() =>
|
|||
);
|
||||
|
||||
const nodeNames = computed(() => {
|
||||
const formatNodeName = (nodeToFormat: IWorkflowTemplateNode) => `<b>${nodeToFormat.name}</b>`;
|
||||
const formatNodeName = (nodeToFormat: BaseNode) => `<b>${nodeToFormat.name}</b>`;
|
||||
return formatList(props.credentials.usedBy, {
|
||||
formatFn: formatNodeName,
|
||||
i18n,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCredentialId = computed(
|
||||
() => setupTemplateStore.selectedCredentialIdByKey[props.credentials.key],
|
||||
);
|
||||
|
||||
//#endregion Computed
|
||||
|
||||
//#region Methods
|
||||
|
||||
const onCredentialSelected = (credentialId: string) => {
|
||||
setupTemplateStore.setSelectedCredentialId(props.credentials.key, credentialId);
|
||||
};
|
||||
|
||||
const onCredentialDeselected = () => {
|
||||
setupTemplateStore.unsetSelectedCredential(props.credentials.key);
|
||||
};
|
||||
|
||||
const onCredentialModalOpened = () => {
|
||||
telemetry.track(
|
||||
'User opened Credential modal',
|
||||
{
|
||||
source: 'cred_setup',
|
||||
credentialType: props.credentials.credentialType,
|
||||
new_credential: !selectedCredentialId.value,
|
||||
new_credential: !props.selectedCredentialId,
|
||||
},
|
||||
{
|
||||
withPostHog: true,
|
||||
|
@ -112,8 +114,15 @@ const onCredentialModalOpened = () => {
|
|||
:app-name="appName"
|
||||
:credential-type="props.credentials.credentialType"
|
||||
:selected-credential-id="selectedCredentialId"
|
||||
@credential-selected="onCredentialSelected"
|
||||
@credential-deselected="onCredentialDeselected"
|
||||
@credential-selected="
|
||||
emit('credentialSelected', {
|
||||
credentialUsageKey: $props.credentials.key,
|
||||
credentialId: $event,
|
||||
})
|
||||
"
|
||||
@credential-deselected="
|
||||
emit('credentialDeselected', { credentialUsageKey: $props.credentials.key })
|
||||
"
|
||||
@credential-modal-opened="onCredentialModalOpened"
|
||||
/>
|
||||
|
||||
|
|
|
@ -104,7 +104,10 @@ onMounted(async () => {
|
|||
<template #content>
|
||||
<div :class="$style.grid">
|
||||
<div :class="$style.notice" data-test-id="info-callout">
|
||||
<AppsRequiringCredsNotice v-if="isReady" />
|
||||
<AppsRequiringCredsNotice
|
||||
v-if="isReady"
|
||||
:app-credentials="setupTemplateStore.appCredentials"
|
||||
/>
|
||||
<n8n-loading v-else variant="p" />
|
||||
</div>
|
||||
|
||||
|
@ -116,6 +119,18 @@ onMounted(async () => {
|
|||
:class="$style.appCredential"
|
||||
:order="index + 1"
|
||||
:credentials="credentials"
|
||||
:selected-credential-id="
|
||||
setupTemplateStore.selectedCredentialIdByKey[credentials.key]
|
||||
"
|
||||
@credential-selected="
|
||||
setupTemplateStore.setSelectedCredentialId(
|
||||
$event.credentialUsageKey,
|
||||
$event.credentialId,
|
||||
)
|
||||
"
|
||||
@credential-deselected="
|
||||
setupTemplateStore.unsetSelectedCredential($event.credentialUsageKey)
|
||||
"
|
||||
/>
|
||||
</ol>
|
||||
<div v-else :class="$style.appCredentialsContainer">
|
||||
|
|
|
@ -1,233 +1,13 @@
|
|||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
||||
import type {
|
||||
TemplateCredentialKey,
|
||||
IWorkflowTemplateNodeWithCredentials,
|
||||
} from '@/utils/templates/templateTransforms';
|
||||
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import {
|
||||
getAppCredentials,
|
||||
getAppsRequiringCredentials,
|
||||
useSetupTemplateStore,
|
||||
groupNodeCredentialsByKey,
|
||||
} from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import * as testData from './setupTemplate.store.testData';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
|
||||
return new Map(Object.entries(obj)) as Map<TKey, T>;
|
||||
};
|
||||
|
||||
describe('SetupWorkflowFromTemplateView store', () => {
|
||||
const nodesByName = {
|
||||
Twitter: {
|
||||
name: 'Twitter',
|
||||
type: 'n8n-nodes-base.twitter',
|
||||
position: [720, -220],
|
||||
parameters: {
|
||||
text: '=Hey there, my design is now on a new product ✨\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}}) 🛍️',
|
||||
additionalFields: {},
|
||||
},
|
||||
credentials: {
|
||||
twitterOAuth1Api: 'twitter',
|
||||
},
|
||||
typeVersion: 1,
|
||||
},
|
||||
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
|
||||
|
||||
describe('groupNodeCredentialsByTypeAndName', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
expect(groupNodeCredentialsByKey([])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('returns credentials grouped by type and name', () => {
|
||||
expect(
|
||||
groupNodeCredentialsByKey([
|
||||
{
|
||||
node: nodesByName.Twitter,
|
||||
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
|
||||
},
|
||||
]),
|
||||
).toEqual(
|
||||
objToMap({
|
||||
'twitterOAuth1Api-twitter': {
|
||||
key: 'twitterOAuth1Api-twitter',
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns credentials grouped when the credential names are the same', () => {
|
||||
const [node1, node2] = [
|
||||
newWorkflowTemplateNode({
|
||||
type: 'n8n-nodes-base.twitter',
|
||||
credentials: {
|
||||
twitterOAuth1Api: 'credential',
|
||||
},
|
||||
}) as IWorkflowTemplateNodeWithCredentials,
|
||||
newWorkflowTemplateNode({
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
credentials: {
|
||||
telegramApi: 'credential',
|
||||
},
|
||||
}) as IWorkflowTemplateNodeWithCredentials,
|
||||
];
|
||||
|
||||
expect(
|
||||
groupNodeCredentialsByKey([
|
||||
{
|
||||
node: node1,
|
||||
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
|
||||
},
|
||||
{
|
||||
node: node2,
|
||||
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
|
||||
},
|
||||
]),
|
||||
).toEqual(
|
||||
objToMap({
|
||||
'twitterOAuth1Api-credential': {
|
||||
key: 'twitterOAuth1Api-credential',
|
||||
credentialName: 'credential',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [node1],
|
||||
},
|
||||
'telegramApi-credential': {
|
||||
key: 'telegramApi-credential',
|
||||
credentialName: 'credential',
|
||||
credentialType: 'telegramApi',
|
||||
nodeTypeName: 'n8n-nodes-base.telegram',
|
||||
usedBy: [node2],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppsRequiringCredentials', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
expect(getAppsRequiringCredentials(new Map(), appNameByNodeTypeName)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an array of apps requiring credentials', () => {
|
||||
const credentialUsages = objToMap<TemplateCredentialKey, CredentialUsages>({
|
||||
[keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter')]: {
|
||||
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
});
|
||||
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
|
||||
expect(getAppsRequiringCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
|
||||
{
|
||||
appName: 'Twitter',
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppCredentials', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
expect(getAppCredentials([], appNameByNodeTypeName)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an array of apps requiring credentials', () => {
|
||||
const credentialUsages: CredentialUsages[] = [
|
||||
{
|
||||
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
];
|
||||
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
|
||||
expect(getAppCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
|
||||
{
|
||||
appName: 'Twitter',
|
||||
credentials: [
|
||||
{
|
||||
key: 'twitterOAuth1Api-twitter',
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentialOverrides', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
stubActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
|
||||
const templatesStore = useTemplatesStore();
|
||||
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
nodeTypesStore.setNodeTypes([
|
||||
testData.nodeTypeTelegramV1,
|
||||
testData.nodeTypeTwitterV1,
|
||||
testData.nodeTypeShopifyTriggerV1,
|
||||
]);
|
||||
const setupTemplateStore = useSetupTemplateStore();
|
||||
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
|
||||
});
|
||||
|
||||
it('should return an empty object if there are no credential overrides', () => {
|
||||
// Setup
|
||||
const setupTemplateStore = useSetupTemplateStore();
|
||||
|
||||
expect(setupTemplateStore.credentialUsages.length).toBe(3);
|
||||
expect(setupTemplateStore.credentialOverrides).toEqual({});
|
||||
});
|
||||
|
||||
it('should return overrides for one node', () => {
|
||||
// Setup
|
||||
const credentialsStore = useCredentialsStore();
|
||||
credentialsStore.setCredentials([testData.credentialsTelegram1]);
|
||||
|
||||
const setupTemplateStore = useSetupTemplateStore();
|
||||
setupTemplateStore.setSelectedCredentialId(
|
||||
keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
|
||||
testData.credentialsTelegram1.id,
|
||||
);
|
||||
|
||||
expect(setupTemplateStore.credentialUsages.length).toBe(3);
|
||||
expect(setupTemplateStore.credentialOverrides).toEqual({
|
||||
'twitterOAuth1Api-twitter': {
|
||||
id: testData.credentialsTelegram1.id,
|
||||
name: testData.credentialsTelegram1.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setInitialCredentialsSelection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
||||
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
|
||||
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
import {
|
||||
getAppCredentials,
|
||||
groupNodeCredentialsByKey,
|
||||
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
import * as testData from './setupTemplate.store.testData';
|
||||
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
|
||||
|
||||
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
|
||||
return new Map(Object.entries(obj)) as Map<TKey, T>;
|
||||
};
|
||||
|
||||
describe('useCredentialSetupState', () => {
|
||||
const nodesByName = {
|
||||
Twitter: {
|
||||
name: 'Twitter',
|
||||
type: 'n8n-nodes-base.twitter',
|
||||
position: [720, -220],
|
||||
parameters: {
|
||||
text: '=Hey there, my design is now on a new product ✨\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}}) 🛍️',
|
||||
additionalFields: {},
|
||||
},
|
||||
credentials: {
|
||||
twitterOAuth1Api: 'twitter',
|
||||
},
|
||||
typeVersion: 1,
|
||||
},
|
||||
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
|
||||
|
||||
describe('groupNodeCredentialsByTypeAndName', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
expect(groupNodeCredentialsByKey([])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('returns credentials grouped by type and name', () => {
|
||||
expect(
|
||||
groupNodeCredentialsByKey([
|
||||
{
|
||||
node: nodesByName.Twitter,
|
||||
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
|
||||
},
|
||||
]),
|
||||
).toEqual(
|
||||
objToMap({
|
||||
'twitterOAuth1Api-twitter': {
|
||||
key: 'twitterOAuth1Api-twitter',
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns credentials grouped when the credential names are the same', () => {
|
||||
const [node1, node2] = [
|
||||
newWorkflowTemplateNode({
|
||||
type: 'n8n-nodes-base.twitter',
|
||||
credentials: {
|
||||
twitterOAuth1Api: 'credential',
|
||||
},
|
||||
}) as IWorkflowTemplateNodeWithCredentials,
|
||||
newWorkflowTemplateNode({
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
credentials: {
|
||||
telegramApi: 'credential',
|
||||
},
|
||||
}) as IWorkflowTemplateNodeWithCredentials,
|
||||
];
|
||||
|
||||
expect(
|
||||
groupNodeCredentialsByKey([
|
||||
{
|
||||
node: node1,
|
||||
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
|
||||
},
|
||||
{
|
||||
node: node2,
|
||||
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
|
||||
},
|
||||
]),
|
||||
).toEqual(
|
||||
objToMap({
|
||||
'twitterOAuth1Api-credential': {
|
||||
key: 'twitterOAuth1Api-credential',
|
||||
credentialName: 'credential',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [node1],
|
||||
},
|
||||
'telegramApi-credential': {
|
||||
key: 'telegramApi-credential',
|
||||
credentialName: 'credential',
|
||||
credentialType: 'telegramApi',
|
||||
nodeTypeName: 'n8n-nodes-base.telegram',
|
||||
usedBy: [node2],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppCredentials', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
expect(getAppCredentials([], appNameByNodeTypeName)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an array of apps requiring credentials', () => {
|
||||
const credentialUsages: CredentialUsages[] = [
|
||||
{
|
||||
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
];
|
||||
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
|
||||
expect(getAppCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
|
||||
{
|
||||
appName: 'Twitter',
|
||||
credentials: [
|
||||
{
|
||||
key: 'twitterOAuth1Api-twitter',
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,4 +1,3 @@
|
|||
import sortBy from 'lodash-es/sortBy';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Router } from 'vue-router';
|
||||
|
@ -7,22 +6,13 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
||||
import type { INodeCredentialsDetails, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { ICredentialsResponse, INodeUi, IWorkflowTemplateNode } from '@/Interface';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
|
||||
import type {
|
||||
TemplateCredentialKey,
|
||||
TemplateNodeWithRequiredCredential,
|
||||
} from '@/utils/templates/templateTransforms';
|
||||
import {
|
||||
getNodesRequiringCredentials,
|
||||
keyFromCredentialTypeAndName,
|
||||
normalizeTemplateNodeCredentials,
|
||||
} from '@/utils/templates/templateTransforms';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useCredentialSetupState } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
|
||||
export type NodeAndType = {
|
||||
node: INodeUi;
|
||||
|
@ -35,131 +25,21 @@ export type RequiredCredentials = {
|
|||
credentialType: string;
|
||||
};
|
||||
|
||||
export type CredentialUsages = {
|
||||
/**
|
||||
* Key is a combination of the credential name and the credential type name,
|
||||
* e.g. "twitter-twitterOAuth1Api"
|
||||
*/
|
||||
key: TemplateCredentialKey;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
nodeTypeName: string;
|
||||
usedBy: IWorkflowTemplateNode[];
|
||||
};
|
||||
|
||||
export type AppCredentials = {
|
||||
appName: string;
|
||||
credentials: CredentialUsages[];
|
||||
};
|
||||
|
||||
export type AppCredentialCount = {
|
||||
appName: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
//#region Getter functions
|
||||
|
||||
export const groupNodeCredentialsByKey = (
|
||||
nodeWithRequiredCredentials: TemplateNodeWithRequiredCredential[],
|
||||
) => {
|
||||
const credentialsByTypeName = new Map<TemplateCredentialKey, CredentialUsages>();
|
||||
|
||||
for (const { node, requiredCredentials } of nodeWithRequiredCredentials) {
|
||||
const normalizedNodeCreds = node.credentials
|
||||
? normalizeTemplateNodeCredentials(node.credentials)
|
||||
: {};
|
||||
|
||||
for (const credentialDescription of requiredCredentials) {
|
||||
const credentialType = credentialDescription.name;
|
||||
const nodeCredentialName = normalizedNodeCreds[credentialDescription.name] ?? '';
|
||||
const key = keyFromCredentialTypeAndName(credentialType, nodeCredentialName);
|
||||
|
||||
let credentialUsages = credentialsByTypeName.get(key);
|
||||
if (!credentialUsages) {
|
||||
credentialUsages = {
|
||||
key,
|
||||
nodeTypeName: node.type,
|
||||
credentialName: nodeCredentialName,
|
||||
credentialType,
|
||||
usedBy: [],
|
||||
};
|
||||
credentialsByTypeName.set(key, credentialUsages);
|
||||
}
|
||||
|
||||
credentialUsages.usedBy.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return credentialsByTypeName;
|
||||
};
|
||||
|
||||
export const getAppCredentials = (
|
||||
credentialUsages: CredentialUsages[],
|
||||
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
|
||||
) => {
|
||||
const credentialsByAppName = new Map<string, AppCredentials>();
|
||||
|
||||
for (const credentialUsage of credentialUsages) {
|
||||
const nodeTypeName = credentialUsage.nodeTypeName;
|
||||
|
||||
const appName = getAppNameByNodeType(nodeTypeName) ?? nodeTypeName;
|
||||
const appCredentials = credentialsByAppName.get(appName);
|
||||
if (appCredentials) {
|
||||
appCredentials.credentials.push(credentialUsage);
|
||||
} else {
|
||||
credentialsByAppName.set(appName, {
|
||||
appName,
|
||||
credentials: [credentialUsage],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(credentialsByAppName.values());
|
||||
};
|
||||
|
||||
export const getAppsRequiringCredentials = (
|
||||
credentialUsagesByName: Map<string, CredentialUsages>,
|
||||
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
|
||||
) => {
|
||||
const credentialsByAppName = new Map<string, AppCredentialCount>();
|
||||
|
||||
for (const credentialUsage of credentialUsagesByName.values()) {
|
||||
const node = credentialUsage.usedBy[0];
|
||||
|
||||
const appName = getAppNameByNodeType(node.type, node.typeVersion) ?? node.type;
|
||||
const appCredentials = credentialsByAppName.get(appName);
|
||||
if (appCredentials) {
|
||||
appCredentials.count++;
|
||||
} else {
|
||||
credentialsByAppName.set(appName, {
|
||||
appName,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(credentialsByAppName.values());
|
||||
};
|
||||
|
||||
//#endregion Getter functions
|
||||
|
||||
/**
|
||||
* Store for managing the state of the SetupWorkflowFromTemplateView
|
||||
*/
|
||||
export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
||||
//#region State
|
||||
|
||||
const templateId = ref<string>('');
|
||||
const isLoading = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
/**
|
||||
* Credentials user has selected from the UI. Map from credential
|
||||
* name in the template to the credential ID.
|
||||
*/
|
||||
const selectedCredentialIdByKey = ref<
|
||||
Record<CredentialUsages['key'], ICredentialsResponse['id']>
|
||||
>({});
|
||||
|
||||
//#endregion State
|
||||
|
||||
const templatesStore = useTemplatesStore();
|
||||
|
@ -174,55 +54,21 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
|||
return templateId.value ? templatesStore.getFullTemplateById(templateId.value) : null;
|
||||
});
|
||||
|
||||
const nodesRequiringCredentialsSorted = computed(() => {
|
||||
const nodesWithCredentials = template.value
|
||||
? getNodesRequiringCredentials(nodeTypesStore, template.value)
|
||||
: [];
|
||||
|
||||
// Order by the X coordinate of the node
|
||||
return sortBy(nodesWithCredentials, ({ node }) => node.position[0]);
|
||||
const templateNodes = computed(() => {
|
||||
return template.value?.workflow.nodes ?? [];
|
||||
});
|
||||
|
||||
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
|
||||
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
|
||||
|
||||
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
|
||||
};
|
||||
|
||||
const credentialsByKey = computed(() => {
|
||||
return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
|
||||
});
|
||||
|
||||
const credentialUsages = computed(() => {
|
||||
return Array.from(credentialsByKey.value.values());
|
||||
});
|
||||
|
||||
const appCredentials = computed(() => {
|
||||
return getAppCredentials(credentialUsages.value, appNameByNodeType);
|
||||
});
|
||||
|
||||
const credentialOverrides = computed(() => {
|
||||
const overrides: Record<TemplateCredentialKey, INodeCredentialsDetails> = {};
|
||||
|
||||
for (const [key, credentialId] of Object.entries(selectedCredentialIdByKey.value)) {
|
||||
const credential = credentialsStore.getCredentialById(credentialId);
|
||||
if (!credential) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Object.entries fails to give the more accurate key type
|
||||
overrides[key as TemplateCredentialKey] = {
|
||||
id: credentialId,
|
||||
name: credential.name,
|
||||
};
|
||||
}
|
||||
|
||||
return overrides;
|
||||
});
|
||||
|
||||
const numFilledCredentials = computed(() => {
|
||||
return Object.keys(selectedCredentialIdByKey.value).length;
|
||||
});
|
||||
const {
|
||||
appCredentials,
|
||||
credentialOverrides,
|
||||
credentialUsages,
|
||||
credentialsByKey,
|
||||
nodesRequiringCredentialsSorted,
|
||||
numFilledCredentials,
|
||||
selectedCredentialIdByKey,
|
||||
setSelectedCredentialId,
|
||||
unsetSelectedCredential,
|
||||
} = useCredentialSetupState(templateNodes);
|
||||
|
||||
//#endregion Getters
|
||||
|
||||
|
@ -370,14 +216,6 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
|||
}
|
||||
};
|
||||
|
||||
const setSelectedCredentialId = (credentialKey: TemplateCredentialKey, credentialId: string) => {
|
||||
selectedCredentialIdByKey.value[credentialKey] = credentialId;
|
||||
};
|
||||
|
||||
const unsetSelectedCredential = (credentialKey: TemplateCredentialKey) => {
|
||||
delete selectedCredentialIdByKey.value[credentialKey];
|
||||
};
|
||||
|
||||
//#endregion Actions
|
||||
|
||||
return {
|
||||
|
|
|
@ -0,0 +1,231 @@
|
|||
import type { Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ICredentialsResponse, INodeUi } from '@/Interface';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
||||
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||
import {
|
||||
keyFromCredentialTypeAndName,
|
||||
normalizeTemplateNodeCredentials,
|
||||
} from '@/utils/templates/templateTransforms';
|
||||
import type { INodeCredentialDescription, INodeCredentialsDetails } from 'n8n-workflow';
|
||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
|
||||
import sortBy from 'lodash-es/sortBy';
|
||||
|
||||
//#region Types
|
||||
|
||||
export type NodeCredentials = {
|
||||
[key: string]: string | INodeCredentialsDetails;
|
||||
};
|
||||
|
||||
/**
|
||||
* Node that can either be in a workflow or in a template workflow. These
|
||||
* have a bit different shape and this type is used to represent both.
|
||||
*/
|
||||
export type BaseNode = Pick<
|
||||
INodeUi,
|
||||
'name' | 'parameters' | 'position' | 'type' | 'typeVersion'
|
||||
> & {
|
||||
credentials?: NodeCredentials;
|
||||
};
|
||||
|
||||
export type NodeWithCredentials<TNode extends BaseNode> = TNode & {
|
||||
credentials: NodeCredentials;
|
||||
};
|
||||
|
||||
export type NodeWithRequiredCredential<TNode extends BaseNode> = {
|
||||
node: TNode;
|
||||
requiredCredentials: INodeCredentialDescription[];
|
||||
};
|
||||
|
||||
export type CredentialUsages<TNode extends BaseNode = BaseNode> = {
|
||||
/**
|
||||
* Key is a combination of the credential name and the credential type name,
|
||||
* e.g. "twitter-twitterOAuth1Api"
|
||||
*/
|
||||
key: TemplateCredentialKey;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
nodeTypeName: string;
|
||||
usedBy: TNode[];
|
||||
};
|
||||
|
||||
export type AppCredentials<TNode extends BaseNode> = {
|
||||
appName: string;
|
||||
credentials: Array<CredentialUsages<TNode>>;
|
||||
};
|
||||
|
||||
//#endregion Types
|
||||
|
||||
//#region Getters
|
||||
|
||||
/**
|
||||
* Returns the nodes in the template that require credentials
|
||||
* and the required credentials for each node.
|
||||
*/
|
||||
export const getNodesRequiringCredentials = <TNode extends BaseNode>(
|
||||
nodeTypeProvider: NodeTypeProvider,
|
||||
nodes: TNode[],
|
||||
): Array<NodeWithRequiredCredential<TNode>> => {
|
||||
const nodesWithCredentials: Array<NodeWithRequiredCredential<TNode>> = nodes
|
||||
.map((node) => ({
|
||||
node,
|
||||
requiredCredentials: getNodeTypeDisplayableCredentials(nodeTypeProvider, node),
|
||||
}))
|
||||
.filter(({ requiredCredentials }) => requiredCredentials.length > 0);
|
||||
|
||||
return nodesWithCredentials;
|
||||
};
|
||||
|
||||
export const groupNodeCredentialsByKey = <TNode extends BaseNode>(
|
||||
nodeWithRequiredCredentials: Array<NodeWithRequiredCredential<TNode>>,
|
||||
) => {
|
||||
const credentialsByTypeName = new Map<TemplateCredentialKey, CredentialUsages<TNode>>();
|
||||
|
||||
for (const { node, requiredCredentials } of nodeWithRequiredCredentials) {
|
||||
const normalizedNodeCreds = node.credentials
|
||||
? normalizeTemplateNodeCredentials(node.credentials)
|
||||
: {};
|
||||
|
||||
for (const credentialDescription of requiredCredentials) {
|
||||
const credentialType = credentialDescription.name;
|
||||
const nodeCredentialName = normalizedNodeCreds[credentialDescription.name] ?? '';
|
||||
const key = keyFromCredentialTypeAndName(credentialType, nodeCredentialName);
|
||||
|
||||
let credentialUsages = credentialsByTypeName.get(key);
|
||||
if (!credentialUsages) {
|
||||
credentialUsages = {
|
||||
key,
|
||||
nodeTypeName: node.type,
|
||||
credentialName: nodeCredentialName,
|
||||
credentialType,
|
||||
usedBy: [],
|
||||
};
|
||||
credentialsByTypeName.set(key, credentialUsages);
|
||||
}
|
||||
|
||||
credentialUsages.usedBy.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return credentialsByTypeName;
|
||||
};
|
||||
|
||||
export const getAppCredentials = <TNode extends BaseNode>(
|
||||
credentialUsages: Array<CredentialUsages<TNode>>,
|
||||
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
|
||||
) => {
|
||||
const credentialsByAppName = new Map<string, AppCredentials<TNode>>();
|
||||
|
||||
for (const credentialUsage of credentialUsages) {
|
||||
const nodeTypeName = credentialUsage.nodeTypeName;
|
||||
|
||||
const appName = getAppNameByNodeType(nodeTypeName) ?? nodeTypeName;
|
||||
const appCredentials = credentialsByAppName.get(appName);
|
||||
if (appCredentials) {
|
||||
appCredentials.credentials.push(credentialUsage);
|
||||
} else {
|
||||
credentialsByAppName.set(appName, {
|
||||
appName,
|
||||
credentials: [credentialUsage],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(credentialsByAppName.values());
|
||||
};
|
||||
|
||||
//#endregion Getters
|
||||
|
||||
export const useCredentialSetupState = <TNode extends BaseNode>(nodes: Ref<TNode[]>) => {
|
||||
/**
|
||||
* Credentials user has selected from the UI. Map from credential
|
||||
* name in the template to the credential ID.
|
||||
*/
|
||||
const selectedCredentialIdByKey = ref<
|
||||
Record<CredentialUsages<TNode>['key'], ICredentialsResponse['id']>
|
||||
>({});
|
||||
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
|
||||
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
|
||||
|
||||
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
|
||||
};
|
||||
|
||||
//#region Computed
|
||||
|
||||
const nodesRequiringCredentialsSorted = computed(() => {
|
||||
const nodesWithCredentials = nodes.value
|
||||
? getNodesRequiringCredentials(nodeTypesStore, nodes.value)
|
||||
: [];
|
||||
|
||||
// Order by the X coordinate of the node
|
||||
return sortBy(nodesWithCredentials, ({ node }) => node.position[0]);
|
||||
});
|
||||
|
||||
const credentialsByKey = computed(() => {
|
||||
return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
|
||||
});
|
||||
|
||||
const credentialUsages = computed(() => {
|
||||
return Array.from(credentialsByKey.value.values());
|
||||
});
|
||||
|
||||
const appCredentials = computed(() => {
|
||||
return getAppCredentials(credentialUsages.value, appNameByNodeType);
|
||||
});
|
||||
|
||||
const credentialOverrides = computed(() => {
|
||||
const overrides: Record<TemplateCredentialKey, INodeCredentialsDetails> = {};
|
||||
|
||||
for (const [key, credentialId] of Object.entries(selectedCredentialIdByKey.value)) {
|
||||
const credential = credentialsStore.getCredentialById(credentialId);
|
||||
if (!credential) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Object.entries fails to give the more accurate key type
|
||||
overrides[key as TemplateCredentialKey] = {
|
||||
id: credentialId,
|
||||
name: credential.name,
|
||||
};
|
||||
}
|
||||
|
||||
return overrides;
|
||||
});
|
||||
|
||||
const numFilledCredentials = computed(() => {
|
||||
return Object.keys(selectedCredentialIdByKey.value).length;
|
||||
});
|
||||
|
||||
//#endregion Computed
|
||||
|
||||
//#region Actions
|
||||
|
||||
const setSelectedCredentialId = (credentialKey: TemplateCredentialKey, credentialId: string) => {
|
||||
selectedCredentialIdByKey.value[credentialKey] = credentialId;
|
||||
};
|
||||
|
||||
const unsetSelectedCredential = (credentialKey: TemplateCredentialKey) => {
|
||||
delete selectedCredentialIdByKey.value[credentialKey];
|
||||
};
|
||||
|
||||
//#endregion Actions
|
||||
|
||||
return {
|
||||
appCredentials,
|
||||
credentialOverrides,
|
||||
credentialUsages,
|
||||
credentialsByKey,
|
||||
nodesRequiringCredentialsSorted,
|
||||
numFilledCredentials,
|
||||
selectedCredentialIdByKey,
|
||||
setSelectedCredentialId,
|
||||
unsetSelectedCredential,
|
||||
};
|
||||
};
|
Loading…
Reference in a new issue