1
0
mirror of https://github.com/esphome/esphome.git synced 2025-04-08 11:50:34 +01:00
2021-04-08 14:26:01 +02:00

1022 lines
28 KiB
JavaScript

'use strict';
// Document Ready
$(document).ready(function () {
M.AutoInit(document.body);
nodeGrid();
startAceWebsocket();
fixNavbarHeight();
});
// WebSocket URL Helper
const loc = window.location;
const wsLoc = new URL("./", `${loc.protocol}//${loc.host}${loc.pathname}`);
wsLoc.protocol = 'ws:';
if (loc.protocol === "https:") {
wsLoc.protocol = 'wss:';
}
const wsUrl = wsLoc.href;
/**
* Fix NavBar height
*/
const fixNavbarHeight = () => {
const fixFunc = () => {
const sel = $(".select-wrapper");
$(".navbar-fixed").css("height", (sel.position().top + sel.outerHeight()) + "px");
}
$(window).resize(fixFunc);
fixFunc();
}
/**
* Dashboard Dynamic Grid
*/
const nodeGrid = () => {
const nodeCount = document.querySelectorAll("#nodes .card").length;
const nodeGrid = document.querySelector("#nodes #grid");
if (nodeCount <= 3) {
nodeGrid.classList.add("grid-1-col");
} else if (nodeCount <= 6) {
nodeGrid.classList.add("grid-2-col");
} else {
nodeGrid.classList.add("grid-3-col");
}
}
/**
* Online/ Offline Status Indication
*/
let isFetchingPing = false;
const fetchPing = () => {
if (isFetchingPing) {
return;
}
isFetchingPing = true;
fetch(`./ping`, { credentials: "same-origin" }).then(res => res.json())
.then(response => {
for (let filename in response) {
let node = document.querySelector(`#nodes .card[data-filename="${filename}"]`);
if (node === null) {
continue;
}
let status = response[filename];
let className;
if (status === null) {
className = 'status-unknown';
} else if (status === true) {
className = 'status-online';
node.setAttribute('data-last-connected', Date.now().toString());
} else if (node.hasAttribute('data-last-connected')) {
const attr = parseInt(node.getAttribute('data-last-connected'));
if (Date.now() - attr <= 5000) {
className = 'status-not-responding';
} else {
className = 'status-offline';
}
} else {
className = 'status-offline';
}
if (node.classList.contains(className)) {
continue;
}
node.classList.remove('status-unknown', 'status-online', 'status-offline', 'status-not-responding');
node.classList.add(className);
}
isFetchingPing = false;
});
};
setInterval(fetchPing, 2000);
fetchPing();
/**
* Log Color Parsing
*/
const initializeColorState = () => {
return {
bold: false,
italic: false,
underline: false,
strikethrough: false,
foregroundColor: false,
backgroundColor: false,
carriageReturn: false,
secret: false,
};
};
const colorReplace = (pre, state, text) => {
const re = /(?:\033|\\033)(?:\[(.*?)[@-~]|\].*?(?:\007|\033\\))/g;
let i = 0;
if (state.carriageReturn) {
if (text !== "\n") {
// don't remove if \r\n
pre.removeChild(pre.lastChild);
}
state.carriageReturn = false;
}
if (text.includes("\r")) {
state.carriageReturn = true;
}
const lineSpan = document.createElement("span");
lineSpan.classList.add("line");
pre.appendChild(lineSpan);
const addSpan = (content) => {
if (content === "")
return;
const span = document.createElement("span");
if (state.bold) span.classList.add("log-bold");
if (state.italic) span.classList.add("log-italic");
if (state.underline) span.classList.add("log-underline");
if (state.strikethrough) span.classList.add("log-strikethrough");
if (state.secret) span.classList.add("log-secret");
if (state.foregroundColor !== null) span.classList.add(`log-fg-${state.foregroundColor}`);
if (state.backgroundColor !== null) span.classList.add(`log-bg-${state.backgroundColor}`);
span.appendChild(document.createTextNode(content));
lineSpan.appendChild(span);
if (state.secret) {
const redacted = document.createElement("span");
redacted.classList.add("log-secret-redacted");
redacted.appendChild(document.createTextNode("[redacted]"));
lineSpan.appendChild(redacted);
}
};
while (true) {
const match = re.exec(text);
if (match === null)
break;
const j = match.index;
addSpan(text.substring(i, j));
i = j + match[0].length;
if (match[1] === undefined) continue;
for (const colorCode of match[1].split(";")) {
switch (parseInt(colorCode)) {
case 0:
// reset
state.bold = false;
state.italic = false;
state.underline = false;
state.strikethrough = false;
state.foregroundColor = null;
state.backgroundColor = null;
state.secret = false;
break;
case 1:
state.bold = true;
break;
case 3:
state.italic = true;
break;
case 4:
state.underline = true;
break;
case 5:
state.secret = true;
break;
case 6:
state.secret = false;
break;
case 9:
state.strikethrough = true;
break;
case 22:
state.bold = false;
break;
case 23:
state.italic = false;
break;
case 24:
state.underline = false;
break;
case 29:
state.strikethrough = false;
break;
case 30:
state.foregroundColor = "black";
break;
case 31:
state.foregroundColor = "red";
break;
case 32:
state.foregroundColor = "green";
break;
case 33:
state.foregroundColor = "yellow";
break;
case 34:
state.foregroundColor = "blue";
break;
case 35:
state.foregroundColor = "magenta";
break;
case 36:
state.foregroundColor = "cyan";
break;
case 37:
state.foregroundColor = "white";
break;
case 39:
state.foregroundColor = null;
break;
case 41:
state.backgroundColor = "red";
break;
case 42:
state.backgroundColor = "green";
break;
case 43:
state.backgroundColor = "yellow";
break;
case 44:
state.backgroundColor = "blue";
break;
case 45:
state.backgroundColor = "magenta";
break;
case 46:
state.backgroundColor = "cyan";
break;
case 47:
state.backgroundColor = "white";
break;
case 40:
case 49:
state.backgroundColor = null;
break;
}
}
}
addSpan(text.substring(i));
if (pre.scrollTop + 56 >= (pre.scrollHeight - pre.offsetHeight)) {
// at bottom
pre.scrollTop = pre.scrollHeight;
}
};
/**
* Serial Port Selection
*/
const portSelect = document.querySelector('.nav-wrapper select');
let ports = [];
const fetchSerialPorts = (begin = false) => {
fetch(`./serial-ports`, { credentials: "same-origin" }).then(res => res.json())
.then(response => {
if (ports.length === response.length) {
let allEqual = true;
for (let i = 0; i < response.length; i++) {
if (ports[i].port !== response[i].port) {
allEqual = false;
break;
}
}
if (allEqual)
return;
}
const hasNewPort = response.length >= ports.length;
ports = response;
const inst = M.FormSelect.getInstance(portSelect);
if (inst !== undefined) {
inst.destroy();
}
portSelect.innerHTML = "";
const prevSelected = getUploadPort();
for (let i = 0; i < response.length; i++) {
const val = response[i];
if (val.port === prevSelected) {
portSelect.innerHTML += `<option value="${val.port}" selected>${val.port} (${val.desc})</option>`;
} else {
portSelect.innerHTML += `<option value="${val.port}">${val.port} (${val.desc})</option>`;
}
}
M.FormSelect.init(portSelect, {});
if (!begin && hasNewPort)
M.toast({ html: "Discovered new serial port." });
});
};
const getUploadPort = () => {
const inst = M.FormSelect.getInstance(portSelect);
if (inst === undefined) {
return "OTA";
}
inst._setSelectedStates();
return inst.getSelectedValues()[0];
};
setInterval(fetchSerialPorts, 5000);
fetchSerialPorts(true);
/**
* Log Elements
*/
// Log Modal Class
class LogModal {
constructor({
name,
onPrepare = (modalElement, config) => { },
onProcessExit = (modalElement, code) => { },
onSocketClose = (modalElement) => { },
dismissible = true
}) {
this.modalId = `js-${name}-modal`;
this.dataAction = `${name}`;
this.wsUrl = `${wsUrl}${name}`;
this.dismissible = dismissible;
this.activeFilename = null;
this.modalElement = document.getElementById(this.modalId);
this.nodeFilenameElement = document.querySelector(`#${this.modalId} #js-node-filename`);
this.logElement = document.querySelector(`#${this.modalId} #js-log-area`);
this.onPrepare = onPrepare;
this.onProcessExit = onProcessExit;
this.onSocketClose = onSocketClose;
}
setup() {
const boundOnPress = this._onPress.bind(this);
document.querySelectorAll(`[data-action="${this.dataAction}"]`).forEach((button) => {
button.addEventListener('click', boundOnPress);
});
}
_setupModalInstance() {
this.modalInstance = M.Modal.init(this.modalElement, {
onOpenStart: this._onOpenStart.bind(this),
onCloseStart: this._onCloseStart.bind(this),
dismissible: this.dismissible
})
}
_onOpenStart() {
document.addEventListener('keydown', this._boundKeydown);
}
_onCloseStart() {
document.removeEventListener('keydown', this._boundKeydown);
this.activeSocket.close();
}
open(event) {
this._onPress(event);
}
_onPress(event) {
this.activeFilename = event.target.getAttribute('data-filename');
this._setupModalInstance();
this.nodeFilenameElement.innerHTML = this.activeFilename;
this.logElement.innerHTML = "";
const colorLogState = initializeColorState();
this.onPrepare(this.modalElement, this.activeFilename);
let stopped = false;
this.modalInstance.open();
const socket = new WebSocket(this.wsUrl);
this.activeSocket = socket;
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (data.event === "line") {
colorReplace(this.logElement, colorLogState, data.data);
} else if (data.event === "exit") {
this.onProcessExit(this.modalElement, data.code);
stopped = true;
}
});
socket.addEventListener('open', () => {
const msg = JSON.stringify(this._encodeSpawnMessage(this.activeFilename));
socket.send(msg);
});
socket.addEventListener('close', () => {
if (!stopped) {
this.onSocketClose(this.modalElement);
}
});
}
_onKeyDown(event) {
// Close on escape key
if (event.keyCode === 27) {
this.modalInstance.close();
}
}
_encodeSpawnMessage(filename) {
return {
type: 'spawn',
configuration: filename,
port: getUploadPort(),
};
}
}
// Logs Modal
const logsModal = new LogModal({
name: "logs",
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("[data-action='stop-logs']").innerHTML = "Stop";
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: "Program exited successfully",
displayLength: 10000
});
} else {
M.toast({
html: `Program failed with code ${code}`,
displayLength: 10000
});
}
modalElem.querySelector("data-action='stop-logs'").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000
});
}
})
logsModal.setup();
// Upload Modal
const uploadModal = new LogModal({
name: "upload",
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("#js-upload-modal [data-action='download-binary']").classList.add('disabled');
modalElement.querySelector("#js-upload-modal [data-action='upload']").setAttribute('data-filename', activeFilename);
modalElement.querySelector("#js-upload-modal [data-action='upload']").classList.add('disabled');
modalElement.querySelector("#js-upload-modal [data-action='edit']").setAttribute('data-filename', activeFilename);
modalElement.querySelector("#js-upload-modal [data-action='stop-logs']").innerHTML = "Stop";
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: "Program exited successfully",
displayLength: 10000
});
modalElement.querySelector("#js-upload-modal [data-action='download-binary']").classList.remove('disabled');
} else {
M.toast({
html: `Program failed with code ${code}`,
displayLength: 10000
});
modalElement.querySelector("#js-upload-modal [data-action='upload']").classList.add('disabled');
modalElement.querySelector("#js-upload-modal [data-action='upload']").classList.remove('disabled');
}
modalElement.querySelector("#js-upload-modal [data-action='stop-logs']").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000
});
},
dismissible: false
})
uploadModal.setup();
const downloadAfterUploadButton = document.querySelector("#js-upload-modal [data-action='download-binary']");
downloadAfterUploadButton.addEventListener('click', () => {
const link = document.createElement("a");
link.download = name;
link.href = `./download.bin?configuration=${encodeURIComponent(uploadModal.activeFilename)}`;
document.body.appendChild(link);
link.click();
link.remove();
});
// Validate Modal
const validateModal = new LogModal({
name: 'validate',
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("#js-validate-modal [data-action='stop-logs']").innerHTML = "Stop";
modalElement.querySelector("#js-validate-modal [data-action='edit']").setAttribute('data-filename', activeFilename);
modalElement.querySelector("#js-validate-modal [data-action='upload']").setAttribute('data-filename', activeFilename);
modalElement.querySelector("#js-validate-modal [data-action='upload']").classList.add('disabled');
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: `<code class="inlinecode">${validateModal.activeFilename}</code> is valid 👍`,
displayLength: 10000,
});
modalElement.querySelector("#js-validate-modal [data-action='upload']").classList.remove('disabled');
} else {
M.toast({
html: `<code class="inlinecode">${validateModal.activeFilename}</code> is invalid 😕`,
displayLength: 10000,
});
}
modalElement.querySelector("#js-validate-modal [data-action='stop-logs']").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000,
});
},
});
validateModal.setup();
// Compile Modal
const compileModal = new LogModal({
name: 'compile',
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("#js-compile-modal [data-action='stop-logs']").innerHTML = "Stop";
modalElement.querySelector("#js-compile-modal [data-action='download-binary']").classList.add('disabled');
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: "Program exited successfully",
displayLength: 10000,
});
modalElement.querySelector("#js-compile-modal [data-action='download-binary']").classList.remove('disabled');
} else {
M.toast({
html: `Program failed with code ${data.code}`,
displayLength: 10000,
});
}
modalElement.querySelector("#js-compile-modal [data-action='stop-logs']").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000,
});
},
dismissible: false,
});
compileModal.setup();
const downloadAfterCompileButton = document.querySelector("#js-compile-modal [data-action='download-binary']");
downloadAfterCompileButton.addEventListener('click', () => {
const link = document.createElement("a");
link.download = name;
link.href = `./download.bin?configuration=${encodeURIComponent(compileModal.activeFilename)}`;
document.body.appendChild(link);
link.click();
link.remove();
});
// Clean MQTT Modal
const cleanMqttModal = new LogModal({
name: 'clean-mqtt',
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("#js-clean-mqtt-modal [data-action='stop-logs']").innerHTML = "Stop";
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: "Program exited successfully",
displayLength: 10000,
});
} else {
M.toast({
html: `Program failed with code ${code}`,
displayLength: 10000,
});
}
modalElement.querySelector("#js-clean-mqtt-modal [data-action='stop-logs']").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000,
});
},
});
cleanMqttModal.setup();
// Clean Build Files Modal
const cleanModal = new LogModal({
name: 'clean',
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("#js-clean-modal [data-action='stop-logs']").innerHTML = "Stop";
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: "Program exited successfully",
displayLength: 10000,
});
} else {
M.toast({
html: `Program failed with code ${code}`,
displayLength: 10000,
});
}
modalElement.querySelector("#js-clean-modal [data-action='stop-logs']").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000,
});
},
});
cleanModal.setup();
// Update All Modal
const updateAllModal = new LogModal({
name: 'update-all',
onPrepare: (modalElement, activeFilename) => {
modalElement.querySelector("#js-update-all-modal [data-action='stop-logs']").innerHTML = "Stop";
modalElement.querySelector("#js-update-all-modal #js-node-filename").style.visibility = "hidden";
},
onProcessExit: (modalElement, code) => {
if (code === 0) {
M.toast({
html: "Program exited successfully",
displayLength: 10000,
});
downloadButton.classList.remove('disabled');
} else {
M.toast({
html: `Program failed with code ${data.code}`,
displayLength: 10000,
});
}
modalElement.querySelector("#js-update-all-modal [data-action='stop-logs']").innerHTML = "Close";
},
onSocketClose: (modalElement) => {
M.toast({
html: 'Terminated process',
displayLength: 10000,
});
},
dismissible: false,
});
updateAllModal.setup();
/**
* Node Editing
*/
let editorActiveFilename = null;
let editorActiveSecrets = false;
let editorActiveWebSocket = null;
let editorValidationScheduled = false;
let editorValidationRunning = false;
// Setup Editor
const editorElement = document.querySelector("#js-editor-modal #js-editor-area");
const editor = ace.edit(editorElement);
editor.setOptions({
highlightActiveLine: true,
showPrintMargin: true,
useSoftTabs: true,
tabSize: 2,
useWorker: false,
theme: 'ace/theme/dreamweaver',
mode: 'ace/mode/yaml'
});
editor.commands.addCommand({
name: 'saveCommand',
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
exec: function () {
saveFile(editorActiveFilename);
},
readOnly: false
});
// Edit Button Listener
document.querySelectorAll("[data-action='edit']").forEach((button) => {
button.addEventListener('click', (event) => {
editorActiveFilename = event.target.getAttribute("data-filename");
const filenameField = document.querySelector("#js-editor-modal #js-node-filename");
filenameField.innerHTML = editorActiveFilename;
const saveButton = document.querySelector("#js-editor-modal [data-action='save']");
const uploadButton = document.querySelector("#js-editor-modal [data-action='upload']");
const closeButton = document.querySelector("#js-editor-modal [data-action='close']");
saveButton.setAttribute('data-filename', editorActiveFilename);
uploadButton.setAttribute('data-filename', editorActiveFilename);
uploadButton.setAttribute('onClick', `saveFile("${editorActiveFilename}")`);
if (editorActiveFilename === "secrets.yaml") {
uploadButton.classList.add("disabled");
editorActiveSecrets = true;
} else {
uploadButton.classList.remove("disabled");
editorActiveSecrets = false;
}
closeButton.setAttribute('data-filename', editorActiveFilename);
const loadingIndicator = document.querySelector("#js-editor-modal #js-loading-indicator");
const editorArea = document.querySelector("#js-editor-modal #js-editor-area");
loadingIndicator.style.display = "block";
editorArea.style.display = "none";
editor.setOption('readOnly', true);
fetch(`./edit?configuration=${editorActiveFilename}`, { credentials: "same-origin" })
.then(res => res.text()).then(response => {
editor.setValue(response, -1);
editor.setOption('readOnly', false);
loadingIndicator.style.display = "none";
editorArea.style.display = "block";
});
editor.focus();
const editModalElement = document.getElementById("js-editor-modal");
const editorModal = M.Modal.init(editModalElement, {
onOpenStart: function () {
editorModalOnOpen()
},
onCloseStart: function () {
editorModalOnClose()
},
dismissible: false
})
editorModal.open();
});
});
// Editor On Open
const editorModalOnOpen = () => {
return
}
// Editor On Close
const editorModalOnClose = () => {
editorActiveFilename = null;
}
// Editor WebSocket Validation
const startAceWebsocket = () => {
editorActiveWebSocket = new WebSocket(`${wsUrl}ace`);
editorActiveWebSocket.addEventListener('message', (event) => {
const raw = JSON.parse(event.data);
if (raw.event === "line") {
const msg = JSON.parse(raw.data);
if (msg.type === "result") {
const arr = [];
for (const v of msg.validation_errors) {
let o = {
text: v.message,
type: 'error',
row: 0,
column: 0
};
if (v.range != null) {
o.row = v.range.start_line;
o.column = v.range.start_col;
}
arr.push(o);
}
for (const v of msg.yaml_errors) {
arr.push({
text: v.message,
type: 'error',
row: 0,
column: 0
});
}
editor.session.setAnnotations(arr);
if (arr.length) {
document.querySelector("#js-editor-modal [data-action='upload']").classList.add('disabled');
} else {
document.querySelector("#js-editor-modal [data-action='upload']").classList.remove('disabled');
}
editorValidationRunning = false;
} else if (msg.type === "read_file") {
sendAceStdin({
type: 'file_response',
content: editor.getValue()
});
}
}
})
editorActiveWebSocket.addEventListener('open', () => {
const msg = JSON.stringify({ type: 'spawn' });
editorActiveWebSocket.send(msg);
});
editorActiveWebSocket.addEventListener('close', () => {
editorActiveWebSocket = null;
setTimeout(startAceWebsocket, 5000);
});
};
const sendAceStdin = (data) => {
let send = JSON.stringify({
type: 'stdin',
data: JSON.stringify(data) + '\n',
});
editorActiveWebSocket.send(send);
};
const debounce = (func, wait) => {
let timeout;
return function () {
let context = this, args = arguments;
let later = function () {
timeout = null;
func.apply(context, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
editor.session.on('change', debounce(() => {
editorValidationScheduled = !editorActiveSecrets;
}, 250));
setInterval(() => {
if (!editorValidationScheduled || editorValidationRunning)
return;
if (editorActiveWebSocket == null)
return;
sendAceStdin({
type: 'validate',
file: editorActiveFilename
});
editorValidationRunning = true;
editorValidationScheduled = false;
}, 100);
// Save File
const saveFile = (filename) => {
const extensionRegex = new RegExp("(?:\.([^.]+))?$");
if (filename.match(extensionRegex)[0] !== ".yaml") {
M.toast({
html: `❌ File <code class="inlinecode">${filename}</code> cannot be saved as it is not a YAML file!`,
displayLength: 10000
});
return;
}
fetch(`./edit?configuration=${filename}`, {
credentials: "same-origin",
method: "POST",
body: editor.getValue()
})
.then((response) => {
response.text();
})
.then(() => {
M.toast({
html: `✅ Saved <code class="inlinecode">${filename}</code>`,
displayLength: 10000
});
})
.catch((error) => {
M.toast({
html: `❌ An error occured saving <code class="inlinecode">${filename}</code>`,
displayLength: 10000
});
})
}
document.querySelectorAll("[data-action='save']").forEach((btn) => {
btn.addEventListener("click", (e) => {
saveFile(editorActiveFilename);
});
});
// Delete Node
document.querySelectorAll("[data-action='delete']").forEach((btn) => {
btn.addEventListener("click", (e) => {
const filename = e.target.getAttribute("data-filename");
fetch(`./delete?configuration=${filename}`, {
credentials: "same-origin",
method: "POST",
})
.then((res) => {
res.text()
})
.then(() => {
const toastHtml = `<span>🗑️ Deleted <code class="inlinecode">${filename}</code>
<button class="btn-flat toast-action">Undo</button>`;
const toast = M.toast({
html: toastHtml,
displayLength: 10000
});
const undoButton = toast.el.querySelector('.toast-action');
document.querySelector(`.card[data-filename="${filename}"]`).remove();
undoButton.addEventListener('click', () => {
fetch(`./undo-delete?configuration=${filename}`, {
credentials: "same-origin",
method: "POST",
})
.then((res) => {
res.text()
})
.then(() => {
window.location.reload(false);
});
});
});
});
});
/**
* Wizard
*/
const wizardTriggerElement = document.querySelector("[data-action='wizard']");
const wizardModal = document.getElementById("js-wizard-modal");
const wizardCloseButton = document.querySelector("[data-action='wizard-close']");
const wizardStepper = document.querySelector('#js-wizard-modal .stepper');
const wizardStepperInstace = new MStepper(wizardStepper, {
firstActive: 0,
stepTitleNavigation: false,
autoFocusInput: true,
showFeedbackLoader: true,
})
const startWizard = () => {
M.Modal.init(wizardModal, {
dismissible: false
}).open();
}
document.querySelectorAll("[data-action='wizard']").forEach((btn) => {
btn.addEventListener("click", (event) => {
startWizard();
})
});
jQuery.validator.addMethod("nospaces", (value, element) => {
return value.indexOf(' ') < 0;
}, "Name cannot contain any spaces!");
jQuery.validator.addMethod("nounderscores", (value, element) => {
return value.indexOf('_') < 0;
}, "Name cannot contain underscores!");
jQuery.validator.addMethod("lowercase", (value, element) => {
return value === value.toLowerCase();
}, "Name must be all lower case!");