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
1 change: 1 addition & 0 deletions docs/en/nodes/widgets/ui-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ When Node-RED uses its default Monaco code editor, the template editor understan
- 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.
- With a Node-RED version that supports code in `<script>` and `<style>` blocks, you also get JavaScript completion, hover information, parameter hints and error checking inside `<script>`, and CSS completion, checking and colour previews inside `<style>`. `this` knows your component's `data`, `computed` and `methods`, as well as what Dashboard adds, such as `this.send()`, `this.msg` and `this.$socket`.

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.

Expand Down
17 changes: 14 additions & 3 deletions nodes/plugins/vue-editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,23 @@
env.vueSfcWorkerShim = true
}

/** Newer Node-RED versions register HTML-like languages itself, including the worker mapping */
function nodeRedRegistersHtmlLanguages () {
return typeof RED.editor?.codeEditor?.monaco?.registerHTMLLanguage === 'function'
}

function registerHtmlLanguageService (monaco) {
const options = { data: { useDefaultDataProvider: true } }
if (nodeRedRegistersHtmlLanguages()) {
RED.editor.codeEditor.monaco.registerHTMLLanguage(LANGUAGE_ID, options)
return
}
// 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)
html.registerHTMLLanguageService(LANGUAGE_ID, Object.assign({}, html.htmlDefaults?.options, options))
}

/**
Expand All @@ -63,7 +72,9 @@
const query = version ? '?v=' + encodeURIComponent(version) : ''
const base = new URL('resources/' + MODULE + '/vue-editor/', document.baseURI)
monaco.languages.onLanguage(LANGUAGE_ID, function () {
shimWorkerLabel()
if (!nodeRedRegistersHtmlLanguages()) {
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))
Expand Down
3 changes: 3 additions & 0 deletions nodes/widgets/locales/en-US/ui_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"template": "Template",
"expand": "Expand",
"resend": "Reload last value on refresh."
},
"errors": {
"invalid-template": "Error in template code"
}
}
}
9 changes: 9 additions & 0 deletions nodes/widgets/ui_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@
height: { value: 0 },
head: { value: '' },
format: { value: defaultTemplate },
// number of errors the code editor reported when the edit dialog was last saved (set in oneditsave)
noerr: {
value: 0,
// two parameters, so Node-RED shows the returned message (a one-parameter validator's result is coerced to a boolean)
validate: function (v, opt) {
return !v ? true : c_('errors.invalid-template')
}
},
storeOutMessages: { value: true },
passthru: { value: true },
resendOnRefresh: { value: true },
Expand Down Expand Up @@ -428,6 +436,7 @@
<div class="form-row" style="margin-bottom:0px;">
<label for="node-input-format" style="width:110px;"><i class="fa fa-copy"></i> <span data-i18n="ui-template.label.template"></span></label>
<input type="hidden" id="node-input-format">
<input type="hidden" id="node-input-noerr">
<button id="node-template-expand-editor" class="red-ui-button red-ui-button-small" style="float:right"><i class="fa fa-expand"></i></button>
</div>
<div class="form-row node-text-editor-row" style="display: block;">
Expand Down
148 changes: 148 additions & 0 deletions resources/vue-editor/vue-script.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/* eslint-env browser */
/* global RED */
/**
* JavaScript and CSS editing for the <script> and <style> blocks of a ui-template (`vue-sfc` language).
*
* Node-RED provides the machinery (RED.editor.codeEditor.monaco.registerEmbeddedLanguages, in newer
* Node-RED versions). This module only adds what is specific to Dashboard: the type of `this`. Dashboard runs a
* ui-template script in one of two ways (ui/src/widgets/ui-template/UITemplate.vue, ui/src/util/vue.acorn.js):
* - a script whose only statement is `export default { ... }` is a Vue component; Node-RED's hidden copy
* of it becomes `export default __uiTemplate({ ... })`, which types `this` in data, computed, methods,
* watch, mounted and unmounted
* - any other script runs inside `mounted`, so it is wrapped in a function whose `this` is the component
* Both see what Dashboard adds to the component: send(), submit(), msg, value, id, props, $socket, ...
*/

const TYPES_PATH = 'file:///node_modules/@types/node-red-dashboard-ui-template/index.d.ts'

/** Type declarations for the ui-template component instance */
export const UI_TEMPLATE_TYPES = `
/** Members that Dashboard adds to every ui-template component */
interface UiTemplateContext {
/** The ID of this ui-template node, assigned by Node-RED */
readonly id: string
/** The configuration of this ui-template node */
readonly props: { name?: string, templateScope?: string, [key: string]: any }
/** The last message received by this ui-template node */
readonly msg: { payload?: any, topic?: string, [key: string]: any }
/** Get or set \`msg.payload\` of the last message */
value: any
/** Send a message to the Node-RED flow. A non-object value is sent as \`msg.payload\`. */
send(msg: any): void
/** Send an event to the Node-RED flow, with \`topic\` set to the event type */
submit(event: Event): void
/** The socket.io connection to Node-RED */
readonly $socket: {
on(event: string, listener: (...args: any[]) => void): any
off(event: string, listener?: (...args: any[]) => void): any
emit(event: string, ...args: any[]): any
[key: string]: any
}
/** Last message received by every widget, by widget ID */
readonly messages: Record<string, any>
/** Dashboard setup information */
readonly setup: any
readonly $dataTracker: any
/** The Dashboard Vuex store */
readonly $store: any
/** The Dashboard router */
readonly $router: any
/** The current route */
readonly $route: any
/** The Vuetify instance */
readonly $vuetify: any
/** The root DOM element of this component */
readonly $el: any
/** Elements and components registered with \`ref\` */
readonly $refs: Record<string, any>
readonly $data: Record<string, any>
readonly $parent: any
readonly $root: any
readonly $options: Record<string, any>
/** Trigger a custom event on this component */
$emit(event: string, ...args: any[]): void
/** Wait for the next DOM update */
$nextTick(fn?: () => void): Promise<void>
/** Watch a reactive property or getter */
$watch(source: string | (() => any), callback: (value: any, oldValue: any) => void, options?: { immediate?: boolean, deep?: boolean }): () => void
/** Force this component to re-render */
$forceUpdate(): void
}
type UiTemplateComputed<C> = { [K in keyof C]: C[K] extends (...args: any[]) => infer R ? R : C[K] extends { get(): infer G } ? G : never }
type UiTemplateInstance<D, C, M> = D & UiTemplateComputed<C> & M & UiTemplateContext
type UiTemplateWatcher = ((value: any, oldValue: any) => void) | { handler: (value: any, oldValue: any) => void, deep?: boolean, immediate?: boolean }
/** The options that a ui-template component supports */
declare function __uiTemplate<D = {}, C = {}, M = {}>(component: {
name?: string
data?: (this: UiTemplateContext) => D
computed?: C & ThisType<UiTemplateInstance<D, C, M>>
methods?: M & ThisType<UiTemplateInstance<D, C, M>>
// \`this\` in these comes from the ThisType below; typing it here as well stops TypeScript inferring D, C and M
watch?: Record<string, UiTemplateWatcher>
mounted?: () => void
unmounted?: () => void
/** Dashboard ignores other options (it logs a warning); allowed here so that one doesn't stop \`this\` being typed */
[option: string]: any
} & ThisType<UiTemplateInstance<D, C, M>>): void
`

// whitespace and comments, which Dashboard allows around a component (they aren't statements)
const GAP = String.raw`(?:\s|\/\/[^\n]*(?:\n|$)|\/\*(?:[^*]|\*(?!\/))*\*\/)*`

/**
* Where a ui-template script is a component: Dashboard treats it as one when its only statement is
* `export default { ... }`.
* @param {string} body - the script's code
* @returns {{ open: number, close: number } | null} offsets (in body) of the object's `{` and `}`
*/
export function findComponent (body) {
const head = new RegExp(`^${GAP}export\\s+default\\s*(?=\\{)`).exec(body)
const tail = new RegExp(`\\}\\s*;?${GAP}$`).exec(body)
return head && tail ? { open: head[0].length, close: tail.index } : null
}

/**
* The `wrap` hook for registerEmbeddedLanguages: text to insert around a script so `this` has its type.
* @param {string} text - the ui-template
* @param {{start: number, end: number}} region - the script's code
* @param {number} index - the script's position in the template
* @returns {{at: number, text: string}[]} insertions at offsets in the ui-template
*/
export function wrapScript (text, region, index) {
const component = findComponent(text.slice(region.start, region.end))
if (component) {
return [
{ at: region.start + component.open, text: '__uiTemplate(' },
{ at: region.start + component.close + 1, text: ')' }
]
}
return [
{ at: region.start, text: `/** @this {UiTemplateContext} */ function __uiTemplateScript${index} () {` },
{ at: region.end, text: '\n}' }
]
}

/**
* Registers Dashboard's script and style support with Node-RED's code editor.
* @param {object} monaco - the monaco namespace (unused, kept for the same signature as vue-sfc.mjs)
* @param {object} options
* @param {string} options.languageId - the ui-template language id (vue-sfc)
* @returns {boolean} false if this Node-RED doesn't provide registerEmbeddedLanguages
*/
export function activate (monaco, { languageId }) {
const register = RED.editor?.codeEditor?.monaco?.registerEmbeddedLanguages
if (typeof register !== 'function') {
return false
}
register(languageId, {
javascript: {
wrap: wrapScript,
extraLibs: [{ content: UI_TEMPLATE_TYPES, filePath: TYPES_PATH }],
hiddenNames: /^__uiTemplate/,
// list the component's own members before the Vue/Dashboard `$` members
demotedNames: /^\$/
},
css: {}
})
return true
}
10 changes: 10 additions & 0 deletions resources/vue-editor/vue-sfc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* are tokenized with JavaScript embedded; <script> and <style> blocks embed JavaScript/TypeScript and CSS/SCSS/Less)
* - Vue and Vuetify completion and hover for tags, props, events, slots and directives, using Vuetify's
* web-types data for the Vuetify version bundled with the Dashboard (see scripts/build-vue-editor-data.js)
* - on Node-RED versions that support it, JavaScript and CSS in <script> and <style> blocks, with Dashboard's
* `this` types (vue-script.mjs)
*
* The HTML language service (completion, hover, formatting, folding from Monaco's html worker) is registered by
* the plugin itself, because it has to be in place before the language is first used.
Expand Down Expand Up @@ -604,5 +606,13 @@ export function activate (monaco, { dataUrl }) {
}
})

// JavaScript and CSS in <script> and <style> blocks come from Node-RED (newer versions), which this adds
// Dashboard's `this` types to. Loaded with the same cache-busting query as this module.
if (typeof globalThis.RED?.editor?.codeEditor?.monaco?.registerEmbeddedLanguages === 'function') {
import(new URL('./vue-script.mjs' + new URL(import.meta.url).search, import.meta.url).href)
.then(script => script.activate(monaco, { languageId: LANGUAGE_ID }))
.catch(err => console.warn('vue-sfc: could not load <script> support', err))
}

return { ready }
}
105 changes: 105 additions & 0 deletions test/resources/vue-script.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
const path = require('path')
const { pathToFileURL } = require('url')

const should = require('should') // eslint-disable-line no-unused-vars

