node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
1,215 lines (1,082 loc) âĸ 87.2 kB
JavaScript
/* eslint-disable @stylistic/newline-per-chained-call */
// @ts-nocheck
// Now loading as a module so no need to further Isolate this code
// #region --------- module variables for the panel --------- //
// RED._debug({topic: 'RED.settings', payload:RED.settings})
// NOTE: window.uibuilder is added by editor-common.js - see `resources` folder
const uibuilder = window['uibuilder'] // eslint-disable-line no-redeclare
const log = uibuilder.log
/** Module name must match this nodes html file @constant {string} moduleName */
const moduleName = 'uibuilder'
/** Track which urls have been used - required for error handling in url validation. Only use for URL tracking */
const editorInstances = uibuilder.editorUibInstances
/** Default template name */
const defaultTemplate = 'blank'
/** List of installed packages - rebuilt when editor is opened, updates by library mgr */
let packages = uibuilder.packages
/** placeholder for ACE editor vars - so that they survive close/reopen admin config ui
* @typedef {object} uiace Options for the ACE/Monaco code editor
* @property {string} format What format to use for the code editor (html)
* @property {string} folder What folder was last used
* @property {string} fname What filename was last edited
* @property {boolean} fullscreen Is the editor in fullscreen mode?
*/
const uiace = {
format: 'html',
folder: 'src',
fname: 'index.html',
fullscreen: false,
}
/** placeholder for instance folder list
@type {Array<string>}
*/
let folders = []
// #endregion ------------------------------------------------- //
// #region --------- module functions for the panel --------- //
// #region ==== Package Management Functions ==== //
/** Perform a call to the package update v3 API
* @param {JQuery.ClickEvent<HTMLElement, undefined, HTMLElement, HTMLElement>} evt jQuery Click Event
*/
function doPkgUpd(evt) {
const packageName = evt.data.pkgName
const node = evt.data.node
const displayVer = evt.target.nextElementSibling
RED.notify('Installing npm package ' + packageName)
// Call the npm installPackage v2 API (it updates the package list)
$.ajax({
url: 'uibuilder/uibnpmmanage',
method: 'GET',
dataType: 'json', // Expect JSON data
data: { // converted to URL parameters
cmd: 'update',
package: packageName,
url: node.url,
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
success: function(data) {
const npmOutput = data.result[0]
if (data.success === true) {
packages = uibuilder.packages = data.result[1]
console.log('đ[uibuilder:doPkgUpd:get] PACKAGE INSTALLED. ', packageName, node.url, '\n\n', npmOutput, '\n ', packages[packageName])
RED.notify(`Successful update of npm package ${packageName}`, 'success')
displayVer.innerHTML = data.result[1][packageName].installedVersion
$(evt.target).remove() // removes the update button
} else {
console.log('đ[uibuilder:doPkgUpd:get] ERROR ON INSTALLATION OF PACKAGE ', packageName, node.url, '\n\n', npmOutput, '\n ')
RED.notify(`FAILED update of npm package ${packageName}`, 'error')
}
// Hide the progress spinner
$('i.spinner').hide()
},
error: function(jqXHR, textStatus, errorThrown) {
console.error('đđ[uibuilder:doPkgUpd:get] Error ' + textStatus, errorThrown)
RED.notify(`FAILED update of npm package ${packageName}`, 'error')
$('i.spinner').hide()
return 'addPackageRow failed'
// TODO otherwise highlight input
},
})
$.ajax({
type: 'PUT',
dataType: 'json',
url: './uibuilder/admin/' + evt.data.node.url, // v3 api
data: {
cmd: 'updatepackage',
pkgName: evt.data.pkgName,
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
.done(function(data, _textStatus, jqXHR) {
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:doPkgUpd:PUT] Error ' + textStatus, errorThrown )
RED.notify(`uibuilder: Package update for '${evt.data.pkgName}' failed.<br>${errorThrown}`, { type: 'error', })
})
// Assuming update worked, hide the button - And update version string
$(`#upd_${evt.data.pkgName}`)
.hide()
.next()
.text('XXX')
}
/** AddItem function for package list
* @param {object} node A reference to the panel's `this` object
* @param {JQuery<HTMLElement>} element the jQuery DOM element to which any row content should be added
* @param {number} index the index of the row
* @param {string|*} data data object for the row. {} if add button pressed, else data passed to addItem method
*/
function addPackageRow(node, element, index, data) {
let hRow = ''
let pkgSpec = null
if (Object.entries(data).length === 0) { // Add button was pressed so we have no packageName, create an input form instead
hRow = `
<div>
<label for="packageList-input-${index}" style="width:3em;">Name:</label>
<input type="text" id="packageList-input-${index}" title="" style="width:80%" placeholder="Valid npm package name">
</div>
<div>
<label for="packageList-tag-${index}" style="width:3em;">Tag:</label>
<input type="text" id="packageList-tag-${index}" title="" style="width:80%" placeholder="npm: @tag/version, GH: #branch/tag">
</div>
<div>
<label for="packageList-button-${index}" style="width:3em;"></label>
<button id="packageList-button-${index}" style="width:5em;">Install</button>
</div>
`
} else { // existing line
/*
npm install [<@scope>/]<name>
npm install [<@scope>/]<name>@<tag>
npm install [<@scope>/]<name>@<version>
npm install [<@scope>/]<name>@<version range>
npm install <alias>@npm:<name>
npm install <git-host>:<git-user>/<repo-name>[#branch-tag-name]
npm install <git repo url>
npm install <tarball file>
npm install <tarball url>
npm install <folder>
*/
pkgSpec = packages[data]
// if wanted !== installed, show update
let upd = ''
if ( pkgSpec.outdated && pkgSpec.outdated.wanted && pkgSpec.installedVersion !== pkgSpec.outdated.wanted ) {
upd = `<button id="upd_${data}" style="color:orange;background-color:black;" title="Click to update (or install again to get a non-standard version)">UPDATE: ${pkgSpec.outdated.wanted}</button>`
$('#node-input-packageList').on('click', `#upd_${data}`, { pkgName: data, node: node, }, doPkgUpd)
}
// addItem method was called with a packageName passed
hRow = `
<div id="packageList-row-${index}" class="packageList-row-data">
<div style="display:flex;justify-content:space-between;">
<div>
<a href="${pkgSpec.homepage}" target="_blank" title="Click to open package homepage in a new tab">
<b>${data}</b> <span class="emoji">âšī¸</span>
</a>
</div>
<div style="margin-left:auto;text-align:right;">
${upd}
<span title="npm version specification: ${pkgSpec.spec}">${pkgSpec.installedVersion}</span>
</div>
</div>
<div title="NB: This link is an estimate, check the package docs for the actual entry point" style="display:flex;justify-content:space-between;">
<div>Est. link:</div>
<div style="margin-left:auto;text-align:right;"><code style="white-space:inherit;text-decoration: underline;"><a href="${uibuilder.urlPrefix}${pkgSpec.url}" target="_blank">
${pkgSpec.url}
</code></div>
</div>
</div>
`
}
// Build the output row
$(hRow).appendTo(element)
// Add tooltips for the input fields
if (Object.entries(data).length === 0) {
// @ts-expect-error ts(2339)
$(`#packageList-input-${index}`).tooltip({
content: 'Enter one of:<ul><li>An npm package name (optionally with leading)</li><li>A GitHub user/repo</li><li>A filing system path to a local package</li></ul>Ensure that you select the correct "From" dropdown.',
})
// @ts-expect-error ts(2339)
$(`#packageList-ver-${index}`).tooltip({
content: 'Optional. Branch, Tag or Version to install (defaults to @latest)',
})
}
// Create a button click listener for the install button for this row
$('#packageList-button-' + index).on('click', function() {
// show activity spinner
$('i.spinner').show()
// Get the data from the input fields
const packageName = String($(`#packageList-input-${index}`).val())
const packageTag = String($(`#packageList-tag-${index}`).val())
if ( packageName.length !== 0 ) {
RED.notify('Installing npm package ' + packageName)
// Call the npm installPackage v2 API (it updates the package list)
$.ajax({
url: 'uibuilder/uibnpmmanage',
method: 'GET',
dataType: 'json', // Expect JSON data
data: { // converted to URL parameters
cmd: 'install',
package: packageName,
url: node.url,
tag: packageTag,
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
success: function(data) {
const npmOutput = data.result[0]
if ( data.success === true) {
packages = uibuilder.packages = data.result[1]
console.log('đ[uibuilder:addPackageRow:get] PACKAGE INSTALLED. ', packageName, node.url, '\n\n', npmOutput, '\n ', packages[packageName])
RED.notify(`Successful installation of npm package ${packageName} for ${node.url}`, 'success')
RED._debug({ topic: 'UIBUILDER Library Install', result: 'success', payload: packageName, output: npmOutput, })
// reset and populate the list
$('#node-input-packageList').editableList('empty')
// @ts-ignore
$('#node-input-packageList').editableList('addItems', Object.keys(packages))
} else {
console.log('đ[uibuilder:addPackageRow:get] ERROR ON INSTALLATION OF PACKAGE ', packageName, node.url, '\n\n', npmOutput, '\n ' )
RED.notify(`FAILED installation of npm package ${packageName} for ${node.url}`, 'error')
}
// Hide the progress spinner
$('i.spinner').hide()
},
error: function(jqXHR, textStatus, errorThrown) {
console.log('đ[uibuilder:addPackageRow:get] ERROR ON INSTALLATION OF PACKAGE ', packageName, node.url, '\n\n', textStatus, '\n ', errorThrown, '\n ' )
RED.notify(`FAILED installation of npm package ${packageName} for ${node.url}`, 'error')
},
})
.fail(function(_jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:addPackageRow:get] Error ' + textStatus, errorThrown )
RED.notify(`FAILED installation of npm package ${packageName} for ${node.url}`, 'error')
$('i.spinner').hide()
return 'addPackageRow failed'
// TODO otherwise highlight input
})
} // else Do nothing
}) // -- end of button click -- //
} // --- End of addPackageRow() ---- //
/** RemoveItem function for package list
* @param {string} packageName Name of the npm package to remove
* @returns {string|null} Result text
*/
function removePackageRow(packageName) {
// If package name is an empty object - user removed an add row so ignore
if ( (packageName === '') || (typeof packageName !== 'string') ) {
return 'No package'
}
RED.notify('Starting removal of npm package ' + packageName)
// show activity spinner
$('i.spinner').show()
// Call the npm installPackage API (it updates the package list)
$.ajax({
url: 'uibuilder/uibnpmmanage',
method: 'GET',
dataType: 'json', // Expect JSON data
data: { // converted to URL parameters
cmd: 'remove',
package: packageName,
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
success: function(data) {
if ( data.success === true) {
console.log('đ[uibuilder:removePackageRow:get] PACKAGE REMOVED. ', packageName)
RED.notify('Successfully uninstalled npm package ' + packageName, 'success')
if ( packages[packageName] ) delete packages[packageName]
} else {
console.log('đ[uibuilder:removePackageRow:get] ERROR ON PACKAGE REMOVAL ', data.result )
RED.notify(`FAILED to uninstall npm package ${packageName}`, 'error')
// Put the entry back again
$('#node-input-packageList').editableList('addItem', packageName)
}
$('i.spinner').hide()
},
error: function(jqXHR, textStatus, errorThrown) {
console.log('đ[uibuilder:removePackageRow:get] ERROR ON PACKAGE REMOVAL ', packageName, '\n\n', textStatus, '\n ', errorThrown, '\n ' )
RED.notify(`FAILED to uninstall npm package ${packageName}`, 'error')
},
})
.fail(function(_jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:removePackageRow:get] Error ' + textStatus, errorThrown )
RED.notify('FAILED to uninstall npm package ' + packageName, 'error')
// Put the entry back again
// @ts-ignore
$('#node-input-packageList').editableList('addItem', packageName)
$('i.spinner').hide()
return 'removePackageRow failed'
// TODO otherwise highlight input
})
return null
} // ---- End of removePackageRow ---- //
// #endregion ==== Package Management Functions ==== //
// #region ==== File Management Functions ==== //
/** Return a file type from a file name (or default to txt)
* ftype can be used in ACE editor modes
* @param {string} fname File name for which to return the type
* @returns {string} File type
*/
function fileType(fname) {
let ftype = 'text'
const fparts = fname.split('.')
// Take off the first entry if the file name started with a dot
if ( fparts[0] === '' ) fparts.shift()
if (fparts.length > 1) {
// Get the last element of the array
const fext = fparts.pop().toLowerCase().trim()
switch (fext) {
case 'js':
ftype = 'javascript'
break
case 'html':
case 'css':
case 'json':
ftype = fext
break
case 'vue':
ftype = 'html'
break
case 'md':
ftype = 'markdown'
break
case 'yaml':
case 'yml':
ftype = 'yaml'
break
default:
ftype = fext
}
}
return ftype
} // --- End of fileType --- //
/** Enable/disable buttons if file has edits or not
* @param {boolean} isClean true = the file is clean, else there are pending edits that need saving
*/
function fileIsClean(isClean) {
// If clean, disable the save & reset buttons
$('#edit-save').prop('disabled', isClean)
$('#edit-reset').prop('disabled', isClean)
// If clean, enable the delete and edit buttons
// $('#edit-delete').prop('disabled', !isClean)
$('#edit-close').prop('disabled', !isClean)
$('#node-edit-file').prop('disabled', !isClean)
$('#node-input-filename').prop('disabled', !isClean)
// If not clean, disable main Done and Cancel buttons to prevent loss
$('#node-dialog-ok').prop('disabled', !isClean)
$('#node-dialog-cancel').prop('disabled', !isClean)
// If not clean, Add a user hint
if ( !isClean ) {
$('#file-action-message').text('Save Required')
$('#node-dialog-ok').css( 'cursor', 'not-allowed' )
$('#node-dialog-cancel').css( 'cursor', 'not-allowed' )
} else {
$('#node-dialog-ok').css( 'cursor', 'pointer' )
$('#node-dialog-cancel').css( 'cursor', 'pointer' )
}
} // --- End of fileIsClean --- //
/** Get the chosen file contents & set up the ACE editor */
function getFileContents() {
// Get the current url
const url = $('#node-input-url').val()
/** Get the chosen folder name - use the default/last saved on first load
* @type {string}
*/
let folder = $('#node-input-folder').val()
if ( folder === null ) {
folder = localStorage.getItem('uibuilder.' + url + '.folder') || uiace.folder
}
/** Get the chosen filename - use the default/last saved on first load
* @type {string}
*/
let fname = $('#node-input-filename').val()
if ( fname === null ) {
fname = localStorage.getItem('uibuilder.' + url + '.selectedFile') || uiace.fname
}
// Save the file & folder names
uiace.folder = folder
uiace.fname = fname
// Persist the folder & file name selection
localStorage.setItem('uibuilder.' + url + '.folder', uiace.folder)
localStorage.setItem('uibuilder.' + url + '.selectedFile', uiace.fname)
// Change mode to match file type
const filetype = uiace.format = fileType(fname)
$('#node-input-format').val(filetype)
// Get the file contents via API defined in uibuilder.js
$.ajax({
url: 'uibuilder/uibgetfile',
method: 'GET',
data: { // converted to URL parameters
url: url,
fname: fname,
folder: folder,
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
success: function(data) {
$('#node-input-template-editor').show()
$('#node-input-template-editor-no-file').hide()
// Add the fetched data to the editor
uiace.editorSession.setValue(data)
// Set the editor file mode
uiace.editorSession.setMode({
path: 'ace/mode/' + filetype, v: Date.now(),
})
// Mark the current session as clean
uiace.editorSession.getUndoManager().isClean()
// Position the cursor in the edit area
uiace.editor.focus()
},
})
.fail(function(_jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:getFileContents:get] Error ' + textStatus, errorThrown )
uiace.editorSession.setValue('')
$('#node-input-template-editor').hide()
$('#node-input-template-editor-no-file').show()
})
.always(function() {
fileIsClean(true)
// Default the language selector in case it wasn't recognised
if (!$('#node-input-format option:selected').length) $('#node-input-format').val('text')
})
} // --- End of getFileContents --- //
/** Get the list of files for the chosen url & folder
* @param {string} [selectedFile] Optional. If present will select this filename after refresh, otherwise 1st file is selected.
*/
function getFileList(selectedFile) {
// #region --- Collect variables from Config UI ---
const url = /** @type {string} */ ($('#node-input-url').val())
let folder = /** @type {string} */ ($('#node-input-folder').val())
const f = /** @type {string} */ ($('#node-input-filename').val())
// Whether or not to force the index.(html|js|css) files to be copied over if missing
// var nodeInputCopyIndex = $('#node-input-copyIndex').is(':checked')
// #endregion -------------------------------------
// Collect the current filename from various places
if ( selectedFile === undefined ) selectedFile = /** @type {string} */ (f)
if ( selectedFile === null ) {
selectedFile = localStorage.getItem('uibuilder.' + url + '.selectedFile') || undefined
}
if ( folder === null ) {
folder = localStorage.getItem('uibuilder.' + url + '.folder') || undefined
}
// Clear out drop-downs ready for rebuilding
$('#node-input-filename option').remove()
$('#node-input-folder option').remove()
// Get all files/folders for this uibuilder instance from API call
$.ajax({
type: 'GET',
dataType: 'json',
url: './uibuilder/admin/' + url,
data: {
cmd: 'listall',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
.done(function(data, _textStatus, jqXHR) {
let firstFile = ''
let indexHtml = false
let selected = false
// build folder list, pre-select src if no current folder selected #node-input-folder - Object.keys(res)
const folders = Object.keys(data).sort()
// Rebuild folder drop-down
$.each(folders, function (i, fldrname) {
// For the root folder, use empty string for folders lookup but "root" for display
if ( fldrname === '' ) fldrname = 'root'
// Build the drop-down
$('#node-input-folder').append($('<option>', {
value: fldrname,
text: fldrname,
}))
})
// if currently selected folder doesn't exist
if ( !data[folder] ) {
// Use 'src' if it exists otherwise use 'root'
if ( data.src ) folder = 'src'
else folder = 'root'
}
// Selected folder
$('#node-input-folder').val(folder)
uiace.folder = folder
// Persist the folder selection
localStorage.setItem('uibuilder.' + url + '.folder', folder)
let files = []
files = data[folder]
$.each(files, function (i, filename) {
// Build the drop-down
$('#node-input-filename').append($('<option>', {
value: filename,
text: filename,
}))
// Choose the default file. In order: selectedFile param, index.html, 1st returned
if ( i === 0 ) firstFile = filename
if ( filename === uiace.fname ) indexHtml = true
if ( filename === selectedFile ) selected = true
})
// Set default file name/type. In order: selectedFile param, index.html, 1st returned
// @ts-ignore
if ( selected === true ) uiace.fname = selectedFile
// @ts-ignore
else if ( indexHtml === true ) uiace.fname = 'index.html'
else uiace.fname = firstFile
$('#node-input-filename').val(uiace.fname)
uiace.format = fileType(uiace.fname)
// Persist the file name selection
localStorage.setItem('uibuilder.' + url + '.selectedFile', uiace.fname)
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:getFileList:getJSON] Error ' + textStatus, errorThrown )
uiace.fname = ''
uiace.format = 'text'
RED.notify(`uibuilder: Folder and file listing error.<br>${errorThrown}`, { type: 'error', })
})
.always(function() {
getFileContents()
})
} // --- End of getFileList --- //
/** Get a full list of instance folders (minus protected ones). Updates folders global var. */
function getFolders() {
const url = /** @type {string} */ ($('#node-input-url').val())
const data = $.ajax({
async: false,
type: 'GET',
dataType: 'json',
url: './uibuilder/admin/' + url,
data: {
cmd: 'listfolders',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
folders = data.responseJSON
$('#node-input-sourceFolder').typedInput({
types: [
{
value: 'sourceFolders',
options: folders,
}
],
})
} // --- End of getFolders --- //
/** Call v3 admin API to create a new folder
* @param {string} folder Name of new folder to create (combines with current uibuilder url)
* returns {string} Status message
*/
function createNewFolder(folder) {
// Also get the current url
const url = $('#node-input-url').val()
$.ajax({
type: 'POST',
dataType: 'json',
url: `./uibuilder/admin/${url}`,
data: {
folder: folder,
cmd: 'newfolder',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
.done(function() { // data, textStatus, jqXHR) {
RED.notify(`uibuilder: Folder <code>${folder}</code> Created.`, { type: 'success', })
// Rebuild the file list
getFileList()
// Rebuild the folder list
getFolders()
return 'Create folder succeeded'
})
.fail(function(jqXHR, textStatus, errorThrown) {
RED.notify(`uibuilder: Create Folder Error.<br>${errorThrown}`, { type: 'error', })
return 'Create folder failed'
})
} // --- End of createNewFile --- //
/** Call v3 admin API to create a new file
* @param {string} fname Name of new file to create (combines with current selected folder and the current uibuilder url)
* returns {string} Status message
*/
function createNewFile(fname) {
// Also get the current folder & url
const folder = $('#node-input-folder').val() || uiace.folder
const url = $('#node-input-url').val()
$.ajax({
type: 'POST',
dataType: 'json',
url: `./uibuilder/admin/${url}`,
data: {
folder: folder,
fname: fname,
cmd: 'newfile',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
.done(function() { // data, textStatus, jqXHR) {
RED.notify(`uibuilder: File <code>${folder}/${fname}</code> Created.`, { type: 'success', })
// Rebuild the file list
getFileList(fname)
return 'Create file succeeded'
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:createNewFile:post] Error ' + textStatus, errorThrown )
RED.notify(`uibuilder: Create File Error.<br>${errorThrown}`, { type: 'error', })
return 'Create file failed'
})
} // --- End of createNewFile --- //
/** Call v3 admin API to delete the currently selected folder
* returns {string} Status message
*/
function deleteFolder() {
// Also get the current url & folder
const url = $('#node-input-url').val()
const folder = $('#node-input-folder').val()
$.ajax({
type: 'DELETE',
dataType: 'json',
url: `./uibuilder/admin/${url}`,
data: {
folder: folder,
cmd: 'deletefolder',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
.done(function() { // data, textStatus, jqXHR) {
RED.notify(`uibuilder: Folder <code>${folder}</code> deleted.`, { type: 'success', })
// Rebuild the file list
getFileList()
return 'Delete folder succeeded'
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:deleteFolder:delete] Error ' + textStatus, errorThrown )
RED.notify(`uibuilder: Delete Folder Error.<br>${errorThrown}`, { type: 'error', })
return 'Delete folder failed'
})
} // --- End of deleteFolder --- //
/** Call v3 admin API to delete the currently selected file
* returns {string} Status message
*/
function deleteFile() {
// Get the current file, folder & url
const folder = /** @type {string} */ ($('#node-input-folder').val()) || uiace.folder
const url = /** @type {string} */ ($('#node-input-url').val())
const fname = /** @type {string} */ ($('#node-input-filename').val())
$.ajax({
type: 'DELETE',
dataType: 'json',
url: `./uibuilder/admin/${url}`,
data: {
folder: folder,
fname: fname,
cmd: 'deletefile',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
})
.done(function() { // data, textStatus, jqXHR) {
RED.notify(`uibuilder: File <code>${folder}/${fname}</code> Deleted.`, { type: 'success', })
// Rebuild the file list
getFileList(fname)
return 'Delete file succeeded'
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.error( 'đđ[uibuilder:deleteFile:delete] Error ' + textStatus, errorThrown )
RED.notify(`uibuilder: Delete File Error.<br>${errorThrown}`, { type: 'error', })
return 'Delete file failed'
})
} // --- End of deleteFile --- //
/** Set the height of the ACE text editor box */
function setACEheight() {
let height
if ( uiace.editorLoaded === true ) {
// If the editor is in full-screen ...
if (document.fullscreenElement) {
// Force background color and add some padding to keep away from edge
$('#edit-props').css('background-color', '#f6f6f6')
.css('padding', '1em')
// Start to calculate the available height and adjust the editor to fill the ht
height = parseInt($('#edit-props').css('height'), 10) // full available height
height -= 25
// Replace the expand icon with a compress icon
$('#node-function-expand-js').css('background-color', 'black')
.html('<i class="fa fa-compress"></i>')
uiace.fullscreen = true
} else {
// Don't bother if the top of the editor is still auto
if ( $('#edit-outer').css('top') === 'auto' ) return
$('#edit-props').css('background-color', '')
.css('padding', '')
height = ($('.red-ui-tray-footer').position()).top - ($('#edit-outer').offset()).top - 35
// Replace the compress icon with a expand icon
$('#node-function-expand-js').css('background-color', '')
.html('<i class="fa fa-expand"></i>')
uiace.fullscreen = false
}
// everything but the edit box
const rows = $('#edit-props > div:not(.node-text-editor-row)')
// subtract height of each row from the total
for (let i = 0; i < rows.length; i++) {
height -= $(rows[i]).outerHeight(true)
}
// Set the height of the edit box - no longer needed, using calc CSS
// $('#node-input-template-editor').css('height', height + 'px')
// Get the content to match the edit box size
uiace.editor.resize()
}
} // --- End of setACEheight --- //
/** Save Edited File */
function saveFile() {
const authTokens = RED.settings.get('auth-tokens')
// Post the updated content of the file via the admin API
// NOTE: Cannot use jQuery POST function as it sets headers node trigger a CORS error. Do it using native requests only.
// Clients will be reloaded if the reload checkbox is set.
const request = new XMLHttpRequest()
const params = 'fname=' + $('#node-input-filename').val() + '&folder=' + $('#node-input-folder').val()
+ '&url=' + $('#node-input-url').val()
+ '&reload=' + $('#node-input-reload').prop('checked')
+ '&data=' + encodeURIComponent(uiace.editorSession.getValue())
request.open('POST', 'uibuilder/uibputfile', true)
request.onreadystatechange = function() {
if (this.readyState === XMLHttpRequest.DONE) {
if (this.status === 200) {
// Request successful
// display msg - blank msg when new edits present
$('#file-action-message').text('File Saved')
fileIsClean(true)
} else {
// Request failed
// display msg - blank msg when new edits present
$('#file-action-message').text('File Save FAILED')
}
}
}
request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded')
// If admin ui is protected with a login, we need to send the access token
if (authTokens) request.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
request.send(params)
} // ---- End of saveFile ---- //
/** Create the wrapping HTML string that provides a link to open the instance folder in vscode
* @param {object} node A reference to the panel's `this` object
* @returns {{pre,post,url,icon}} Prefix and postfix for link + vscode url scheme & icon
*/
function vscodeLink(node) {
if (node.url) {
if (uibuilder.localHost) node.editurl = `vscode://file${RED.settings.uibuilderRootFolder}/${node.url}/?windowId=_blank`
else node.editurl = `vscode://vscode-remote/ssh-remote+${uibuilder.nrServer}${RED.settings.uibuilderRootFolder}/${node.url}/?windowId=_blank`
$('#node-input-editurl').val(node.editurl)
}
let pre, post
if (node.editurl) {
pre = `<a href="${node.editurl}" title="Open in VScode">`
post = '</a>'
} else {
pre = '<b>'
post = '</b>'
}
return {
pre: pre,
post: post,
url: node.editurl,
icon: '<img src="resources/node-red-contrib-uibuilder/vscode.svg" style="width:20px" >',
}
}
// #endregion ==== File Management Functions ==== //
// #region ==== Validation Functions ==== //
// These are called on editor load & node added to flow as well as on form change
// Use `$('#node-input-url').length > 0` to check if form exists
/** Update the deployed instances list
* @param {object} node Pass in this
*/
function updateDeployedInstances(node) {
uibuilder.deployedUibInstances = uibuilder.getDeployedUrls()
if ( node ) node.isDeployed = uibuilder.deployedUibInstances[node.id] !== undefined
}
/** Find out if a server folder exists for this url
* @param {string} url URL to check
* @returns {boolean} Whether the folder exists
*/
function queryFolderExists(url) {
if (url === undefined) return false
let check = false
$.ajax({
type: 'GET',
async: false,
dataType: 'json',
url: `./uibuilder/admin/${url}`,
data: {
cmd: 'checkfolder',
},
beforeSend: function(jqXHR) {
const authTokens = RED.settings.get('auth-tokens')
if (authTokens) {
jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token)
}
},
success: function(data) {
check = data
},
error: function(jqXHR, textStatus, errorThrown) {
if (errorThrown !== 'Not Found') {
console.error( 'đđ[uibuilder:queryFolderExists] Error ' + textStatus, errorThrown )
}
check = false
},
})
return check
} // ---- end of queryFolderExists ---- //
/** (Dis)Allow uibuilder configuration other than URL changes
* @param {object} urlErrors List of errors
* @param {boolean} enable True=Enable config editing, false=disable
*/
function enableEdit(urlErrors, enable = true) {
if (enable === true) {
// Enable template changes
$('#node-input-templateFolder, #btn-load-template')
.prop('disabled', false)
.css({
cursor: 'pointer',
})
// Enable tabs and links
$('#red-ui-tab-tab-files, #red-ui-tab-tab-libraries, #red-ui-tab-tab-security, #red-ui-tab-tab-advanced, info')
.css({
cursor: 'pointer',
})
$('#red-ui-tab-tab-files>a, #red-ui-tab-tab-libraries>a, #red-ui-tab-tab-security>a, #red-ui-tab-tab-advanced>a, info>a')
.prop('disabled', false)
.css({
'pointer-events': 'auto',
'cursor': 'pointer',
'opacity': 1,
})
// Enable action buttons
$('#uibuilderurl, #uibinstanceconf, #uib-apps-list')
.prop('disabled', false)
.css({
// 'pointer-events': 'auto',
cursor: 'pointer',
opacity: 1,
})
// Clear the errors
$('#url-errors').remove()
} else {
// Disable template changes
$('#node-input-templateFolder, #btn-load-template')
.prop('disabled', true)
.css({
cursor: 'not-allowed',
})
// Disable tabs and links
$('#red-ui-tab-tab-files, #red-ui-tab-tab-libraries, #red-ui-tab-tab-security, #red-ui-tab-tab-advanced, info')
.css({
cursor: 'not-allowed',
})
$('#red-ui-tab-tab-files>a, #red-ui-tab-tab-libraries>a, #red-ui-tab-tab-security>a, #red-ui-tab-tab-advanced>a, info>a')
.prop('disabled', true)
.css({
'pointer-events': 'none',
'cursor': 'not-allowed',
'opacity': 0.3,
})
// Disable action buttons
$('#uibuilderurl, #uibinstanceconf, #btntopopen, #uib-apps-list')
.prop('disabled', true)
.css({
// 'pointer-events': 'none',
cursor: 'not-allowed',
opacity: 0.3,
})
// Show the errors
$('#url-errors').remove()
$('#url-input').after(`
<div id="url-errors" class="form-row" style="color:var(--red-ui-text-color-error)">
${Object.values(urlErrors).join('<br>')}
</div>
`)
}
} // ---- End of enableEdit ---- //
/** Show key data for URL changes
* @param {*} node Reference to node definition
* @param {*} value Value
*/
function debugUrl(node, value) {
if (!uibuilder.debug) return
console.groupCollapsed(`>> validateUrl >> ${node.id}`)
log('-- isDeployed --', node.isDeployed )
log('-- node.url --', node.url, '-- node.oldUrl --', node.oldUrl)
log('-- value --', value)
log('-- Editor URL Changed? --', node.urlChanged, '-- Valid? --', node.urlValid )
log('-- Deployed URL Changed? --', node.urlDeployedChanged )
log('-- deployedUibInstances[node.id] --', uibuilder.deployedUibInstances[node.id])
log('-- editorInstances[node.id] --', editorInstances[node.id])
log('-- is Dup? -- Deployed:', node.urlDeployedDup, ', Editor:', node.urlEditorDup)
log('-- URL Errors --', node.urlErrors )
log('-- Node Changed? --', node.changed, '-- Valid? --', node.valid )
log('-- this --', node)
console.groupEnd()
}
/** Live URL Validation Function: Validate the url property
* Max 20 chars, can't contain any of ['..', ]
* @param {string} value The url value to validate
* @returns {boolean} true = valid
* @this {*}
*/
function validateUrl(value) {
if ($('#node-input-url').is(':visible')) {
// Update the DEPLOYED instances list (also updates this.isDeployed) - not needed on Editor load
updateDeployedInstances(this)
}
// this.urlValid = false
this.urlErrors = {}
this.urlDeployedChanged = uibuilder.deployedUibInstances[this.id] !== value // || (this.oldUrl !== undefined && this.url !== this.oldUrl)
this.urlChanged = (this.url !== value)
let f = Object.values(uibuilder.deployedUibInstances).indexOf(value)
this.urlDeployedDup = ( f > -1 && Object.keys(uibuilder.deployedUibInstances)[f] !== this.id )
f = Object.values(editorInstances).indexOf(value)
this.urlEditorDup = ( f > -1 && Object.keys(editorInstances)[f] !== this.id )
// If value is undefined, node hasn't been configured yet - we assume empty url which is invalid
if ( value === undefined ) {
this.urlErrors.config = 'Not yet configured, valid URL needed'
}
// Must be >0 chars
if ( value === undefined || value === '' ) {
this.urlErrors.none = 'Must not be empty'
} else { // These will fail for empty value
// Max 20 chars
if ( value.length > 20 ) {
this.urlErrors.len = 'Too long, must be <= 20 chars'
}
// Cannot contain ..
if ( value.indexOf('..') !== -1 ) {
this.urlErrors.dbldot = 'Cannot contain <code>..</code> (double-dot)'
}
// Cannot contain / or \
if ( value.indexOf('/') !== -1 ) {
this.urlErrors.fslash = 'Cannot contain <code>/</code>'
}
if ( value.indexOf('\\') !== -1 ) {
this.urlErrors.bslash = 'Cannot contain <code>\\</code>'
}
// Cannot contain spaces
if ( value.indexOf(' ') !== -1 ) this.urlErrors.sp = 'Cannot contain spaces'
// Cannot start with _ or .
if ( value.substring(0, 1) === '_' ) this.urlErrors.strtU = 'Cannot start with <code>_</code> (underscore)'
if ( value.substring(0, 1) === '.' ) this.urlErrors.strtDot = 'Cannot start with <code>.</code> (dot)'
// Cannot be 'templates' as this is a reserved value (for v2)
if ( value.toLowerCase().substring(0, 9) === 'templates' ) this.urlErrors.templ = 'Cannot be "templates"'
// Cannot be 'common' as this is a reserved value
if ( value.toLowerCase().substring(0, 9) === 'common' ) this.urlErrors.templ = 'Cannot be "common"'
// Must not be `uibuilder` (breaking change in v5)
if ( value.toLowerCase() === 'uibuilder' ) this.urlErrors.uibname = 'Cannot be "uibuilder" (since v5)'
}
// TODO ?MAYBE? Notify's shouldn't be here - only needed if "Done" (or event change)
// Check whether the url is already in use in another deployed uib node
if ( this.urlDeployedDup === true ) {
// RED.notify(`<b>ERROR</b>: <p>The chosen URL (${value}) is already in use.<br>It must be changed before you can save/commit</p>`, {type: 'error'})
this.urlErrors.dup = 'Cannot be a URL already deployed'
}
// Check whether the url is already in use in another undeployed uib node
if ( this.urlEditorDup === true ) {
// RED.notify(`<b>ERROR</b>: <p>The chosen URL (${value}) is already in use in another undeployed uib node.<br>It must be changed before you can save/commit</p>`, {type: 'error'})
this.urlErrors.dup = 'Cannot be a URL already in use'
}
// Check whether the folder already exists - if it does, give a warning & adopt it
if ( value !== undefined && value !== '') {
this.folderExists = queryFolderExists(value)
/** If the folder already exists but not in another node */
if ( this.urlEditorDup === false && this.folderExists === true && this.urlChanged === true ) {
log('>> folder already exists >>', this.url, this.id)
RED.notify(`<b>WARNING</b>: <p>The folder for the chosen URL (${value}) is already exists.<br>It will be adopted by this node.</p>`, { type: 'warning', })
}
if ( this.folderExists === false ) {
this.urlErrors.fldNotExist = 'URL does not yet exist. You must Deploy first.<br>If changing a deployed URL, the folder will be renamed on Deploy'
}
}
// Warn user when changing URL. NOTE: Set/reset old url in the onsave function not here
if ( this.isDeployed && this.deployedUrlChanged === true ) {
log('[uib] >> deployed url changed >> this.url:', this.url, ', this.oldUrl:', this.oldUrl, this.id)
this.urlErrors.warnChange = `Renaming from ${this.url} to ${value}. <b>MUST</b> redeploy now`
RED.notify(`<b>NOTE</b>: <p>You are renaming the url from ${this.url} to ${value}.<br>You <b>MUST</b> redeploy before doing anything else.</p>`, { type: 'warning', })
}
// Process panel display for valid/invalid URL - NB repeated once in oneditprepare cause 1st time this is called, the panel doesn't yet exist
if ( Object.keys(this.urlErrors).length > 0) {
// Changed URL not valid: turn off other edits, show errors
this.urlValid = false
enableEdit(this.urlErrors, false)
this.okToGo = false
} else {
// Changed URL valid: turn on other edits
this.urlValid = true
enableEdit(this.urlErrors, true)
this.okToGo = true
}
// debugUrl(this, value)
// Add exception if the only error is that the fldr doesn't yet exist
if ( Object.keys(this.urlErrors).length === 1 && this.urlErrors.fldNotExist ) {
this.okToGo = true
return true
}
return this.urlValid
} // --- End of validateUrl --- //
/** Validation Function: Validate the session length property
* If security is on, must contain a number
* @returns {boolean} true = valid, false = not valid
* @this {*}
*/
// function validateSessLen() {
// // NB: `this` is the node instance configuration as at last press of Done
// // TODO: Add display comment to help user
// var newVal = $('#node-input-sessionLength').val()
// var newSec = $('#node-input-useSecurity').is(':checked')
// if (newSec === true && (newVal.toString()).length < 1 ) return false
// return true
// } // --- End of validateSessLen --- //
/** Validation Function: Validate the jwtSecret property
* If security is on, must contain text
* @returns {boolean} true = valid, false = not valid
*/
// function validateSecret() { // eslint-disable-line no-unused-vars
// // NB: `this` is the node instance configuration as at last press of Done
// // TODO: Add display comment to help user
// var newVal = $('#node-input-jwtSecret').val()
// var newSec = $('#node-input-useSecurity').is(':checked')
// if (newSec === true && (newVal.toString()).length < 1 ) return false
// return true
// } // --- End of validateSecret --- //
/** Validation function: Was this node last deployed with a safe version?
* In uibuilder.js, can change uib.reDeployNeeded to be the last version before the v that made a breaking change.
* @example If the node was last deployed with v4.1.2 and the current version is now v5.0.0, set uib.reDeployNeeded to '4.1.2'
* @returns {boolean} True if valid
* @this {*}
*/
function validateVersion() {
if ( this.url !== undefined ) { // Undefined means that the node hasn't been configured at all yet
const deployedVersion = this.deployedVersion === undefined ? '0' : this.deployedVersion
if (deployedVersion < RED.settings.uibuilderRedeployNeeded && RED.settings.uibuilderCurrentVersion > RED.settings.uibuilderRedeployNeeded) {
RED.notify(`<i>uibuilder ${this.url}</i><br>uibuilder has been updated since you last deployed this instance. Please deploy now.`, {
modal: false,
fixed: false,
type: 'warning', // 'compact', 'success', 'warning', 'error'
})
this.mustChange = true
return false
}