Skip to content

Commit

Permalink
feat(editor): Improve n8n welcome experience (#3289)
Browse files Browse the repository at this point in the history
* ✨ 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>
  • Loading branch information
5 people authored May 16, 2022
1 parent 68cbb78 commit 35f2ce2
Show file tree
Hide file tree
Showing 29 changed files with 10,517 additions and 104,800 deletions.
114,961 changes: 10,193 additions & 104,768 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/cli/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/GenericHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,10 @@ export interface IPersonalizationSurveyAnswers {
workArea: string[] | string | null;
}

export interface IUserSettings {
isOnboarded?: boolean;
}

export interface IUserManagementSettings {
enabled: boolean;
showSetupOnFirstLoad?: boolean;
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 };
}),
);

Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/WorkflowHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
10 changes: 6 additions & 4 deletions packages/cli/src/api/credentials.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
};
}),
);

Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/databases/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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`');
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/mysqldb/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,4 +31,5 @@ export const mysqlMigrations = [
AddExecutionEntityIndexes1644424784709,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
];
Original file line number Diff line number Diff line change
@@ -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`);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/postgresdb/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,4 +27,5 @@ export const postgresMigrations = [
IncreaseTypeVarcharLimit1646834195327,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
];
Original file line number Diff line number Diff line change
@@ -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');
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/sqlite/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ const sqliteMigrations = [
AddExecutionEntityIndexes1644421939510,
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
];

export { sqliteMigrations };
2 changes: 1 addition & 1 deletion packages/cli/src/requests.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;

Expand Down
27 changes: 26 additions & 1 deletion packages/design-system/src/components/N8nMarkdown/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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');
Expand All @@ -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 '';
}
}
Expand All @@ -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>

Expand Down Expand Up @@ -287,6 +308,10 @@ export default {
img {
object-fit: contain;
&[src*="#full-width"] {
width: 100%;
}
}
}
Expand Down
Loading

0 comments on commit 35f2ce2

Please sign in to comment.