describe('vue-editor <script> support (ui-template code editor)', function () {
let script
before(async function () {
script = await import(pathToFileURL(path.join(__dirname, '../../resources/vue-editor/vue-script.mjs')).href)
})

// what Node-RED's registerEmbeddedLanguages does with the insertions (see monaco-embedded.js)
function applyInsertions (text, insertions) {
let out = text
for (const ins of insertions.slice().sort((a, b) => b.at - a.at)) {
out = out.slice(0, ins.at) + ins.text + out.slice(ins.at)
}
return out
}
const scriptRegion = (text) => {
const start = text.indexOf('<script>') + '<script>'.length
return { start, end: text.indexOf('</script>') }
}

describe('findComponent', function () {
it('finds the object of a single export default, like Dashboard does', function () {
const body = '\n// a comment\nexport default {\n data () { return {} }\n}\n'
const c = script.findComponent(body)
body[c.open].should.equal('{')
body[c.close].should.equal('}')
body.slice(c.close + 1).should.equal('\n')
should(script.findComponent('/* x */ export default {};')).not.be.null()
})
it('allows // and /* */ comments before and after the component', function () {
const comments = ['// a {', '/* a { */', '/*\n * a {\n */']
for (const before of comments) {
for (const after of comments) {
const body = `\n${before}\nexport default {}\n${after}\n`
const c = script.findComponent(body)
should(c).not.be.null()
body.slice(c.open, c.close + 1).should.equal('{}')
}
}
for (const body of ['export default {} // bye', 'export default {}; /* bye */', '/* a */ export default {}\n// b\n/* c */']) {
should(script.findComponent(body)).not.be.null()
}
})
it('treats anything else as raw JavaScript', function () {
should(script.findComponent('const a = 1\nexport default {}')).be.null()
should(script.findComponent('this.send({ payload: 1 })')).be.null()
should(script.findComponent('export default { }\nconsole.log(1)')).be.null()
})
})

describe('wrapScript', function () {
it('wraps a component in __uiTemplate( ... )', function () {
const text = '<template><div/></template>\n<script>\nexport default {\n data () { return { a: 1 } }\n}\n</script>'
const wrapped = applyInsertions(text, script.wrapScript(text, scriptRegion(text), 0))
wrapped.should.containEql('export default __uiTemplate({\n data () { return { a: 1 } }\n})\n</script>')
})
it('wraps raw JavaScript in a function whose this is the component', function () {
const text = '<div id="x"></div>\n<script>\nthis.send({ payload: 1 })\n</script>'
const wrapped = applyInsertions(text, script.wrapScript(text, scriptRegion(text), 2))
wrapped.should.containEql('<script>/** @this {UiTemplateContext} */ function __uiTemplateScript2 () {\nthis.send({ payload: 1 })\n\n}</script>')
})
it('only inserts inside the script region', function () {
const text = '<script>\nexport default {}\n</script>'
const region = scriptRegion(text)
for (const ins of script.wrapScript(text, region, 0)) {
ins.at.should.be.within(region.start, region.end)
}
})
})

describe('UI_TEMPLATE_TYPES', function () {
it('describes what Dashboard adds to the component', function () {
for (const member of ['send(msg: any)', 'submit(event: Event)', 'readonly msg:', 'readonly id:', 'readonly props:', 'value: any', 'readonly $socket:', 'readonly $store:', 'readonly $router:', 'readonly $vuetify:']) {
script.UI_TEMPLATE_TYPES.should.containEql(member)
}
script.UI_TEMPLATE_TYPES.should.containEql('declare function __uiTemplate<D = {}, C = {}, M = {}>(')
})
})

describe('activate', function () {
afterEach(function () { delete global.RED })

it('registers the wrapper, types and CSS with Node-RED', function () {
const calls = []
global.RED = { editor: { codeEditor: { monaco: { registerEmbeddedLanguages: (id, options) => calls.push({ id, options }) } } } }
script.activate({}, { languageId: 'vue-sfc' }).should.be.true()
calls.should.have.length(1)
calls[0].id.should.equal('vue-sfc')
const { javascript, css } = calls[0].options
javascript.wrap.should.equal(script.wrapScript)
javascript.extraLibs[0].content.should.equal(script.UI_TEMPLATE_TYPES)
javascript.hiddenNames.test('__uiTemplateScript0').should.be.true()
javascript.demotedNames.test('$socket').should.be.true()
css.should.eql({})
})
it('does nothing on Node-RED versions without registerEmbeddedLanguages', function () {
global.RED = { editor: { codeEditor: { monaco: {} } } }
script.activate({}, { languageId: 'vue-sfc' }).should.be.false()
})
})
})
Loading