Add Templates (#2720)

* Templates Bugs / Fixed Various Bugs / Multiply Api Request, Carousel Gradient, Core Nodes Filters ...

* Updated MainSidebar Paddings

* N8N-Templates Bugfixing - Remove Unnecesairy Icon (Shape), Refatctor infiniteScrollEnabled Prop + updated infiniterScroll functinality

* N8N-2853 Fixed Carousel Arrows Bug after Cleaning the SearchBar

* fix telemetry init

* fix search tracking issues

* N8N-2853 Created FilterTemplateNode Constant Array, Filter PlayButton and WebhookRespond from Nodes, Added Box for showing more nodes inside TemplateList, Updated NewWorkflowButton to primary, Fixed Markdown issue with Code

* N8N-2853 Removed Placeholder if Workflows Or Collections are not found, Updated the Logic

* fix telemetry events

* clean up session id

* update user inserted event

* N8N-2853 Fixed Categories to Moving if the names are long

* Add todos

* Update Routes on loading

* fix spacing

* Update Border Color

* Update Border Readius

* fix filter fn

* fix constant, console error

* N8N-2853 PR Fixes, Refactoring, Removing unnecesairy code ..

* N8N-2853 PR Fixes - Editor-ui Fixes, Refactoring, Removing Dead Code ...

* N8N-2853 Refactor Card to LongCard

* clean up spacing, replace css var

* clean up spacing

* set categories as optional in node

* replace vars

* refactor store

* remove unnesssary import

* fix error

* fix templates view to start

* add to cache

* fix coll view data

* fix categories

* fix category event

* fix collections carousel

* fix initial load and search

* fix infinite load

* fix query param

* fix scrolling issues

* fix scroll to top

* fix search

* fix collections search

* fix navigation bug

* rename view

* update package lock

* rename workflow view

* rename coll view

* update routes

* add wrapper component

* set session id

* fix search tracking

* fix session tracking

* remove deleted mutation

* remove check for unsupported nodes

* refactor filters

* lazy load template

* clean up types

* refactor infinte scroll

* fix end of search

* Fix spacing

* fix coll loading

* fix types

* fix coll view list

* fix navigation

* rename types

* rename state

* fix search responsiveness

* fix coll view spacing

* fix search view spacing

* clean up views

* set background color

* center page not vert

* fix workflow view

* remove import

* fix background color

* fix background

* clean props

* clean up imports

* refactor button

* update background color

* fix spacing issue

* rename event

* update telemetry event

* update endpoints, add loading view, check for endpoint health

* remove conolse log

* N8N-2853 Fixed Menu Items Padding

* replace endpoints

* fix type issues

* fix categories

* N8N-2853 Fixed ParameterInput Placeholder after ElementUI Upgrade

* update createdAt

*  Fix placeholder in creds config modal

* ✏️ Adjust docstring to `credText` placeholder version

* N8N-2853 Optimized

* N8N-2853 Optimized code

*  Add deployment type to FE settings

*  Add deployment type to interfaces

* N8N-2853 Removed Animated prop from components

*  Add deployment type to store module

*  Create hiring banner

*  Display hiring banner

*  Undo unrelated change

* N8N-2853 Refactor TemplateFilters

*  Fix indentation

* N8N-2853 Reorder items / TemplateList

* 👕 Fix lint

* N8N-2853 Refactor TemplateFilters Component

* N8N-2853 Reorder TemplateList

* refactor template card

* update timeout

* fix removelistener

* fix spacing

* split enabled from offline

* add spacing to go back

* N8N-2853 Fixed Screens for Tablet & Mobile

* N8N-2853 Update Stores Order

* remove image componet

* remove placeholder changes

* N8N-2853 Fixed Chinnese Placeholders for El Select Component that comes from the Library Upgrade

* N8N-2853 Fixed Vue Agile Console Warnings

* N8N-2853 Update Collection Route

* ✏️ Update jobs URL

* 🚚 Move logging to root component

*  Refactor `deploymentType` to `isInternalUser`

*  Improve syntax

* fix cut bug in readonly view

* N8N-3012 Fixed Details section in templates with lots of description, Fixed Mardown Block with overflox-x

* N8N-3012 Increased Font-size, Spacing and Line-height of the Categories Items

* N8N-3012 Fixed Vue-agile client width error on resize

* only delay redirect for root path

* N8N-3012 Fixed Carousel Arrows that Disappear

* N8N-3012 Make Loading Screen same color as Templates

* N8N-3012 Markdown renders inline block as block code

* add offline warning

* hide log from workflow iframe

* update text

* make search button larger

* N8N-3012 Categories / Tags extended all the way in details section

* load data in cred modals

* remove deleted message

* add external hook

* remove import

* update env variable description

* fix markdown width issue

* disable telemetry for demo, add session id to template pages

* fix telemetery bugs

* N8N-3012 Not found Collections/Wokrkflow

* N8N-3012 Checkboxes change order when categories are changed

* N8N-3012 Refactor SortedCategories inside TemplateFilters component

* fix firefox bug

* add telemetry requirements

* add error check

* N8N-3012 Update GoBackButton to check if Route History is present

* N8N-3012 Fixed WF Nodes Icons

* hide workflow screenshots

* remove unnessary mixins

* rename prop

* fix design a bit

* rename data

* clear workspace on destroy

* fix copy paste bug

* fix disabled state

* N8N-3012 Fixed Saving/Leave without saving Modal

* fix telemetry issue

* fix telemetry issues, error bug

* fix error notification

* disable workflow menu items on templates

* fix i18n elementui issue

* Remove Emit - NodeType from HoverableNodeIcon component

* TechnicalFixes: NavigateTo passed down as function should be helper

* TechnicalFixes: Update NavigateTo function

* TechnicalFixes: Add FilterCoreNodes directly as function

* check for empty connecitions

* fix titles

* respect new lines

* increase categories to be sliced

* rename prop

* onUseWorkflow

* refactor click event

* fix bug, refactor

* fix loading story

* add default

* fix styles at right level of abstraction

* add wrapper with width

* remove loading blocks component

* add story

* rename prop

* fix spacing

* refactor tag, add story

* move margin to container

* fix tag redirect, remove unnessary check

* make version optional

* rename view

* move from workflows to templates store

* remove unnessary change

* remove unnessary css

* rename component

* refactor collection card

* add boolean to prevent shrink

* clean up carousel

* fix redirection bug on save

* remove listeners to fix multiple listeners bug

* remove unnessary types

* clean up boolean set

* fix node select bug

* rename component

* remove unnessary class

* fix redirection bug

* remove unnessary error

* fix typo

* fix blockquotes, pre

* refactor markdown rendering

* remove console log

* escape markdown

* fix safari bug

* load active workflows to fix modal bug

* ⬆️ Update package-lock.json file

*  Add n8n version as header

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Oliver Trajceski 2022-02-28 10:57:44 +01:00 committed by GitHub
parent 401e626a64
commit cfa91cda27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 4409 additions and 433 deletions

523
package-lock.json generated
View file

@ -300,9 +300,9 @@
}
},
"@babel/helper-create-class-features-plugin": {
"version": "7.17.1",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.1.tgz",
"integrity": "sha512-JBdSr/LtyYIno/pNnJ75lBcqc3Z1XXujzPanHqjvvrhOA+DTceTFuJi8XjmWTZh4r3fsdfqaCMN0iZemdkxZHQ==",
"version": "7.17.6",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.6.tgz",
"integrity": "sha512-SogLLSxXm2OkBbSsHZMM4tUi8fUzjs63AT/d0YQIzr6GSd8Hxsbk2KYDX0k0DweAzGMj/YWeiCsorIdtdcW8Eg==",
"requires": {
"@babel/helper-annotate-as-pure": "^7.16.7",
"@babel/helper-environment-visitor": "^7.16.7",
@ -396,9 +396,9 @@
}
},
"@babel/helper-module-transforms": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.16.7.tgz",
"integrity": "sha512-gaqtLDxJEFCeQbYp9aLAefjhkKdjKcdh6DB7jniIGU3Pz52WAmP268zK0VgPz9hUNkMSYeH976K2/Y6yPadpng==",
"version": "7.17.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.6.tgz",
"integrity": "sha512-2ULmRdqoOMpdvkbT8jONrZML/XALfzxlb052bldftkicAUy8AxSCkD5trDPQcwHNmolcl7wP6ehNqMlyUw6AaA==",
"requires": {
"@babel/helper-environment-visitor": "^7.16.7",
"@babel/helper-module-imports": "^7.16.7",
@ -406,8 +406,8 @@
"@babel/helper-split-export-declaration": "^7.16.7",
"@babel/helper-validator-identifier": "^7.16.7",
"@babel/template": "^7.16.7",
"@babel/traverse": "^7.16.7",
"@babel/types": "^7.16.7"
"@babel/traverse": "^7.17.3",
"@babel/types": "^7.17.0"
}
},
"@babel/helper-optimise-call-expression": {
@ -553,11 +553,11 @@
}
},
"@babel/plugin-proposal-class-static-block": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.16.7.tgz",
"integrity": "sha512-dgqJJrcZoG/4CkMopzhPJjGxsIe9A8RlkQLnL/Vhhx8AA9ZuaRwGSlscSh42hazc7WSrya/IK7mTeoF0DP9tEw==",
"version": "7.17.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.17.6.tgz",
"integrity": "sha512-X/tididvL2zbs7jZCeeRJ8167U/+Ac135AM6jCAx6gYXDUviZV5Ku9UDvWS2NCuWlFjIRXklYhwo6HhAC7ETnA==",
"requires": {
"@babel/helper-create-class-features-plugin": "^7.16.7",
"@babel/helper-create-class-features-plugin": "^7.17.6",
"@babel/helper-plugin-utils": "^7.16.7",
"@babel/plugin-syntax-class-static-block": "^7.14.5"
}
@ -9930,14 +9930,14 @@
"integrity": "sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw=="
},
"@oclif/parser": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/@oclif/parser/-/parser-3.8.6.tgz",
"integrity": "sha512-tXb0NKgSgNxmf6baN6naK+CCwOueaFk93FG9u202U7mTBHUKsioOUlw1SG/iPi9aJM3WE4pHLXmty59pci0OEw==",
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/@oclif/parser/-/parser-3.8.7.tgz",
"integrity": "sha512-b11xBmIUK+LuuwVGJpFs4LwQN2xj2cBWj2c4z1FtiXGrJ85h9xV6q+k136Hw0tGg1jQoRXuvuBnqQ7es7vO9/Q==",
"requires": {
"@oclif/errors": "^1.2.2",
"@oclif/errors": "^1.3.5",
"@oclif/linewrap": "^1.0.0",
"chalk": "^4.1.0",
"tslib": "^2.0.0"
"tslib": "^2.3.1"
},
"dependencies": {
"ansi-styles": {
@ -10812,6 +10812,11 @@
}
}
},
"acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ=="
},
"braces": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
@ -11041,10 +11046,11 @@
}
},
"terser": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
"version": "5.11.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.11.0.tgz",
"integrity": "sha512-uCA9DLanzzWSsN1UirKwylhhRz3aKPInlfmpGfw8VN6jHsAtu8HJtIpeeHHK23rxnE/cDc+yvmq5wqkIC6Kn0A==",
"requires": {
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.20"
@ -11722,6 +11728,11 @@
"webpack-virtual-modules": "^0.2.2"
},
"dependencies": {
"acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -11900,10 +11911,11 @@
}
},
"terser": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
"version": "5.11.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.11.0.tgz",
"integrity": "sha512-uCA9DLanzzWSsN1UirKwylhhRz3aKPInlfmpGfw8VN6jHsAtu8HJtIpeeHHK23rxnE/cDc+yvmq5wqkIC6Kn0A==",
"requires": {
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.20"
@ -12592,15 +12604,20 @@
"@types/node": "*"
}
},
"@types/linkify-it": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz",
"integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA=="
},
"@types/localtunnel": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/localtunnel/-/localtunnel-1.9.0.tgz",
"integrity": "sha512-3YxO7RHRrmtYNX6Rhkr97bnXHrF1Ckfo4axENWLcBXWi+8B1WsNbqPqe5Eg6TA5survjAWWvLTu1KQesuLHVgQ=="
},
"@types/lodash": {
"version": "4.14.178",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz",
"integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw=="
"version": "4.14.179",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.179.tgz",
"integrity": "sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w=="
},
"@types/lodash.camelcase": {
"version": "4.3.6",
@ -12647,6 +12664,15 @@
"@types/node": "*"
}
},
"@types/markdown-it": {
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
"requires": {
"@types/linkify-it": "*",
"@types/mdurl": "*"
}
},
"@types/mdast": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@ -12655,6 +12681,11 @@
"@types/unist": "*"
}
},
"@types/mdurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA=="
},
"@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@ -13646,15 +13677,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
"integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"array-union": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
@ -13690,21 +13712,6 @@
}
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -13767,58 +13774,6 @@
"worker-rpc": "^0.1.0"
}
},
"fork-ts-checker-webpack-plugin-v5": {
"version": "npm:fork-ts-checker-webpack-plugin@5.2.1",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz",
"integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==",
"optional": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@types/json-schema": "^7.0.5",
"chalk": "^4.1.0",
"cosmiconfig": "^6.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^9.0.0",
"memfs": "^3.1.2",
"minimatch": "^3.0.4",
"schema-utils": "2.7.0",
"semver": "^7.3.2",
"tapable": "^1.0.0"
},
"dependencies": {
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"optional": true,
"requires": {
"lru-cache": "^6.0.0"
}
}
}
},
"fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"optional": true,
"requires": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"glob-parent": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
@ -13853,12 +13808,6 @@
"slash": "^2.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"optional": true
},
"ignore": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
@ -13887,16 +13836,6 @@
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
},
"jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"optional": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"micromatch": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
@ -13932,17 +13871,6 @@
}
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"optional": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@ -13953,15 +13881,6 @@
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"to-regex-range": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
@ -14055,12 +13974,6 @@
"requires": {
"tslib": "^1.8.1"
}
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
"optional": true
}
}
},
@ -14869,11 +14782,11 @@
},
"dependencies": {
"postcss": {
"version": "8.4.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.6.tgz",
"integrity": "sha512-OovjwIzs9Te46vlEx7+uXB0PLijpwjXGKXjVGGPIGubGpq7uh5Xgf6D6FiJ/SzJMBosHDp6a2hiXOS97iBXcaA==",
"version": "8.4.7",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.7.tgz",
"integrity": "sha512-L9Ye3r6hkkCeOETQX6iOaWZgjp3LL6Lpqm6EtgbKrgqGGteRMNb9vzBfRL96YOSu8o7x3MfIH9Mo5cPJFGrW6A==",
"requires": {
"nanoid": "^3.2.0",
"nanoid": "^3.3.1",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
@ -16000,9 +15913,9 @@
"integrity": "sha512-uUbetCWczQHbsKyX1C99XpQHBM8SWfovvaZhPIj23/1uV7SQf0WeRZbiLpw0JZm+LHTChfNgrLfDJOVoU2kU+A=="
},
"aws-sdk": {
"version": "2.1077.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1077.0.tgz",
"integrity": "sha512-orJvJROs8hJaQRfHsX7Zl5PxEgrD/uTXyqXz9Yu9Io5VVxzvnOty9oHmvEMSlgTIf1qd01gnev/vpvP1HgzKtw==",
"version": "2.1082.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1082.0.tgz",
"integrity": "sha512-aDrUZ63O/ocuC827ursDqFQAm3jhqsJu1DvMCCFg73y+FK9pXXNHp2mwdi3UeeHvtfxISCLCjuyO3VFd/tpVfA==",
"requires": {
"buffer": "4.9.2",
"events": "1.1.1",
@ -20356,6 +20269,11 @@
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
},
"cssfilter": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4="
},
"cssnano": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.11.tgz",
@ -21426,9 +21344,9 @@
"integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA=="
},
"electron-to-chromium": {
"version": "1.4.71",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.71.tgz",
"integrity": "sha512-Hk61vXXKRb2cd3znPE9F+2pLWdIOmP7GjiTj45y6L3W/lO+hSnUSUhq+6lEaERWBdZOHbk2s3YV5c9xVl3boVw=="
"version": "1.4.73",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.73.tgz",
"integrity": "sha512-RlCffXkE/LliqfA5m29+dVDPB2r72y2D2egMMfIy3Le8ODrxjuZNVo4NIC2yPL01N4xb4nZQLwzi6Z5tGIGLnA=="
},
"element-resize-detector": {
"version": "1.2.4",
@ -21439,9 +21357,9 @@
}
},
"element-ui": {
"version": "2.13.2",
"resolved": "https://registry.npmjs.org/element-ui/-/element-ui-2.13.2.tgz",
"integrity": "sha512-r761DRPssMPKDiJZWFlG+4e4vr0cRG/atKr3Eqr8Xi0tQMNbtmYU1QXvFnKiFPFFGkgJ6zS6ASkG+sellcoHlQ==",
"version": "2.15.7",
"resolved": "https://registry.npmjs.org/element-ui/-/element-ui-2.15.7.tgz",
"integrity": "sha512-+J6rnXajxzLwV6w8Q6bf7Yqzk1FO1ewbIrCy/4B5alnd7tj8WEpfQoAvISirVaUGVGy77d9Ji3o2bF4f0AsJLQ==",
"requires": {
"async-validator": "~1.8.1",
"babel-helper-vue-jsx-merge-props": "^2.0.0",
@ -23534,6 +23452,124 @@
}
}
},
"fork-ts-checker-webpack-plugin-v5": {
"version": "npm:fork-ts-checker-webpack-plugin@5.2.1",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz",
"integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==",
"optional": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@types/json-schema": "^7.0.5",
"chalk": "^4.1.0",
"cosmiconfig": "^6.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^9.0.0",
"memfs": "^3.1.2",
"minimatch": "^3.0.4",
"schema-utils": "2.7.0",
"semver": "^7.3.2",
"tapable": "^1.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
"fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"optional": true,
"requires": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"optional": true
},
"jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"optional": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"optional": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"optional": true,
"requires": {
"lru-cache": "^6.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
"optional": true
}
}
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
@ -31263,11 +31299,6 @@
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"json3": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz",
"integrity": "sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA=="
},
"json5": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
@ -32042,6 +32073,11 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
"lodash.orderby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.6.0.tgz",
"integrity": "sha1-5pfwTOXXhSL1TZM4syuBozk+TrM="
},
"lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
@ -32071,6 +32107,11 @@
"lodash._reinterpolate": "^3.0.0"
}
},
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
},
"lodash.transform": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz",
@ -32437,6 +32478,45 @@
"resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz",
"integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg=="
},
"markdown-it": {
"version": "12.3.2",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
"integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
"requires": {
"argparse": "^2.0.1",
"entities": "~2.1.0",
"linkify-it": "^3.0.1",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"dependencies": {
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
}
}
},
"markdown-it-emoji": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-2.0.0.tgz",
"integrity": "sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ=="
},
"markdown-it-link-attributes": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.0.tgz",
"integrity": "sha512-ssjxSLlLfQBkX6BvAx1rCPrx7ZoK91llQQvS3P7KXvlbnVD34OUkfXwWecN7su/7mrI/HOW0RI5szdJOIqYC3w=="
},
"markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
},
"markdown-to-jsx": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.6.tgz",
@ -33245,11 +33325,11 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"mssql": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/mssql/-/mssql-6.4.0.tgz",
"integrity": "sha512-Mtgu3PXqoaL7aHCMurttvEHibjvz5XKjlR6ZCDyAeKtDBORpxm88JyzEU2EESVf7588GulYKc7Gr+Txf5CICBQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/mssql/-/mssql-6.4.1.tgz",
"integrity": "sha512-G1I7mM0gfxcH5TGSNoVmxq13Mve5YnQgRAlonqaMlHEjHjMn1g04bsrIQbVHFRdI6++dw/FGWlh8GoItJMoUDw==",
"requires": {
"debug": "^4.3.2",
"debug": "^4.3.3",
"tarn": "^1.1.5",
"tedious": "^6.7.1"
}
@ -37481,9 +37561,9 @@
},
"dependencies": {
"history": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.2.0.tgz",
"integrity": "sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"requires": {
"@babel/runtime": "^7.7.6"
}
@ -37500,9 +37580,9 @@
},
"dependencies": {
"history": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.2.0.tgz",
"integrity": "sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"requires": {
"@babel/runtime": "^7.7.6"
}
@ -37814,20 +37894,13 @@
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
},
"refractor": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.5.0.tgz",
"integrity": "sha512-QwPJd3ferTZ4cSPPjdP5bsYHMytwWYnAN5EEnLtGvkqp/FCCnGsBgxrm9EuIDnjUC3Uc/kETtvVi7fSIVC74Dg==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
"integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
"requires": {
"hastscript": "^6.0.0",
"parse-entities": "^2.0.0",
"prismjs": "~1.25.0"
},
"dependencies": {
"prismjs": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz",
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg=="
}
"prismjs": "~1.27.0"
}
},
"regenerate": {
@ -38244,9 +38317,9 @@
}
},
"yargs-parser": {
"version": "21.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz",
"integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA=="
"version": "21.0.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
"integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg=="
}
}
},
@ -38744,9 +38817,9 @@
}
},
"sass": {
"version": "1.49.8",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.8.tgz",
"integrity": "sha512-NoGOjvDDOU9og9oAxhRnap71QaTjjlzrvLnKecUJ3GxhaQBrV6e7gPuSPF28u1OcVAArVojPAe4ZhOXwwC4tGw==",
"version": "1.49.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz",
"integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==",
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@ -39475,16 +39548,15 @@
}
},
"sockjs-client": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.5.2.tgz",
"integrity": "sha512-ZzRxPBISQE7RpzlH4tKJMQbHM9pabHluk0WBaxAQ+wm/UieeBVBou0p4wVnSQGN9QmpAZygQ0cDIypWuqOFmFQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.0.tgz",
"integrity": "sha512-qVHJlyfdHFht3eBFZdKEXKTlb7I4IV41xnVNo8yUKA1UHcPJwgW2SvTq9LhnjjCywSkSK7c/e4nghU0GOoMCRQ==",
"requires": {
"debug": "^3.2.6",
"eventsource": "^1.0.7",
"faye-websocket": "^0.11.3",
"debug": "^3.2.7",
"eventsource": "^1.1.0",
"faye-websocket": "^0.11.4",
"inherits": "^2.0.4",
"json3": "^3.3.3",
"url-parse": "^1.5.3"
"url-parse": "^1.5.10"
},
"dependencies": {
"debug": {
@ -41482,9 +41554,9 @@
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
"uglify-js": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.1.tgz",
"integrity": "sha512-FAGKF12fWdkpvNJZENacOH0e/83eG6JyVQyanIJaBXCN1J11TUQv1T1/z8S+Z0CG0ZPk1nPcreF/c7lrTd0TEQ==",
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.2.tgz",
"integrity": "sha512-peeoTk3hSwYdoc9nrdiEJk+gx1ALCtTjdYuKSXMTDqq7n1W7dHPqWDdSi+BPL0ni2YMeHD7hKUSdbj3TZauY2A==",
"optional": true
},
"uid-number": {
@ -41986,9 +42058,9 @@
}
},
"url-parse": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.8.tgz",
"integrity": "sha512-9JZ5zDrn9wJoOy/t+rH00HHejbU8dq9VsOYVu272TYDrCiyVAgHKUSpPh3ruZIpv8PMVR+NXLZvfRPJv8xAcQw==",
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@ -42339,9 +42411,9 @@
"integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="
},
"vm2": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.8.tgz",
"integrity": "sha512-/1PYg/BwdKzMPo8maOZ0heT7DLI0DAFTm7YQaz/Lim9oIaFZsJs3EdtalvXuBfZwczNwsYhju75NW4d6E+4q+w==",
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.9.tgz",
"integrity": "sha512-xwTm7NLh/uOjARRBs8/95H0e8fT3Ukw5D/JJWhxMbhKzNh1Nu981jQKvkep9iKYNxzlVrdzD0mlBGkDKZWprlw==",
"requires": {
"acorn": "^8.7.0",
"acorn-walk": "^8.2.0"
@ -42369,6 +42441,15 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz",
"integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ=="
},
"vue-agile": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-agile/-/vue-agile-2.0.0.tgz",
"integrity": "sha512-5xkSLJQNRdQ7qpEnXj5FgLg33XKRHaTZKGP5qkvteOc/uGJX89MYCjPSgdNqJ1GYFGfdGAp0jvhihW8OMuXS3g==",
"requires": {
"lodash.orderby": "^4.6.0",
"lodash.throttle": "^4.1.1"
}
},
"vue-class-component": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz",
@ -42383,9 +42464,9 @@
}
},
"vue-docgen-api": {
"version": "4.44.15",
"resolved": "https://registry.npmjs.org/vue-docgen-api/-/vue-docgen-api-4.44.15.tgz",
"integrity": "sha512-JBFe4EAUSmRqRHaNNHqDo1U+w1HRaHh00C0bYKE65HdN9QS6pCJCUBwi1blow0beDzLTAJYCa90xwG61WYBo4A==",
"version": "4.44.17",
"resolved": "https://registry.npmjs.org/vue-docgen-api/-/vue-docgen-api-4.44.17.tgz",
"integrity": "sha512-bU1V9gvXDv5GPaOmXYcnHrc3EjwRxZ8IKFE+Hk7QWpnI5/MYriyt7rf/g/z0JS8u0vGdiYqqUVx6CB+Ghmkm5g==",
"requires": {
"@babel/parser": "^7.13.12",
"@babel/types": "^7.13.12",
@ -42397,7 +42478,7 @@
"pug": "^3.0.2",
"recast": "0.20.5",
"ts-map": "^1.0.3",
"vue-inbrowser-compiler-utils": "^4.44.15"
"vue-inbrowser-compiler-utils": "^4.44.17"
},
"dependencies": {
"lru-cache": {
@ -42473,9 +42554,9 @@
"integrity": "sha512-SX35iJHL5PJ4Gfh0Mo/q0shyHiI2V6Zkh51c+k8E9O1RKv5BQyYrCxRzpvPrsIOJEnLaeiovet3dsUB0e/kDzw=="
},
"vue-inbrowser-compiler-utils": {
"version": "4.44.15",
"resolved": "https://registry.npmjs.org/vue-inbrowser-compiler-utils/-/vue-inbrowser-compiler-utils-4.44.15.tgz",
"integrity": "sha512-dbuZbFNl7q3+MjLyFxD14LnrbYuhexVCbCU9AFJ2zd3zqHrueXSYGzYTLLTZ++fnCMC3J60xe409e/KEft+Cbw==",
"version": "4.44.17",
"resolved": "https://registry.npmjs.org/vue-inbrowser-compiler-utils/-/vue-inbrowser-compiler-utils-4.44.17.tgz",
"integrity": "sha512-dvxumVgIzR4FXjAWYWIOnpD+6bW0dLkoAv43UShER8gVIhLFo9UEmbF31wD6YWJj94lUpbVIuWl2qc6axYNEAQ==",
"requires": {
"camelcase": "^5.3.1"
}
@ -43914,6 +43995,22 @@
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz",
"integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM="
},
"xss": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.10.tgz",
"integrity": "sha512-qmoqrRksmzqSKvgqzN0055UFWY7OKx1/9JWeRswwEVX9fCG5jcYRxa/A2DHcmZX6VJvjzHRQ2STeeVcQkrmLSw==",
"requires": {
"commander": "^2.20.3",
"cssfilter": "0.0.10"
},
"dependencies": {
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
}
}
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -690,6 +690,21 @@ const config = convict({
},
},
templates: {
enabled: {
doc: 'Whether templates feature is enabled to load workflow templates.',
format: Boolean,
default: true,
env: 'N8N_TEMPLATES_ENABLED',
},
host: {
doc: 'Endpoint host to retrieve workflow templates from endpoints.',
format: String,
default: 'https://api.n8n.io/',
env: 'N8N_TEMPLATES_HOST',
},
},
binaryDataManager: {
availableModes: {
format: String,

View file

@ -412,6 +412,11 @@ export interface IN8nUISettings {
personalizationSurvey: IPersonalizationSurvey;
defaultLocale: string;
logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose';
deploymentType: string;
templates: {
enabled: boolean;
host: string;
};
}
export interface IPersonalizationSurveyAnswers {

View file

@ -286,6 +286,11 @@ class App {
},
defaultLocale: config.get('defaultLocale'),
logLevel: config.get('logs.level'),
deploymentType: config.get('deployment.type'),
templates: {
enabled: config.get('templates.enabled'),
host: config.get('templates.host'),
},
};
}

View file

@ -17,6 +17,8 @@ import {
faCheck,
faChevronDown,
faChevronUp,
faChevronLeft,
faChevronRight,
faCode,
faCodeBranch,
faCog,
@ -100,6 +102,8 @@ library.add(faCalendar);
library.add(faCheck);
library.add(faChevronDown);
library.add(faChevronUp);
library.add(faChevronLeft);
library.add(faChevronRight);
library.add(faCode);
library.add(faCodeBranch);
library.add(faCog);

View file

@ -30,14 +30,14 @@
"@fortawesome/free-solid-svg-icons": "5.x",
"@fortawesome/vue-fontawesome": "2.x",
"core-js": "3.x",
"element-ui": "2.13.x"
"element-ui": "2.15.x"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
"core-js": "^3.6.5",
"element-ui": "~2.13.0",
"element-ui": "2.15.x",
"storybook-addon-themes": "^6.1.0",
"vue": "^2.6.11",
"vue-class-component": "^7.2.3",
@ -48,6 +48,7 @@
"@storybook/addon-links": "^6.3.6",
"@storybook/vue": "^6.3.6",
"@types/jest": "^26.0.13",
"@types/markdown-it": "^12.2.3",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"@vue/cli-plugin-babel": "~4.5.0",
@ -63,6 +64,10 @@
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-vue": "^7.16.0",
"gulp": "^4.0.0",
"markdown-it": "^12.3.2",
"markdown-it-emoji": "^2.0.0",
"markdown-it-link-attributes": "^4.0.0",
"markdown-it-task-lists": "^2.1.1",
"prettier": "^2.3.2",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
@ -74,6 +79,7 @@
"gulp-clean-css": "^4.3.0",
"gulp-dart-sass": "^1.0.2",
"node-notifier": ">=8.0.1",
"trim": ">=0.0.3"
"trim": ">=0.0.3",
"xss": "^1.0.10"
}
}

View file

@ -0,0 +1,43 @@
import N8nLoading from './Loading.vue';
export default {
title: 'Atoms/Loading',
component: N8nLoading,
argTypes: {
animated: {
control: {
type: 'boolean',
},
},
loading: {
control: {
type: 'boolean',
},
},
rows: {
control: {
type: 'select',
options: [1, 2, 3, 4, 5],
},
},
variant: {
control: {
type: 'select',
options: ['button', 'h1', 'image', 'p'],
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nLoading,
},
template: '<n8n-loading v-bind="$props"></n8n-loading>',
});
export const Loading = Template.bind({});
Loading.args = {
variant: 'p',
};

View file

@ -0,0 +1,86 @@
<template>
<el-skeleton :loading="loading" :animated="animated">
<template slot="template">
<el-skeleton-item
v-if="variant === 'button'"
:variant="variant"
/>
<div v-if="variant === 'h1'">
<div
v-for="(item, index) in rows"
:key="index"
:class="{
[$style.h1Last]: item === rows && rows > 1 && shrinkLast,
}"
>
<el-skeleton-item
:variant="variant"
/>
</div>
</div>
<el-skeleton-item
v-if="variant === 'image'"
:variant="variant"
/>
<div v-if="variant === 'p'">
<div
v-for="(item, index) in rows"
:key="index"
:class="{
[$style.pLast]: item === rows && rows > 1 && shrinkLast,
}">
<el-skeleton-item
:variant="variant"
/>
</div>
</div>
</template>
</el-skeleton>
</template>
<script lang="ts">
import ElSkeleton from 'element-ui/lib/skeleton';
import ElSkeletonItem from 'element-ui/lib/skeleton-item';
export default {
name: 'n8n-loading',
components: {
ElSkeleton,
ElSkeletonItem,
},
props: {
animated: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: true,
},
rows: {
type: Number,
default: 1,
},
shrinkLast: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'p',
validator: (value: string): boolean => ['p', 'h1', 'button', 'image'].includes(value),
},
},
};
</script>
<style lang="scss" module>
.h1Last {
width: 40%;
}
.pLast {
width: 61%;
}
</style>

