Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,7 @@ docs/.vitepress/cache/
/cypress/fixtures/user-dir/flows.json
/cypress/fixtures/user-dir/.flows.json.backup
/cypress/screenshots
/cypress/downloads
/cypress/downloads

# Generated by scripts/build-vue-editor-data.js
/resources/vue-editor/vuetify.json
10 changes: 10 additions & 0 deletions docs/en/nodes/widgets/ui-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ Provide custom JS and HTML (including any [Vuetify components](https://vuetifyjs

<DynamicPropsTable/>

## 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 `<v-` suggests Vuetify components. Inside a Vuetify tag, you get suggestions for its props, bound props (`:`), events (`@`) and, on a `<template>` 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:
Expand Down
97 changes: 97 additions & 0 deletions nodes/plugins/vue-editor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<script type="text/javascript">
/**
* Registers a "vue-sfc" language with Node-RED's Monaco code editor, used by ui-template.
* Up front this only registers the language id and Monaco's HTML language service for it. The tokenizer,
* Vue/Vuetify providers and Vuetify data are loaded from resources/vue-editor the first time an editor uses it.
*/
(function () {
const PLUGIN_ID = 'ui-template-vue-editor'
const LANGUAGE_ID = 'vue-sfc'
const MODULE = '@flowfuse/node-red-dashboard'

/**
* Monaco asks MonacoEnvironment.getWorkerUrl/getWorker for a language worker using the language id as the
* label. Node-RED only knows its built-in labels, so map ours onto the html worker that serves it.
* Done on first use, when Node-RED has already set up MonacoEnvironment.
*/
function shimWorkerLabel () {
const env = window.MonacoEnvironment
if (!env || env.vueSfcWorkerShim) {
return
}
for (const fn of ['getWorkerUrl', 'getWorker']) {
if (typeof env[fn] === 'function') {
const original = env[fn]
env[fn] = function (moduleId, label) {
return original.call(this, moduleId, label === LANGUAGE_ID ? 'html' : label)
}
}
}
env.vueSfcWorkerShim = true
}

function registerHtmlLanguageService (monaco) {
// Monaco 0.55 moved monaco.languages.html to monaco.html
const html = monaco.html?.registerHTMLLanguageService ? monaco.html : monaco.languages.html
if (!html?.registerHTMLLanguageService) {
return
}
const options = Object.assign({}, html.htmlDefaults?.options, { data: { useDefaultDataProvider: true } })
html.registerHTMLLanguageService(LANGUAGE_ID, options)
}

/**
* Registers the language if Monaco is loaded. Safe to call more than once.
* @returns {boolean} true when the vue-sfc language is available
*/
function ensure () {
const monaco = window.monaco
if (!monaco?.languages?.register) {
return false
}
if (monaco.languages.getLanguages().some(l => l.id === LANGUAGE_ID)) {
return true
}
monaco.languages.register({ id: LANGUAGE_ID, aliases: ['Vue', LANGUAGE_ID], extensions: ['.vue'] })
try {
registerHtmlLanguageService(monaco)
} catch (err) {
console.warn('Dashboard: HTML language service not available for Vue templates', err)
}

const version = RED.nodes.registry?.getModule?.(MODULE)?.version
const query = version ? '?v=' + encodeURIComponent(version) : ''
const base = new URL('resources/' + MODULE + '/vue-editor/', document.baseURI)
monaco.languages.onLanguage(LANGUAGE_ID, function () {
shimWorkerLabel()
import(new URL('vue-sfc.mjs' + query, base).href)
.then(m => m.activate(monaco, { dataUrl: new URL('vuetify.json' + query, base).href }))
.catch(err => console.warn('Dashboard: could not load the Vue editor support', err))
})
return true
}

/**
* nr-assistant before 0.21.0 only gives ui-template Expert completions and its CodeLens for html,
* so keep ui-template on html when an older nr-assistant is installed.
*/
function assistantSupportsVueSfc () {
const assistant = RED.plugins.getPlugin('flowfuse-nr-assistant')
return !assistant || assistant.supportsVueSfc === true
}

/**
* Registers the language if needed. Safe to call more than once.
* @returns {boolean} true when ui-template should use the vue-sfc language
*/
function useVueSfc () {
return RED.editor.codeEditor?.editor?.type === 'monaco' && assistantSupportsVueSfc() && ensure()
}

RED.plugins.registerPlugin(PLUGIN_ID, {
onadd: ensure,
ensure,
useVueSfc
})
})()
</script>
17 changes: 11 additions & 6 deletions nodes/widgets/ui_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -239,31 +244,31 @@
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)
})

this.editor = RED.editor.createEditor({
id: 'node-input-format-editor',
mode: 'ace/mode/html',
mode: htmlMode,
value: $('#node-input-format').val()
})

RED.library.create({
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']
})

Expand All @@ -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(),
Expand Down
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading