mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -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'", () => {
|
it("add node by clicking on 'On form submission'", () => {
|
||||||
workflowPage.getters.canvasPlusButton().click();
|
workflowPage.getters.canvasPlusButton().click();
|
||||||
cy.get('#node-view-root > div:nth-child(2) > div > div > aside ')
|
workflowPage.getters.nodeCreatorNodeItems().contains('On form submission').click();
|
||||||
.find('span')
|
|
||||||
.contains('On form submission')
|
|
||||||
.click();
|
|
||||||
ndv.getters.parameterInput('formTitle').type('Test Form');
|
ndv.getters.parameterInput('formTitle').type('Test Form');
|
||||||
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
||||||
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
|
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
|
||||||
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
|
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { assert } from '@/utils/assert';
|
import { assert } from '@/utils/assert';
|
||||||
import CredentialsDropdown from './CredentialsDropdown.vue';
|
import CredentialsDropdown from './CredentialsDropdown.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/constants';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
appName: {
|
appName: {
|
||||||
|
@ -31,6 +32,8 @@ const uiStore = useUIStore();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const wasModalOpenedFromHere = ref(false);
|
||||||
|
|
||||||
const availableCredentials = computed(() => {
|
const availableCredentials = computed(() => {
|
||||||
return credentialsStore.getCredentialsByType(props.credentialType);
|
return credentialsStore.getCredentialsByType(props.credentialType);
|
||||||
});
|
});
|
||||||
|
@ -48,27 +51,30 @@ const onCredentialSelected = (credentialId: string) => {
|
||||||
};
|
};
|
||||||
const createNewCredential = () => {
|
const createNewCredential = () => {
|
||||||
uiStore.openNewCredential(props.credentialType, true);
|
uiStore.openNewCredential(props.credentialType, true);
|
||||||
|
wasModalOpenedFromHere.value = true;
|
||||||
$emit('credentialModalOpened');
|
$emit('credentialModalOpened');
|
||||||
};
|
};
|
||||||
const editCredential = () => {
|
const editCredential = () => {
|
||||||
assert(props.selectedCredentialId);
|
assert(props.selectedCredentialId);
|
||||||
uiStore.openExistingCredential(props.selectedCredentialId);
|
uiStore.openExistingCredential(props.selectedCredentialId);
|
||||||
|
wasModalOpenedFromHere.value = true;
|
||||||
$emit('credentialModalOpened');
|
$emit('credentialModalOpened');
|
||||||
};
|
};
|
||||||
|
|
||||||
listenForCredentialChanges({
|
listenForCredentialChanges({
|
||||||
store: credentialsStore,
|
store: credentialsStore,
|
||||||
onCredentialCreated: (credential) => {
|
onCredentialCreated: (credential) => {
|
||||||
// TODO: We should have a better way to detect if credential created was due to
|
if (!wasModalOpenedFromHere.value) {
|
||||||
// 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) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$emit('credentialSelected', credential.id);
|
$emit('credentialSelected', credential.id);
|
||||||
},
|
},
|
||||||
onCredentialDeleted: (deletedCredentialId) => {
|
onCredentialDeleted: (deletedCredentialId) => {
|
||||||
|
if (!wasModalOpenedFromHere.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deletedCredentialId !== props.selectedCredentialId) {
|
if (deletedCredentialId !== props.selectedCredentialId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -83,6 +89,15 @@ listenForCredentialChanges({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
listenForModalChanges({
|
||||||
|
store: uiStore,
|
||||||
|
onModalClosed(modalName) {
|
||||||
|
if (modalName === CREDENTIAL_EDIT_MODAL_KEY && wasModalOpenedFromHere.value) {
|
||||||
|
wasModalOpenedFromHere.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -154,7 +154,6 @@ export default defineComponent({
|
||||||
params: { name: routeWorkflowId },
|
params: { name: routeWorkflowId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// this.modalBus.emit('closeAll');
|
|
||||||
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -134,7 +134,6 @@ export default defineComponent({
|
||||||
window.addEventListener('keydown', this.onWindowKeydown);
|
window.addEventListener('keydown', this.onWindowKeydown);
|
||||||
|
|
||||||
this.eventBus?.on('close', this.closeDialog);
|
this.eventBus?.on('close', this.closeDialog);
|
||||||
this.eventBus?.on('closeAll', this.uiStore.closeAllModals);
|
|
||||||
|
|
||||||
const activeElement = document.activeElement as HTMLElement;
|
const activeElement = document.activeElement as HTMLElement;
|
||||||
if (activeElement) {
|
if (activeElement) {
|
||||||
|
@ -143,7 +142,6 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.eventBus?.off('close', this.closeDialog);
|
this.eventBus?.off('close', this.closeDialog);
|
||||||
this.eventBus?.off('closeAll', this.uiStore.closeAllModals);
|
|
||||||
window.removeEventListener('keydown', this.onWindowKeydown);
|
window.removeEventListener('keydown', this.onWindowKeydown);
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -227,6 +225,7 @@ export default defineComponent({
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -162,6 +162,16 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -197,6 +207,7 @@ import {
|
||||||
MFA_SETUP_MODAL_KEY,
|
MFA_SETUP_MODAL_KEY,
|
||||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||||
|
SETUP_CREDENTIALS_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from './AboutModal.vue';
|
import AboutModal from './AboutModal.vue';
|
||||||
|
@ -229,6 +240,7 @@ import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderMo
|
||||||
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
||||||
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
|
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
|
||||||
import SuggestedTemplatesPreviewModal from '@/components/SuggestedTemplates/SuggestedTemplatesPreviewModal.vue';
|
import SuggestedTemplatesPreviewModal from '@/components/SuggestedTemplates/SuggestedTemplatesPreviewModal.vue';
|
||||||
|
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Modals',
|
name: 'Modals',
|
||||||
|
@ -263,6 +275,7 @@ export default defineComponent({
|
||||||
MfaSetupModal,
|
MfaSetupModal,
|
||||||
WorkflowHistoryVersionRestoreModal,
|
WorkflowHistoryVersionRestoreModal,
|
||||||
SuggestedTemplatesPreviewModal,
|
SuggestedTemplatesPreviewModal,
|
||||||
|
SetupWorkflowCredentialsModal,
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
CHAT_EMBED_MODAL_KEY,
|
CHAT_EMBED_MODAL_KEY,
|
||||||
|
@ -294,6 +307,7 @@ export default defineComponent({
|
||||||
MFA_SETUP_MODAL_KEY,
|
MFA_SETUP_MODAL_KEY,
|
||||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||||
|
SETUP_CREDENTIALS_MODAL_KEY,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
</script>
|
</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 MFA_SETUP_MODAL_KEY = 'mfaSetup';
|
||||||
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
|
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
|
||||||
export const SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY = 'suggestedTemplatePreview';
|
export const SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY = 'suggestedTemplatePreview';
|
||||||
|
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
|
||||||
|
|
||||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||||
|
|
||||||
|
|
|
@ -1095,6 +1095,7 @@
|
||||||
"nodeView.zoomOut": "Zoom Out",
|
"nodeView.zoomOut": "Zoom Out",
|
||||||
"nodeView.zoomToFit": "Zoom to Fit",
|
"nodeView.zoomToFit": "Zoom to Fit",
|
||||||
"nodeView.replaceMe": "Replace Me",
|
"nodeView.replaceMe": "Replace Me",
|
||||||
|
"nodeView.setupTemplate": "Set up Template",
|
||||||
"contextMenu.node": "node | nodes",
|
"contextMenu.node": "node | nodes",
|
||||||
"contextMenu.sticky": "sticky note | sticky notes",
|
"contextMenu.sticky": "sticky note | sticky notes",
|
||||||
"contextMenu.selectAll": "Select all",
|
"contextMenu.selectAll": "Select all",
|
||||||
|
@ -2359,5 +2360,6 @@
|
||||||
"templateSetup.skip": "Skip",
|
"templateSetup.skip": "Skip",
|
||||||
"templateSetup.continue.button": "Continue",
|
"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.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,
|
N8N_PRICING_PAGE_URL,
|
||||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||||
|
SETUP_CREDENTIALS_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
CloudUpdateLinkSourceType,
|
CloudUpdateLinkSourceType,
|
||||||
|
@ -84,67 +85,47 @@ try {
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
export type UiStore = ReturnType<typeof useUIStore>;
|
||||||
|
|
||||||
export const useUIStore = defineStore(STORES.UI, {
|
export const useUIStore = defineStore(STORES.UI, {
|
||||||
state: (): UIState => ({
|
state: (): UIState => ({
|
||||||
activeActions: [],
|
activeActions: [],
|
||||||
activeCredentialType: null,
|
activeCredentialType: null,
|
||||||
theme: savedTheme,
|
theme: savedTheme,
|
||||||
modals: {
|
modals: {
|
||||||
[ABOUT_MODAL_KEY]: {
|
...Object.fromEntries(
|
||||||
open: false,
|
[
|
||||||
},
|
ABOUT_MODAL_KEY,
|
||||||
[CHAT_EMBED_MODAL_KEY]: {
|
CHAT_EMBED_MODAL_KEY,
|
||||||
open: false,
|
CHANGE_PASSWORD_MODAL_KEY,
|
||||||
},
|
CONTACT_PROMPT_MODAL_KEY,
|
||||||
[CHANGE_PASSWORD_MODAL_KEY]: {
|
CREDENTIAL_SELECT_MODAL_KEY,
|
||||||
open: false,
|
DUPLICATE_MODAL_KEY,
|
||||||
},
|
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
|
||||||
[CONTACT_PROMPT_MODAL_KEY]: {
|
PERSONALIZATION_MODAL_KEY,
|
||||||
open: false,
|
INVITE_USER_MODAL_KEY,
|
||||||
},
|
TAGS_MANAGER_MODAL_KEY,
|
||||||
[CREDENTIAL_SELECT_MODAL_KEY]: {
|
VALUE_SURVEY_MODAL_KEY,
|
||||||
open: false,
|
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]: {
|
[DELETE_USER_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
activeId: null,
|
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]: {
|
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
mode: '',
|
mode: '',
|
||||||
|
@ -155,9 +136,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
curlCommand: '',
|
curlCommand: '',
|
||||||
httpNodeParameters: '',
|
httpNodeParameters: '',
|
||||||
},
|
},
|
||||||
[MFA_SETUP_MODAL_KEY]: {
|
|
||||||
open: false,
|
|
||||||
},
|
|
||||||
[LOG_STREAM_MODAL_KEY]: {
|
[LOG_STREAM_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
@ -168,24 +146,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
activeId: null,
|
activeId: null,
|
||||||
showAuthSelector: false,
|
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: [],
|
modalStack: [],
|
||||||
sidebarMenuCollapsed: true,
|
sidebarMenuCollapsed: true,
|
||||||
|
@ -463,17 +423,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
return name !== openModalName;
|
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 {
|
draggableStartDragging(type: string, data: string): void {
|
||||||
this.draggable = {
|
this.draggable = {
|
||||||
isDragging: true,
|
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;
|
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 { getFixedNodesList } from '@/utils/nodeViewUtils';
|
||||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||||
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||||
import {
|
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
|
||||||
getNodesRequiringCredentials,
|
|
||||||
replaceAllTemplateNodeCredentials,
|
|
||||||
} from '@/utils/templates/templateTransforms';
|
|
||||||
import type { INodeCredentialsDetails } from 'n8n-workflow';
|
import type { INodeCredentialsDetails } from 'n8n-workflow';
|
||||||
import type { RouteLocationRaw, Router } from 'vue-router';
|
import type { RouteLocationRaw, Router } from 'vue-router';
|
||||||
import type { TemplatesStore } from '@/stores/templates.store';
|
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 { Telemetry } from '@/plugins/telemetry';
|
||||||
import type { useExternalHooks } from '@/composables/useExternalHooks';
|
import type { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { assert } from '@/utils/assert';
|
import { assert } from '@/utils/assert';
|
||||||
|
import { doesNodeHaveCredentialsToFill } from '@/utils/nodes/nodeTransforms';
|
||||||
|
|
||||||
type ExternalHooks = ReturnType<typeof useExternalHooks>;
|
type ExternalHooks = ReturnType<typeof useExternalHooks>;
|
||||||
|
|
||||||
|
@ -126,9 +124,9 @@ function hasTemplateCredentials(
|
||||||
nodeTypeProvider: NodeTypeProvider,
|
nodeTypeProvider: NodeTypeProvider,
|
||||||
template: ITemplatesWorkflowFull,
|
template: ITemplatesWorkflowFull,
|
||||||
) {
|
) {
|
||||||
const nodesRequiringCreds = getNodesRequiringCredentials(nodeTypeProvider, template);
|
return template.workflow.nodes.some((node) =>
|
||||||
|
doesNodeHaveCredentialsToFill(nodeTypeProvider, node),
|
||||||
return nodesRequiringCreds.length > 0;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFullTemplate(templatesStore: TemplatesStore, templateId: string) {
|
async function getFullTemplate(templatesStore: TemplatesStore, templateId: string) {
|
||||||
|
|
|
@ -1,8 +1,4 @@
|
||||||
import type {
|
import type { IWorkflowTemplateNode, IWorkflowTemplateNodeCredentials } from '@/Interface';
|
||||||
ITemplatesWorkflowFull,
|
|
||||||
IWorkflowTemplateNode,
|
|
||||||
IWorkflowTemplateNodeCredentials,
|
|
||||||
} from '@/Interface';
|
|
||||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||||
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
|
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
|
||||||
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
|
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
|
||||||
|
@ -43,8 +39,12 @@ export const keyFromCredentialTypeAndName = (
|
||||||
* different versions of n8n may have different credential formats.
|
* different versions of n8n may have different credential formats.
|
||||||
*/
|
*/
|
||||||
export const normalizeTemplateNodeCredentials = (
|
export const normalizeTemplateNodeCredentials = (
|
||||||
credentials: IWorkflowTemplateNodeCredentials,
|
credentials?: IWorkflowTemplateNodeCredentials,
|
||||||
): NormalizedTemplateNodeCredentials => {
|
): NormalizedTemplateNodeCredentials => {
|
||||||
|
if (!credentials) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(credentials).map(([key, value]) => {
|
Object.entries(credentials).map(([key, value]) => {
|
||||||
return typeof value === 'string' ? [key, value] : [key, value.name];
|
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"
|
@stopExecution="stopExecution"
|
||||||
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
||||||
/>
|
/>
|
||||||
|
<Suspense>
|
||||||
|
<div :class="$style.setupCredentialsButtonWrapper">
|
||||||
|
<SetupWorkflowCredentialsButton />
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<NodeCreation
|
<NodeCreation
|
||||||
v-if="!isReadOnlyRoute && !readOnlyEnv"
|
v-if="!isReadOnlyRoute && !readOnlyEnv"
|
||||||
|
@ -381,6 +386,10 @@ interface AddNodeOptions {
|
||||||
|
|
||||||
const NodeCreation = defineAsyncComponent(async () => import('@/components/Node/NodeCreation.vue'));
|
const NodeCreation = defineAsyncComponent(async () => import('@/components/Node/NodeCreation.vue'));
|
||||||
const CanvasControls = defineAsyncComponent(async () => import('@/components/CanvasControls.vue'));
|
const CanvasControls = defineAsyncComponent(async () => import('@/components/CanvasControls.vue'));
|
||||||
|
const SetupWorkflowCredentialsButton = defineAsyncComponent(
|
||||||
|
async () =>
|
||||||
|
import('@/components/SetupWorkflowCredentialsButton/SetupWorkflowCredentialsButton.vue'),
|
||||||
|
);
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
|
@ -393,6 +402,7 @@ export default defineComponent({
|
||||||
NodeCreation,
|
NodeCreation,
|
||||||
CanvasControls,
|
CanvasControls,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
|
SetupWorkflowCredentialsButton,
|
||||||
},
|
},
|
||||||
mixins: [moveNodeWorkflow, workflowHelpers, workflowRun, debounceHelper],
|
mixins: [moveNodeWorkflow, workflowHelpers, workflowRun, debounceHelper],
|
||||||
async beforeRouteLeave(to, from, next) {
|
async beforeRouteLeave(to, from, next) {
|
||||||
|
@ -5180,4 +5190,10 @@ export default defineComponent({
|
||||||
transform: translate3d(4px, 0, 0);
|
transform: translate3d(4px, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setupCredentialsButtonWrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 35px;
|
||||||
|
top: var(--spacing-s);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import N8nNotice from 'n8n-design-system/components/N8nNotice';
|
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 { formatList } from '@/utils/formatters/listFormatter';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type {
|
||||||
|
AppCredentials,
|
||||||
|
BaseNode,
|
||||||
|
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||||
|
|
||||||
const i18n = useI18n();
|
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(() => {
|
const appNodeCounts = computed(() => {
|
||||||
return formatList(appCredentials.value, {
|
return formatList(props.appCredentials, {
|
||||||
formatFn: formatApp,
|
formatFn: formatApp,
|
||||||
i18n,
|
i18n,
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,11 +8,13 @@ import IconSuccess from './IconSuccess.vue';
|
||||||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
||||||
import { formatList } from '@/utils/formatters/listFormatter';
|
import { formatList } from '@/utils/formatters/listFormatter';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
import type {
|
||||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
BaseNode,
|
||||||
import type { IWorkflowTemplateNode } from '@/Interface';
|
CredentialUsages,
|
||||||
|
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
@ -24,10 +26,22 @@ const props = defineProps({
|
||||||
type: Object as PropType<CredentialUsages>,
|
type: Object as PropType<CredentialUsages>,
|
||||||
required: true,
|
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
|
// Stores
|
||||||
const setupTemplateStore = useSetupTemplateStore();
|
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
@ -45,36 +59,24 @@ const appName = computed(() =>
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeNames = 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, {
|
return formatList(props.credentials.usedBy, {
|
||||||
formatFn: formatNodeName,
|
formatFn: formatNodeName,
|
||||||
i18n,
|
i18n,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCredentialId = computed(
|
|
||||||
() => setupTemplateStore.selectedCredentialIdByKey[props.credentials.key],
|
|
||||||
);
|
|
||||||
|
|
||||||
//#endregion Computed
|
//#endregion Computed
|
||||||
|
|
||||||
//#region Methods
|
//#region Methods
|
||||||
|
|
||||||
const onCredentialSelected = (credentialId: string) => {
|
|
||||||
setupTemplateStore.setSelectedCredentialId(props.credentials.key, credentialId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCredentialDeselected = () => {
|
|
||||||
setupTemplateStore.unsetSelectedCredential(props.credentials.key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCredentialModalOpened = () => {
|
const onCredentialModalOpened = () => {
|
||||||
telemetry.track(
|
telemetry.track(
|
||||||
'User opened Credential modal',
|
'User opened Credential modal',
|
||||||
{
|
{
|
||||||
source: 'cred_setup',
|
source: 'cred_setup',
|
||||||
credentialType: props.credentials.credentialType,
|
credentialType: props.credentials.credentialType,
|
||||||
new_credential: !selectedCredentialId.value,
|
new_credential: !props.selectedCredentialId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
withPostHog: true,
|
withPostHog: true,
|
||||||
|
@ -112,8 +114,15 @@ const onCredentialModalOpened = () => {
|
||||||
:app-name="appName"
|
:app-name="appName"
|
||||||
:credential-type="props.credentials.credentialType"
|
:credential-type="props.credentials.credentialType"
|
||||||
:selected-credential-id="selectedCredentialId"
|
:selected-credential-id="selectedCredentialId"
|
||||||
@credential-selected="onCredentialSelected"
|
@credential-selected="
|
||||||
@credential-deselected="onCredentialDeselected"
|
emit('credentialSelected', {
|
||||||
|
credentialUsageKey: $props.credentials.key,
|
||||||
|
credentialId: $event,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@credential-deselected="
|
||||||
|
emit('credentialDeselected', { credentialUsageKey: $props.credentials.key })
|
||||||
|
"
|
||||||
@credential-modal-opened="onCredentialModalOpened"
|
@credential-modal-opened="onCredentialModalOpened"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -104,7 +104,10 @@ onMounted(async () => {
|
||||||
<template #content>
|
<template #content>
|
||||||
<div :class="$style.grid">
|
<div :class="$style.grid">
|
||||||
<div :class="$style.notice" data-test-id="info-callout">
|
<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" />
|
<n8n-loading v-else variant="p" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -116,6 +119,18 @@ onMounted(async () => {
|
||||||
:class="$style.appCredential"
|
:class="$style.appCredential"
|
||||||
:order="index + 1"
|
:order="index + 1"
|
||||||
:credentials="credentials"
|
:credentials="credentials"
|
||||||
|
:selected-credential-id="
|
||||||
|
setupTemplateStore.selectedCredentialIdByKey[credentials.key]
|
||||||
|
"
|
||||||
|
@credential-selected="
|
||||||
|
setupTemplateStore.setSelectedCredentialId(
|
||||||
|
$event.credentialUsageKey,
|
||||||
|
$event.credentialId,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@credential-deselected="
|
||||||
|
setupTemplateStore.unsetSelectedCredential($event.credentialUsageKey)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</ol>
|
</ol>
|
||||||
<div v-else :class="$style.appCredentialsContainer">
|
<div v-else :class="$style.appCredentialsContainer">
|
||||||
|
|
|
@ -1,233 +1,13 @@
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
|
||||||
import type {
|
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||||
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 { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import * as testData from './setupTemplate.store.testData';
|
import * as testData from './setupTemplate.store.testData';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
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', () => {
|
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', () => {
|
describe('setInitialCredentialsSelection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(
|
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 { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { Router } from 'vue-router';
|
import type { Router } from 'vue-router';
|
||||||
|
@ -7,22 +6,13 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||||
import type { INodeCredentialsDetails, INodeTypeDescription } from 'n8n-workflow';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { ICredentialsResponse, INodeUi, IWorkflowTemplateNode } from '@/Interface';
|
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
|
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 { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { useCredentialSetupState } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||||
|
|
||||||
export type NodeAndType = {
|
export type NodeAndType = {
|
||||||
node: INodeUi;
|
node: INodeUi;
|
||||||
|
@ -35,131 +25,21 @@ export type RequiredCredentials = {
|
||||||
credentialType: string;
|
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 = {
|
export type AppCredentialCount = {
|
||||||
appName: string;
|
appName: string;
|
||||||
count: number;
|
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
|
* Store for managing the state of the SetupWorkflowFromTemplateView
|
||||||
*/
|
*/
|
||||||
export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
||||||
//#region State
|
//#region State
|
||||||
|
|
||||||
const templateId = ref<string>('');
|
const templateId = ref<string>('');
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const isSaving = ref(false);
|
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
|
//#endregion State
|
||||||
|
|
||||||
const templatesStore = useTemplatesStore();
|
const templatesStore = useTemplatesStore();
|
||||||
|
@ -174,55 +54,21 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
||||||
return templateId.value ? templatesStore.getFullTemplateById(templateId.value) : null;
|
return templateId.value ? templatesStore.getFullTemplateById(templateId.value) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodesRequiringCredentialsSorted = computed(() => {
|
const templateNodes = computed(() => {
|
||||||
const nodesWithCredentials = template.value
|
return template.value?.workflow.nodes ?? [];
|
||||||
? getNodesRequiringCredentials(nodeTypesStore, template.value)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Order by the X coordinate of the node
|
|
||||||
return sortBy(nodesWithCredentials, ({ node }) => node.position[0]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
|
const {
|
||||||
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
|
appCredentials,
|
||||||
|
credentialOverrides,
|
||||||
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
|
credentialUsages,
|
||||||
};
|
credentialsByKey,
|
||||||
|
nodesRequiringCredentialsSorted,
|
||||||
const credentialsByKey = computed(() => {
|
numFilledCredentials,
|
||||||
return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
|
selectedCredentialIdByKey,
|
||||||
});
|
setSelectedCredentialId,
|
||||||
|
unsetSelectedCredential,
|
||||||
const credentialUsages = computed(() => {
|
} = useCredentialSetupState(templateNodes);
|
||||||
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 Getters
|
//#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
|
//#endregion Actions
|
||||||
|
|
||||||
return {
|
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