View file

@ -0,0 +1,3 @@
import N8nLoading from './Loading.vue';
export default N8nLoading;

View file

@ -0,0 +1,48 @@
import N8nMarkdown from './Markdown.vue';
export default {
title: 'Atoms/Markdown',
component: N8nMarkdown,
argTypes: {
content: {
control: {
type: 'text',
},
},
loading: {
control: {
type: 'boolean',
},
},
loadingBlocks: {
control: {
type: 'select',
options: [1, 2, 3, 4, 5],
},
},
loadingRows: {
control: {
type: 'select',
options: [1, 2, 3, 4, 5],
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMarkdown,
},
template: '<n8n-markdown v-bind="$props"></n8n-markdown>',
});
export const Markdown = Template.bind({});
Markdown.args = {
content: `I wanted a system to monitor website content changes and notify me. So I made it using n8n.\n\nEspecially my competitor blogs. I wanted to know how often they are posting new articles. (I used their sitemap.xml file) (The below workflow may vary)\n\nIn the Below example, I used HackerNews for example.\n\nExplanation:\n\n- First HTTP Request node crawls the webpage and grabs the website source code\n- Then wait for x minutes\n- Again, HTTP Node crawls the webpage\n- If Node compares both results are equal if anything is changed. Itll go to the false branch and notify me in telegram.\n\n**Workflow:**\n\n![](fileId:1)\n\n**Sample Response:**\n\n![](https://community.n8n.io/uploads/default/original/2X/d/d21ba41d7ac9ff5cd8148fedb07d0f1ff53b2529.png)\n`,
loading: false,
images: [{
id: 1,
url: 'https://community.n8n.io/uploads/default/optimized/2X/b/b737a95de4dfe0825d50ca098171e9f33a459e74_2_690x288.png',
}],
};

View file

@ -0,0 +1,220 @@
<template>
<div>
<div v-if="!loading" ref="editor" :class="$style.markdown" v-html="htmlContent" />
<div v-else :class="$style.markdown">
<div v-for="(block, index) in loadingBlocks"
:key="index">
<n8n-loading
:loading="loading"
:rows="loadingRows"
animated
variant="p"
/>
<div :class="$style.spacer" />
</div>
</div>
</div>
</template>
<script lang="ts">
import N8nLoading from '../N8nLoading';
import Markdown from 'markdown-it';
const markdownLink = require('markdown-it-link-attributes');
const markdownEmoji = require('markdown-it-emoji');
const markdownTasklists = require('markdown-it-task-lists');
import xss from 'xss';
import { escapeMarkdown } from '../../utils/markdown';
const DEFAULT_OPTIONS_MARKDOWN = {
html: true,
linkify: true,
typographer: true,
breaks: true,
};
const DEFAULT_OPTIONS_LINK_ATTRIBUTES = {
attrs: {
target: '_blank',
rel: 'noopener',
},
};
const DEFAULT_OPTIONS_TASKLISTS = {
label: true,
labelAfter: true,
};
interface IImage {
id: string;
url: string;
}
export default {
components: {
N8nLoading,
},
name: 'n8n-markdown',
props: {
content: {
type: String,
},
images: {
type: Array,
},
loading: {
type: Boolean,
},
loadingBlocks: {
type: Number,
default: 2,
},
loadingRows: {
type: Number,
default: () => {
return 3;
},
},
options: {
type: Object,
default() {
return {
markdown: DEFAULT_OPTIONS_MARKDOWN,
linkAttributes: DEFAULT_OPTIONS_LINK_ATTRIBUTES,
tasklists: DEFAULT_OPTIONS_TASKLISTS,
};
},
},
},
computed: {
htmlContent(): string {
if (!this.content) {
return '';
}
const imageUrls: { [key: string]: string } = {};
if (this.images) {
// @ts-ignore
this.images.forEach((image: IImage) => {
if (!image) {
// Happens if an image got deleted but the workflow
// still has a reference to it
return;
}
imageUrls[image.id] = image.url;
});
}
const fileIdRegex = new RegExp('fileId:([0-9]+)');
const html = this.md.render(escapeMarkdown(this.content));
const safeHtml = xss(html, {
onTagAttr: (tag, name, value, isWhiteAttr) => {
if (tag === 'img' && name === 'src') {
if (value.match(fileIdRegex)) {
const id = value.split('fileId:')[1];
return `src=${xss.friendlyAttrValue(imageUrls[id])}` || '';
}
if (!value.startsWith('https://')) {
return '';
}
}
// Return nothing, means keep the default handling measure
},
onTag: function (tag, html, options) {
if (tag === 'img' && html.includes(`alt="workflow-screenshot"`)) {
return '';
}
// return nothing, keep tag
},
});
return safeHtml;
},
},
data() {
return {
md: new Markdown(this.options.markdown)
.use(markdownLink, this.options.linkAttributes)
.use(markdownEmoji)
.use(markdownTasklists, this.options.tasklists),
};
},
};
</script>
<style lang="scss" module>
.markdown {
color: var(--color-text-base);
* {
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose);
}
h1, h2, h3, h4 {
margin-bottom: var(--spacing-s);
font-size: var(--font-size-m);
font-weight: var(--font-weight-bold);
}
h3, h4 {
font-weight: var(--font-weight-bold);
}
p,
span {
margin-bottom: var(--spacing-s);
}
ul, ol {
margin-bottom: var(--spacing-s);
padding-left: var(--spacing-m);
li {
margin-top: 0.25em;
}
}
pre {
margin-bottom: var(--spacing-s);
display: grid;
}
pre > code {
display: block;
padding: var(--spacing-s);
color: var(--color-text-dark);
background-color: var(--color-background-base);
overflow-x: auto;
}
li > code,
p > code {
padding: 0 var(--spacing-4xs);
color: var(--color-text-dark);
background-color: var(--color-background-base);
}
.label {
color: var(--color-text-base);
}
img {
width: 100%;
max-height: 90vh;
object-fit: cover;
border: var(--border-width-base) var(--color-foreground-base) var(--border-style-base);
border-radius: var(--border-radius-large);
}
blockquote {
padding-left: 10px;
font-style: italic;
border-left: var(--border-color-base) 2px solid;
}
}
.spacer {
margin: var(--spacing-2xl);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nMarkdown from './Markdown.vue';
export default N8nMarkdown;

View file

@ -0,0 +1,27 @@
import N8nTag from './Tag.vue';
export default {
title: 'Atoms/Tag',
component: N8nTag,
argTypes: {
text: {
control: {
control: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nTag,
},
template:
'<n8n-tag v-bind="$props"></n8n-tag>',
});
export const Tag = Template.bind({});
Tag.args = {
text: 'tag name',
};

View file

@ -0,0 +1,25 @@
<template functional>
<span :class="$style.tag" v-text="props.text" @click="(e) => listeners.click && listeners.click(e)" />
</template>
<script lang="ts">
export default {
name: 'n8n-tag',
props: {
text: {
type: String,
},
},
};
</script>
<style lang="scss" module>
.tag {
min-width: max-content;
padding: var(--spacing-4xs);
background-color: var(--color-foreground-base);
border-radius: var(--border-radius-base);
font-size: var(--font-size-2xs);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,3 @@
import Tag from './Tag.vue';
export default Tag;

View file

@ -0,0 +1,35 @@
import N8nTags from './Tags.vue';
export default {
title: 'Atoms/Tags',
component: N8nTags,
argTypes: {
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nTags,
},
template:
'<n8n-tags v-bind="$props"></n8n-tags>',
});
export const Tags = Template.bind({});
Tags.args = {
tags: [
{
id: 1,
name: 'very long tag name',
},
{
id: 2,
name: 'tag1',
},
{
id: 3,
name: 'tag2 yo',
},
],
};

View file

@ -0,0 +1,32 @@
<template functional>
<div :class="$style.tags">
<component :is="$options.components.N8nTag" v-for="tag in props.tags" :key="tag.id" :text="tag.name" @click="(e) => listeners.click && listeners.click(tag.id, e)"/>
</div>
</template>
<script lang="ts">
import N8nTag from '../N8nTag';
export default {
name: 'n8n-tags',
components: {
N8nTag,
},
props: {
tags: {
type: Array,
},
},
};
</script>
<style lang="scss" module>
.tags {
display: flex;
flex-wrap: wrap;
* {
margin: 0 var(--spacing-4xs) var(--spacing-4xs) 0;
}
}
</style>

View file

@ -0,0 +1,3 @@
import Tags from './Tags.vue';
export default Tags;

View file

@ -5,12 +5,16 @@ import N8nInput from './N8nInput';
import N8nInfoTip from './N8nInfoTip';
import N8nInputNumber from './N8nInputNumber';
import N8nInputLabel from './N8nInputLabel';
import N8nLoading from './N8nLoading';
import N8nHeading from './N8nHeading';
import N8nMarkdown from './N8nMarkdown';
import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nSquareButton from './N8nSquareButton';
import N8nTags from './N8nTags';
import N8nTag from './N8nTag';
import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption';
@ -23,12 +27,16 @@ export {
N8nInput,
N8nInputLabel,
N8nInputNumber,
N8nLoading,
N8nMarkdown,
N8nHeading,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nTags,
N8nTag,
N8nText,
N8nTooltip,
N8nOption,

View file

@ -6,4 +6,6 @@ declare module 'element-ui/lib/select';
declare module 'element-ui/lib/option';
declare module 'element-ui/lib/menu';
declare module 'element-ui/lib/menu-item';
declare module 'element-ui/lib/skeleton';
declare module 'element-ui/lib/skeleton-item';

View file

@ -0,0 +1,12 @@
export const escapeMarkdown = (html: string | undefined): string => {
if (!html) {
return '';
}
const escaped = html.replace(/</g, "&lt;").replace(/>/g, "&gt;");
// unescape greater than quotes at start of line
const withQuotes = escaped.replace(/^((\s)*(&gt;)+)+\s*/gm, (matches) => {
return matches.replace(/&gt;/g, '>');
});
return withQuotes;
};

View file

@ -22,6 +22,7 @@
// @use "./checkbox-group.scss";
@use "./switch.scss";
@use "./select.scss";
@use "./skeleton.scss";
@use "./button.scss";
// @use "./button-group.scss";
@use "./table.scss";

View file

@ -0,0 +1,82 @@
.el-skeleton {
width: 100%;
}
.el-skeleton__item {
width: 100%;
height: 16px;
border-radius: var(--border-radius-large);
background: var(--color-background-base);
display: inline-block;
}
.el-skeleton__button {
width: 162px;
height: 40px;
border-radius: 20px;
}
.el-skeleton__p {
width: 100%;
height: 16px;
margin-top: 16px;
}
.el-skeleton__h1 {
height: 20px;
margin-top: 14px;
}
.el-skeleton__image {
width: unset;
height: 500px !important;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
border-radius: 8px !important;
}
.el-skeleton__image svg {
width: 22%;
height: 22%;
fill: var(--color-info-tint-1);
}
.el-skeleton__first-line,
.el-skeleton__paragraph {
background: var(--color-background-base);
}
.el-skeleton.is-animated .el-skeleton__item {
background: -webkit-gradient(linear, left top, right top, color-stop(25%, #f2f2f2), color-stop(37%, #e6e6e6), color-stop(63%, #f2f2f2));
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
background-size: 400% 100%;
-webkit-animation: el-skeleton-loading 1.4s ease infinite;
animation: el-skeleton-loading 1.4s ease infinite;
}
@-webkit-keyframes el-skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
@keyframes el-skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}

View file

@ -31,7 +31,8 @@
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2",
"vue-i18n": "^8.26.7"
"vue-i18n": "^8.26.7",
"xss": "^1.0.10"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
@ -62,7 +63,7 @@
"babel-eslint": "^10.0.1",
"cross-env": "^7.0.2",
"dateformat": "^3.0.3",
"element-ui": "~2.13.0",
"element-ui": "~2.15.7",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-vue": "^7.16.0",
@ -89,6 +90,7 @@
"typescript": "~4.3.5",
"uuid": "^8.3.2",
"vue": "^2.6.11",
"vue-agile": "^2.0.0",
"vue-cli-plugin-webpack-bundle-analyzer": "^2.0.0",
"vue-json-pretty": "1.7.1",
"vue-prism-editor": "^0.3.0",

View file

@ -1,46 +1,125 @@
<template>
<div id="app">
<div id="header">
<router-view name="header"></router-view>
<div>
<LoadingView v-if="loading" />
<div v-else id="app">
<div id="header">
<router-view name="header"></router-view>
</div>
<div id="sidebar">
<router-view name="sidebar"></router-view>
</div>
<div id="content">
<router-view />
</div>
<Modals />
<Telemetry />
</div>
<div id="sidebar">
<router-view name="sidebar"></router-view>
</div>
<div id="content">
<router-view />
</div>
<Telemetry />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
import Telemetry from './components/Telemetry.vue';
import { HIRING_BANNER } from './constants';
import Modals from '@/components/Modals.vue';
import LoadingView from './views/LoadingView.vue';
import mixins from 'vue-typed-mixins';
import { showMessage } from './components/mixins/showMessage';
export default Vue.extend({
export default mixins(showMessage).extend({
name: 'App',
components: {
LoadingView,
Modals,
Telemetry,
},
mounted() {
this.$telemetry.page('Editor', this.$route.name);
computed: {
...mapGetters('settings', ['isInternalUser', 'isTemplatesEnabled', 'isTemplatesEndpointReachable']),
isRootPath(): boolean {
return this.$route.path === '/';
},
},
data() {
return {
loading: true,
};
},
methods: {
async initSettings(): Promise<void> {
try {
await this.$store.dispatch('settings/getSettings');
} catch (e) {
this.$showToast({
title: this.$locale.baseText('settings.errors.connectionError.title'),
message: this.$locale.baseText('settings.errors.connectionError.message'),
type: 'error',
duration: 0,
});
throw e;
}
},
async initTemplates(): Promise<void> {
try {
const templatesPromise = this.$store.dispatch('settings/testTemplatesEndpoint');
if (this.isRootPath) { // only delay loading to determine redirect
await templatesPromise;
}
} catch (e) {
}
},
async initialize(): Promise<void> {
await this.initSettings();
await this.initTemplates();
if (!this.isInternalUser && this.$route.name !== 'WorkflowDemo') {
console.log(HIRING_BANNER); // eslint-disable-line no-console
}
},
trackPage() {
this.$store.commit('ui/setCurrentView', this.$route.name);
if (this.$route && this.$route.meta && this.$route.meta.templatesEnabled) {
this.$store.commit('templates/setSessionId');
}
else {
this.$store.commit('templates/resetSessionId'); // reset telemetry session id when user leaves template pages
}
this.$telemetry.page('Editor', this.$route);
},
},
async mounted() {
await this.initialize();
if (this.isTemplatesEnabled && this.isTemplatesEndpointReachable && this.isRootPath) {
this.$router.replace({ name: 'TemplatesSearchView'});
} else if (this.isRootPath) {
this.$router.replace({ name: 'NodeViewNew'});
}
else if (!this.isTemplatesEnabled && this.$route.meta && this.$route.meta.templatesEnabled) {
this.$router.replace({ name: 'NodeViewNew'});
}
this.loading = false;
this.trackPage();
this.$externalHooks().run('app.mount');
},
watch: {
'$route'(route) {
this.$telemetry.page('Editor', route.name);
'$route'() {
this.trackPage();
},
},
});
</script>
<style lang="scss">
#app {
padding: 0;
margin: 0 auto;
}
#content {
background-color: var(--color-background-light);
position: relative;
top: 0;
left: 0;

View file

@ -253,7 +253,7 @@ export interface IWorkflowDataUpdate {
}
export interface IWorkflowTemplate {
id: string;
id: number;
name: string;
workflow: {
nodes: INodeUi[];
@ -514,6 +514,64 @@ export interface IN8nPromptResponse {
updated: boolean;
}
export interface ITemplatesCollection {
id: number;
name: string;
nodes: ITemplatesNode[];
workflows: Array<{id: number}>;
}
interface ITemplatesImage {
id: number;
url: string;
}
interface ITemplatesCollectionExtended extends ITemplatesCollection {
description: string | null;
image: ITemplatesImage[];
categories: ITemplatesCategory[];
createdAt: string;
}
export interface ITemplatesCollectionFull extends ITemplatesCollectionExtended {
full: true;
}
export interface ITemplatesCollectionResponse extends ITemplatesCollectionExtended {
workflows: ITemplatesWorkflow[];
}
export interface ITemplatesWorkflow {
id: number;
createdAt: string;
name: string;
nodes: ITemplatesNode[];
totalViews: number;
user: {
username: string;
};
}
export interface ITemplatesWorkflowResponse extends ITemplatesWorkflow, IWorkflowTemplate {
description: string | null;
image: ITemplatesImage[];
categories: ITemplatesCategory[];
}
export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
full: true;
}
export interface ITemplatesQuery {
categories: number[];
search: string;
}
export interface ITemplatesCategory {
id: number;
name: string;
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@ -538,6 +596,11 @@ export interface IN8nUISettings {
telemetry: ITelemetrySettings;
defaultLocale: string;
logLevel: ILogLevel;
deploymentType: string;
templates: {
enabled: boolean;
host: string;
};
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -644,7 +707,13 @@ export interface IVersionNode {
icon?: string;
fileBuffer?: string;
};
typeVersion?: number;
}
export interface ITemplatesNode extends IVersionNode {
categories?: ITemplatesCategory[];
}
export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
@ -717,6 +786,7 @@ export interface IUiState {
[key: string]: IModalState;
};
isPageLoading: boolean;
currentView: string;
}
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';
@ -724,6 +794,27 @@ export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';
export interface ISettingsState {
settings: IN8nUISettings;
promptsData: IN8nPrompts;
templatesEndpointHealthy: boolean;
}
export interface ITemplateState {
categories: {[id: string]: ITemplatesCategory};
collections: {[id: string]: ITemplatesCollection};
workflows: {[id: string]: ITemplatesWorkflow};
workflowSearches: {
[search: string]: {
workflowIds: string[];
totalWorkflows: number;
loadingMore?: boolean;
}
};
collectionSearches: {
[search: string]: {
collectionIds: string[];
}
};
currentSessionId: string;
previousSessionId: string;
}
export interface IVersionsState {

View file

@ -1,7 +1,7 @@
import { IDataObject } from 'n8n-workflow';
import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest, get, post } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
import { N8N_IO_BASE_URL } from '@/constants';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return await makeRestApiRequest(context, 'GET', '/settings');
@ -12,14 +12,14 @@ export async function submitPersonalizationSurvey(context: IRestApiContext, para
}
export async function getPromptsData(instanceId: string): Promise<IN8nPrompts> {
return await get(TEMPLATES_BASE_URL, '/prompts', {}, {'n8n-instance-id': instanceId});
return await get(N8N_IO_BASE_URL, '/prompts', {}, {'n8n-instance-id': instanceId});
}
export async function submitContactInfo(instanceId: string, email: string): Promise<void> {
return await post(TEMPLATES_BASE_URL, '/prompt', { email }, {'n8n-instance-id': instanceId});
return await post(N8N_IO_BASE_URL, '/prompt', { email }, {'n8n-instance-id': instanceId});
}
export async function submitValueSurvey(instanceId: string, params: IN8nValueSurveyData): Promise<IN8nPrompts> {
return await post(TEMPLATES_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId});
return await post(N8N_IO_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId});
}

View file

@ -0,0 +1,39 @@
import { ITemplatesCategory, ITemplatesCollection, ITemplatesQuery, ITemplatesWorkflow, ITemplatesCollectionResponse, ITemplatesWorkflowResponse, IWorkflowTemplate } from '@/Interface';
import { IDataObject } from 'n8n-workflow';
import { get } from './helpers';
function stringifyArray(arr: number[]) {
return arr.join(',');
}
export function testHealthEndpoint(apiEndpoint: string) {
return get(apiEndpoint, '/health');
}
export function getCategories(apiEndpoint: string, headers?: IDataObject): Promise<{categories: ITemplatesCategory[]}> {
return get(apiEndpoint, '/templates/categories', undefined, headers);
}
export async function getCollections(apiEndpoint: string, query: ITemplatesQuery, headers?: IDataObject): Promise<{collections: ITemplatesCollection[]}> {
return await get(apiEndpoint, '/templates/collections', {category: stringifyArray(query.categories || []), search: query.search}, headers);
}
export async function getWorkflows(
apiEndpoint: string,
query: {skip: number, limit: number, categories: number[], search: string},
headers?: IDataObject,
): Promise<{totalWorkflows: number, workflows: ITemplatesWorkflow[]}> {
return get(apiEndpoint, '/templates/workflows', {skip: query.skip, rows: query.limit, category: stringifyArray(query.categories), search: query.search}, headers);
}
export async function getCollectionById(apiEndpoint: string, collectionId: string, headers?: IDataObject): Promise<{collection: ITemplatesCollectionResponse}> {
return await get(apiEndpoint, `/templates/collections/${collectionId}`, undefined, headers);
}
export async function getTemplateById(apiEndpoint: string, templateId: string, headers?: IDataObject): Promise<{workflow: ITemplatesWorkflowResponse}> {
return await get(apiEndpoint, `/templates/workflows/${templateId}`, undefined, headers);
}
export async function getWorkflowTemplate(apiEndpoint: string, templateId: string, headers?: IDataObject): Promise<IWorkflowTemplate> {
return await get(apiEndpoint, `/workflows/templates/${templateId}`, undefined, headers);
}

View file

@ -1,11 +1,7 @@
import { IRestApiContext, IWorkflowTemplate } from '@/Interface';
import { makeRestApiRequest, get } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
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 } : {});
}
export async function getWorkflowTemplate(templateId: string): Promise<IWorkflowTemplate> {
return await get(TEMPLATES_BASE_URL, `/workflows/templates/${templateId}`);
}

View file

@ -0,0 +1,80 @@
<template>
<div
:class="$style.card"
@click="(e) => $emit('click', e)"
>
<div :class="$style.container">
<span
v-if="!loading"
v-text="title"
:class="$style.title"
/>
<n8n-loading :loading="loading" :rows="3" variant="p" />
<div :class="$style.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers).extend({
name: 'Card',
props: {
loading: {
type: Boolean,
},
title: {
type: String,
},
},
});
</script>
<style lang="scss" module>
.card {
width: 240px !important;
height: 140px;
border-radius: var(--border-radius-large);
border: $--version-card-border;
margin-right: var(--spacing-2xs);
background-color: var(--color-background-xlight);
padding: var(--spacing-s);
cursor: pointer;
&:last-child {
margin-right: var(--spacing-5xs);
}
&:hover {
box-shadow: 0 2px 4px rgba(68,28,23,0.07);
}
}
.title {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
font-size: var(--font-size-s);
line-height: var(--font-line-height-regular);
font-weight: var(--font-weight-bold);
overflow: hidden;
white-space: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.footer {
display: flex;
justify-content: space-between;
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<Card
:loading="loading"
:title="collection.name"
@click="onClick"
>
<template v-slot:footer>
<n8n-text size="small" color="text-light">
{{ collection.workflows.length }}
{{ $locale.baseText('templates.workflows') }}
</n8n-text>
<NodeList :nodes="collection.nodes" :showMore="false" />
</template>
</Card>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import Card from '@/components/Card.vue';
import mixins from 'vue-typed-mixins';
import NodeList from '@/components/NodeList.vue';
export default mixins(genericHelpers).extend({
name: 'CollectionCard',
props: {
loading: {
type: Boolean,
},
collection: {
type: Object,
},
},
components: {
Card,
NodeList,
},
methods: {
onClick(e: MouseEvent) {
this.$emit('click', e);
},
},
});
</script>
<style lang="scss" module>
</style>

View file

@ -0,0 +1,181 @@
<template>
<div :class="$style.container" v-show="loading || collections.length">
<agile ref="slider" :dots="false" :navButtons="false" :infinite="false" :slides-to-show="4" @after-change="updateCarouselScroll">
<Card v-for="n in (loading ? 4: 0)" :key="`loading-${n}`" :loading="loading" />
<CollectionCard
v-for="collection in (loading? []: collections)"
:key="collection.id"
:collection="collection"
@click="(e) => onCardClick(e, collection.id)"
/>
</agile>
<button v-show="carouselScrollPosition > 0" :class="$style.leftButton" @click="scrollLeft">
<font-awesome-icon icon="chevron-left" />
</button>
<button v-show="!scrollEnd" :class="$style.rightButton" @click="scrollRight">
<font-awesome-icon icon="chevron-right" />
</button>
</div>
</template>
<script lang="ts">
import Card from '@/components/Card.vue';
import CollectionCard from '@/components/CollectionCard.vue';
import VueAgile from 'vue-agile';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers).extend({
name: 'CollectionsCarousel',
props: {
collections: {
type: Array,
},
loading: {
type: Boolean,
},
},
watch: {
collections() {
setTimeout(() => {
this.updateCarouselScroll();
}, 0);
},
loading() {
setTimeout(() => {
this.updateCarouselScroll();
}, 0);
},
},
components: {
Card,
CollectionCard,
VueAgile,
},
data() {
return {
carouselScrollPosition: 0,
cardWidth: 240,
scrollEnd: false,
listElement: null as null | Element,
};
},
methods: {
updateCarouselScroll() {
if (this.listElement) {
this.carouselScrollPosition = Number(this.listElement.scrollLeft.toFixed());
const width = this.listElement.clientWidth;
const scrollWidth = this.listElement.scrollWidth;
const scrollLeft = this.carouselScrollPosition;
this.scrollEnd = scrollWidth - width <= scrollLeft + 7;
}
},
onCardClick(event: MouseEvent, id: string) {
this.$emit('openCollection', {event, id});
},
scrollLeft() {
if (this.listElement) {
this.listElement.scrollBy({ left: -(this.cardWidth * 2), top: 0, behavior: 'smooth' });
}
},
scrollRight() {
if (this.listElement) {
this.listElement.scrollBy({ left: this.cardWidth * 2, top: 0, behavior: 'smooth' });
}
},
},
mounted() {
this.$nextTick(() => {
const slider = this.$refs.slider;
if (!slider) {
return;
}
// @ts-ignore
this.listElement = slider.$el.querySelector('.agile__list');
if (this.listElement) {
this.listElement.addEventListener('scroll', this.updateCarouselScroll);
}
});
},
beforeDestroy() {
if (this.$refs.slider) {
// @ts-ignore
this.$refs.slider.destroy();
}
window.removeEventListener('scroll', this.updateCarouselScroll);
},
});
</script>
<style lang="scss" module>
.container {
position: relative;
}
.button {
width: 28px;
height: 37px;
position: absolute;
top: 35%;
border-radius: var(--border-radius-large);
border: var(--border-base);
background-color: #fbfcfe;
cursor: pointer;
&:after {
content: '';
width: 40px;
height: 140px;
top: -55px;
position: absolute;
}
svg {
color: var(--color-foreground-xdark);
}
}
.leftButton {
composes: button;
left: -30px;
&:after {
left: 27px;
background: linear-gradient(270deg, rgba(255, 255, 255, 0.25) 0%, rgba(248, 249, 251, 1) 86%);
}
}
.rightButton {
composes: button;
right: -30px;
&:after {
right: 27px;
background: linear-gradient(270deg,rgba(248, 249, 251, 1) 25%, rgba(255, 255, 255, 0.25) 100%);
}
}
</style>
<style lang="scss">
.agile {
&__list {
width: 100%;
padding-bottom: var(--spacing-2xs);
overflow-x: scroll;
transition: all 1s ease-in-out;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 6px;
background-color: var(--color-foreground-dark);
}
}
&__track {
width: 50px;
}
}
</style>

View file

@ -16,7 +16,7 @@
/>
</div>
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table :data="credentialsToDisplay" v-loading="loading" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table-column property="name" :label="$locale.baseText('credentialsList.name')" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" :label="$locale.baseText('credentialsList.type')" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" :label="$locale.baseText('credentialsList.created')" class-name="clickable" sortable></el-table-column>
@ -64,11 +64,12 @@ export default mixins(
data() {
return {
CREDENTIAL_LIST_MODAL_KEY,
loading: true,
};
},
computed: {
...mapGetters('credentials', ['allCredentials']),
credentialsToDisplay() {
credentialsToDisplay(): ICredentialsResponse[] {
return this.allCredentials.reduce((accu: ICredentialsResponse[], cred: ICredentialsResponse) => {
const type = this.$store.getters['credentials/getCredentialTypeByName'](cred.type);
@ -85,7 +86,17 @@ export default mixins(
}, []);
},
},
mounted() {
async mounted() {
try {
await Promise.all([
await this.$store.dispatch('credentials/fetchCredentialTypes'),
await this.$store.dispatch('credentials/fetchAllCredentials'),
]);
} catch (e) {
this.$showError(e, this.$locale.baseText('credentialsList.errorLoadingCredentials'));
}
this.loading = false;
this.$externalHooks().run('credentialsList.mounted');
this.$telemetry.track('User opened Credentials panel', { workflow_id: this.$store.getters.workflowId });
},

View file

@ -4,7 +4,9 @@
:eventBus="modalBus"
width="50%"
:center="true"
:loading="loading"
maxWidth="460px"
minHeight="250px"
>
<template slot="header">
<h2 :class="$style.title">{{ $locale.baseText('credentialSelectModal.addNewCredential') }}</h2>
@ -58,7 +60,13 @@ export default Vue.extend({
components: {
Modal,
},
mounted() {
async mounted() {
try {
await this.$store.dispatch('credentials/fetchCredentialTypes');
} catch (e) {
}
this.loading = false;
setTimeout(() => {
const element = this.$refs.select as HTMLSelectElement;
if (element) {
@ -70,6 +78,7 @@ export default Vue.extend({
return {
modalBus: new Vue(),
selected: '',
loading: true,
CREDENTIAL_SELECT_MODAL_KEY,
};
},

View file

@ -0,0 +1,55 @@
<template>
<div :class="$style.wrapper" @click="navigateTo">
<font-awesome-icon :class="$style.icon" icon="arrow-left" />
<div :class="$style.text" v-text="$locale.baseText('template.buttons.goBackButton')" />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'TemplateList',
data() {
return {
routeHasHistory: false,
};
},
methods: {
navigateTo() {
if (this.routeHasHistory) this.$router.go(-1);
else this.$router.push({ name: 'TemplatesSearchView' });
},
},
mounted() {
window.history.state ? this.routeHasHistory = true : this.routeHasHistory = false;
},
});
</script>
<style lang="scss" module>
.wrapper {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
.icon,
.text {
color: var(--color-primary);
}
}
}
.icon {
margin-right: var(--spacing-2xs);
color: var(--color-foreground-dark);
font-size: var(--font-size-m);
}
.text {
font-size: var(--font-size-s);
line-height: var(--font-line-height-loose);
color: var(--color-text-base);
}
</style>

View file

@ -0,0 +1,176 @@
<template>
<div
:class="$style.wrapper"
:style="iconStyleData"
@click="(e) => $emit('click')"
@mouseover="showTooltip = true"
@mouseleave="showTooltip = false"
>
<div :class="$style.tooltip">
<n8n-tooltip placement="top" :manual="true" :value="showTooltip">
<div slot="content" v-text="nodeType.displayName"></div>
<span />
</n8n-tooltip>
</div>
<div v-if="nodeIconData !== null" :class="$style.icon" title="">
<div :class="$style.iconWrapper" :style="iconStyleData">
<div v-if="nodeIconData !== null" :class="$style.icon">
<img
v-if="nodeIconData.type === 'file'"
:src="nodeIconData.fileBuffer || nodeIconData.path"
:style="imageStyleData"
/>
<font-awesome-icon
v-else
:icon="nodeIconData.icon || nodeIconData.path"
:style="fontStyleData"
/>
</div>
<div v-else class="node-icon-placeholder">
{{ nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
</div>
</div>
</div>
<div v-else :class="$style.placeholder">
{{ nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { ITemplatesNode } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
interface NodeIconData {
type: string;
path?: string;
fileExtension?: string;
fileBuffer?: string;
}
export default Vue.extend({
name: 'HoverableNodeIcon',
props: {
circle: {
type: Boolean,
default: false,
},
clickButton: {
type: Function,
},
disabled: {
type: Boolean,
default: false,
},
nodeType: {
type: Object,
},
size: {
type: Number,
},
},
computed: {
fontStyleData(): object {
return {
'max-width': this.size + 'px',
};
},
iconStyleData(): object {
const nodeType = this.nodeType as ITemplatesNode | null;
const color = nodeType ? nodeType.defaults && nodeType!.defaults.color : '';
if (!this.size) {
return { color };
}
return {
color,
width: this.size + 'px',
height: this.size + 'px',
'font-size': this.size + 'px',
'line-height': this.size + 'px',
'border-radius': this.circle ? '50%' : '2px',
...(this.disabled && {
color: '#ccc',
'-webkit-filter': 'contrast(40%) brightness(1.5) grayscale(100%)',
filter: 'contrast(40%) brightness(1.5) grayscale(100%)',
}),
};
},
imageStyleData(): object {
return {
width: '100%',
'max-width': '100%',
'max-height': '100%',
};
},
nodeIconData(): null | NodeIconData {
const nodeType = this.nodeType as INodeTypeDescription | ITemplatesNode | null;
if (nodeType === null) {
return null;
}
if ((nodeType as ITemplatesNode).iconData) {
return (nodeType as ITemplatesNode).iconData;
}
const restUrl = this.$store.getters.getRestUrl;
if (nodeType.icon) {
let type, path;
[type, path] = nodeType.icon.split(':');
const returnData: NodeIconData = {
type,
path,
};
if (type === 'file') {
returnData.path = restUrl + '/node-icon/' + nodeType.name;
returnData.fileExtension = path.split('.').slice(-1).join();
}
return returnData;
}
return null;
},
},
data() {
return {
showTooltip: false,
};
},
});
</script>
<style lang="scss" module>
.wrapper {
cursor: pointer;
z-index: 2000;
}
.icon {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.iconWrapper {
svg {
height: 100%;
width: 100%;
}
}
.placeholder {
text-align: center;
}
.tooltip {
left: 10px;
position: relative;
z-index: 9999;
}
</style>

View file

@ -30,49 +30,55 @@
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.new') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item v-if="isTemplatesEnabled" index="template-new">
<template slot="title">
<font-awesome-icon icon="box-open"/>&nbsp;
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.newTemplate') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-save">
<n8n-menu-item index="workflow-save" :disabled="!onWorkflowPage">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.save') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<n8n-menu-item index="workflow-duplicate" :disabled="!onWorkflowPage || !currentWorkflow">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.duplicate') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<n8n-menu-item index="workflow-delete" :disabled="!onWorkflowPage || !currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.delete') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-download">
<n8n-menu-item index="workflow-download" :disabled="!onWorkflowPage">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.download') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-import-url">
<n8n-menu-item index="workflow-import-url" :disabled="!onWorkflowPage">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromUrl') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-import-file">
<n8n-menu-item index="workflow-import-file" :disabled="!onWorkflowPage">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromFile') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<n8n-menu-item index="workflow-settings" :disabled="!onWorkflowPage || !currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.settings') }}</span>
@ -80,6 +86,11 @@
</n8n-menu-item>
</el-submenu>
<n8n-menu-item v-if="isTemplatesEnabled" index="templates">
<font-awesome-icon icon="box-open"/>&nbsp;
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.templates') }}</span>
</n8n-menu-item>
<el-submenu index="credentials" :title="$locale.baseText('mainSidebar.credentials')" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="key"/>&nbsp;
@ -165,7 +176,19 @@ import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue';
import { CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, EXECUTIONS_MODAL_KEY } from '@/constants';
import {
CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DUPLICATE_MODAL_KEY,
MODAL_CANCEL,
MODAL_CLOSE,
MODAL_CONFIRMED,
TAGS_MANAGER_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_OPEN_MODAL_KEY,
EXECUTIONS_MODAL_KEY,
} from '@/constants';
export default mixins(
genericHelpers,
@ -200,6 +223,9 @@ export default mixins(
'hasVersionUpdates',
'nextVersions',
]),
...mapGetters('settings', [
'isTemplatesEnabled',
]),
helpMenuItems (): object[] {
return [
{
@ -286,6 +312,9 @@ export default mixins(
sidebarMenuBottomItems(): IMenuItem[] {
return this.$store.getters.sidebarMenuItems.filter((item: IMenuItem) => item.position === 'bottom');
},
onWorkflowPage(): boolean {
return this.$route.meta && this.$route.meta.nodeView;
},
},
methods: {
trackHelpItemClick (itemType: string) {
@ -449,14 +478,30 @@ export default mixins(
} else if (key === 'workflow-new') {
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(
const confirmModal = await this.confirmModal(
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.message'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.headline'),
'warning',
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.confirmButtonText'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.cancelButtonText'),
true,
);
if (importConfirm === true) {
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
if (this.$router.currentRoute.name === 'NodeViewNew') {
this.$root.$emit('newWorkflow');
} else {
this.$router.push({ name: 'NodeViewNew' });
}
this.$showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
type: 'success',
});
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
if (this.$router.currentRoute.name === 'NodeViewNew') {
this.$root.$emit('newWorkflow');
@ -468,6 +513,8 @@ export default mixins(
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
type: 'success',
});
} else if (confirmModal === MODAL_CLOSE) {
return;
}
} else {
if (this.$router.currentRoute.name !== 'NodeViewNew') {
@ -480,6 +527,10 @@ export default mixins(
});
}
this.$titleReset();
} else if (key === 'templates' || key === 'template-new') {
if (this.$router.currentRoute.name !== 'TemplatesSearchView') {
this.$router.push({ name: 'TemplatesSearchView' });
}
} else if (key === 'credentials-open') {
this.$store.dispatch('ui/openModal', CREDENTIAL_LIST_MODAL_KEY);
} else if (key === 'credentials-new') {
@ -554,7 +605,8 @@ export default mixins(
}
.item-title {
position: absolute;
left: 73px;
left: 56px;
font-size: var(--font-size-s);
}
.item-title-root {
position: absolute;
@ -563,6 +615,12 @@ export default mixins(
}
}
.el-menu--inline {
.el-menu-item {
padding-left: 30px!important;
}
}
}
.el-menu-item {

View file

@ -96,8 +96,8 @@ export default Vue.extend({
},
},
watch: {
nodeTypes(newList, prevList) {
if (prevList.length === 0) {
nodeTypes(newList) {
if (newList.length !== this.allNodeTypes.length) {
this.allNodeTypes = newList;
}
},

View file

@ -0,0 +1,111 @@
<template>
<div :class="$style.list">
<div v-for="node in slicedNodes" :class="[$style.container, $style[size]]" :key="node.name">
<HoverableNodeIcon :nodeType="node" :size="size === 'md'? 24: 18" :title="node.name" />
</div>
<div :class="[$style.button, size === 'md' ? $style.buttonMd : $style.buttonSm]" v-if="filteredCoreNodes.length > limit + 1">
+{{ hiddenNodes }}
</div>
</div>
</template>
<script lang="ts">
import HoverableNodeIcon from '@/components/HoverableNodeIcon.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { ITemplatesNode } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { filterTemplateNodes } from './helpers';
export default mixins(genericHelpers).extend({
name: 'NodeList',
props: {
nodes: {
type: Array,
},
limit: {
type: Number,
default: 4,
},
size: {
type: String,
default: 'sm',
},
},
components: {
HoverableNodeIcon,
},
computed: {
filteredCoreNodes() {
return filterTemplateNodes(this.nodes as ITemplatesNode[]);
},
hiddenNodes(): number {
return this.filteredCoreNodes.length - this.countNodesToBeSliced(this.filteredCoreNodes);
},
slicedNodes(): ITemplatesNode[] {
return this.filteredCoreNodes.slice(0, this.countNodesToBeSliced(this.filteredCoreNodes));
},
},
methods: {
countNodesToBeSliced(nodes: ITemplatesNode[]): number {
if (nodes.length > this.limit) {
return this.limit - 1;
} else {
return this.limit;
}
},
},
});
</script>
<style lang="scss" module>
.list {
max-width: 100px;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
.container {
position: relative;
display: block;
}
.sm {
margin-left: var(--spacing-2xs);
}
.md {
margin-left: var(--spacing-xs);
}
.button {
top: 0px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-background-light);
border: 1px var(--color-foreground-base) solid;
border-radius: var(--border-radius-base);
font-size: 10px;
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
}
.buttonSm {
margin-left: var(--spacing-2xs);
width: 20px;
min-width: 20px;
height: 20px;
}
.buttonMd {
margin-left: var(--spacing-xs);
width: 24px;
min-width: 24px;
height: 24px;
}
</style>

View file

@ -143,11 +143,12 @@
:value="displayValue"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:placeholder="$locale.baseText('parameterInput.select')"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
>
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="getOptionsOptionDisplayName(option)">
<div class="list-option">
@ -302,6 +303,9 @@ export default mixins(
},
},
computed: {
areExpressionsDisabled(): boolean {
return this.$store.getters['ui/areExpressionsDisabled'];
},
codeAutocomplete (): string | undefined {
return this.getArgument('codeAutocomplete') as string | undefined;
},
@ -419,6 +423,10 @@ export default mixins(
return false;
},
expressionValueComputed (): NodeParameterValue | null {
if (this.areExpressionsDisabled) {
return this.value;
}
if (this.node === null) {
return null;
}
@ -661,6 +669,10 @@ export default mixins(
this.valueChanged(value);
},
openExpressionEdit() {
if (this.areExpressionsDisabled) {
return;
}
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();

View file

@ -9,15 +9,43 @@ import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'Telemetry',
data() {
return {
initialised: false,
};
},
computed: {
...mapGetters('settings', ['telemetry']),
isTelemeteryEnabledOnRoute(): boolean {
return this.$route.meta && this.$route.meta.telemetry ? !this.$route.meta.telemetry.disabled: true;
},
},
mounted() {
this.init();
},
methods: {
init() {
if (this.initialised || !this.isTelemeteryEnabledOnRoute) {
return;
}
const opts = this.telemetry;
if (opts && opts.enabled) {
this.initialised = true;
const instanceId = this.$store.getters.instanceId;
const logLevel = this.$store.getters['settings/logLevel'];
this.$telemetry.init(opts, {instanceId, logLevel, store: this.$store});
}
},
},
watch: {
telemetry(opts) {
if (opts && opts.enabled) {
this.$telemetry.init(opts, this.$store.getters.instanceId, this.$store.getters['settings/logLevel']);
isTelemeteryEnabledOnRoute(enabled) {
if (enabled) {
this.init();
}
},
telemetry() {
this.init();
},
},
});
</script>

View file

@ -0,0 +1,176 @@
<template>
<div
:class="[$style.card, lastItem && $style.last, firstItem && $style.first, !loading && $style.loaded]"
@click="onCardClick"
>
<div :class="$style.loading" v-if="loading">
<n8n-loading :rows="2" :shrinkLast="false" :loading="loading" />
</div>
<div v-else>
<n8n-heading :bold="true" size="small">{{ workflow.name }}</n8n-heading>
<div :class="$style.content">
<span v-if="workflow.totalViews">
<n8n-text size="small" color="text-light">
<font-awesome-icon icon="eye" />
{{ abbreviateNumber(workflow.totalViews) }}
</n8n-text>
</span>
<div v-if="workflow.totalViews" :class="$style.line" v-text="'|'" />
<n8n-text size="small" color="text-light">
<TimeAgo :date="workflow.createdAt" />
</n8n-text>
<div v-if="workflow.user" :class="$style.line" v-text="'|'" />
<n8n-text v-if="workflow.user" size="small" color="text-light">By {{ workflow.user.username }}</n8n-text>
</div>
</div>
<div :class="[$style.nodesContainer, useWorkflowButton && $style.hideOnHover]" v-if="!loading">
<NodeList v-if="workflow.nodes" :nodes="workflow.nodes" :limit="nodesToBeShown" size="md" />
</div>
<div :class="$style.buttonContainer" v-if="useWorkflowButton">
<n8n-button
v-if="useWorkflowButton"
type="outline"
label="Use workflow"
@click.stop="onUseWorkflowClick"
/>
</div>
</div>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
import { filterTemplateNodes, abbreviateNumber } from './helpers';
import NodeList from './NodeList.vue';
export default mixins(genericHelpers).extend({
name: 'TemplateCard',
props: {
lastItem: {
type: Boolean,
default: false,
},
firstItem: {
type: Boolean,
default: false,
},
workflow: {
type: Object,
},
useWorkflowButton: {
type: Boolean,
},
loading: {
type: Boolean,
},
},
components: {
NodeList,
},
data() {
return {
nodesToBeShown: 5,
};
},
methods: {
filterTemplateNodes,
abbreviateNumber,
countNodesToBeSliced(nodes: []): number {
if (nodes.length > this.nodesToBeShown) {
return this.nodesToBeShown - 1;
} else {
return this.nodesToBeShown;
}
},
onUseWorkflowClick(e: MouseEvent) {
this.$emit('useWorkflow', e);
},
onCardClick(e: MouseEvent) {
this.$emit('click', e);
},
},
});
</script>
<style lang="scss" module>
.nodes {
display: flex;
justify-content: center;
align-content: center;
flex-direction: row;
}
.icon {
margin-left: var(--spacing-xs);
}
.card {
position: relative;
border-left: var(--border-base);
border-right: var(--border-base);
border-bottom: var(--border-base);
background-color: var(--color-background-xlight);
display: flex;
padding: 0 var(--spacing-s) var(--spacing-s) var(--spacing-s);
background-color: var(--color-background-xlight);
cursor: pointer;
&:hover {
.hideOnHover {
visibility: hidden;
}
.buttonContainer {
display: block;
}
}
}
.buttonContainer {
display: none;
position: absolute;
right: 10px;
top: 30%;
}
.loaded {
padding-top: var(--spacing-s);
}
.first {
border-top: var(--border-base);
border-top-right-radius: var(--border-radius-large);
border-top-left-radius: var(--border-radius-large);
}
.last {
border-bottom-right-radius: var(--border-radius-large);
border-bottom-left-radius: var(--border-radius-large);
}
.content {
display: flex;
align-items: center;
}
.line {
padding: 0 6px;
color: var(--color-foreground-base);
font-size: var(--font-size-2xs);
}
.loading {
width: 100%;
background-color: var(--color-background-xlight);
}
.nodesContainer {
min-width: 175px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
}
</style>

View file

@ -0,0 +1,103 @@
<template>
<div>
<n8n-loading :loading="loading" :rows="5" variant="p" />
<template-details-block v-if="!loading && template.nodes.length > 0" :title="blockTitle">
<div :class="$style.icons">
<div
v-for="node in filterTemplateNodes(template.nodes)"
:key="node.name"
:class="$style.icon"
>
<HoverableNodeIcon
:nodeType="node"
:title="node.name"
:size="24"
@click="redirectToSearchPage(node)"
/>
</div>
</div>
</template-details-block>
<template-details-block
v-if="!loading && template.categories.length > 0"
:title="$locale.baseText('template.details.categories')"
>
<n8n-tags :tags="template.categories" @click="redirectToCategory" />
</template-details-block>
<template-details-block v-if="!loading" :title="$locale.baseText('template.details.details')">
<div :class="$style.text">
<n8n-text size="small" color="text-base">
{{ $locale.baseText('template.details.created') }}
<TimeAgo :date="template.createdAt" />
<span>{{ $locale.baseText('template.details.by') }}</span>
<span v-if="template.user"> {{ template.user.username }}</span>
<span v-else> n8n team</span>
</n8n-text>
</div>
<div :class="$style.text">
<n8n-text v-if="template.totalViews !== 0" size="small" color="text-base">
{{ $locale.baseText('template.details.viewed') }}
{{ abbreviateNumber(template.totalViews) }}
{{ $locale.baseText('template.details.times') }}
</n8n-text>
</div>
</template-details-block>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import TemplateDetailsBlock from '@/components/TemplateDetailsBlock.vue';
import HoverableNodeIcon from '@/components/HoverableNodeIcon.vue';
import { abbreviateNumber, filterTemplateNodes } from '@/components/helpers';
import { ITemplatesNode } from '@/Interface';
export default Vue.extend({
name: 'TemplateDetails',
props: {
blockTitle: {
type: String,
},
loading: {
type: Boolean,
},
template: {
type: Object,
},
},
components: {
HoverableNodeIcon,
TemplateDetailsBlock,
},
methods: {
abbreviateNumber,
filterTemplateNodes,
redirectToCategory(id: string) {
this.$store.commit('templates/resetSessionId');
this.$router.push(`/templates?categories=${id}`);
},
redirectToSearchPage(node: ITemplatesNode) {
this.$store.commit('templates/resetSessionId');
this.$router.push(`/templates?search=${node.displayName}`);
},
},
});
</script>
<style lang="scss" module>
.icons {
display: flex;
flex-wrap: wrap;
}
.icon {
margin-right: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
}
.text {
padding-bottom: var(--spacing-xs);
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div :class="$style.block">
<div :class="$style.header">
<n8n-heading tag="h3" size="small" color="text-base">{{ title }}</n8n-heading>
</div>
<div :class="$style.content">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'TemplateDetailsBlock',
props: {
title: {
type: String,
},
},
});
</script>
<style lang="scss" module>
.block {
padding-bottom: var(--spacing-xl);
}
.header {
padding: 0 0 var(--spacing-4xs);
border-bottom: var(--border-base);
}
.content {
padding: var(--spacing-xs) 0 0;
}
</style>

View file

@ -0,0 +1,151 @@
<template>
<div :class="$style.filters" class="template-filters">
<div :class="$style.title" v-text="$locale.baseText('templates.categoriesHeading')" />
<div v-if="loading" :class="$style.list">
<n8n-loading :loading="loading" :rows="expandLimit" />
</div>
<ul v-if="!loading" :class="$style.categories">
<li :class="$style.item">
<el-checkbox
:label="$locale.baseText('templates.allCategories')"
:value="allSelected"
@change="(value) => resetCategories(value)"
/>
</li>
<li
v-for="category in collapsed
? sortedCategories.slice(0, expandLimit)
: sortedCategories"
:key="category.id"
:class="$style.item"
>
<el-checkbox
:label="category.name"
:value="isSelected(category.id)"
@change="(value) => handleCheckboxChanged(value, category)"
/>
</li>
</ul>
<div
:class="$style.button"
v-if="sortedCategories.length > expandLimit && collapsed && !loading"
@click="collapseAction"
>
<n8n-text size="small" color="primary">
+ {{ `${sortedCategories.length - expandLimit} more` }}
</n8n-text>
</div>
</div>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { ITemplatesCategory } from '@/Interface';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers).extend({
name: 'TemplateFilters',
props: {
sortOnPopulate: {
type: Boolean,
default: false,
},
categories: {
type: Array,
},
expandLimit: {
type: Number,
default: 12,
},
loading: {
type: Boolean,
},
selected: {
type: Array,
},
},
watch: {
categories: {
handler(categories: ITemplatesCategory[]) {
if (!this.sortOnPopulate) {
this.sortedCategories = categories;
} else {
const selected = this.selected || [];
const selectedCategories = categories.filter(({ id }) => selected.includes(id));
const notSelectedCategories = categories.filter(({ id }) => !selected.includes(id));
this.sortedCategories = selectedCategories.concat(notSelectedCategories);
}
},
immediate: true,
},
},
data() {
return {
collapsed: true,
sortedCategories: [] as ITemplatesCategory[],
};
},
computed: {
allSelected(): boolean {
return this.selected.length === 0;
},
},
methods: {
collapseAction() {
this.collapsed = false;
},
handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
this.$emit(value ? 'select' : 'clear', selectedCategory.id);
},
isSelected(categoryId: string) {
return this.selected.includes(categoryId);
},
resetCategories() {
this.$emit('clearAll');
},
},
});
</script>
<style lang="scss" module>
.title {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
}
.categories {
padding-top: var(--spacing-xs);
list-style-type: none;
}
.item {
margin-top: var(--spacing-xs);
&:nth-child(1) {
margin-top: 0;
}
}
.button {
padding-top: var(--spacing-2xs);
cursor: pointer;
}
</style>
<style lang="scss">
.template-filters {
.el-checkbox {
display: flex;
white-space: unset;
}
.el-checkbox__label {
top: -2px;
position: relative;
font-size: var(--font-size-xs);
line-height: var(--font-line-height-loose);
color: var(--color-text-dark);
padding-left: var(--spacing-2xs);
}
}
</style>

View file

@ -0,0 +1,116 @@
<template>
<div :class="$style.list" v-if="loading || workflows.length">
<div :class="$style.header">
<n8n-heading :bold="true" size="medium" color="text-light">
{{ $locale.baseText('templates.workflows') }}
<span v-if="!loading && totalWorkflows" v-text="`(${totalWorkflows})`" />
</n8n-heading>
</div>
<div :class="$style.container">
<TemplateCard
v-for="(workflow, index) in workflows"
:key="workflow.id"
:workflow="workflow"
:firstItem="index === 0"
:lastItem="index === workflows.length - 1 && !loading"
:useWorkflowButton="useWorkflowButton"
@click="(e) => onCardClick(e, workflow.id)"
@useWorkflow="(e) => onUseWorkflow(e, workflow.id)"
/>
<div v-if="infiniteScrollEnabled" ref="loader" />
<div v-if="loading">
<TemplateCard
v-for="n in 4"
:key="'index-' + n"
:loading="true"
:firstItem="workflows.length === 0 && n === 1"
:lastItem="n === 4"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
import TemplateCard from './TemplateCard.vue';
export default mixins(genericHelpers).extend({
name: 'TemplateList',
props: {
infiniteScrollEnabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
},
useWorkflowButton: {
type: Boolean,
default: false,
},
workflows: {
type: Array,
},
totalWorkflows: {
type: Number,
},
},
mounted() {
if (this.infiniteScrollEnabled) {
window.addEventListener('scroll', this.onScroll);
}
},
destroyed() {
window.removeEventListener('scroll', this.onScroll);
},
components: {
TemplateCard,
},
methods: {
onScroll() {
const el = this.$refs.loader;
if (!el || this.loading) {
return;
}
const rect = (el as Element).getBoundingClientRect();
const inView =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth);
if (inView) {
this.$emit('loadMore');
}
},
onCardClick(event: MouseEvent, id: string) {
this.$emit('openTemplate', {event, id});
},
onUseWorkflow(event: MouseEvent, id: string) {
this.$emit('useWorkflow', {event, id});
},
},
});
</script>
<style lang="scss" module>
.header {
padding-bottom: var(--spacing-2xs);
}
.workflowButton {
&:hover {
.button {
display: block;
}
.nodes {
display: none;
}
}
}
</style>

View file

@ -66,7 +66,7 @@ import TagsContainer from '@/components/TagsContainer.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { convertToDisplayDate } from './helpers';
import { WORKFLOW_OPEN_MODAL_KEY } from '../constants';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, WORKFLOW_OPEN_MODAL_KEY } from '../constants';
export default mixins(
genericHelpers,
@ -112,10 +112,14 @@ export default mixins(
});
},
},
mounted() {
async mounted() {
this.filterText = '';
this.filterTagIds = [];
this.openDialog();
this.isDataLoading = true;
await this.loadActiveWorkflows();
await this.loadWorkflows();
this.isDataLoading = false;
Vue.nextTick(() => {
// Make sure that users can directly type in the filter
@ -159,23 +163,32 @@ export default mixins(
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(
const confirmModal = await this.confirmModal(
this.$locale.baseText('workflowOpen.confirmMessage.message'),
this.$locale.baseText('workflowOpen.confirmMessage.headline'),
'warning',
this.$locale.baseText('workflowOpen.confirmMessage.confirmButtonText'),
this.$locale.baseText('workflowOpen.confirmMessage.cancelButtonText'),
true,
);
if (importConfirm === false) {
return;
} else {
// This is used to avoid duplicating the message
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
} else if (confirmModal === MODAL_CLOSE) {
return;
}
} else {
this.$router.push({
@ -186,29 +199,30 @@ export default mixins(
this.$store.commit('ui/closeAllModals');
}
},
openDialog () {
this.isDataLoading = true;
this.restApi().getWorkflows()
.then(
(data) => {
this.workflows = data;
this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
});
this.isDataLoading = false;
},
)
.catch(
(error: Error) => {
this.$showError(
error,
this.$locale.baseText('workflowOpen.showError.title'),
);
this.isDataLoading = false;
},
async loadWorkflows () {
try {
this.workflows = await this.restApi().getWorkflows();
this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
});
} catch (error) {
this.$showError(
error,
this.$locale.baseText('workflowOpen.showError.title'),
);
}
},
async loadActiveWorkflows () {
try {
const activeWorkflows = await this.restApi().getActiveWorkflows();
this.$store.commit('setActiveWorkflows', activeWorkflows);
} catch (error) {
this.$showError(
error,
this.$locale.baseText('workflowOpen.couldNotLoadActiveWorkflows'),
);
}
},
workflowActiveChanged (data: { id: string, active: boolean }) {
for (const workflow of this.workflows) {

View file

@ -0,0 +1,144 @@
<template>
<div :class="$style.container">
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
<iframe
:class="{
[$style.workflow]: !this.nodeViewDetailsOpened,
[$style.openNDV]: this.nodeViewDetailsOpened,
[$style.show]: this.showPreview,
}"
ref="preview_iframe"
src="/workflows/demo"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
></iframe>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/components/mixins/showMessage';
export default mixins(showMessage).extend({
name: 'WorkflowPreview',
props: ['loading', 'workflow'],
data() {
return {
nodeViewDetailsOpened: false,
ready: false,
insideIframe: false,
scrollX: 0,
scrollY: 0,
};
},
computed: {
showPreview(): boolean {
return !this.loading && !!this.workflow && this.ready;
},
},
methods: {
onMouseEnter() {
this.insideIframe = true;
this.scrollX = window.scrollX;
this.scrollY = window.scrollY;
},
onMouseLeave() {
this.insideIframe = false;
},
loadWorkflow() {
try {
if (!this.workflow) {
throw new Error(this.$locale.baseText('workflowPreview.showError.missingWorkflow'));
}
if (!this.workflow.nodes || !Array.isArray(this.workflow.nodes)) {
throw new Error(this.$locale.baseText('workflowPreview.showError.arrayEmpty'));
}
const iframe = this.$refs.preview_iframe as HTMLIFrameElement;
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
JSON.stringify({
command: 'openWorkflow',
workflow: this.workflow,
}),
'*',
);
}
} catch (error) {
this.$showError(
error,
this.$locale.baseText('workflowPreview.showError.previewError.title'),
this.$locale.baseText('workflowPreview.showError.previewError.message'),
);
}
},
receiveMessage({ data }: MessageEvent) {
try {
const json = JSON.parse(data);
if (json.command === 'n8nReady') {
this.ready = true;
} else if (json.command === 'openNDV') {
this.nodeViewDetailsOpened = true;
} else if (json.command === 'closeNDV') {
this.nodeViewDetailsOpened = false;
} else if (json.command === 'error') {
this.$emit('close');
}
} catch (e) {
}
},
onDocumentScroll() {
if (this.insideIframe) {
window.scrollTo(this.scrollX, this.scrollY);
}
},
},
watch: {
showPreview(show) {
if (show) {
this.loadWorkflow();
}
},
},
mounted() {
window.addEventListener('message', this.receiveMessage);
document.addEventListener('scroll', this.onDocumentScroll);
},
beforeDestroy() {
window.removeEventListener('message', this.receiveMessage);
document.removeEventListener('scroll', this.onDocumentScroll);
},
});
</script>
<style lang="scss" module>
.container {
width: 100%;
height: 500px;
}
.workflow {
border: var(--border-base);
border-radius: var(--border-radius-large);
// firefox bug requires loading iframe as such
visibility: hidden;
height: 0;
width: 0;
}
.show {
visibility: visible;
height: 100%;
width: 100%;
}
.openNDV {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: 9999999;
}
</style>

View file

@ -1,8 +1,21 @@
import { ERROR_TRIGGER_NODE_TYPE } from '@/constants';
import { INodeUi } from '@/Interface';
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, TEMPLATES_NODES_FILTER } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import dateformat from 'dateformat';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E'];
export function abbreviateNumber(num: number) {
const tier = (Math.log10(Math.abs(num)) / 3) | 0;
if (tier === 0) return num;
const suffix = SI_SYMBOL[tier];
const scale = Math.pow(10, tier * 3);
const scaled = num / scale;
return Number(scaled.toFixed(1)) + suffix;
}
export function convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
@ -31,3 +44,18 @@ export function getActivatableTriggerNodes(nodes: INodeUi[]) {
return !node.disabled && node.type !== ERROR_TRIGGER_NODE_TYPE;
});
}
export function filterTemplateNodes(nodes: ITemplatesNode[]) {
const notCoreNodes = nodes.filter((node: ITemplatesNode) => {
return !(node.categories || []).some(
(category) => category.name === CORE_NODES_CATEGORY,
);
});
const results = notCoreNodes.length > 0 ? notCoreNodes : nodes;
return results.filter((elem) => !TEMPLATES_NODES_FILTER.includes(elem.name));
}
export function setPageTitle(title: string) {
window.document.title = title;
}

View file

@ -9,6 +9,9 @@ export const copyPaste = Vue.extend({
data () {
return {
copyPasteElementsGotCreated: false,
hiddenInput: null as null | Element,
onPaste: null as null | Function,
onBeforePaste: null as null | Function,
};
},
mounted () {
@ -53,6 +56,7 @@ export const copyPaste = Vue.extend({
hiddenInput.setAttribute('type', 'text');
hiddenInput.setAttribute('id', 'hidden-input-copy-paste');
hiddenInput.setAttribute('class', 'hidden-copy-paste');
this.hiddenInput = hiddenInput;
document.body.append(hiddenInput);
@ -64,12 +68,14 @@ export const copyPaste = Vue.extend({
ieClipboardDiv.setAttribute('contenteditable', 'true');
document.body.append(ieClipboardDiv);
document.addEventListener('beforepaste', () => {
this.onBeforePaste = () => {
// @ts-ignore
if (hiddenInput.is(':focus')) {
this.focusIeClipboardDiv(ieClipboardDiv as HTMLDivElement);
}
}, true);
};
// @ts-ignore
document.addEventListener('beforepaste', this.onBeforePaste, true);
}
let userInput = '';
@ -90,36 +96,38 @@ export const copyPaste = Vue.extend({
}
});
// Set clipboard event listeners on the document.
['paste'].forEach((event) => {
document.addEventListener(event, debounce((e) => {
// Check if the event got emitted from a message box or from something
// else which should ignore the copy/paste
// @ts-ignore
const path = e.path || (e.composedPath && e.composedPath());
for (let index = 0; index < path.length; index++) {
if (path[index].className && typeof path[index].className === 'string' && (
path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press')
)) {
return;
}
this.onPaste = debounce((e) => {
const event = 'paste';
// Check if the event got emitted from a message box or from something
// else which should ignore the copy/paste
// @ts-ignore
const path = e.path || (e.composedPath && e.composedPath());
for (let index = 0; index < path.length; index++) {
if (path[index].className && typeof path[index].className === 'string' && (
path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press')
)) {
return;
}
}
if (ieClipboardDiv !== null) {
this.ieClipboardEvent(event, ieClipboardDiv);
} else {
this.standardClipboardEvent(event, e as ClipboardEvent);
// @ts-ignore
if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) {
// That it still allows to paste into text, email, password & textarea-fiels we
// check if we can identify the active element and if so only
// run it if something else is selected.
this.focusHiddenArea(hiddenInput);
e.preventDefault();
}
if (ieClipboardDiv !== null) {
this.ieClipboardEvent(event, ieClipboardDiv);
} else {
this.standardClipboardEvent(event, e as ClipboardEvent);
// @ts-ignore
if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) {
// That it still allows to paste into text, email, password & textarea-fiels we
// check if we can identify the active element and if so only
// run it if something else is selected.
this.focusHiddenArea(hiddenInput);
e.preventDefault();
}
}, 1000, { leading: true }));
});
}
}, 1000, { leading: true });
// Set clipboard event listeners on the document.
// @ts-ignore
document.addEventListener('paste', this.onPaste);
},
methods: {
receivedCopyPasteData (plainTextData: string, event?: ClipboardEvent): void {
@ -198,4 +206,17 @@ export const copyPaste = Vue.extend({
},
},
beforeDestroy() {
if (this.hiddenInput) {
this.hiddenInput.remove();
}
if (this.onPaste) {
// @ts-ignore
document.removeEventListener('paste', this.onPaste);
}
if (this.onBeforePaste) {
// @ts-ignore
document.removeEventListener('beforepaste', this.onBeforePaste);
}
},
});

View file

@ -77,11 +77,12 @@ export const genericHelpers = mixins(showMessage).extend({
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const debounceTime = inputParameters.shift() as number;
const trailing = inputParameters.shift() as boolean;
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, { leading: true });
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing: true } : { leading: true } );
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);

View file

@ -150,6 +150,23 @@ export const showMessage = mixins(externalHooks).extend({
}
},
async confirmModal (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string, showClose = false): Promise<string> {
try {
const options: ElMessageBoxOptions = {
confirmButtonText: confirmButtonText || this.$locale.baseText('showMessage.ok'),
cancelButtonText: cancelButtonText || this.$locale.baseText('showMessage.cancel'),
dangerouslyUseHTMLString: true,
showClose,
...(type && { type }),
};
await this.$confirm(message, headline, options);
return 'confirmed';
} catch (e) {
return e as string;
}
},
clearAllStickyNotifications() {
stickyNotificationQueue.map((notification: ElNotificationComponent) => {
if (notification) {

View file

@ -451,10 +451,10 @@ export const workflowHelpers = mixins(
}
},
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}): Promise<boolean> {
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
const currentWorkflow = this.$route.params.name;
if (!currentWorkflow) {
return this.saveAsNewWorkflow({name, tags});
return this.saveAsNewWorkflow({name, tags}, redirect);
}
// Workflow exists already so update it
@ -501,7 +501,7 @@ export const workflowHelpers = mixins(
}
},
async saveAsNewWorkflow ({name, tags, resetWebhookUrls, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean} = {}): Promise<boolean> {
async saveAsNewWorkflow ({name, tags, resetWebhookUrls, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean} = {}, redirect = true): Promise<boolean> {
try {
this.$store.commit('addActiveAction', 'workflowSaving');
@ -552,10 +552,21 @@ export const workflowHelpers = mixins(
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds);
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
const templateId = this.$route.query.templateId;
if (templateId) {
this.$telemetry.track('User saved new workflow from template', {
template_id: templateId,
workflow_id: workflowData.id,
wf_template_repo_session_id: this.$store.getters['templates/previousSessionId'],
});
}
if (redirect) {
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
}
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$store.commit('setStateDirty', false);
@ -567,7 +578,7 @@ export const workflowHelpers = mixins(
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `"${e.message}"`,
message: (e as Error).message,
type: 'error',
});

View file

@ -38,8 +38,7 @@ export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;
// templates
export const TEMPLATES_BASE_URL = `https://api.n8n.io/`;
export const N8N_IO_BASE_URL = `https://api.n8n.io/`;
// node types
export const CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger';
@ -136,6 +135,36 @@ export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea';
export const OTHER_COMPANY_INDUSTRY_KEY = 'otherCompanyIndustry';
export const MODAL_CANCEL = 'cancel';
export const MODAL_CLOSE = 'close';
export const MODAL_CONFIRMED = 'confirmed';
export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
export const HIRING_BANNER = `
//////
///////////
///// ////
/////////////////// ////
////////////////////// ////
/////// /////// //// /////////////
//////////// //////////// //// ///////
//// //// //// //// ////
///// ///////////// //////////
///// //// //// //// ////
//////////// //////////// //// ////////
/////// ////// //// /////////////
///////////// ////
////////// ////
//// ////
///////////
//////
Love n8n? Help us build the future of automation! https://n8n.io/careers
`;
export const TEMPLATES_NODES_FILTER = [
'n8n-nodes-base.start',
'n8n-nodes-base.respondToWebhook',
];

View file

@ -123,10 +123,16 @@ const module: Module<ICredentialsState, IRootState> = {
},
actions: {
fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>) => {
if (context.getters.allCredentialTypes.length > 0) {
return;
}
const credentialTypes = await getCredentialTypes(context.rootGetters.getRestApiContext);
context.commit('setCredentialTypes', credentialTypes);
},
fetchAllCredentials: async (context: ActionContext<ICredentialsState, IRootState>) => {
if (context.getters.allCredentials.length > 0) {
return;
}
const credentials = await getAllCredentials(context.rootGetters.getRestApiContext);
context.commit('setCredentials', credentials);
},

View file

@ -13,12 +13,14 @@ import Vue from 'vue';
import { getPersonalizedNodeTypes } from './helper';
import { CONTACT_PROMPT_MODAL_KEY, PERSONALIZATION_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import { ITelemetrySettings } from 'n8n-workflow';
import { testHealthEndpoint } from '@/api/templates';
const module: Module<ISettingsState, IRootState> = {
namespaced: true,
state: {
settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
templatesEndpointHealthy: false,
},
getters: {
personalizedNodeTypes(state: ISettingsState): string[] {
@ -41,6 +43,18 @@ const module: Module<ISettingsState, IRootState> = {
isTelemetryEnabled: (state) => {
return state.settings.telemetry && state.settings.telemetry.enabled;
},
isInternalUser: (state): boolean => {
return state.settings.deploymentType === 'n8n-internal';
},
isTemplatesEnabled: (state): boolean => {
return Boolean(state.settings.templates && state.settings.templates.enabled);
},
isTemplatesEndpointReachable: (state): boolean => {
return state.templatesEndpointHealthy;
},
templatesHost: (state): string => {
return state.settings.templates.host;
},
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
@ -55,6 +69,9 @@ const module: Module<ISettingsState, IRootState> = {
setPromptsData(state: ISettingsState, promptsData: IN8nPrompts) {
Vue.set(state, 'promptsData', promptsData);
},
setTemplatesEndpointHealthy(state: ISettingsState) {
state.templatesEndpointHealthy = true;
},
},
actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) {
@ -124,6 +141,11 @@ const module: Module<ISettingsState, IRootState> = {
return e;
}
},
async testTemplatesEndpoint(context: ActionContext<ISettingsState, IRootState>) {
const timeout = new Promise((_, reject) => setTimeout(() => reject(), 2000));
await Promise.race([testHealthEndpoint(context.getters.templatesHost), timeout]);
context.commit('setTemplatesEndpointHealthy', true);
},
},
};

View file

@ -0,0 +1,282 @@
import { getCategories, getCollectionById, getCollections, getTemplateById, getWorkflows, getWorkflowTemplate } from '@/api/templates';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
ITemplatesCollection,
ITemplatesWorkflow,
ITemplatesCategory,
ITemplateState,
ITemplatesQuery,
ITemplatesWorkflowFull,
ITemplatesCollectionFull,
IWorkflowTemplate,
} from '../Interface';
import Vue from 'vue';
const TEMPLATES_PAGE_SIZE = 10;
function getSearchKey(query: ITemplatesQuery): string {
return JSON.stringify([query.search || '', [...query.categories].sort()]);
}
const module: Module<ITemplateState, IRootState> = {
namespaced: true,
state: {
categories: {},
collections: {},
workflows: {},
collectionSearches: {},
workflowSearches: {},
currentSessionId: '',
previousSessionId: '',
},
getters: {
allCategories(state: ITemplateState) {
return Object.values(state.categories).sort((a: ITemplatesCategory, b: ITemplatesCategory) => a.name > b.name ? 1: -1);
},
getTemplateById(state: ITemplateState) {
return (id: string): null | ITemplatesWorkflow => state.workflows[id];
},
getCollectionById(state: ITemplateState) {
return (id: string): null | ITemplatesCollection => state.collections[id];
},
getCategoryById(state: ITemplateState) {
return (id: string): null | ITemplatesCategory => state.categories[id];
},
getSearchedCollections(state: ITemplateState) {
return (query: ITemplatesQuery) => {
const searchKey = getSearchKey(query);
const search = state.collectionSearches[searchKey];
if (!search) {
return null;
}
return search.collectionIds.map((collectionId: string) => state.collections[collectionId]);
};
},
getSearchedWorkflows(state: ITemplateState) {
return (query: ITemplatesQuery) => {
const searchKey = getSearchKey(query);
const search = state.workflowSearches[searchKey];
if (!search) {
return null;
}
return search.workflowIds.map((workflowId: string) => state.workflows[workflowId]);
};
},
getSearchedWorkflowsTotal(state: ITemplateState) {
return (query: ITemplatesQuery) => {
const searchKey = getSearchKey(query);
const search = state.workflowSearches[searchKey];
return search ? search.totalWorkflows : 0;
};
},
isSearchLoadingMore(state: ITemplateState) {
return (query: ITemplatesQuery) => {
const searchKey = getSearchKey(query);
const search = state.workflowSearches[searchKey];
return Boolean(search && search.loadingMore);
};
},
isSearchFinished(state: ITemplateState) {
return (query: ITemplatesQuery) => {
const searchKey = getSearchKey(query);
const search = state.workflowSearches[searchKey];
return Boolean(search && !search.loadingMore && search.totalWorkflows === search.workflowIds.length);
};
},
currentSessionId(state: ITemplateState) {
return state.currentSessionId;
},
previousSessionId(state: ITemplateState) {
return state.previousSessionId;
},
},
mutations: {
addCategories(state: ITemplateState, categories: ITemplatesCategory[]) {
categories.forEach((category: ITemplatesCategory) => {
Vue.set(state.categories, category.id, category);
});
},
addCollections(state: ITemplateState, collections: Array<ITemplatesCollection | ITemplatesCollectionFull>) {
collections.forEach((collection) => {
const workflows = (collection.workflows || []).map((workflow) => ({id: workflow.id}));
const cachedCollection = state.collections[collection.id] || {};
Vue.set(state.collections, collection.id, {
...cachedCollection,
...collection,
workflows,
});
});
},
addWorkflows(state: ITemplateState, workflows: Array<ITemplatesWorkflow | ITemplatesWorkflowFull>) {
workflows.forEach((workflow: ITemplatesWorkflow) => {
const cachedWorkflow = state.workflows[workflow.id] || {};
Vue.set(state.workflows, workflow.id, {
...cachedWorkflow,
...workflow,
});
});
},
addCollectionSearch(state: ITemplateState, data: {collections: ITemplatesCollection[], query: ITemplatesQuery}) {
const collectionIds = data.collections.map((collection) => collection.id);
const searchKey = getSearchKey(data.query);
Vue.set(state.collectionSearches, searchKey, {
collectionIds,
});
},
addWorkflowsSearch(state: ITemplateState, data: {totalWorkflows: number; workflows: ITemplatesWorkflow[], query: ITemplatesQuery}) {
const workflowIds = data.workflows.map((workflow) => workflow.id);
const searchKey = getSearchKey(data.query);
const cachedResults = state.workflowSearches[searchKey];
if (!cachedResults) {
Vue.set(state.workflowSearches, searchKey, {
workflowIds,
totalWorkflows: data.totalWorkflows,
});
return;
}
Vue.set(state.workflowSearches, searchKey, {
workflowIds: [...cachedResults.workflowIds, ...workflowIds],
totalWorkflows: data.totalWorkflows,
});
},
setWorkflowSearchLoading(state: ITemplateState, query: ITemplatesQuery) {
const searchKey = getSearchKey(query);
const cachedResults = state.workflowSearches[searchKey];
if (!cachedResults) {
return;
}
Vue.set(state.workflowSearches[searchKey], 'loadingMore', true);
},
setWorkflowSearchLoaded(state: ITemplateState, query: ITemplatesQuery) {
const searchKey = getSearchKey(query);
const cachedResults = state.workflowSearches[searchKey];
if (!cachedResults) {
return;
}
Vue.set(state.workflowSearches[searchKey], 'loadingMore', false);
},
resetSessionId(state: ITemplateState) {
state.previousSessionId = state.currentSessionId;
state.currentSessionId = '';
},
setSessionId(state: ITemplateState) {
if (!state.currentSessionId) {
state.currentSessionId = `templates-${Date.now()}`;
}
},
},
actions: {
async getTemplateById(context: ActionContext<ITemplateState, IRootState>, templateId: string): Promise<ITemplatesWorkflowFull> {
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
const versionCli: string = context.rootGetters['versionCli'];
const response = await getTemplateById(apiEndpoint, templateId, { 'n8n-version': versionCli });
const template: ITemplatesWorkflowFull = {
...response.workflow,
full: true,
};
context.commit('addWorkflows', [template]);
return template;
},
async getCollectionById(context: ActionContext<ITemplateState, IRootState>, collectionId: string): Promise<ITemplatesCollection> {
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
const versionCli: string = context.rootGetters['versionCli'];
const response = await getCollectionById(apiEndpoint, collectionId, { 'n8n-version': versionCli });
const collection: ITemplatesCollectionFull = {
...response.collection,
full: true,
};
context.commit('addCollections', [collection]);
context.commit('addWorkflows', response.collection.workflows);
return context.getters.getCollectionById(collectionId);
},
async getCategories(context: ActionContext<ITemplateState, IRootState>): Promise<ITemplatesCategory[]> {
const cachedCategories: ITemplatesCategory[] = context.getters.allCategories;
if (cachedCategories.length) {
return cachedCategories;
}
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
const versionCli: string = context.rootGetters['versionCli'];
const response = await getCategories(apiEndpoint, { 'n8n-version': versionCli });
const categories = response.categories;
context.commit('addCategories', categories);
return categories;
},
async getCollections(context: ActionContext<ITemplateState, IRootState>, query: ITemplatesQuery): Promise<ITemplatesCollection[]> {
const cachedResults: ITemplatesCollection[] | null = context.getters.getSearchedCollections(query);
if (cachedResults) {
return cachedResults;
}
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
const versionCli: string = context.rootGetters['versionCli'];
const response = await getCollections(apiEndpoint, query, { 'n8n-version': versionCli });
const collections = response.collections;
context.commit('addCollections', collections);
context.commit('addCollectionSearch', {query, collections});
collections.forEach((collection: ITemplatesCollection) => context.commit('addWorkflows', collection.workflows));
return collections;
},
async getWorkflows(context: ActionContext<ITemplateState, IRootState>, query: ITemplatesQuery): Promise<ITemplatesWorkflow[]> {
const cachedResults: ITemplatesWorkflow[] = context.getters.getSearchedWorkflows(query);
if (cachedResults) {
return cachedResults;
}
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
const versionCli: string = context.rootGetters['versionCli'];
const payload = await getWorkflows(apiEndpoint, {...query, skip: 0, limit: TEMPLATES_PAGE_SIZE}, { 'n8n-version': versionCli });
context.commit('addWorkflows', payload.workflows);
context.commit('addWorkflowsSearch', {...payload, query});
return context.getters.getSearchedWorkflows(query);
},
async getMoreWorkflows(context: ActionContext<ITemplateState, IRootState>, query: ITemplatesQuery): Promise<ITemplatesWorkflow[]> {
if (context.getters.isSearchLoadingMore(query) && !context.getters.isSearchFinished(query)) {
return [];
}
const cachedResults: ITemplatesWorkflow[] = context.getters.getSearchedWorkflows(query) || [];
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
context.commit('setWorkflowSearchLoading', query);
try {
const payload = await getWorkflows(apiEndpoint, {...query, skip: cachedResults.length, limit: TEMPLATES_PAGE_SIZE});
context.commit('setWorkflowSearchLoaded', query);
context.commit('addWorkflows', payload.workflows);
context.commit('addWorkflowsSearch', {...payload, query});
return context.getters.getSearchedWorkflows(query);
} catch (e) {
context.commit('setWorkflowSearchLoaded', query);
throw e;
}
},
getWorkflowTemplate: async (context: ActionContext<ITemplateState, IRootState>, templateId: string): Promise<IWorkflowTemplate> => {
const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
const versionCli: string = context.rootGetters['versionCli'];
return await getWorkflowTemplate(apiEndpoint, templateId, { 'n8n-version': versionCli });
},
},
};
export default module;

View file

@ -55,8 +55,12 @@ const module: Module<IUiState, IRootState> = {
modalStack: [],
sidebarMenuCollapsed: true,
isPageLoading: true,
currentView: '',
},
getters: {
areExpressionsDisabled(state: IUiState) {
return state.currentView === 'WorkflowDemo';
},
isVersionsOpen: (state: IUiState) => {
return state.modals[VERSIONS_MODAL_KEY].open;
},
@ -104,6 +108,9 @@ const module: Module<IUiState, IRootState> = {
toggleSidebarMenuCollapse: (state: IUiState) => {
state.sidebarMenuCollapsed = !state.sidebarMenuCollapsed;
},
setCurrentView: (state: IUiState, currentView: string) => {
state.currentView = currentView;
},
},
actions: {
openModal: async (context: ActionContext<IUiState, IRootState>, modalKey: string) => {

View file

@ -1,10 +1,9 @@
import { getNewWorkflow, getWorkflowTemplate } from '@/api/workflows';
import { getNewWorkflow } from '@/api/workflows';
import { DUPLICATE_POSTFFIX, MAX_WORKFLOW_NAME_LENGTH, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
IWorkflowsState,
IWorkflowTemplate,
} from '../Interface';
const module: Module<IWorkflowsState, IRootState> = {
@ -39,14 +38,11 @@ const module: Module<IWorkflowsState, IRootState> = {
newName = newWorkflow.name;
}
catch (e) {
}
}
return newName;
},
getWorkflowTemplate: async (context: ActionContext<IWorkflowsState, IRootState>, templateId: string): Promise<IWorkflowTemplate> => {
return await getWorkflowTemplate(templateId);
},
},
};
export default module;
export default module;

View file

@ -2,11 +2,6 @@
@import "~n8n-design-system/theme/dist/index.css";
body {
background-color: var(--color-canvas-background);
}
.clickable {
cursor: pointer;
}

View file

@ -37,6 +37,7 @@ import MessageBox from 'element-ui/lib/message-box';
import Message from 'element-ui/lib/message';
import Notification from 'element-ui/lib/notification';
import CollapseTransition from 'element-ui/lib/transitions/collapse-transition';
import VueAgile from 'vue-agile';
// @ts-ignore
import lang from 'element-ui/lib/locale/lang/en';
@ -50,12 +51,16 @@ import {
N8nInput,
N8nInputLabel,
N8nInputNumber,
N8nLoading,
N8nHeading,
N8nMarkdown,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nTags,
N8nTag,
N8nText,
N8nTooltip,
N8nOption,
@ -71,12 +76,16 @@ Vue.use(N8nInfoTip);
Vue.use(N8nInput);
Vue.use(N8nInputLabel);
Vue.use(N8nInputNumber);
Vue.component('n8n-loading', N8nLoading);
Vue.use(N8nHeading);
Vue.component('n8n-markdown', N8nMarkdown);
Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.component('n8n-square-button', N8nSquareButton);
Vue.use(N8nTags);
Vue.use(N8nTag);
Vue.component('n8n-text', N8nText);
Vue.use(N8nTooltip);
Vue.use(N8nOption);
@ -111,6 +120,7 @@ Vue.use(Badge);
Vue.use(Card);
Vue.use(ColorPicker);
Vue.use(Container);
Vue.use(VueAgile);
Vue.component(CollapseTransition.name, CollapseTransition);
@ -141,7 +151,8 @@ Vue.prototype.$confirm = async (message: string, configOrTitle: string | ElMessa
roundButton: true,
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
showClose: false,
distinguishCancelAndClose: true,
showClose: config.showClose || false,
closeOnClickModal: false,
};

View file

@ -131,7 +131,8 @@
},
"type": "Type",
"updated": "Updated",
"yourSavedCredentials": "Your saved credentials"
"yourSavedCredentials": "Your saved credentials",
"errorLoadingCredentials": "Error loading credentials"
},
"dataDisplay": {
"needHelp": "Need help?",
@ -289,10 +290,10 @@
"message": "Are you sure that you want to delete '{workflowName}'?"
},
"workflowNew": {
"cancelButtonText": "",
"confirmButtonText": "Yes, switch workflows and forget changes",
"headline": "Switch workflows without saving?",
"message": "When you switch workflows without saving, your current changes will be lost."
"cancelButtonText": "Leave without saving",
"confirmButtonText": "Save",
"headline": "Save changes before leaving?",
"message": "If you don't save, you will lose your changes."
}
},
"credentials": "Credentials",
@ -309,6 +310,7 @@
"importFromFile": "Import from File",
"importFromUrl": "Import from URL",
"new": "New",
"newTemplate": "New from template",
"open": "Open",
"prompt": {
"cancel": "@:reusableBaseText.cancel",
@ -342,6 +344,7 @@
"title": "Execution stopped"
}
},
"templates": "Templates",
"workflows": "Workflows"
},
"multipleParameter": {
@ -488,16 +491,16 @@
"addNode": "Add node",
"confirmMessage": {
"beforeRouteLeave": {
"cancelButtonText": "",
"confirmButtonText": "Yes, switch workflows and forget changes",
"headline": "Switch workflows without saving?",
"message": "When you switch workflows without saving, your current changes will be lost."
"cancelButtonText": "Leave without saving",
"confirmButtonText": "Save",
"headline": "Save changes before leaving?",
"message": "If you don't save, you will lose your changes."
},
"initView": {
"cancelButtonText": "",
"confirmButtonText": "Yes, switch workflows and forget changes",
"headline": "Switch workflows without saving?",
"message": "When you switch workflows without saving, your current changes will be lost."
"cancelButtonText": "Leave without saving",
"confirmButtonText": "Save",
"headline": "Save changes before leaving?",
"message": "If you don't save, you will lose your changes."
},
"receivedCopyPasteData": {
"cancelButtonText": "",
@ -620,7 +623,8 @@
"refreshList": "Refresh List",
"removeExpression": "Remove Expression",
"resetValue": "Reset Value",
"selectDateAndTime": "Select date and time"
"selectDateAndTime": "Select date and time",
"select": "Select"
},
"parameterInputExpanded": {
"openDocs": "Open docs",
@ -729,6 +733,14 @@
"saved": "Saved",
"saving": "Saving"
},
"settings": {
"errors": {
"connectionError": {
"title": "Error connecting to n8n",
"message": "Could not connect to server. <a onclick='window.location.reload(false);'>Refresh</a> to try again"
}
}
},
"showMessage": {
"cancel": "@:reusableBaseText.cancel",
"ok": "OK",
@ -795,6 +807,38 @@
},
"notBeingUsed": "Not being used"
},
"template": {
"buttons": {
"goBackButton": "Go back",
"useThisWorkflowButton": "Use this workflow"
},
"details": {
"appsInTheWorkflow": "Apps in this workflow",
"appsInTheCollection": "This collection features",
"by": "by",
"categories": "Categories",
"created": "Created",
"details": "Details",
"times": "times",
"viewed": "Viewed"
}
},
"templates": {
"allCategories": "All Categories",
"categoriesHeading": "Categories",
"collection": "Collection",
"collections": "Collections",
"collectionsNotFound": "Collection could not be found",
"endResult": "Share your own useful workflows through your <a href='https://n8n.io/dashboard' target='_blank'>n8n.io account</a>",
"heading": "Workflow templates",
"newButton": "New blank workflow",
"noSearchResults": "Nothing found. Try adjusting your search to see more.",
"searchPlaceholder": "Search workflows",
"workflow": "Workflow",
"workflows": "Workflows",
"workflowsNotFound": "Workflow could not be found",
"connectionWarning": "⚠️ There was a problem fetching workflow templates. Check your internet connection."
},
"textEdit": {
"edit": "Edit"
},
@ -898,10 +942,10 @@
"workflowOpen": {
"active": "Active",
"confirmMessage": {
"cancelButtonText": "",
"confirmButtonText": "Yes, switch workflows and forget changes",
"headline": "Switch workflows without saving?",
"message": "If you do this, your current changes will be lost."
"cancelButtonText": "Leave without saving",
"confirmButtonText": "Save",
"headline": "Save changes before leaving?",
"message": "If you don't save, you will lose your changes."
},
"created": "Created",
"name": "@:reusableBaseText.name",
@ -915,7 +959,8 @@
"message": "This is the current workflow",
"title": "Workflow already open"
},
"updated": "Updated"
"updated": "Updated",
"couldNotLoadActiveWorkflows": "Could not load active workflows"
},
"workflowRun": {
"noActiveConnectionToTheServer": "Lost connection to the server",
@ -1009,5 +1054,15 @@
"yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
"yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
"gotIt": "Got it"
},
"workflowPreview": {
"showError": {
"previewError": {
"message": "Unable to preview workflow",
"title": "Preview error"
},
"missingWorkflow": "Missing workflow",
"arrayEmpty": "Must have an array of nodes"
}
}
}

View file

@ -10,12 +10,15 @@ import {
faArrowRight,
faAt,
faBook,
faBoxOpen,
faBug,
faCalendar,
faCheck,
faCheckCircle,
faChevronDown,
faChevronUp,
faChevronLeft,
faChevronRight,
faCode,
faCodeBranch,
faCog,
@ -99,10 +102,13 @@ addIcon(faArrowLeft);
addIcon(faArrowRight);
addIcon(faAt);
addIcon(faBook);
addIcon(faBoxOpen);
addIcon(faBug);
addIcon(faCalendar);
addIcon(faCheck);
addIcon(faCheckCircle);
addIcon(faChevronLeft);
addIcon(faChevronRight);
addIcon(faChevronDown);
addIcon(faChevronUp);
addIcon(faCode);

View file

@ -3,7 +3,9 @@ import {
ITelemetrySettings,
IDataObject,
} from 'n8n-workflow';
import { ILogLevel, INodeCreateElement } from "@/Interface";
import { ILogLevel, INodeCreateElement, IRootState } from "@/Interface";
import { Route } from "vue-router";
import { Store } from "vuex";
declare module 'vue/types/vue' {
interface Vue {
@ -35,7 +37,9 @@ interface IUserNodesPanelSession {
class Telemetry {
private pageEventQueue: Array<{category?: string, name?: string | null}>;
private pageEventQueue: Array<{category: string, route: Route}>;
private previousPath: string;
private store: Store<IRootState> | null;
private get telemetry() {
// @ts-ignore
@ -53,17 +57,20 @@ class Telemetry {
constructor() {
this.pageEventQueue = [];
this.previousPath = '';
this.store = null;
}
init(options: ITelemetrySettings, instanceId: string, logLevel?: ILogLevel) {
init(options: ITelemetrySettings, props: {instanceId: string, logLevel?: ILogLevel, store: Store<IRootState>}) {
if (options.enabled && !this.telemetry) {
if(!options.config) {
return;
}
const logging = logLevel === 'debug' ? { logLevel: 'DEBUG'} : {};
this.store = props.store;
const logging = props.logLevel === 'debug' ? { logLevel: 'DEBUG'} : {};
this.loadTelemetryLibrary(options.config.key, options.config.url, { integrations: { All: false }, loadIntegration: false, ...logging});
this.telemetry.identify(instanceId);
this.telemetry.identify(props.instanceId);
this.flushPageEvents();
}
}
@ -74,14 +81,24 @@ class Telemetry {
}
}
page(category?: string, name?: string | null) {
page(category: string, route: Route) {
if (this.telemetry) {
this.telemetry.page(category, name);
if (route.path === this.previousPath) { // avoid duplicate requests query is changed for example on search page
return;
}
this.previousPath = route.path;
const pageName = route.name;
let properties: {[key: string]: string} = {};
if (this.store && route.meta && route.meta.telemetry && typeof route.meta.telemetry.getProperties === 'function') {
properties = route.meta.telemetry.getProperties(route, this.store);
}
this.telemetry.page(category, pageName, properties);
}
else {
this.pageEventQueue.push({
category,
name,
route,
});
}
}
@ -89,10 +106,8 @@ class Telemetry {
flushPageEvents() {
const queue = this.pageEventQueue;
this.pageEventQueue = [];
queue.forEach(({category, name}) => {
if (this.telemetry) {
this.telemetry.page(category, name);
}
queue.forEach(({category, route}) => {
this.page(category, route);
});
}

View file

@ -1,8 +1,14 @@
import Vue from 'vue';
import Router from 'vue-router';
import Router, { Route } from 'vue-router';
import TemplatesCollectionView from '@/views/TemplatesCollectionView.vue';
import MainHeader from '@/components/MainHeader/MainHeader.vue';
import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';
import TemplatesWorkflowView from '@/views/TemplatesWorkflowView.vue';
import TemplatesSearchView from '@/views/TemplatesSearchView.vue';
import { Store } from 'vuex';
import { IRootState } from './Interface';
Vue.use(Router);
@ -11,6 +17,25 @@ export default new Router({
// @ts-ignore
base: window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH,
routes: [
{
path: '/collections/:id',
name: 'TemplatesCollectionView',
components: {
default: TemplatesCollectionView,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
telemetry: {
getProperties(route: Route, store: Store<IRootState>) {
return {
collection_id: route.params.id,
wf_template_repo_session_id: store.getters['templates/currentSessionId'],
};
},
},
},
},
{
path: '/execution/:id',
name: 'ExecutionById',
@ -19,6 +44,46 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
nodeView: true,
},
},
{
path: '/templates/:id',
name: 'TemplatesWorkflowView',
components: {
default: TemplatesWorkflowView,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
telemetry: {
getProperties(route: Route, store: Store<IRootState>) {
return {
template_id: route.params.id,
wf_template_repo_session_id: store.getters['templates/currentSessionId'],
};
},
},
},
},
{
path: '/templates/',
name: 'TemplatesSearchView',
components: {
default: TemplatesSearchView,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
telemetry: {
getProperties(route: Route, store: Store<IRootState>) {
return {
wf_template_repo_session_id: store.getters['templates/currentSessionId'],
};
},
},
},
},
{
path: '/workflow',
@ -28,6 +93,9 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
nodeView: true,
},
},
{
path: '/workflow/:name',
@ -37,10 +105,9 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
},
{
path: '/',
redirect: '/workflow',
meta: {
nodeView: true,
},
},
{
path: '/workflows/templates/:id',
@ -50,6 +117,9 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
},
},
{
path: '/workflows/demo',
@ -57,6 +127,12 @@ export default new Router({
components: {
default: NodeView,
},
meta: {
nodeView: true,
telemetry: {
disabled: true,
},
},
},
],
});

View file

@ -37,6 +37,7 @@ import settings from './modules/settings';
import ui from './modules/ui';
import workflows from './modules/workflows';
import versions from './modules/versions';
import templates from './modules/templates';
Vue.use(Vuex);
@ -94,6 +95,7 @@ const modules = {
credentials,
tags,
settings,
templates,
workflows,
versions,
ui,

View file

@ -0,0 +1,29 @@
<template>
<div :class="$style.wrapper">
<div :class="$style.spinner">
<n8n-spinner />
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
width: 100%;
height: 100%;
position: absolute;
background-color: var(--color-background-light);
}
.spinner {
margin-bottom: var(--spacing-l);
position: absolute;
left: 50%;
top: 30%;
* {
color: var(--color-primary);
min-height: 40px;
min-width: 40px;
}
}
</style>

View file

@ -104,7 +104,6 @@
@click.stop="clearExecutionData()"
/>
</div>
<Modals />
</div>
</template>
@ -115,7 +114,7 @@ import {
} from 'jsplumb';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import { NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, 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, START_NODE_TYPE, 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';
@ -130,7 +129,6 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import DataDisplay from '@/components/DataDisplay.vue';
import Modals from '@/components/Modals.vue';
import Node from '@/components/Node.vue';
import NodeCreator from '@/components/NodeCreator/NodeCreator.vue';
import NodeSettings from '@/components/NodeSettings.vue';
@ -197,7 +195,6 @@ export default mixins(
name: 'NodeView',
components: {
DataDisplay,
Modals,
Node,
NodeCreator,
NodeSettings,
@ -242,20 +239,27 @@ export default mixins(
async beforeRouteLeave(to, from, next) {
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(
const confirmModal = await this.confirmModal(
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.message'),
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.headline'),
'warning',
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.confirmButtonText'),
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.cancelButtonText'),
true,
);
if (importConfirm === false) {
next(false);
} else {
// Prevent other popups from displaying
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
}
} else {
next();
}
@ -347,6 +351,7 @@ export default mixins(
};
},
beforeDestroy () {
this.resetWorkspace();
// Make sure the event listeners get removed again else we
// could add up with them registred multiple times
document.removeEventListener('keydown', this.keyDown);
@ -530,7 +535,7 @@ export default mixins(
let data: IWorkflowTemplate | undefined;
try {
this.$externalHooks().run('template.requested', { templateId });
data = await this.$store.dispatch('workflows/getWorkflowTemplate', templateId);
data = await this.$store.dispatch('templates/getWorkflowTemplate', templateId);
if (!data) {
throw new Error(
@ -540,22 +545,16 @@ export default mixins(
),
);
}
data.workflow.nodes.forEach((node) => {
if (!this.$store.getters.nodeType(node.type, node.typeVersion)) {
throw new Error(`The ${this.$locale.shortNodeType(node.type)} node is not supported`);
}
});
} catch (error) {
this.$showError(error, this.$locale.baseText('nodeView.couldntImportWorkflow'));
this.$router.push({ name: 'NodeViewNew' });
this.$router.replace({ name: 'NodeViewNew' });
return;
}
data.workflow.nodes = CanvasHelpers.getFixedNodesList(data.workflow.nodes);
this.blankRedirect = true;
this.$router.push({ name: 'NodeViewNew', query: { templateId } });
this.$router.replace({ name: 'NodeViewNew', query: { templateId } });
await this.addNodes(data.workflow.nodes, data.workflow.connections);
await this.$store.dispatch('workflows/setNewWorkflowName', data.name);
@ -955,8 +954,11 @@ export default mixins(
},
cutSelectedNodes () {
this.copySelectedNodes(true);
this.deleteSelectedNodes();
const deleteCopiedNodes = !this.isReadOnly;
this.copySelectedNodes(deleteCopiedNodes);
if (deleteCopiedNodes) {
this.deleteSelectedNodes();
}
},
copySelectedNodes (isCut: boolean) {
@ -1003,6 +1005,9 @@ export default mixins(
setZoomLevel (zoomLevel: number) {
this.nodeViewScale = zoomLevel; // important for background
const element = this.instance.getContainer() as HTMLElement;
if (!element) {
return;
}
// https://docs.jsplumbtoolkit.com/community/current/articles/zooming.html
const prependProperties = ['webkit', 'moz', 'ms', 'o'];
@ -1789,14 +1794,19 @@ export default mixins(
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(
const confirmModal = await this.confirmModal(
this.$locale.baseText('nodeView.confirmMessage.initView.message'),
this.$locale.baseText('nodeView.confirmMessage.initView.headline'),
'warning',
this.$locale.baseText('nodeView.confirmMessage.initView.confirmButtonText'),
this.$locale.baseText('nodeView.confirmMessage.initView.cancelButtonText'),
true,
);
if (importConfirm === false) {
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
} else if (confirmModal === MODAL_CLOSE) {
return Promise.resolve();
}
}
@ -2259,11 +2269,13 @@ export default mixins(
deleteEveryEndpoint () {
// Check as it does not exist on first load
if (this.instance) {
const nodes = this.$store.getters.allNodes as INodeUi[];
// @ts-ignore
nodes.forEach((node: INodeUi) => this.instance.destroyDraggable(`${NODE_NAME_PREFIX}${this.$store.getters.getNodeIndex(node.name)}`));
try {
const nodes = this.$store.getters.allNodes as INodeUi[];
// @ts-ignore
nodes.forEach((node: INodeUi) => this.instance.destroyDraggable(`${NODE_NAME_PREFIX}${this.$store.getters.getNodeIndex(node.name)}`));
this.instance.deleteEveryEndpoint();
this.instance.deleteEveryEndpoint();
} catch (e) {}
}
},
matchCredentials(node: INodeUi) {
@ -2377,7 +2389,11 @@ export default mixins(
for (const sourceNode of Object.keys(connections)) {
for (const type of Object.keys(connections[sourceNode])) {
for (let sourceIndex = 0; sourceIndex < connections[sourceNode][type].length; sourceIndex++) {
connections[sourceNode][type][sourceIndex].forEach((
const outwardConnections = connections[sourceNode][type][sourceIndex];
if (!outwardConnections) {
continue;
}
outwardConnections.forEach((
targetData,
) => {
connectionData = [
@ -2629,9 +2645,6 @@ export default mixins(
const activeWorkflows = await this.restApi().getActiveWorkflows();
this.$store.commit('setActiveWorkflows', activeWorkflows);
},
async loadSettings (): Promise<void> {
await this.$store.dispatch('settings/getSettings');
},
async loadNodeTypes (): Promise<void> {
const nodeTypes = await this.restApi().getNodeTypes();
this.$store.commit('setNodeTypes', nodeTypes);
@ -2676,11 +2689,7 @@ export default mixins(
this.stopLoading();
}
},
},
async mounted () {
window.addEventListener('message', async (message) => {
async onPostMessageReceived(message: MessageEvent) {
try {
const json = JSON.parse(message.data);
if (json && json.command === 'openWorkflow') {
@ -2699,20 +2708,24 @@ export default mixins(
}
} catch (e) {
}
});
this.$root.$on('importWorkflowData', async (data: IDataObject) => {
},
async onImportWorkflowDataEvent(data: IDataObject) {
await this.importWorkflowData(data.data as IWorkflowDataUpdate);
});
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowUrl', async (data: IDataObject) => {
},
async onImportWorkflowUrlEvent(data: IDataObject) {
const workflowData = await this.getWorkflowDataFromUrl(data.url as string);
if (workflowData !== undefined) {
await this.importWorkflowData(workflowData);
}
});
},
},
async mounted () {
this.$titleReset();
window.addEventListener('message', this.onPostMessageReceived);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.startLoading();
@ -2721,7 +2734,6 @@ export default mixins(
this.loadCredentials(),
this.loadCredentialTypes(),
this.loadNodeTypes(),
this.loadSettings(),
];
try {
@ -2770,6 +2782,11 @@ export default mixins(
destroyed () {
this.resetWorkspace();
this.$store.commit('setStateDirty', false);
window.removeEventListener('message', this.onPostMessageReceived);
this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
},
});
</script>
@ -2841,6 +2858,7 @@ export default mixins(
}
.node-view-background {
background-color: var(--color-canvas-background);
position: absolute;
width: 10000px;
height: 10000px;

View file

@ -0,0 +1,193 @@
<template>
<TemplatesView :goBackEnabled="true">
<template v-slot:header>
<div v-if="!notFoundError" :class="$style.wrapper">
<div :class="$style.title">
<n8n-heading v-if="collection && collection.name" tag="h1" size="2xlarge">
{{ collection.name }}
</n8n-heading>
<n8n-text v-if="collection && collection.name" color="text-base" size="small">
{{ $locale.baseText('templates.collection') }}
</n8n-text>
<n8n-loading :loading="!collection || !collection.name" :rows="2" variant="h1" />
</div>
</div>
<div :class="$style.notFound" v-else>
<n8n-text color="text-base">{{ $locale.baseText('templates.collectionsNotFound') }}</n8n-text>
</div>
</template>
<template v-if="!notFoundError" v-slot:content>
<div :class="$style.wrapper">
<div :class="$style.mainContent">
<div :class="$style.markdown" v-if="loading || (collection && collection.description)">
<n8n-markdown
:content="collection && collection.description"
:images="collection && collection.image"
:loading="loading"
/>
</div>
<TemplateList
:infinite-scroll-enabled="false"
:loading="loading"
:use-workflow-button="true"
:workflows="loading ? [] : collectionWorkflows"
@useWorkflow="onUseWorkflow"
@openTemplate="onOpenTemplate"
/>
</div>
<div :class="$style.details">
<TemplateDetails
:block-title="$locale.baseText('template.details.appsInTheCollection')"
:loading="loading"
:template="collection"
/>
</div>
</div>
</template>
</TemplatesView>
</template>
<script lang="ts">
import TemplateDetails from '@/components/TemplateDetails.vue';
import TemplateList from '@/components/TemplateList.vue';
import TemplatesView from './TemplatesView.vue';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import {
ITemplatesCollection,
ITemplatesCollectionFull,
ITemplatesWorkflow,
ITemplatesWorkflowFull,
} from '@/Interface';
import mixins from 'vue-typed-mixins';
import { setPageTitle } from '@/components/helpers';
export default mixins(workflowHelpers).extend({
name: 'TemplatesCollectionView',
components: {
TemplateDetails,
TemplateList,
TemplatesView,
},
computed: {
collection(): null | ITemplatesCollection | ITemplatesCollectionFull {
return this.$store.getters['templates/getCollectionById'](this.collectionId);
},
collectionId(): string {
return this.$route.params.id;
},
collectionWorkflows(): Array<ITemplatesWorkflow | ITemplatesWorkflowFull> | null {
if (!this.collection) {
return null;
}
return this.collection.workflows.map(({ id }) => {
return this.$store.getters['templates/getTemplateById'](id) as ITemplatesWorkflow;
});
},
},
data() {
return {
loading: true,
notFoundError: false,
};
},
methods: {
scrollToTop() {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}, 50);
},
onOpenTemplate({event, id}: {event: MouseEvent, id: string}) {
this.navigateTo(event, 'TemplatesWorkflowView', id);
},
onUseWorkflow({event, id}: {event: MouseEvent, id: string}) {
this.$telemetry.track('User inserted workflow template', {
template_id: id,
wf_template_repo_session_id: this.$store.getters['templates/currentSessionId'],
source: 'collection',
});
this.navigateTo(event, 'WorkflowTemplate', id);
},
navigateTo(e: MouseEvent, page: string, id: string) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({ name: page, params: { id } });
window.open(route.href, '_blank');
return;
} else {
this.$router.push({ name: page, params: { id } });
}
},
},
watch: {
collection(collection: ITemplatesCollection) {
if (collection) {
setPageTitle(`n8n - Template collection: ${collection.name}`);
}
else {
setPageTitle(`n8n - Templates`);
}
},
},
async mounted() {
this.scrollToTop();
if (this.collection && (this.collection as ITemplatesCollectionFull).full) {
this.loading = false;
return;
}
try {
await this.$store.dispatch('templates/getCollectionById', this.collectionId);
} catch (e) {
this.notFoundError = true;
}
this.loading = false;
},
});
</script>
<style lang="scss" module>
.wrapper {
display: flex;
justify-content: space-between;
@media (max-width: $--breakpoint-xs) {
display: block;
}
}
.notFound {
padding-top: var(--spacing-xl);
}
.title {
width: 100%;
}
.button {
display: block;
}
.mainContent {
padding-right: var(--spacing-2xl);
margin-bottom: var(--spacing-l);
width: 100%;
@media (max-width: $--breakpoint-xs) {
padding-right: 0;
}
}
.markdown {
margin-bottom: var(--spacing-l);
}
.details {
width: 180px;
}
</style>

View file

@ -0,0 +1,396 @@
<template>
<TemplatesView>
<template v-slot:header>
<div :class="$style.wrapper">
<div :class="$style.title">
<n8n-heading tag="h1" size="2xlarge">
{{ $locale.baseText('templates.heading') }}
</n8n-heading>
</div>
<div :class="$style.button">
<n8n-button
size="medium"
:label="$locale.baseText('templates.newButton')"
@click="openNewWorkflow"
/>
</div>
</div>
</template>
<template v-slot:content>
<div :class="$style.contentWrapper">
<div :class="$style.filters">
<TemplateFilters
:categories="allCategories"
:sortOnPopulate="areCategoriesPrepopulated"
:loading="loadingCategories"
:selected="categories"
@clear="onCategoryUnselected"
@clearAll="onCategoriesCleared"
@select="onCategorySelected"
/>
</div>
<div :class="$style.search">
<n8n-input
:value="search"
:placeholder="$locale.baseText('templates.searchPlaceholder')"
@input="onSearchInput"
@blur="trackSearch"
clearable
>
<font-awesome-icon icon="search" slot="prefix" />
</n8n-input>
<div :class="$style.carouselContainer" v-show="collections.length || loadingCollections">
<div :class="$style.header">
<n8n-heading :bold="true" size="medium" color="text-light">
{{ $locale.baseText('templates.collections') }}
<span v-if="!loadingCollections" v-text="`(${collections.length})`" />
</n8n-heading>
</div>
<CollectionsCarousel
:collections="collections"
:loading="loadingCollections"
@openCollection="onOpenCollection"
/>
</div>
<TemplateList
:infinite-scroll-enabled="true"
:loading="loadingWorkflows"
:total-workflows="totalWorkflows"
:workflows="workflows"
@loadMore="onLoadMore"
@openTemplate="onOpenTemplate"
/>
<div v-if="endOfSearchMessage" :class="$style.endText">
<n8n-text size="medium" color="text-base">
<span v-html="endOfSearchMessage" />
</n8n-text>
</div>
</div>
</div>
</template>
</TemplatesView>
</template>
<script lang="ts">
import CollectionsCarousel from '@/components/CollectionsCarousel.vue';
import TemplateFilters from '@/components/TemplateFilters.vue';
import TemplateList from '@/components/TemplateList.vue';
import TemplatesView from './TemplatesView.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { ITemplatesCollection, ITemplatesWorkflow, ITemplatesQuery, ITemplatesCategory } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { IDataObject } from 'n8n-workflow';
import { setPageTitle } from '@/components/helpers';
interface ISearchEvent {
search_string: string;
workflow_results_count: number;
collection_results_count: number;
categories_applied: ITemplatesCategory[];
wf_template_repo_session_id: number;
}
export default mixins(genericHelpers).extend({
name: 'TemplatesSearchView',
components: {
CollectionsCarousel,
TemplateFilters,
TemplateList,
TemplatesView,
},
computed: {
...mapGetters('templates', ['allCategories', 'getSearchedWorkflowsTotal', 'getSearchedWorkflows', 'getSearchedCollections']),
...mapGetters('settings', ['isTemplatesEndpointReachable']),
collections(): ITemplatesCollection[] {
return this.getSearchedCollections(this.query) || [];
},
endOfSearchMessage(): string | null {
if (this.loadingWorkflows) {
return null;
}
if (this.workflows.length && this.workflows.length >= this.totalWorkflows) {
return this.$locale.baseText('templates.endResult');
}
if (!this.loadingCollections && this.workflows.length === 0 && this.collections.length === 0) {
if (!this.isTemplatesEndpointReachable && this.errorLoadingWorkflows) {
return this.$locale.baseText('templates.connectionWarning');
}
return this.$locale.baseText('templates.noSearchResults');
}
return null;
},
query(): ITemplatesQuery {
return {
categories: this.categories,
search: this.search,
};
},
nothingFound(): boolean {
return (
!this.loadingWorkflows &&
!this.loadingCollections &&
this.workflows.length === 0 &&
this.collections.length === 0
);
},
totalWorkflows(): number {
return this.getSearchedWorkflowsTotal(this.query);
},
workflows(): ITemplatesWorkflow[] {
return this.getSearchedWorkflows(this.query) || [];
},
},
data() {
return {
areCategoriesPrepopulated: false,
categories: [] as number[],
loading: true,
loadingCategories: true,
loadingCollections: true,
loadingWorkflows: true,
search: '',
searchEventToTrack: null as null | ISearchEvent,
errorLoadingWorkflows: false,
};
},
methods: {
onOpenCollection({event, id}: {event: MouseEvent, id: string}) {
this.navigateTo(event, 'TemplatesCollectionView', id);
},
onOpenTemplate({event, id}: {event: MouseEvent, id: string}) {
this.navigateTo(event, 'TemplatesWorkflowView', id);
},
navigateTo(e: MouseEvent, page: string, id: string) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({ name: page, params: { id } });
window.open(route.href, '_blank');
return;
} else {
this.$router.push({ name: page, params: { id } });
}
},
updateSearch() {
this.updateQueryParam(this.search, this.categories.join(','));
this.loadWorkflowsAndCollections(false);
},
updateSearchTracking(search: string, categories: number[]) {
if (!search) {
return;
}
if (this.searchEventToTrack && this.searchEventToTrack.search_string.length > search.length) {
return;
}
this.searchEventToTrack = {
search_string: search,
workflow_results_count: this.getSearchedWorkflowsTotal({search, categories}),
collection_results_count: this.getSearchedCollections({search, categories}).length,
categories_applied: categories.map((categoryId) =>
this.$store.getters['templates/getCategoryById'](categoryId),
) as ITemplatesCategory[],
wf_template_repo_session_id: this.$store.getters['templates/currentSessionId'],
};
},
trackSearch() {
if (this.searchEventToTrack) {
this.$telemetry.track('User searched workflow templates', this.searchEventToTrack as unknown as IDataObject);
this.searchEventToTrack = null;
}
},
openNewWorkflow() {
this.$router.push({ name: 'NodeViewNew' });
},
onSearchInput(search: string) {
this.loadingWorkflows = true;
this.loadingCollections = true;
this.search = search;
this.callDebounced('updateSearch', 500, true);
if (search.length === 0) {
this.trackSearch();
}
},
onCategorySelected(selected: number) {
this.categories = this.categories.concat(selected);
this.updateSearch();
this.trackCategories();
},
onCategoryUnselected(selected: number) {
this.categories = this.categories.filter((id) => id !== selected);
this.updateSearch();
this.trackCategories();
},
onCategoriesCleared() {
this.categories = [];
this.updateSearch();
},
trackCategories() {
if (this.categories.length) {
this.$telemetry.track('User changed template filters', {
search_string: this.search,
categories_applied: this.categories.map((categoryId: number) =>
this.$store.getters['templates/getCategoryById'](categoryId),
),
wf_template_repo_session_id: this.$store.getters['templates/currentSessionId'],
});
}
},
updateQueryParam(search: string, category: string) {
const query = Object.assign({}, this.$route.query);
if (category.length) {
query.categories = category;
} else {
delete query.categories;
}
if (search.length) {
query.search = search;
} else {
delete query.search;
}
this.$router.replace({ query });
},
async onLoadMore() {
if (this.workflows.length >= this.totalWorkflows) {
return;
}
try {
this.loadingWorkflows = true;
await this.$store.dispatch('templates/getMoreWorkflows', {
categories: this.categories,
search: this.search,
});
} catch (e) {
this.$showMessage({
title: 'Error',
message: 'Could not load more workflows',
type: 'error',
});
} finally {
this.loadingWorkflows = false;
}
},
async loadCategories() {
try {
await this.$store.dispatch('templates/getCategories');
} catch (e) {
}
this.loadingCategories = false;
},
async loadCollections() {
try {
this.loadingCollections = true;
await this.$store.dispatch('templates/getCollections', {
categories: this.categories,
search: this.search,
});
} catch (e) {
}
this.loadingCollections = false;
},
async loadWorkflows() {
try {
this.loadingWorkflows = true;
await this.$store.dispatch('templates/getWorkflows', {
search: this.search,
categories: this.categories,
});
this.errorLoadingWorkflows = false;
} catch (e) {
this.errorLoadingWorkflows = true;
}
this.loadingWorkflows = false;
},
async loadWorkflowsAndCollections(initialLoad: boolean) {
const search = this.search;
const categories = [...this.categories];
await Promise.all([this.loadWorkflows(), this.loadCollections()]);
if (!initialLoad) {
this.updateSearchTracking(search, categories);
}
},
scrollToTop() {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}, 100);
},
},
watch: {
workflows(newWorkflows) {
if (newWorkflows.length === 0) {
this.scrollToTop();
}
},
},
beforeRouteLeave(to, from, next) {
this.trackSearch();
next();
},
async mounted() {
setPageTitle('n8n - Templates');
this.loadCategories();
this.loadWorkflowsAndCollections(true);
},
async created() {
if (this.$route.query.search && typeof this.$route.query.search === 'string') {
this.search = this.$route.query.search;
}
if (typeof this.$route.query.categories === 'string' && this.$route.query.categories.length) {
this.categories = this.$route.query.categories.split(',').map((categoryId) => parseInt(categoryId, 10));
this.areCategoriesPrepopulated = true;
}
},
});
</script>
<style lang="scss" module>
.wrapper {
display: flex;
justify-content: space-between;
}
.contentWrapper {
display: flex;
justify-content: space-between;
@media (max-width: $--breakpoint-xs) {
flex-direction: column;
}
}
.filters {
width: 200px;
margin-bottom: var(--spacing-xl);
}
.search {
width: 100%;
padding-left: var(--spacing-2xl);
> * {
margin-bottom: var(--spacing-l);
}
@media (max-width: $--breakpoint-xs) {
padding-left: 0;
}
}
.header {
margin-bottom: var(--spacing-2xs);
}
</style>

View file

@ -0,0 +1,81 @@
<template>
<div :class="$style.template">
<div :class="isMenuCollapsed ? $style.menu : $style.expandedMenu"></div>
<div :class="$style.container">
<div :class="$style.header">
<div :class="$style.goBack" v-if="goBackEnabled">
<GoBackButton />
</div>
<slot name="header"></slot>
</div>
<div>
<slot name="content"></slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import GoBackButton from '@/components/GoBackButton.vue';
export default Vue.extend({
name: 'TemplatesView',
components: {
GoBackButton,
},
props: {
goBackEnabled: {
type: Boolean,
default: false,
},
},
computed: {
isMenuCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
},
});
</script>
<style lang="scss" module>
.mockMenu {
height: 100%;
min-height: 100vh;
}
.menu {
composes: mockMenu;
min-width: $--sidebar-width;
}
.expandedMenu {
composes: mockMenu;
min-width: $--sidebar-expanded-width;
}
.template {
display: flex;
}
.container {
width: 100%;
max-width: 1024px;
padding: var(--spacing-3xl) var(--spacing-3xl) var(--spacing-4xl) var(--spacing-3xl);
margin: 0 auto;
@media (max-width: $--breakpoint-md) {
width: 900px;
}
}
.header {
display: flex;
flex-direction: column;
margin-bottom: var(--spacing-2xl);
}
.goBack {
margin-bottom: var(--spacing-2xs);
}
</style>

View file

@ -0,0 +1,194 @@
<template>
<TemplatesView :goBackEnabled="true">
<template v-slot:header>
<div v-if="!notFoundError" :class="$style.wrapper">
<div :class="$style.title">
<n8n-heading v-if="template && template.name" tag="h1" size="2xlarge">{{
template.name
}}</n8n-heading>
<n8n-text v-if="template && template.name" color="text-base" size="small">
{{ $locale.baseText('templates.workflow') }}
</n8n-text>
<n8n-loading :loading="!template || !template.name" :rows="2" variant="h1" />
</div>
<div :class="$style.button">
<n8n-button
v-if="template"
:label="$locale.baseText('template.buttons.useThisWorkflowButton')"
size="large"
@click="navigateTo(template.id, 'WorkflowTemplate', $event)"
/>
<n8n-loading :loading="!template" :rows="1" variant="button" />
</div>
</div>
<div :class="$style.notFound" v-else>
<n8n-text color="text-base">{{ $locale.baseText('templates.workflowsNotFound') }}</n8n-text>
</div>
</template>
<template v-if="!notFoundError" v-slot:content>
<div :class="$style.image">
<WorkflowPreview
v-if="showPreview"
:loading="loading"
:workflow="template && template.workflow"
@close="onHidePreview"
/>
</div>
<div :class="$style.content">
<div :class="$style.markdown">
<n8n-markdown
:content="template && template.description"
:images="template && template.image"
:loading="loading"
/>
</div>
<div :class="$style.details">
<TemplateDetails
:block-title="$locale.baseText('template.details.appsInTheWorkflow')"
:loading="loading"
:template="template"
/>
</div>
</div>
</template>
</TemplatesView>
</template>
<script lang="ts">
import TemplateDetails from '@/components/TemplateDetails.vue';
import TemplatesView from './TemplatesView.vue';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { ITemplatesWorkflow, ITemplatesWorkflowFull } from '@/Interface';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { setPageTitle } from '@/components/helpers';
export default mixins(workflowHelpers).extend({
name: 'TemplatesWorkflowView',
components: {
TemplateDetails,
TemplatesView,
WorkflowPreview,
},
computed: {
template(): ITemplatesWorkflow | ITemplatesWorkflowFull {
return this.$store.getters['templates/getTemplateById'](this.templateId);
},
templateId() {
return this.$route.params.id;
},
},
data() {
return {
loading: true,
showPreview: true,
notFoundError: false,
};
},
methods: {
navigateTo(id: string, page: string, e: PointerEvent) {
if (page === 'WorkflowTemplate') {
this.$telemetry.track('User inserted workflow template', {
source: 'workflow',
template_id: id,
wf_template_repo_session_id: this.$store.getters['templates/currentSessionId'],
});
}
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({ name: page, params: { id } });
window.open(route.href, '_blank');
return;
} else {
this.$router.push({ name: page, params: { id } });
}
},
onHidePreview() {
this.showPreview = false;
},
scrollToTop() {
window.scrollTo({
top: 0,
});
},
},
watch: {
template(template: ITemplatesWorkflowFull) {
if (template) {
setPageTitle(`n8n - Template template: ${template.name}`);
}
else {
setPageTitle(`n8n - Templates`);
}
},
},
async mounted() {
this.scrollToTop();
if (this.template && (this.template as ITemplatesWorkflowFull).full) {
this.loading = false;
return;
}
try {
await this.$store.dispatch('templates/getTemplateById', this.templateId);
} catch (e) {
this.notFoundError = true;
}
this.loading = false;
},
});
</script>
<style lang="scss" module>
.wrapper {
display: flex;
justify-content: space-between;
}
.notFound {
padding-top: var(--spacing-xl);
}
.title {
width: 75%;
}
.button {
display: block;
}
.image {
width: 100%;
img {
width: 100%;
}
}
.content {
padding: var(--spacing-2xl) 0;
display: flex;
justify-content: space-between;
@media (max-width: $--breakpoint-xs) {
display: block;
}
}
.markdown {
width: calc(100% - 180px);
padding-right: var(--spacing-2xl);
margin-bottom: var(--spacing-l);
@media (max-width: $--breakpoint-xs) {
width: 100%;
}
}
.details {
width: 180px;
}
</style>

1
packages/editor-ui/src/vue-agile.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'vue-agile';

View file

@ -1,4 +1,5 @@
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
const webpack = require('webpack');
module.exports = {
chainWebpack: config => {
@ -26,6 +27,7 @@ module.exports = {
},
plugins: [
new MonacoWebpackPlugin({ languages: ['javascript', 'json', 'typescript'] }),
new webpack.NormalModuleReplacementPlugin(/element-ui[\/\\]lib[\/\\]locale[\/\\]lang[\/\\]zh-CN/, 'element-ui/lib/locale/lang/en'),
],
},
css: {