UNPKG

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
/* 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.&nbsp;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 }