feat(editor): Improve n8n welcome experience (#3289)

*  Injecting a welcome sticky note if a corresponding flag has been received from backend

* 🔒 Allowing resources from `/static` route to be displayed in markown component.

*  Implemented image width control via markdown URLs

* 💄Updating quickstart video thumbnail images.

* 🔨 Updated new workflow action name and quickstart sticky name

*  Added quickstart menu item in the Help menu

* 🔨 Moving quickstart video thumbnail to the translation file.

* 🔒 Limiting http static resource requests in markdown img tags only to image files.

* 🔒 Adding more file types to supported image list in markown component.

* 👌 Extracting quickstart note name to constant.

* 🐘 add DB migration sqlite

* ️ add logic for onboarding flow flag

* 🐘 add postgres migration for user settings

* 🐘 add mysql migration for user settings

*  Injecting a welcome sticky note if a corresponding flag has been received from backend

* 🔒 Allowing resources from `/static` route to be displayed in markown component.

*  Implemented image width control via markdown URLs

* 💄Updating quickstart video thumbnail images.

* 🔨 Updated new workflow action name and quickstart sticky name

*  Added quickstart menu item in the Help menu

* 🔨 Moving quickstart video thumbnail to the translation file.

* 🔒 Limiting http static resource requests in markdown img tags only to image files.

* 🔒 Adding more file types to supported image list in markown component.

* 👌 Extracting quickstart note name to constant.

* 📈 Added telemetry events to quickstart sticky note.

*  Disable sticky node type from showing in expression editor

* 🔨 Improving welcome video link detecton when triggering telemetry events

* 👌Moved sticky links click handling logic outside of the design system, removed user and instance id from telemetry events.

* 👌Improving sticky note link telemetry tracking.

* 🔨 Refactoring markdown component click event logic.

* 🔨 Moving bits of clicked link detection logic to Markdown component.

* 💄Fixing code spacing.

* remove transpileonly option

* update package lock

* 💄Changing the default route to `/workflow`, updating welcome sticky content.

* remove hardcoded

* 🐛 Fixing the onboarding threshold logic so sticky notes are skipped when counting nodes.

* 👕 Fixing linting errors.

Co-authored-by: Milorad Filipović <milorad.filipovic19@gmail.com>
Co-authored-by: Milorad Filipović <miloradfilipovic19@gmail.com>
Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>
Co-authored-by: Milorad Filipović <milorad@n8n.io>
This commit is contained in:
Mutasem Aldmour 2022-05-16 18:19:33 +02:00 committed by GitHub
parent 68cbb78680
commit 35f2ce2359
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1346 additions and 95629 deletions

96619
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -179,6 +179,12 @@ export const schema = {
default: 'My workflow',
env: 'WORKFLOWS_DEFAULT_NAME',
},
onboardingFlowDisabled: {
doc: 'Show onboarding flow in new workflow',
format: 'Boolean',
default: false,
env: 'N8N_ONBOARDING_FLOW_DISABLED',
},
},
executions: {

View file

@ -170,7 +170,7 @@ export async function generateUniqueName(
// name is unique
if (found.length === 0) {
return { name: requestedName };
return requestedName;
}
const maxSuffix = found.reduce((acc, { name }) => {
@ -190,10 +190,10 @@ export async function generateUniqueName(
// name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) {
return { name: `${requestedName} 2` };
return `${requestedName} 2`;
}
return { name: `${requestedName} ${maxSuffix + 1}` };
return `${requestedName} ${maxSuffix + 1}`;
}
export async function validateEntity(

View file

@ -475,6 +475,10 @@ export interface IPersonalizationSurveyAnswers {
workArea: string[] | string | null;
}
export interface IUserSettings {
isOnboarded?: boolean;
}
export interface IUserManagementSettings {
enabled: boolean;
showSetupOnFirstLoad?: boolean;

View file

@ -138,7 +138,7 @@ import * as TagHelpers from './TagHelpers';
import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers';
import { getSharedWorkflowIds, isBelowOnboardingThreshold, whereClause } from './WorkflowHelpers';
import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers';
import { WEBHOOK_METHODS } from './WebhookHelpers';
@ -911,7 +911,14 @@ class App {
const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName;
return await GenericHelpers.generateUniqueName(requestedName, 'workflow');
const name = await GenericHelpers.generateUniqueName(requestedName, 'workflow');
const onboardingFlowEnabled =
!config.getEnv('workflows.onboardingFlowDisabled') &&
!req.user.settings?.isOnboarded &&
(await isBelowOnboardingThreshold(req.user));
return { name, onboardingFlowEnabled };
}),
);

View file

@ -9,6 +9,7 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-param-reassign */
import { In } from 'typeorm';
import {
IDataObject,
IExecuteData,
@ -596,3 +597,52 @@ export async function getSharedWorkflowIds(user: User): Promise<number[]> {
return sharedWorkflows.map(({ workflow }) => workflow.id);
}
/**
* Check if user owns more than 15 workflows or more than 2 workflows with at least 2 nodes.
* If user does, set flag in its settings.
*/
export async function isBelowOnboardingThreshold(user: User): Promise<boolean> {
let belowThreshold = true;
const skippedTypes = ['n8n-nodes-base.start', 'n8n-nodes-base.stickyNote'];
const workflowOwnerRole = await Db.collections.Role.findOne({
name: 'owner',
scope: 'workflow',
});
const ownedWorkflowsIds = await Db.collections.SharedWorkflow.find({
user,
role: workflowOwnerRole,
}).then((ownedWorkflows) => ownedWorkflows.map((wf) => wf.workflowId));
if (ownedWorkflowsIds.length > 15) {
belowThreshold = false;
} else {
// just fetch workflows' nodes to keep memory footprint low
const workflows = await Db.collections.Workflow.find({
where: { id: In(ownedWorkflowsIds) },
select: ['nodes'],
});
// valid workflow: 2+ nodes without start node
const validWorkflowCount = workflows.reduce((counter, workflow) => {
if (counter <= 2 && workflow.nodes.length > 2) {
const nodes = workflow.nodes.filter((node) => !skippedTypes.includes(node.type));
if (nodes.length >= 2) {
return counter + 1;
}
}
return counter;
}, 0);
// more than 2 valid workflows required
belowThreshold = validWorkflowCount <= 2;
}
// user is above threshold --> set flag in settings
if (!belowThreshold) {
void Db.collections.User.update(user.id, { settings: { isOnboarded: true } });
}
return belowThreshold;
}

View file

@ -98,10 +98,12 @@ credentialsController.get(
ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => {
const { name: newName } = req.query;
return GenericHelpers.generateUniqueName(
return {
name: await GenericHelpers.generateUniqueName(
newName ?? config.getEnv('credentials.defaultName'),
'credentials',
);
),
};
}),
);

View file

@ -16,7 +16,7 @@ import {
} from 'typeorm';
import { IsEmail, IsString, Length } from 'class-validator';
import * as config from '../../../config';
import { DatabaseType, IPersonalizationSurveyAnswers } from '../..';
import { DatabaseType, IPersonalizationSurveyAnswers, IUserSettings } from '../..';
import { Role } from './Role';
import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials';
@ -102,6 +102,12 @@ export class User {
})
personalizationAnswers: IPersonalizationSurveyAnswers | null;
@Column({
type: resolveDataType('json') as ColumnOptions['type'],
nullable: true,
})
settings: IUserSettings | null;
@ManyToOne(() => Role, (role) => role.globalForUsers, {
cascade: true,
nullable: false,

View file

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
export class AddUserSettings1652367743993 implements MigrationInterface {
name = 'AddUserSettings1652367743993';
public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
'ALTER TABLE `' + tablePrefix + 'user` ADD COLUMN `settings` json NULL DEFAULT NULL',
);
await queryRunner.query(
'ALTER TABLE `' +
tablePrefix +
'user` CHANGE COLUMN `personalizationAnswers` `personalizationAnswers` json NULL DEFAULT NULL',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'user` DROP COLUMN `settings`');
}
}

View file

@ -13,6 +13,7 @@ import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWo
import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -30,4 +31,5 @@ export const mysqlMigrations = [
AddExecutionEntityIndexes1644424784709,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
];

View file

@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
export class AddUserSettings1652367743993 implements MigrationInterface {
name = 'AddUserSettings1652367743993';
public async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`ALTER TABLE ${tablePrefix}user ADD COLUMN settings json`);
await queryRunner.query(
`ALTER TABLE ${tablePrefix}user ALTER COLUMN "personalizationAnswers" TYPE json USING to_jsonb("personalizationAnswers")::json;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`ALTER TABLE ${tablePrefix}user DROP COLUMN settings`);
}
}

View file

@ -11,6 +11,7 @@ import { AddExecutionEntityIndexes1644422880309 } from './1644422880309-AddExecu
import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -26,4 +27,5 @@ export const postgresMigrations = [
IncreaseTypeVarcharLimit1646834195327,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
];

View file

@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class AddUserSettings1652367743993 implements MigrationInterface {
name = 'AddUserSettings1652367743993';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId" FROM "${tablePrefix}user"`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "${tablePrefix}user"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
);
await queryRunner.query('PRAGMA foreign_keys=ON');
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.query(`ALTER TABLE "${tablePrefix}user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "${tablePrefix}user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId" FROM "temporary_user"`,
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
);
await queryRunner.query('PRAGMA foreign_keys=ON');
}
}

