Skip to content

Commit

Permalink
feat: Ado 1296 spike credential setup in templates (#7786)
Browse files Browse the repository at this point in the history
- Add a 'Setup template credentials' view to setup the credentials of a
template before it is created
  • Loading branch information
tomi authored Nov 27, 2023
1 parent 27e048c commit aae45b0
Show file tree
Hide file tree
Showing 25 changed files with 1,423 additions and 20 deletions.
23 changes: 21 additions & 2 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type {
INodeExecutionData,
INodeProperties,
NodeConnectionType,
INodeCredentialsDetails,
} from 'n8n-workflow';
import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
Expand Down Expand Up @@ -248,10 +249,22 @@ export interface IWorkflowToShare extends IWorkflowDataUpdate {
};
}

export interface IWorkflowTemplateNode
extends Pick<INodeUi, 'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId'> {
// The credentials in a template workflow have a different type than in a regular workflow
credentials?: IWorkflowTemplateNodeCredentials;
}

export interface IWorkflowTemplateNodeCredentials {
[key: string]: string | INodeCredentialsDetails;
}

export interface IWorkflowTemplate {
id: number;
name: string;
workflow: Pick<IWorkflowData, 'nodes' | 'connections' | 'settings' | 'pinData'>;
workflow: Pick<IWorkflowData, 'connections' | 'settings' | 'pinData'> & {
nodes: IWorkflowTemplateNode[];
};
}

export interface INewWorkflowData {
Expand Down Expand Up @@ -790,6 +803,9 @@ export interface ITemplatesCollectionResponse extends ITemplatesCollectionExtend
workflows: ITemplatesWorkflow[];
}

/**
* A template without the actual workflow definition
*/
export interface ITemplatesWorkflow {
id: number;
createdAt: string;
Expand All @@ -807,6 +823,9 @@ export interface ITemplatesWorkflowResponse extends ITemplatesWorkflow, IWorkflo
categories: ITemplatesCategory[];
}

/**
* A template with also the full workflow definition
*/
export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
full: true;
}
Expand Down Expand Up @@ -1302,7 +1321,7 @@ export interface INodeTypesState {
export interface ITemplateState {
categories: { [id: string]: ITemplatesCategory };
collections: { [id: string]: ITemplatesCollection };
workflows: { [id: string]: ITemplatesWorkflow };
workflows: { [id: string]: ITemplatesWorkflow | ITemplatesWorkflowFull };
workflowSearches: {
[search: string]: {
workflowIds: string[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUIStore } from '@/stores';
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
import { assert } from '@/utils/assert';
import CredentialsDropdown from './CredentialsDropdown.vue';
import { useI18n } from '@/composables/useI18n';
const props = defineProps({
appName: {
type: String,
required: true,
},
credentialType: {
type: String,
required: true,
},
selectedCredentialId: {
type: String,
required: false,
},
});
const $emit = defineEmits({
credentialSelected: (_credentialId: string) => true,
credentialDeselected: () => true,
});
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const i18n = useI18n();
const availableCredentials = computed(() => {
return credentialsStore.getCredentialsByType(props.credentialType);
});
const credentialOptions = computed(() => {
return availableCredentials.value.map((credential) => ({
id: credential.id,
name: credential.name,
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
}));
});
const onCredentialSelected = (credentialId: string) => {
$emit('credentialSelected', credentialId);
};
const createNewCredential = () => {
uiStore.openNewCredential(props.credentialType, true);
};
const editCredential = () => {
assert(props.selectedCredentialId);
uiStore.openExistingCredential(props.selectedCredentialId);
};
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) {
return;
}
$emit('credentialSelected', credential.id);
},
onCredentialDeleted: (deletedCredentialId) => {
if (deletedCredentialId !== props.selectedCredentialId) {
return;
}
const optionsWoDeleted = credentialOptions.value
.map((credential) => credential.id)
.filter((id) => id !== deletedCredentialId);
if (optionsWoDeleted.length > 0) {
$emit('credentialSelected', optionsWoDeleted[0]);
} else {
$emit('credentialDeselected');
}
},
});
</script>

<template>
<div>
<div v-if="credentialOptions.length > 0" :class="$style.dropdown">
<CredentialsDropdown
:credential-type="props.credentialType"
:credential-options="credentialOptions"
:selected-credential-id="props.selectedCredentialId"
@credential-selected="onCredentialSelected"
@new-credential="createNewCredential"
/>

<n8n-icon-button
icon="pen"
type="secondary"
:class="{
[$style.edit]: true,
[$style.invisible]: !props.selectedCredentialId,
}"
:title="i18n.baseText('nodeCredentials.updateCredential')"
@click="editCredential()"
data-test-id="credential-edit-button"
/>
</div>

<n8n-button
v-else
:label="`Create new ${props.appName} credential`"
@click="createNewCredential"
/>
</div>
</template>

