mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
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:
parent
68cbb78680
commit
35f2ce2359
96619
package-lock.json
generated
96619
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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: {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -475,6 +475,10 @@ export interface IPersonalizationSurveyAnswers {
|
|||
workArea: string[] | string | null;
|
||||
}
|
||||
|
||||
export interface IUserSettings {
|
||||
isOnboarded?: boolean;
|
||||
}
|
||||
|
||||
export interface IUserManagementSettings {
|
||||
enabled: boolean;
|
||||
showSetupOnFirstLoad?: boolean;
|
||||
|
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -98,10 +98,12 @@ credentialsController.get(
|
|||
ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => {
|
||||
const { name: newName } = req.query;
|
||||
|
||||
return GenericHelpers.generateUniqueName(
|
||||
newName ?? config.getEnv('credentials.defaultName'),
|
||||
'credentials',
|
||||
);
|
||||
return {
|
||||
name: await GenericHelpers.generateUniqueName(
|
||||
newName ?? config.getEnv('credentials.defaultName'),
|
||||
'credentials',
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`');
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
2
packages/cli/src/requests.d.ts
vendored
2
packages/cli/src/requests.d.ts
vendored
|
@ -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 }>;
|
||||
|
||||
|
|
|
@ -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 \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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
BIN
packages/editor-ui/public/static/quickstart_thumbnail.png
Normal file
BIN
packages/editor-ui/public/static/quickstart_thumbnail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 389 KiB |
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
@ -124,7 +126,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
|
|||
workflowRunning (): boolean {
|
||||
return this.$store.getters.isActionActive('workflowRunning');
|
||||
},
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isResizing: false,
|
||||
|
@ -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});
|
||||
},
|
||||
|
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue