diff --git a/.gitignore b/.gitignore
index 1f5d3156a..951a0d441 100644
--- a/.gitignore
+++ b/.gitignore
@@ -147,4 +147,7 @@ docs/.vitepress/cache/
/cypress/fixtures/user-dir/flows.json
/cypress/fixtures/user-dir/.flows.json.backup
/cypress/screenshots
-/cypress/downloads
\ No newline at end of file
+/cypress/downloads
+
+# Generated by scripts/build-vue-editor-data.js
+/resources/vue-editor/vuetify.json
diff --git a/docs/en/nodes/widgets/ui-template.md b/docs/en/nodes/widgets/ui-template.md
index 40993cf18..6e4829f11 100644
--- a/docs/en/nodes/widgets/ui-template.md
+++ b/docs/en/nodes/widgets/ui-template.md
@@ -38,6 +38,16 @@ Provide custom JS and HTML (including any [Vuetify components](https://vuetifyjs
+## Code Editor
+
+When Node-RED uses its default Monaco code editor, the template editor understands Vue templates:
+
+- Vue syntax is highlighted, including directives (`v-if`, `v-for`, ...), bindings (`:prop`), events (`@click`), slots (`#activator`) and `{{ }}` expressions, which are highlighted as JavaScript.
+- Typing `` inside a component, that component's slots (`#`). Props with a fixed set of values, such as `variant`, suggest those values.
+- Hovering over a Vuetify component, prop, event or slot shows its type, default and description, with a link to the Vuetify API docs.
+
+The component information matches the version of Vuetify that ships with the Dashboard and is loaded from Node-RED itself, so it works offline. Editors using ACE keep the plain HTML mode.
+
## Writing Custom Widgets
UI Template will parse different tags and render them into Dashboard. The available tags are:
diff --git a/nodes/plugins/vue-editor.html b/nodes/plugins/vue-editor.html
new file mode 100644
index 000000000..57fd7d54e
--- /dev/null
+++ b/nodes/plugins/vue-editor.html
@@ -0,0 +1,97 @@
+
diff --git a/nodes/widgets/ui_template.html b/nodes/widgets/ui_template.html
index ee3f5f726..f20bdfffc 100644
--- a/nodes/widgets/ui_template.html
+++ b/nodes/widgets/ui_template.html
@@ -183,6 +183,11 @@
const that = this
const $templateScope = $('#node-input-templateScope')
+ // Monaco gets Vue/Vuetify syntax and completions from the ui-template-vue-editor plugin (nodes/plugins/vue-editor.html).
+ // ACE, Monaco without the plugin, or an nr-assistant too old for vue-sfc keeps the HTML mode.
+ const vueEditor = RED.plugins.getPlugin('ui-template-vue-editor')
+ const htmlMode = vueEditor?.useVueSfc?.() ? 'ace/mode/vue-sfc' : 'ace/mode/html'
+
// if this groups parent is a subflow template, set the node-config-input-width and node-config-input-height up
// as typedInputs and hide the elementSizer (as it doesn't make sense for subflow templates)
if (RED.nodes.subflow(this.z)) {
@@ -239,15 +244,15 @@
break
case 'widget:ui':
$('#template-row-ui').show()
- that.editor.getSession().setMode('ace/mode/html')
+ that.editor.getSession().setMode(htmlMode)
break
case 'widget:page':
$('#template-row-page').show()
- that.editor.getSession().setMode('ace/mode/html')
+ that.editor.getSession().setMode(htmlMode)
break
default:
$('#template-row-group, #template-row-class').show()
- that.editor.getSession().setMode('ace/mode/html')
+ that.editor.getSession().setMode(htmlMode)
break
}
resize.call(that)
@@ -255,7 +260,7 @@
this.editor = RED.editor.createEditor({
id: 'node-input-format-editor',
- mode: 'ace/mode/html',
+ mode: htmlMode,
value: $('#node-input-format').val()
})
@@ -263,7 +268,7 @@
url: 'uitemplates', // where to get the data from
type: 'ui-template', // the type of object the library is for
editor: this.editor, // the field name the main text body goes to
- mode: 'ace/mode/html',
+ mode: htmlMode,
fields: ['name']
})
@@ -275,7 +280,7 @@
e.preventDefault()
const value = that.editor.getValue()
RED.editor.editText({
- mode: $templateScope.val() === 'global' ? 'css' : 'html',
+ mode: $templateScope.val() === 'global' ? 'css' : htmlMode.replace('ace/mode/', ''),
value,
width: 'Infinity',
cursor: that.editor.getCursorPosition(),
diff --git a/package.json b/package.json
index 025c4e6b1..c761baabc 100644
--- a/package.json
+++ b/package.json
@@ -31,15 +31,17 @@
],
"files": [
"dist/*",
- "nodes/*"
+ "nodes/*",
+ "resources/*"
],
"scripts": {
- "build": "vite build",
- "build:dev": "cross-env NODE_ENV=development vite build --mode development",
+ "build": "npm run build:vue-editor && vite build",
+ "build:dev": "npm run build:vue-editor && cross-env NODE_ENV=development vite build --mode development",
+ "build:vue-editor": "node scripts/build-vue-editor-data.js",
"cy:open": "cypress open",
"cy:run": "cypress run",
"cy:server": "node-red -u ./cypress/fixtures/user-dir/ -p 1881",
- "dev": "cross-env NODE_ENV=development vite build --mode development --watch",
+ "dev": "npm run build:vue-editor && cross-env NODE_ENV=development vite build --mode development --watch",
"dev:prod": "vite build --watch",
"docs:build": "vitepress build docs",
"docs:dev": "vitepress dev docs",
@@ -157,6 +159,9 @@
"ui-event": "nodes/widgets/ui_event.js",
"ui-control": "nodes/widgets/ui_control.js",
"ui-progress": "nodes/widgets/ui_progress.js"
+ },
+ "plugins": {
+ "ui-template-vue-editor": "nodes/plugins/vue-editor.html"
}
},
"overrides": {
diff --git a/resources/vue-editor/vue-sfc.mjs b/resources/vue-editor/vue-sfc.mjs
new file mode 100644
index 000000000..d5bbb5882
--- /dev/null
+++ b/resources/vue-editor/vue-sfc.mjs
@@ -0,0 +1,608 @@
+/* eslint-env browser */
+/**
+ * Vue single file component support for Node-RED's Monaco code editor, used by the ui-template node.
+ *
+ * Loaded on demand by nodes/plugins/vue-editor.html the first time an editor uses the `vue-sfc` language.
+ * It provides:
+ * - a Monarch tokenizer for Vue templates (directives, bindings, events, slots and {{ }} interpolation
+ * are tokenized with JavaScript embedded; \n\n\n')).equal('v-menu')
+ should(vue.getParentTag('\n\n')).equal('v-card')
+ should(vue.getParentTag('
')).equal('v-card')
+ should(vue.getParentTag('')).be.null()
+ })
+ })
+
+ describe('getCompletions', function () {
+ const range = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }
+ let catalog
+ before(function () { catalog = vue.createCatalog(data) })
+ const labels = (items) => items.map(i => i.label)
+
+ it('offers Vuetify and Vue tags, PascalCase when typing a capital', function () {
+ labels(vue.getCompletions(monaco, catalog, { kind: 'tagName', prefix: 'v' }, range)).should.containDeep(['v-btn', 'v-menu', 'transition'])
+ labels(vue.getCompletions(monaco, catalog, { kind: 'tagName', prefix: 'V' }, range)).should.containDeep(['VBtn', 'VMenu'])
+ })
+ it('offers props, events, directives for an attribute name', function () {
+ const items = vue.getCompletions(monaco, catalog, { kind: 'attributeName', tag: 'v-btn', prefix: '' }, range)
+ labels(items).should.containDeep(['variant', 'color', 'block', 'v-if', 'v-for', 'v-ripple', 'key', '@click'])
+ items.find(i => i.label === 'block').insertText.should.equal('block') // boolean prop needs no value
+ items.find(i => i.label === 'color').insertText.should.equal('color="$1"')
+ })
+ it('offers bound props for : and v-bind:', function () {
+ labels(vue.getCompletions(monaco, catalog, { kind: 'attributeName', tag: 'v-btn', prefix: ':' }, range)).should.eql([':variant', ':color', ':block'])
+ labels(vue.getCompletions(monaco, catalog, { kind: 'attributeName', tag: 'VBtn', prefix: 'v-bind:co' }, range)).should.containDeep(['v-bind:color'])
+ })
+ it('offers component and DOM events for @', function () {
+ const items = labels(vue.getCompletions(monaco, catalog, { kind: 'attributeName', tag: 'v-menu', prefix: '@' }, range))
+ items[0].should.equal('@update:model-value')
+ items.should.containDeep(['@click', '@keydown'])
+ })
+ it('offers the parent component slots on a ', function () {
+ const items = vue.getCompletions(monaco, catalog, { kind: 'attributeName', tag: 'template', prefix: '#', textBeforeTag: '\n ' }, range)
+ labels(items).should.eql(['#activator', '#default'])
+ })
+ it('offers literal values for a plain prop', function () {
+ labels(vue.getCompletions(monaco, catalog, { kind: 'attributeValue', tag: 'v-btn', attribute: 'variant', prefix: '' }, range)).should.eql(['flat', 'text', 'outlined'])
+ vue.getCompletions(monaco, catalog, { kind: 'attributeValue', tag: 'v-btn', attribute: ':variant', prefix: '' }, range).should.be.empty()
+ })
+ it('returns nothing for unknown components', function () {
+ labels(vue.getCompletions(monaco, catalog, { kind: 'attributeName', tag: 'my-widget', prefix: ':' }, range)).should.be.empty()
+ })
+ })
+
+ describe('getHover', function () {
+ let catalog
+ before(function () { catalog = vue.createCatalog(data) })
+
+ it('describes props, events, slots and directives', function () {
+ vue.getHover(catalog, 'variant', { kind: 'attributeName', tag: 'v-btn' }).should.match(/\*\*variant\*\* \(v-btn prop\)[\s\S]*Applies a distinct style[\s\S]*Default: `'elevated'`[\s\S]*vuetifyjs\.com\/api\/v-btn#props/)
+ vue.getHover(catalog, ':color', { kind: 'attributeName', tag: 'v-btn' }).should.match(/\*\*color\*\*/)
+ vue.getHover(catalog, '@update:model-value', { kind: 'attributeName', tag: 'v-menu' }).should.match(/v-menu event/)
+ vue.getHover(catalog, '#activator', { kind: 'attributeName', tag: 'template', textBeforeTag: '' }).should.match(/v-menu slot/)
+ vue.getHover(catalog, 'v-if', { kind: 'attributeName', tag: 'v-btn' }).should.match(/vuejs\.org\/api\/built-in-directives\.html#v-if/)
+ vue.getHover(catalog, 'v-btn', { kind: 'tagName' }).should.match(/vuetifyjs\.com\/api\/v-btn/)
+ })
+ it('returns null for unknown names', function () {
+ should(vue.getHover(catalog, 'nope', { kind: 'attributeName', tag: 'v-btn' })).be.null()
+ should(vue.getHover(catalog, 'variant', null)).be.null()
+ })
+ })
+})