<style lang="scss" module>
.dropdown {
display: flex;
}
.edit {
display: flex;
justify-content: center;
align-items: center;
min-width: 20px;
margin-left: var(--spacing-2xs);
font-size: var(--font-size-s);
}
.invisible {
visibility: hidden;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import { useI18n } from '@/composables';
export type CredentialOption = {
id: string;
name: string;
typeDisplayName: string | undefined;
};
const props = defineProps({
credentialOptions: {
type: Array as PropType<CredentialOption[]>,
required: true,
},
selectedCredentialId: {
type: String,
required: false,
},
});
const $emit = defineEmits({
credentialSelected: (_credentialId: string) => true,
newCredential: () => true,
});
const i18n = useI18n();
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`;
const onCredentialSelected = (credentialId: string) => {
if (credentialId === NEW_CREDENTIALS_TEXT) {
$emit('newCredential');
} else {
$emit('credentialSelected', credentialId);
}
};
</script>

<template>
<n8n-select
size="small"
:modelValue="props.selectedCredentialId"
@update:modelValue="onCredentialSelected"
>
<n8n-option
v-for="item in props.credentialOptions"
:data-test-id="`node-credentials-select-item-${item.id}`"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div :class="[$style.credentialOption, 'mt-2xs mb-2xs']">
<n8n-text bold>{{ item.name }}</n8n-text>
<n8n-text size="small">{{ item.typeDisplayName }}</n8n-text>
</div>
</n8n-option>
<n8n-option
data-test-id="node-credentials-select-item-new"
:key="NEW_CREDENTIALS_TEXT"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
</template>

<style lang="scss" module>
.credentialOption {
display: flex;
flex-direction: column;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,7 @@ export default defineComponent({
methods: {
async initView(loadWorkflow: boolean): Promise<void> {
if (loadWorkflow) {
if (this.nodeTypesStore.allNodeTypes.length === 0) {
await this.nodeTypesStore.getNodeTypes();
}
await this.nodeTypesStore.loadNodeTypesIfNotLoaded();
await this.openWorkflow(this.$route.params.name);
this.uiStore.nodeViewInitialized = false;
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {
Expand Down
1 change: 1 addition & 0 deletions packages/editor-ui/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ export const enum VIEWS {
EXECUTION_DEBUG = 'ExecutionDebug',
EXECUTION_HOME = 'ExecutionsLandingPage',
TEMPLATE = 'TemplatesWorkflowView',
TEMPLATE_SETUP = 'TemplatesWorkflowSetupView',
TEMPLATES = 'TemplatesSearchView',
CREDENTIALS = 'CredentialsView',
VARIABLES = 'VariablesView',
Expand Down
9 changes: 8 additions & 1 deletion packages/editor-ui/src/plugins/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"generic.editor": "Editor",
"generic.seePlans": "See plans",
"generic.loading": "Loading",
"generic.and": "and",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
Expand Down Expand Up @@ -2271,5 +2272,11 @@
"executionUsage.label.executions": "Executions",
"executionUsage.button.upgrade": "Upgrade plan",
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating."
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating.",
"templateSetup.title": "Setup '{name}' template",
"templateSetup.instructions": "You need {0} account to setup this template",
"templateSetup.skip": "Skip",
"templateSetup.continue.button": "Continue",
"templateSetup.continue.tooltip": "Connect to {numLeft} more app to continue | Connect to {numLeft} more apps to 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."
}
24 changes: 24 additions & 0 deletions packages/editor-ui/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const SigninView = async () => import('./views/SigninView.vue');
const SignupView = async () => import('./views/SignupView.vue');
const TemplatesCollectionView = async () => import('@/views/TemplatesCollectionView.vue');
const TemplatesWorkflowView = async () => import('@/views/TemplatesWorkflowView.vue');
const SetupWorkflowFromTemplateView = async () =>
import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
const TemplatesSearchView = async () => import('@/views/TemplatesSearchView.vue');
const CredentialsView = async () => import('@/views/CredentialsView.vue');
const ExecutionsView = async () => import('@/views/ExecutionsView.vue');
Expand Down Expand Up @@ -123,6 +125,28 @@ export const routes = [
middleware: ['authenticated'],
},
},
{
path: '/templates/:id/setup',
name: VIEWS.TEMPLATE_SETUP,
components: {
default: SetupWorkflowFromTemplateView,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
getRedirect: getTemplatesRedirect,
telemetry: {
getProperties(route: RouteLocation) {
const templatesStore = useTemplatesStore();
return {
template_id: route.params.id,
wf_template_repo_session_id: templatesStore.currentSessionId,
};
},
},
middleware: ['authenticated'],
},
},
{
path: '/templates/',
name: VIEWS.TEMPLATES,
Expand Down
41 changes: 41 additions & 0 deletions packages/editor-ui/src/stores/credentials.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', 'oAuth1Api'];

export type CredentialsStore = ReturnType<typeof useCredentialsStore>;

export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
state: (): ICredentialsState => ({
credentialTypes: {},
Expand Down Expand Up @@ -400,3 +402,42 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
},
},
});

/**
* Helper function for listening to credential changes in the store
*/
export const listenForCredentialChanges = (opts: {
store: CredentialsStore;
onCredentialCreated?: (credential: ICredentialsResponse) => void;
onCredentialUpdated?: (credential: ICredentialsResponse) => void;
onCredentialDeleted?: (credentialId: string) => void;
}): void => {
const { store, onCredentialCreated, onCredentialDeleted, onCredentialUpdated } = opts;
const listeningForActions = ['createNewCredential', 'updateCredential', 'deleteCredential'];

store.$onAction((result) => {
const { name, after, args } = result;
after(async (returnValue) => {
if (!listeningForActions.includes(name)) {
return;
}

switch (name) {
case 'createNewCredential':
const createdCredential = returnValue as ICredentialsResponse;
onCredentialCreated?.(createdCredential);
break;

case 'updateCredential':
const updatedCredential = returnValue as ICredentialsResponse;
onCredentialUpdated?.(updatedCredential);
break;

case 'deleteCredential':
const credentialId = args[0].id;
onCredentialDeleted?.(credentialId);
break;
}
});
});
};
Loading

0 comments on commit aae45b0

Please sign in to comment.