View file

@ -12,6 +12,7 @@ import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWo
import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
const sqliteMigrations = [
InitialMigration1588102412422,
@ -26,6 +27,7 @@ const sqliteMigrations = [
AddExecutionEntityIndexes1644421939510,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
];
export { sqliteMigrations };

View file

@ -51,7 +51,7 @@ export declare namespace WorkflowRequest {
type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>;
type NewName = express.Request<{}, {}, {}, { name?: string }>;
type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>;
type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>;

View file

@ -4,6 +4,7 @@
v-if="!loading"
ref="editor"
:class="$style[theme]" v-html="htmlContent"
@click="onClick"
/>
<div v-else :class="$style.markdown">
<div v-for="(block, index) in loadingBlocks"
@ -117,6 +118,7 @@ export default {
}
const fileIdRegex = new RegExp('fileId:([0-9]+)');
const imageFilesRegex = /\.(jpeg|jpg|gif|png|webp|bmp|tif|tiff|apng|svg|avif)$/;
let contentToRender = this.content;
if (this.withMultiBreaks) {
contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n');
@ -129,7 +131,10 @@ export default {
const id = value.split('fileId:')[1];
return `src=${xss.friendlyAttrValue(imageUrls[id])}` || '';
}
if (!value.startsWith('https://')) {
// Only allow http requests to supported image files from the `static` directory
const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null;
const isStaticImageFile = isImageFile && value.startsWith('/static/');
if (!value.startsWith('https://') && !isStaticImageFile) {
return '';
}
}
@ -154,6 +159,22 @@ export default {
.use(markdownTasklists, this.options.tasklists),
};
},
methods: {
onClick(event) {
let clickedLink = null;
if(event.target instanceof HTMLAnchorElement) {
clickedLink = event.target;
}
if(event.target.matches('a *')) {
const parentLink = event.target.closest('a');
if(parentLink) {
clickedLink = parentLink;
}
}
this.$emit('markdown-click', clickedLink, event);
}
}
};
</script>
@ -287,6 +308,10 @@ export default {
img {
object-fit: contain;
&[src*="#full-width"] {
width: 100%;
}
}
}

View file

@ -26,6 +26,7 @@
theme="sticky"
:content="content"
:withMultiBreaks="true"
@markdown-click="onMarkdownClick"
/>
</div>
<div
@ -164,6 +165,9 @@ export default mixins(Locale).extend({
onInput(value: string) {
this.$emit('input', value);
},
onMarkdownClick(link, event) {
this.$emit('markdown-click', link, event);
},
onResize(values) {
this.$emit('resize', values);
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

View file

@ -2,6 +2,10 @@ import { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from './helpers';
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
return await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {});
const response = await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {});
return {
name: response.name,
onboardingFlowEnabled: response.onboardingFlowEnabled === true,
};
}

View file

@ -263,6 +263,16 @@ export default mixins(
},
helpMenuItems (): object[] {
return [
{
id: 'quickstart',
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
title: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'),
icon: 'video',
newWindow: true,
},
},
{
id: 'docs',
type: 'link',

View file

@ -28,6 +28,7 @@
@resizestart="onResizeStart"
@resize="onResize"
@resizeend="onResizeEnd"
@markdown-click="onMarkdownClick"
/>
</div>
@ -54,6 +55,7 @@ import { INodeUi, XYPosition } from '@/Interface';
import {
INodeTypeDescription,
} from 'n8n-workflow';
import { QUICKSTART_NOTE_NAME } from '@/constants';
export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({
name: 'Sticky',
@ -146,6 +148,15 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.$store.commit('setActiveNode', null);
}
},
onMarkdownClick ( link:HTMLAnchorElement, event: Event ) {
if (link) {
const isOnboardingNote = this.name === QUICKSTART_NOTE_NAME;
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"');
const type = isOnboardingNote && isWelcomeVideo ? 'welcome_video' : isOnboardingNote && link.getAttribute('href') === '/templates' ? 'templates' : 'other';
this.$telemetry.track('User clicked note link', { type } );
}
},
onInputChange(content: string) {
this.setParameters({content});
},

View file

@ -13,7 +13,7 @@
<script lang="ts">
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME, STICKY_NODE_TYPE,
} from '@/constants';
import {
@ -39,6 +39,11 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
// Node types that should not be displayed in variable selector
const SKIPPED_NODE_TYPES = [
STICKY_NODE_TYPE,
];
export default mixins(
workflowHelpers,
)
@ -561,6 +566,10 @@ export default mixins(
// Skip the current node as this one get added separately
continue;
}
// If node type should be skipped, continue
if (SKIPPED_NODE_TYPES.includes(node.type)) {
continue;
}
nodeOptions = [
{

View file

@ -12,6 +12,7 @@ export const MIN_WORKFLOW_NAME_LENGTH = 1;
export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy';
export const NODE_OUTPUT_DEFAULT_KEY = '_NODE_OUTPUT_DEFAULT_KEY_';
export const QUICKSTART_NOTE_NAME = '_QUICKSTART_NOTE_';
// tags
export const MAX_TAG_NAME_LENGTH = 24;

View file

@ -10,18 +10,21 @@ const module: Module<IWorkflowsState, IRootState> = {
namespaced: true,
state: {},
actions: {
setNewWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>, name?: string): Promise<void> => {
let newName = '';
getNewWorkflowData: async (context: ActionContext<IWorkflowsState, IRootState>, name?: string): Promise<object> => {
let workflowData = {
name: '',
onboardingFlowEnabled: false,
};
try {
const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext, name);
newName = newWorkflow.name;
workflowData = await getNewWorkflow(context.rootGetters.getRestApiContext, name);
}
catch (e) {
// in case of error, default to original name
newName = name || DEFAULT_NEW_WORKFLOW_NAME;
workflowData.name = name || DEFAULT_NEW_WORKFLOW_NAME;
}
context.commit('setWorkflowName', { newName }, { root: true });
context.commit('setWorkflowName', { newName: workflowData.name }, { root: true });
return workflowData;
},
getDuplicateCurrentWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>): Promise<string> => {

View file

@ -265,6 +265,7 @@
"mainSidebar.helpMenuItems.course": "Course",
"mainSidebar.helpMenuItems.documentation": "Documentation",
"mainSidebar.helpMenuItems.forum": "Forum",
"mainSidebar.helpMenuItems.quickstart": "Quickstart",
"mainSidebar.importFromFile": "Import from File",
"mainSidebar.importFromUrl": "Import from URL",
"mainSidebar.new": "New",
@ -480,6 +481,7 @@
"nodeWebhooks.showMessage.title": "URL copied",
"nodeWebhooks.testUrl": "Test URL",
"nodeWebhooks.webhookUrls": "Webhook URLs",
"onboardingWorkflow.stickyContent": "## 👇 Get started faster \n\n### Quickstart video\n[![n8n quickstart video](/static/quickstart_thumbnail.png#full-width)](https://www.youtube.com/watch?v=RpjQTGKm-ok)\nCovers key concepts really quickly\n\n\n### Template library\nGet inspiration and learn useful techniques from our pre-built [workflow templates](/templates).",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.addExpression": "Add Expression",

View file

@ -93,6 +93,7 @@ import {
faUserCircle,
faUserFriends,
faUsers,
faVideo,
faStickyNote as faSolidStickyNote,
} from '@fortawesome/free-solid-svg-icons';
import {
@ -197,6 +198,7 @@ addIcon(faUndo);
addIcon(faUserCircle);
addIcon(faUserFriends);
addIcon(faUsers);
addIcon(faVideo);
Vue.component('font-awesome-icon', FontAwesomeIcon);

View file

@ -56,12 +56,6 @@ const router = new Router({
name: VIEWS.HOMEPAGE,
meta: {
getRedirect(store: Store<IRootState>) {
const isTemplatesEnabled: boolean = store.getters['settings/isTemplatesEnabled'];
const isTemplatesEndpointReachable: boolean = store.getters['settings/isTemplatesEndpointReachable'];
if (isTemplatesEnabled && isTemplatesEndpointReachable) {
return { name: VIEWS.TEMPLATES };
}
return { name: VIEWS.NEW_WORKFLOW };
},
permissions: {

View file

@ -147,7 +147,7 @@ import {
} from 'jsplumb';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, STICKY_NODE_TYPE, VIEWS, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, QUICKSTART_NOTE_NAME, START_NODE_TYPE, STICKY_NODE_TYPE, VIEWS, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
@ -596,7 +596,7 @@ export default mixins(
this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } });
await this.addNodes(data.workflow.nodes, data.workflow.connections);
await this.$store.dispatch('workflows/setNewWorkflowName', data.name);
await this.$store.dispatch('workflows/getNewWorkflowData', data.name);
this.$nextTick(() => {
this.zoomToFit();
this.$store.commit('setStateDirty', true);
@ -1836,7 +1836,8 @@ export default mixins(
},
async newWorkflow (): Promise<void> {
await this.resetWorkspace();
await this.$store.dispatch('workflows/setNewWorkflowName');
const newWorkflow = await this.$store.dispatch('workflows/getNewWorkflowData');
this.$store.commit('setStateDirty', false);
await this.addNodes([{...CanvasHelpers.DEFAULT_START_NODE}]);
@ -1848,6 +1849,24 @@ export default mixins(
this.setZoomLevel(1);
setTimeout(() => {
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [0, 0]});
// For novice users (onboardingFlowEnabled == true)
// Inject welcome sticky note and zoom to fit
if(newWorkflow.onboardingFlowEnabled) {
this.$nextTick(async () => {
await this.addNodes([
{
...CanvasHelpers.WELCOME_STICKY_NODE,
parameters: {
// Use parameters from the template but add translated content
...CanvasHelpers.WELCOME_STICKY_NODE.parameters,
content: this.$locale.baseText('onboardingWorkflow.stickyContent'),
},
},
]);
this.zoomToFit();
this.$telemetry.track('welcome node inserted');
});
}
}, 0);
},
async initView (): Promise<void> {
@ -2213,7 +2232,13 @@ export default mixins(
}
if(node.type === STICKY_NODE_TYPE) {
this.$telemetry.track('User deleted workflow note', { workflow_id: this.$store.getters.workflowId });
this.$telemetry.track(
'User deleted workflow note',
{
workflow_id: this.$store.getters.workflowId,
is_welcome_note: node.name === QUICKSTART_NOTE_NAME,
},
);
} else {
this.$externalHooks().run('node.deleteNode', { node });
this.$telemetry.track('User deleted node', { node_type: node.type, workflow_id: this.$store.getters.workflowId });

View file

@ -1,5 +1,5 @@
import { getStyleTokenValue, isNumber } from "@/components/helpers";
import { NODE_OUTPUT_DEFAULT_KEY, START_NODE_TYPE, STICKY_NODE_TYPE } from "@/constants";
import { NODE_OUTPUT_DEFAULT_KEY, START_NODE_TYPE, STICKY_NODE_TYPE, QUICKSTART_NOTE_NAME } from "@/constants";
import { IBounds, INodeUi, IZoomConfig, XYPosition } from "@/Interface";
import { Connection, Endpoint, Overlay, OverlaySpec, PaintStyle } from "jsplumb";
import {
@ -48,6 +48,20 @@ export const DEFAULT_START_NODE = {
parameters: {},
};
export const WELCOME_STICKY_NODE = {
name: QUICKSTART_NOTE_NAME,
type: STICKY_NODE_TYPE,
typeVersion: 1,
position: [
-240,
140,
] as XYPosition,
parameters: {
height: 440,
width: 380,
},
};
export const CONNECTOR_FLOWCHART_TYPE = ['N8nCustom', {
cornerRadius: 12,
stub: JSPLUMB_FLOWCHART_STUB + 10,