diff --git a/package-lock.json b/package-lock.json index e5e85fc76b..99461f2cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 9bd78931a3..85544254c2 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -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, diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index f92b91427a..a2f9a2235a 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -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 { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 564a0e0dbd..70baa21fcc 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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'), + }, }; } diff --git a/packages/design-system/.storybook/font-awesome-icons.js b/packages/design-system/.storybook/font-awesome-icons.js index ca24a7f7a8..d839486fc0 100644 --- a/packages/design-system/.storybook/font-awesome-icons.js +++ b/packages/design-system/.storybook/font-awesome-icons.js @@ -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); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 6a209215e6..bb412f9cde 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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" } } diff --git a/packages/design-system/src/components/N8nLoading/Loading.stories.js b/packages/design-system/src/components/N8nLoading/Loading.stories.js new file mode 100644 index 0000000000..2d08e3a409 --- /dev/null +++ b/packages/design-system/src/components/N8nLoading/Loading.stories.js @@ -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', +}; diff --git a/packages/design-system/src/components/N8nLoading/Loading.vue b/packages/design-system/src/components/N8nLoading/Loading.vue new file mode 100644 index 0000000000..011a8bfab5 --- /dev/null +++ b/packages/design-system/src/components/N8nLoading/Loading.vue @@ -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> diff --git a/packages/design-system/src/components/N8nLoading/index.js b/packages/design-system/src/components/N8nLoading/index.js new file mode 100644 index 0000000000..a7420bd670 --- /dev/null +++ b/packages/design-system/src/components/N8nLoading/index.js @@ -0,0 +1,3 @@ +import N8nLoading from './Loading.vue'; + +export default N8nLoading; diff --git a/packages/design-system/src/components/N8nMarkdown/Markdown.stories.js b/packages/design-system/src/components/N8nMarkdown/Markdown.stories.js new file mode 100644 index 0000000000..bd77554449 --- /dev/null +++ b/packages/design-system/src/components/N8nMarkdown/Markdown.stories.js @@ -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. It’ll go to the false branch and notify me in telegram.\n\n**Workflow:**\n\n\n\n**Sample Response:**\n\n\n`, + loading: false, + images: [{ + id: 1, + url: 'https://community.n8n.io/uploads/default/optimized/2X/b/b737a95de4dfe0825d50ca098171e9f33a459e74_2_690x288.png', + }], +}; diff --git a/packages/design-system/src/components/N8nMarkdown/Markdown.vue b/packages/design-system/src/components/N8nMarkdown/Markdown.vue new file mode 100644 index 0000000000..b94d93ac3c --- /dev/null +++ b/packages/design-system/src/components/N8nMarkdown/Markdown.vue @@ -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> diff --git a/packages/design-system/src/components/N8nMarkdown/index.js b/packages/design-system/src/components/N8nMarkdown/index.js new file mode 100644 index 0000000000..3751d0c839 --- /dev/null +++ b/packages/design-system/src/components/N8nMarkdown/index.js @@ -0,0 +1,3 @@ +import N8nMarkdown from './Markdown.vue'; + +export default N8nMarkdown; diff --git a/packages/design-system/src/components/N8nTag/Tag.stories.js b/packages/design-system/src/components/N8nTag/Tag.stories.js new file mode 100644 index 0000000000..c204d5c2dd --- /dev/null +++ b/packages/design-system/src/components/N8nTag/Tag.stories.js @@ -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', +}; diff --git a/packages/design-system/src/components/N8nTag/Tag.vue b/packages/design-system/src/components/N8nTag/Tag.vue new file mode 100644 index 0000000000..401e4f24b5 --- /dev/null +++ b/packages/design-system/src/components/N8nTag/Tag.vue @@ -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> diff --git a/packages/design-system/src/components/N8nTag/index.js b/packages/design-system/src/components/N8nTag/index.js new file mode 100644 index 0000000000..5e8a0d45a6 --- /dev/null +++ b/packages/design-system/src/components/N8nTag/index.js @@ -0,0 +1,3 @@ +import Tag from './Tag.vue'; + +export default Tag; diff --git a/packages/design-system/src/components/N8nTags/Tags.stories.js b/packages/design-system/src/components/N8nTags/Tags.stories.js new file mode 100644 index 0000000000..5b3370f349 --- /dev/null +++ b/packages/design-system/src/components/N8nTags/Tags.stories.js @@ -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', + }, + ], +}; diff --git a/packages/design-system/src/components/N8nTags/Tags.vue b/packages/design-system/src/components/N8nTags/Tags.vue new file mode 100644 index 0000000000..cad6716ae3 --- /dev/null +++ b/packages/design-system/src/components/N8nTags/Tags.vue @@ -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> diff --git a/packages/design-system/src/components/N8nTags/index.js b/packages/design-system/src/components/N8nTags/index.js new file mode 100644 index 0000000000..ebf28f6470 --- /dev/null +++ b/packages/design-system/src/components/N8nTags/index.js @@ -0,0 +1,3 @@ +import Tags from './Tags.vue'; + +export default Tags; diff --git a/packages/design-system/src/components/index.js b/packages/design-system/src/components/index.js index a82ddc6e3f..c62ee197e9 100644 --- a/packages/design-system/src/components/index.js +++ b/packages/design-system/src/components/index.js @@ -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, diff --git a/packages/design-system/src/shims-element-ui.d.ts b/packages/design-system/src/shims-element-ui.d.ts index e59e86f062..645df50e69 100644 --- a/packages/design-system/src/shims-element-ui.d.ts +++ b/packages/design-system/src/shims-element-ui.d.ts @@ -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'; diff --git a/packages/design-system/src/utils/markdown.ts b/packages/design-system/src/utils/markdown.ts new file mode 100644 index 0000000000..8d757580d3 --- /dev/null +++ b/packages/design-system/src/utils/markdown.ts @@ -0,0 +1,12 @@ +export const escapeMarkdown = (html: string | undefined): string => { + if (!html) { + return ''; + } + const escaped = html.replace(/</g, "<").replace(/>/g, ">"); + // unescape greater than quotes at start of line + const withQuotes = escaped.replace(/^((\s)*(>)+)+\s*/gm, (matches) => { + return matches.replace(/>/g, '>'); + }); + + return withQuotes; +}; diff --git a/packages/design-system/theme/src/index.scss b/packages/design-system/theme/src/index.scss index 353317e1c3..953f306f1a 100644 --- a/packages/design-system/theme/src/index.scss +++ b/packages/design-system/theme/src/index.scss @@ -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"; diff --git a/packages/design-system/theme/src/skeleton.scss b/packages/design-system/theme/src/skeleton.scss new file mode 100644 index 0000000000..7a0dc72ec5 --- /dev/null +++ b/packages/design-system/theme/src/skeleton.scss @@ -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%; + } +} diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 05faa59e9d..0e221856a9 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -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", diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index c248d1f626..0487c89a1c 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -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; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 08460e715d..d6b33d0091 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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 { diff --git a/packages/editor-ui/src/api/settings.ts b/packages/editor-ui/src/api/settings.ts index 6609524149..25702a29b1 100644 --- a/packages/editor-ui/src/api/settings.ts +++ b/packages/editor-ui/src/api/settings.ts @@ -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}); } diff --git a/packages/editor-ui/src/api/templates.ts b/packages/editor-ui/src/api/templates.ts new file mode 100644 index 0000000000..15efc8e8d7 --- /dev/null +++ b/packages/editor-ui/src/api/templates.ts @@ -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); +} diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index 89c2569347..f8ee6ed79f 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -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}`); -} diff --git a/packages/editor-ui/src/components/Card.vue b/packages/editor-ui/src/components/Card.vue new file mode 100644 index 0000000000..670f92d2d9 --- /dev/null +++ b/packages/editor-ui/src/components/Card.vue @@ -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> diff --git a/packages/editor-ui/src/components/CollectionCard.vue b/packages/editor-ui/src/components/CollectionCard.vue new file mode 100644 index 0000000000..8a74fc7ee9 --- /dev/null +++ b/packages/editor-ui/src/components/CollectionCard.vue @@ -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> diff --git a/packages/editor-ui/src/components/CollectionsCarousel.vue b/packages/editor-ui/src/components/CollectionsCarousel.vue new file mode 100644 index 0000000000..6c536138a1 --- /dev/null +++ b/packages/editor-ui/src/components/CollectionsCarousel.vue @@ -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> diff --git a/packages/editor-ui/src/components/CredentialsList.vue b/packages/editor-ui/src/components/CredentialsList.vue index d8154d5072..2fef894871 100644 --- a/packages/editor-ui/src/components/CredentialsList.vue +++ b/packages/editor-ui/src/components/CredentialsList.vue @@ -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 }); }, diff --git a/packages/editor-ui/src/components/CredentialsSelectModal.vue b/packages/editor-ui/src/components/CredentialsSelectModal.vue index 2f4af064af..3f019fbb73 100644 --- a/packages/editor-ui/src/components/CredentialsSelectModal.vue +++ b/packages/editor-ui/src/components/CredentialsSelectModal.vue @@ -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, }; }, diff --git a/packages/editor-ui/src/components/GoBackButton.vue b/packages/editor-ui/src/components/GoBackButton.vue new file mode 100644 index 0000000000..082444dd4b --- /dev/null +++ b/packages/editor-ui/src/components/GoBackButton.vue @@ -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> diff --git a/packages/editor-ui/src/components/HoverableNodeIcon.vue b/packages/editor-ui/src/components/HoverableNodeIcon.vue new file mode 100644 index 0000000000..582da3866f --- /dev/null +++ b/packages/editor-ui/src/components/HoverableNodeIcon.vue @@ -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> diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index a044b0b6e1..cdc2f6434b 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -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"/> + <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"/> <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"/> + <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"/> @@ -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 { diff --git a/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue index 0fcc5eda09..44fa813cd8 100644 --- a/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue +++ b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue @@ -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; } }, diff --git a/packages/editor-ui/src/components/NodeList.vue b/packages/editor-ui/src/components/NodeList.vue new file mode 100644 index 0000000000..bf9f04cea1 --- /dev/null +++ b/packages/editor-ui/src/components/NodeList.vue @@ -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> diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index f3579e7cb4..6ebe6fc591 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -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(); diff --git a/packages/editor-ui/src/components/Telemetry.vue b/packages/editor-ui/src/components/Telemetry.vue index ac7978b513..159d5944d0 100644 --- a/packages/editor-ui/src/components/Telemetry.vue +++ b/packages/editor-ui/src/components/Telemetry.vue @@ -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> diff --git a/packages/editor-ui/src/components/TemplateCard.vue b/packages/editor-ui/src/components/TemplateCard.vue new file mode 100644 index 0000000000..e4dcbf8f05 --- /dev/null +++ b/packages/editor-ui/src/components/TemplateCard.vue @@ -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> diff --git a/packages/editor-ui/src/components/TemplateDetails.vue b/packages/editor-ui/src/components/TemplateDetails.vue new file mode 100644 index 0000000000..7ba252f857 --- /dev/null +++ b/packages/editor-ui/src/components/TemplateDetails.vue @@ -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> diff --git a/packages/editor-ui/src/components/TemplateDetailsBlock.vue b/packages/editor-ui/src/components/TemplateDetailsBlock.vue new file mode 100644 index 0000000000..aa6bf787e2 --- /dev/null +++ b/packages/editor-ui/src/components/TemplateDetailsBlock.vue @@ -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> diff --git a/packages/editor-ui/src/components/TemplateFilters.vue b/packages/editor-ui/src/components/TemplateFilters.vue new file mode 100644 index 0000000000..41103bfde8 --- /dev/null +++ b/packages/editor-ui/src/components/TemplateFilters.vue @@ -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> diff --git a/packages/editor-ui/src/components/TemplateList.vue b/packages/editor-ui/src/components/TemplateList.vue new file mode 100644 index 0000000000..7f9fcc685b --- /dev/null +++ b/packages/editor-ui/src/components/TemplateList.vue @@ -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> diff --git a/packages/editor-ui/src/components/WorkflowOpen.vue b/packages/editor-ui/src/components/WorkflowOpen.vue index 4a927519ec..84edc06973 100644 --- a/packages/editor-ui/src/components/WorkflowOpen.vue +++ b/packages/editor-ui/src/components/WorkflowOpen.vue @@ -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) { diff --git a/packages/editor-ui/src/components/WorkflowPreview.vue b/packages/editor-ui/src/components/WorkflowPreview.vue new file mode 100644 index 0000000000..c05ba94688 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowPreview.vue @@ -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> diff --git a/packages/editor-ui/src/components/helpers.ts b/packages/editor-ui/src/components/helpers.ts index a15a7396ae..d2c4b2d3fa 100644 --- a/packages/editor-ui/src/components/helpers.ts +++ b/packages/editor-ui/src/components/helpers.ts @@ -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; +} diff --git a/packages/editor-ui/src/components/mixins/copyPaste.ts b/packages/editor-ui/src/components/mixins/copyPaste.ts index 98852c890d..4e368739f7 100644 --- a/packages/editor-ui/src/components/mixins/copyPaste.ts +++ b/packages/editor-ui/src/components/mixins/copyPaste.ts @@ -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); + } + }, }); diff --git a/packages/editor-ui/src/components/mixins/genericHelpers.ts b/packages/editor-ui/src/components/mixins/genericHelpers.ts index 15502f9ff9..9b015f0ac3 100644 --- a/packages/editor-ui/src/components/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/components/mixins/genericHelpers.ts @@ -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); diff --git a/packages/editor-ui/src/components/mixins/showMessage.ts b/packages/editor-ui/src/components/mixins/showMessage.ts index 9bb83b8f50..a7237e062d 100644 --- a/packages/editor-ui/src/components/mixins/showMessage.ts +++ b/packages/editor-ui/src/components/mixins/showMessage.ts @@ -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) { diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 2e6644a54e..885a4aa65b 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -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', }); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index a4001cef03..e7fd5d5e80 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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', +]; diff --git a/packages/editor-ui/src/modules/credentials.ts b/packages/editor-ui/src/modules/credentials.ts index efe5e4db23..c890e8a61d 100644 --- a/packages/editor-ui/src/modules/credentials.ts +++ b/packages/editor-ui/src/modules/credentials.ts @@ -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); }, diff --git a/packages/editor-ui/src/modules/settings.ts b/packages/editor-ui/src/modules/settings.ts index 3332f2cc92..ff427f4bc6 100644 --- a/packages/editor-ui/src/modules/settings.ts +++ b/packages/editor-ui/src/modules/settings.ts @@ -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); + }, }, }; diff --git a/packages/editor-ui/src/modules/templates.ts b/packages/editor-ui/src/modules/templates.ts new file mode 100644 index 0000000000..44c0c379f9 --- /dev/null +++ b/packages/editor-ui/src/modules/templates.ts @@ -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; diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index 331828bbe1..bd9cb9b44d 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -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) => { diff --git a/packages/editor-ui/src/modules/workflows.ts b/packages/editor-ui/src/modules/workflows.ts index 3654c13de5..8a44f11243 100644 --- a/packages/editor-ui/src/modules/workflows.ts +++ b/packages/editor-ui/src/modules/workflows.ts @@ -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; \ No newline at end of file +export default module; diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index 7d865c78b9..acd8ab1c75 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -2,11 +2,6 @@ @import "~n8n-design-system/theme/dist/index.css"; - -body { - background-color: var(--color-canvas-background); -} - .clickable { cursor: pointer; } diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 24856be949..7c84fa12a6 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -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, }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 005c955d74..806270c2ac 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -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" + } } } diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index 1a6d086ed6..864f5cc940 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -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); diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index 146d4d1f48..a44e8769d3 100644 --- a/packages/editor-ui/src/plugins/telemetry/index.ts +++ b/packages/editor-ui/src/plugins/telemetry/index.ts @@ -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); }); } diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index bfd54b0dba..30fe2bc48d 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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, + }, + }, }, ], }); diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 8cd1d6c643..e97ab47731 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -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, diff --git a/packages/editor-ui/src/views/LoadingView.vue b/packages/editor-ui/src/views/LoadingView.vue new file mode 100644 index 0000000000..55a051b3d3 --- /dev/null +++ b/packages/editor-ui/src/views/LoadingView.vue @@ -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> diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 14810077ca..15be05eac3 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -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; diff --git a/packages/editor-ui/src/views/TemplatesCollectionView.vue b/packages/editor-ui/src/views/TemplatesCollectionView.vue new file mode 100644 index 0000000000..e1c71f6616 --- /dev/null +++ b/packages/editor-ui/src/views/TemplatesCollectionView.vue @@ -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> diff --git a/packages/editor-ui/src/views/TemplatesSearchView.vue b/packages/editor-ui/src/views/TemplatesSearchView.vue new file mode 100644 index 0000000000..57fb46da6e --- /dev/null +++ b/packages/editor-ui/src/views/TemplatesSearchView.vue @@ -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> diff --git a/packages/editor-ui/src/views/TemplatesView.vue b/packages/editor-ui/src/views/TemplatesView.vue new file mode 100644 index 0000000000..9a3a830065 --- /dev/null +++ b/packages/editor-ui/src/views/TemplatesView.vue @@ -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> diff --git a/packages/editor-ui/src/views/TemplatesWorkflowView.vue b/packages/editor-ui/src/views/TemplatesWorkflowView.vue new file mode 100644 index 0000000000..765de0f8a0 --- /dev/null +++ b/packages/editor-ui/src/views/TemplatesWorkflowView.vue @@ -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> diff --git a/packages/editor-ui/src/vue-agile.d.ts b/packages/editor-ui/src/vue-agile.d.ts new file mode 100644 index 0000000000..23b389c3eb --- /dev/null +++ b/packages/editor-ui/src/vue-agile.d.ts @@ -0,0 +1 @@ +declare module 'vue-agile'; diff --git a/packages/editor-ui/vue.config.js b/packages/editor-ui/vue.config.js index 35338dd497..b001f73168 100644 --- a/packages/editor-ui/vue.config.js +++ b/packages/editor-ui/vue.config.js @@ -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: {