feat: Add onboarding flow (#7212)

Github issue / Community forum post (link here to close automatically):
This commit is contained in:
Mutasem Aldmour 2023-09-25 15:49:36 +02:00 committed by GitHub
parent 60c152dc72
commit 01e9340621
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1373 additions and 18 deletions

View file

@ -0,0 +1,34 @@
import { TemplatesPage } from '../pages/templates';
import { WorkflowPage } from '../pages/workflow';
import OnboardingWorkflow from '../fixtures/Onboarding_workflow.json';
const templatesPage = new TemplatesPage();
const workflowPage = new WorkflowPage();
describe('Templates', () => {
it('can open onboarding flow', () => {
templatesPage.actions.openOnboardingFlow(1234, OnboardingWorkflow.name, OnboardingWorkflow);
cy.url().then(($url) => {
expect($url).to.match(/.*\/workflow\/.*?onboardingId=1234$/);
})
workflowPage.actions.shouldHaveWorkflowName(`Demo: ${name}`);
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon');
});
it('can import template', () => {
templatesPage.actions.importTemplate(1234, OnboardingWorkflow.name, OnboardingWorkflow);
cy.url().then(($url) => {
expect($url).to.include('/workflow/new?templateId=1234');
});
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.actions.shouldHaveWorkflowName(OnboardingWorkflow.name);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
import { BasePage } from './base';
import { WorkflowPage } from './workflow';
const workflowPage = new WorkflowPage();
export class TemplatesPage extends BasePage {
url = '/templates';
getters = {
};
actions = {
openOnboardingFlow: (id: number, name: string , workflow: object) => {
const apiResponse = {
id,
name,
workflow,
};
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
cy.intercept('GET', `https://api.n8n.io/api/workflows/templates/${id}`, {
statusCode: 200,
body: apiResponse,
}).as('getTemplate');
cy.intercept('GET', 'rest/workflows/**').as('getWorkflow');
cy.visit(`/workflows/onboarding/${id}`);
cy.wait('@getTemplate');
cy.wait(['@createWorkflow', '@getWorkflow']);
},
importTemplate: (id: number, name: string, workflow: object) => {
const apiResponse = {
id,
name,
workflow,
};
cy.intercept('GET', `https://api.n8n.io/api/workflows/templates/${id}`, {
statusCode: 200,
body: apiResponse,
}).as('getTemplate');
cy.intercept('GET', 'rest/workflows/**').as('getWorkflow');
cy.visit(`/workflows/templates/${id}`);
cy.wait('@getTemplate');
cy.wait( '@getWorkflow');
}
}
}

View file

@ -304,5 +304,11 @@ export class WorkflowPage extends BasePage {
editSticky: (content: string) => {
this.getters.stickies().dblclick().find('textarea').clear().type(content).type('{esc}');
},
shouldHaveWorkflowName: (name: string) => {
this.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('include', name);
},
};
}

View file

@ -1,6 +1,6 @@
import { Length } from 'class-validator';
import { IConnections, IDataObject, IWorkflowSettings } from 'n8n-workflow';
import { IConnections, IDataObject, IWorkflowSettings, WorkflowFEMeta } from 'n8n-workflow';
import type { IBinaryKeyData, INode, IPairedItemData } from 'n8n-workflow';
import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, OneToMany } from 'typeorm';
@ -46,6 +46,13 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
})
staticData?: IDataObject;
@Column({
type: jsonColumnType,
nullable: true,
transformer: objectRetriever,
})
meta?: WorkflowFEMeta;
@ManyToMany('TagEntity', 'workflows')
@JoinTable({
name: 'workflows_tags', // table name for the junction table of this relation

View file

@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
export class AddWorkflowMetadata1695128658538 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('workflow_entity', [column('meta').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('workflow_entity', ['meta']);
}
}

View file

@ -48,6 +48,7 @@ import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColu
import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable';
import { DisallowOrphanExecutions1693554410387 } from '../common/1693554410387-DisallowOrphanExecutions';
import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete';
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -99,4 +100,5 @@ export const mysqlMigrations: Migration[] = [
CreateWorkflowHistoryTable1692967111175,
DisallowOrphanExecutions1693554410387,
ExecutionSoftDelete1693491613982,
AddWorkflowMetadata1695128658538,
];

View file

@ -46,6 +46,7 @@ import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColu
import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable';
import { DisallowOrphanExecutions1693554410387 } from '../common/1693554410387-DisallowOrphanExecutions';
import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete';
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@ -95,4 +96,5 @@ export const postgresMigrations: Migration[] = [
CreateWorkflowHistoryTable1692967111175,
DisallowOrphanExecutions1693554410387,
ExecutionSoftDelete1693491613982,
AddWorkflowMetadata1695128658538,
];

View file

@ -0,0 +1,5 @@
import { AddWorkflowMetadata1695128658538 as BaseMigration } from '../common/1695128658538-AddWorkflowMetadata';
export class AddWorkflowMetadata1695128658538 extends BaseMigration {
transaction = false as const;
}

View file

@ -45,6 +45,7 @@ import { AddMfaColumns1690000000030 } from './1690000000040-AddMfaColumns';
import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable';
import { DisallowOrphanExecutions1693554410387 } from '../common/1693554410387-DisallowOrphanExecutions';
import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftDelete';
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -93,6 +94,7 @@ const sqliteMigrations: Migration[] = [
CreateWorkflowHistoryTable1692967111175,
DisallowOrphanExecutions1693554410387,
ExecutionSoftDelete1693491613982,
AddWorkflowMetadata1695128658538,
];
export { sqliteMigrations };

View file

@ -85,6 +85,7 @@ export declare namespace WorkflowRequest {
active: boolean;
tags: string[];
hash: string;
meta: Record<string, unknown>;
}>;
type ManualRunPayload = {

View file

@ -214,6 +214,7 @@ export interface IWorkflowDataUpdate {
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
pinData?: IPinData;
versionId?: string;
meta?: WorkflowMetadata;
}
export interface IWorkflowToShare extends IWorkflowDataUpdate {
@ -225,10 +226,7 @@ export interface IWorkflowToShare extends IWorkflowDataUpdate {
export interface IWorkflowTemplate {
id: number;
name: string;
workflow: {
nodes: INodeUi[];
connections: IConnections;
};
workflow: Pick<IWorkflowData, 'nodes' | 'connections' | 'settings' | 'pinData'>;
}
export interface INewWorkflowData {
@ -236,6 +234,10 @@ export interface INewWorkflowData {
onboardingFlowEnabled: boolean;
}
export interface WorkflowMetadata {
onboardingId?: string;
}
// Almost identical to cli.Interfaces.ts
export interface IWorkflowDb {
id: string;
@ -252,6 +254,7 @@ export interface IWorkflowDb {
ownedBy?: Partial<IUser>;
versionId: string;
usedCredentials?: IUsedCredential[];
meta?: WorkflowMetadata;
}
// Identical to cli.Interfaces.ts

View file

@ -361,6 +361,7 @@ export const enum VIEWS {
WORKFLOW = 'NodeViewExisting',
DEMO = 'WorkflowDemo',
TEMPLATE_IMPORT = 'WorkflowTemplate',
WORKFLOW_ONBOARDING = 'WorkflowOnboarding',
SIGNIN = 'SigninView',
SIGNUP = 'SignupView',
SIGNOUT = 'SignoutView',

View file

@ -878,8 +878,6 @@ export const workflowHelpers = defineComponent({
const workflowDataRequest: IWorkflowDataUpdate =
data || (await this.getWorkflowDataToSave());
// make sure that the new ones are not active
workflowDataRequest.active = false;
const changedNodes = {} as IDataObject;
if (resetNodeIds) {

View file

@ -1560,6 +1560,7 @@
"tagsTableHeader.searchTags": "Search Tags",
"tagsView.inUse": "{count} workflow | {count} workflows",
"tagsView.notBeingUsed": "Not being used",
"onboarding.title": "Demo: {name}",
"template.buttons.goBackButton": "Go back",
"template.buttons.useThisWorkflowButton": "Use this workflow",
"template.details.appsInTheCollection": "This collection features",

View file

@ -41,6 +41,7 @@ import SettingsSourceControl from './views/SettingsSourceControl.vue';
import SettingsExternalSecrets from './views/SettingsExternalSecrets.vue';
import SettingsAuditLogs from './views/SettingsAuditLogs.vue';
import WorkflowHistory from '@/views/WorkflowHistory.vue';
import WorkflowOnboardingView from '@/views/WorkflowOnboardingView.vue';
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
interface IRouteConfig {
@ -57,11 +58,11 @@ interface IRouteConfig {
};
}
function getTemplatesRedirect() {
function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]) {
const settingsStore = useSettingsStore();
const isTemplatesEnabled: boolean = settingsStore.isTemplatesEnabled;
if (!isTemplatesEnabled) {
return { name: VIEWS.NOT_FOUND };
return { name: defaultRedirect || VIEWS.NOT_FOUND };
}
return false;
@ -334,6 +335,24 @@ export const routes = [
},
},
},
{
path: '/workflows/onboarding/:id',
name: VIEWS.WORKFLOW_ONBOARDING,
components: {
default: WorkflowOnboardingView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
getRedirect: () => getTemplatesRedirect(VIEWS.NEW_WORKFLOW),
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '/workflow/new',
name: VIEWS.NEW_WORKFLOW,

View file

@ -0,0 +1,101 @@
import { setActivePinia, createPinia } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IWorkflowDataUpdate } from '@/Interface';
import { makeRestApiRequest } from '@/utils';
import { useRootStore } from '../n8nRoot.store';
vi.mock('@/utils', () => ({
makeRestApiRequest: vi.fn(),
}));
const MOCK_WORKFLOW_SIMPLE: IWorkflowDataUpdate = {
id: '1',
name: 'test',
nodes: [
{
parameters: {
path: '21a77783-e050-4e0f-9915-2d2dd5b53cde',
options: {},
},
id: '2dbf9369-2eec-42e7-9b89-37e50af12289',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [340, 240],
webhookId: '21a77783-e050-4e0f-9915-2d2dd5b53cde',
},
{
parameters: {
table: 'product',
columns: 'name,ean',
additionalFields: {},
},
name: 'Insert Rows1',
type: 'n8n-nodes-base.postgres',
position: [580, 240],
typeVersion: 1,
id: 'a10ba62a-8792-437c-87df-0762fa53e157',
credentials: {
postgres: {
id: 'iEFl08xIegmR8xF6',
name: 'Postgres account',
},
},
},
],
connections: {
Webhook: {
main: [
[
{
node: 'Insert Rows1',
type: 'main',
index: 0,
},
],
],
},
},
};
describe('worklfows store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
describe('createNewWorkflow', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('creates new workflow', async () => {
const workflowsStore = useWorkflowsStore();
await workflowsStore.createNewWorkflow(MOCK_WORKFLOW_SIMPLE);
expect(makeRestApiRequest).toHaveBeenCalledWith(
useRootStore().getRestApiContext,
'POST',
'/workflows',
{
...MOCK_WORKFLOW_SIMPLE,
active: false,
},
);
});
it('sets active to false', async () => {
const workflowsStore = useWorkflowsStore();
await workflowsStore.createNewWorkflow({ ...MOCK_WORKFLOW_SIMPLE, active: true });
expect(makeRestApiRequest).toHaveBeenCalledWith(
useRootStore().getRestApiContext,
'POST',
'/workflows',
{
...MOCK_WORKFLOW_SIMPLE,
active: false,
},
);
});
});
});

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { STORES } from '@/constants';
import type {
INodeUi,
ITemplatesCategory,
ITemplatesCollection,
ITemplatesCollectionFull,
@ -19,6 +20,7 @@ import {
getWorkflows,
getWorkflowTemplate,
} from '@/api/templates';
import { getFixedNodesList } from '@/utils/nodeViewUtils';
const TEMPLATES_PAGE_SIZE = 10;
@ -332,5 +334,19 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
const versionCli: string = settingsStore.versionCli;
return getWorkflowTemplate(apiEndpoint, templateId, { 'n8n-version': versionCli });
},
async getFixedWorkflowTemplate(templateId: string): Promise<IWorkflowTemplate | undefined> {
const template = await this.getWorkflowTemplate(templateId);
if (template?.workflow?.nodes) {
template.workflow.nodes = getFixedNodesList(template.workflow.nodes) as INodeUi[];
template.workflow.nodes?.forEach((node) => {
if (node.credentials) {
delete node.credentials;
}
});
}
return template;
},
},
});

View file

@ -1272,6 +1272,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
// Creates a new workflow
async createNewWorkflow(sendData: IWorkflowDataUpdate): Promise<IWorkflowDb> {
// make sure that the new ones are not active
sendData.active = false;
const rootStore = useRootStore();
return makeRestApiRequest(
rootStore.getRestApiContext,

View file

@ -886,7 +886,7 @@ export default defineComponent({
let data: IWorkflowTemplate | undefined;
try {
void this.$externalHooks().run('template.requested', { templateId });
data = await this.templatesStore.getWorkflowTemplate(templateId);
data = await this.templatesStore.getFixedWorkflowTemplate(templateId);
if (!data) {
throw new Error(
@ -901,14 +901,6 @@ export default defineComponent({
return;
}
data.workflow.nodes = NodeViewUtils.getFixedNodesList(data.workflow.nodes) as INodeUi[];
data.workflow.nodes?.forEach((node) => {
if (node.credentials) {
delete node.credentials;
}
});
this.blankRedirect = true;
await this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } });
@ -2635,6 +2627,15 @@ export default defineComponent({
this.titleSet(workflow.name, 'IDLE');
await this.openWorkflow(workflow);
await this.checkAndInitDebugMode();
if (workflow.meta?.onboardingId) {
this.$telemetry.track(
`User opened workflow from onboarding template with ID ${workflow.meta.onboardingId}`,
{
workflow_id: workflow.id,
},
);
}
}
} else if (this.$route.meta?.nodeView === true) {
// Create new workflow

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { useLoadingService, useI18n } from '@/composables';
import { VIEWS } from '@/constants';
import { useTemplatesStore, useWorkflowsStore } from '@/stores';
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const loadingService = useLoadingService();
const templateStore = useTemplatesStore();
const workfowStore = useWorkflowsStore();
const router = useRouter();
const route = useRoute();
const i18n = useI18n();
const openWorkflowTemplate = async (templateId: string) => {
try {
loadingService.startLoading();
const template = await templateStore.getFixedWorkflowTemplate(templateId);
if (!template) {
throw new Error();
}
const name: string = i18n.baseText('onboarding.title', {
interpolate: { name: template.name },
});
const workflow = await workfowStore.createNewWorkflow({
name,
connections: template.workflow.connections,
nodes: template.workflow.nodes,
pinData: template.workflow.pinData,
settings: template.workflow.settings,
meta: {
onboardingId: templateId,
},
});
await router.replace({
name: VIEWS.WORKFLOW,
params: { name: workflow.id },
query: { onboardingId: templateId },
});
loadingService.stopLoading();
} catch (e) {
await router.replace({ name: VIEWS.NEW_WORKFLOW });
loadingService.stopLoading();
throw new Error(`Could not load onboarding template ${templateId}`); // sentry reporing
}
};
onMounted(async () => {
const templateId = route.params.id;
if (!templateId || typeof templateId !== 'string') {
await router.replace({ name: VIEWS.NEW_WORKFLOW });
return;
}
await openWorkflowTemplate(templateId);
});
</script>
<template>
<div></div>
</template>
<style lang="scss" module></style>

View file

@ -1816,6 +1816,10 @@ export interface IWorkflowSettings {
executionOrder?: 'v0' | 'v1';
}
export interface WorkflowFEMeta {
onboardingId?: string;
}
export interface WorkflowTestData {
description: string;
input: {